krz/domain-dig

an ios app for DNS & SSL analysis

clone: git clone https://gitbay.org/krz/domain-dig.git

535bb0ff0f64d57be1074e33ceb42f598f80205b

verified · cmc

author: Christian Cleberg <hello@cleberg.net> · 2026-04-25T05:41:34Z

DomainDig v4.1.0: Add a full local data reset flow in Data Management

- add a destructive Delete All Data action with confirmation, progress, success,
  and failure handling
- centralize wipe behavior in DataResetService instead of scattering delete
  logic in views
- clear local persistence, temp/export files, integration secrets,
  notifications, caches, and in-memory app state
- reset sync, purchase, and integration services after wipe so the app returns
  to a clean first-launch state

Polish batch and empty-state UX

- present the batch sweep summary after manual bulk searches complete, not only
  from Watchlist
- align the Workflows empty state styling with other empty states by removing
  the extra background card treatment

Bump the project version from 4.0.0 to 4.1.0
 DomainDig-4.1.0.patch                    | 413 +++++++++++++++++++++++++++++++
 DomainDig.xcodeproj/project.pbxproj      |   8 +-
 DomainDig/CloudSyncService.swift         |  14 ++
 DomainDig/ContentView.swift              |  92 +++++++
 DomainDig/DataResetService.swift         |  76 ++++++
 DomainDig/DomainViewModel.swift          |  40 +++
 DomainDig/IntegrationService.swift       |  22 ++
 DomainDig/LocalNotificationService.swift |   6 +
 DomainDig/PurchaseService.swift          |   8 +
 DomainDig/WorkflowsView.swift            |   3 +-
 10 files changed, 677 insertions(+), 5 deletions(-)

diff --git a/DomainDig-4.1.0.patch b/DomainDig-4.1.0.patch
new file mode 100644
index 0000000..3c90b2c
--- /dev/null
+++ b/DomainDig-4.1.0.patch
@@ -0,0 +1,413 @@
+diff --git a/DomainDig.xcodeproj/project.pbxproj b/DomainDig.xcodeproj/project.pbxproj
+index c641a56..2de9125 100644
+--- a/DomainDig.xcodeproj/project.pbxproj
++++ b/DomainDig.xcodeproj/project.pbxproj
+@@ -366,7 +366,7 @@
+ 				ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
+ 				CODE_SIGN_ENTITLEMENTS = DomainDig/DomainDig.entitlements;
+ 				CODE_SIGN_STYLE = Automatic;
+-				CURRENT_PROJECT_VERSION = 32;
++				CURRENT_PROJECT_VERSION = 33;
+ 				DEVELOPMENT_TEAM = ZCNAX3VL9D;
+ 				ENABLE_PREVIEWS = YES;
+ 				GENERATE_INFOPLIST_FILE = YES;
+@@ -383,7 +383,7 @@
+ 					"$(inherited)",
+ 					"@executable_path/Frameworks",
+ 				);
+-				MARKETING_VERSION = 4.0.0;
++				MARKETING_VERSION = 4.1.0;
+ 				PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig;
+ 				PRODUCT_NAME = "$(TARGET_NAME)";
+ 				STRING_CATALOG_GENERATE_SYMBOLS = YES;
+@@ -403,7 +403,7 @@
+ 				ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
+ 				CODE_SIGN_ENTITLEMENTS = DomainDig/DomainDig.entitlements;
+ 				CODE_SIGN_STYLE = Automatic;
+-				CURRENT_PROJECT_VERSION = 32;
++				CURRENT_PROJECT_VERSION = 33;
+ 				DEVELOPMENT_TEAM = ZCNAX3VL9D;
+ 				ENABLE_PREVIEWS = YES;
+ 				GENERATE_INFOPLIST_FILE = YES;
+@@ -420,7 +420,7 @@
+ 					"$(inherited)",
+ 					"@executable_path/Frameworks",
+ 				);
+-				MARKETING_VERSION = 4.0.0;
++				MARKETING_VERSION = 4.1.0;
+ 				PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig;
+ 				PRODUCT_NAME = "$(TARGET_NAME)";
+ 				STRING_CATALOG_GENERATE_SYMBOLS = YES;
+diff --git a/DomainDig/CloudSyncService.swift b/DomainDig/CloudSyncService.swift
+index 1cf41d9..2cb4cfe 100644
+--- a/DomainDig/CloudSyncService.swift
++++ b/DomainDig/CloudSyncService.swift
+@@ -321,6 +321,20 @@ final class CloudSyncService {
+         }
+     }
+ 
++    func resetLocalStateAfterWipe() {
++        scheduledSyncTask?.cancel()
++        scheduledSyncTask = nil
++        syncTask?.cancel()
++        syncTask = nil
++
++        let syncEnabled = defaults.bool(forKey: StorageKey.isEnabled)
++        isEnabled = syncEnabled
++        status = CloudSyncStatus(rawValue: defaults.string(forKey: StorageKey.status) ?? "") ?? (syncEnabled ? .synced : .disabled)
++        lastSyncDate = defaults.object(forKey: StorageKey.lastSyncDate) as? Date
++        lastErrorMessage = defaults.string(forKey: StorageKey.lastErrorMessage)
++        detailMessage = defaults.string(forKey: StorageKey.detailMessage) ?? "DomainDig stores synced data in your private iCloud account."
++    }
++
+     func acceptShare(metadata: CKShare.Metadata) async throws {
+         guard let container = cloudKitContainer() else {
+             throw CloudSyncRuntimeError.missingEntitlement
+diff --git a/DomainDig/ContentView.swift b/DomainDig/ContentView.swift
+index c9f40d6..c5c8e50 100644
+--- a/DomainDig/ContentView.swift
++++ b/DomainDig/ContentView.swift
+@@ -261,6 +261,9 @@ struct ContentView: View {
+                 availableDomains: viewModel.batchResults.map(\.domain)
+             )
+         }
++        .sheet(item: manualBatchSummaryBinding) { summary in
++            BatchSweepSummaryView(viewModel: viewModel, summary: summary)
++        }
+         .sheet(isPresented: $showingTimeline) {
+             NavigationStack {
+                 TimelineView(viewModel: viewModel, domain: viewModel.searchedDomain)
+@@ -268,6 +271,19 @@ struct ContentView: View {
+         }
+     }
+ 
++    private var manualBatchSummaryBinding: Binding<BatchSweepSummary?> {
++        Binding(
++            get: {
++                guard let summary = viewModel.latestBatchSweepSummary,
++                      summary.source == .manual else {
++                    return nil
++                }
++                return summary
++            },
++            set: { viewModel.latestBatchSweepSummary = $0 }
++        )
++    }
++
+     private var inputSection: some View {
+         VStack(spacing: appDensity.metrics.cardSpacing + 2) {
+             Picker("Mode", selection: $inputMode) {
+@@ -3257,6 +3273,10 @@ private struct DataManagementSettingsView: View {
+     @State private var showClearCacheConfirmation = false
+     @State private var showClearWorkflowsConfirmation = false
+     @State private var showClearTrackedDomainsConfirmation = false
++    @State private var showDeleteAllConfirmation = false
++    @State private var deleteAllErrorMessage: String?
++    @State private var deleteAllSuccessMessage: String?
++    @State private var isDeletingAllData = false
+ 
+     var body: some View {
+         Form {
+@@ -3277,7 +3297,28 @@ private struct DataManagementSettingsView: View {
+                     showClearTrackedDomainsConfirmation = true
+                 }
+             }
++
++            Section {
++                Button(role: .destructive) {
++                    showDeleteAllConfirmation = true
++                } label: {
++                    HStack {
++                        Text("Delete All Data")
++                        Spacer()
++                        if isDeletingAllData {
++                            ProgressView()
++                                .controlSize(.small)
++                        }
++                    }
++                }
++                .disabled(isDeletingAllData)
++            } header: {
++                Text("Danger Zone")
++            } footer: {
++                Text("Permanently removes all local DomainDig data from this device.")
++            }
+         }
++        .disabled(isDeletingAllData)
+         .navigationTitle("Data Management")
+         .alert("Clear history?", isPresented: $showClearHistoryConfirmation) {
+             Button("Clear", role: .destructive) {
+@@ -3311,6 +3352,57 @@ private struct DataManagementSettingsView: View {
+         } message: {
+             Text("This removes the watchlist and clears monitoring run history. History and workflows stay intact.")
+         }
++        .alert("Delete All Data?", isPresented: $showDeleteAllConfirmation) {
++            Button("Cancel", role: .cancel) {}
++            Button("Delete All Data", role: .destructive) {
++                deleteAllData()
++            }
++        } message: {
++            Text("This will permanently remove all saved DomainDig data from this device. This includes tracked domains, monitoring history, snapshots, exports, cached reports, and local settings. This action cannot be undone.")
++        }
++        .alert("Delete Failed", isPresented: Binding(
++            get: { deleteAllErrorMessage != nil },
++            set: { if !$0 { deleteAllErrorMessage = nil } }
++        )) {
++            Button("OK", role: .cancel) {}
++        } message: {
++            Text(deleteAllErrorMessage ?? "The local data reset could not be completed.")
++        }
++        .safeAreaInset(edge: .bottom) {
++            if let deleteAllSuccessMessage {
++                Text(deleteAllSuccessMessage)
++                    .font(.footnote.weight(.medium))
++                    .foregroundStyle(.secondary)
++                    .padding(.horizontal, 14)
++                    .padding(.vertical, 10)
++                    .background(.thinMaterial, in: Capsule())
++                    .padding(.bottom, 8)
++                    .transition(.move(edge: .bottom).combined(with: .opacity))
++            }
++        }
++    }
++
++    private func deleteAllData() {
++        guard !isDeletingAllData else { return }
++
++        isDeletingAllData = true
++        deleteAllErrorMessage = nil
++        deleteAllSuccessMessage = nil
++
++        Task {
++            do {
++                try await DataResetService.wipeAllLocalData(viewModel: viewModel)
++                deleteAllSuccessMessage = "All local data removed."
++                try? await Task.sleep(for: .seconds(2))
++                if deleteAllSuccessMessage == "All local data removed." {
++                    deleteAllSuccessMessage = nil
++                }
++            } catch {
++                deleteAllErrorMessage = error.localizedDescription
++            }
++
++            isDeletingAllData = false
++        }
+     }
+ }
+ 
+diff --git a/DomainDig/DomainViewModel.swift b/DomainDig/DomainViewModel.swift
+index b65c6df..1d75221 100644
+--- a/DomainDig/DomainViewModel.swift
++++ b/DomainDig/DomainViewModel.swift
+@@ -953,6 +953,46 @@ final class DomainViewModel {
+         refreshDataLifecycleSummary()
+     }
+ 
++    func applyLocalDataReset() async {
++        domain = ""
++        bulkInput = ""
++        reset()
++
++        recentSearches = []
++        savedDomains = []
++        trackedDomains = []
++        history = []
++        workflows = []
++        historySearchText = ""
++        historyDateFilter = .all
++        historyChangeFilter = .all
++        historySortOption = .newest
++        timelineGrouping = .relativeDay
++        timelineDomainFilter = ""
++        watchlistSearchText = ""
++        watchlistFilter = .all
++        watchlistSortOption = .pinned
++        dashboardSearchText = ""
++        dashboardFilter = .all
++        monitoringSettings = MonitoringSettings()
++        monitoringLogs = []
++        notificationsAuthorized = false
++        monitoringRunInProgress = false
++        monitoringStatusMessage = nil
++        portabilityStatusMessage = "All local data removed."
++        upgradePrompt = nil
++        isPaywallPresented = false
++        selectedSnapshotIDs.removeAll()
++        activeDomainDiff = nil
++        activeDiffChangeIndex = 0
++        latestBatchSweepSummary = nil
++        latestWorkflowRunSummary = nil
++        historyAutoPruneOption = Self.loadHistoryAutoPruneOption()
++        refreshDataLifecycleSummary()
++        await refreshUsageCredits()
++        await refreshMonitoringAuthorizationStatus()
++    }
++
+     func refreshMonitoringAuthorizationStatus() async {
+         let settings = await UNUserNotificationCenter.current().notificationSettings()
+         monitoringNotificationStatus = settings.authorizationStatus
+diff --git a/DomainDig/IntegrationService.swift b/DomainDig/IntegrationService.swift
+index 838cfc2..dba8034 100644
+--- a/DomainDig/IntegrationService.swift
++++ b/DomainDig/IntegrationService.swift
+@@ -177,6 +177,28 @@ final class IntegrationService {
+         scheduleProcessing(force: true)
+     }
+ 
++    func localSecretReferences() -> [String] {
++        targets.compactMap { target in
++            switch target.configuration {
++            case .webhook(let configuration):
++                configuration.credentialReference
++            case .slack(let configuration):
++                configuration.credentialReference
++            case .email(let configuration):
++                configuration.credentialReference
++            }
++        }
++    }
++
++    func resetAfterLocalWipe() {
++        processingTask?.cancel()
++        processingTask = nil
++        targets = []
++        deliveryRecords = []
++        queue = []
++        statusMessage = nil
++    }
++
+     private func scheduleProcessing(force: Bool = false) {
+         if force {
+             processingTask?.cancel()
+diff --git a/DomainDig/LocalNotificationService.swift b/DomainDig/LocalNotificationService.swift
+index 93beca5..eef0567 100644
+--- a/DomainDig/LocalNotificationService.swift
++++ b/DomainDig/LocalNotificationService.swift
+@@ -117,6 +117,12 @@ final class LocalNotificationService {
+         )
+     }
+ 
++    func clearAllNotifications() async {
++        let center = UNUserNotificationCenter.current()
++        center.removeAllPendingNotificationRequests()
++        center.removeAllDeliveredNotifications()
++    }
++
+     private func schedule(
+         identifier: String,
+         title: String,
+diff --git a/DomainDig/PurchaseService.swift b/DomainDig/PurchaseService.swift
+index af34a33..dbc31ba 100644
+--- a/DomainDig/PurchaseService.swift
++++ b/DomainDig/PurchaseService.swift
+@@ -196,6 +196,14 @@ final class PurchaseService {
+         errorMessage = nil
+     }
+ 
++    func resetCachedStateAfterLocalWipe() {
++        currentTier = Self.cachedTier
++        activeProductID = Self.cachedEntitlement?.activeProductID
++        statusMessage = nil
++        errorMessage = nil
++        applyDebugOverrideIfNeeded()
++    }
++
+     private func apply(transaction: Transaction) {
+         guard Self.productIDs.contains(transaction.productID), transaction.revocationDate == nil else {
+             return
+diff --git a/DomainDig/WorkflowsView.swift b/DomainDig/WorkflowsView.swift
+index 06ae96b..11273c7 100644
+--- a/DomainDig/WorkflowsView.swift
++++ b/DomainDig/WorkflowsView.swift
+@@ -100,7 +100,8 @@ struct WorkflowsView: View {
+                     title: "No Workflows Yet",
+                     message: "Workflows save a reusable set of domains so repeat inspections take one tap instead of rebuilding the same batch each time.",
+                     suggestion: "Create a workflow for a weekly audit set, customer domains, or a monitoring group.",
+-                    systemImage: "square.stack.3d.down.right"
++                    systemImage: "square.stack.3d.down.right",
++                    showsCardBackground: false
+                 )
+             }
+             .listRowBackground(Color(.systemGray6).opacity(0.5))
+diff --git a/DomainDig/DataResetService.swift b/DomainDig/DataResetService.swift
+new file mode 100644
+index 0000000..e993eba
+--- /dev/null
++++ b/DomainDig/DataResetService.swift
+@@ -0,0 +1,76 @@
++import Foundation
++import Security
++
++enum DataResetService {
++    enum ResetError: LocalizedError {
++        case missingBundleIdentifier
++
++        var errorDescription: String? {
++            switch self {
++            case .missingBundleIdentifier:
++                "DomainDig could not determine its local storage identifier."
++            }
++        }
++    }
++
++    static func wipeAllLocalData(viewModel: DomainViewModel) async throws {
++        let secretReferences = await MainActor.run {
++            IntegrationService.shared.localSecretReferences()
++        }
++
++        try await Task.detached(priority: .userInitiated) {
++            try performPersistentWipe(secretReferences: secretReferences)
++        }.value
++
++        await LookupRuntime.shared.clearCache()
++        await LocalNotificationService.shared.clearAllNotifications()
++        await UsageCreditService.shared.resetForCurrentVersion()
++
++        await MainActor.run {
++            IntegrationService.shared.resetAfterLocalWipe()
++            CloudSyncService.shared.resetLocalStateAfterWipe()
++            PurchaseService.shared.resetCachedStateAfterLocalWipe()
++            _ = DomainMonitoringScheduler.shared.syncSchedule()
++        }
++
++        await viewModel.applyLocalDataReset()
++    }
++
++    private nonisolated static func performPersistentWipe(secretReferences: [String]) throws {
++        guard let bundleIdentifier = Bundle.main.bundleIdentifier else {
++            throw ResetError.missingBundleIdentifier
++        }
++
++        for reference in secretReferences {
++            deleteIntegrationSecret(reference: reference)
++        }
++
++        try removeTemporaryFiles()
++
++        let defaults = UserDefaults.standard
++        defaults.removePersistentDomain(forName: bundleIdentifier)
++        defaults.synchronize()
++    }
++
++    private nonisolated static func removeTemporaryFiles() throws {
++        let tempDirectory = FileManager.default.temporaryDirectory
++        let urls = try FileManager.default.contentsOfDirectory(
++            at: tempDirectory,
++            includingPropertiesForKeys: nil,
++            options: [.skipsHiddenFiles]
++        )
++
++        for url in urls {
++            try? FileManager.default.removeItem(at: url)
++        }
++    }
++
++    private nonisolated static func deleteIntegrationSecret(reference: String) {
++        let query: [String: Any] = [
++            kSecClass as String: kSecClassGenericPassword,
++            kSecAttrAccount as String: reference
++        ]
++
++        SecItemDelete(query as CFDictionary)
++    }
++}
diff --git a/DomainDig.xcodeproj/project.pbxproj b/DomainDig.xcodeproj/project.pbxproj
index c641a56..2de9125 100644
--- a/DomainDig.xcodeproj/project.pbxproj
+++ b/DomainDig.xcodeproj/project.pbxproj
@@ -366,7 +366,7 @@
 				ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
 				CODE_SIGN_ENTITLEMENTS = DomainDig/DomainDig.entitlements;
 				CODE_SIGN_STYLE = Automatic;
-				CURRENT_PROJECT_VERSION = 32;
+				CURRENT_PROJECT_VERSION = 33;
 				DEVELOPMENT_TEAM = ZCNAX3VL9D;
 				ENABLE_PREVIEWS = YES;
 				GENERATE_INFOPLIST_FILE = YES;
@@ -383,7 +383,7 @@
 					"$(inherited)",
 					"@executable_path/Frameworks",
 				);
-				MARKETING_VERSION = 4.0.0;
+				MARKETING_VERSION = 4.1.0;
 				PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig;
 				PRODUCT_NAME = "$(TARGET_NAME)";
 				STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -403,7 +403,7 @@
 				ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
 				CODE_SIGN_ENTITLEMENTS = DomainDig/DomainDig.entitlements;
 				CODE_SIGN_STYLE = Automatic;
-				CURRENT_PROJECT_VERSION = 32;
+				CURRENT_PROJECT_VERSION = 33;
 				DEVELOPMENT_TEAM = ZCNAX3VL9D;
 				ENABLE_PREVIEWS = YES;
 				GENERATE_INFOPLIST_FILE = YES;
@@ -420,7 +420,7 @@
 					"$(inherited)",
 					"@executable_path/Frameworks",
 				);
-				MARKETING_VERSION = 4.0.0;
+				MARKETING_VERSION = 4.1.0;
 				PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig;
 				PRODUCT_NAME = "$(TARGET_NAME)";
 				STRING_CATALOG_GENERATE_SYMBOLS = YES;
diff --git a/DomainDig/CloudSyncService.swift b/DomainDig/CloudSyncService.swift
index 1cf41d9..2cb4cfe 100644
--- a/DomainDig/CloudSyncService.swift
+++ b/DomainDig/CloudSyncService.swift
@@ -321,6 +321,20 @@ final class CloudSyncService {
         }
     }
 
+    func resetLocalStateAfterWipe() {
+        scheduledSyncTask?.cancel()
+        scheduledSyncTask = nil
+        syncTask?.cancel()
+        syncTask = nil
+
+        let syncEnabled = defaults.bool(forKey: StorageKey.isEnabled)
+        isEnabled = syncEnabled
+        status = CloudSyncStatus(rawValue: defaults.string(forKey: StorageKey.status) ?? "") ?? (syncEnabled ? .synced : .disabled)
+        lastSyncDate = defaults.object(forKey: StorageKey.lastSyncDate) as? Date
+        lastErrorMessage = defaults.string(forKey: StorageKey.lastErrorMessage)
+        detailMessage = defaults.string(forKey: StorageKey.detailMessage) ?? "DomainDig stores synced data in your private iCloud account."
+    }
+
     func acceptShare(metadata: CKShare.Metadata) async throws {
         guard let container = cloudKitContainer() else {
             throw CloudSyncRuntimeError.missingEntitlement
diff --git a/DomainDig/ContentView.swift b/DomainDig/ContentView.swift
index c9f40d6..c5c8e50 100644
--- a/DomainDig/ContentView.swift
+++ b/DomainDig/ContentView.swift
@@ -261,6 +261,9 @@ struct ContentView: View {
                 availableDomains: viewModel.batchResults.map(\.domain)
             )
         }
+        .sheet(item: manualBatchSummaryBinding) { summary in
+            BatchSweepSummaryView(viewModel: viewModel, summary: summary)
+        }
         .sheet(isPresented: $showingTimeline) {
             NavigationStack {
                 TimelineView(viewModel: viewModel, domain: viewModel.searchedDomain)
@@ -268,6 +271,19 @@ struct ContentView: View {
         }
     }
 
+    private var manualBatchSummaryBinding: Binding<BatchSweepSummary?> {
+        Binding(
+            get: {
+                guard let summary = viewModel.latestBatchSweepSummary,
+                      summary.source == .manual else {
+                    return nil
+                }
+                return summary
+            },
+            set: { viewModel.latestBatchSweepSummary = $0 }
+        )
+    }
+
     private var inputSection: some View {
         VStack(spacing: appDensity.metrics.cardSpacing + 2) {
             Picker("Mode", selection: $inputMode) {
@@ -3257,6 +3273,10 @@ private struct DataManagementSettingsView: View {
     @State private var showClearCacheConfirmation = false
     @State private var showClearWorkflowsConfirmation = false
     @State private var showClearTrackedDomainsConfirmation = false
+    @State private var showDeleteAllConfirmation = false
+    @State private var deleteAllErrorMessage: String?
+    @State private var deleteAllSuccessMessage: String?
+    @State private var isDeletingAllData = false
 
     var body: some View {
         Form {
@@ -3277,7 +3297,28 @@ private struct DataManagementSettingsView: View {
                     showClearTrackedDomainsConfirmation = true
                 }
             }
+
+            Section {
+                Button(role: .destructive) {
+                    showDeleteAllConfirmation = true
+                } label: {
+                    HStack {
+                        Text("Delete All Data")
+                        Spacer()
+                        if isDeletingAllData {
+                            ProgressView()
+                                .controlSize(.small)
+                        }
+                    }
+                }
+                .disabled(isDeletingAllData)
+            } header: {
+                Text("Danger Zone")
+            } footer: {
+                Text("Permanently removes all local DomainDig data from this device.")
+            }
         }
+        .disabled(isDeletingAllData)
         .navigationTitle("Data Management")
         .alert("Clear history?", isPresented: $showClearHistoryConfirmation) {
             Button("Clear", role: .destructive) {
@@ -3311,6 +3352,57 @@ private struct DataManagementSettingsView: View {
         } message: {
             Text("This removes the watchlist and clears monitoring run history. History and workflows stay intact.")
         }
+        .alert("Delete All Data?", isPresented: $showDeleteAllConfirmation) {
+            Button("Cancel", role: .cancel) {}
+            Button("Delete All Data", role: .destructive) {
+                deleteAllData()
+            }
+        } message: {
+            Text("This will permanently remove all saved DomainDig data from this device. This includes tracked domains, monitoring history, snapshots, exports, cached reports, and local settings. This action cannot be undone.")
+        }
+        .alert("Delete Failed", isPresented: Binding(
+            get: { deleteAllErrorMessage != nil },
+            set: { if !$0 { deleteAllErrorMessage = nil } }
+        )) {
+            Button("OK", role: .cancel) {}
+        } message: {
+            Text(deleteAllErrorMessage ?? "The local data reset could not be completed.")
+        }
+        .safeAreaInset(edge: .bottom) {
+            if let deleteAllSuccessMessage {
+                Text(deleteAllSuccessMessage)
+                    .font(.footnote.weight(.medium))
+                    .foregroundStyle(.secondary)
+                    .padding(.horizontal, 14)
+                    .padding(.vertical, 10)
+                    .background(.thinMaterial, in: Capsule())
+                    .padding(.bottom, 8)
+                    .transition(.move(edge: .bottom).combined(with: .opacity))
+            }
+        }
+    }
+
+    private func deleteAllData() {
+        guard !isDeletingAllData else { return }
+
+        isDeletingAllData = true
+        deleteAllErrorMessage = nil
+        deleteAllSuccessMessage = nil
+
+        Task {
+            do {
+                try await DataResetService.wipeAllLocalData(viewModel: viewModel)
+                deleteAllSuccessMessage = "All local data removed."
+                try? await Task.sleep(for: .seconds(2))
+                if deleteAllSuccessMessage == "All local data removed." {
+                    deleteAllSuccessMessage = nil
+                }
+            } catch {
+                deleteAllErrorMessage = error.localizedDescription
+            }
+
+            isDeletingAllData = false
+        }
     }
 }
 
diff --git a/DomainDig/DataResetService.swift b/DomainDig/DataResetService.swift
new file mode 100644
index 0000000..e993eba
--- /dev/null
+++ b/DomainDig/DataResetService.swift
@@ -0,0 +1,76 @@
+import Foundation
+import Security
+
+enum DataResetService {
+    enum ResetError: LocalizedError {
+        case missingBundleIdentifier
+
+        var errorDescription: String? {
+            switch self {
+            case .missingBundleIdentifier:
+                "DomainDig could not determine its local storage identifier."
+            }
+        }
+    }
+
+    static func wipeAllLocalData(viewModel: DomainViewModel) async throws {
+        let secretReferences = await MainActor.run {
+            IntegrationService.shared.localSecretReferences()
+        }
+
+        try await Task.detached(priority: .userInitiated) {
+            try performPersistentWipe(secretReferences: secretReferences)
+        }.value
+
+        await LookupRuntime.shared.clearCache()
+        await LocalNotificationService.shared.clearAllNotifications()
+        await UsageCreditService.shared.resetForCurrentVersion()
+
+        await MainActor.run {
+            IntegrationService.shared.resetAfterLocalWipe()
+            CloudSyncService.shared.resetLocalStateAfterWipe()
+            PurchaseService.shared.resetCachedStateAfterLocalWipe()
+            _ = DomainMonitoringScheduler.shared.syncSchedule()
+        }
+
+        await viewModel.applyLocalDataReset()
+    }
+
+    private nonisolated static func performPersistentWipe(secretReferences: [String]) throws {
+        guard let bundleIdentifier = Bundle.main.bundleIdentifier else {
+            throw ResetError.missingBundleIdentifier
+        }
+
+        for reference in secretReferences {
+            deleteIntegrationSecret(reference: reference)
+        }
+
+        try removeTemporaryFiles()
+
+        let defaults = UserDefaults.standard
+        defaults.removePersistentDomain(forName: bundleIdentifier)
+        defaults.synchronize()
+    }
+
+    private nonisolated static func removeTemporaryFiles() throws {
+        let tempDirectory = FileManager.default.temporaryDirectory
+        let urls = try FileManager.default.contentsOfDirectory(
+            at: tempDirectory,
+            includingPropertiesForKeys: nil,
+            options: [.skipsHiddenFiles]
+        )
+
+        for url in urls {
+            try? FileManager.default.removeItem(at: url)
+        }
+    }
+
+    private nonisolated static func deleteIntegrationSecret(reference: String) {
+        let query: [String: Any] = [
+            kSecClass as String: kSecClassGenericPassword,
+            kSecAttrAccount as String: reference
+        ]
+
+        SecItemDelete(query as CFDictionary)
+    }
+}
diff --git a/DomainDig/DomainViewModel.swift b/DomainDig/DomainViewModel.swift
index b65c6df..1d75221 100644
--- a/DomainDig/DomainViewModel.swift
+++ b/DomainDig/DomainViewModel.swift
@@ -953,6 +953,46 @@ final class DomainViewModel {
         refreshDataLifecycleSummary()
     }
 
+    func applyLocalDataReset() async {
+        domain = ""
+        bulkInput = ""
+        reset()
+
+        recentSearches = []
+        savedDomains = []
+        trackedDomains = []
+        history = []
+        workflows = []
+        historySearchText = ""
+        historyDateFilter = .all
+        historyChangeFilter = .all
+        historySortOption = .newest
+        timelineGrouping = .relativeDay
+        timelineDomainFilter = ""
+        watchlistSearchText = ""
+        watchlistFilter = .all
+        watchlistSortOption = .pinned
+        dashboardSearchText = ""
+        dashboardFilter = .all
+        monitoringSettings = MonitoringSettings()
+        monitoringLogs = []
+        notificationsAuthorized = false
+        monitoringRunInProgress = false
+        monitoringStatusMessage = nil
+        portabilityStatusMessage = "All local data removed."
+        upgradePrompt = nil
+        isPaywallPresented = false
+        selectedSnapshotIDs.removeAll()
+        activeDomainDiff = nil
+        activeDiffChangeIndex = 0
+        latestBatchSweepSummary = nil
+        latestWorkflowRunSummary = nil
+        historyAutoPruneOption = Self.loadHistoryAutoPruneOption()
+        refreshDataLifecycleSummary()
+        await refreshUsageCredits()
+        await refreshMonitoringAuthorizationStatus()
+    }
+
     func refreshMonitoringAuthorizationStatus() async {
         let settings = await UNUserNotificationCenter.current().notificationSettings()
         monitoringNotificationStatus = settings.authorizationStatus
diff --git a/DomainDig/IntegrationService.swift b/DomainDig/IntegrationService.swift
index 838cfc2..dba8034 100644
--- a/DomainDig/IntegrationService.swift
+++ b/DomainDig/IntegrationService.swift
@@ -177,6 +177,28 @@ final class IntegrationService {
         scheduleProcessing(force: true)
     }
 
+    func localSecretReferences() -> [String] {
+        targets.compactMap { target in
+            switch target.configuration {
+            case .webhook(let configuration):
+                configuration.credentialReference
+            case .slack(let configuration):
+                configuration.credentialReference
+            case .email(let configuration):
+                configuration.credentialReference
+            }
+        }
+    }
+
+    func resetAfterLocalWipe() {
+        processingTask?.cancel()
+        processingTask = nil
+        targets = []
+        deliveryRecords = []
+        queue = []
+        statusMessage = nil
+    }
+
     private func scheduleProcessing(force: Bool = false) {
         if force {
             processingTask?.cancel()
diff --git a/DomainDig/LocalNotificationService.swift b/DomainDig/LocalNotificationService.swift
index 93beca5..eef0567 100644
--- a/DomainDig/LocalNotificationService.swift
+++ b/DomainDig/LocalNotificationService.swift
@@ -117,6 +117,12 @@ final class LocalNotificationService {
         )
     }
 
+    func clearAllNotifications() async {
+        let center = UNUserNotificationCenter.current()
+        center.removeAllPendingNotificationRequests()
+        center.removeAllDeliveredNotifications()
+    }
+
     private func schedule(
         identifier: String,
         title: String,
diff --git a/DomainDig/PurchaseService.swift b/DomainDig/PurchaseService.swift
index af34a33..dbc31ba 100644
--- a/DomainDig/PurchaseService.swift
+++ b/DomainDig/PurchaseService.swift
@@ -196,6 +196,14 @@ final class PurchaseService {
         errorMessage = nil
     }
 
+    func resetCachedStateAfterLocalWipe() {
+        currentTier = Self.cachedTier
+        activeProductID = Self.cachedEntitlement?.activeProductID
+        statusMessage = nil
+        errorMessage = nil
+        applyDebugOverrideIfNeeded()
+    }
+
     private func apply(transaction: Transaction) {
         guard Self.productIDs.contains(transaction.productID), transaction.revocationDate == nil else {
             return
diff --git a/DomainDig/WorkflowsView.swift b/DomainDig/WorkflowsView.swift
index 06ae96b..11273c7 100644
--- a/DomainDig/WorkflowsView.swift
+++ b/DomainDig/WorkflowsView.swift
@@ -100,7 +100,8 @@ struct WorkflowsView: View {
                     title: "No Workflows Yet",
                     message: "Workflows save a reusable set of domains so repeat inspections take one tap instead of rebuilding the same batch each time.",
                     suggestion: "Create a workflow for a weekly audit set, customer domains, or a monitoring group.",
-                    systemImage: "square.stack.3d.down.right"
+                    systemImage: "square.stack.3d.down.right",
+                    showsCardBackground: false
                 )
             }
             .listRowBackground(Color(.systemGray6).opacity(0.5))