krz/domain-dig

an ios app for DNS & SSL analysis

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

v4.7.0: DomainDig-4.1.0.patch · raw

  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+}