
Debug Menu
- 2 installs
- 591 repo stars
- Updated July 24, 2026
- rshankras/claude-code-apple-skills
Generates a DEBUG-only developer menu with feature flag toggles, environment switching, network log viewer, cache clearing, crash trigger, and diagnostic export.
About
Generates a developer debug menu accessed via shake or hidden tap, wrapped in #if DEBUG so it never ships. A developer uses it to toggle flags, switch environments, and inspect network logs during development.
- Shake-to-open menu with feature-flag toggles and environment switching
- Network log viewer, cache clearing, and crash trigger, DEBUG-only
Debug Menu by the numbers
- 2 all-time installs (skills.sh)
- Ranked #464 of 596 Debugging skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/rshankras/claude-code-apple-skills --skill debug-menuAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 2 |
|---|---|
| repo stars | ★ 591 |
| Last updated | July 24, 2026 |
| Repository | rshankras/claude-code-apple-skills ↗ |
What it does
Generates a DEBUG-only developer menu with feature flag toggles, environment switching, network log viewer, cache clearing, crash trigger, and diagnostic export.
Files
Debug Menu Generator
Generate a comprehensive developer debug menu accessible via shake gesture or hidden tap. Includes feature flag toggles, environment switching, network log viewer, cache clearing, crash trigger, and diagnostic info export. All code is wrapped in #if DEBUG so it never ships to production.
When This Skill Activates
Use this skill when the user:
- Asks for a "debug menu" or "developer menu"
- Wants "dev tools" or a "debug panel"
- Mentions "shake to debug" or "diagnostic menu"
- Wants to "toggle feature flags" from the app
- Asks about "environment switching" (dev/staging/production)
- Wants a "network log viewer" in the app
Pre-Generation Checks
1. Project Context Detection
- [ ] Check deployment target (iOS 17+ / macOS 14+ required for @Observable)
- [ ] Check Swift version (requires Swift 5.9+)
- [ ] Identify source file locations and project structure
2. Conflict Detection
Search for existing debug/dev menu code:
Glob: **/*Debug*Menu*.swift, **/*DevMenu*.swift, **/*DevTools*.swift, **/*DebugPanel*.swift
Grep: "DebugMenu" or "DevMenu" or "motionEnded" or "shake" in *.swiftIf existing debug menu found:
- Ask if user wants to replace or extend it
- If extending, integrate new sections into existing structure
3. Feature Flags Detection
Search for existing feature flag setup:
Glob: **/*FeatureFlag*.swift, **/*Feature*Toggle*.swift
Grep: "FeatureFlag" or "featureFlag" or "isFeatureEnabled"If found, integrate debug menu toggles with existing feature flag system rather than creating a new one.
Configuration Questions
Ask user via AskUserQuestion:
1. Access method?
- Shake gesture (shake device to open)
- Hidden tap (5-tap on a hidden area)
- Both (shake + hidden tap) -- recommended
2. Sections to include? (multi-select)
- Feature flags (toggle flags on/off at runtime)
- Environment switcher (dev / staging / production)
- Network logs (recent requests with status codes and timing)
- Cache tools (clear image cache, HTTP cache, all caches)
- Crash trigger (force crash for Crashlytics testing)
- Diagnostics (device info, memory, disk, app version)
3. Include push notification testing?
- Yes (simulate local push notifications for testing)
- No
4. Include export diagnostics?
- Yes (share sheet with full diagnostic report)
- No
Generation Process
Step 1: Read Templates
Read templates.md for production Swift code wrapped in #if DEBUG.
Step 2: Create Core Files
Generate these files (all wrapped in #if DEBUG): 1. DebugMenuView.swift -- Main NavigationStack with all sections 2. DebugSection.swift -- Enum defining available debug sections
Step 3: Create Section Files
Based on configuration: 3. DebugEnvironmentSwitcher.swift -- If environment switcher selected 4. DebugNetworkLogger.swift -- If network logs selected 5. DiagnosticInfo.swift -- If diagnostics or export selected
Step 4: Create Trigger Files
6. DebugMenuTrigger.swift -- ShakeDetector + hidden tap gesture + ViewModifier
Step 5: Create Action Files
7. DebugActions.swift -- Collection of debug utility actions
Step 6: Determine File Location
Check project structure:
- If
Sources/exists ->Sources/DebugMenu/ - If
App/exists ->App/DebugMenu/ - Otherwise ->
DebugMenu/
Entire folder is #if DEBUG and should be excluded from release builds.
Output Format
After generation, provide:
Files Created
DebugMenu/
├── DebugMenuView.swift # Main NavigationStack with all sections
├── DebugSection.swift # Enum of available sections
├── DebugEnvironmentSwitcher.swift # Environment switching (optional)
├── DebugNetworkLogger.swift # Network request logger (optional)
├── DiagnosticInfo.swift # Device and app diagnostics (optional)
├── DebugMenuTrigger.swift # Shake gesture + hidden tap trigger
└── DebugActions.swift # Utility actions (reset, clear, crash)Integration Steps
Add the debug trigger to your root view:
#if DEBUG
import SwiftUI
@main
struct MyApp: App {
var body: some Scene {
WindowGroup {
ContentView()
.debugMenuTrigger() // Adds shake + tap to open debug menu
}
}
}
#endifOr add only to specific views:
struct SettingsView: View {
var body: some View {
Form {
// ... your settings
}
#if DEBUG
.debugMenuTrigger(method: .hiddenTap)
#endif
}
}Hook up the network logger to your API client:
#if DEBUG
func performRequest(_ request: URLRequest) async throws -> (Data, URLResponse) {
let start = Date()
let (data, response) = try await session.data(for: request)
DebugNetworkLogger.shared.log(request: request, response: response, data: data, duration: Date().timeIntervalSince(start))
return (data, response)
}
#endifRegister your feature flags:
#if DEBUG
extension DebugMenuView {
static let featureFlags: [FeatureFlag] = [
FeatureFlag(key: "new_onboarding", title: "New Onboarding Flow", defaultValue: false),
FeatureFlag(key: "dark_mode_v2", title: "Dark Mode V2", defaultValue: false),
FeatureFlag(key: "premium_paywall", title: "Premium Paywall", defaultValue: true),
]
}
#endifTesting
#if DEBUG
@Test
func debugMenuSectionsRender() {
let view = DebugMenuView()
// Verify all sections are present
#expect(DebugSection.allCases.count > 0)
}
@Test
func environmentSwitcherChangesBaseURL() {
let switcher = DebugEnvironmentSwitcher()
switcher.current = .staging
#expect(switcher.baseURL.absoluteString.contains("staging"))
switcher.current = .production
#expect(switcher.baseURL.absoluteString.contains("api."))
}
@Test
func networkLoggerRecordsRequests() async {
let logger = DebugNetworkLogger.shared
await logger.clear()
let request = URLRequest(url: URL(string: "https://api.example.com/users")!)
let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!
await logger.log(request: request, response: response, data: Data(), duration: 0.25)
let entries = await logger.entries
#expect(entries.count == 1)
#expect(entries.first?.statusCode == 200)
}
@Test
func diagnosticInfoCollectsDeviceData() {
let info = DiagnosticInfo.collect()
#expect(!info.appVersion.isEmpty)
#expect(!info.osVersion.isEmpty)
#expect(!info.deviceModel.isEmpty)
#expect(info.memoryUsageMB > 0)
}
#endifCommon Patterns
Shake Gesture Trigger
#if DEBUG
// In your app's UIWindow subclass or scene delegate:
ContentView()
.debugMenuTrigger(method: .shakeGesture)
#endifToggle a Feature Flag at Runtime
#if DEBUG
// In DebugMenuView, toggle persists to UserDefaults:
Toggle(flag.title, isOn: binding(for: flag))
.onChange(of: flag.isEnabled) {
NotificationCenter.default.post(name: .featureFlagChanged, object: flag.key)
}
#endifSwitch API Environment
#if DEBUG
DebugEnvironmentSwitcher.shared.current = .staging
// All subsequent API calls use staging base URL
// App restarts recommended for full effect
#endifClear All Caches
#if DEBUG
Button("Clear All Caches", role: .destructive) {
DebugActions.clearAllCaches()
}
#endifGotchas
- MUST be `#if DEBUG` only -- every file, every type, every extension must be wrapped. Never let debug menu code ship to production.
- Never ship debug menu to production -- verify your release scheme does not define the DEBUG flag. Check Build Settings > Swift Compiler > Active Compilation Conditions.
- Shake gesture conflicts -- UIKit's
motionEndedmay conflict with other shake handlers (e.g., "shake to undo"). Disable the system shake-to-undo if using shake for debug:UIApplication.shared.applicationSupportsShakeToEdit = false. - Sensitive data in logs -- Network logger captures request/response bodies. Strip authorization headers and redact sensitive fields before logging.
- UserDefaults pollution -- Feature flag overrides stored in UserDefaults persist across launches. Prefix all debug keys (e.g.,
debug_flag_) and provide a "Reset All" button. - Environment switching requires restart -- Some services cache their base URL at init. Show an alert recommending app restart after environment change.
- Thread safety -- Use actors or
@MainActorfor shared debug state. The network logger must be an actor since it is written to from URLSession callbacks.
References
- templates.md -- All production Swift templates for debug menu components
- Related:
generators/feature-flags-- Full feature flag system with remote config - Related:
generators/logging-setup-- Structured logging infrastructure
Debug Menu Code Templates
Production-ready Swift templates for a developer debug menu. All code targets iOS 17+ / macOS 14+ with @Observable and modern Swift concurrency. Every type and extension is wrapped in `#if DEBUG` to ensure nothing ships to production.
DebugMenuView.swift
#if DEBUG
import SwiftUI
/// Main debug menu presented as a NavigationStack with categorized sections.
///
/// Access via shake gesture or hidden tap using `.debugMenuTrigger()`.
struct DebugMenuView: View {
@Environment(\.dismiss) private var dismiss
@State private var environmentSwitcher = DebugEnvironmentSwitcher.shared
@State private var showResetConfirmation = false
@State private var showExportSheet = false
@State private var diagnosticReport = ""
var body: some View {
NavigationStack {
List {
appInfoSection
featureFlagsSection
environmentSection
cacheSection
networkLogSection
diagnosticsSection
actionsSection
}
.navigationTitle("Debug Menu")
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Done") { dismiss() }
}
ToolbarItem(placement: .primaryAction) {
ShareLink(item: diagnosticReport) {
Label("Export", systemImage: "square.and.arrow.up")
}
.disabled(diagnosticReport.isEmpty)
}
}
.task {
diagnosticReport = DiagnosticInfo.collect().formattedReport
}
}
}
// MARK: - App Info
private var appInfoSection: some View {
Section("App Info") {
LabeledContent("Version", value: Bundle.main.appVersion)
LabeledContent("Build", value: Bundle.main.buildNumber)
LabeledContent("OS", value: ProcessInfo.processInfo.operatingSystemVersionString)
LabeledContent("Device", value: DiagnosticInfo.deviceModel)
LabeledContent("Bundle ID", value: Bundle.main.bundleIdentifier ?? "N/A")
}
}
// MARK: - Feature Flags
private var featureFlagsSection: some View {
Section("Feature Flags") {
ForEach(DebugMenuView.featureFlags) { flag in
FeatureFlagToggleRow(flag: flag)
}
if DebugMenuView.featureFlags.isEmpty {
Text("No feature flags configured.")
.foregroundStyle(.secondary)
.italic()
}
Button("Reset All Flags", role: .destructive) {
DebugActions.resetFeatureFlags(DebugMenuView.featureFlags)
}
}
}
/// Register your feature flags here.
///
/// ```swift
/// static let featureFlags: [FeatureFlag] = [
/// FeatureFlag(key: "new_onboarding", title: "New Onboarding", defaultValue: false),
/// ]
/// ```
static var featureFlags: [FeatureFlag] = []
// MARK: - Environment
private var environmentSection: some View {
Section("Environment") {
Picker("API Environment", selection: $environmentSwitcher.current) {
ForEach(AppEnvironment.allCases, id: \.self) { env in
Text(env.displayName).tag(env)
}
}
.pickerStyle(.segmented)
LabeledContent("Base URL", value: environmentSwitcher.baseURL.absoluteString)
.font(.caption)
.foregroundStyle(.secondary)
if environmentSwitcher.needsRestart {
Label("Restart recommended for full effect", systemImage: "exclamationmark.triangle")
.foregroundStyle(.orange)
.font(.caption)
}
}
}
// MARK: - Cache
private var cacheSection: some View {
Section("Cache") {
Button("Clear Image Cache") {
DebugActions.clearImageCache()
}
Button("Clear HTTP Cache") {
DebugActions.clearHTTPCache()
}
Button("Clear All Caches", role: .destructive) {
DebugActions.clearAllCaches()
}
LabeledContent("URL Cache Size", value: DebugActions.urlCacheSizeDescription)
}
}
// MARK: - Network Log
private var networkLogSection: some View {
Section("Network Log") {
NavigationLink {
DebugNetworkLogListView()
} label: {
HStack {
Text("Recent Requests")
Spacer()
Text("\(DebugNetworkLogger.shared.entryCount)")
.foregroundStyle(.secondary)
}
}
}
}
// MARK: - Diagnostics
private var diagnosticsSection: some View {
Section("Diagnostics") {
let info = DiagnosticInfo.collect()
LabeledContent("Memory Usage", value: info.formattedMemoryUsage)
LabeledContent("Disk Free", value: info.formattedDiskFree)
LabeledContent("Locale", value: Locale.current.identifier)
LabeledContent("Timezone", value: TimeZone.current.identifier)
LabeledContent("Preferred Languages", value: Locale.preferredLanguages.prefix(3).joined(separator: ", "))
}
}
// MARK: - Actions
private var actionsSection: some View {
Section("Actions") {
Button("Reset Onboarding") {
DebugActions.resetOnboarding()
}
Button("Clear UserDefaults") {
showResetConfirmation = true
}
.confirmationDialog("Clear all UserDefaults?", isPresented: $showResetConfirmation) {
Button("Clear All", role: .destructive) {
DebugActions.clearUserDefaults()
}
} message: {
Text("This will remove all stored preferences. The app may behave as if freshly installed.")
}
Button("Force Crash", role: .destructive) {
DebugActions.simulateCrash()
}
}
}
}
// MARK: - Feature Flag Model
/// A toggleable feature flag stored in UserDefaults.
struct FeatureFlag: Identifiable {
let key: String
let title: String
let defaultValue: Bool
var id: String { key }
private var userDefaultsKey: String { "debug_flag_\(key)" }
var isEnabled: Bool {
get {
if UserDefaults.standard.object(forKey: userDefaultsKey) != nil {
return UserDefaults.standard.bool(forKey: userDefaultsKey)
}
return defaultValue
}
set {
UserDefaults.standard.set(newValue, forKey: userDefaultsKey)
NotificationCenter.default.post(name: .debugFeatureFlagChanged, object: key)
}
}
func reset() {
UserDefaults.standard.removeObject(forKey: userDefaultsKey)
}
}
extension Notification.Name {
static let debugFeatureFlagChanged = Notification.Name("debugFeatureFlagChanged")
}
/// A row that toggles a single feature flag.
struct FeatureFlagToggleRow: View {
let flag: FeatureFlag
@State private var isOn: Bool
init(flag: FeatureFlag) {
self.flag = flag
self._isOn = State(initialValue: flag.isEnabled)
}
var body: some View {
Toggle(flag.title, isOn: $isOn)
.onChange(of: isOn) { _, newValue in
var mutableFlag = flag
mutableFlag.isEnabled = newValue
}
}
}
// MARK: - Bundle Helpers
extension Bundle {
var appVersion: String {
infoDictionary?["CFBundleShortVersionString"] as? String ?? "N/A"
}
var buildNumber: String {
infoDictionary?["CFBundleVersion"] as? String ?? "N/A"
}
}
#Preview {
DebugMenuView()
}
#endifDebugEnvironmentSwitcher.swift
#if DEBUG
import Foundation
import Observation
/// Available API environments.
enum AppEnvironment: String, CaseIterable, Sendable {
case development
case staging
case production
var displayName: String {
switch self {
case .development: "Development"
case .staging: "Staging"
case .production: "Production"
}
}
var baseURL: URL {
switch self {
case .development:
URL(string: "https://dev-api.example.com/v1")!
case .staging:
URL(string: "https://staging-api.example.com/v1")!
case .production:
URL(string: "https://api.example.com/v1")!
}
}
}
/// Manages switching between API environments at runtime.
///
/// Persists the selected environment to UserDefaults.
/// Services should read `DebugEnvironmentSwitcher.shared.baseURL`
/// for their API base URL.
///
/// Usage:
/// ```swift
/// let baseURL = DebugEnvironmentSwitcher.shared.baseURL
/// ```
@Observable
final class DebugEnvironmentSwitcher {
static let shared = DebugEnvironmentSwitcher()
private static let storageKey = "debug_selected_environment"
var current: AppEnvironment {
didSet {
UserDefaults.standard.set(current.rawValue, forKey: Self.storageKey)
needsRestart = (current != initialEnvironment)
}
}
var needsRestart = false
var baseURL: URL { current.baseURL }
private let initialEnvironment: AppEnvironment
private init() {
if let stored = UserDefaults.standard.string(forKey: Self.storageKey),
let env = AppEnvironment(rawValue: stored) {
self.current = env
self.initialEnvironment = env
} else {
self.current = .development
self.initialEnvironment = .development
}
}
}
#endifDebugNetworkLogger.swift
#if DEBUG
import Foundation
/// A single logged network request/response pair.
struct NetworkLogEntry: Identifiable, Sendable {
let id = UUID()
let timestamp: Date
let url: String
let method: String
let statusCode: Int
let duration: TimeInterval
let requestHeaders: [String: String]
let responseHeaders: [String: String]
let requestBodySize: Int
let responseBodySize: Int
let responseBodyPreview: String
var isSuccess: Bool { (200...299).contains(statusCode) }
var formattedDuration: String {
String(format: "%.0fms", duration * 1000)
}
var statusEmoji: String {
switch statusCode {
case 200...299: return "green"
case 300...399: return "yellow"
case 400...499: return "orange"
default: return "red"
}
}
}
/// Singleton actor that collects recent network requests.
///
/// Uses a circular buffer to store the last 100 entries.
/// Hook into your networking layer to log requests:
///
/// ```swift
/// DebugNetworkLogger.shared.log(
/// request: request,
/// response: response,
/// data: data,
/// duration: elapsed
/// )
/// ```
actor DebugNetworkLogger {
static let shared = DebugNetworkLogger()
private var buffer: [NetworkLogEntry] = []
private let maxEntries = 100
var entries: [NetworkLogEntry] {
buffer.reversed() // Most recent first
}
var entryCount: Int { buffer.count }
/// Log a completed network request.
func log(
request: URLRequest,
response: URLResponse,
data: Data,
duration: TimeInterval
) {
let httpResponse = response as? HTTPURLResponse
// Redact sensitive headers
var sanitizedRequestHeaders = request.allHTTPHeaderFields ?? [:]
sanitizedRequestHeaders["Authorization"] = sanitizedRequestHeaders["Authorization"] != nil ? "[REDACTED]" : nil
sanitizedRequestHeaders["Cookie"] = sanitizedRequestHeaders["Cookie"] != nil ? "[REDACTED]" : nil
let responseHeaders = httpResponse?.allHeaderFields as? [String: String] ?? [:]
// Truncate response body preview
let maxPreviewLength = 500
let bodyPreview: String
if let bodyString = String(data: data.prefix(maxPreviewLength), encoding: .utf8) {
bodyPreview = data.count > maxPreviewLength ? bodyString + "... (\(data.count) bytes total)" : bodyString
} else {
bodyPreview = "[\(data.count) bytes, non-UTF8]"
}
let entry = NetworkLogEntry(
timestamp: Date(),
url: request.url?.absoluteString ?? "Unknown",
method: request.httpMethod ?? "GET",
statusCode: httpResponse?.statusCode ?? 0,
duration: duration,
requestHeaders: sanitizedRequestHeaders,
responseHeaders: responseHeaders,
requestBodySize: request.httpBody?.count ?? 0,
responseBodySize: data.count,
responseBodyPreview: bodyPreview
)
buffer.append(entry)
// Circular buffer: remove oldest when exceeding limit
if buffer.count > maxEntries {
buffer.removeFirst(buffer.count - maxEntries)
}
}
/// Clear all logged entries.
func clear() {
buffer.removeAll()
}
}
// MARK: - Network Log List View
import SwiftUI
/// Displays all logged network requests in a navigable list.
struct DebugNetworkLogListView: View {
@State private var entries: [NetworkLogEntry] = []
@State private var searchText = ""
var filteredEntries: [NetworkLogEntry] {
if searchText.isEmpty { return entries }
return entries.filter { $0.url.localizedCaseInsensitiveContains(searchText) }
}
var body: some View {
List(filteredEntries) { entry in
NavigationLink {
NetworkLogDetailView(entry: entry)
} label: {
HStack {
Circle()
.fill(statusColor(for: entry.statusCode))
.frame(width: 8, height: 8)
VStack(alignment: .leading, spacing: 2) {
HStack {
Text(entry.method)
.font(.caption.monospaced().bold())
Text("\(entry.statusCode)")
.font(.caption.monospaced())
.foregroundStyle(statusColor(for: entry.statusCode))
}
Text(entry.url)
.font(.caption2)
.lineLimit(1)
.truncationMode(.middle)
.foregroundStyle(.secondary)
}
Spacer()
Text(entry.formattedDuration)
.font(.caption.monospaced())
.foregroundStyle(.secondary)
}
}
}
.searchable(text: $searchText, prompt: "Filter by URL")
.navigationTitle("Network Log")
.toolbar {
ToolbarItem(placement: .primaryAction) {
Button("Clear") {
Task {
await DebugNetworkLogger.shared.clear()
entries = []
}
}
}
}
.task {
entries = await DebugNetworkLogger.shared.entries
}
.refreshable {
entries = await DebugNetworkLogger.shared.entries
}
}
private func statusColor(for code: Int) -> Color {
switch code {
case 200...299: .green
case 300...399: .yellow
case 400...499: .orange
default: .red
}
}
}
/// Detail view for a single network log entry.
struct NetworkLogDetailView: View {
let entry: NetworkLogEntry
var body: some View {
List {
Section("Request") {
LabeledContent("Method", value: entry.method)
LabeledContent("URL", value: entry.url)
.textSelection(.enabled)
LabeledContent("Body Size", value: "\(entry.requestBodySize) bytes")
}
Section("Response") {
LabeledContent("Status", value: "\(entry.statusCode)")
LabeledContent("Duration", value: entry.formattedDuration)
LabeledContent("Body Size", value: "\(entry.responseBodySize) bytes")
}
if !entry.requestHeaders.isEmpty {
Section("Request Headers") {
ForEach(entry.requestHeaders.sorted(by: { $0.key < $1.key }), id: \.key) { key, value in
LabeledContent(key, value: value)
.font(.caption)
}
}
}
if !entry.responseHeaders.isEmpty {
Section("Response Headers") {
ForEach(entry.responseHeaders.sorted(by: { $0.key < $1.key }), id: \.key) { key, value in
LabeledContent(key, value: value)
.font(.caption)
}
}
}
Section("Response Body Preview") {
Text(entry.responseBodyPreview)
.font(.caption.monospaced())
.textSelection(.enabled)
}
}
.navigationTitle("\(entry.method) \(entry.statusCode)")
}
}
#endifDiagnosticInfo.swift
#if DEBUG
import Foundation
/// Collects device and app diagnostic information.
///
/// Usage:
/// ```swift
/// let info = DiagnosticInfo.collect()
/// print(info.formattedReport)
/// ```
struct DiagnosticInfo {
let appVersion: String
let buildNumber: String
let bundleIdentifier: String
let osVersion: String
let deviceModel: String
let memoryUsageMB: Double
let totalMemoryMB: Double
let diskFreeMB: Double
let diskTotalMB: Double
let locale: String
let timezone: String
let preferredLanguages: [String]
let isLowPowerMode: Bool
let thermalState: String
let timestamp: Date
/// Collect current diagnostic information.
static func collect() -> DiagnosticInfo {
let processInfo = ProcessInfo.processInfo
let fileManager = FileManager.default
// Memory usage
var taskInfo = mach_task_basic_info()
var count = mach_msg_type_number_t(MemoryLayout<mach_task_basic_info>.size) / 4
let result = withUnsafeMutablePointer(to: &taskInfo) {
$0.withMemoryRebound(to: integer_t.self, capacity: Int(count)) {
task_info(mach_task_self_, task_flavor_t(MACH_TASK_BASIC_INFO), $0, &count)
}
}
let memoryUsageMB = result == KERN_SUCCESS
? Double(taskInfo.resident_size) / (1024 * 1024)
: 0
// Disk space
let homeURL = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first ?? URL(fileURLWithPath: NSHomeDirectory())
let diskValues = try? homeURL.resourceValues(forKeys: [.volumeAvailableCapacityForImportantUsageKey, .volumeTotalCapacityKey])
let diskFreeMB = Double(diskValues?.volumeAvailableCapacityForImportantUsage ?? 0) / (1024 * 1024)
let diskTotalMB = Double(diskValues?.volumeTotalCapacity ?? 0) / (1024 * 1024)
// Thermal state
let thermalState: String = switch processInfo.thermalState {
case .nominal: "Nominal"
case .fair: "Fair"
case .serious: "Serious"
case .critical: "Critical"
@unknown default: "Unknown"
}
return DiagnosticInfo(
appVersion: Bundle.main.appVersion,
buildNumber: Bundle.main.buildNumber,
bundleIdentifier: Bundle.main.bundleIdentifier ?? "N/A",
osVersion: processInfo.operatingSystemVersionString,
deviceModel: Self.deviceModel,
memoryUsageMB: memoryUsageMB,
totalMemoryMB: Double(processInfo.physicalMemory) / (1024 * 1024),
diskFreeMB: diskFreeMB,
diskTotalMB: diskTotalMB,
locale: Locale.current.identifier,
timezone: TimeZone.current.identifier,
preferredLanguages: Array(Locale.preferredLanguages.prefix(5)),
isLowPowerMode: processInfo.isLowPowerModeEnabled,
thermalState: thermalState,
timestamp: Date()
)
}
/// Human-readable device model string.
static var deviceModel: String {
#if canImport(UIKit)
var systemInfo = utsname()
uname(&systemInfo)
let machineMirror = Mirror(reflecting: systemInfo.machine)
let identifier = machineMirror.children.reduce("") { identifier, element in
guard let value = element.value as? Int8, value != 0 else { return identifier }
return identifier + String(UnicodeScalar(UInt8(value)))
}
return identifier
#elseif canImport(AppKit)
var size = 0
sysctlbyname("hw.model", nil, &size, nil, 0)
var model = [CChar](repeating: 0, count: size)
sysctlbyname("hw.model", &model, &size, nil, 0)
return String(cString: model)
#endif
}
// MARK: - Formatted Output
var formattedMemoryUsage: String {
String(format: "%.0f MB / %.0f MB", memoryUsageMB, totalMemoryMB)
}
var formattedDiskFree: String {
if diskFreeMB > 1024 {
return String(format: "%.1f GB free", diskFreeMB / 1024)
}
return String(format: "%.0f MB free", diskFreeMB)
}
/// Full diagnostic report formatted for sharing.
var formattedReport: String {
let dateFormatter = DateFormatter()
dateFormatter.dateStyle = .medium
dateFormatter.timeStyle = .long
return """
=== Diagnostic Report ===
Generated: \(dateFormatter.string(from: timestamp))
-- App --
Version: \(appVersion) (\(buildNumber))
Bundle ID: \(bundleIdentifier)
-- Device --
Model: \(deviceModel)
OS: \(osVersion)
Thermal State: \(thermalState)
Low Power Mode: \(isLowPowerMode ? "Yes" : "No")
-- Memory --
App Usage: \(String(format: "%.0f MB", memoryUsageMB))
Total: \(String(format: "%.0f MB", totalMemoryMB))
-- Disk --
Free: \(formattedDiskFree)
Total: \(String(format: "%.1f GB", diskTotalMB / 1024))
-- Locale --
Current: \(locale)
Timezone: \(timezone)
Languages: \(preferredLanguages.joined(separator: ", "))
-- Debug State --
Environment: \(DebugEnvironmentSwitcher.shared.current.displayName)
Base URL: \(DebugEnvironmentSwitcher.shared.baseURL.absoluteString)
===========================
"""
}
}
#endifDebugMenuTrigger.swift
#if DEBUG
import SwiftUI
// MARK: - Trigger Method
/// How the debug menu is activated.
enum DebugTriggerMethod {
/// Shake the device to open.
case shakeGesture
/// Tap a hidden area 5 times to open.
case hiddenTap
/// Both shake and hidden tap.
case both
}
// MARK: - Shake Detection (iOS)
#if canImport(UIKit)
import UIKit
/// Custom UIWindow subclass that detects shake gestures.
///
/// Set your app's window to this class to enable shake-to-debug.
/// The window posts a notification when a shake is detected.
class ShakeDetectingWindow: UIWindow {
override func motionEnded(_ motion: UIEvent.EventSubtype, with event: UIEvent?) {
super.motionEnded(motion, with: event)
if motion == .motionShake {
NotificationCenter.default.post(name: .debugShakeDetected, object: nil)
}
}
}
extension Notification.Name {
static let debugShakeDetected = Notification.Name("debugShakeDetected")
}
#endif
// MARK: - View Modifier
/// ViewModifier that presents the debug menu via shake gesture and/or hidden tap.
///
/// Usage:
/// ```swift
/// ContentView()
/// .debugMenuTrigger() // Both shake + tap
/// .debugMenuTrigger(method: .shakeGesture) // Shake only
/// .debugMenuTrigger(method: .hiddenTap) // Hidden tap only
/// ```
struct DebugMenuTriggerModifier: ViewModifier {
let method: DebugTriggerMethod
@State private var showDebugMenu = false
@State private var tapCount = 0
private let requiredTaps = 5
private let tapResetDelay: TimeInterval = 2.0
func body(content: Content) -> some View {
content
.overlay(alignment: .topTrailing) {
if method == .hiddenTap || method == .both {
hiddenTapArea
}
}
.sheet(isPresented: $showDebugMenu) {
DebugMenuView()
}
#if canImport(UIKit)
.onReceive(NotificationCenter.default.publisher(for: .debugShakeDetected)) { _ in
if method == .shakeGesture || method == .both {
showDebugMenu = true
}
}
#endif
}
/// Invisible tap target in the top-right corner.
private var hiddenTapArea: some View {
Color.clear
.frame(width: 44, height: 44)
.contentShape(Rectangle())
.onTapGesture {
tapCount += 1
if tapCount >= requiredTaps {
tapCount = 0
showDebugMenu = true
}
// Reset tap count after delay
Task {
try? await Task.sleep(for: .seconds(tapResetDelay))
tapCount = 0
}
}
.accessibilityHidden(true)
}
}
// MARK: - View Extension
extension View {
/// Adds a debug menu trigger to this view.
///
/// - Parameter method: How the debug menu is activated. Default is `.both`.
/// - Returns: A view with the debug menu trigger attached.
///
/// ```swift
/// ContentView()
/// .debugMenuTrigger()
/// ```
func debugMenuTrigger(method: DebugTriggerMethod = .both) -> some View {
modifier(DebugMenuTriggerModifier(method: method))
}
}
#endifDebugActions.swift
#if DEBUG
import Foundation
/// Collection of debug utility actions.
///
/// Each action is a static function that can be called from the debug menu
/// or programmatically during development.
enum DebugActions {
// MARK: - Onboarding
/// Reset onboarding state so the user sees onboarding again on next launch.
static func resetOnboarding() {
let onboardingKeys = [
"hasCompletedOnboarding",
"onboardingCompleted",
"isOnboarded",
"didFinishOnboarding",
"hasSeenWelcome",
]
for key in onboardingKeys {
UserDefaults.standard.removeObject(forKey: key)
}
print("[DebugActions] Onboarding state reset")
}
// MARK: - UserDefaults
/// Clear all UserDefaults for the app's bundle.
static func clearUserDefaults() {
guard let bundleId = Bundle.main.bundleIdentifier else { return }
UserDefaults.standard.removePersistentDomain(forName: bundleId)
UserDefaults.standard.synchronize()
print("[DebugActions] UserDefaults cleared for \(bundleId)")
}
// MARK: - Keychain
/// Remove all keychain items for the app.
static func clearKeychain() {
let secClasses = [
kSecClassGenericPassword,
kSecClassInternetPassword,
kSecClassCertificate,
kSecClassKey,
kSecClassIdentity,
]
for secClass in secClasses {
let query: [String: Any] = [kSecClass as String: secClass]
SecItemDelete(query as CFDictionary)
}
print("[DebugActions] Keychain cleared")
}
// MARK: - Cache
/// Clear the shared URL cache.
static func clearHTTPCache() {
URLCache.shared.removeAllCachedResponses()
print("[DebugActions] HTTP cache cleared")
}
/// Clear the image cache (if using ImagePipeline from image-loading generator).
///
/// Override this to integrate with your image caching layer:
/// ```swift
/// DebugActions.clearImageCache = {
/// await ImagePipeline.shared.clearCache()
/// }
/// ```
static func clearImageCache() {
// Clear the shared URLCache images
URLCache.shared.removeAllCachedResponses()
// Clear any on-disk image cache directory
let cacheDir = FileManager.default
.urls(for: .cachesDirectory, in: .userDomainMask)[0]
.appendingPathComponent("ImageCache", isDirectory: true)
try? FileManager.default.removeItem(at: cacheDir)
print("[DebugActions] Image cache cleared")
}
/// Clear all caches: URL, image, and temporary files.
static func clearAllCaches() {
clearHTTPCache()
clearImageCache()
// Clear tmp directory
let tmpDir = FileManager.default.temporaryDirectory
if let files = try? FileManager.default.contentsOfDirectory(at: tmpDir, includingPropertiesForKeys: nil) {
for file in files {
try? FileManager.default.removeItem(at: file)
}
}
print("[DebugActions] All caches cleared")
}
/// Human-readable URL cache size.
static var urlCacheSizeDescription: String {
let cache = URLCache.shared
let usedMB = Double(cache.currentDiskUsage) / (1024 * 1024)
let totalMB = Double(cache.diskCapacity) / (1024 * 1024)
return String(format: "%.1f MB / %.0f MB", usedMB, totalMB)
}
// MARK: - Feature Flags
/// Reset all feature flags to their default values.
static func resetFeatureFlags(_ flags: [FeatureFlag]) {
for flag in flags {
flag.reset()
}
print("[DebugActions] All feature flags reset to defaults")
}
// MARK: - Crash
/// Trigger a fatal error for testing crash reporting (e.g., Crashlytics).
///
/// - Warning: This will terminate the app immediately.
static func simulateCrash() {
fatalError("[DebugActions] Intentional crash triggered from debug menu")
}
}
#endif