Commit 535bb0ff0f

535bb0ff0f64d57be1074e33ceb42f598f80205b

parent: 1715e59287

Verified · cmc

cmc <hello@cleberg.net> · 2026-04-25 05:41 UTC

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

Layout: unified · split

DomainDig-4.1.0.patch added +413
@@ -0,0 +1,413 @@
1diff --git a/DomainDig.xcodeproj/project.pbxproj b/DomainDig.xcodeproj/project.pbxproj
2index c641a56..2de9125 100644
3--- a/DomainDig.xcodeproj/project.pbxproj
4+++ b/DomainDig.xcodeproj/project.pbxproj
5@@ -366,7 +366,7 @@
6 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
7 CODE_SIGN_ENTITLEMENTS = DomainDig/DomainDig.entitlements;
8 CODE_SIGN_STYLE = Automatic;
9- CURRENT_PROJECT_VERSION = 32;
10+ CURRENT_PROJECT_VERSION = 33;
11 DEVELOPMENT_TEAM = ZCNAX3VL9D;
12 ENABLE_PREVIEWS = YES;
13 GENERATE_INFOPLIST_FILE = YES;
14@@ -383,7 +383,7 @@
15 "$(inherited)",
16 "@executable_path/Frameworks",
17 );
18- MARKETING_VERSION = 4.0.0;
19+ MARKETING_VERSION = 4.1.0;
20 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig;
21 PRODUCT_NAME = "$(TARGET_NAME)";
22 STRING_CATALOG_GENERATE_SYMBOLS = YES;
23@@ -403,7 +403,7 @@
24 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
25 CODE_SIGN_ENTITLEMENTS = DomainDig/DomainDig.entitlements;
26 CODE_SIGN_STYLE = Automatic;
27- CURRENT_PROJECT_VERSION = 32;
28+ CURRENT_PROJECT_VERSION = 33;
29 DEVELOPMENT_TEAM = ZCNAX3VL9D;
30 ENABLE_PREVIEWS = YES;
31 GENERATE_INFOPLIST_FILE = YES;
32@@ -420,7 +420,7 @@
33 "$(inherited)",
34 "@executable_path/Frameworks",
35 );
36- MARKETING_VERSION = 4.0.0;
37+ MARKETING_VERSION = 4.1.0;
38 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig;
39 PRODUCT_NAME = "$(TARGET_NAME)";
40 STRING_CATALOG_GENERATE_SYMBOLS = YES;
41diff --git a/DomainDig/CloudSyncService.swift b/DomainDig/CloudSyncService.swift
42index 1cf41d9..2cb4cfe 100644
43--- a/DomainDig/CloudSyncService.swift
44+++ b/DomainDig/CloudSyncService.swift
45@@ -321,6 +321,20 @@ final class CloudSyncService {
46 }
47 }
48
49+ func resetLocalStateAfterWipe() {
50+ scheduledSyncTask?.cancel()
51+ scheduledSyncTask = nil
52+ syncTask?.cancel()
53+ syncTask = nil
54+
55+ let syncEnabled = defaults.bool(forKey: StorageKey.isEnabled)
56+ isEnabled = syncEnabled
57+ status = CloudSyncStatus(rawValue: defaults.string(forKey: StorageKey.status) ?? "") ?? (syncEnabled ? .synced : .disabled)
58+ lastSyncDate = defaults.object(forKey: StorageKey.lastSyncDate) as? Date
59+ lastErrorMessage = defaults.string(forKey: StorageKey.lastErrorMessage)
60+ detailMessage = defaults.string(forKey: StorageKey.detailMessage) ?? "DomainDig stores synced data in your private iCloud account."
61+ }
62+
63 func acceptShare(metadata: CKShare.Metadata) async throws {
64 guard let container = cloudKitContainer() else {
65 throw CloudSyncRuntimeError.missingEntitlement
66diff --git a/DomainDig/ContentView.swift b/DomainDig/ContentView.swift
67index c9f40d6..c5c8e50 100644
68--- a/DomainDig/ContentView.swift
69+++ b/DomainDig/ContentView.swift
70@@ -261,6 +261,9 @@ struct ContentView: View {
71 availableDomains: viewModel.batchResults.map(\.domain)
72 )
73 }
74+ .sheet(item: manualBatchSummaryBinding) { summary in
75+ BatchSweepSummaryView(viewModel: viewModel, summary: summary)
76+ }
77 .sheet(isPresented: $showingTimeline) {
78 NavigationStack {
79 TimelineView(viewModel: viewModel, domain: viewModel.searchedDomain)
80@@ -268,6 +271,19 @@ struct ContentView: View {
81 }
82 }
83
84+ private var manualBatchSummaryBinding: Binding<BatchSweepSummary?> {
85+ Binding(
86+ get: {
87+ guard let summary = viewModel.latestBatchSweepSummary,
88+ summary.source == .manual else {
89+ return nil
90+ }
91+ return summary
92+ },
93+ set: { viewModel.latestBatchSweepSummary = $0 }
94+ )
95+ }
96+
97 private var inputSection: some View {
98 VStack(spacing: appDensity.metrics.cardSpacing + 2) {
99 Picker("Mode", selection: $inputMode) {
100@@ -3257,6 +3273,10 @@ private struct DataManagementSettingsView: View {
101 @State private var showClearCacheConfirmation = false
102 @State private var showClearWorkflowsConfirmation = false
103 @State private var showClearTrackedDomainsConfirmation = false
104+ @State private var showDeleteAllConfirmation = false
105+ @State private var deleteAllErrorMessage: String?
106+ @State private var deleteAllSuccessMessage: String?
107+ @State private var isDeletingAllData = false
108
109 var body: some View {
110 Form {
111@@ -3277,7 +3297,28 @@ private struct DataManagementSettingsView: View {
112 showClearTrackedDomainsConfirmation = true
113 }
114 }
115+
116+ Section {
117+ Button(role: .destructive) {
118+ showDeleteAllConfirmation = true
119+ } label: {
120+ HStack {
121+ Text("Delete All Data")
122+ Spacer()
123+ if isDeletingAllData {
124+ ProgressView()
125+ .controlSize(.small)
126+ }
127+ }
128+ }
129+ .disabled(isDeletingAllData)
130+ } header: {
131+ Text("Danger Zone")
132+ } footer: {
133+ Text("Permanently removes all local DomainDig data from this device.")
134+ }
135 }
136+ .disabled(isDeletingAllData)
137 .navigationTitle("Data Management")
138 .alert("Clear history?", isPresented: $showClearHistoryConfirmation) {
139 Button("Clear", role: .destructive) {
140@@ -3311,6 +3352,57 @@ private struct DataManagementSettingsView: View {
141 } message: {
142 Text("This removes the watchlist and clears monitoring run history. History and workflows stay intact.")
143 }
144+ .alert("Delete All Data?", isPresented: $showDeleteAllConfirmation) {
145+ Button("Cancel", role: .cancel) {}
146+ Button("Delete All Data", role: .destructive) {
147+ deleteAllData()
148+ }
149+ } message: {
150+ 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.")
151+ }
152+ .alert("Delete Failed", isPresented: Binding(
153+ get: { deleteAllErrorMessage != nil },
154+ set: { if !$0 { deleteAllErrorMessage = nil } }
155+ )) {
156+ Button("OK", role: .cancel) {}
157+ } message: {
158+ Text(deleteAllErrorMessage ?? "The local data reset could not be completed.")
159+ }
160+ .safeAreaInset(edge: .bottom) {
161+ if let deleteAllSuccessMessage {
162+ Text(deleteAllSuccessMessage)
163+ .font(.footnote.weight(.medium))
164+ .foregroundStyle(.secondary)
165+ .padding(.horizontal, 14)
166+ .padding(.vertical, 10)
167+ .background(.thinMaterial, in: Capsule())
168+ .padding(.bottom, 8)
169+ .transition(.move(edge: .bottom).combined(with: .opacity))
170+ }
171+ }
172+ }
173+
174+ private func deleteAllData() {
175+ guard !isDeletingAllData else { return }
176+
177+ isDeletingAllData = true
178+ deleteAllErrorMessage = nil
179+ deleteAllSuccessMessage = nil
180+
181+ Task {
182+ do {
183+ try await DataResetService.wipeAllLocalData(viewModel: viewModel)
184+ deleteAllSuccessMessage = "All local data removed."
185+ try? await Task.sleep(for: .seconds(2))
186+ if deleteAllSuccessMessage == "All local data removed." {
187+ deleteAllSuccessMessage = nil
188+ }
189+ } catch {
190+ deleteAllErrorMessage = error.localizedDescription
191+ }
192+
193+ isDeletingAllData = false
194+ }
195 }
196 }
197
198diff --git a/DomainDig/DomainViewModel.swift b/DomainDig/DomainViewModel.swift
199index b65c6df..1d75221 100644
200--- a/DomainDig/DomainViewModel.swift
201+++ b/DomainDig/DomainViewModel.swift
202@@ -953,6 +953,46 @@ final class DomainViewModel {
203 refreshDataLifecycleSummary()
204 }
205
206+ func applyLocalDataReset() async {
207+ domain = ""
208+ bulkInput = ""
209+ reset()
210+
211+ recentSearches = []
212+ savedDomains = []
213+ trackedDomains = []
214+ history = []
215+ workflows = []
216+ historySearchText = ""
217+ historyDateFilter = .all
218+ historyChangeFilter = .all
219+ historySortOption = .newest
220+ timelineGrouping = .relativeDay
221+ timelineDomainFilter = ""
222+ watchlistSearchText = ""
223+ watchlistFilter = .all
224+ watchlistSortOption = .pinned
225+ dashboardSearchText = ""
226+ dashboardFilter = .all
227+ monitoringSettings = MonitoringSettings()
228+ monitoringLogs = []
229+ notificationsAuthorized = false
230+ monitoringRunInProgress = false
231+ monitoringStatusMessage = nil
232+ portabilityStatusMessage = "All local data removed."
233+ upgradePrompt = nil
234+ isPaywallPresented = false
235+ selectedSnapshotIDs.removeAll()
236+ activeDomainDiff = nil
237+ activeDiffChangeIndex = 0
238+ latestBatchSweepSummary = nil
239+ latestWorkflowRunSummary = nil
240+ historyAutoPruneOption = Self.loadHistoryAutoPruneOption()
241+ refreshDataLifecycleSummary()
242+ await refreshUsageCredits()
243+ await refreshMonitoringAuthorizationStatus()
244+ }
245+
246 func refreshMonitoringAuthorizationStatus() async {
247 let settings = await UNUserNotificationCenter.current().notificationSettings()
248 monitoringNotificationStatus = settings.authorizationStatus
249diff --git a/DomainDig/IntegrationService.swift b/DomainDig/IntegrationService.swift
250index 838cfc2..dba8034 100644
251--- a/DomainDig/IntegrationService.swift
252+++ b/DomainDig/IntegrationService.swift
253@@ -177,6 +177,28 @@ final class IntegrationService {
254 scheduleProcessing(force: true)
255 }
256
257+ func localSecretReferences() -> [String] {
258+ targets.compactMap { target in
259+ switch target.configuration {
260+ case .webhook(let configuration):
261+ configuration.credentialReference
262+ case .slack(let configuration):
263+ configuration.credentialReference
264+ case .email(let configuration):
265+ configuration.credentialReference
266+ }
267+ }
268+ }
269+
270+ func resetAfterLocalWipe() {
271+ processingTask?.cancel()
272+ processingTask = nil
273+ targets = []
274+ deliveryRecords = []
275+ queue = []
276+ statusMessage = nil
277+ }
278+
279 private func scheduleProcessing(force: Bool = false) {
280 if force {
281 processingTask?.cancel()
282diff --git a/DomainDig/LocalNotificationService.swift b/DomainDig/LocalNotificationService.swift
283index 93beca5..eef0567 100644
284--- a/DomainDig/LocalNotificationService.swift
285+++ b/DomainDig/LocalNotificationService.swift
286@@ -117,6 +117,12 @@ final class LocalNotificationService {
287 )
288 }
289
290+ func clearAllNotifications() async {
291+ let center = UNUserNotificationCenter.current()
292+ center.removeAllPendingNotificationRequests()
293+ center.removeAllDeliveredNotifications()
294+ }
295+
296 private func schedule(
297 identifier: String,
298 title: String,
299diff --git a/DomainDig/PurchaseService.swift b/DomainDig/PurchaseService.swift
300index af34a33..dbc31ba 100644
301--- a/DomainDig/PurchaseService.swift
302+++ b/DomainDig/PurchaseService.swift
303@@ -196,6 +196,14 @@ final class PurchaseService {
304 errorMessage = nil
305 }
306
307+ func resetCachedStateAfterLocalWipe() {
308+ currentTier = Self.cachedTier
309+ activeProductID = Self.cachedEntitlement?.activeProductID
310+ statusMessage = nil
311+ errorMessage = nil
312+ applyDebugOverrideIfNeeded()
313+ }
314+
315 private func apply(transaction: Transaction) {
316 guard Self.productIDs.contains(transaction.productID), transaction.revocationDate == nil else {
317 return
318diff --git a/DomainDig/WorkflowsView.swift b/DomainDig/WorkflowsView.swift
319index 06ae96b..11273c7 100644
320--- a/DomainDig/WorkflowsView.swift
321+++ b/DomainDig/WorkflowsView.swift
322@@ -100,7 +100,8 @@ struct WorkflowsView: View {
323 title: "No Workflows Yet",
324 message: "Workflows save a reusable set of domains so repeat inspections take one tap instead of rebuilding the same batch each time.",
325 suggestion: "Create a workflow for a weekly audit set, customer domains, or a monitoring group.",
326- systemImage: "square.stack.3d.down.right"
327+ systemImage: "square.stack.3d.down.right",
328+ showsCardBackground: false
329 )
330 }
331 .listRowBackground(Color(.systemGray6).opacity(0.5))
332diff --git a/DomainDig/DataResetService.swift b/DomainDig/DataResetService.swift
333new file mode 100644
334index 0000000..e993eba
335--- /dev/null
336+++ b/DomainDig/DataResetService.swift
337@@ -0,0 +1,76 @@
338+import Foundation
339+import Security
340+
341+enum DataResetService {
342+ enum ResetError: LocalizedError {
343+ case missingBundleIdentifier
344+
345+ var errorDescription: String? {
346+ switch self {
347+ case .missingBundleIdentifier:
348+ "DomainDig could not determine its local storage identifier."
349+ }
350+ }
351+ }
352+
353+ static func wipeAllLocalData(viewModel: DomainViewModel) async throws {
354+ let secretReferences = await MainActor.run {
355+ IntegrationService.shared.localSecretReferences()
356+ }
357+
358+ try await Task.detached(priority: .userInitiated) {
359+ try performPersistentWipe(secretReferences: secretReferences)
360+ }.value
361+
362+ await LookupRuntime.shared.clearCache()
363+ await LocalNotificationService.shared.clearAllNotifications()
364+ await UsageCreditService.shared.resetForCurrentVersion()
365+
366+ await MainActor.run {
367+ IntegrationService.shared.resetAfterLocalWipe()
368+ CloudSyncService.shared.resetLocalStateAfterWipe()
369+ PurchaseService.shared.resetCachedStateAfterLocalWipe()
370+ _ = DomainMonitoringScheduler.shared.syncSchedule()
371+ }
372+
373+ await viewModel.applyLocalDataReset()
374+ }
375+
376+ private nonisolated static func performPersistentWipe(secretReferences: [String]) throws {
377+ guard let bundleIdentifier = Bundle.main.bundleIdentifier else {
378+ throw ResetError.missingBundleIdentifier
379+ }
380+
381+ for reference in secretReferences {
382+ deleteIntegrationSecret(reference: reference)
383+ }
384+
385+ try removeTemporaryFiles()
386+
387+ let defaults = UserDefaults.standard
388+ defaults.removePersistentDomain(forName: bundleIdentifier)
389+ defaults.synchronize()
390+ }
391+
392+ private nonisolated static func removeTemporaryFiles() throws {
393+ let tempDirectory = FileManager.default.temporaryDirectory
394+ let urls = try FileManager.default.contentsOfDirectory(
395+ at: tempDirectory,
396+ includingPropertiesForKeys: nil,
397+ options: [.skipsHiddenFiles]
398+ )
399+
400+ for url in urls {
401+ try? FileManager.default.removeItem(at: url)
402+ }
403+ }
404+
405+ private nonisolated static func deleteIntegrationSecret(reference: String) {
406+ let query: [String: Any] = [
407+ kSecClass as String: kSecClassGenericPassword,
408+ kSecAttrAccount as String: reference
409+ ]
410+
411+ SecItemDelete(query as CFDictionary)
412+ }
413+}
DomainDig.xcodeproj/project.pbxproj +4 −4
@@ -366,7 +366,7 @@
366366 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
367367 CODE_SIGN_ENTITLEMENTS = DomainDig/DomainDig.entitlements;
368368 CODE_SIGN_STYLE = Automatic;
369 CURRENT_PROJECT_VERSION = 32;
369 CURRENT_PROJECT_VERSION = 33;
370370 DEVELOPMENT_TEAM = ZCNAX3VL9D;
371371 ENABLE_PREVIEWS = YES;
372372 GENERATE_INFOPLIST_FILE = YES;
@@ -383,7 +383,7 @@
383383 "$(inherited)",
384384 "@executable_path/Frameworks",
385385 );
386 MARKETING_VERSION = 4.0.0;
386 MARKETING_VERSION = 4.1.0;
387387 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig;
388388 PRODUCT_NAME = "$(TARGET_NAME)";
389389 STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -403,7 +403,7 @@
403403 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
404404 CODE_SIGN_ENTITLEMENTS = DomainDig/DomainDig.entitlements;
405405 CODE_SIGN_STYLE = Automatic;
406 CURRENT_PROJECT_VERSION = 32;
406 CURRENT_PROJECT_VERSION = 33;
407407 DEVELOPMENT_TEAM = ZCNAX3VL9D;
408408 ENABLE_PREVIEWS = YES;
409409 GENERATE_INFOPLIST_FILE = YES;
@@ -420,7 +420,7 @@
420420 "$(inherited)",
421421 "@executable_path/Frameworks",
422422 );
423 MARKETING_VERSION = 4.0.0;
423 MARKETING_VERSION = 4.1.0;
424424 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig;
425425 PRODUCT_NAME = "$(TARGET_NAME)";
426426 STRING_CATALOG_GENERATE_SYMBOLS = YES;
DomainDig/CloudSyncService.swift +14
@@ -321,6 +321,20 @@ final class CloudSyncService {
321321 }
322322 }
323323
324 func resetLocalStateAfterWipe() {
325 scheduledSyncTask?.cancel()
326 scheduledSyncTask = nil
327 syncTask?.cancel()
328 syncTask = nil
329
330 let syncEnabled = defaults.bool(forKey: StorageKey.isEnabled)
331 isEnabled = syncEnabled
332 status = CloudSyncStatus(rawValue: defaults.string(forKey: StorageKey.status) ?? "") ?? (syncEnabled ? .synced : .disabled)
333 lastSyncDate = defaults.object(forKey: StorageKey.lastSyncDate) as? Date
334 lastErrorMessage = defaults.string(forKey: StorageKey.lastErrorMessage)
335 detailMessage = defaults.string(forKey: StorageKey.detailMessage) ?? "DomainDig stores synced data in your private iCloud account."
336 }
337
324338 func acceptShare(metadata: CKShare.Metadata) async throws {
325339 guard let container = cloudKitContainer() else {
326340 throw CloudSyncRuntimeError.missingEntitlement
DomainDig/ContentView.swift +92
@@ -261,6 +261,9 @@ struct ContentView: View {
261261 availableDomains: viewModel.batchResults.map(\.domain)
262262 )
263263 }
264 .sheet(item: manualBatchSummaryBinding) { summary in
265 BatchSweepSummaryView(viewModel: viewModel, summary: summary)
266 }
264267 .sheet(isPresented: $showingTimeline) {
265268 NavigationStack {
266269 TimelineView(viewModel: viewModel, domain: viewModel.searchedDomain)
@@ -268,6 +271,19 @@ struct ContentView: View {
268271 }
269272 }
270273
274 private var manualBatchSummaryBinding: Binding<BatchSweepSummary?> {
275 Binding(
276 get: {
277 guard let summary = viewModel.latestBatchSweepSummary,
278 summary.source == .manual else {
279 return nil
280 }
281 return summary
282 },
283 set: { viewModel.latestBatchSweepSummary = $0 }
284 )
285 }
286
271287 private var inputSection: some View {
272288 VStack(spacing: appDensity.metrics.cardSpacing + 2) {
273289 Picker("Mode", selection: $inputMode) {
@@ -3257,6 +3273,10 @@ private struct DataManagementSettingsView: View {
32573273 @State private var showClearCacheConfirmation = false
32583274 @State private var showClearWorkflowsConfirmation = false
32593275 @State private var showClearTrackedDomainsConfirmation = false
3276 @State private var showDeleteAllConfirmation = false
3277 @State private var deleteAllErrorMessage: String?
3278 @State private var deleteAllSuccessMessage: String?
3279 @State private var isDeletingAllData = false
32603280
32613281 var body: some View {
32623282 Form {
@@ -3277,7 +3297,28 @@ private struct DataManagementSettingsView: View {
32773297 showClearTrackedDomainsConfirmation = true
32783298 }
32793299 }
3300
3301 Section {
3302 Button(role: .destructive) {
3303 showDeleteAllConfirmation = true
3304 } label: {
3305 HStack {
3306 Text("Delete All Data")
3307 Spacer()
3308 if isDeletingAllData {
3309 ProgressView()
3310 .controlSize(.small)
3311 }
3312 }
3313 }
3314 .disabled(isDeletingAllData)
3315 } header: {
3316 Text("Danger Zone")
3317 } footer: {
3318 Text("Permanently removes all local DomainDig data from this device.")
3319 }
32803320 }
3321 .disabled(isDeletingAllData)
32813322 .navigationTitle("Data Management")
32823323 .alert("Clear history?", isPresented: $showClearHistoryConfirmation) {
32833324 Button("Clear", role: .destructive) {
@@ -3311,6 +3352,57 @@ private struct DataManagementSettingsView: View {
33113352 } message: {
33123353 Text("This removes the watchlist and clears monitoring run history. History and workflows stay intact.")
33133354 }
3355 .alert("Delete All Data?", isPresented: $showDeleteAllConfirmation) {
3356 Button("Cancel", role: .cancel) {}
3357 Button("Delete All Data", role: .destructive) {
3358 deleteAllData()
3359 }
3360 } message: {
3361 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.")
3362 }
3363 .alert("Delete Failed", isPresented: Binding(
3364 get: { deleteAllErrorMessage != nil },
3365 set: { if !$0 { deleteAllErrorMessage = nil } }
3366 )) {
3367 Button("OK", role: .cancel) {}
3368 } message: {
3369 Text(deleteAllErrorMessage ?? "The local data reset could not be completed.")
3370 }
3371 .safeAreaInset(edge: .bottom) {
3372 if let deleteAllSuccessMessage {
3373 Text(deleteAllSuccessMessage)
3374 .font(.footnote.weight(.medium))
3375 .foregroundStyle(.secondary)
3376 .padding(.horizontal, 14)
3377 .padding(.vertical, 10)
3378 .background(.thinMaterial, in: Capsule())
3379 .padding(.bottom, 8)
3380 .transition(.move(edge: .bottom).combined(with: .opacity))
3381 }
3382 }
3383 }
3384
3385 private func deleteAllData() {
3386 guard !isDeletingAllData else { return }
3387
3388 isDeletingAllData = true
3389 deleteAllErrorMessage = nil
3390 deleteAllSuccessMessage = nil
3391
3392 Task {
3393 do {
3394 try await DataResetService.wipeAllLocalData(viewModel: viewModel)
3395 deleteAllSuccessMessage = "All local data removed."
3396 try? await Task.sleep(for: .seconds(2))
3397 if deleteAllSuccessMessage == "All local data removed." {
3398 deleteAllSuccessMessage = nil
3399 }
3400 } catch {
3401 deleteAllErrorMessage = error.localizedDescription
3402 }
3403
3404 isDeletingAllData = false
3405 }
33143406 }
33153407}
33163408
DomainDig/DataResetService.swift added +76
@@ -0,0 +1,76 @@
1import Foundation
2import Security
3
4enum DataResetService {
5 enum ResetError: LocalizedError {
6 case missingBundleIdentifier
7
8 var errorDescription: String? {
9 switch self {
10 case .missingBundleIdentifier:
11 "DomainDig could not determine its local storage identifier."
12 }
13 }
14 }
15
16 static func wipeAllLocalData(viewModel: DomainViewModel) async throws {
17 let secretReferences = await MainActor.run {
18 IntegrationService.shared.localSecretReferences()
19 }
20
21 try await Task.detached(priority: .userInitiated) {
22 try performPersistentWipe(secretReferences: secretReferences)
23 }.value
24
25 await LookupRuntime.shared.clearCache()
26 await LocalNotificationService.shared.clearAllNotifications()
27 await UsageCreditService.shared.resetForCurrentVersion()
28
29 await MainActor.run {
30 IntegrationService.shared.resetAfterLocalWipe()
31 CloudSyncService.shared.resetLocalStateAfterWipe()
32 PurchaseService.shared.resetCachedStateAfterLocalWipe()
33 _ = DomainMonitoringScheduler.shared.syncSchedule()
34 }
35
36 await viewModel.applyLocalDataReset()
37 }
38
39 private nonisolated static func performPersistentWipe(secretReferences: [String]) throws {
40 guard let bundleIdentifier = Bundle.main.bundleIdentifier else {
41 throw ResetError.missingBundleIdentifier
42 }
43
44 for reference in secretReferences {
45 deleteIntegrationSecret(reference: reference)
46 }
47
48 try removeTemporaryFiles()
49
50 let defaults = UserDefaults.standard
51 defaults.removePersistentDomain(forName: bundleIdentifier)
52 defaults.synchronize()
53 }
54
55 private nonisolated static func removeTemporaryFiles() throws {
56 let tempDirectory = FileManager.default.temporaryDirectory
57 let urls = try FileManager.default.contentsOfDirectory(
58 at: tempDirectory,
59 includingPropertiesForKeys: nil,
60 options: [.skipsHiddenFiles]
61 )
62
63 for url in urls {
64 try? FileManager.default.removeItem(at: url)
65 }
66 }
67
68 private nonisolated static func deleteIntegrationSecret(reference: String) {
69 let query: [String: Any] = [
70 kSecClass as String: kSecClassGenericPassword,
71 kSecAttrAccount as String: reference
72 ]
73
74 SecItemDelete(query as CFDictionary)
75 }
76}
DomainDig/DomainViewModel.swift +40
@@ -953,6 +953,46 @@ final class DomainViewModel {
953953 refreshDataLifecycleSummary()
954954 }
955955
956 func applyLocalDataReset() async {
957 domain = ""
958 bulkInput = ""
959 reset()
960
961 recentSearches = []
962 savedDomains = []
963 trackedDomains = []
964 history = []
965 workflows = []
966 historySearchText = ""
967 historyDateFilter = .all
968 historyChangeFilter = .all
969 historySortOption = .newest
970 timelineGrouping = .relativeDay
971 timelineDomainFilter = ""
972 watchlistSearchText = ""
973 watchlistFilter = .all
974 watchlistSortOption = .pinned
975 dashboardSearchText = ""
976 dashboardFilter = .all
977 monitoringSettings = MonitoringSettings()
978 monitoringLogs = []
979 notificationsAuthorized = false
980 monitoringRunInProgress = false
981 monitoringStatusMessage = nil
982 portabilityStatusMessage = "All local data removed."
983 upgradePrompt = nil
984 isPaywallPresented = false
985 selectedSnapshotIDs.removeAll()
986 activeDomainDiff = nil
987 activeDiffChangeIndex = 0
988 latestBatchSweepSummary = nil
989 latestWorkflowRunSummary = nil
990 historyAutoPruneOption = Self.loadHistoryAutoPruneOption()
991 refreshDataLifecycleSummary()
992 await refreshUsageCredits()
993 await refreshMonitoringAuthorizationStatus()
994 }
995
956996 func refreshMonitoringAuthorizationStatus() async {
957997 let settings = await UNUserNotificationCenter.current().notificationSettings()
958998 monitoringNotificationStatus = settings.authorizationStatus
DomainDig/IntegrationService.swift +22
@@ -177,6 +177,28 @@ final class IntegrationService {
177177 scheduleProcessing(force: true)
178178 }
179179
180 func localSecretReferences() -> [String] {
181 targets.compactMap { target in
182 switch target.configuration {
183 case .webhook(let configuration):
184 configuration.credentialReference
185 case .slack(let configuration):
186 configuration.credentialReference
187 case .email(let configuration):
188 configuration.credentialReference
189 }
190 }
191 }
192
193 func resetAfterLocalWipe() {
194 processingTask?.cancel()
195 processingTask = nil
196 targets = []
197 deliveryRecords = []
198 queue = []
199 statusMessage = nil
200 }
201
180202 private func scheduleProcessing(force: Bool = false) {
181203 if force {
182204 processingTask?.cancel()
DomainDig/LocalNotificationService.swift +6
@@ -117,6 +117,12 @@ final class LocalNotificationService {
117117 )
118118 }
119119
120 func clearAllNotifications() async {
121 let center = UNUserNotificationCenter.current()
122 center.removeAllPendingNotificationRequests()
123 center.removeAllDeliveredNotifications()
124 }
125
120126 private func schedule(
121127 identifier: String,
122128 title: String,
DomainDig/PurchaseService.swift +8
@@ -196,6 +196,14 @@ final class PurchaseService {
196196 errorMessage = nil
197197 }
198198
199 func resetCachedStateAfterLocalWipe() {
200 currentTier = Self.cachedTier
201 activeProductID = Self.cachedEntitlement?.activeProductID
202 statusMessage = nil
203 errorMessage = nil
204 applyDebugOverrideIfNeeded()
205 }
206
199207 private func apply(transaction: Transaction) {
200208 guard Self.productIDs.contains(transaction.productID), transaction.revocationDate == nil else {
201209 return
DomainDig/WorkflowsView.swift +2 −1
@@ -100,7 +100,8 @@ struct WorkflowsView: View {
100100 title: "No Workflows Yet",
101101 message: "Workflows save a reusable set of domains so repeat inspections take one tap instead of rebuilding the same batch each time.",
102102 suggestion: "Create a workflow for a weekly audit set, customer domains, or a monitoring group.",
103 systemImage: "square.stack.3d.down.right"
103 systemImage: "square.stack.3d.down.right",
104 showsCardBackground: false
104105 )
105106 }
106107 .listRowBackground(Color(.systemGray6).opacity(0.5))