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 @@
16 8BBFEFCB2F987E8700E8E144 /* DomainReportBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BBFEF042F9874AE00E8E144 /* DomainReportBuilder.swift */; }; 16 8BBFEFCB2F987E8700E8E144 /* DomainReportBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BBFEF042F9874AE00E8E144 /* DomainReportBuilder.swift */; };
17 8BBFEFCC2F987E8700E8E144 /* DomainReportExporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BBFEF052F9874AE00E8E144 /* DomainReportExporter.swift */; }; 17 8BBFEFCC2F987E8700E8E144 /* DomainReportExporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BBFEF052F9874AE00E8E144 /* DomainReportExporter.swift */; };
18 8BBFEFCD2F987E8700E8E144 /* LookupSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BBFEF062F9874AE00E8E144 /* LookupSnapshot.swift */; }; 18 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 */; };
19/* End PBXBuildFile section */ 21/* End PBXBuildFile section */
20 22
21/* Begin PBXFileReference section */ 23/* Begin PBXFileReference section */
@@ -26,6 +28,7 @@
26 8BBFEF052F9874AE00E8E144 /* DomainReportExporter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DomainReportExporter.swift; sourceTree = "<group>"; }; 28 8BBFEF052F9874AE00E8E144 /* DomainReportExporter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DomainReportExporter.swift; sourceTree = "<group>"; };
27 8BBFEF062F9874AE00E8E144 /* LookupSnapshot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LookupSnapshot.swift; sourceTree = "<group>"; }; 29 8BBFEF062F9874AE00E8E144 /* LookupSnapshot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LookupSnapshot.swift; sourceTree = "<group>"; };
28 8BF124F92F70000100933221 /* domaindig */ = {isa = PBXFileReference; explicitFileType = "compiled.mach-o.executable"; includeInIndex = 0; path = domaindig; sourceTree = BUILT_PRODUCTS_DIR; }; 30 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>"; };
29/* End PBXFileReference section */ 32/* End PBXFileReference section */
30 33
31/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ 34/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
@@ -95,6 +98,7 @@
95 8BBFEF032F9874AE00E8E144 /* DomainInspectionService.swift */, 98 8BBFEF032F9874AE00E8E144 /* DomainInspectionService.swift */,
96 8BBFEF052F9874AE00E8E144 /* DomainReportExporter.swift */, 99 8BBFEF052F9874AE00E8E144 /* DomainReportExporter.swift */,
97 8BBFEF022F9874AE00E8E144 /* DomainDigCLI.swift */, 100 8BBFEF022F9874AE00E8E144 /* DomainDigCLI.swift */,
101 8BF9DA842F9B13FB00EF41D5 /* DomainDataPortabilityService.swift */,
98 ); 102 );
99 sourceTree = "<group>"; 103 sourceTree = "<group>";
100 }; 104 };
@@ -206,6 +210,7 @@
206 8BBFEF092F9874AE00E8E144 /* DomainInspectionService.swift in Sources */, 210 8BBFEF092F9874AE00E8E144 /* DomainInspectionService.swift in Sources */,
207 8BBFEF0A2F9874AE00E8E144 /* DomainReportBuilder.swift in Sources */, 211 8BBFEF0A2F9874AE00E8E144 /* DomainReportBuilder.swift in Sources */,
208 8BBFEF0B2F9874AE00E8E144 /* DomainReportExporter.swift in Sources */, 212 8BBFEF0B2F9874AE00E8E144 /* DomainReportExporter.swift in Sources */,
213 8BF9DA872F9B13FB00EF41D5 /* DomainDataPortabilityService.swift in Sources */,
209 8BBFEF0C2F9874AE00E8E144 /* LookupSnapshot.swift in Sources */, 214 8BBFEF0C2F9874AE00E8E144 /* LookupSnapshot.swift in Sources */,
210 ); 215 );
211 runOnlyForDeploymentPostprocessing = 0; 216 runOnlyForDeploymentPostprocessing = 0;
@@ -215,6 +220,7 @@
215 buildActionMask = 2147483647; 220 buildActionMask = 2147483647;
216 files = ( 221 files = (
217 8BBFEF082F9874AE00E8E144 /* DomainDigCLI.swift in Sources */, 222 8BBFEF082F9874AE00E8E144 /* DomainDigCLI.swift in Sources */,
223 8BF9DA862F9B13FB00EF41D5 /* DomainDataPortabilityService.swift in Sources */,
218 8BBFEFCA2F987E8700E8E144 /* DomainInspectionService.swift in Sources */, 224 8BBFEFCA2F987E8700E8E144 /* DomainInspectionService.swift in Sources */,
219 8BBFEFCB2F987E8700E8E144 /* DomainReportBuilder.swift in Sources */, 225 8BBFEFCB2F987E8700E8E144 /* DomainReportBuilder.swift in Sources */,
220 8BBFEFCC2F987E8700E8E144 /* DomainReportExporter.swift in Sources */, 226 8BBFEFCC2F987E8700E8E144 /* DomainReportExporter.swift in Sources */,
@@ -354,7 +360,7 @@
354 ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 360 ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
355 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 361 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
356 CODE_SIGN_STYLE = Automatic; 362 CODE_SIGN_STYLE = Automatic;
357 CURRENT_PROJECT_VERSION = 22; 363 CURRENT_PROJECT_VERSION = 26;
358 DEVELOPMENT_TEAM = ZCNAX3VL9D; 364 DEVELOPMENT_TEAM = ZCNAX3VL9D;
359 ENABLE_PREVIEWS = YES; 365 ENABLE_PREVIEWS = YES;
360 GENERATE_INFOPLIST_FILE = YES; 366 GENERATE_INFOPLIST_FILE = YES;
@@ -371,7 +377,7 @@
371 "$(inherited)", 377 "$(inherited)",
372 "@executable_path/Frameworks", 378 "@executable_path/Frameworks",
373 ); 379 );
374 MARKETING_VERSION = 2.9.0; 380 MARKETING_VERSION = 3.4.0;
375 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig; 381 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig;
376 PRODUCT_NAME = "$(TARGET_NAME)"; 382 PRODUCT_NAME = "$(TARGET_NAME)";
377 STRING_CATALOG_GENERATE_SYMBOLS = YES; 383 STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -390,7 +396,7 @@
390 ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 396 ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
391 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 397 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
392 CODE_SIGN_STYLE = Automatic; 398 CODE_SIGN_STYLE = Automatic;
393 CURRENT_PROJECT_VERSION = 22; 399 CURRENT_PROJECT_VERSION = 26;
394 DEVELOPMENT_TEAM = ZCNAX3VL9D; 400 DEVELOPMENT_TEAM = ZCNAX3VL9D;
395 ENABLE_PREVIEWS = YES; 401 ENABLE_PREVIEWS = YES;
396 GENERATE_INFOPLIST_FILE = YES; 402 GENERATE_INFOPLIST_FILE = YES;
@@ -407,7 +413,7 @@
407 "$(inherited)", 413 "$(inherited)",
408 "@executable_path/Frameworks", 414 "@executable_path/Frameworks",
409 ); 415 );
410 MARKETING_VERSION = 2.9.0; 416 MARKETING_VERSION = 3.4.0;
411 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig; 417 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig;
412 PRODUCT_NAME = "$(TARGET_NAME)"; 418 PRODUCT_NAME = "$(TARGET_NAME)";
413 STRING_CATALOG_GENERATE_SYMBOLS = YES; 419 STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -426,6 +432,7 @@
426 COPY_PHASE_STRIP = NO; 432 COPY_PHASE_STRIP = NO;
427 GCC_DYNAMIC_NO_PIC = NO; 433 GCC_DYNAMIC_NO_PIC = NO;
428 GCC_OPTIMIZATION_LEVEL = 0; 434 GCC_OPTIMIZATION_LEVEL = 0;
435 IPHONEOS_DEPLOYMENT_TARGET = 17.6;
429 PRODUCT_NAME = domaindig; 436 PRODUCT_NAME = domaindig;
430 }; 437 };
431 name = Debug; 438 name = Debug;
@@ -435,6 +442,7 @@
435 buildSettings = { 442 buildSettings = {
436 COPY_PHASE_STRIP = YES; 443 COPY_PHASE_STRIP = YES;
437 DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; 444 DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
445 IPHONEOS_DEPLOYMENT_TARGET = 17.6;
438 PRODUCT_NAME = domaindig; 446 PRODUCT_NAME = domaindig;
439 }; 447 };
440 name = Release; 448 name = Release;
DomainDig/AppVersion.swift +1 −1
@@ -2,6 +2,6 @@ import Foundation
2 2
3enum AppVersion { 3enum AppVersion {
4 nonisolated static var current: String { 4 nonisolated static var current: String {
5 "3.2.0" 5 "3.4.0"
6 } 6 }
7} 7}
DomainDig/ContentView.swift +376 −1
@@ -1,5 +1,6 @@
1import MapKit 1import MapKit
2import SwiftUI 2import SwiftUI
3import UniformTypeIdentifiers
3 4
4enum LookupInputMode: String, CaseIterable, Identifiable { 5enum LookupInputMode: String, CaseIterable, Identifiable {
5 case single 6 case single
@@ -2431,6 +2432,13 @@ struct SettingsView: View {
2431 @State private var showClearCacheConfirmation = false 2432 @State private var showClearCacheConfirmation = false
2432 @State private var showClearWorkflowsConfirmation = false 2433 @State private var showClearWorkflowsConfirmation = false
2433 @State private var showClearTrackedDomainsConfirmation = false 2434 @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
2434 2442
2435 private var customResolverError: String? { 2443 private var customResolverError: String? {
2436 guard resolverOption == .custom else { 2444 guard resolverOption == .custom else {
@@ -2472,6 +2480,161 @@ struct SettingsView: View {
2472 } 2480 }
2473 } 2481 }
2474 2482
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
2475 Section("Data") { 2638 Section("Data") {
2476 Button("Clear History", role: .destructive) { 2639 Button("Clear History", role: .destructive) {
2477 showClearHistoryConfirmation = true 2640 showClearHistoryConfirmation = true
@@ -2551,7 +2714,7 @@ struct SettingsView: View {
2551 Section("About") { 2714 Section("About") {
2552 LabeledContent("Version", value: appVersion) 2715 LabeledContent("Version", value: appVersion)
2553 LabeledContent("Storage", value: "Local-only") 2716 LabeledContent("Storage", value: "Local-only")
2554 LabeledContent("Report Schema", value: "3.2.0") 2717 LabeledContent("Backup Schema", value: "v\(DomainDigBackup.currentSchemaVersion)")
2555 } 2718 }
2556 } 2719 }
2557 .navigationTitle("Settings") 2720 .navigationTitle("Settings")
@@ -2587,12 +2750,73 @@ struct SettingsView: View {
2587 } message: { 2750 } message: {
2588 Text("This removes the watchlist only. History and workflows stay intact.") 2751 Text("This removes the watchlist only. History and workflows stay intact.")
2589 } 2752 }
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 }
2590 .onAppear { 2811 .onAppear {
2591 let currentResolverURL = storedResolverURL.trimmingCharacters(in: .whitespacesAndNewlines) 2812 let currentResolverURL = storedResolverURL.trimmingCharacters(in: .whitespacesAndNewlines)
2592 resolverOption = DNSResolverOption.option(for: currentResolverURL) 2813 resolverOption = DNSResolverOption.option(for: currentResolverURL)
2593 customResolverURL = resolverOption == .custom ? currentResolverURL : DNSResolverOption.defaultURLString 2814 customResolverURL = resolverOption == .custom ? currentResolverURL : DNSResolverOption.defaultURLString
2815 viewModel.refreshMonitoringState()
2816 viewModel.refreshDataLifecycleSummary()
2594 Task { 2817 Task {
2595 await viewModel.refreshUsageCredits() 2818 await viewModel.refreshUsageCredits()
2819 await viewModel.refreshMonitoringAuthorizationStatus()
2596 } 2820 }
2597 } 2821 }
2598 .onChange(of: resolverOption) { _, newValue in 2822 .onChange(of: resolverOption) { _, newValue in
@@ -2611,6 +2835,157 @@ struct SettingsView: View {
2611 private var appVersion: String { 2835 private var appVersion: String {
2612 AppVersion.current 2836 AppVersion.current
2613 } 2837 }
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 }
2614} 2989}
2615 2990
2616#Preview { 2991#Preview {
DomainDig/DomainDigApp.swift +13
@@ -9,12 +9,14 @@ import SwiftUI
9 9
10@main 10@main
11struct DomainDigApp: App { 11struct DomainDigApp: App {
12 @Environment(\.scenePhase) private var scenePhase
12 @AppStorage(AppDensity.userDefaultsKey) private var density = AppDensity.compact.rawValue 13 @AppStorage(AppDensity.userDefaultsKey) private var density = AppDensity.compact.rawValue
13 @State private var viewModel = DomainViewModel() 14 @State private var viewModel = DomainViewModel()
14 @State private var purchaseService = PurchaseService.shared 15 @State private var purchaseService = PurchaseService.shared
15 16
16 init() { 17 init() {
17 LocalNotificationService.shared.configureForegroundPresentation() 18 LocalNotificationService.shared.configureForegroundPresentation()
19 DomainMonitoringScheduler.shared.registerBackgroundTask()
18 } 20 }
19 21
20 var body: some Scene { 22 var body: some Scene {
@@ -24,7 +26,18 @@ struct DomainDigApp: App {
24 .task { 26 .task {
25 let _ = purchaseService.currentTier 27 let _ = purchaseService.currentTier
26 await purchaseService.refreshEntitlements() 28 await purchaseService.refreshEntitlements()
29 viewModel.refreshMonitoringState()
30 await viewModel.refreshMonitoringAuthorizationStatus()
31 viewModel.monitoringStatusMessage = DomainMonitoringScheduler.shared.syncSchedule()
27 } 32 }
28 } 33 }
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 }
29 } 42 }
30} 43}
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 @@
1import Foundation 1import Foundation
2import SwiftUI 2import SwiftUI
3import UserNotifications
3 4
4enum ResultTone { 5enum ResultTone {
5 case primary 6 case primary
@@ -218,10 +219,10 @@ final class DomainViewModel {
218 219
219 private static let recentSearchesKey = "recentSearches" 220 private static let recentSearchesKey = "recentSearches"
220 private static let maxRecent = 20 221 private static let maxRecent = 20
221 var recentSearches: [String] = UserDefaults.standard.stringArray(forKey: recentSearchesKey) ?? [] 222 var recentSearches: [String] = DomainDataPortabilityService.loadRecentSearches()
222 223
223 private static let savedDomainsKey = "savedDomains" 224 private static let savedDomainsKey = "savedDomains"
224 var savedDomains: [String] = UserDefaults.standard.stringArray(forKey: savedDomainsKey) ?? [] 225 var savedDomains: [String] = DomainDataPortabilityService.loadSavedDomains()
225 226
226 private static let trackedDomainsKey = "trackedDomains" 227 private static let trackedDomainsKey = "trackedDomains"
227 private static let legacyWatchedDomainsKey = "watchedDomains" 228 private static let legacyWatchedDomainsKey = "watchedDomains"
@@ -239,6 +240,13 @@ final class DomainViewModel {
239 var watchlistSearchText = "" 240 var watchlistSearchText = ""
240 var watchlistFilter: WatchlistFilterOption = .all 241 var watchlistFilter: WatchlistFilterOption = .all
241 var watchlistSortOption: WatchlistSortOption = .pinned 242 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?
242 var upgradePrompt: UpgradePromptContext? 250 var upgradePrompt: UpgradePromptContext?
243 var isPaywallPresented = false 251 var isPaywallPresented = false
244 252
@@ -598,12 +606,14 @@ final class DomainViewModel {
598 } else { 606 } else {
599 savedDomains.append(searchedDomain) 607 savedDomains.append(searchedDomain)
600 } 608 }
601 UserDefaults.standard.set(savedDomains, forKey: Self.savedDomainsKey) 609 DomainDataPortabilityService.saveSavedDomains(savedDomains)
610 refreshDataLifecycleSummary()
602 } 611 }
603 612
604 func removeSavedDomains(at offsets: IndexSet) { 613 func removeSavedDomains(at offsets: IndexSet) {
605 savedDomains.remove(atOffsets: offsets) 614 savedDomains.remove(atOffsets: offsets)
606 UserDefaults.standard.set(savedDomains, forKey: Self.savedDomainsKey) 615 DomainDataPortabilityService.saveSavedDomains(savedDomains)
616 refreshDataLifecycleSummary()
607 } 617 }
608 618
609 @discardableResult 619 @discardableResult
@@ -636,6 +646,7 @@ final class DomainViewModel {
636 at: 0 646 at: 0
637 ) 647 )
638 persistTrackedDomains() 648 persistTrackedDomains()
649 sanitizeMonitoringSelection()
639 linkTrackedDomainHistory(for: normalizedDomain) 650 linkTrackedDomainHistory(for: normalizedDomain)
640 return true 651 return true
641 } 652 }
@@ -664,6 +675,7 @@ final class DomainViewModel {
664 } 675 }
665 persistTrackedDomains() 676 persistTrackedDomains()
666 persistHistory() 677 persistHistory()
678 sanitizeMonitoringSelection()
667 } 679 }
668 680
669 func deleteTrackedDomain(_ trackedDomain: TrackedDomain) { 681 func deleteTrackedDomain(_ trackedDomain: TrackedDomain) {
@@ -675,6 +687,7 @@ final class DomainViewModel {
675 } 687 }
676 persistTrackedDomains() 688 persistTrackedDomains()
677 persistHistory() 689 persistHistory()
690 sanitizeMonitoringSelection()
678 } 691 }
679 692
680 func togglePinned(for trackedDomain: TrackedDomain) { 693 func togglePinned(for trackedDomain: TrackedDomain) {
@@ -702,6 +715,7 @@ final class DomainViewModel {
702 func clearHistory() { 715 func clearHistory() {
703 history.removeAll() 716 history.removeAll()
704 persistHistory() 717 persistHistory()
718 refreshDataLifecycleSummary()
705 } 719 }
706 720
707 func clearLookupCache() { 721 func clearLookupCache() {
@@ -714,17 +728,157 @@ final class DomainViewModel {
714 workflows.removeAll() 728 workflows.removeAll()
715 latestWorkflowRunSummary = nil 729 latestWorkflowRunSummary = nil
716 persistWorkflows() 730 persistWorkflows()
731 refreshDataLifecycleSummary()
717 } 732 }
718 733
719 func clearTrackedDomains() { 734 func clearTrackedDomains() {
720 trackedDomains.removeAll() 735 trackedDomains.removeAll()
721 refreshingTrackedDomainID = nil 736 refreshingTrackedDomainID = nil
722 persistTrackedDomains() 737 persistTrackedDomains()
738 sanitizeMonitoringSelection()
739 refreshDataLifecycleSummary()
723 } 740 }
724 741
725 func clearRecentSearches() { 742 func clearRecentSearches() {
726 recentSearches.removeAll() 743 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 }
728 } 882 }
729 883
730 func rerunLookup(from entry: HistoryEntry, useSnapshotResolver: Bool) { 884 func rerunLookup(from entry: HistoryEntry, useSnapshotResolver: Bool) {
@@ -1159,6 +1313,45 @@ final class DomainViewModel {
1159 ) 1313 )
1160 } 1314 }
1161 1315
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
1162 func exportWorkflowText(summary: WorkflowRunSummary, changedOnly: Bool) -> String { 1355 func exportWorkflowText(summary: WorkflowRunSummary, changedOnly: Bool) -> String {
1163 let reports = workflowReports(from: summary, changedOnly: changedOnly) 1356 let reports = workflowReports(from: summary, changedOnly: changedOnly)
1164 let base = DomainReportExporter.batchText( 1357 let base = DomainReportExporter.batchText(
@@ -1806,9 +1999,8 @@ final class DomainViewModel {
1806 } 1999 }
1807 2000
1808 private func persistHistory() { 2001 private func persistHistory() {
1809 if let data = try? JSONEncoder().encode(history) { 2002 DomainDataPortabilityService.saveHistoryEntries(history)
1810 UserDefaults.standard.set(data, forKey: Self.historyKey) 2003 refreshDataLifecycleSummary()
1811 }
1812 } 2004 }
1813 2005
1814 func updateHistoryNote(_ note: String, for entry: HistoryEntry) { 2006 func updateHistoryNote(_ note: String, for entry: HistoryEntry) {
@@ -1818,9 +2010,18 @@ final class DomainViewModel {
1818 } 2010 }
1819 2011
1820 private func persistTrackedDomains() { 2012 private func persistTrackedDomains() {
1821 if let data = try? JSONEncoder().encode(trackedDomains) { 2013 DomainDataPortabilityService.saveTrackedDomains(trackedDomains)
1822 UserDefaults.standard.set(data, forKey: Self.trackedDomainsKey) 2014 refreshDataLifecycleSummary()
1823 } 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)
1824 } 2025 }
1825 2026
1826 private func updateTrackedDomainAvailability(for domain: String, status: DomainAvailabilityStatus) { 2027 private func updateTrackedDomainAvailability(for domain: String, status: DomainAvailabilityStatus) {
@@ -1948,7 +2149,8 @@ final class DomainViewModel {
1948 if recentSearches.count > Self.maxRecent { 2149 if recentSearches.count > Self.maxRecent {
1949 recentSearches = Array(recentSearches.prefix(Self.maxRecent)) 2150 recentSearches = Array(recentSearches.prefix(Self.maxRecent))
1950 } 2151 }
1951 UserDefaults.standard.set(recentSearches, forKey: Self.recentSearchesKey) 2152 DomainDataPortabilityService.saveRecentSearches(recentSearches)
2153 refreshDataLifecycleSummary()
1952 } 2154 }
1953 2155
1954 private func beginLookup(for target: String, cancelExistingTask: Bool = true) -> UUID { 2156 private func beginLookup(for target: String, cancelExistingTask: Bool = true) -> UUID {
@@ -2510,76 +2712,23 @@ final class DomainViewModel {
2510 } 2712 }
2511 2713
2512 private static func loadHistoryEntries() -> [HistoryEntry] { 2714 private static func loadHistoryEntries() -> [HistoryEntry] {
2513 let defaults = UserDefaults.standard 2715 DataMigrationService.migrateIfNeeded()
2514 guard let data = defaults.data(forKey: historyKey) else { 2716 return DomainDataPortabilityService.loadHistoryEntries()
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 }
2535 } 2717 }
2536 2718
2537 private static func loadTrackedDomains() -> [TrackedDomain] { 2719 private static func loadTrackedDomains() -> [TrackedDomain] {
2538 let defaults = UserDefaults.standard 2720 DataMigrationService.migrateIfNeeded()
2539 let decoder = JSONDecoder() 2721 return DomainDataPortabilityService.loadTrackedDomains()
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 []
2562 } 2722 }
2563 2723
2564 private func persistWorkflows() { 2724 private func persistWorkflows() {
2565 if let data = try? JSONEncoder().encode(workflows) { 2725 DomainDataPortabilityService.saveWorkflows(workflows)
2566 UserDefaults.standard.set(data, forKey: Self.workflowsKey) 2726 refreshDataLifecycleSummary()
2567 }
2568 } 2727 }
2569 2728
2570 private static func loadWorkflows() -> [DomainWorkflow] { 2729 private static func loadWorkflows() -> [DomainWorkflow] {
2571 let defaults = UserDefaults.standard 2730 DataMigrationService.migrateIfNeeded()
2572 guard let data = defaults.data(forKey: workflowsKey), 2731 return DomainDataPortabilityService.loadWorkflows()
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 }
2583 } 2732 }
2584 2733
2585 private static func deduplicatedTrackedDomains(_ domains: [TrackedDomain]) -> [TrackedDomain] { 2734 private static func deduplicatedTrackedDomains(_ domains: [TrackedDomain]) -> [TrackedDomain] {
DomainDig/FeatureAccessService.swift +17 −2
@@ -25,6 +25,8 @@ enum FeatureCapability: String, CaseIterable, Identifiable {
25 case limitedTracking 25 case limitedTracking
26 case workflows 26 case workflows
27 case batchOperations 27 case batchOperations
28 case automatedMonitoring
29 case localAlerts
28 case advancedExports 30 case advancedExports
29 case ownershipHistory 31 case ownershipHistory
30 case dnsHistory 32 case dnsHistory
@@ -45,6 +47,10 @@ enum FeatureCapability: String, CaseIterable, Identifiable {
45 return "Workflows" 47 return "Workflows"
46 case .batchOperations: 48 case .batchOperations:
47 return "Batch operations" 49 return "Batch operations"
50 case .automatedMonitoring:
51 return "Background monitoring"
52 case .localAlerts:
53 return "Local alerts"
48 case .advancedExports: 54 case .advancedExports:
49 return "Advanced exports" 55 return "Advanced exports"
50 case .ownershipHistory: 56 case .ownershipHistory:
@@ -94,7 +100,16 @@ enum FeatureAccessService {
94 case .pro: 100 case .pro:
95 return FeatureEntitlements( 101 return FeatureEntitlements(
96 tier: .pro, 102 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 ],
98 trackedDomainLimit: effectivelyUnlimitedTrackedDomains, 113 trackedDomainLimit: effectivelyUnlimitedTrackedDomains,
99 workflowLimit: nil, 114 workflowLimit: nil,
100 batchSizeLimit: nil 115 batchSizeLimit: nil
@@ -140,7 +155,7 @@ enum FeatureAccessService {
140 155
141 static func upgradeMessage(for capability: FeatureCapability) -> String { 156 static func upgradeMessage(for capability: FeatureCapability) -> String {
142 switch capability { 157 switch capability {
143 case .workflows, .batchOperations, .advancedExports: 158 case .workflows, .batchOperations, .automatedMonitoring, .localAlerts, .advancedExports:
144 return "Available in Pro" 159 return "Available in Pro"
145 case .ownershipHistory, .dnsHistory, .extendedSubdomains, .domainPricing: 160 case .ownershipHistory, .dnsHistory, .extendedSubdomains, .domainPricing:
146 return "Available in Data+" 161 return "Available in Data+"
DomainDig/Info.plist +8
@@ -6,5 +6,13 @@
6 <true/> 6 <true/>
7 <key>App ​Transport ​Security ​Settings (or NSApp​Transport​Security)</key> 7 <key>App ​Transport ​Security ​Settings (or NSApp​Transport​Security)</key>
8 <dict/> 8 <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>
9</dict> 17</dict>
10</plist> 18</plist>
DomainDig/LocalNotificationService.swift +33
@@ -27,6 +27,18 @@ final class LocalNotificationService {
27 } 27 }
28 } 28 }
29 29
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
30 func notifyDomainEvent(domain: String, message: String, severity: ChangeSeverity) async { 42 func notifyDomainEvent(domain: String, message: String, severity: ChangeSeverity) async {
31 await schedule( 43 await schedule(
32 identifier: "domain-change-\(domain)", 44 identifier: "domain-change-\(domain)",
@@ -45,6 +57,27 @@ final class LocalNotificationService {
45 ) 57 )
46 } 58 }
47 59
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
48 func notifySweepComplete(summary: BatchSweepSummary) async { 81 func notifySweepComplete(summary: BatchSweepSummary) async {
49 let body = "\(summary.changedDomains) changed, \(summary.warningDomains) warnings, \(summary.unchangedDomains) unchanged" 82 let body = "\(summary.changedDomains) changed, \(summary.warningDomains) warnings, \(summary.unchangedDomains) unchanged"
50 await schedule( 83 await schedule(
DomainDig/Models.swift +222 −1
@@ -591,12 +591,15 @@ struct TrackedDomain: Codable, Identifiable, Equatable {
591 var updatedAt: Date 591 var updatedAt: Date
592 var note: String? 592 var note: String?
593 var isPinned: Bool 593 var isPinned: Bool
594 var monitoringEnabled: Bool
594 var lastKnownAvailability: DomainAvailabilityStatus? 595 var lastKnownAvailability: DomainAvailabilityStatus?
595 var lastSnapshotID: UUID? 596 var lastSnapshotID: UUID?
596 var lastChangeSummary: DomainChangeSummary? 597 var lastChangeSummary: DomainChangeSummary?
597 var lastChangeSeverity: ChangeSeverity? 598 var lastChangeSeverity: ChangeSeverity?
598 var certificateWarningLevel: CertificateWarningLevel 599 var certificateWarningLevel: CertificateWarningLevel
599 var certificateDaysRemaining: Int? 600 var certificateDaysRemaining: Int?
601 var lastMonitoredAt: Date?
602 var lastAlertAt: Date?
600 603
601 init( 604 init(
602 id: UUID = UUID(), 605 id: UUID = UUID(),
@@ -605,12 +608,15 @@ struct TrackedDomain: Codable, Identifiable, Equatable {
605 updatedAt: Date = Date(), 608 updatedAt: Date = Date(),
606 note: String? = nil, 609 note: String? = nil,
607 isPinned: Bool = false, 610 isPinned: Bool = false,
611 monitoringEnabled: Bool = true,
608 lastKnownAvailability: DomainAvailabilityStatus? = nil, 612 lastKnownAvailability: DomainAvailabilityStatus? = nil,
609 lastSnapshotID: UUID? = nil, 613 lastSnapshotID: UUID? = nil,
610 lastChangeSummary: DomainChangeSummary? = nil, 614 lastChangeSummary: DomainChangeSummary? = nil,
611 lastChangeSeverity: ChangeSeverity? = nil, 615 lastChangeSeverity: ChangeSeverity? = nil,
612 certificateWarningLevel: CertificateWarningLevel = .none, 616 certificateWarningLevel: CertificateWarningLevel = .none,
613 certificateDaysRemaining: Int? = nil 617 certificateDaysRemaining: Int? = nil,
618 lastMonitoredAt: Date? = nil,
619 lastAlertAt: Date? = nil
614 ) { 620 ) {
615 self.id = id 621 self.id = id
616 self.domain = domain 622 self.domain = domain
@@ -618,12 +624,15 @@ struct TrackedDomain: Codable, Identifiable, Equatable {
618 self.updatedAt = updatedAt 624 self.updatedAt = updatedAt
619 self.note = note 625 self.note = note
620 self.isPinned = isPinned 626 self.isPinned = isPinned
627 self.monitoringEnabled = monitoringEnabled
621 self.lastKnownAvailability = lastKnownAvailability 628 self.lastKnownAvailability = lastKnownAvailability
622 self.lastSnapshotID = lastSnapshotID 629 self.lastSnapshotID = lastSnapshotID
623 self.lastChangeSummary = lastChangeSummary 630 self.lastChangeSummary = lastChangeSummary
624 self.lastChangeSeverity = lastChangeSeverity 631 self.lastChangeSeverity = lastChangeSeverity
625 self.certificateWarningLevel = certificateWarningLevel 632 self.certificateWarningLevel = certificateWarningLevel
626 self.certificateDaysRemaining = certificateDaysRemaining 633 self.certificateDaysRemaining = certificateDaysRemaining
634 self.lastMonitoredAt = lastMonitoredAt
635 self.lastAlertAt = lastAlertAt
627 } 636 }
628 637
629 init(from decoder: Decoder) throws { 638 init(from decoder: Decoder) throws {
@@ -634,12 +643,224 @@ struct TrackedDomain: Codable, Identifiable, Equatable {
634 updatedAt = try container.decodeIfPresent(Date.self, forKey: .updatedAt) ?? createdAt 643 updatedAt = try container.decodeIfPresent(Date.self, forKey: .updatedAt) ?? createdAt
635 note = try container.decodeIfPresent(String.self, forKey: .note) 644 note = try container.decodeIfPresent(String.self, forKey: .note)
636 isPinned = try container.decodeIfPresent(Bool.self, forKey: .isPinned) ?? false 645 isPinned = try container.decodeIfPresent(Bool.self, forKey: .isPinned) ?? false
646 monitoringEnabled = try container.decodeIfPresent(Bool.self, forKey: .monitoringEnabled) ?? true
637 lastKnownAvailability = try container.decodeIfPresent(DomainAvailabilityStatus.self, forKey: .lastKnownAvailability) 647 lastKnownAvailability = try container.decodeIfPresent(DomainAvailabilityStatus.self, forKey: .lastKnownAvailability)
638 lastSnapshotID = try container.decodeIfPresent(UUID.self, forKey: .lastSnapshotID) 648 lastSnapshotID = try container.decodeIfPresent(UUID.self, forKey: .lastSnapshotID)
639 lastChangeSummary = try container.decodeIfPresent(DomainChangeSummary.self, forKey: .lastChangeSummary) 649 lastChangeSummary = try container.decodeIfPresent(DomainChangeSummary.self, forKey: .lastChangeSummary)
640 lastChangeSeverity = try container.decodeIfPresent(ChangeSeverity.self, forKey: .lastChangeSeverity) ?? lastChangeSummary?.severity 650 lastChangeSeverity = try container.decodeIfPresent(ChangeSeverity.self, forKey: .lastChangeSeverity) ?? lastChangeSummary?.severity
641 certificateWarningLevel = try container.decodeIfPresent(CertificateWarningLevel.self, forKey: .certificateWarningLevel) ?? .none 651 certificateWarningLevel = try container.decodeIfPresent(CertificateWarningLevel.self, forKey: .certificateWarningLevel) ?? .none
642 certificateDaysRemaining = try container.decodeIfPresent(Int.self, forKey: .certificateDaysRemaining) 652 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"
643 } 864 }
644} 865}
645 866
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 {
10 NavigationStack { 10 NavigationStack {
11 List { 11 List {
12 Section { 12 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.")
14 .font(appDensity.font(.body, design: .default)) 14 .font(appDensity.font(.body, design: .default))
15 .foregroundStyle(.secondary) 15 .foregroundStyle(.secondary)
16 .fixedSize(horizontal: false, vertical: true) 16 .fixedSize(horizontal: false, vertical: true)
@@ -20,6 +20,8 @@ struct PaywallView: View {
20 featureRow("Unlimited tracked domains") 20 featureRow("Unlimited tracked domains")
21 featureRow("Workflows") 21 featureRow("Workflows")
22 featureRow("Larger batch sizes") 22 featureRow("Larger batch sizes")
23 featureRow("Background monitoring")
24 featureRow("Local alerts")
23 featureRow("Advanced exports") 25 featureRow("Advanced exports")
24 } 26 }
25 27
DomainDig/PremiumAccessService.swift +4 −2
@@ -9,8 +9,10 @@ enum PremiumAccessService {
9 return FeatureAccessService.hasAccess(to: .batchOperations) 9 return FeatureAccessService.hasAccess(to: .batchOperations)
10 case .unlimitedTrackedDomains: 10 case .unlimitedTrackedDomains:
11 return FeatureAccessService.currentTier != .free 11 return FeatureAccessService.currentTier != .free
12 case .automatedMonitoring, .pushAlerts: 12 case .automatedMonitoring:
13 return false 13 return FeatureAccessService.hasAccess(to: .automatedMonitoring)
14 case .pushAlerts:
15 return FeatureAccessService.hasAccess(to: .localAlerts)
14 } 16 }
15 } 17 }
16 18
DomainDig/RootTabView.swift +7
@@ -20,6 +20,13 @@ struct RootTabView: View {
20 Label("Watchlist", systemImage: "eye") 20 Label("Watchlist", systemImage: "eye")
21 } 21 }
22 22
23 NavigationStack {
24 MonitoringView(viewModel: viewModel)
25 }
26 .tabItem {
27 Label("Monitoring", systemImage: "waveform.path.ecg")
28 }
29
23 NavigationStack { 30 NavigationStack {
24 HistoryView(viewModel: viewModel) 31 HistoryView(viewModel: viewModel)
25 } 32 }
DomainDig/WatchlistView.swift +21
@@ -287,6 +287,18 @@ struct WatchlistRowView: View {
287 .font(appDensity.font(.caption2)) 287 .font(appDensity.font(.caption2))
288 .foregroundStyle(.secondary) 288 .foregroundStyle(.secondary)
289 289
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
290 indicatorRow 302 indicatorRow
291 303
292 if let note = trackedDomain.note?.trimmingCharacters(in: .whitespacesAndNewlines), !note.isEmpty { 304 if let note = trackedDomain.note?.trimmingCharacters(in: .whitespacesAndNewlines), !note.isEmpty {
@@ -399,6 +411,15 @@ struct TrackedDomainDetailView: View {
399 Label(liveTrackedDomain.isPinned ? "Unpin Domain" : "Pin Domain", systemImage: liveTrackedDomain.isPinned ? "pin.slash" : "pin") 411 Label(liveTrackedDomain.isPinned ? "Unpin Domain" : "Pin Domain", systemImage: liveTrackedDomain.isPinned ? "pin.slash" : "pin")
400 } 412 }
401 413
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
402 Button { 423 Button {
403 noteDraft = liveTrackedDomain.note ?? "" 424 noteDraft = liveTrackedDomain.note ?? ""
404 isEditingNote = true 425 isEditingNote = true
DomainDigCLI.swift +179 −7
@@ -4,14 +4,24 @@ import Foundation
4struct DomainDigCLI { 4struct DomainDigCLI {
5 static func main() async { 5 static func main() async {
6 let arguments = Array(CommandLine.arguments.dropFirst()) 6 let arguments = Array(CommandLine.arguments.dropFirst())
7 let wantsJSON = arguments.contains("--json") || arguments.contains("-j")
7 8
8 guard let command = CommandLine.arguments.first else { 9 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)
10 Foundation.exit(1) 11 Foundation.exit(1)
11 } 12 }
12 _ = command 13 _ = command
13 14
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
15 let wantsOwnershipHistory = arguments.contains("--ownership-history") 25 let wantsOwnershipHistory = arguments.contains("--ownership-history")
16 let wantsDNSHistory = arguments.contains("--dns-history") 26 let wantsDNSHistory = arguments.contains("--dns-history")
17 let wantsExtendedSubdomains = arguments.contains("--extended-subdomains") 27 let wantsExtendedSubdomains = arguments.contains("--extended-subdomains")
@@ -24,7 +34,7 @@ struct DomainDigCLI {
24 .filter { !$0.isEmpty } 34 .filter { !$0.isEmpty }
25 35
26 guard !requestedDomains.isEmpty else { 36 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)
28 Foundation.exit(1) 38 Foundation.exit(1)
29 } 39 }
30 40
@@ -252,10 +262,172 @@ struct DomainDigCLI {
252 } 262 }
253 263
254 private static func loadHistoryEntries() -> [HistoryEntry] { 264 private static func loadHistoryEntries() -> [HistoryEntry] {
255 guard let data = UserDefaults.standard.data(forKey: "lookupHistory"), 265 DomainDataPortabilityService.loadHistoryEntries()
256 let entries = try? JSONDecoder().decode([HistoryEntry].self, from: data) else { 266 }
257 return [] 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)" })
258 } 419 }
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 """
260 } 432 }
261} 433}