| @@ -1,413 +0,0 @@ |
| 1 | | diff --git a/DomainDig.xcodeproj/project.pbxproj b/DomainDig.xcodeproj/project.pbxproj |
| 2 | | index 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; |
| 41 | | diff --git a/DomainDig/CloudSyncService.swift b/DomainDig/CloudSyncService.swift |
| 42 | | index 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 |
| 66 | | diff --git a/DomainDig/ContentView.swift b/DomainDig/ContentView.swift |
| 67 | | index 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 | | |
| 198 | | diff --git a/DomainDig/DomainViewModel.swift b/DomainDig/DomainViewModel.swift |
| 199 | | index 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 |
| 249 | | diff --git a/DomainDig/IntegrationService.swift b/DomainDig/IntegrationService.swift |
| 250 | | index 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() |
| 282 | | diff --git a/DomainDig/LocalNotificationService.swift b/DomainDig/LocalNotificationService.swift |
| 283 | | index 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, |
| 299 | | diff --git a/DomainDig/PurchaseService.swift b/DomainDig/PurchaseService.swift |
| 300 | | index 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 |
| 318 | | diff --git a/DomainDig/WorkflowsView.swift b/DomainDig/WorkflowsView.swift |
| 319 | | index 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)) |
| 332 | | diff --git a/DomainDig/DataResetService.swift b/DomainDig/DataResetService.swift |
| 333 | | new file mode 100644 |
| 334 | | index 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 | | +} |