krz/domain-dig
an ios app for DNS & SSL analysis
clone: git clone https://gitbay.org/krz/domain-dig.git
v4.5.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+}