Commit f045a5d339

f045a5d339a796f40a9e958436ebc6f5314bdab8

parent: 6c23daaaba

Verified · cmc

cmc <hello@cleberg.net> · 2026-04-24 03:15 UTC

feat(v3.4.0): add backup, restore, and data portability

• introduce versioned DomainDig backup format
• add full backup export/import with merge and replace modes
• implement data validation and conflict handling
• add partial exports for tracked domains, workflows, and history
• add Data Portability settings section
• support Files/iCloud Drive import and export
• harden migrations for evolving local models
• extend CLI with backup export and validation

Layout: unified · split

DomainDataPortabilityService.swift added +1397
@@ -0,0 +1,1397 @@
1import Foundation
2import UniformTypeIdentifiers
3
4struct DomainDigBackup: Codable {
5 static let currentSchemaVersion = 1
6
7 let schemaVersion: Int
8 let exportedAt: Date
9 let appVersion: String
10 let trackedDomains: [TrackedDomain]
11 let historyEntries: [HistoryEntry]
12 let workflows: [DomainWorkflow]
13 let monitoringSettings: MonitoringSettings?
14 let monitoringLogs: [MonitoringLog]
15 let appSettings: AppSettingsSnapshot
16 let featureMetadata: FeatureMetadataSnapshot?
17}
18
19struct AppSettingsSnapshot: Codable {
20 let recentSearches: [String]
21 let savedDomains: [String]
22 let resolverURLString: String
23 let appDensityRawValue: String
24}
25
26struct FeatureMetadataSnapshot: Codable {
27 let cachedEntitlement: PurchaseService.CachedEntitlement?
28 let usageCredits: UsageCreditsSnapshot?
29}
30
31struct UsageCreditsSnapshot: Codable {
32 let appVersion: String
33 let remainingByFeature: [UsageCreditFeature: Int]
34}
35
36struct TrackedDomainsExport: Codable {
37 let schemaVersion: Int
38 let exportedAt: Date
39 let appVersion: String
40 let trackedDomains: [TrackedDomain]
41}
42
43struct WorkflowsExport: Codable {
44 let schemaVersion: Int
45 let exportedAt: Date
46 let appVersion: String
47 let workflows: [DomainWorkflow]
48}
49
50struct HistoryExport: Codable {
51 let schemaVersion: Int
52 let exportedAt: Date
53 let appVersion: String
54 let historyEntries: [HistoryEntry]
55}
56
57enum DataPortabilityImportMode: String, CaseIterable, Identifiable {
58 case merge
59 case replace
60
61 var id: String { rawValue }
62
63 var title: String {
64 switch self {
65 case .merge:
66 return "Merge"
67 case .replace:
68 return "Replace"
69 }
70 }
71
72 var explanation: String {
73 switch self {
74 case .merge:
75 return "Keep local data and merge imported items."
76 case .replace:
77 return "Replace local data with the imported file."
78 }
79 }
80}
81
82enum DataPortabilityImportKind: String {
83 case backup
84 case trackedDomains
85 case workflows
86}
87
88struct DataLifecycleSummary: Equatable {
89 let trackedDomains: Int
90 let historySnapshots: Int
91 let workflows: Int
92 let cachedItems: Int
93 let monitoringLogs: Int
94}
95
96struct DataValidationMessage: Identifiable, Equatable {
97 let id = UUID()
98 let text: String
99 let isError: Bool
100}
101
102struct DataValidationReport {
103 let messages: [DataValidationMessage]
104
105 var warnings: [String] {
106 messages.filter { !$0.isError }.map(\.text)
107 }
108
109 var errors: [String] {
110 messages.filter(\.isError).map(\.text)
111 }
112}
113
114struct DataImportPreview {
115 let fileName: String
116 let kind: DataPortabilityImportKind
117 let mode: DataPortabilityImportMode
118 let summaryLines: [String]
119 let warnings: [String]
120 let currentCounts: DataLifecycleSummary
121 let projectedCounts: DataLifecycleSummary
122 fileprivate let payload: ImportPayload
123}
124
125struct DataImportResult {
126 let kind: DataPortabilityImportKind
127 let mode: DataPortabilityImportMode
128 let summary: String
129 let warnings: [String]
130}
131
132enum DataPortabilityError: LocalizedError {
133 case unsupportedFormat
134 case unsupportedSchema(Int)
135 case invalidCSV(String)
136 case unreadableFile
137 case validationFailed([String])
138
139 var errorDescription: String? {
140 switch self {
141 case .unsupportedFormat:
142 return "The selected file is not a supported DomainDig import."
143 case .unsupportedSchema(let version):
144 return "Backup schema v\(version) is not supported by this version of DomainDig."
145 case .invalidCSV(let message):
146 return message
147 case .unreadableFile:
148 return "The selected file could not be read."
149 case .validationFailed(let messages):
150 return messages.joined(separator: "\n")
151 }
152 }
153}
154
155enum DataPortabilityCSV {
156 static func trackedDomains(_ domains: [TrackedDomain]) -> String {
157 let header = [
158 "id",
159 "domain",
160 "createdAt",
161 "updatedAt",
162 "note",
163 "isPinned",
164 "monitoringEnabled",
165 "lastKnownAvailability",
166 "certificateWarningLevel",
167 "certificateDaysRemaining",
168 "lastMonitoredAt",
169 "lastAlertAt"
170 ]
171
172 let rows = domains.map { domain in
173 [
174 domain.id.uuidString,
175 domain.domain,
176 DomainDataPortabilityService.formatISODate(domain.createdAt),
177 DomainDataPortabilityService.formatISODate(domain.updatedAt),
178 domain.note ?? "",
179 String(domain.isPinned),
180 String(domain.monitoringEnabled),
181 domain.lastKnownAvailability?.rawValue ?? "",
182 domain.certificateWarningLevel.rawValue,
183 domain.certificateDaysRemaining.map(String.init) ?? "",
184 domain.lastMonitoredAt.map(DomainDataPortabilityService.formatISODate) ?? "",
185 domain.lastAlertAt.map(DomainDataPortabilityService.formatISODate) ?? ""
186 ]
187 }
188
189 return ([header] + rows).map(Self.csvLine).joined(separator: "\n")
190 }
191
192 static func workflows(_ workflows: [DomainWorkflow]) -> String {
193 let header = ["id", "name", "domains", "createdAt", "updatedAt", "notes"]
194 let rows = workflows.map { workflow in
195 [
196 workflow.id.uuidString,
197 workflow.name,
198 workflow.domains.joined(separator: "|"),
199 DomainDataPortabilityService.formatISODate(workflow.createdAt),
200 DomainDataPortabilityService.formatISODate(workflow.updatedAt),
201 workflow.notes ?? ""
202 ]
203 }
204
205 return ([header] + rows).map(Self.csvLine).joined(separator: "\n")
206 }
207
208 static func parseTrackedDomains(from string: String) throws -> [TrackedDomain] {
209 let rows = try parseRows(from: string)
210 guard let header = rows.first else { return [] }
211 let mappedRows = rows.dropFirst().map { dictionary(for: Array($0), header: header) }
212
213 return mappedRows.compactMap { row in
214 let domain = Self.normalizedDomain(row["domain"])
215 guard !domain.isEmpty else { return nil }
216
217 let createdAt = parseDate(row["createdAt"]) ?? Date()
218 let updatedAt = parseDate(row["updatedAt"]) ?? createdAt
219 let availability = row["lastKnownAvailability"].flatMap(DomainAvailabilityStatus.init(rawValue:))
220 let certificateLevel = row["certificateWarningLevel"].flatMap(CertificateWarningLevel.init(rawValue:)) ?? .none
221
222 return TrackedDomain(
223 id: UUID(uuidString: row["id"] ?? "") ?? UUID(),
224 domain: domain,
225 createdAt: createdAt,
226 updatedAt: updatedAt,
227 note: row["note"]?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty,
228 isPinned: Self.parseBool(row["isPinned"]),
229 monitoringEnabled: row["monitoringEnabled"].map(Self.parseBool) ?? true,
230 lastKnownAvailability: availability,
231 certificateWarningLevel: certificateLevel,
232 certificateDaysRemaining: Int(row["certificateDaysRemaining"] ?? ""),
233 lastMonitoredAt: parseDate(row["lastMonitoredAt"]),
234 lastAlertAt: parseDate(row["lastAlertAt"])
235 )
236 }
237 }
238
239 static func parseWorkflows(from string: String) throws -> [DomainWorkflow] {
240 let rows = try parseRows(from: string)
241 guard let header = rows.first else { return [] }
242 let mappedRows = rows.dropFirst().map { dictionary(for: Array($0), header: header) }
243
244 return mappedRows.compactMap { row in
245 let name = row["name"]?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
246 let domains = (row["domains"] ?? "")
247 .split(separator: "|")
248 .map { Self.normalizedDomain(String($0)) }
249 .filter { !$0.isEmpty }
250
251 guard !name.isEmpty, !domains.isEmpty else { return nil }
252
253 let createdAt = parseDate(row["createdAt"]) ?? Date()
254 let updatedAt = parseDate(row["updatedAt"]) ?? createdAt
255 return DomainWorkflow(
256 id: UUID(uuidString: row["id"] ?? "") ?? UUID(),
257 name: name,
258 domains: Self.deduplicated(domains),
259 createdAt: createdAt,
260 updatedAt: updatedAt,
261 notes: row["notes"]?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty
262 )
263 }
264 }
265
266 private nonisolated static func parseRows(from string: String) throws -> [[String]] {
267 var rows: [[String]] = []
268 var currentRow: [String] = []
269 var currentField = ""
270 var insideQuotes = false
271
272 for character in string {
273 switch character {
274 case "\"":
275 insideQuotes.toggle()
276 case "," where !insideQuotes:
277 currentRow.append(currentField)
278 currentField = ""
279 case "\n" where !insideQuotes:
280 currentRow.append(currentField)
281 rows.append(currentRow)
282 currentRow = []
283 currentField = ""
284 case "\r":
285 continue
286 default:
287 currentField.append(character)
288 }
289 }
290
291 if insideQuotes {
292 throw DataPortabilityError.invalidCSV("The CSV file is malformed.")
293 }
294
295 if !currentField.isEmpty || !currentRow.isEmpty {
296 currentRow.append(currentField)
297 rows.append(currentRow)
298 }
299
300 return rows.filter { !$0.allSatisfy(\.isEmpty) }
301 }
302
303 private nonisolated static func dictionary(for row: [String], header: [String]) -> [String: String] {
304 Dictionary(uniqueKeysWithValues: zip(header, row).map { ($0.0, $0.1) })
305 }
306
307 private nonisolated static func csvLine(_ values: [String]) -> String {
308 values.map { value in
309 let escaped = value.replacingOccurrences(of: "\"", with: "\"\"")
310 return "\"\(escaped)\""
311 }
312 .joined(separator: ",")
313 }
314
315 private nonisolated static func parseDate(_ value: String?) -> Date? {
316 guard let value, !value.isEmpty else { return nil }
317 return DomainDataPortabilityService.parseISODate(value)
318 }
319
320 private nonisolated static func parseBool(_ value: String?) -> Bool {
321 guard let value else { return false }
322 return value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == "true"
323 }
324
325 private nonisolated static func normalizedDomain(_ value: String?) -> String {
326 value?
327 .trimmingCharacters(in: .whitespacesAndNewlines)
328 .replacingOccurrences(of: "https://", with: "")
329 .replacingOccurrences(of: "http://", with: "")
330 .components(separatedBy: "/")
331 .first?
332 .lowercased() ?? ""
333 }
334
335 private nonisolated static func deduplicated(_ values: [String]) -> [String] {
336 var seen = Set<String>()
337 return values.filter { seen.insert($0).inserted }
338 }
339}
340
341enum DataMigrationService {
342 private static let migrationMarkerKey = "data.migrations.v3_4_0"
343
344 static func migrateIfNeeded(defaults: UserDefaults = .standard) {
345 guard !defaults.bool(forKey: migrationMarkerKey) else { return }
346
347 let trackedDomains = DomainDataPortabilityService.loadTrackedDomains(defaults: defaults)
348 DomainDataPortabilityService.saveTrackedDomains(trackedDomains, defaults: defaults)
349
350 let historyEntries = DomainDataPortabilityService.loadHistoryEntries(defaults: defaults)
351 DomainDataPortabilityService.saveHistoryEntries(historyEntries, defaults: defaults)
352
353 let workflows = DomainDataPortabilityService.loadWorkflows(defaults: defaults)
354 DomainDataPortabilityService.saveWorkflows(workflows, defaults: defaults)
355
356 let monitoringSettings = DomainDataPortabilityService.loadMonitoringSettings(defaults: defaults)
357 let sanitizedMonitoringSettings = MonitoringStorage.sanitizeSettings(monitoringSettings, trackedDomains: trackedDomains)
358 DomainDataPortabilityService.saveMonitoringSettings(sanitizedMonitoringSettings, defaults: defaults)
359
360 let monitoringLogs = DomainDataPortabilityService.loadMonitoringLogs(defaults: defaults)
361 DomainDataPortabilityService.saveMonitoringLogs(monitoringLogs, defaults: defaults)
362
363 defaults.set(true, forKey: migrationMarkerKey)
364 }
365}
366
367enum DataValidationService {
368 static func validate(backup: DomainDigBackup) -> DataValidationReport {
369 var messages: [DataValidationMessage] = []
370
371 if backup.schemaVersion > DomainDigBackup.currentSchemaVersion {
372 messages.append(.init(text: "This backup was created by a newer DomainDig version.", isError: true))
373 } else if backup.schemaVersion < DomainDigBackup.currentSchemaVersion {
374 messages.append(.init(text: "Older backup schema detected. DomainDig will import using compatibility rules.", isError: false))
375 }
376
377 messages.append(contentsOf: validateTrackedDomains(backup.trackedDomains))
378 messages.append(contentsOf: validateHistoryEntries(backup.historyEntries))
379 messages.append(contentsOf: validateWorkflows(backup.workflows))
380
381 if backup.appSettings.resolverURLString.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
382 messages.append(.init(text: "Backup is missing a resolver URL. The default resolver will be used.", isError: false))
383 }
384
385 return DataValidationReport(messages: messages)
386 }
387
388 static func validate(trackedDomains: [TrackedDomain]) -> DataValidationReport {
389 DataValidationReport(messages: validateTrackedDomains(trackedDomains))
390 }
391
392 static func validate(workflows: [DomainWorkflow]) -> DataValidationReport {
393 DataValidationReport(messages: validateWorkflows(workflows))
394 }
395
396 private static func validateTrackedDomains(_ trackedDomains: [TrackedDomain]) -> [DataValidationMessage] {
397 var messages: [DataValidationMessage] = []
398 var seen = Set<String>()
399
400 for trackedDomain in trackedDomains {
401 let normalized = normalizeDomain(trackedDomain.domain)
402 if normalized.isEmpty {
403 messages.append(.init(text: "A tracked domain entry is missing its domain name.", isError: true))
404 continue
405 }
406
407 if !seen.insert(normalized).inserted {
408 messages.append(.init(text: "Duplicate tracked domain found for \(normalized). Merge rules will consolidate it.", isError: false))
409 }
410 }
411
412 return messages
413 }
414
415 private static func validateHistoryEntries(_ historyEntries: [HistoryEntry]) -> [DataValidationMessage] {
416 var messages: [DataValidationMessage] = []
417 for entry in historyEntries {
418 if normalizeDomain(entry.domain).isEmpty {
419 messages.append(.init(text: "A history snapshot is missing its domain name.", isError: true))
420 }
421 if entry.timestamp == .distantPast {
422 messages.append(.init(text: "A history snapshot is missing its timestamp.", isError: false))
423 }
424 for issue in entry.validationIssues {
425 messages.append(.init(text: "History warning for \(entry.domain): \(issue)", isError: false))
426 }
427 }
428 return messages
429 }
430
431 private static func validateWorkflows(_ workflows: [DomainWorkflow]) -> [DataValidationMessage] {
432 var messages: [DataValidationMessage] = []
433 for workflow in workflows {
434 if workflow.name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
435 messages.append(.init(text: "A workflow is missing its name.", isError: true))
436 }
437 if workflow.domains.isEmpty {
438 messages.append(.init(text: "Workflow \(workflow.name) has no domains.", isError: false))
439 }
440 if Set(workflow.domains.map(Self.normalizeDomain)).count != workflow.domains.count {
441 messages.append(.init(text: "Workflow \(workflow.name) contains duplicate domains. Import will deduplicate them.", isError: false))
442 }
443 }
444 return messages
445 }
446
447 private nonisolated static func normalizeDomain(_ value: String) -> String {
448 value
449 .trimmingCharacters(in: .whitespacesAndNewlines)
450 .replacingOccurrences(of: "https://", with: "")
451 .replacingOccurrences(of: "http://", with: "")
452 .components(separatedBy: "/")
453 .first?
454 .lowercased() ?? ""
455 }
456}
457
458enum DomainDataPortabilityService {
459 static let backupUTType = UTType.json
460 static let csvUTType = UTType.commaSeparatedText
461
462 fileprivate nonisolated static func formatISODate(_ date: Date) -> String {
463 let formatter = ISO8601DateFormatter()
464 formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
465 return formatter.string(from: date)
466 }
467
468 fileprivate nonisolated static func parseISODate(_ value: String) -> Date? {
469 let formatter = ISO8601DateFormatter()
470 formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
471 return formatter.date(from: value)
472 }
473
474 private enum StorageKey {
475 static let recentSearches = "recentSearches"
476 static let savedDomains = "savedDomains"
477 static let trackedDomains = "trackedDomains"
478 static let legacyWatchedDomains = "watchedDomains"
479 static let history = "lookupHistory"
480 static let workflows = "domainWorkflows"
481 static let monitoringSettings = "monitoring.settings"
482 static let monitoringLogs = "monitoring.logs"
483 static let appDensity = AppDensity.userDefaultsKey
484 static let resolverURL = DNSResolverOption.userDefaultsKey
485 static let purchaseCache = "purchase.cachedEntitlement"
486 static let usageCredits = "usageCredits.ledger"
487 }
488
489 static func loadRecentSearches(defaults: UserDefaults = .standard) -> [String] {
490 defaults.stringArray(forKey: StorageKey.recentSearches) ?? []
491 }
492
493 static func saveRecentSearches(_ values: [String], defaults: UserDefaults = .standard) {
494 defaults.set(Array(values.prefix(20)), forKey: StorageKey.recentSearches)
495 }
496
497 static func loadSavedDomains(defaults: UserDefaults = .standard) -> [String] {
498 defaults.stringArray(forKey: StorageKey.savedDomains) ?? []
499 }
500
501 static func saveSavedDomains(_ values: [String], defaults: UserDefaults = .standard) {
502 defaults.set(values, forKey: StorageKey.savedDomains)
503 }
504
505 static func loadTrackedDomains(defaults: UserDefaults = .standard) -> [TrackedDomain] {
506 let decoder = JSONDecoder()
507
508 if let data = defaults.data(forKey: StorageKey.trackedDomains),
509 let domains = try? decoder.decode([TrackedDomain].self, from: data) {
510 return deduplicatedTrackedDomains(domains)
511 }
512
513 if let data = defaults.data(forKey: StorageKey.legacyWatchedDomains),
514 let legacy = try? decoder.decode([WatchedDomain].self, from: data) {
515 return deduplicatedTrackedDomains(
516 legacy.map {
517 TrackedDomain(
518 id: $0.id,
519 domain: normalizeDomain($0.domain),
520 createdAt: $0.createdAt,
521 updatedAt: $0.createdAt,
522 lastKnownAvailability: $0.lastKnownAvailability
523 )
524 }
525 )
526 }
527
528 return []
529 }
530
531 static func saveTrackedDomains(_ trackedDomains: [TrackedDomain], defaults: UserDefaults = .standard) {
532 if let data = try? JSONEncoder().encode(deduplicatedTrackedDomains(trackedDomains)) {
533 defaults.set(data, forKey: StorageKey.trackedDomains)
534 }
535 defaults.removeObject(forKey: StorageKey.legacyWatchedDomains)
536 }
537
538 static func loadHistoryEntries(defaults: UserDefaults = .standard) -> [HistoryEntry] {
539 guard let data = defaults.data(forKey: StorageKey.history) else { return [] }
540
541 if let entries = try? JSONDecoder().decode([HistoryEntry].self, from: data) {
542 return deduplicatedHistoryEntries(entries)
543 }
544
545 guard let rawArray = (try? JSONSerialization.jsonObject(with: data)) as? [Any] else {
546 return []
547 }
548
549 let decoder = JSONDecoder()
550 let entries = rawArray.compactMap { item -> HistoryEntry? in
551 guard JSONSerialization.isValidJSONObject(item),
552 let entryData = try? JSONSerialization.data(withJSONObject: item) else {
553 return nil
554 }
555 return try? decoder.decode(HistoryEntry.self, from: entryData)
556 }
557 return deduplicatedHistoryEntries(entries)
558 }
559
560 static func saveHistoryEntries(_ historyEntries: [HistoryEntry], defaults: UserDefaults = .standard) {
561 if let data = try? JSONEncoder().encode(deduplicatedHistoryEntries(historyEntries)) {
562 defaults.set(data, forKey: StorageKey.history)
563 }
564 }
565
566 static func loadWorkflows(defaults: UserDefaults = .standard) -> [DomainWorkflow] {
567 guard let data = defaults.data(forKey: StorageKey.workflows),
568 let workflows = try? JSONDecoder().decode([DomainWorkflow].self, from: data) else {
569 return []
570 }
571
572 return deduplicatedWorkflows(workflows).sorted(by: workflowSort)
573 }
574
575 static func saveWorkflows(_ workflows: [DomainWorkflow], defaults: UserDefaults = .standard) {
576 if let data = try? JSONEncoder().encode(deduplicatedWorkflows(workflows)) {
577 defaults.set(data, forKey: StorageKey.workflows)
578 }
579 }
580
581 static func loadMonitoringSettings(defaults: UserDefaults = .standard) -> MonitoringSettings {
582 guard let data = defaults.data(forKey: StorageKey.monitoringSettings),
583 let settings = try? JSONDecoder().decode(MonitoringSettings.self, from: data) else {
584 return MonitoringSettings()
585 }
586 return settings
587 }
588
589 static func saveMonitoringSettings(_ settings: MonitoringSettings, defaults: UserDefaults = .standard) {
590 if let data = try? JSONEncoder().encode(settings) {
591 defaults.set(data, forKey: StorageKey.monitoringSettings)
592 }
593 }
594
595 static func loadMonitoringLogs(defaults: UserDefaults = .standard) -> [MonitoringLog] {
596 guard let data = defaults.data(forKey: StorageKey.monitoringLogs),
597 let logs = try? JSONDecoder().decode([MonitoringLog].self, from: data) else {
598 return []
599 }
600 return Array(logs.prefix(MonitoringStorage.maxLogs))
601 }
602
603 static func saveMonitoringLogs(_ logs: [MonitoringLog], defaults: UserDefaults = .standard) {
604 if let data = try? JSONEncoder().encode(Array(logs.prefix(MonitoringStorage.maxLogs))) {
605 defaults.set(data, forKey: StorageKey.monitoringLogs)
606 }
607 }
608
609 static func loadAppSettings(defaults: UserDefaults = .standard) -> AppSettingsSnapshot {
610 AppSettingsSnapshot(
611 recentSearches: loadRecentSearches(defaults: defaults),
612 savedDomains: loadSavedDomains(defaults: defaults),
613 resolverURLString: defaults.string(forKey: StorageKey.resolverURL) ?? DNSResolverOption.defaultURLString,
614 appDensityRawValue: defaults.string(forKey: StorageKey.appDensity) ?? AppDensity.compact.rawValue
615 )
616 }
617
618 static func saveAppSettings(_ settings: AppSettingsSnapshot, defaults: UserDefaults = .standard) {
619 saveRecentSearches(settings.recentSearches, defaults: defaults)
620 saveSavedDomains(settings.savedDomains, defaults: defaults)
621 defaults.set(settings.resolverURLString, forKey: StorageKey.resolverURL)
622 defaults.set(settings.appDensityRawValue, forKey: StorageKey.appDensity)
623 }
624
625 static func loadFeatureMetadata(defaults: UserDefaults = .standard) -> FeatureMetadataSnapshot {
626 let cachedEntitlement: PurchaseService.CachedEntitlement?
627 if let data = defaults.data(forKey: StorageKey.purchaseCache) {
628 cachedEntitlement = try? JSONDecoder().decode(PurchaseService.CachedEntitlement.self, from: data)
629 } else {
630 cachedEntitlement = nil
631 }
632
633 let usageCredits: UsageCreditsSnapshot?
634 if let data = defaults.data(forKey: StorageKey.usageCredits) {
635 usageCredits = try? JSONDecoder().decode(UsageCreditsSnapshot.self, from: data)
636 } else {
637 usageCredits = nil
638 }
639
640 return FeatureMetadataSnapshot(cachedEntitlement: cachedEntitlement, usageCredits: usageCredits)
641 }
642
643 static func saveFeatureMetadata(_ featureMetadata: FeatureMetadataSnapshot?, defaults: UserDefaults = .standard) {
644 if let cachedEntitlement = featureMetadata?.cachedEntitlement,
645 let data = try? JSONEncoder().encode(cachedEntitlement) {
646 defaults.set(data, forKey: StorageKey.purchaseCache)
647 }
648
649 if let usageCredits = featureMetadata?.usageCredits,
650 let data = try? JSONEncoder().encode(usageCredits) {
651 defaults.set(data, forKey: StorageKey.usageCredits)
652 }
653 }
654
655 static func currentBackup(defaults: UserDefaults = .standard) -> DomainDigBackup {
656 DataMigrationService.migrateIfNeeded(defaults: defaults)
657 return DomainDigBackup(
658 schemaVersion: DomainDigBackup.currentSchemaVersion,
659 exportedAt: Date(),
660 appVersion: AppVersion.current,
661 trackedDomains: loadTrackedDomains(defaults: defaults),
662 historyEntries: loadHistoryEntries(defaults: defaults),
663 workflows: loadWorkflows(defaults: defaults),
664 monitoringSettings: loadMonitoringSettings(defaults: defaults),
665 monitoringLogs: loadMonitoringLogs(defaults: defaults),
666 appSettings: loadAppSettings(defaults: defaults),
667 featureMetadata: loadFeatureMetadata(defaults: defaults)
668 )
669 }
670
671 static func backupData(defaults: UserDefaults = .standard) throws -> Data {
672 try makeEncoder().encode(currentBackup(defaults: defaults))
673 }
674
675 static func trackedDomainsExportData(defaults: UserDefaults = .standard) throws -> Data {
676 try makeEncoder().encode(
677 TrackedDomainsExport(
678 schemaVersion: DomainDigBackup.currentSchemaVersion,
679 exportedAt: Date(),
680 appVersion: AppVersion.current,
681 trackedDomains: loadTrackedDomains(defaults: defaults)
682 )
683 )
684 }
685
686 static func workflowsExportData(defaults: UserDefaults = .standard) throws -> Data {
687 try makeEncoder().encode(
688 WorkflowsExport(
689 schemaVersion: DomainDigBackup.currentSchemaVersion,
690 exportedAt: Date(),
691 appVersion: AppVersion.current,
692 workflows: loadWorkflows(defaults: defaults)
693 )
694 )
695 }
696
697 static func historyExportData(defaults: UserDefaults = .standard) throws -> Data {
698 try makeEncoder().encode(
699 HistoryExport(
700 schemaVersion: DomainDigBackup.currentSchemaVersion,
701 exportedAt: Date(),
702 appVersion: AppVersion.current,
703 historyEntries: loadHistoryEntries(defaults: defaults)
704 )
705 )
706 }
707
708 static func trackedDomainsCSV(defaults: UserDefaults = .standard) -> String {
709 DataPortabilityCSV.trackedDomains(loadTrackedDomains(defaults: defaults))
710 }
711
712 static func workflowsCSV(defaults: UserDefaults = .standard) -> String {
713 DataPortabilityCSV.workflows(loadWorkflows(defaults: defaults))
714 }
715
716 static func prepareImport(
717 data: Data,
718 fileName: String,
719 mode: DataPortabilityImportMode,
720 defaults: UserDefaults = .standard
721 ) throws -> DataImportPreview {
722 DataMigrationService.migrateIfNeeded(defaults: defaults)
723 let currentCounts = lifecycleSummary(defaults: defaults)
724
725 let payload = try decodeImportPayload(from: data, fileName: fileName)
726 let projectedCounts = projectCounts(for: payload, mode: mode, defaults: defaults)
727
728 return DataImportPreview(
729 fileName: fileName,
730 kind: payload.kind,
731 mode: mode,
732 summaryLines: payload.summaryLines(mode: mode),
733 warnings: payload.validationReport.warnings,
734 currentCounts: currentCounts,
735 projectedCounts: projectedCounts,
736 payload: payload
737 )
738 }
739
740 static func applyImport(
741 _ preview: DataImportPreview,
742 mode: DataPortabilityImportMode,
743 defaults: UserDefaults = .standard
744 ) throws -> DataImportResult {
745 switch preview.payload {
746 case .backup(let backup, let report):
747 try applyBackup(backup, mode: mode, defaults: defaults)
748 return DataImportResult(
749 kind: .backup,
750 mode: mode,
751 summary: "Imported backup with \(backup.trackedDomains.count) tracked domains, \(backup.historyEntries.count) history snapshots, and \(backup.workflows.count) workflows.",
752 warnings: report.warnings
753 )
754 case .trackedDomains(let trackedDomains, let report):
755 applyTrackedDomainsImport(trackedDomains, mode: mode, defaults: defaults)
756 return DataImportResult(
757 kind: .trackedDomains,
758 mode: mode,
759 summary: "Imported \(trackedDomains.count) tracked domains.",
760 warnings: report.warnings
761 )
762 case .workflows(let workflows, let report):
763 applyWorkflowsImport(workflows, mode: mode, defaults: defaults)
764 return DataImportResult(
765 kind: .workflows,
766 mode: mode,
767 summary: "Imported \(workflows.count) workflows.",
768 warnings: report.warnings
769 )
770 }
771 }
772
773 static func validateBackup(data: Data, fileName: String = "backup.json") throws -> DataValidationReport {
774 switch try decodeImportPayload(from: data, fileName: fileName) {
775 case .backup(_, let report):
776 return report
777 case .trackedDomains(_, let report):
778 return report
779 case .workflows(_, let report):
780 return report
781 }
782 }
783
784 static func lifecycleSummary(defaults: UserDefaults = .standard) -> DataLifecycleSummary {
785 let appSettings = loadAppSettings(defaults: defaults)
786 let featureMetadata = loadFeatureMetadata(defaults: defaults)
787 let cachedItems = appSettings.recentSearches.count
788 + appSettings.savedDomains.count
789 + (featureMetadata.cachedEntitlement == nil ? 0 : 1)
790 + (featureMetadata.usageCredits == nil ? 0 : 1)
791
792 return DataLifecycleSummary(
793 trackedDomains: loadTrackedDomains(defaults: defaults).count,
794 historySnapshots: loadHistoryEntries(defaults: defaults).count,
795 workflows: loadWorkflows(defaults: defaults).count,
796 cachedItems: cachedItems,
797 monitoringLogs: loadMonitoringLogs(defaults: defaults).count
798 )
799 }
800
801 private static func decodeImportPayload(from data: Data, fileName: String) throws -> ImportPayload {
802 let decoder = JSONDecoder()
803
804 if let backup = try? decoder.decode(DomainDigBackup.self, from: data) {
805 if backup.schemaVersion > DomainDigBackup.currentSchemaVersion {
806 throw DataPortabilityError.unsupportedSchema(backup.schemaVersion)
807 }
808 let report = DataValidationService.validate(backup: backup)
809 if !report.errors.isEmpty {
810 throw DataPortabilityError.validationFailed(report.errors)
811 }
812 return .backup(backup, report)
813 }
814
815 if let backup = try? decodeLegacyBackup(from: data) {
816 let report = DataValidationService.validate(backup: backup)
817 if !report.errors.isEmpty {
818 throw DataPortabilityError.validationFailed(report.errors)
819 }
820 return .backup(backup, report)
821 }
822
823 if let export = try? decoder.decode(TrackedDomainsExport.self, from: data) {
824 let report = DataValidationService.validate(trackedDomains: export.trackedDomains)
825 if !report.errors.isEmpty {
826 throw DataPortabilityError.validationFailed(report.errors)
827 }
828 return .trackedDomains(export.trackedDomains, report)
829 }
830
831 if let export = try? decoder.decode(WorkflowsExport.self, from: data) {
832 let report = DataValidationService.validate(workflows: export.workflows)
833 if !report.errors.isEmpty {
834 throw DataPortabilityError.validationFailed(report.errors)
835 }
836 return .workflows(export.workflows, report)
837 }
838
839 if let trackedDomains = try? decoder.decode([TrackedDomain].self, from: data) {
840 let report = DataValidationService.validate(trackedDomains: trackedDomains)
841 if !report.errors.isEmpty {
842 throw DataPortabilityError.validationFailed(report.errors)
843 }
844 return .trackedDomains(trackedDomains, report)
845 }
846
847 if let workflows = try? decoder.decode([DomainWorkflow].self, from: data) {
848 let report = DataValidationService.validate(workflows: workflows)
849 if !report.errors.isEmpty {
850 throw DataPortabilityError.validationFailed(report.errors)
851 }
852 return .workflows(workflows, report)
853 }
854
855 let lowercasedFileName = fileName.lowercased()
856 if lowercasedFileName.hasSuffix(".csv") {
857 guard let string = String(data: data, encoding: .utf8) else {
858 throw DataPortabilityError.unreadableFile
859 }
860 if lowercasedFileName.contains("workflow") {
861 let workflows = try DataPortabilityCSV.parseWorkflows(from: string)
862 let report = DataValidationService.validate(workflows: workflows)
863 if !report.errors.isEmpty {
864 throw DataPortabilityError.validationFailed(report.errors)
865 }
866 return .workflows(workflows, report)
867 }
868
869 let trackedDomains = try DataPortabilityCSV.parseTrackedDomains(from: string)
870 let report = DataValidationService.validate(trackedDomains: trackedDomains)
871 if !report.errors.isEmpty {
872 throw DataPortabilityError.validationFailed(report.errors)
873 }
874 return .trackedDomains(trackedDomains, report)
875 }
876
877 throw DataPortabilityError.unsupportedFormat
878 }
879
880 private static func decodeLegacyBackup(from data: Data) throws -> DomainDigBackup {
881 struct LegacyBackup: Decodable {
882 let trackedDomains: [TrackedDomain]?
883 let historyEntries: [HistoryEntry]?
884 let workflows: [DomainWorkflow]?
885 let monitoringSettings: MonitoringSettings?
886 let monitoringLogs: [MonitoringLog]?
887 let recentSearches: [String]?
888 let savedDomains: [String]?
889 }
890
891 let legacy = try JSONDecoder().decode(LegacyBackup.self, from: data)
892 return DomainDigBackup(
893 schemaVersion: 0,
894 exportedAt: Date(),
895 appVersion: AppVersion.current,
896 trackedDomains: legacy.trackedDomains ?? [],
897 historyEntries: legacy.historyEntries ?? [],
898 workflows: legacy.workflows ?? [],
899 monitoringSettings: legacy.monitoringSettings,
900 monitoringLogs: legacy.monitoringLogs ?? [],
901 appSettings: AppSettingsSnapshot(
902 recentSearches: legacy.recentSearches ?? [],
903 savedDomains: legacy.savedDomains ?? [],
904 resolverURLString: DNSResolverOption.defaultURLString,
905 appDensityRawValue: AppDensity.compact.rawValue
906 ),
907 featureMetadata: nil
908 )
909 }
910
911 private static func applyBackup(
912 _ backup: DomainDigBackup,
913 mode: DataPortabilityImportMode,
914 defaults: UserDefaults
915 ) throws {
916 if mode == .replace {
917 saveTrackedDomains(deduplicatedTrackedDomains(backup.trackedDomains), defaults: defaults)
918 saveHistoryEntries(deduplicatedHistoryEntries(backup.historyEntries), defaults: defaults)
919 saveWorkflows(deduplicatedWorkflows(backup.workflows), defaults: defaults)
920 saveMonitoringSettings(
921 MonitoringStorage.sanitizeSettings(
922 backup.monitoringSettings ?? MonitoringSettings(),
923 trackedDomains: deduplicatedTrackedDomains(backup.trackedDomains)
924 ),
925 defaults: defaults
926 )
927 saveMonitoringLogs(backup.monitoringLogs, defaults: defaults)
928 saveAppSettings(backup.appSettings, defaults: defaults)
929 saveFeatureMetadata(backup.featureMetadata, defaults: defaults)
930 return
931 }
932
933 let existingTrackedDomains = loadTrackedDomains(defaults: defaults)
934 let mergedTrackedDomains = mergeTrackedDomains(existing: existingTrackedDomains, incoming: backup.trackedDomains)
935 saveTrackedDomains(mergedTrackedDomains.domains, defaults: defaults)
936
937 let existingHistoryEntries = loadHistoryEntries(defaults: defaults)
938 let remappedIncomingHistory = backup.historyEntries.map { remapHistoryEntry($0, using: mergedTrackedDomains.idMap) }
939 saveHistoryEntries(
940 mergeHistoryEntries(existing: existingHistoryEntries, incoming: remappedIncomingHistory),
941 defaults: defaults
942 )
943
944 let existingWorkflows = loadWorkflows(defaults: defaults)
945 saveWorkflows(mergeWorkflows(existing: existingWorkflows, incoming: backup.workflows), defaults: defaults)
946
947 let existingMonitoringLogs = loadMonitoringLogs(defaults: defaults)
948 saveMonitoringLogs(mergeMonitoringLogs(existing: existingMonitoringLogs, incoming: backup.monitoringLogs), defaults: defaults)
949
950 let remappedMonitoringSettings = remapMonitoringSettings(
951 backup.monitoringSettings ?? MonitoringSettings(),
952 using: mergedTrackedDomains.idMap
953 )
954 saveMonitoringSettings(
955 MonitoringStorage.sanitizeSettings(remappedMonitoringSettings, trackedDomains: mergedTrackedDomains.domains),
956 defaults: defaults
957 )
958
959 let mergedSettings = mergeAppSettings(existing: loadAppSettings(defaults: defaults), incoming: backup.appSettings)
960 saveAppSettings(mergedSettings, defaults: defaults)
961
962 let mergedFeatureMetadata = mergeFeatureMetadata(existing: loadFeatureMetadata(defaults: defaults), incoming: backup.featureMetadata)
963 saveFeatureMetadata(mergedFeatureMetadata, defaults: defaults)
964 }
965
966 private static func applyTrackedDomainsImport(
967 _ trackedDomains: [TrackedDomain],
968 mode: DataPortabilityImportMode,
969 defaults: UserDefaults
970 ) {
971 let importedDomains = deduplicatedTrackedDomains(trackedDomains)
972 let finalDomains: [TrackedDomain]
973 switch mode {
974 case .merge:
975 finalDomains = mergeTrackedDomains(existing: loadTrackedDomains(defaults: defaults), incoming: importedDomains).domains
976 case .replace:
977 finalDomains = importedDomains
978 }
979
980 saveTrackedDomains(finalDomains, defaults: defaults)
981 let sanitizedSettings = MonitoringStorage.sanitizeSettings(loadMonitoringSettings(defaults: defaults), trackedDomains: finalDomains)
982 saveMonitoringSettings(sanitizedSettings, defaults: defaults)
983 }
984
985 private static func applyWorkflowsImport(
986 _ workflows: [DomainWorkflow],
987 mode: DataPortabilityImportMode,
988 defaults: UserDefaults
989 ) {
990 let importedWorkflows = deduplicatedWorkflows(workflows)
991 let finalWorkflows: [DomainWorkflow]
992 switch mode {
993 case .merge:
994 finalWorkflows = mergeWorkflows(existing: loadWorkflows(defaults: defaults), incoming: importedWorkflows)
995 case .replace:
996 finalWorkflows = importedWorkflows
997 }
998 saveWorkflows(finalWorkflows, defaults: defaults)
999 }
1000
1001 private static func projectCounts(
1002 for payload: ImportPayload,
1003 mode: DataPortabilityImportMode,
1004 defaults: UserDefaults
1005 ) -> DataLifecycleSummary {
1006 let current = lifecycleSummary(defaults: defaults)
1007 switch payload {
1008 case .backup(let backup, _):
1009 if mode == .replace {
1010 return DataLifecycleSummary(
1011 trackedDomains: backup.trackedDomains.count,
1012 historySnapshots: backup.historyEntries.count,
1013 workflows: backup.workflows.count,
1014 cachedItems: backup.appSettings.recentSearches.count + backup.appSettings.savedDomains.count + ((backup.featureMetadata?.cachedEntitlement == nil ? 0 : 1) + (backup.featureMetadata?.usageCredits == nil ? 0 : 1)),
1015 monitoringLogs: backup.monitoringLogs.count
1016 )
1017 }
1018
1019 return DataLifecycleSummary(
1020 trackedDomains: mergeTrackedDomains(existing: loadTrackedDomains(defaults: defaults), incoming: backup.trackedDomains).domains.count,
1021 historySnapshots: mergeHistoryEntries(existing: loadHistoryEntries(defaults: defaults), incoming: backup.historyEntries).count,
1022 workflows: mergeWorkflows(existing: loadWorkflows(defaults: defaults), incoming: backup.workflows).count,
1023 cachedItems: max(current.cachedItems, backup.appSettings.recentSearches.count + backup.appSettings.savedDomains.count),
1024 monitoringLogs: mergeMonitoringLogs(existing: loadMonitoringLogs(defaults: defaults), incoming: backup.monitoringLogs).count
1025 )
1026 case .trackedDomains(let trackedDomains, _):
1027 let total = mode == .replace
1028 ? trackedDomains.count
1029 : mergeTrackedDomains(existing: loadTrackedDomains(defaults: defaults), incoming: trackedDomains).domains.count
1030 return DataLifecycleSummary(
1031 trackedDomains: total,
1032 historySnapshots: current.historySnapshots,
1033 workflows: current.workflows,
1034 cachedItems: current.cachedItems,
1035 monitoringLogs: current.monitoringLogs
1036 )
1037 case .workflows(let workflows, _):
1038 let total = mode == .replace
1039 ? workflows.count
1040 : mergeWorkflows(existing: loadWorkflows(defaults: defaults), incoming: workflows).count
1041 return DataLifecycleSummary(
1042 trackedDomains: current.trackedDomains,
1043 historySnapshots: current.historySnapshots,
1044 workflows: total,
1045 cachedItems: current.cachedItems,
1046 monitoringLogs: current.monitoringLogs
1047 )
1048 }
1049 }
1050
1051 private static func mergeTrackedDomains(existing: [TrackedDomain], incoming: [TrackedDomain]) -> MergedTrackedDomains {
1052 var mergedByDomain = Dictionary(uniqueKeysWithValues: existing.map { (normalizeDomain($0.domain), $0) })
1053 var idMap: [UUID: UUID] = [:]
1054
1055 for trackedDomain in incoming {
1056 let key = normalizeDomain(trackedDomain.domain)
1057 guard !key.isEmpty else { continue }
1058
1059 if let existingDomain = mergedByDomain[key] {
1060 let chosen = trackedDomain.updatedAt >= existingDomain.updatedAt
1061 ? mergeTrackedDomain(existing: existingDomain, incoming: trackedDomain)
1062 : mergeTrackedDomain(existing: trackedDomain, incoming: existingDomain)
1063 mergedByDomain[key] = chosen
1064 idMap[trackedDomain.id] = chosen.id
1065 } else {
1066 let normalized = normalizedTrackedDomain(trackedDomain)
1067 mergedByDomain[key] = normalized
1068 idMap[trackedDomain.id] = normalized.id
1069 }
1070 }
1071
1072 for trackedDomain in existing {
1073 idMap[trackedDomain.id] = mergedByDomain[normalizeDomain(trackedDomain.domain)]?.id ?? trackedDomain.id
1074 }
1075
1076 return MergedTrackedDomains(
1077 domains: mergedByDomain.values.sorted {
1078 if $0.updatedAt != $1.updatedAt {
1079 return $0.updatedAt > $1.updatedAt
1080 }
1081 return $0.domain.localizedCaseInsensitiveCompare($1.domain) == .orderedAscending
1082 },
1083 idMap: idMap
1084 )
1085 }
1086
1087 private static func mergeTrackedDomain(existing: TrackedDomain, incoming: TrackedDomain) -> TrackedDomain {
1088 let normalizedDomain = normalizeDomain(existing.domain.isEmpty ? incoming.domain : existing.domain)
1089 let winner = existing.updatedAt >= incoming.updatedAt ? existing : incoming
1090 let note = winner.note ?? existing.note ?? incoming.note
1091
1092 return TrackedDomain(
1093 id: existing.id,
1094 domain: normalizedDomain,
1095 createdAt: min(existing.createdAt, incoming.createdAt),
1096 updatedAt: max(existing.updatedAt, incoming.updatedAt),
1097 note: note,
1098 isPinned: existing.isPinned || incoming.isPinned,
1099 monitoringEnabled: existing.monitoringEnabled || incoming.monitoringEnabled,
1100 lastKnownAvailability: winner.lastKnownAvailability ?? existing.lastKnownAvailability ?? incoming.lastKnownAvailability,
1101 lastSnapshotID: winner.lastSnapshotID ?? existing.lastSnapshotID ?? incoming.lastSnapshotID,
1102 lastChangeSummary: winner.lastChangeSummary ?? existing.lastChangeSummary ?? incoming.lastChangeSummary,
1103 lastChangeSeverity: winner.lastChangeSeverity ?? existing.lastChangeSeverity ?? incoming.lastChangeSeverity,
1104 certificateWarningLevel: higherCertificateWarningLevel(existing.certificateWarningLevel, incoming.certificateWarningLevel),
1105 certificateDaysRemaining: winner.certificateDaysRemaining ?? existing.certificateDaysRemaining ?? incoming.certificateDaysRemaining,
1106 lastMonitoredAt: [existing.lastMonitoredAt, incoming.lastMonitoredAt].compactMap { $0 }.max(),
1107 lastAlertAt: [existing.lastAlertAt, incoming.lastAlertAt].compactMap { $0 }.max()
1108 )
1109 }
1110
1111 private static func mergeHistoryEntries(existing: [HistoryEntry], incoming: [HistoryEntry]) -> [HistoryEntry] {
1112 var mergedByKey: [String: HistoryEntry] = [:]
1113
1114 for entry in existing + incoming {
1115 let key = historyKey(for: entry)
1116 if let existingEntry = mergedByKey[key] {
1117 mergedByKey[key] = chooseBetterHistoryEntry(existingEntry, entry)
1118 } else {
1119 mergedByKey[key] = entry
1120 }
1121 }
1122
1123 return mergedByKey.values.sorted { lhs, rhs in
1124 if lhs.timestamp != rhs.timestamp {
1125 return lhs.timestamp > rhs.timestamp
1126 }
1127 return lhs.domain.localizedCaseInsensitiveCompare(rhs.domain) == .orderedAscending
1128 }
1129 }
1130
1131 private static func mergeWorkflows(existing: [DomainWorkflow], incoming: [DomainWorkflow]) -> [DomainWorkflow] {
1132 var merged = Dictionary(uniqueKeysWithValues: existing.map { ($0.id, normalizedWorkflow($0)) })
1133
1134 for workflow in incoming {
1135 let normalized = normalizedWorkflow(workflow)
1136 if let existingWorkflow = merged[workflow.id] {
1137 let winner = existingWorkflow.updatedAt >= normalized.updatedAt ? existingWorkflow : normalized
1138 merged[workflow.id] = DomainWorkflow(
1139 id: existingWorkflow.id,
1140 name: winner.name,
1141 domains: deduplicated(existingWorkflow.domains + normalized.domains),
1142 createdAt: min(existingWorkflow.createdAt, normalized.createdAt),
1143 updatedAt: max(existingWorkflow.updatedAt, normalized.updatedAt),
1144 notes: winner.notes ?? existingWorkflow.notes ?? normalized.notes
1145 )
1146 } else {
1147 merged[workflow.id] = normalized
1148 }
1149 }
1150
1151 return merged.values.sorted(by: workflowSort)
1152 }
1153
1154 private static func mergeMonitoringLogs(existing: [MonitoringLog], incoming: [MonitoringLog]) -> [MonitoringLog] {
1155 var merged: [UUID: MonitoringLog] = [:]
1156 for log in existing + incoming {
1157 if let current = merged[log.id] {
1158 merged[log.id] = log.timestamp >= current.timestamp ? log : current
1159 } else {
1160 merged[log.id] = log
1161 }
1162 }
1163 return merged.values.sorted { $0.timestamp > $1.timestamp }.prefix(MonitoringStorage.maxLogs).map { $0 }
1164 }
1165
1166 private static func mergeAppSettings(existing: AppSettingsSnapshot, incoming: AppSettingsSnapshot) -> AppSettingsSnapshot {
1167 AppSettingsSnapshot(
1168 recentSearches: deduplicated(incoming.recentSearches + existing.recentSearches).prefix(20).map { $0 },
1169 savedDomains: deduplicated(incoming.savedDomains + existing.savedDomains),
1170 resolverURLString: incoming.resolverURLString.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? existing.resolverURLString : incoming.resolverURLString,
1171 appDensityRawValue: incoming.appDensityRawValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? existing.appDensityRawValue : incoming.appDensityRawValue
1172 )
1173 }
1174
1175 private static func mergeFeatureMetadata(existing: FeatureMetadataSnapshot, incoming: FeatureMetadataSnapshot?) -> FeatureMetadataSnapshot {
1176 guard let incoming else { return existing }
1177
1178 let mergedUsageCredits: UsageCreditsSnapshot?
1179 switch (existing.usageCredits, incoming.usageCredits) {
1180 case let (.some(lhs), .some(rhs)):
1181 let mergedRemaining = Dictionary(uniqueKeysWithValues: UsageCreditFeature.allCases.map { feature in
1182 (feature, max(lhs.remainingByFeature[feature] ?? 0, rhs.remainingByFeature[feature] ?? 0))
1183 })
1184 mergedUsageCredits = UsageCreditsSnapshot(
1185 appVersion: rhs.appVersion,
1186 remainingByFeature: mergedRemaining
1187 )
1188 case (.some(let lhs), nil):
1189 mergedUsageCredits = lhs
1190 case (nil, .some(let rhs)):
1191 mergedUsageCredits = rhs
1192 case (nil, nil):
1193 mergedUsageCredits = nil
1194 }
1195
1196 return FeatureMetadataSnapshot(
1197 cachedEntitlement: incoming.cachedEntitlement ?? existing.cachedEntitlement,
1198 usageCredits: mergedUsageCredits
1199 )
1200 }
1201
1202 private static func remapMonitoringSettings(
1203 _ settings: MonitoringSettings,
1204 using idMap: [UUID: UUID]
1205 ) -> MonitoringSettings {
1206 var remapped = settings
1207 remapped.selectedDomainIDs = deduplicatedUUIDs(settings.selectedDomainIDs.compactMap { idMap[$0] ?? $0 })
1208 return remapped
1209 }
1210
1211 private static func remapHistoryEntry(_ entry: HistoryEntry, using idMap: [UUID: UUID]) -> HistoryEntry {
1212 var remapped = entry
1213 if let trackedDomainID = entry.trackedDomainID {
1214 remapped.trackedDomainID = idMap[trackedDomainID] ?? trackedDomainID
1215 }
1216 return remapped
1217 }
1218
1219 private static func chooseBetterHistoryEntry(_ lhs: HistoryEntry, _ rhs: HistoryEntry) -> HistoryEntry {
1220 if historyQualityScore(lhs) == historyQualityScore(rhs) {
1221 return lhs.timestamp >= rhs.timestamp ? lhs : rhs
1222 }
1223 return historyQualityScore(lhs) >= historyQualityScore(rhs) ? lhs : rhs
1224 }
1225
1226 private static func historyQualityScore(_ entry: HistoryEntry) -> Int {
1227 var score = 0
1228 score += entry.dnsSections.count
1229 score += entry.httpHeaders.count
1230 score += entry.reachabilityResults.count
1231 score += entry.redirectChain.count
1232 score += entry.subdomains.count
1233 score += entry.portScanResults.count
1234 score += entry.validationIssues.isEmpty ? 2 : 0
1235 score += entry.sslInfo == nil ? 0 : 4
1236 score += entry.ownership == nil ? 0 : 4
1237 score += entry.emailSecurity == nil ? 0 : 3
1238 score += entry.ipGeolocation == nil ? 0 : 2
1239 return score
1240 }
1241
1242 private static func historyKey(for entry: HistoryEntry) -> String {
1243 if entry.id != UUID() {
1244 return "id:\(entry.id.uuidString)"
1245 }
1246 return "domain:\(normalizeDomain(entry.domain))|timestamp:\(entry.timestamp.timeIntervalSince1970)"
1247 }
1248
1249 private nonisolated static func normalizeDomain(_ value: String) -> String {
1250 value
1251 .trimmingCharacters(in: .whitespacesAndNewlines)
1252 .replacingOccurrences(of: "https://", with: "")
1253 .replacingOccurrences(of: "http://", with: "")
1254 .components(separatedBy: "/")
1255 .first?
1256 .lowercased() ?? ""
1257 }
1258
1259 private static func normalizedTrackedDomain(_ trackedDomain: TrackedDomain) -> TrackedDomain {
1260 TrackedDomain(
1261 id: trackedDomain.id,
1262 domain: normalizeDomain(trackedDomain.domain),
1263 createdAt: trackedDomain.createdAt,
1264 updatedAt: trackedDomain.updatedAt,
1265 note: trackedDomain.note,
1266 isPinned: trackedDomain.isPinned,
1267 monitoringEnabled: trackedDomain.monitoringEnabled,
1268 lastKnownAvailability: trackedDomain.lastKnownAvailability,
1269 lastSnapshotID: trackedDomain.lastSnapshotID,
1270 lastChangeSummary: trackedDomain.lastChangeSummary,
1271 lastChangeSeverity: trackedDomain.lastChangeSeverity,
1272 certificateWarningLevel: trackedDomain.certificateWarningLevel,
1273 certificateDaysRemaining: trackedDomain.certificateDaysRemaining,
1274 lastMonitoredAt: trackedDomain.lastMonitoredAt,
1275 lastAlertAt: trackedDomain.lastAlertAt
1276 )
1277 }
1278
1279 private static func normalizedWorkflow(_ workflow: DomainWorkflow) -> DomainWorkflow {
1280 DomainWorkflow(
1281 id: workflow.id,
1282 name: workflow.name.trimmingCharacters(in: .whitespacesAndNewlines),
1283 domains: deduplicated(workflow.domains.map(Self.normalizeDomain).filter { !$0.isEmpty }),
1284 createdAt: workflow.createdAt,
1285 updatedAt: workflow.updatedAt,
1286 notes: workflow.notes?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty
1287 )
1288 }
1289
1290 private static func deduplicatedTrackedDomains(_ trackedDomains: [TrackedDomain]) -> [TrackedDomain] {
1291 mergeTrackedDomains(existing: [], incoming: trackedDomains).domains
1292 }
1293
1294 private static func deduplicatedHistoryEntries(_ historyEntries: [HistoryEntry]) -> [HistoryEntry] {
1295 mergeHistoryEntries(existing: [], incoming: historyEntries)
1296 }
1297
1298 private static func deduplicatedWorkflows(_ workflows: [DomainWorkflow]) -> [DomainWorkflow] {
1299 mergeWorkflows(existing: [], incoming: workflows)
1300 }
1301
1302 private nonisolated static func deduplicated(_ values: [String]) -> [String] {
1303 var seen = Set<String>()
1304 return values.filter { seen.insert($0).inserted }
1305 }
1306
1307 private nonisolated static func deduplicatedUUIDs(_ values: [UUID]) -> [UUID] {
1308 var seen = Set<UUID>()
1309 return values.filter { seen.insert($0).inserted }
1310 }
1311
1312 private nonisolated static func workflowSort(lhs: DomainWorkflow, rhs: DomainWorkflow) -> Bool {
1313 if lhs.updatedAt != rhs.updatedAt {
1314 return lhs.updatedAt > rhs.updatedAt
1315 }
1316 return lhs.name.localizedCaseInsensitiveCompare(rhs.name) == .orderedAscending
1317 }
1318
1319 private nonisolated static func higherCertificateWarningLevel(
1320 _ lhs: CertificateWarningLevel,
1321 _ rhs: CertificateWarningLevel
1322 ) -> CertificateWarningLevel {
1323 let rank: [CertificateWarningLevel: Int] = [
1324 .none: 0,
1325 .warning: 1,
1326 .critical: 2
1327 ]
1328 return (rank[lhs] ?? 0) >= (rank[rhs] ?? 0) ? lhs : rhs
1329 }
1330
1331 private static func makeEncoder() -> JSONEncoder {
1332 let encoder = JSONEncoder()
1333 encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
1334 return encoder
1335 }
1336}
1337
1338private struct MergedTrackedDomains {
1339 let domains: [TrackedDomain]
1340 let idMap: [UUID: UUID]
1341}
1342
1343private enum ImportPayload {
1344 case backup(DomainDigBackup, DataValidationReport)
1345 case trackedDomains([TrackedDomain], DataValidationReport)
1346 case workflows([DomainWorkflow], DataValidationReport)
1347
1348 var kind: DataPortabilityImportKind {
1349 switch self {
1350 case .backup:
1351 return .backup
1352 case .trackedDomains:
1353 return .trackedDomains
1354 case .workflows:
1355 return .workflows
1356 }
1357 }
1358
1359 var validationReport: DataValidationReport {
1360 switch self {
1361 case .backup(_, let report), .trackedDomains(_, let report), .workflows(_, let report):
1362 return report
1363 }
1364 }
1365
1366 func summaryLines(mode: DataPortabilityImportMode) -> [String] {
1367 switch self {
1368 case .backup(let backup, _):
1369 return [
1370 "Full backup import",
1371 "\(backup.trackedDomains.count) tracked domains",
1372 "\(backup.historyEntries.count) history snapshots",
1373 "\(backup.workflows.count) workflows",
1374 "\(backup.monitoringLogs.count) monitoring logs",
1375 mode == .replace ? "Replace mode will overwrite local backupable data." : "Merge mode will keep local data and consolidate duplicates."
1376 ]
1377 case .trackedDomains(let trackedDomains, _):
1378 return [
1379 "Tracked domains import",
1380 "\(trackedDomains.count) tracked domains",
1381 mode == .replace ? "Replace mode will overwrite the watchlist." : "Merge mode will consolidate duplicates by normalized domain."
1382 ]
1383 case .workflows(let workflows, _):
1384 return [
1385 "Workflow import",
1386 "\(workflows.count) workflows",
1387 mode == .replace ? "Replace mode will overwrite saved workflows." : "Merge mode will merge matching workflow IDs and deduplicate domains."
1388 ]
1389 }
1390 }
1391}
1392
1393private extension String {
1394 var nilIfEmpty: String? {
1395 isEmpty ? nil : self
1396 }
1397}
DomainDig.xcodeproj/project.pbxproj +12 −4
@@ -16,6 +16,8 @@
1616 8BBFEFCB2F987E8700E8E144 /* DomainReportBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BBFEF042F9874AE00E8E144 /* DomainReportBuilder.swift */; };
1717 8BBFEFCC2F987E8700E8E144 /* DomainReportExporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BBFEF052F9874AE00E8E144 /* DomainReportExporter.swift */; };
1818 8BBFEFCD2F987E8700E8E144 /* LookupSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BBFEF062F9874AE00E8E144 /* LookupSnapshot.swift */; };
19 8BF9DA862F9B13FB00EF41D5 /* DomainDataPortabilityService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BF9DA842F9B13FB00EF41D5 /* DomainDataPortabilityService.swift */; };
20 8BF9DA872F9B13FB00EF41D5 /* DomainDataPortabilityService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BF9DA842F9B13FB00EF41D5 /* DomainDataPortabilityService.swift */; };
1921/* End PBXBuildFile section */
2022
2123/* Begin PBXFileReference section */
@@ -26,6 +28,7 @@
2628 8BBFEF052F9874AE00E8E144 /* DomainReportExporter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DomainReportExporter.swift; sourceTree = "<group>"; };
2729 8BBFEF062F9874AE00E8E144 /* LookupSnapshot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LookupSnapshot.swift; sourceTree = "<group>"; };
2830 8BF124F92F70000100933221 /* domaindig */ = {isa = PBXFileReference; explicitFileType = "compiled.mach-o.executable"; includeInIndex = 0; path = domaindig; sourceTree = BUILT_PRODUCTS_DIR; };
31 8BF9DA842F9B13FB00EF41D5 /* DomainDataPortabilityService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DomainDataPortabilityService.swift; sourceTree = "<group>"; };
2932/* End PBXFileReference section */
3033
3134/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
@@ -95,6 +98,7 @@
9598 8BBFEF032F9874AE00E8E144 /* DomainInspectionService.swift */,
9699 8BBFEF052F9874AE00E8E144 /* DomainReportExporter.swift */,
97100 8BBFEF022F9874AE00E8E144 /* DomainDigCLI.swift */,
101 8BF9DA842F9B13FB00EF41D5 /* DomainDataPortabilityService.swift */,
98102 );
99103 sourceTree = "<group>";
100104 };
@@ -206,6 +210,7 @@
206210 8BBFEF092F9874AE00E8E144 /* DomainInspectionService.swift in Sources */,
207211 8BBFEF0A2F9874AE00E8E144 /* DomainReportBuilder.swift in Sources */,
208212 8BBFEF0B2F9874AE00E8E144 /* DomainReportExporter.swift in Sources */,
213 8BF9DA872F9B13FB00EF41D5 /* DomainDataPortabilityService.swift in Sources */,
209214 8BBFEF0C2F9874AE00E8E144 /* LookupSnapshot.swift in Sources */,
210215 );
211216 runOnlyForDeploymentPostprocessing = 0;
@@ -215,6 +220,7 @@
215220 buildActionMask = 2147483647;
216221 files = (
217222 8BBFEF082F9874AE00E8E144 /* DomainDigCLI.swift in Sources */,
223 8BF9DA862F9B13FB00EF41D5 /* DomainDataPortabilityService.swift in Sources */,
218224 8BBFEFCA2F987E8700E8E144 /* DomainInspectionService.swift in Sources */,
219225 8BBFEFCB2F987E8700E8E144 /* DomainReportBuilder.swift in Sources */,
220226 8BBFEFCC2F987E8700E8E144 /* DomainReportExporter.swift in Sources */,
@@ -354,7 +360,7 @@
354360 ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
355361 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
356362 CODE_SIGN_STYLE = Automatic;
357 CURRENT_PROJECT_VERSION = 22;
363 CURRENT_PROJECT_VERSION = 26;
358364 DEVELOPMENT_TEAM = ZCNAX3VL9D;
359365 ENABLE_PREVIEWS = YES;
360366 GENERATE_INFOPLIST_FILE = YES;
@@ -371,7 +377,7 @@
371377 "$(inherited)",
372378 "@executable_path/Frameworks",
373379 );
374 MARKETING_VERSION = 2.9.0;
380 MARKETING_VERSION = 3.4.0;
375381 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig;
376382 PRODUCT_NAME = "$(TARGET_NAME)";
377383 STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -390,7 +396,7 @@
390396 ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
391397 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
392398 CODE_SIGN_STYLE = Automatic;
393 CURRENT_PROJECT_VERSION = 22;
399 CURRENT_PROJECT_VERSION = 26;
394400 DEVELOPMENT_TEAM = ZCNAX3VL9D;
395401 ENABLE_PREVIEWS = YES;
396402 GENERATE_INFOPLIST_FILE = YES;
@@ -407,7 +413,7 @@
407413 "$(inherited)",
408414 "@executable_path/Frameworks",
409415 );
410 MARKETING_VERSION = 2.9.0;
416 MARKETING_VERSION = 3.4.0;
411417 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig;
412418 PRODUCT_NAME = "$(TARGET_NAME)";
413419 STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -426,6 +432,7 @@
426432 COPY_PHASE_STRIP = NO;
427433 GCC_DYNAMIC_NO_PIC = NO;
428434 GCC_OPTIMIZATION_LEVEL = 0;
435 IPHONEOS_DEPLOYMENT_TARGET = 17.6;
429436 PRODUCT_NAME = domaindig;
430437 };
431438 name = Debug;
@@ -435,6 +442,7 @@
435442 buildSettings = {
436443 COPY_PHASE_STRIP = YES;
437444 DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
445 IPHONEOS_DEPLOYMENT_TARGET = 17.6;
438446 PRODUCT_NAME = domaindig;
439447 };
440448 name = Release;
DomainDig/AppVersion.swift +1 −1
@@ -2,6 +2,6 @@ import Foundation
22
33enum AppVersion {
44 nonisolated static var current: String {
5 "3.2.0"
5 "3.4.0"
66 }
77}
DomainDig/ContentView.swift +376 −1
@@ -1,5 +1,6 @@
11import MapKit
22import SwiftUI
3import UniformTypeIdentifiers
34
45enum LookupInputMode: String, CaseIterable, Identifiable {
56 case single
@@ -2431,6 +2432,13 @@ struct SettingsView: View {
24312432 @State private var showClearCacheConfirmation = false
24322433 @State private var showClearWorkflowsConfirmation = false
24332434 @State private var showClearTrackedDomainsConfirmation = false
2435 @State private var importMode: DataPortabilityImportMode = .merge
2436 @State private var showBackupImporter = false
2437 @State private var showTrackedDomainsImporter = false
2438 @State private var showWorkflowsImporter = false
2439 @State private var pendingImportPreview: DataImportPreview?
2440 @State private var pendingImportError: String?
2441 @State private var showReplaceImportConfirmation = false
24342442
24352443 private var customResolverError: String? {
24362444 guard resolverOption == .custom else {
@@ -2472,6 +2480,161 @@ struct SettingsView: View {
24722480 }
24732481 }
24742482
2483 Section("Monitoring") {
2484 Toggle(
2485 "Enable Background Monitoring",
2486 isOn: Binding(
2487 get: { viewModel.monitoringSettings.isEnabled },
2488 set: { viewModel.setMonitoringEnabled($0) }
2489 )
2490 )
2491
2492 Picker(
2493 "Frequency",
2494 selection: Binding(
2495 get: { viewModel.monitoringSettings.frequency },
2496 set: { viewModel.setMonitoringFrequency($0) }
2497 )
2498 ) {
2499 ForEach(MonitoringFrequency.allCases) { frequency in
2500 Text(frequency.title).tag(frequency)
2501 }
2502 }
2503
2504 Picker(
2505 "Domains",
2506 selection: Binding(
2507 get: { viewModel.monitoringSettings.scope },
2508 set: { viewModel.setMonitoringScope($0) }
2509 )
2510 ) {
2511 ForEach(MonitoringScope.allCases) { scope in
2512 Text(scope.title).tag(scope)
2513 }
2514 }
2515
2516 if viewModel.monitoringSettings.scope == .selectedOnly {
2517 ForEach(viewModel.trackedDomains) { trackedDomain in
2518 Toggle(
2519 trackedDomain.domain,
2520 isOn: Binding(
2521 get: { viewModel.monitoringSettings.selectedDomainIDs.contains(trackedDomain.id) },
2522 set: { viewModel.setMonitoringSelection(for: trackedDomain, isSelected: $0) }
2523 )
2524 )
2525 }
2526 }
2527
2528 Toggle(
2529 "Local Alerts",
2530 isOn: Binding(
2531 get: { viewModel.monitoringSettings.alertsEnabled },
2532 set: { isEnabled in
2533 if isEnabled {
2534 Task {
2535 await viewModel.requestMonitoringNotificationAuthorization()
2536 }
2537 } else {
2538 viewModel.setMonitoringAlertsEnabled(false)
2539 }
2540 }
2541 )
2542 )
2543
2544 Picker(
2545 "Notify For",
2546 selection: Binding(
2547 get: { viewModel.monitoringSettings.alertFilter },
2548 set: { viewModel.setMonitoringAlertFilter($0) }
2549 )
2550 ) {
2551 ForEach(MonitoringAlertFilter.allCases) { filter in
2552 Text(filter.title).tag(filter)
2553 }
2554 }
2555
2556 LabeledContent("Background Refresh", value: DomainMonitoringScheduler.shared.backgroundRefreshStatusDescription())
2557 LabeledContent("Notification Access", value: notificationAuthorizationLabel)
2558
2559 if let monitoringStatusMessage = viewModel.monitoringStatusMessage {
2560 Text(monitoringStatusMessage)
2561 .font(appDensity.font(.caption, design: .default))
2562 .foregroundStyle(.secondary)
2563 }
2564
2565 if !FeatureAccessService.hasAccess(to: .automatedMonitoring) {
2566 Text("Background monitoring and alerts are available in Pro.")
2567 .font(appDensity.font(.caption, design: .default))
2568 .foregroundStyle(.secondary)
2569 }
2570 }
2571
2572 Section("Data Portability") {
2573 Picker("Import Mode", selection: $importMode) {
2574 ForEach(DataPortabilityImportMode.allCases) { mode in
2575 Text(mode.title).tag(mode)
2576 }
2577 }
2578
2579 Text(importMode.explanation)
2580 .font(appDensity.font(.caption, design: .default))
2581 .foregroundStyle(.secondary)
2582
2583 Button("Export Full Backup") {
2584 exportFullBackup()
2585 }
2586
2587 Button("Import Backup") {
2588 showBackupImporter = true
2589 }
2590
2591 Menu("Export Tracked Domains") {
2592 Button("JSON") {
2593 exportPortableTrackedDomainsJSON()
2594 }
2595 Button("CSV") {
2596 exportPortableTrackedDomainsCSV()
2597 }
2598 }
2599
2600 Button("Import Tracked Domains") {
2601 showTrackedDomainsImporter = true
2602 }
2603
2604 Menu("Export Workflows") {
2605 Button("JSON") {
2606 exportPortableWorkflowsJSON()
2607 }
2608 Button("CSV") {
2609 exportPortableWorkflowsCSV()
2610 }
2611 }
2612
2613 Button("Import Workflows") {
2614 showWorkflowsImporter = true
2615 }
2616
2617 Button("Export History") {
2618 exportPortableHistoryJSON()
2619 }
2620
2621 LabeledContent("Tracked Domains", value: "\(viewModel.dataLifecycleSummary.trackedDomains)")
2622 LabeledContent("History Snapshots", value: "\(viewModel.dataLifecycleSummary.historySnapshots)")
2623 LabeledContent("Workflows", value: "\(viewModel.dataLifecycleSummary.workflows)")
2624 LabeledContent("Cached Items", value: "\(viewModel.dataLifecycleSummary.cachedItems)")
2625 LabeledContent("Monitoring Logs", value: "\(viewModel.dataLifecycleSummary.monitoringLogs)")
2626
2627 Text("Data stays on this device unless you export it. Backup files can include domain history, monitoring settings, and notes. Imported files are processed on-device.")
2628 .font(appDensity.font(.caption, design: .default))
2629 .foregroundStyle(.secondary)
2630
2631 if let portabilityStatusMessage = viewModel.portabilityStatusMessage {
2632 Text(portabilityStatusMessage)
2633 .font(appDensity.font(.caption, design: .default))
2634 .foregroundStyle(.secondary)
2635 }
2636 }
2637
24752638 Section("Data") {
24762639 Button("Clear History", role: .destructive) {
24772640 showClearHistoryConfirmation = true
@@ -2551,7 +2714,7 @@ struct SettingsView: View {
25512714 Section("About") {
25522715 LabeledContent("Version", value: appVersion)
25532716 LabeledContent("Storage", value: "Local-only")
2554 LabeledContent("Report Schema", value: "3.2.0")
2717 LabeledContent("Backup Schema", value: "v\(DomainDigBackup.currentSchemaVersion)")
25552718 }
25562719 }
25572720 .navigationTitle("Settings")
@@ -2587,12 +2750,73 @@ struct SettingsView: View {
25872750 } message: {
25882751 Text("This removes the watchlist only. History and workflows stay intact.")
25892752 }
2753 .alert("Replace local data?", isPresented: $showReplaceImportConfirmation) {
2754 Button("Replace", role: .destructive) {
2755 applyPendingImport()
2756 }
2757 Button("Cancel", role: .cancel) {}
2758 } message: {
2759 Text("Replace mode overwrites local data covered by the imported file and may remove items that are only on this device.")
2760 }
2761 .alert("Import Error", isPresented: Binding(
2762 get: { pendingImportError != nil },
2763 set: { if !$0 { pendingImportError = nil } }
2764 )) {
2765 Button("OK", role: .cancel) {}
2766 } message: {
2767 Text(pendingImportError ?? "The import could not be completed.")
2768 }
2769 .sheet(isPresented: Binding(
2770 get: { pendingImportPreview != nil },
2771 set: { if !$0 { pendingImportPreview = nil } }
2772 )) {
2773 if let pendingImportPreview {
2774 DataImportPreviewSheet(
2775 preview: pendingImportPreview,
2776 mode: importMode,
2777 onCancel: {
2778 self.pendingImportPreview = nil
2779 },
2780 onApply: {
2781 if importMode == .replace {
2782 showReplaceImportConfirmation = true
2783 } else {
2784 applyPendingImport()
2785 }
2786 }
2787 )
2788 }
2789 }
2790 .fileImporter(
2791 isPresented: $showBackupImporter,
2792 allowedContentTypes: [UTType.json],
2793 allowsMultipleSelection: false
2794 ) { result in
2795 handleImportResult(result, expectedKind: .backup)
2796 }
2797 .fileImporter(
2798 isPresented: $showTrackedDomainsImporter,
2799 allowedContentTypes: [UTType.json, UTType.commaSeparatedText],
2800 allowsMultipleSelection: false
2801 ) { result in
2802 handleImportResult(result, expectedKind: .trackedDomains)
2803 }
2804 .fileImporter(
2805 isPresented: $showWorkflowsImporter,
2806 allowedContentTypes: [UTType.json, UTType.commaSeparatedText],
2807 allowsMultipleSelection: false
2808 ) { result in
2809 handleImportResult(result, expectedKind: .workflows)
2810 }
25902811 .onAppear {
25912812 let currentResolverURL = storedResolverURL.trimmingCharacters(in: .whitespacesAndNewlines)
25922813 resolverOption = DNSResolverOption.option(for: currentResolverURL)
25932814 customResolverURL = resolverOption == .custom ? currentResolverURL : DNSResolverOption.defaultURLString
2815 viewModel.refreshMonitoringState()
2816 viewModel.refreshDataLifecycleSummary()
25942817 Task {
25952818 await viewModel.refreshUsageCredits()
2819 await viewModel.refreshMonitoringAuthorizationStatus()
25962820 }
25972821 }
25982822 .onChange(of: resolverOption) { _, newValue in
@@ -2611,6 +2835,157 @@ struct SettingsView: View {
26112835 private var appVersion: String {
26122836 AppVersion.current
26132837 }
2838
2839 private var notificationAuthorizationLabel: String {
2840 switch viewModel.monitoringNotificationStatus {
2841 case .authorized, .provisional, .ephemeral:
2842 return "Allowed"
2843 case .denied:
2844 return "Denied"
2845 case .notDetermined:
2846 return "Not Requested"
2847 @unknown default:
2848 return "Unknown"
2849 }
2850 }
2851
2852 private func exportFullBackup() {
2853 guard let data = viewModel.exportFullBackupData() else { return }
2854 ExportPresenter.share(filename: portabilityFilename(suffix: "backup", fileExtension: "json"), data: data)
2855 }
2856
2857 private func exportPortableTrackedDomainsJSON() {
2858 guard let data = viewModel.exportPortableTrackedDomainsJSONData() else { return }
2859 ExportPresenter.share(filename: portabilityFilename(suffix: "tracked_domains", fileExtension: "json"), data: data)
2860 }
2861
2862 private func exportPortableTrackedDomainsCSV() {
2863 ExportPresenter.share(
2864 filename: portabilityFilename(suffix: "tracked_domains", fileExtension: "csv"),
2865 contents: viewModel.exportPortableTrackedDomainsCSV()
2866 )
2867 }
2868
2869 private func exportPortableWorkflowsJSON() {
2870 guard let data = viewModel.exportPortableWorkflowsJSONData() else { return }
2871 ExportPresenter.share(filename: portabilityFilename(suffix: "workflows", fileExtension: "json"), data: data)
2872 }
2873
2874 private func exportPortableWorkflowsCSV() {
2875 ExportPresenter.share(
2876 filename: portabilityFilename(suffix: "workflows", fileExtension: "csv"),
2877 contents: viewModel.exportPortableWorkflowsCSV()
2878 )
2879 }
2880
2881 private func exportPortableHistoryJSON() {
2882 guard let data = viewModel.exportPortableHistoryJSONData() else { return }
2883 ExportPresenter.share(filename: portabilityFilename(suffix: "history", fileExtension: "json"), data: data)
2884 }
2885
2886 private func handleImportResult(
2887 _ result: Result<[URL], Error>,
2888 expectedKind: DataPortabilityImportKind
2889 ) {
2890 do {
2891 guard let url = try result.get().first else { return }
2892 let shouldStopAccessing = url.startAccessingSecurityScopedResource()
2893 defer {
2894 if shouldStopAccessing {
2895 url.stopAccessingSecurityScopedResource()
2896 }
2897 }
2898
2899 let data = try Data(contentsOf: url)
2900 let preview = try viewModel.prepareDataImport(
2901 data: data,
2902 fileName: url.lastPathComponent,
2903 mode: importMode
2904 )
2905
2906 guard preview.kind == expectedKind else {
2907 pendingImportError = preview.kind == .backup
2908 ? "That file is a full backup. Use Import Backup."
2909 : "That file type does not match this import action."
2910 return
2911 }
2912
2913 pendingImportPreview = preview
2914 } catch {
2915 pendingImportError = error.localizedDescription
2916 }
2917 }
2918
2919 private func applyPendingImport() {
2920 guard let pendingImportPreview else { return }
2921 do {
2922 _ = try viewModel.applyDataImport(pendingImportPreview, mode: importMode)
2923 self.pendingImportPreview = nil
2924 } catch {
2925 pendingImportError = error.localizedDescription
2926 }
2927 }
2928
2929 private func portabilityFilename(suffix: String, fileExtension: String) -> String {
2930 let formatter = DateFormatter()
2931 formatter.dateFormat = "yyyyMMdd_HHmmss"
2932 return "\(formatter.string(from: Date()))_domaindig_\(suffix).\(fileExtension)"
2933 }
2934}
2935
2936private struct DataImportPreviewSheet: View {
2937 @Environment(\.dismiss) private var dismiss
2938
2939 let preview: DataImportPreview
2940 let mode: DataPortabilityImportMode
2941 let onCancel: () -> Void
2942 let onApply: () -> Void
2943
2944 var body: some View {
2945 NavigationStack {
2946 List {
2947 Section("Summary") {
2948 ForEach(preview.summaryLines, id: \.self) { line in
2949 Text(line)
2950 }
2951 }
2952
2953 Section("Projected Counts") {
2954 LabeledContent("Tracked Domains", value: "\(preview.projectedCounts.trackedDomains)")
2955 LabeledContent("History Snapshots", value: "\(preview.projectedCounts.historySnapshots)")
2956 LabeledContent("Workflows", value: "\(preview.projectedCounts.workflows)")
2957 LabeledContent("Cached Items", value: "\(preview.projectedCounts.cachedItems)")
2958 LabeledContent("Monitoring Logs", value: "\(preview.projectedCounts.monitoringLogs)")
2959 }
2960
2961 if !preview.warnings.isEmpty {
2962 Section("Warnings") {
2963 ForEach(preview.warnings, id: \.self) { warning in
2964 Text(warning)
2965 .foregroundStyle(.secondary)
2966 }
2967 }
2968 }
2969 }
2970 .navigationTitle("Import Preview")
2971 .toolbar {
2972 ToolbarItem(placement: .cancellationAction) {
2973 Button("Cancel") {
2974 onCancel()
2975 dismiss()
2976 }
2977 }
2978 ToolbarItem(placement: .confirmationAction) {
2979 Button(mode == .replace ? "Replace" : "Import") {
2980 onApply()
2981 if mode == .merge {
2982 dismiss()
2983 }
2984 }
2985 }
2986 }
2987 }
2988 }
26142989}
26152990
26162991#Preview {
DomainDig/DomainDigApp.swift +13
@@ -9,12 +9,14 @@ import SwiftUI
99
1010@main
1111struct DomainDigApp: App {
12 @Environment(\.scenePhase) private var scenePhase
1213 @AppStorage(AppDensity.userDefaultsKey) private var density = AppDensity.compact.rawValue
1314 @State private var viewModel = DomainViewModel()
1415 @State private var purchaseService = PurchaseService.shared
1516
1617 init() {
1718 LocalNotificationService.shared.configureForegroundPresentation()
19 DomainMonitoringScheduler.shared.registerBackgroundTask()
1820 }
1921
2022 var body: some Scene {
@@ -24,7 +26,18 @@ struct DomainDigApp: App {
2426 .task {
2527 let _ = purchaseService.currentTier
2628 await purchaseService.refreshEntitlements()
29 viewModel.refreshMonitoringState()
30 await viewModel.refreshMonitoringAuthorizationStatus()
31 viewModel.monitoringStatusMessage = DomainMonitoringScheduler.shared.syncSchedule()
2732 }
2833 }
34 .onChange(of: scenePhase) { _, newValue in
35 guard newValue == .active else { return }
36 viewModel.refreshMonitoringState()
37 Task {
38 await viewModel.refreshMonitoringAuthorizationStatus()
39 }
40 viewModel.monitoringStatusMessage = DomainMonitoringScheduler.shared.syncSchedule()
41 }
2942 }
3043}
DomainDig/DomainMonitoringService.swift added +613
@@ -0,0 +1,613 @@
1import Foundation
2import UserNotifications
3
4#if canImport(BackgroundTasks)
5import BackgroundTasks
6#endif
7
8#if canImport(UIKit)
9import UIKit
10#endif
11
12enum MonitoringStorage {
13 static let settingsKey = "monitoring.settings"
14 static let logsKey = "monitoring.logs"
15 static let trackedDomainsKey = "trackedDomains"
16 static let historyKey = "lookupHistory"
17 static let maxLogs = 40
18
19 static func loadSettings() -> MonitoringSettings {
20 DataMigrationService.migrateIfNeeded()
21 return DomainDataPortabilityService.loadMonitoringSettings()
22 }
23
24 static func saveSettings(_ settings: MonitoringSettings) {
25 DomainDataPortabilityService.saveMonitoringSettings(settings)
26 }
27
28 static func loadLogs() -> [MonitoringLog] {
29 DataMigrationService.migrateIfNeeded()
30 return DomainDataPortabilityService.loadMonitoringLogs()
31 }
32
33 static func saveLogs(_ logs: [MonitoringLog]) {
34 DomainDataPortabilityService.saveMonitoringLogs(Array(logs.prefix(maxLogs)))
35 }
36
37 static func loadTrackedDomains() -> [TrackedDomain] {
38 DataMigrationService.migrateIfNeeded()
39 return DomainDataPortabilityService.loadTrackedDomains()
40 }
41
42 static func saveTrackedDomains(_ domains: [TrackedDomain]) {
43 DomainDataPortabilityService.saveTrackedDomains(domains)
44 }
45
46 static func loadHistoryEntries() -> [HistoryEntry] {
47 DataMigrationService.migrateIfNeeded()
48 return DomainDataPortabilityService.loadHistoryEntries()
49 }
50
51 static func saveHistoryEntries(_ entries: [HistoryEntry]) {
52 DomainDataPortabilityService.saveHistoryEntries(entries)
53 }
54
55 static func sanitizeSettings(_ settings: MonitoringSettings, trackedDomains: [TrackedDomain]) -> MonitoringSettings {
56 let validIDs = Set(trackedDomains.map(\.id))
57 var sanitized = settings
58 sanitized.selectedDomainIDs = sanitized.selectedDomainIDs.filter { validIDs.contains($0) }
59 return sanitized
60 }
61
62 static func monitoredDomains(settings: MonitoringSettings, trackedDomains: [TrackedDomain]) -> [TrackedDomain] {
63 let sanitized = sanitizeSettings(settings, trackedDomains: trackedDomains)
64 switch sanitized.scope {
65 case .allTracked:
66 return trackedDomains.filter(\.monitoringEnabled)
67 case .selectedOnly:
68 let selected = Set(sanitized.selectedDomainIDs)
69 return trackedDomains.filter { selected.contains($0.id) && $0.monitoringEnabled }
70 }
71 }
72}
73
74struct MonitoringRunOutcome {
75 let success: Bool
76 let message: String
77 let log: MonitoringLog?
78}
79
80@MainActor
81final class DomainMonitoringScheduler {
82 static let shared = DomainMonitoringScheduler()
83 static let taskIdentifier = "net.cleberg.DomainDig.monitor.refresh"
84
85 private var isRegistered = false
86
87 private init() {}
88
89 func registerBackgroundTask() {
90 #if canImport(BackgroundTasks)
91 guard !isRegistered else { return }
92 isRegistered = BGTaskScheduler.shared.register(forTaskWithIdentifier: Self.taskIdentifier, using: nil) { task in
93 guard let refreshTask = task as? BGAppRefreshTask else {
94 task.setTaskCompleted(success: false)
95 return
96 }
97 self.handleAppRefresh(task: refreshTask)
98 }
99 #endif
100 }
101
102 func backgroundRefreshStatusDescription() -> String {
103 #if canImport(UIKit)
104 switch UIApplication.shared.backgroundRefreshStatus {
105 case .available:
106 return "Available"
107 case .denied:
108 return "Disabled in Settings"
109 case .restricted:
110 return "Restricted by the system"
111 @unknown default:
112 return "Unknown"
113 }
114 #else
115 return "Unavailable on this platform"
116 #endif
117 }
118
119 @discardableResult
120 func syncSchedule() -> String? {
121 #if canImport(BackgroundTasks)
122 let settings = MonitoringStorage.loadSettings()
123 guard settings.isEnabled, FeatureAccessService.hasAccess(to: .automatedMonitoring) else {
124 BGTaskScheduler.shared.cancel(taskRequestWithIdentifier: Self.taskIdentifier)
125 return nil
126 }
127
128 #if canImport(UIKit)
129 guard UIApplication.shared.backgroundRefreshStatus == .available else {
130 BGTaskScheduler.shared.cancel(taskRequestWithIdentifier: Self.taskIdentifier)
131 return "Background refresh is unavailable."
132 }
133 #endif
134
135 let request = BGAppRefreshTaskRequest(identifier: Self.taskIdentifier)
136 request.earliestBeginDate = Date(
137 timeIntervalSinceNow: max(settings.frequency.schedulingInterval, 15 * 60)
138 )
139
140 do {
141 BGTaskScheduler.shared.cancel(taskRequestWithIdentifier: Self.taskIdentifier)
142 try BGTaskScheduler.shared.submit(request)
143 return nil
144 } catch {
145 return "Could not schedule monitoring."
146 }
147 #else
148 return "Background monitoring is unavailable on this platform."
149 #endif
150 }
151
152#if canImport(BackgroundTasks)
153 private func handleAppRefresh(task: BGAppRefreshTask) {
154 _ = syncSchedule()
155
156 let worker = Task {
157 let outcome = await DomainMonitoringService.shared.performMonitoring(
158 trigger: .background,
159 requireEnabledSetting: true
160 )
161 task.setTaskCompleted(success: outcome.success)
162 }
163
164 task.expirationHandler = {
165 worker.cancel()
166 }
167 }
168#endif
169}
170
171@MainActor
172final class DomainMonitoringService {
173 static let shared = DomainMonitoringService()
174
175 private let inspectionService = DomainInspectionService()
176 private let maxHistoryEntries = 250
177
178 func performMonitoring(
179 trigger: MonitoringRunTrigger,
180 requireEnabledSetting: Bool
181 ) async -> MonitoringRunOutcome {
182 let trackedDomains = MonitoringStorage.loadTrackedDomains()
183 var settings = MonitoringStorage.sanitizeSettings(MonitoringStorage.loadSettings(), trackedDomains: trackedDomains)
184 MonitoringStorage.saveSettings(settings)
185
186 guard FeatureAccessService.hasAccess(to: .automatedMonitoring) else {
187 if settings.isEnabled {
188 settings.isEnabled = false
189 MonitoringStorage.saveSettings(settings)
190 await MainActor.run {
191 _ = DomainMonitoringScheduler.shared.syncSchedule()
192 }
193 }
194 return MonitoringRunOutcome(success: false, message: "Monitoring requires Pro.", log: nil)
195 }
196
197 if requireEnabledSetting, !settings.isEnabled {
198 return MonitoringRunOutcome(success: false, message: "Monitoring is disabled.", log: nil)
199 }
200
201 var history = MonitoringStorage.loadHistoryEntries()
202 var mutableTrackedDomains = trackedDomains
203 let eligibleDomains = MonitoringStorage.monitoredDomains(settings: settings, trackedDomains: mutableTrackedDomains)
204
205 guard !eligibleDomains.isEmpty else {
206 let log = MonitoringLog(
207 timestamp: Date(),
208 trigger: trigger,
209 domainsChecked: 0,
210 changesFound: 0,
211 alertsTriggered: 0,
212 checkedDomains: [],
213 errors: ["No tracked domains are configured for monitoring."]
214 )
215 saveLog(log)
216 return MonitoringRunOutcome(success: false, message: "No domains selected for monitoring.", log: log)
217 }
218
219 let notificationsAuthorized: Bool
220 if settings.alertsEnabled {
221 notificationsAuthorized = await LocalNotificationService.shared.isAuthorizedForAlerts()
222 } else {
223 notificationsAuthorized = false
224 }
225 var results: [MonitoringDomainResult] = []
226 var errors: [String] = []
227 var alertsTriggered = 0
228
229 for trackedDomain in eligibleDomains {
230 guard !Task.isCancelled else {
231 return MonitoringRunOutcome(success: false, message: "Monitoring cancelled.", log: nil)
232 }
233
234 let previousSnapshot = latestSnapshot(for: trackedDomain, history: history)
235 let inspectedSnapshot = await inspectionService.inspectSnapshot(
236 domain: trackedDomain.domain,
237 previousSnapshot: previousSnapshot
238 )
239 let snapshot = Self.resolvedSnapshotAfterFallback(inspectedSnapshot, previousSnapshot: previousSnapshot)
240 let savedEntry: HistoryEntry?
241 if snapshot.statusMessage == nil {
242 savedEntry = persistSnapshot(
243 snapshot,
244 trackedDomainID: trackedDomain.id,
245 trackedDomains: &mutableTrackedDomains,
246 history: &history
247 )
248 } else {
249 savedEntry = history.first(where: { $0.id == snapshot.historyEntryID })
250 }
251
252 let alertDescriptor = alertDescriptor(
253 previousSnapshot: previousSnapshot,
254 snapshot: snapshot,
255 entry: savedEntry
256 )
257
258 if let index = mutableTrackedDomains.firstIndex(where: { $0.id == trackedDomain.id }) {
259 mutableTrackedDomains[index].lastMonitoredAt = Date()
260 }
261
262 if notificationsAuthorized,
263 let alertDescriptor,
264 alertDescriptor.severity >= settings.alertFilter.minimumSeverity {
265 await LocalNotificationService.shared.notifyMonitoringAlert(
266 domain: trackedDomain.domain,
267 message: alertDescriptor.message,
268 severity: alertDescriptor.severity
269 )
270 alertsTriggered += 1
271 if let index = mutableTrackedDomains.firstIndex(where: { $0.id == trackedDomain.id }) {
272 mutableTrackedDomains[index].lastAlertAt = Date()
273 }
274 }
275
276 let result = MonitoringDomainResult(
277 domain: trackedDomain.domain,
278 historyEntryID: savedEntry?.id ?? snapshot.historyEntryID,
279 checkedAt: Date(),
280 didChange: snapshot.statusMessage == nil && savedEntry?.changeSummary?.hasChanges == true,
281 summaryMessage: snapshot.statusMessage
282 ?? savedEntry?.changeSummary?.message
283 ?? "No meaningful changes",
284 alertSeverity: alertDescriptor?.severity,
285 certificateWarningLevel: DomainDiffService.certificateWarningLevel(for: snapshot),
286 resultSource: snapshot.resultSource,
287 errorMessage: snapshot.statusMessage
288 )
289 results.append(result)
290
291 if let errorMessage = result.errorMessage {
292 errors.append("\(trackedDomain.domain): \(errorMessage)")
293 }
294 }
295
296 MonitoringStorage.saveTrackedDomains(mutableTrackedDomains)
297 MonitoringStorage.saveHistoryEntries(history)
298
299 let log = MonitoringLog(
300 timestamp: Date(),
301 trigger: trigger,
302 domainsChecked: results.count,
303 changesFound: results.filter(\.didChange).count,
304 alertsTriggered: alertsTriggered,
305 checkedDomains: results.sorted {
306 $0.domain.localizedCaseInsensitiveCompare($1.domain) == .orderedAscending
307 },
308 errors: errors
309 )
310 saveLog(log)
311
312 return MonitoringRunOutcome(
313 success: errors.count < results.count,
314 message: log.summary,
315 log: log
316 )
317 }
318
319 private func saveLog(_ log: MonitoringLog) {
320 var logs = MonitoringStorage.loadLogs()
321 logs.insert(log, at: 0)
322 MonitoringStorage.saveLogs(logs)
323 }
324
325 private func latestSnapshot(for trackedDomain: TrackedDomain, history: [HistoryEntry]) -> LookupSnapshot? {
326 history.first(where: { entry in
327 if let trackedDomainID = entry.trackedDomainID {
328 return trackedDomainID == trackedDomain.id
329 }
330 return entry.domain.caseInsensitiveCompare(trackedDomain.domain) == .orderedSame
331 })?.snapshot
332 }
333
334 private func persistSnapshot(
335 _ snapshot: LookupSnapshot,
336 trackedDomainID: UUID,
337 trackedDomains: inout [TrackedDomain],
338 history: inout [HistoryEntry]
339 ) -> HistoryEntry? {
340 let previousSnapshot = latestSnapshot(
341 for: trackedDomains.first(where: { $0.id == trackedDomainID }) ?? TrackedDomain(domain: snapshot.domain),
342 history: history
343 )
344 let analysis = DomainInsightEngine.analyze(snapshot: snapshot, previousSnapshot: previousSnapshot)
345 let changeSummary = previousSnapshot.map {
346 DomainDiffService.summary(
347 from: $0,
348 to: snapshot,
349 generatedAt: snapshot.timestamp,
350 riskAssessment: analysis.riskAssessment,
351 insights: analysis.insights
352 )
353 }
354
355 let entry = HistoryEntry(
356 domain: snapshot.domain,
357 timestamp: snapshot.timestamp,
358 trackedDomainID: trackedDomainID,
359 note: trackedDomains.first(where: { $0.id == trackedDomainID })?.note,
360 dnsSections: snapshot.dnsSections,
361 sslInfo: snapshot.sslInfo,
362 httpHeaders: snapshot.httpHeaders,
363 reachabilityResults: snapshot.reachabilityResults,
364 ipGeolocation: snapshot.ipGeolocation,
365 emailSecurity: snapshot.emailSecurity,
366 mtaSts: snapshot.emailSecurity?.mtaSts,
367 ownership: snapshot.ownership,
368 ownershipHistory: snapshot.ownershipHistory,
369 ptrRecord: snapshot.ptrRecord,
370 redirectChain: snapshot.redirectChain,
371 subdomains: snapshot.subdomains,
372 extendedSubdomains: snapshot.extendedSubdomains,
373 dnsHistory: snapshot.dnsHistory,
374 domainPricing: snapshot.domainPricing,
375 portScanResults: snapshot.portScanResults,
376 hstsPreloaded: snapshot.hstsPreloaded,
377 availabilityResult: snapshot.availabilityResult,
378 suggestions: snapshot.suggestions,
379 appVersion: snapshot.appVersion,
380 resultSource: snapshot.resultSource,
381 dataSources: snapshot.dataSources,
382 provenanceBySection: snapshot.provenanceBySection,
383 availabilityConfidence: snapshot.availabilityConfidence,
384 ownershipConfidence: snapshot.ownershipConfidence,
385 subdomainConfidence: snapshot.subdomainConfidence,
386 emailSecurityConfidence: snapshot.emailSecurityConfidence,
387 geolocationConfidence: snapshot.geolocationConfidence,
388 errorDetails: snapshot.errorDetails,
389 isPartialSnapshot: snapshot.isPartialSnapshot,
390 validationIssues: snapshot.validationIssues,
391 resolverDisplayName: snapshot.resolverDisplayName,
392 resolverURLString: snapshot.resolverURLString,
393 totalLookupDurationMs: snapshot.totalLookupDurationMs,
394 primaryIP: Self.primaryIPAddress(from: snapshot),
395 finalRedirectURL: snapshot.redirectChain.last?.url,
396 tlsStatusSummary: Self.tlsSummary(from: snapshot),
397 emailSecuritySummary: Self.emailSummary(from: snapshot),
398 httpGradeSummary: snapshot.httpSecurityGrade ?? snapshot.httpHeadersError,
399 changeSummary: changeSummary,
400 sslError: snapshot.sslError,
401 httpHeadersError: snapshot.httpHeadersError,
402 reachabilityError: snapshot.reachabilityError,
403 ipGeolocationError: snapshot.ipGeolocationError,
404 emailSecurityError: snapshot.emailSecurityError,
405 ownershipError: snapshot.ownershipError,
406 ownershipHistoryError: snapshot.ownershipHistoryError,
407 ptrError: snapshot.ptrError,
408 redirectChainError: snapshot.redirectChainError,
409 subdomainsError: snapshot.subdomainsError,
410 extendedSubdomainsError: snapshot.extendedSubdomainsError,
411 dnsHistoryError: snapshot.dnsHistoryError,
412 domainPricingError: snapshot.domainPricingError,
413 portScanError: snapshot.portScanError
414 )
415
416 history.insert(entry, at: 0)
417 if history.count > maxHistoryEntries {
418 history = Array(history.prefix(maxHistoryEntries))
419 }
420
421 if let index = trackedDomains.firstIndex(where: { $0.id == trackedDomainID }) {
422 trackedDomains[index].lastSnapshotID = entry.id
423 trackedDomains[index].lastKnownAvailability = snapshot.availabilityResult?.status
424 trackedDomains[index].updatedAt = snapshot.timestamp
425 trackedDomains[index].lastChangeSummary = changeSummary
426 trackedDomains[index].lastChangeSeverity = changeSummary?.severity
427 trackedDomains[index].certificateWarningLevel = DomainDiffService.certificateWarningLevel(for: snapshot)
428 trackedDomains[index].certificateDaysRemaining = snapshot.sslInfo?.daysUntilExpiry
429 }
430
431 return entry
432 }
433
434 private func alertDescriptor(
435 previousSnapshot: LookupSnapshot?,
436 snapshot: LookupSnapshot,
437 entry: HistoryEntry?
438 ) -> (severity: MonitoringAlertSeverity, message: String)? {
439 let changedLabels = Set(
440 (previousSnapshot.map { DomainDiffService.diff(from: $0, to: snapshot) } ?? [])
441 .flatMap(\.items)
442 .filter(\.hasChanges)
443 .map(\.label)
444 )
445
446 if changedLabels.contains("Availability") {
447 return (.critical, "Availability changed")
448 }
449 if changedLabels.contains("Primary IP") {
450 return (.critical, "Primary IP changed")
451 }
452 if changedLabels.contains("Redirect Target") {
453 return (.critical, "Redirect target changed")
454 }
455
456 let ownershipLabels: Set<String> = [
457 "Registrar",
458 "Registration Date",
459 "Expiration Date",
460 "Ownership Status",
461 "Abuse Contact"
462 ]
463 if !changedLabels.isDisjoint(with: ownershipLabels) {
464 return (.warning, "Ownership changed")
465 }
466 if changedLabels.contains("Nameservers") || changedLabels.contains(where: { $0.hasSuffix("Records") }) {
467 return (.warning, "DNS changed")
468 }
469
470 let oldCertificateLevel = previousSnapshot.map { DomainDiffService.certificateWarningLevel(for: $0) } ?? .none
471 let newCertificateLevel = DomainDiffService.certificateWarningLevel(for: snapshot)
472 if newCertificateLevel != .none, newCertificateLevel != oldCertificateLevel {
473 let daysRemaining = snapshot.sslInfo?.daysUntilExpiry ?? 0
474 return (.warning, "Certificate expires in \(daysRemaining) days")
475 }
476
477 if entry?.changeSummary?.hasChanges == true, let message = entry?.changeSummary?.message {
478 return (.info, message)
479 }
480
481 return nil
482 }
483
484 private static func resolvedSnapshotAfterFallback(
485 _ snapshot: LookupSnapshot,
486 previousSnapshot: LookupSnapshot?
487 ) -> LookupSnapshot {
488 guard shouldFallbackToSnapshot(snapshot), let previousSnapshot else {
489 return snapshot
490 }
491
492 return LookupSnapshot(
493 historyEntryID: previousSnapshot.historyEntryID,
494 domain: previousSnapshot.domain,
495 timestamp: previousSnapshot.timestamp,
496 trackedDomainID: previousSnapshot.trackedDomainID,
497 note: previousSnapshot.note,
498 appVersion: previousSnapshot.appVersion,
499 resolverDisplayName: previousSnapshot.resolverDisplayName,
500 resolverURLString: previousSnapshot.resolverURLString,
501 dataSources: previousSnapshot.dataSources,
502 provenanceBySection: previousSnapshot.provenanceBySection,
503 availabilityConfidence: previousSnapshot.availabilityConfidence,
504 ownershipConfidence: previousSnapshot.ownershipConfidence,
505 subdomainConfidence: previousSnapshot.subdomainConfidence,
506 emailSecurityConfidence: previousSnapshot.emailSecurityConfidence,
507 geolocationConfidence: previousSnapshot.geolocationConfidence,
508 errorDetails: previousSnapshot.errorDetails,
509 isPartialSnapshot: previousSnapshot.isPartialSnapshot,
510 validationIssues: previousSnapshot.validationIssues,
511 totalLookupDurationMs: previousSnapshot.totalLookupDurationMs,
512 dnsSections: previousSnapshot.dnsSections,
513 dnsError: previousSnapshot.dnsError,
514 availabilityResult: previousSnapshot.availabilityResult,
515 suggestions: previousSnapshot.suggestions,
516 sslInfo: previousSnapshot.sslInfo,
517 sslError: previousSnapshot.sslError,
518 hstsPreloaded: previousSnapshot.hstsPreloaded,
519 httpHeaders: previousSnapshot.httpHeaders,
520 httpSecurityGrade: previousSnapshot.httpSecurityGrade,
521 httpStatusCode: previousSnapshot.httpStatusCode,
522 httpResponseTimeMs: previousSnapshot.httpResponseTimeMs,
523 httpProtocol: previousSnapshot.httpProtocol,
524 http3Advertised: previousSnapshot.http3Advertised,
525 httpHeadersError: previousSnapshot.httpHeadersError,
526 reachabilityResults: previousSnapshot.reachabilityResults,
527 reachabilityError: previousSnapshot.reachabilityError,
528 ipGeolocation: previousSnapshot.ipGeolocation,
529 ipGeolocationError: previousSnapshot.ipGeolocationError,
530 emailSecurity: previousSnapshot.emailSecurity,
531 emailSecurityError: previousSnapshot.emailSecurityError,
532 ownership: previousSnapshot.ownership,
533 ownershipError: previousSnapshot.ownershipError,
534 ownershipHistory: previousSnapshot.ownershipHistory,
535 ownershipHistoryError: previousSnapshot.ownershipHistoryError,
536 ptrRecord: previousSnapshot.ptrRecord,
537 ptrError: previousSnapshot.ptrError,
538 redirectChain: previousSnapshot.redirectChain,
539 redirectChainError: previousSnapshot.redirectChainError,
540 subdomains: previousSnapshot.subdomains,
541 subdomainsError: previousSnapshot.subdomainsError,
542 extendedSubdomains: previousSnapshot.extendedSubdomains,
543 extendedSubdomainsError: previousSnapshot.extendedSubdomainsError,
544 dnsHistory: previousSnapshot.dnsHistory,
545 dnsHistoryError: previousSnapshot.dnsHistoryError,
546 domainPricing: previousSnapshot.domainPricing,
547 domainPricingError: previousSnapshot.domainPricingError,
548 portScanResults: previousSnapshot.portScanResults,
549 portScanError: previousSnapshot.portScanError,
550 changeSummary: previousSnapshot.changeSummary,
551 resultSource: .snapshot,
552 cachedSections: [],
553 statusMessage: "Last known result • \(previousSnapshot.timestamp.formatted(date: .abbreviated, time: .shortened))"
554 )
555 }
556
557 private static func shouldFallbackToSnapshot(_ snapshot: LookupSnapshot) -> Bool {
558 let candidateMessages = [
559 snapshot.dnsError,
560 snapshot.httpHeadersError,
561 snapshot.sslError,
562 snapshot.ownershipError,
563 snapshot.subdomainsError,
564 snapshot.redirectChainError,
565 snapshot.ipGeolocationError
566 ]
567 .compactMap { $0?.lowercased() }
568
569 guard !candidateMessages.isEmpty else { return false }
570 let connectivityFailure = candidateMessages.allSatisfy { message in
571 message.hasPrefix("network error:")
572 || message.hasPrefix("timeout:")
573 || message.hasPrefix("rate limit:")
574 }
575
576 let hasMaterialData = !snapshot.dnsSections.isEmpty
577 || !snapshot.httpHeaders.isEmpty
578 || snapshot.sslInfo != nil
579 || snapshot.ownership != nil
580 || !snapshot.subdomains.isEmpty
581
582 return connectivityFailure && !hasMaterialData
583 }
584
585 private static func primaryIPAddress(from snapshot: LookupSnapshot) -> String? {
586 snapshot.dnsSections.first(where: { $0.recordType == .A })?.records.first?.value
587 }
588
589 private static func tlsSummary(from snapshot: LookupSnapshot) -> String? {
590 guard let sslInfo = snapshot.sslInfo else { return snapshot.sslError }
591 switch DomainDiffService.certificateWarningLevel(for: snapshot) {
592 case .critical:
593 return "Critical (\(sslInfo.daysUntilExpiry)d)"
594 case .warning:
595 return "Warning (\(sslInfo.daysUntilExpiry)d)"
596 case .none:
597 return "Healthy (\(sslInfo.daysUntilExpiry)d)"
598 }
599 }
600
601 private static func emailSummary(from snapshot: LookupSnapshot) -> String? {
602 if let emailSecurity = snapshot.emailSecurity {
603 return [
604 "spf:\(emailSecurity.spf.found)",
605 "dmarc:\(emailSecurity.dmarc.found)",
606 "dkim:\(emailSecurity.dkim.found)",
607 "bimi:\(emailSecurity.bimi.found)",
608 "mta-sts:\(emailSecurity.mtaSts?.txtFound == true)"
609 ].joined(separator: "|")
610 }
611 return snapshot.emailSecurityError
612 }
613}
DomainDig/DomainViewModel.swift +222 −73
@@ -1,5 +1,6 @@
11import Foundation
22import SwiftUI
3import UserNotifications
34
45enum ResultTone {
56 case primary
@@ -218,10 +219,10 @@ final class DomainViewModel {
218219
219220 private static let recentSearchesKey = "recentSearches"
220221 private static let maxRecent = 20
221 var recentSearches: [String] = UserDefaults.standard.stringArray(forKey: recentSearchesKey) ?? []
222 var recentSearches: [String] = DomainDataPortabilityService.loadRecentSearches()
222223
223224 private static let savedDomainsKey = "savedDomains"
224 var savedDomains: [String] = UserDefaults.standard.stringArray(forKey: savedDomainsKey) ?? []
225 var savedDomains: [String] = DomainDataPortabilityService.loadSavedDomains()
225226
226227 private static let trackedDomainsKey = "trackedDomains"
227228 private static let legacyWatchedDomainsKey = "watchedDomains"
@@ -239,6 +240,13 @@ final class DomainViewModel {
239240 var watchlistSearchText = ""
240241 var watchlistFilter: WatchlistFilterOption = .all
241242 var watchlistSortOption: WatchlistSortOption = .pinned
243 var monitoringSettings: MonitoringSettings = MonitoringStorage.loadSettings()
244 var monitoringLogs: [MonitoringLog] = MonitoringStorage.loadLogs()
245 var monitoringRunInProgress = false
246 var monitoringStatusMessage: String?
247 var monitoringNotificationStatus: UNAuthorizationStatus = .notDetermined
248 var dataLifecycleSummary = DomainDataPortabilityService.lifecycleSummary()
249 var portabilityStatusMessage: String?
242250 var upgradePrompt: UpgradePromptContext?
243251 var isPaywallPresented = false
244252
@@ -598,12 +606,14 @@ final class DomainViewModel {
598606 } else {
599607 savedDomains.append(searchedDomain)
600608 }
601 UserDefaults.standard.set(savedDomains, forKey: Self.savedDomainsKey)
609 DomainDataPortabilityService.saveSavedDomains(savedDomains)
610 refreshDataLifecycleSummary()
602611 }
603612
604613 func removeSavedDomains(at offsets: IndexSet) {
605614 savedDomains.remove(atOffsets: offsets)
606 UserDefaults.standard.set(savedDomains, forKey: Self.savedDomainsKey)
615 DomainDataPortabilityService.saveSavedDomains(savedDomains)
616 refreshDataLifecycleSummary()
607617 }
608618
609619 @discardableResult
@@ -636,6 +646,7 @@ final class DomainViewModel {
636646 at: 0
637647 )
638648 persistTrackedDomains()
649 sanitizeMonitoringSelection()
639650 linkTrackedDomainHistory(for: normalizedDomain)
640651 return true
641652 }
@@ -664,6 +675,7 @@ final class DomainViewModel {
664675 }
665676 persistTrackedDomains()
666677 persistHistory()
678 sanitizeMonitoringSelection()
667679 }
668680
669681 func deleteTrackedDomain(_ trackedDomain: TrackedDomain) {
@@ -675,6 +687,7 @@ final class DomainViewModel {
675687 }
676688 persistTrackedDomains()
677689 persistHistory()
690 sanitizeMonitoringSelection()
678691 }
679692
680693 func togglePinned(for trackedDomain: TrackedDomain) {
@@ -702,6 +715,7 @@ final class DomainViewModel {
702715 func clearHistory() {
703716 history.removeAll()
704717 persistHistory()
718 refreshDataLifecycleSummary()
705719 }
706720
707721 func clearLookupCache() {
@@ -714,17 +728,157 @@ final class DomainViewModel {
714728 workflows.removeAll()
715729 latestWorkflowRunSummary = nil
716730 persistWorkflows()
731 refreshDataLifecycleSummary()
717732 }
718733
719734 func clearTrackedDomains() {
720735 trackedDomains.removeAll()
721736 refreshingTrackedDomainID = nil
722737 persistTrackedDomains()
738 sanitizeMonitoringSelection()
739 refreshDataLifecycleSummary()
723740 }
724741
725742 func clearRecentSearches() {
726743 recentSearches.removeAll()
727 UserDefaults.standard.removeObject(forKey: Self.recentSearchesKey)
744 DomainDataPortabilityService.saveRecentSearches([])
745 refreshDataLifecycleSummary()
746 }
747
748 func refreshMonitoringState() {
749 DataMigrationService.migrateIfNeeded()
750 trackedDomains = Self.loadTrackedDomains()
751 history = Self.loadHistoryEntries()
752 monitoringSettings = MonitoringStorage.sanitizeSettings(
753 MonitoringStorage.loadSettings(),
754 trackedDomains: trackedDomains
755 )
756 monitoringLogs = MonitoringStorage.loadLogs()
757 persistMonitoringSettings()
758 refreshDataLifecycleSummary()
759 }
760
761 func refreshDataLifecycleSummary() {
762 dataLifecycleSummary = DomainDataPortabilityService.lifecycleSummary()
763 }
764
765 func refreshPersistedData() {
766 recentSearches = DomainDataPortabilityService.loadRecentSearches()
767 savedDomains = DomainDataPortabilityService.loadSavedDomains()
768 trackedDomains = Self.loadTrackedDomains()
769 history = Self.loadHistoryEntries()
770 workflows = Self.loadWorkflows()
771 monitoringSettings = MonitoringStorage.sanitizeSettings(
772 MonitoringStorage.loadSettings(),
773 trackedDomains: trackedDomains
774 )
775 monitoringLogs = MonitoringStorage.loadLogs()
776 refreshDataLifecycleSummary()
777 }
778
779 func refreshMonitoringAuthorizationStatus() async {
780 let settings = await UNUserNotificationCenter.current().notificationSettings()
781 monitoringNotificationStatus = settings.authorizationStatus
782 }
783
784 func setMonitoringEnabled(_ isEnabled: Bool) {
785 guard !isEnabled || FeatureAccessService.hasAccess(to: .automatedMonitoring) else {
786 monitoringSettings.isEnabled = false
787 upgradePrompt = FeatureAccessService.upgradePrompt(for: .automatedMonitoring)
788 return
789 }
790
791 monitoringSettings.isEnabled = isEnabled
792 persistMonitoringSettings()
793 monitoringStatusMessage = DomainMonitoringScheduler.shared.syncSchedule()
794 }
795
796 func setMonitoringScope(_ scope: MonitoringScope) {
797 monitoringSettings.scope = scope
798 persistMonitoringSettings()
799 }
800
801 func setMonitoringFrequency(_ frequency: MonitoringFrequency) {
802 guard FeatureAccessService.hasAccess(to: .automatedMonitoring) else {
803 upgradePrompt = FeatureAccessService.upgradePrompt(for: .automatedMonitoring)
804 return
805 }
806 monitoringSettings.frequency = frequency
807 persistMonitoringSettings()
808 monitoringStatusMessage = DomainMonitoringScheduler.shared.syncSchedule()
809 }
810
811 func setMonitoringAlertFilter(_ filter: MonitoringAlertFilter) {
812 guard FeatureAccessService.hasAccess(to: .localAlerts) else {
813 monitoringSettings.alertsEnabled = false
814 upgradePrompt = FeatureAccessService.upgradePrompt(for: .localAlerts)
815 return
816 }
817 monitoringSettings.alertFilter = filter
818 persistMonitoringSettings()
819 }
820
821 func setMonitoringAlertsEnabled(_ isEnabled: Bool) {
822 guard !isEnabled || FeatureAccessService.hasAccess(to: .localAlerts) else {
823 monitoringSettings.alertsEnabled = false
824 upgradePrompt = FeatureAccessService.upgradePrompt(for: .localAlerts)
825 return
826 }
827 monitoringSettings.alertsEnabled = isEnabled
828 persistMonitoringSettings()
829 }
830
831 func setMonitoringSelection(for trackedDomain: TrackedDomain, isSelected: Bool) {
832 if isSelected {
833 if !monitoringSettings.selectedDomainIDs.contains(trackedDomain.id) {
834 monitoringSettings.selectedDomainIDs.append(trackedDomain.id)
835 }
836 } else {
837 monitoringSettings.selectedDomainIDs.removeAll { $0 == trackedDomain.id }
838 }
839 persistMonitoringSettings()
840 }
841
842 func toggleMonitoring(for trackedDomain: TrackedDomain) {
843 guard FeatureAccessService.hasAccess(to: .automatedMonitoring) else {
844 upgradePrompt = FeatureAccessService.upgradePrompt(for: .automatedMonitoring)
845 return
846 }
847 guard let index = trackedDomains.firstIndex(where: { $0.id == trackedDomain.id }) else { return }
848 trackedDomains[index].monitoringEnabled.toggle()
849 MonitoringStorage.saveTrackedDomains(trackedDomains)
850 sanitizeMonitoringSelection()
851 }
852
853 func requestMonitoringNotificationAuthorization() async {
854 let granted = await LocalNotificationService.shared.requestAuthorizationIfNeeded()
855 monitoringSettings.alertsEnabled = granted
856 persistMonitoringSettings()
857 await refreshMonitoringAuthorizationStatus()
858 }
859
860 func runMonitoringNow() {
861 guard FeatureAccessService.hasAccess(to: .automatedMonitoring) else {
862 upgradePrompt = FeatureAccessService.upgradePrompt(for: .automatedMonitoring)
863 return
864 }
865 guard !monitoringRunInProgress else { return }
866
867 monitoringRunInProgress = true
868 monitoringStatusMessage = nil
869
870 Task { [weak self] in
871 guard let self else { return }
872 let outcome = await DomainMonitoringService.shared.performMonitoring(
873 trigger: .manual,
874 requireEnabledSetting: false
875 )
876 await MainActor.run {
877 self.refreshMonitoringState()
878 self.monitoringRunInProgress = false
879 self.monitoringStatusMessage = outcome.message
880 }
881 }
728882 }
729883
730884 func rerunLookup(from entry: HistoryEntry, useSnapshotResolver: Bool) {
@@ -1159,6 +1313,45 @@ final class DomainViewModel {
11591313 )
11601314 }
11611315
1316 func exportFullBackupData() -> Data? {
1317 try? DomainDataPortabilityService.backupData()
1318 }
1319
1320 func exportPortableTrackedDomainsJSONData() -> Data? {
1321 try? DomainDataPortabilityService.trackedDomainsExportData()
1322 }
1323
1324 func exportPortableTrackedDomainsCSV() -> String {
1325 DomainDataPortabilityService.trackedDomainsCSV()
1326 }
1327
1328 func exportPortableWorkflowsJSONData() -> Data? {
1329 try? DomainDataPortabilityService.workflowsExportData()
1330 }
1331
1332 func exportPortableWorkflowsCSV() -> String {
1333 DomainDataPortabilityService.workflowsCSV()
1334 }
1335
1336 func exportPortableHistoryJSONData() -> Data? {
1337 try? DomainDataPortabilityService.historyExportData()
1338 }
1339
1340 func prepareDataImport(
1341 data: Data,
1342 fileName: String,
1343 mode: DataPortabilityImportMode
1344 ) throws -> DataImportPreview {
1345 try DomainDataPortabilityService.prepareImport(data: data, fileName: fileName, mode: mode)
1346 }
1347
1348 func applyDataImport(_ preview: DataImportPreview, mode: DataPortabilityImportMode) throws -> DataImportResult {
1349 let result = try DomainDataPortabilityService.applyImport(preview, mode: mode)
1350 refreshPersistedData()
1351 portabilityStatusMessage = result.summary
1352 return result
1353 }
1354
11621355 func exportWorkflowText(summary: WorkflowRunSummary, changedOnly: Bool) -> String {
11631356 let reports = workflowReports(from: summary, changedOnly: changedOnly)
11641357 let base = DomainReportExporter.batchText(
@@ -1806,9 +1999,8 @@ final class DomainViewModel {
18061999 }
18072000
18082001 private func persistHistory() {
1809 if let data = try? JSONEncoder().encode(history) {
1810 UserDefaults.standard.set(data, forKey: Self.historyKey)
1811 }
2002 DomainDataPortabilityService.saveHistoryEntries(history)
2003 refreshDataLifecycleSummary()
18122004 }
18132005
18142006 func updateHistoryNote(_ note: String, for entry: HistoryEntry) {
@@ -1818,9 +2010,18 @@ final class DomainViewModel {
18182010 }
18192011
18202012 private func persistTrackedDomains() {
1821 if let data = try? JSONEncoder().encode(trackedDomains) {
1822 UserDefaults.standard.set(data, forKey: Self.trackedDomainsKey)
1823 }
2013 DomainDataPortabilityService.saveTrackedDomains(trackedDomains)
2014 refreshDataLifecycleSummary()
2015 }
2016
2017 private func persistMonitoringSettings() {
2018 monitoringSettings = MonitoringStorage.sanitizeSettings(monitoringSettings, trackedDomains: trackedDomains)
2019 MonitoringStorage.saveSettings(monitoringSettings)
2020 }
2021
2022 private func sanitizeMonitoringSelection() {
2023 monitoringSettings = MonitoringStorage.sanitizeSettings(monitoringSettings, trackedDomains: trackedDomains)
2024 MonitoringStorage.saveSettings(monitoringSettings)
18242025 }
18252026
18262027 private func updateTrackedDomainAvailability(for domain: String, status: DomainAvailabilityStatus) {
@@ -1948,7 +2149,8 @@ final class DomainViewModel {
19482149 if recentSearches.count > Self.maxRecent {
19492150 recentSearches = Array(recentSearches.prefix(Self.maxRecent))
19502151 }
1951 UserDefaults.standard.set(recentSearches, forKey: Self.recentSearchesKey)
2152 DomainDataPortabilityService.saveRecentSearches(recentSearches)
2153 refreshDataLifecycleSummary()
19522154 }
19532155
19542156 private func beginLookup(for target: String, cancelExistingTask: Bool = true) -> UUID {
@@ -2510,76 +2712,23 @@ final class DomainViewModel {
25102712 }
25112713
25122714 private static func loadHistoryEntries() -> [HistoryEntry] {
2513 let defaults = UserDefaults.standard
2514 guard let data = defaults.data(forKey: historyKey) else {
2515 return []
2516 }
2517
2518 if let entries = try? JSONDecoder().decode([HistoryEntry].self, from: data) {
2519 return entries
2520 }
2521
2522 guard let rawArray = (try? JSONSerialization.jsonObject(with: data)) as? [Any] else {
2523 return []
2524 }
2525
2526 let decoder = JSONDecoder()
2527 return rawArray.compactMap { item in
2528 guard JSONSerialization.isValidJSONObject(item),
2529 let itemData = try? JSONSerialization.data(withJSONObject: item),
2530 let entry = try? decoder.decode(HistoryEntry.self, from: itemData) else {
2531 return nil
2532 }
2533 return entry
2534 }
2715 DataMigrationService.migrateIfNeeded()
2716 return DomainDataPortabilityService.loadHistoryEntries()
25352717 }
25362718
25372719 private static func loadTrackedDomains() -> [TrackedDomain] {
2538 let defaults = UserDefaults.standard
2539 let decoder = JSONDecoder()
2540
2541 if let data = defaults.data(forKey: trackedDomainsKey),
2542 let domains = try? decoder.decode([TrackedDomain].self, from: data) {
2543 return deduplicatedTrackedDomains(domains)
2544 }
2545
2546 if let legacyData = defaults.data(forKey: legacyWatchedDomainsKey),
2547 let legacyDomains = try? decoder.decode([WatchedDomain].self, from: legacyData) {
2548 return deduplicatedTrackedDomains(
2549 legacyDomains.map {
2550 TrackedDomain(
2551 id: $0.id,
2552 domain: $0.domain.lowercased(),
2553 createdAt: $0.createdAt,
2554 updatedAt: $0.createdAt,
2555 lastKnownAvailability: $0.lastKnownAvailability
2556 )
2557 }
2558 )
2559 }
2560
2561 return []
2720 DataMigrationService.migrateIfNeeded()
2721 return DomainDataPortabilityService.loadTrackedDomains()
25622722 }
25632723
25642724 private func persistWorkflows() {
2565 if let data = try? JSONEncoder().encode(workflows) {
2566 UserDefaults.standard.set(data, forKey: Self.workflowsKey)
2567 }
2725 DomainDataPortabilityService.saveWorkflows(workflows)
2726 refreshDataLifecycleSummary()
25682727 }
25692728
25702729 private static func loadWorkflows() -> [DomainWorkflow] {
2571 let defaults = UserDefaults.standard
2572 guard let data = defaults.data(forKey: workflowsKey),
2573 let workflows = try? JSONDecoder().decode([DomainWorkflow].self, from: data) else {
2574 return []
2575 }
2576
2577 return workflows.sorted { lhs, rhs in
2578 if lhs.updatedAt != rhs.updatedAt {
2579 return lhs.updatedAt > rhs.updatedAt
2580 }
2581 return lhs.name.localizedCaseInsensitiveCompare(rhs.name) == .orderedAscending
2582 }
2730 DataMigrationService.migrateIfNeeded()
2731 return DomainDataPortabilityService.loadWorkflows()
25832732 }
25842733
25852734 private static func deduplicatedTrackedDomains(_ domains: [TrackedDomain]) -> [TrackedDomain] {
DomainDig/FeatureAccessService.swift +17 −2
@@ -25,6 +25,8 @@ enum FeatureCapability: String, CaseIterable, Identifiable {
2525 case limitedTracking
2626 case workflows
2727 case batchOperations
28 case automatedMonitoring
29 case localAlerts
2830 case advancedExports
2931 case ownershipHistory
3032 case dnsHistory
@@ -45,6 +47,10 @@ enum FeatureCapability: String, CaseIterable, Identifiable {
4547 return "Workflows"
4648 case .batchOperations:
4749 return "Batch operations"
50 case .automatedMonitoring:
51 return "Background monitoring"
52 case .localAlerts:
53 return "Local alerts"
4854 case .advancedExports:
4955 return "Advanced exports"
5056 case .ownershipHistory:
@@ -94,7 +100,16 @@ enum FeatureAccessService {
94100 case .pro:
95101 return FeatureEntitlements(
96102 tier: .pro,
97 capabilities: [.singleLookup, .basicHistory, .limitedTracking, .workflows, .batchOperations, .advancedExports],
103 capabilities: [
104 .singleLookup,
105 .basicHistory,
106 .limitedTracking,
107 .workflows,
108 .batchOperations,
109 .automatedMonitoring,
110 .localAlerts,
111 .advancedExports
112 ],
98113 trackedDomainLimit: effectivelyUnlimitedTrackedDomains,
99114 workflowLimit: nil,
100115 batchSizeLimit: nil
@@ -140,7 +155,7 @@ enum FeatureAccessService {
140155
141156 static func upgradeMessage(for capability: FeatureCapability) -> String {
142157 switch capability {
143 case .workflows, .batchOperations, .advancedExports:
158 case .workflows, .batchOperations, .automatedMonitoring, .localAlerts, .advancedExports:
144159 return "Available in Pro"
145160 case .ownershipHistory, .dnsHistory, .extendedSubdomains, .domainPricing:
146161 return "Available in Data+"
DomainDig/Info.plist +8
@@ -6,5 +6,13 @@
66 <true/>
77 <key>App ​Transport ​Security ​Settings (or NSApp​Transport​Security)</key>
88 <dict/>
9 <key>BGTaskSchedulerPermittedIdentifiers</key>
10 <array>
11 <string>net.cleberg.DomainDig.monitor.refresh</string>
12 </array>
13 <key>UIBackgroundModes</key>
14 <array>
15 <string>fetch</string>
16 </array>
917</dict>
1018</plist>
DomainDig/LocalNotificationService.swift +33
@@ -27,6 +27,18 @@ final class LocalNotificationService {
2727 }
2828 }
2929
30 func isAuthorizedForAlerts() async -> Bool {
31 let settings = await UNUserNotificationCenter.current().notificationSettings()
32 switch settings.authorizationStatus {
33 case .authorized, .provisional, .ephemeral:
34 return true
35 case .denied, .notDetermined:
36 return false
37 @unknown default:
38 return false
39 }
40 }
41
3042 func notifyDomainEvent(domain: String, message: String, severity: ChangeSeverity) async {
3143 await schedule(
3244 identifier: "domain-change-\(domain)",
@@ -45,6 +57,27 @@ final class LocalNotificationService {
4557 )
4658 }
4759
60 func notifyMonitoringAlert(
61 domain: String,
62 message: String,
63 severity: MonitoringAlertSeverity
64 ) async {
65 let interruptionLevel: UNNotificationInterruptionLevel
66 switch severity {
67 case .critical:
68 interruptionLevel = .timeSensitive
69 case .warning, .info:
70 interruptionLevel = .active
71 }
72
73 await schedule(
74 identifier: "monitoring-\(domain)-\(UUID().uuidString)",
75 title: domain,
76 body: message,
77 interruptionLevel: interruptionLevel
78 )
79 }
80
4881 func notifySweepComplete(summary: BatchSweepSummary) async {
4982 let body = "\(summary.changedDomains) changed, \(summary.warningDomains) warnings, \(summary.unchangedDomains) unchanged"
5083 await schedule(
DomainDig/Models.swift +222 −1
@@ -591,12 +591,15 @@ struct TrackedDomain: Codable, Identifiable, Equatable {
591591 var updatedAt: Date
592592 var note: String?
593593 var isPinned: Bool
594 var monitoringEnabled: Bool
594595 var lastKnownAvailability: DomainAvailabilityStatus?
595596 var lastSnapshotID: UUID?
596597 var lastChangeSummary: DomainChangeSummary?
597598 var lastChangeSeverity: ChangeSeverity?
598599 var certificateWarningLevel: CertificateWarningLevel
599600 var certificateDaysRemaining: Int?
601 var lastMonitoredAt: Date?
602 var lastAlertAt: Date?
600603
601604 init(
602605 id: UUID = UUID(),
@@ -605,12 +608,15 @@ struct TrackedDomain: Codable, Identifiable, Equatable {
605608 updatedAt: Date = Date(),
606609 note: String? = nil,
607610 isPinned: Bool = false,
611 monitoringEnabled: Bool = true,
608612 lastKnownAvailability: DomainAvailabilityStatus? = nil,
609613 lastSnapshotID: UUID? = nil,
610614 lastChangeSummary: DomainChangeSummary? = nil,
611615 lastChangeSeverity: ChangeSeverity? = nil,
612616 certificateWarningLevel: CertificateWarningLevel = .none,
613 certificateDaysRemaining: Int? = nil
617 certificateDaysRemaining: Int? = nil,
618 lastMonitoredAt: Date? = nil,
619 lastAlertAt: Date? = nil
614620 ) {
615621 self.id = id
616622 self.domain = domain
@@ -618,12 +624,15 @@ struct TrackedDomain: Codable, Identifiable, Equatable {
618624 self.updatedAt = updatedAt
619625 self.note = note
620626 self.isPinned = isPinned
627 self.monitoringEnabled = monitoringEnabled
621628 self.lastKnownAvailability = lastKnownAvailability
622629 self.lastSnapshotID = lastSnapshotID
623630 self.lastChangeSummary = lastChangeSummary
624631 self.lastChangeSeverity = lastChangeSeverity
625632 self.certificateWarningLevel = certificateWarningLevel
626633 self.certificateDaysRemaining = certificateDaysRemaining
634 self.lastMonitoredAt = lastMonitoredAt
635 self.lastAlertAt = lastAlertAt
627636 }
628637
629638 init(from decoder: Decoder) throws {
@@ -634,12 +643,224 @@ struct TrackedDomain: Codable, Identifiable, Equatable {
634643 updatedAt = try container.decodeIfPresent(Date.self, forKey: .updatedAt) ?? createdAt
635644 note = try container.decodeIfPresent(String.self, forKey: .note)
636645 isPinned = try container.decodeIfPresent(Bool.self, forKey: .isPinned) ?? false
646 monitoringEnabled = try container.decodeIfPresent(Bool.self, forKey: .monitoringEnabled) ?? true
637647 lastKnownAvailability = try container.decodeIfPresent(DomainAvailabilityStatus.self, forKey: .lastKnownAvailability)
638648 lastSnapshotID = try container.decodeIfPresent(UUID.self, forKey: .lastSnapshotID)
639649 lastChangeSummary = try container.decodeIfPresent(DomainChangeSummary.self, forKey: .lastChangeSummary)
640650 lastChangeSeverity = try container.decodeIfPresent(ChangeSeverity.self, forKey: .lastChangeSeverity) ?? lastChangeSummary?.severity
641651 certificateWarningLevel = try container.decodeIfPresent(CertificateWarningLevel.self, forKey: .certificateWarningLevel) ?? .none
642652 certificateDaysRemaining = try container.decodeIfPresent(Int.self, forKey: .certificateDaysRemaining)
653 lastMonitoredAt = try container.decodeIfPresent(Date.self, forKey: .lastMonitoredAt)
654 lastAlertAt = try container.decodeIfPresent(Date.self, forKey: .lastAlertAt)
655 }
656}
657
658enum MonitoringFrequency: String, Codable, CaseIterable, Identifiable {
659 case daily
660 case twiceDaily
661
662 var id: String { rawValue }
663
664 var title: String {
665 switch self {
666 case .daily:
667 return "Daily"
668 case .twiceDaily:
669 return "Twice Daily"
670 }
671 }
672
673 var schedulingInterval: TimeInterval {
674 switch self {
675 case .daily:
676 return 24 * 60 * 60
677 case .twiceDaily:
678 return 12 * 60 * 60
679 }
680 }
681}
682
683enum MonitoringScope: String, Codable, CaseIterable, Identifiable {
684 case allTracked
685 case selectedOnly
686
687 var id: String { rawValue }
688
689 var title: String {
690 switch self {
691 case .allTracked:
692 return "All Tracked"
693 case .selectedOnly:
694 return "Selected Only"
695 }
696 }
697}
698
699enum MonitoringAlertSeverity: Int, Codable, CaseIterable, Comparable {
700 case info
701 case warning
702 case critical
703
704 static func < (lhs: MonitoringAlertSeverity, rhs: MonitoringAlertSeverity) -> Bool {
705 lhs.rawValue < rhs.rawValue
706 }
707
708 var title: String {
709 switch self {
710 case .info:
711 return "Info"
712 case .warning:
713 return "Warning"
714 case .critical:
715 return "Critical"
716 }
717 }
718}
719
720enum MonitoringAlertFilter: String, Codable, CaseIterable, Identifiable {
721 case criticalOnly
722 case criticalAndWarnings
723 case allChanges
724
725 var id: String { rawValue }
726
727 var title: String {
728 switch self {
729 case .criticalOnly:
730 return "Critical Only"
731 case .criticalAndWarnings:
732 return "Critical + Warnings"
733 case .allChanges:
734 return "All Changes"
735 }
736 }
737
738 var minimumSeverity: MonitoringAlertSeverity {
739 switch self {
740 case .criticalOnly:
741 return .critical
742 case .criticalAndWarnings:
743 return .warning
744 case .allChanges:
745 return .info
746 }
747 }
748}
749
750enum MonitoringRunTrigger: String, Codable {
751 case manual
752 case background
753 case cli
754
755 var title: String {
756 switch self {
757 case .manual:
758 return "Manual"
759 case .background:
760 return "Background"
761 case .cli:
762 return "CLI"
763 }
764 }
765}
766
767struct MonitoringSettings: Codable, Equatable {
768 var isEnabled: Bool
769 var frequency: MonitoringFrequency
770 var scope: MonitoringScope
771 var selectedDomainIDs: [UUID]
772 var alertFilter: MonitoringAlertFilter
773 var alertsEnabled: Bool
774
775 init(
776 isEnabled: Bool = false,
777 frequency: MonitoringFrequency = .daily,
778 scope: MonitoringScope = .allTracked,
779 selectedDomainIDs: [UUID] = [],
780 alertFilter: MonitoringAlertFilter = .criticalAndWarnings,
781 alertsEnabled: Bool = false
782 ) {
783 self.isEnabled = isEnabled
784 self.frequency = frequency
785 self.scope = scope
786 self.selectedDomainIDs = selectedDomainIDs
787 self.alertFilter = alertFilter
788 self.alertsEnabled = alertsEnabled
789 }
790}
791
792struct MonitoringDomainResult: Codable, Identifiable, Equatable {
793 let id: UUID
794 let domain: String
795 let historyEntryID: UUID?
796 let checkedAt: Date
797 let didChange: Bool
798 let summaryMessage: String
799 let alertSeverity: MonitoringAlertSeverity?
800 let certificateWarningLevel: CertificateWarningLevel
801 let resultSource: LookupResultSource
802 let errorMessage: String?
803
804 init(
805 id: UUID = UUID(),
806 domain: String,
807 historyEntryID: UUID?,
808 checkedAt: Date,
809 didChange: Bool,
810 summaryMessage: String,
811 alertSeverity: MonitoringAlertSeverity?,
812 certificateWarningLevel: CertificateWarningLevel,
813 resultSource: LookupResultSource,
814 errorMessage: String? = nil
815 ) {
816 self.id = id
817 self.domain = domain
818 self.historyEntryID = historyEntryID
819 self.checkedAt = checkedAt
820 self.didChange = didChange
821 self.summaryMessage = summaryMessage
822 self.alertSeverity = alertSeverity
823 self.certificateWarningLevel = certificateWarningLevel
824 self.resultSource = resultSource
825 self.errorMessage = errorMessage
826 }
827}
828
829struct MonitoringLog: Codable, Identifiable, Equatable {
830 let id: UUID
831 let timestamp: Date
832 let trigger: MonitoringRunTrigger
833 let domainsChecked: Int
834 let changesFound: Int
835 let alertsTriggered: Int
836 let checkedDomains: [MonitoringDomainResult]
837 let errors: [String]
838
839 init(
840 id: UUID = UUID(),
841 timestamp: Date,
842 trigger: MonitoringRunTrigger,
843 domainsChecked: Int,
844 changesFound: Int,
845 alertsTriggered: Int,
846 checkedDomains: [MonitoringDomainResult],
847 errors: [String] = []
848 ) {
849 self.id = id
850 self.timestamp = timestamp
851 self.trigger = trigger
852 self.domainsChecked = domainsChecked
853 self.changesFound = changesFound
854 self.alertsTriggered = alertsTriggered
855 self.checkedDomains = checkedDomains
856 self.errors = errors
857 }
858
859 var summary: String {
860 if errors.isEmpty {
861 return "\(changesFound) changes across \(domainsChecked) domains"
862 }
863 return "\(changesFound) changes, \(errors.count) errors"
643864 }
644865}
645866
DomainDig/MonitoringView.swift added +198
@@ -0,0 +1,198 @@
1import SwiftUI
2
3struct MonitoringView: View {
4 @Environment(\.appDensity) private var appDensity
5 @Bindable var viewModel: DomainViewModel
6
7 private var monitoredDomainsCount: Int {
8 MonitoringStorage.monitoredDomains(
9 settings: viewModel.monitoringSettings,
10 trackedDomains: viewModel.trackedDomains
11 ).count
12 }
13
14 var body: some View {
15 List {
16 Section("Overview") {
17 VStack(alignment: .leading, spacing: 8) {
18 LabeledContent("Status", value: viewModel.monitoringSettings.isEnabled ? "Scheduled" : "Manual only")
19 LabeledContent("Domains", value: "\(monitoredDomainsCount)")
20 LabeledContent("Frequency", value: viewModel.monitoringSettings.frequency.title)
21 LabeledContent("Alerts", value: viewModel.monitoringSettings.alertsEnabled ? viewModel.monitoringSettings.alertFilter.title : "Off")
22
23 if let monitoringStatusMessage = viewModel.monitoringStatusMessage,
24 !monitoringStatusMessage.isEmpty {
25 Text(monitoringStatusMessage)
26 .font(appDensity.font(.caption))
27 .foregroundStyle(.secondary)
28 }
29
30 Button(viewModel.monitoringRunInProgress ? "Monitoring…" : "Run Now") {
31 viewModel.runMonitoringNow()
32 }
33 .disabled(viewModel.monitoringRunInProgress)
34 }
35 .padding(.vertical, 4)
36 }
37 .listRowBackground(Color(.systemGray6).opacity(0.5))
38
39 if viewModel.monitoringLogs.isEmpty {
40 Section {
41 EmptyStateCardView(
42 title: "No Monitoring Runs Yet",
43 message: "Monitoring history appears here after manual or background runs finish.",
44 suggestion: "Enable monitoring in Settings or run a manual monitoring sweep.",
45 systemImage: "waveform.path.ecg"
46 )
47 }
48 .listRowBackground(Color(.systemGray6).opacity(0.5))
49 } else {
50 Section("Recent Runs") {
51 ForEach(viewModel.monitoringLogs) { log in
52 NavigationLink {
53 MonitoringLogDetailView(viewModel: viewModel, log: log)
54 } label: {
55 VStack(alignment: .leading, spacing: 6) {
56 HStack {
57 Text(log.trigger.title)
58 .font(appDensity.font(.callout))
59 .foregroundStyle(.primary)
60 Spacer()
61 Text(log.timestamp.formatted(date: .abbreviated, time: .shortened))
62 .font(appDensity.font(.caption2))
63 .foregroundStyle(.secondary)
64 }
65
66 Text(log.summary)
67 .font(appDensity.font(.caption))
68 .foregroundStyle(.secondary)
69
70 HStack(spacing: 8) {
71 metricBadge(title: "\(log.domainsChecked) checked")
72 if log.changesFound > 0 {
73 metricBadge(title: "\(log.changesFound) changed", tint: .orange)
74 }
75 if log.alertsTriggered > 0 {
76 metricBadge(title: "\(log.alertsTriggered) alerts", tint: .red)
77 }
78 }
79 }
80 .padding(.vertical, 4)
81 }
82 }
83 }
84 .listRowBackground(Color(.systemGray6).opacity(0.5))
85 }
86 }
87 .scrollContentBackground(.hidden)
88 .background(Color.black)
89 .navigationTitle("Monitoring")
90 .preferredColorScheme(.dark)
91 .onAppear {
92 viewModel.refreshMonitoringState()
93 }
94 }
95
96 private func metricBadge(title: String, tint: Color = .cyan) -> some View {
97 Text(title)
98 .font(appDensity.font(.caption2))
99 .foregroundStyle(tint)
100 .padding(.horizontal, 8)
101 .padding(.vertical, 4)
102 .background(tint.opacity(0.16))
103 .clipShape(Capsule())
104 }
105}
106
107struct MonitoringLogDetailView: View {
108 @Environment(\.appDensity) private var appDensity
109 @Bindable var viewModel: DomainViewModel
110 let log: MonitoringLog
111
112 var body: some View {
113 List {
114 Section("Summary") {
115 LabeledContent("Trigger", value: log.trigger.title)
116 LabeledContent("Checked", value: "\(log.domainsChecked)")
117 LabeledContent("Changes", value: "\(log.changesFound)")
118 LabeledContent("Alerts", value: "\(log.alertsTriggered)")
119 LabeledContent("Timestamp", value: log.timestamp.formatted(date: .abbreviated, time: .shortened))
120 }
121 .listRowBackground(Color(.systemGray6).opacity(0.5))
122
123 Section("Domains") {
124 ForEach(log.checkedDomains) { result in
125 VStack(alignment: .leading, spacing: 6) {
126 HStack {
127 Text(result.domain)
128 .font(appDensity.font(.callout))
129 .foregroundStyle(.primary)
130 Spacer()
131 if let alertSeverity = result.alertSeverity {
132 Text(alertSeverity.title.uppercased())
133 .font(appDensity.font(.caption2))
134 .foregroundStyle(color(for: alertSeverity))
135 }
136 }
137
138 Text(result.summaryMessage)
139 .font(appDensity.font(.caption))
140 .foregroundStyle(.secondary)
141
142 HStack(spacing: 10) {
143 Text(result.resultSource.label)
144 Text(result.didChange ? "Changed" : "No change")
145 if result.certificateWarningLevel != .none {
146 Text(result.certificateWarningLevel.title)
147 }
148 }
149 .font(appDensity.font(.caption2))
150 .foregroundStyle(.secondary)
151
152 if let errorMessage = result.errorMessage {
153 Text(errorMessage)
154 .font(appDensity.font(.caption2))
155 .foregroundStyle(.yellow)
156 }
157
158 if let historyEntryID = result.historyEntryID,
159 let entry = viewModel.history.first(where: { $0.id == historyEntryID }) {
160 NavigationLink("Open Snapshot") {
161 HistoryDetailView(viewModel: viewModel, entry: entry)
162 }
163 .font(appDensity.font(.caption))
164 }
165 }
166 .padding(.vertical, 4)
167 }
168 }
169 .listRowBackground(Color(.systemGray6).opacity(0.5))
170
171 if !log.errors.isEmpty {
172 Section("Errors") {
173 ForEach(log.errors, id: \.self) { error in
174 Text(error)
175 .font(appDensity.font(.caption))
176 .foregroundStyle(.secondary)
177 }
178 }
179 .listRowBackground(Color(.systemGray6).opacity(0.5))
180 }
181 }
182 .scrollContentBackground(.hidden)
183 .background(Color.black)
184 .navigationTitle("Run Details")
185 .preferredColorScheme(.dark)
186 }
187
188 private func color(for severity: MonitoringAlertSeverity) -> Color {
189 switch severity {
190 case .info:
191 return .secondary
192 case .warning:
193 return .yellow
194 case .critical:
195 return .red
196 }
197 }
198}
DomainDig/PaywallView.swift +3 −1
@@ -10,7 +10,7 @@ struct PaywallView: View {
1010 NavigationStack {
1111 List {
1212 Section {
13 Text("Pro unlocks workflows, scale, and exports. Data+ adds deeper external intelligence with local-first usage credits and no account requirement.")
13 Text("Pro unlocks workflows, scale, monitoring automation, and exports. Data+ adds deeper external intelligence with local-first usage credits and no account requirement.")
1414 .font(appDensity.font(.body, design: .default))
1515 .foregroundStyle(.secondary)
1616 .fixedSize(horizontal: false, vertical: true)
@@ -20,6 +20,8 @@ struct PaywallView: View {
2020 featureRow("Unlimited tracked domains")
2121 featureRow("Workflows")
2222 featureRow("Larger batch sizes")
23 featureRow("Background monitoring")
24 featureRow("Local alerts")
2325 featureRow("Advanced exports")
2426 }
2527
DomainDig/PremiumAccessService.swift +4 −2
@@ -9,8 +9,10 @@ enum PremiumAccessService {
99 return FeatureAccessService.hasAccess(to: .batchOperations)
1010 case .unlimitedTrackedDomains:
1111 return FeatureAccessService.currentTier != .free
12 case .automatedMonitoring, .pushAlerts:
13 return false
12 case .automatedMonitoring:
13 return FeatureAccessService.hasAccess(to: .automatedMonitoring)
14 case .pushAlerts:
15 return FeatureAccessService.hasAccess(to: .localAlerts)
1416 }
1517 }
1618
DomainDig/RootTabView.swift +7
@@ -20,6 +20,13 @@ struct RootTabView: View {
2020 Label("Watchlist", systemImage: "eye")
2121 }
2222
23 NavigationStack {
24 MonitoringView(viewModel: viewModel)
25 }
26 .tabItem {
27 Label("Monitoring", systemImage: "waveform.path.ecg")
28 }
29
2330 NavigationStack {
2431 HistoryView(viewModel: viewModel)
2532 }
DomainDig/WatchlistView.swift +21
@@ -287,6 +287,18 @@ struct WatchlistRowView: View {
287287 .font(appDensity.font(.caption2))
288288 .foregroundStyle(.secondary)
289289
290 HStack(spacing: 8) {
291 Text(trackedDomain.monitoringEnabled ? "Monitoring on" : "Monitoring off")
292 if let lastMonitoredAt = trackedDomain.lastMonitoredAt {
293 Text("Checked \(lastMonitoredAt.formatted(date: .omitted, time: .shortened))")
294 }
295 if let lastAlertAt = trackedDomain.lastAlertAt {
296 Text("Alert \(lastAlertAt.formatted(date: .omitted, time: .shortened))")
297 }
298 }
299 .font(appDensity.font(.caption2))
300 .foregroundStyle(.secondary)
301
290302 indicatorRow
291303
292304 if let note = trackedDomain.note?.trimmingCharacters(in: .whitespacesAndNewlines), !note.isEmpty {
@@ -399,6 +411,15 @@ struct TrackedDomainDetailView: View {
399411 Label(liveTrackedDomain.isPinned ? "Unpin Domain" : "Pin Domain", systemImage: liveTrackedDomain.isPinned ? "pin.slash" : "pin")
400412 }
401413
414 Button {
415 viewModel.toggleMonitoring(for: liveTrackedDomain)
416 } label: {
417 Label(
418 liveTrackedDomain.monitoringEnabled ? "Disable Monitoring" : "Enable Monitoring",
419 systemImage: liveTrackedDomain.monitoringEnabled ? "bell.slash" : "bell"
420 )
421 }
422
402423 Button {
403424 noteDraft = liveTrackedDomain.note ?? ""
404425 isEditingNote = true
DomainDigCLI.swift +179 −7
@@ -4,14 +4,24 @@ import Foundation
44struct DomainDigCLI {
55 static func main() async {
66 let arguments = Array(CommandLine.arguments.dropFirst())
7 let wantsJSON = arguments.contains("--json") || arguments.contains("-j")
78
89 guard let command = CommandLine.arguments.first else {
9 fputs("usage: domaindig <domain> [--json] [--ownership-history] [--dns-history] [--extended-subdomains] [--pricing] [--show-usage]\n", stderr)
10 fputs(usageText, stderr)
1011 Foundation.exit(1)
1112 }
1213 _ = command
1314
14 let wantsJSON = arguments.contains("--json") || arguments.contains("-j")
15 if arguments.first == "backup" {
16 runBackupCommand(arguments: Array(arguments.dropFirst()), wantsJSON: wantsJSON)
17 return
18 }
19
20 if arguments.first == "monitor" {
21 await runMonitorCommand(wantsJSON: wantsJSON)
22 return
23 }
24
1525 let wantsOwnershipHistory = arguments.contains("--ownership-history")
1626 let wantsDNSHistory = arguments.contains("--dns-history")
1727 let wantsExtendedSubdomains = arguments.contains("--extended-subdomains")
@@ -24,7 +34,7 @@ struct DomainDigCLI {
2434 .filter { !$0.isEmpty }
2535
2636 guard !requestedDomains.isEmpty else {
27 fputs("usage: domaindig <domain> [--json] [--ownership-history] [--dns-history] [--extended-subdomains] [--pricing] [--show-usage]\n", stderr)
37 fputs(usageText, stderr)
2838 Foundation.exit(1)
2939 }
3040
@@ -252,10 +262,172 @@ struct DomainDigCLI {
252262 }
253263
254264 private static func loadHistoryEntries() -> [HistoryEntry] {
255 guard let data = UserDefaults.standard.data(forKey: "lookupHistory"),
256 let entries = try? JSONDecoder().decode([HistoryEntry].self, from: data) else {
257 return []
265 DomainDataPortabilityService.loadHistoryEntries()
266 }
267
268 private static func runBackupCommand(arguments: [String], wantsJSON: Bool) {
269 guard let subcommand = arguments.first else {
270 fputs(usageText, stderr)
271 Foundation.exit(1)
272 }
273
274 switch subcommand {
275 case "export":
276 do {
277 let data = try DomainDataPortabilityService.backupData()
278 let outputPath = arguments.dropFirst().first(where: { !$0.hasPrefix("-") })
279 if let outputPath {
280 try data.write(to: URL(fileURLWithPath: outputPath), options: .atomic)
281 } else {
282 FileHandle.standardOutput.write(data)
283 if data.last != 0x0A {
284 FileHandle.standardOutput.write(Data([0x0A]))
285 }
286 }
287 } catch {
288 fputs("domaindig backup export: \(error.localizedDescription)\n", stderr)
289 Foundation.exit(1)
290 }
291 case "validate":
292 guard let path = arguments.dropFirst().first(where: { !$0.hasPrefix("-") }) else {
293 fputs("usage: domaindig backup validate <path>\n", stderr)
294 Foundation.exit(1)
295 }
296
297 do {
298 let data = try Data(contentsOf: URL(fileURLWithPath: path))
299 let report = try DomainDataPortabilityService.validateBackup(data: data, fileName: URL(fileURLWithPath: path).lastPathComponent)
300 if wantsJSON {
301 let payload = [
302 "warnings": report.warnings,
303 "errors": report.errors
304 ]
305 let encoded = try JSONSerialization.data(withJSONObject: payload, options: [.prettyPrinted, .sortedKeys])
306 FileHandle.standardOutput.write(encoded)
307 } else {
308 let lines = [
309 "Warnings: \(report.warnings.count)",
310 "Errors: \(report.errors.count)"
311 ] + report.warnings.map { "warning: \($0)" } + report.errors.map { "error: \($0)" }
312 FileHandle.standardOutput.write(Data(lines.joined(separator: "\n").utf8))
313 }
314 FileHandle.standardOutput.write(Data([0x0A]))
315 if !report.errors.isEmpty {
316 Foundation.exit(1)
317 }
318 } catch {
319 fputs("domaindig backup validate: \(error.localizedDescription)\n", stderr)
320 Foundation.exit(1)
321 }
322 case "import":
323 guard let path = arguments.dropFirst().first(where: { !$0.hasPrefix("-") }) else {
324 fputs("usage: domaindig backup import <path> [--replace]\n", stderr)
325 Foundation.exit(1)
326 }
327
328 let mode: DataPortabilityImportMode = arguments.contains("--replace") ? .replace : .merge
329
330 do {
331 let fileURL = URL(fileURLWithPath: path)
332 let data = try Data(contentsOf: fileURL)
333 let preview = try DomainDataPortabilityService.prepareImport(
334 data: data,
335 fileName: fileURL.lastPathComponent,
336 mode: mode
337 )
338 guard preview.kind == .backup else {
339 fputs("domaindig backup import: expected a full backup file\n", stderr)
340 Foundation.exit(1)
341 }
342 let result = try DomainDataPortabilityService.applyImport(preview, mode: mode)
343 let lines = [result.summary] + result.warnings.map { "warning: \($0)" }
344 FileHandle.standardOutput.write(Data(lines.joined(separator: "\n").utf8))
345 FileHandle.standardOutput.write(Data([0x0A]))
346 } catch {
347 fputs("domaindig backup import: \(error.localizedDescription)\n", stderr)
348 Foundation.exit(1)
349 }
350 default:
351 fputs(usageText, stderr)
352 Foundation.exit(1)
353 }
354 }
355
356 private static func runMonitorCommand(wantsJSON: Bool) async {
357 guard FeatureAccessService.hasAccess(to: .automatedMonitoring) else {
358 fputs("domaindig monitor: monitoring requires Pro\n", stderr)
359 Foundation.exit(1)
360 }
361
362 let outcome = await DomainMonitoringService.shared.performMonitoring(
363 trigger: .cli,
364 requireEnabledSetting: false
365 )
366
367 guard let log = outcome.log else {
368 fputs("domaindig monitor: \(outcome.message)\n", stderr)
369 Foundation.exit(1)
370 }
371
372 do {
373 let output: Data
374 if wantsJSON {
375 let encoder = JSONEncoder()
376 encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
377 output = try encoder.encode(log)
378 } else {
379 output = Data(monitoringTextSummary(for: log).utf8)
380 }
381 FileHandle.standardOutput.write(output)
382 if output.last != 0x0A {
383 FileHandle.standardOutput.write(Data([0x0A]))
384 }
385 if !outcome.success {
386 Foundation.exit(1)
387 }
388 } catch {
389 fputs("domaindig monitor: \(error.localizedDescription)\n", stderr)
390 Foundation.exit(1)
391 }
392 }
393
394 private static func monitoringTextSummary(for log: MonitoringLog) -> String {
395 var lines = [
396 "DomainDig Monitoring",
397 "====================",
398 "Trigger: \(log.trigger.title)",
399 "Timestamp: \(log.timestamp.formatted(date: .abbreviated, time: .shortened))",
400 "Checked: \(log.domainsChecked)",
401 "Changes: \(log.changesFound)",
402 "Alerts: \(log.alertsTriggered)",
403 ""
404 ]
405
406 if log.checkedDomains.isEmpty {
407 lines.append("No domains were checked.")
408 } else {
409 for result in log.checkedDomains {
410 let severity = result.alertSeverity?.title ?? "None"
411 lines.append("\(result.domain): \(result.summaryMessage) [alert: \(severity)]")
412 }
413 }
414
415 if !log.errors.isEmpty {
416 lines.append("")
417 lines.append("Errors:")
418 lines.append(contentsOf: log.errors.map { "- \($0)" })
258419 }
259 return entries
420
421 return lines.joined(separator: "\n")
422 }
423
424 private static var usageText: String {
425 """
426 usage: domaindig <domain> [--json] [--ownership-history] [--dns-history] [--extended-subdomains] [--pricing] [--show-usage]
427 domaindig monitor [--json]
428 domaindig backup export [path]
429 domaindig backup import <path> [--replace]
430 domaindig backup validate <path> [--json]
431 """
260432 }
261433}