krz/domain-dig
an ios app for DNS & SSL analysis
clone: git clone https://gitbay.org/krz/domain-dig.git
v5.0.2: DomainDataPortabilityService.swift · raw
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 auditSessions: [AuditSession]?
13 let workflows: [DomainWorkflow]
14 let monitoringSettings: MonitoringSettings?
15 let monitoringLogs: [MonitoringLog]
16 let appSettings: AppSettingsSnapshot
17 let featureMetadata: FeatureMetadataSnapshot?
18}
19
20struct AppSettingsSnapshot: Codable {
21 let recentSearches: [String]
22 let savedDomains: [String]
23 let resolverURLString: String
24 let appDensityRawValue: String
25}
26
27struct FeatureMetadataSnapshot: Codable {
28 let cachedEntitlement: PurchaseService.CachedEntitlement?
29 let usageCredits: UsageCreditsSnapshot?
30}
31
32struct UsageCreditsSnapshot: Codable {
33 let appVersion: String
34 let remainingByFeature: [UsageCreditFeature: Int]
35}
36
37struct TrackedDomainsExport: Codable {
38 let schemaVersion: Int
39 let exportedAt: Date
40 let appVersion: String
41 let trackedDomains: [TrackedDomain]
42}
43
44struct WorkflowsExport: Codable {
45 let schemaVersion: Int
46 let exportedAt: Date
47 let appVersion: String
48 let workflows: [DomainWorkflow]
49}
50
51struct HistoryExport: Codable {
52 let schemaVersion: Int
53 let exportedAt: Date
54 let appVersion: String
55 let historyEntries: [HistoryEntry]
56}
57
58enum DataPortabilityImportMode: String, CaseIterable, Identifiable {
59 case merge
60 case replace
61
62 var id: String { rawValue }
63
64 var title: String {
65 switch self {
66 case .merge:
67 return "Merge"
68 case .replace:
69 return "Replace"
70 }
71 }
72
73 var explanation: String {
74 switch self {
75 case .merge:
76 return "Keep local data and merge imported items."
77 case .replace:
78 return "Replace local data with the imported file."
79 }
80 }
81}
82
83enum DataPortabilityImportKind: String {
84 case backup
85 case trackedDomains
86 case workflows
87}
88
89struct DataLifecycleSummary: Equatable {
90 let trackedDomains: Int
91 let historySnapshots: Int
92 let auditSessions: Int
93 let workflows: Int
94 let cachedItems: Int
95 let monitoringLogs: Int
96}
97
98struct DataValidationMessage: Identifiable, Equatable {
99 let id = UUID()
100 let text: String
101 let isError: Bool
102}
103
104struct DataValidationReport {
105 let messages: [DataValidationMessage]
106
107 var warnings: [String] {
108 messages.filter { !$0.isError }.map(\.text)
109 }
110
111 var errors: [String] {
112 messages.filter(\.isError).map(\.text)
113 }
114}
115
116struct DataImportPreview {
117 let fileName: String
118 let kind: DataPortabilityImportKind
119 let mode: DataPortabilityImportMode
120 let summaryLines: [String]
121 let warnings: [String]
122 let currentCounts: DataLifecycleSummary
123 let projectedCounts: DataLifecycleSummary
124 fileprivate let payload: ImportPayload
125}
126
127struct DataImportResult {
128 let kind: DataPortabilityImportKind
129 let mode: DataPortabilityImportMode
130 let summary: String
131 let warnings: [String]
132}
133
134enum DataPortabilityError: LocalizedError {
135 case unsupportedFormat
136 case unsupportedSchema(Int)
137 case invalidCSV(String)
138 case unreadableFile
139 case validationFailed([String])
140
141 var errorDescription: String? {
142 switch self {
143 case .unsupportedFormat:
144 return "The selected file is not a supported DomainDig import."
145 case .unsupportedSchema(let version):
146 return "Backup schema v\(version) is not supported by this version of DomainDig."
147 case .invalidCSV(let message):
148 return message
149 case .unreadableFile:
150 return "The selected file could not be read."
151 case .validationFailed(let messages):
152 return messages.joined(separator: "\n")
153 }
154 }
155}
156
157enum DataPortabilityCSV {
158 static func trackedDomains(_ domains: [TrackedDomain]) -> String {
159 let header = [
160 "id",
161 "domain",
162 "createdAt",
163 "updatedAt",
164 "note",
165 "isPinned",
166 "monitoringEnabled",
167 "lastKnownAvailability",
168 "certificateWarningLevel",
169 "certificateDaysRemaining",
170 "lastMonitoredAt",
171 "lastAlertAt"
172 ]
173
174 let rows = domains.map { domain in
175 [
176 domain.id.uuidString,
177 domain.domain,
178 DomainDataPortabilityService.formatISODate(domain.createdAt),
179 DomainDataPortabilityService.formatISODate(domain.updatedAt),
180 domain.note ?? "",
181 String(domain.isPinned),
182 String(domain.monitoringEnabled),
183 domain.lastKnownAvailability?.rawValue ?? "",
184 domain.certificateWarningLevel.rawValue,
185 domain.certificateDaysRemaining.map(String.init) ?? "",
186 domain.lastMonitoredAt.map(DomainDataPortabilityService.formatISODate) ?? "",
187 domain.lastAlertAt.map(DomainDataPortabilityService.formatISODate) ?? ""
188 ]
189 }
190
191 return ([header] + rows).map(Self.csvLine).joined(separator: "\n")
192 }
193
194 static func workflows(_ workflows: [DomainWorkflow]) -> String {
195 let header = ["id", "name", "domains", "createdAt", "updatedAt", "notes"]
196 let rows = workflows.map { workflow in
197 [
198 workflow.id.uuidString,
199 workflow.name,
200 workflow.domains.joined(separator: "|"),
201 DomainDataPortabilityService.formatISODate(workflow.createdAt),
202 DomainDataPortabilityService.formatISODate(workflow.updatedAt),
203 workflow.notes ?? ""
204 ]
205 }
206
207 return ([header] + rows).map(Self.csvLine).joined(separator: "\n")
208 }
209
210 static func parseTrackedDomains(from string: String) throws -> [TrackedDomain] {
211 let rows = try parseRows(from: string)
212 guard let header = rows.first else { return [] }
213 let mappedRows = rows.dropFirst().map { dictionary(for: Array($0), header: header) }
214
215 return mappedRows.compactMap { row in
216 let domain = Self.normalizedDomain(row["domain"])
217 guard !domain.isEmpty else { return nil }
218
219 let createdAt = parseDate(row["createdAt"]) ?? Date()
220 let updatedAt = parseDate(row["updatedAt"]) ?? createdAt
221 let availability = row["lastKnownAvailability"].flatMap(DomainAvailabilityStatus.init(rawValue:))
222 let certificateLevel = row["certificateWarningLevel"].flatMap(CertificateWarningLevel.init(rawValue:)) ?? .none
223
224 return TrackedDomain(
225 id: UUID(uuidString: row["id"] ?? "") ?? UUID(),
226 domain: domain,
227 createdAt: createdAt,
228 updatedAt: updatedAt,
229 note: row["note"]?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty,
230 isPinned: Self.parseBool(row["isPinned"]),
231 monitoringEnabled: row["monitoringEnabled"].map(Self.parseBool) ?? true,
232 lastKnownAvailability: availability,
233 certificateWarningLevel: certificateLevel,
234 certificateDaysRemaining: Int(row["certificateDaysRemaining"] ?? ""),
235 lastMonitoredAt: parseDate(row["lastMonitoredAt"]),
236 lastAlertAt: parseDate(row["lastAlertAt"]),
237 monitoringState: MonitoringState(),
238 pendingMonitoringAlerts: []
239 )
240 }
241 }
242
243 static func parseWorkflows(from string: String) throws -> [DomainWorkflow] {
244 let rows = try parseRows(from: string)
245 guard let header = rows.first else { return [] }
246 let mappedRows = rows.dropFirst().map { dictionary(for: Array($0), header: header) }
247
248 return mappedRows.compactMap { row in
249 let name = row["name"]?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
250 let domains = (row["domains"] ?? "")
251 .split(separator: "|")
252 .map { Self.normalizedDomain(String($0)) }
253 .filter { !$0.isEmpty }
254
255 guard !name.isEmpty, !domains.isEmpty else { return nil }
256
257 let createdAt = parseDate(row["createdAt"]) ?? Date()
258 let updatedAt = parseDate(row["updatedAt"]) ?? createdAt
259 return DomainWorkflow(
260 id: UUID(uuidString: row["id"] ?? "") ?? UUID(),
261 name: name,
262 domains: Self.deduplicated(domains),
263 createdAt: createdAt,
264 updatedAt: updatedAt,
265 notes: row["notes"]?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty
266 )
267 }
268 }
269
270 private nonisolated static func parseRows(from string: String) throws -> [[String]] {
271 var rows: [[String]] = []
272 var currentRow: [String] = []
273 var currentField = ""
274 var insideQuotes = false
275
276 for character in string {
277 switch character {
278 case "\"":
279 insideQuotes.toggle()
280 case "," where !insideQuotes:
281 currentRow.append(currentField)
282 currentField = ""
283 case "\n" where !insideQuotes:
284 currentRow.append(currentField)
285 rows.append(currentRow)
286 currentRow = []
287 currentField = ""
288 case "\r":
289 continue
290 default:
291 currentField.append(character)
292 }
293 }
294
295 if insideQuotes {
296 throw DataPortabilityError.invalidCSV("The CSV file is malformed.")
297 }
298
299 if !currentField.isEmpty || !currentRow.isEmpty {
300 currentRow.append(currentField)
301 rows.append(currentRow)
302 }
303
304 return rows.filter { !$0.allSatisfy(\.isEmpty) }
305 }
306
307 private nonisolated static func dictionary(for row: [String], header: [String]) -> [String: String] {
308 Dictionary(uniqueKeysWithValues: zip(header, row).map { ($0.0, $0.1) })
309 }
310
311 private nonisolated static func csvLine(_ values: [String]) -> String {
312 values.map { value in
313 let escaped = value.replacingOccurrences(of: "\"", with: "\"\"")
314 return "\"\(escaped)\""
315 }
316 .joined(separator: ",")
317 }
318
319 private nonisolated static func parseDate(_ value: String?) -> Date? {
320 guard let value, !value.isEmpty else { return nil }
321 return DomainDataPortabilityService.parseISODate(value)
322 }
323
324 private nonisolated static func parseBool(_ value: String?) -> Bool {
325 guard let value else { return false }
326 return value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == "true"
327 }
328
329 private nonisolated static func normalizedDomain(_ value: String?) -> String {
330 value?
331 .trimmingCharacters(in: .whitespacesAndNewlines)
332 .replacingOccurrences(of: "https://", with: "")
333 .replacingOccurrences(of: "http://", with: "")
334 .components(separatedBy: "/")
335 .first?
336 .lowercased() ?? ""
337 }
338
339 private nonisolated static func deduplicated(_ values: [String]) -> [String] {
340 var seen = Set<String>()
341 return values.filter { seen.insert($0).inserted }
342 }
343}
344
345/// Versioned migration runner for the on-device persisted store.
346///
347/// The store is a set of independent JSON blobs in `UserDefaults` (tracked
348/// domains, history, audits, workflows, monitoring settings/logs, app settings).
349/// Most model evolution is handled additively by the models' own lenient
350/// decoders (`decodeIfPresent` with defaults), which need no migration at all.
351/// This runner exists only for changes lenient decoding can't express: dropping
352/// a renamed storage key, re-normalizing existing rows, or reshaping a blob.
353///
354/// `currentStoreSchemaVersion` is bumped whenever such a step is added. Each step
355/// runs exactly once, in ascending order, and must be safe to run on any prior
356/// state — including an empty store. A store written by a newer build (a higher
357/// version than this build knows) is left untouched; migrations never downgrade.
358/// The policy is documented in `Docs/data-migration.md`.
359enum DataMigrationService {
360 /// The schema version this build expects the on-device store to be at.
361 static let currentStoreSchemaVersion = 1
362
363 /// UserDefaults key holding the store's current schema version.
364 static let storeSchemaVersionKey = "data.storeSchemaVersion"
365
366 /// Pre-versioning installs recorded that the one-shot v1 normalization had
367 /// run using this boolean marker; `true` means the store is already at v1.
368 private static let legacyNormalizationMarkerKey = "data.migrations.v3_4_0"
369
370 /// The store's current schema version. Absent on pre-versioning installs: a
371 /// set legacy marker means v1 already ran, otherwise the store is fresh or
372 /// never-migrated at v0.
373 static func storeSchemaVersion(defaults: UserDefaults = .standard) -> Int {
374 if let version = defaults.object(forKey: storeSchemaVersionKey) as? Int {
375 return version
376 }
377 return defaults.bool(forKey: legacyNormalizationMarkerKey) ? 1 : 0
378 }
379
380 static func migrateIfNeeded(defaults: UserDefaults = .standard) {
381 // At or ahead of this build's version: nothing to do, and never rewrite
382 // a store a newer build may have reshaped (forward compatibility).
383 guard storeSchemaVersion(defaults: defaults) < currentStoreSchemaVersion else { return }
384
385 var version = storeSchemaVersion(defaults: defaults)
386 while version < currentStoreSchemaVersion {
387 let target = version + 1
388 runMigration(to: target, defaults: defaults)
389 defaults.set(target, forKey: storeSchemaVersionKey)
390 version = target
391 }
392 }
393
394 private static func runMigration(to version: Int, defaults: UserDefaults) {
395 switch version {
396 case 1:
397 normalizeAllStores(defaults: defaults)
398 default:
399 break
400 }
401 }
402
403 /// v1: load every store through its deduplicating loader and write it back.
404 /// This consolidates duplicate rows, drops the legacy `watchedDomains` key
405 /// (via `saveTrackedDomains`), and sanitizes monitoring settings against the
406 /// surviving tracked domains. Safe on an empty store (every step is a no-op).
407 private static func normalizeAllStores(defaults: UserDefaults) {
408 let trackedDomains = DomainDataPortabilityService.loadTrackedDomains(defaults: defaults)
409 DomainDataPortabilityService.saveTrackedDomains(trackedDomains, defaults: defaults)
410
411 let historyEntries = DomainDataPortabilityService.loadHistoryEntries(defaults: defaults)
412 DomainDataPortabilityService.saveHistoryEntries(historyEntries, defaults: defaults)
413
414 let auditSessions = DomainDataPortabilityService.loadAuditSessions(defaults: defaults)
415 DomainDataPortabilityService.saveAuditSessions(auditSessions, defaults: defaults)
416
417 let workflows = DomainDataPortabilityService.loadWorkflows(defaults: defaults)
418 DomainDataPortabilityService.saveWorkflows(workflows, defaults: defaults)
419
420 let monitoringSettings = DomainDataPortabilityService.loadMonitoringSettings(defaults: defaults)
421 let sanitizedMonitoringSettings = MonitoringStorage.sanitizeSettings(monitoringSettings, trackedDomains: trackedDomains)
422 DomainDataPortabilityService.saveMonitoringSettings(sanitizedMonitoringSettings, defaults: defaults)
423
424 let monitoringLogs = DomainDataPortabilityService.loadMonitoringLogs(defaults: defaults)
425 DomainDataPortabilityService.saveMonitoringLogs(monitoringLogs, defaults: defaults)
426 }
427}
428
429enum DataValidationService {
430 static func validate(backup: DomainDigBackup) -> DataValidationReport {
431 var messages: [DataValidationMessage] = []
432
433 if backup.schemaVersion > DomainDigBackup.currentSchemaVersion {
434 messages.append(.init(text: "This backup was created by a newer DomainDig version.", isError: true))
435 } else if backup.schemaVersion < DomainDigBackup.currentSchemaVersion {
436 messages.append(.init(text: "Older backup schema detected. DomainDig will import using compatibility rules.", isError: false))
437 }
438
439 messages.append(contentsOf: validateTrackedDomains(backup.trackedDomains))
440 messages.append(contentsOf: validateHistoryEntries(backup.historyEntries))
441 messages.append(contentsOf: validateAuditSessions(backup.auditSessions ?? []))
442 messages.append(contentsOf: validateWorkflows(backup.workflows))
443
444 if backup.appSettings.resolverURLString.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
445 messages.append(.init(text: "Backup is missing a resolver URL. The default resolver will be used.", isError: false))
446 }
447
448 return DataValidationReport(messages: messages)
449 }
450
451 static func validate(trackedDomains: [TrackedDomain]) -> DataValidationReport {
452 DataValidationReport(messages: validateTrackedDomains(trackedDomains))
453 }
454
455 static func validate(workflows: [DomainWorkflow]) -> DataValidationReport {
456 DataValidationReport(messages: validateWorkflows(workflows))
457 }
458
459 private static func validateTrackedDomains(_ trackedDomains: [TrackedDomain]) -> [DataValidationMessage] {
460 var messages: [DataValidationMessage] = []
461 var seen = Set<String>()
462
463 for trackedDomain in trackedDomains {
464 let normalized = normalizeDomain(trackedDomain.domain)
465 if normalized.isEmpty {
466 messages.append(.init(text: "A tracked domain entry is missing its domain name.", isError: true))
467 continue
468 }
469
470 if !seen.insert(normalized).inserted {
471 messages.append(.init(text: "Duplicate tracked domain found for \(normalized). Merge rules will consolidate it.", isError: false))
472 }
473 }
474
475 return messages
476 }
477
478 private static func validateHistoryEntries(_ historyEntries: [HistoryEntry]) -> [DataValidationMessage] {
479 var messages: [DataValidationMessage] = []
480 for entry in historyEntries {
481 if normalizeDomain(entry.domain).isEmpty {
482 messages.append(.init(text: "A history snapshot is missing its domain name.", isError: true))
483 }
484 if entry.timestamp == .distantPast {
485 messages.append(.init(text: "A history snapshot is missing its timestamp.", isError: false))
486 }
487 for issue in entry.validationIssues {
488 messages.append(.init(text: "History warning for \(entry.domain): \(issue)", isError: false))
489 }
490 }
491 return messages
492 }
493
494 private static func validateAuditSessions(_ auditSessions: [AuditSession]) -> [DataValidationMessage] {
495 var messages: [DataValidationMessage] = []
496 var seen = Set<UUID>()
497
498 for session in auditSessions {
499 if normalizeDomain(session.domain).isEmpty {
500 messages.append(.init(text: "An audit session is missing its domain name.", isError: true))
501 }
502
503 if !seen.insert(session.id).inserted {
504 messages.append(.init(text: "Duplicate audit session found for \(session.domain). Merge rules will consolidate it.", isError: false))
505 }
506 }
507
508 return messages
509 }
510
511 private static func validateWorkflows(_ workflows: [DomainWorkflow]) -> [DataValidationMessage] {
512 var messages: [DataValidationMessage] = []
513 for workflow in workflows {
514 if workflow.name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
515 messages.append(.init(text: "A workflow is missing its name.", isError: true))
516 }
517 if workflow.domains.isEmpty {
518 messages.append(.init(text: "Workflow \(workflow.name) has no domains.", isError: false))
519 }
520 if Set(workflow.domains.map(Self.normalizeDomain)).count != workflow.domains.count {
521 messages.append(.init(text: "Workflow \(workflow.name) contains duplicate domains. Import will deduplicate them.", isError: false))
522 }
523 }
524 return messages
525 }
526
527 private nonisolated static func normalizeDomain(_ value: String) -> String {
528 value
529 .trimmingCharacters(in: .whitespacesAndNewlines)
530 .replacingOccurrences(of: "https://", with: "")
531 .replacingOccurrences(of: "http://", with: "")
532 .components(separatedBy: "/")
533 .first?
534 .lowercased() ?? ""
535 }
536}
537
538enum DomainDataPortabilityService {
539 static let backupUTType = UTType.json
540 static let csvUTType = UTType.commaSeparatedText
541
542 fileprivate nonisolated static func formatISODate(_ date: Date) -> String {
543 let formatter = ISO8601DateFormatter()
544 formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
545 return formatter.string(from: date)
546 }
547
548 fileprivate nonisolated static func parseISODate(_ value: String) -> Date? {
549 let formatter = ISO8601DateFormatter()
550 formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
551 return formatter.date(from: value)
552 }
553
554 private enum StorageKey {
555 static let recentSearches = "recentSearches"
556 static let savedDomains = "savedDomains"
557 static let trackedDomains = "trackedDomains"
558 static let legacyWatchedDomains = "watchedDomains"
559 static let history = "lookupHistory"
560 static let audits = "domainAudits"
561 static let workflows = "domainWorkflows"
562 static let monitoringSettings = "monitoring.settings"
563 static let monitoringLogs = "monitoring.logs"
564 static let appDensity = AppDensity.userDefaultsKey
565 static let resolverURL = DNSResolverOption.userDefaultsKey
566 static let purchaseCache = "purchase.cachedEntitlement"
567 static let usageCredits = "usageCredits.ledger"
568 }
569
570 static func loadRecentSearches(defaults: UserDefaults = .standard) -> [String] {
571 defaults.stringArray(forKey: StorageKey.recentSearches) ?? []
572 }
573
574 static func saveRecentSearches(_ values: [String], defaults: UserDefaults = .standard) {
575 defaults.set(Array(values.prefix(20)), forKey: StorageKey.recentSearches)
576 }
577
578 static func loadSavedDomains(defaults: UserDefaults = .standard) -> [String] {
579 defaults.stringArray(forKey: StorageKey.savedDomains) ?? []
580 }
581
582 static func saveSavedDomains(_ values: [String], defaults: UserDefaults = .standard) {
583 defaults.set(values, forKey: StorageKey.savedDomains)
584 }
585
586 static func loadTrackedDomains(defaults: UserDefaults = .standard) -> [TrackedDomain] {
587 let decoder = JSONDecoder()
588
589 if let data = defaults.data(forKey: StorageKey.trackedDomains),
590 let domains = try? decoder.decode([TrackedDomain].self, from: data) {
591 return deduplicatedTrackedDomains(domains)
592 }
593
594 if let data = defaults.data(forKey: StorageKey.legacyWatchedDomains),
595 let legacy = try? decoder.decode([WatchedDomain].self, from: data) {
596 return deduplicatedTrackedDomains(
597 legacy.map {
598 TrackedDomain(
599 id: $0.id,
600 domain: normalizeDomain($0.domain),
601 createdAt: $0.createdAt,
602 updatedAt: $0.createdAt,
603 lastKnownAvailability: $0.lastKnownAvailability
604 )
605 }
606 )
607 }
608
609 return []
610 }
611
612 static func saveTrackedDomains(_ trackedDomains: [TrackedDomain], defaults: UserDefaults = .standard) {
613 if let data = try? JSONEncoder().encode(deduplicatedTrackedDomains(trackedDomains)) {
614 defaults.set(data, forKey: StorageKey.trackedDomains)
615 }
616 defaults.removeObject(forKey: StorageKey.legacyWatchedDomains)
617 }
618
619 static func loadHistoryEntries(defaults: UserDefaults = .standard) -> [HistoryEntry] {
620 guard let data = defaults.data(forKey: StorageKey.history) else { return [] }
621
622 if let entries = try? JSONDecoder().decode([HistoryEntry].self, from: data) {
623 return deduplicatedHistoryEntries(entries)
624 }
625
626 guard let rawArray = (try? JSONSerialization.jsonObject(with: data)) as? [Any] else {
627 return []
628 }
629
630 let decoder = JSONDecoder()
631 let entries = rawArray.compactMap { item -> HistoryEntry? in
632 guard JSONSerialization.isValidJSONObject(item),
633 let entryData = try? JSONSerialization.data(withJSONObject: item) else {
634 return nil
635 }
636 return try? decoder.decode(HistoryEntry.self, from: entryData)
637 }
638 return deduplicatedHistoryEntries(entries)
639 }
640
641 static func saveHistoryEntries(_ historyEntries: [HistoryEntry], defaults: UserDefaults = .standard) {
642 if let data = try? JSONEncoder().encode(deduplicatedHistoryEntries(historyEntries)) {
643 defaults.set(data, forKey: StorageKey.history)
644 }
645 }
646
647 static func loadAuditSessions(defaults: UserDefaults = .standard) -> [AuditSession] {
648 guard let data = defaults.data(forKey: StorageKey.audits),
649 let sessions = try? JSONDecoder().decode([AuditSession].self, from: data) else {
650 return []
651 }
652 return sessions.sorted { $0.createdAt > $1.createdAt }
653 }
654
655 static func saveAuditSessions(_ auditSessions: [AuditSession], defaults: UserDefaults = .standard) {
656 let sessions = auditSessions.sorted { $0.createdAt > $1.createdAt }
657 if let data = try? JSONEncoder().encode(sessions) {
658 defaults.set(data, forKey: StorageKey.audits)
659 }
660 }
661
662 static func loadWorkflows(defaults: UserDefaults = .standard) -> [DomainWorkflow] {
663 guard let data = defaults.data(forKey: StorageKey.workflows),
664 let workflows = try? JSONDecoder().decode([DomainWorkflow].self, from: data) else {
665 return []
666 }
667
668 return deduplicatedWorkflows(workflows).sorted(by: workflowSort)
669 }
670
671 static func saveWorkflows(_ workflows: [DomainWorkflow], defaults: UserDefaults = .standard) {
672 if let data = try? JSONEncoder().encode(deduplicatedWorkflows(workflows)) {
673 defaults.set(data, forKey: StorageKey.workflows)
674 }
675 }
676
677 static func loadMonitoringSettings(defaults: UserDefaults = .standard) -> MonitoringSettings {
678 guard let data = defaults.data(forKey: StorageKey.monitoringSettings),
679 let settings = try? JSONDecoder().decode(MonitoringSettings.self, from: data) else {
680 return MonitoringSettings()
681 }
682 return settings
683 }
684
685 static func saveMonitoringSettings(_ settings: MonitoringSettings, defaults: UserDefaults = .standard) {
686 if let data = try? JSONEncoder().encode(settings) {
687 defaults.set(data, forKey: StorageKey.monitoringSettings)
688 }
689 }
690
691 static func loadMonitoringLogs(defaults: UserDefaults = .standard) -> [MonitoringLog] {
692 guard let data = defaults.data(forKey: StorageKey.monitoringLogs),
693 let logs = try? JSONDecoder().decode([MonitoringLog].self, from: data) else {
694 return []
695 }
696 return Array(logs.prefix(MonitoringStorage.maxLogs))
697 }
698
699 static func saveMonitoringLogs(_ logs: [MonitoringLog], defaults: UserDefaults = .standard) {
700 if let data = try? JSONEncoder().encode(Array(logs.prefix(MonitoringStorage.maxLogs))) {
701 defaults.set(data, forKey: StorageKey.monitoringLogs)
702 }
703 }
704
705 static func loadAppSettings(defaults: UserDefaults = .standard) -> AppSettingsSnapshot {
706 AppSettingsSnapshot(
707 recentSearches: loadRecentSearches(defaults: defaults),
708 savedDomains: loadSavedDomains(defaults: defaults),
709 resolverURLString: defaults.string(forKey: StorageKey.resolverURL) ?? DNSResolverOption.defaultURLString,
710 appDensityRawValue: defaults.string(forKey: StorageKey.appDensity) ?? AppDensity.compact.rawValue
711 )
712 }
713
714 static func saveAppSettings(_ settings: AppSettingsSnapshot, defaults: UserDefaults = .standard) {
715 saveRecentSearches(settings.recentSearches, defaults: defaults)
716 saveSavedDomains(settings.savedDomains, defaults: defaults)
717 defaults.set(settings.resolverURLString, forKey: StorageKey.resolverURL)
718 defaults.set(settings.appDensityRawValue, forKey: StorageKey.appDensity)
719 }
720
721 static func loadFeatureMetadata(defaults: UserDefaults = .standard) -> FeatureMetadataSnapshot {
722 let cachedEntitlement: PurchaseService.CachedEntitlement?
723 if let data = defaults.data(forKey: StorageKey.purchaseCache) {
724 cachedEntitlement = try? JSONDecoder().decode(PurchaseService.CachedEntitlement.self, from: data)
725 } else {
726 cachedEntitlement = nil
727 }
728
729 let usageCredits: UsageCreditsSnapshot?
730 if let data = defaults.data(forKey: StorageKey.usageCredits) {
731 usageCredits = try? JSONDecoder().decode(UsageCreditsSnapshot.self, from: data)
732 } else {
733 usageCredits = nil
734 }
735
736 return FeatureMetadataSnapshot(cachedEntitlement: cachedEntitlement, usageCredits: usageCredits)
737 }
738
739 static func saveFeatureMetadata(_ featureMetadata: FeatureMetadataSnapshot?, defaults: UserDefaults = .standard) {
740 if let cachedEntitlement = featureMetadata?.cachedEntitlement,
741 let data = try? JSONEncoder().encode(cachedEntitlement) {
742 defaults.set(data, forKey: StorageKey.purchaseCache)
743 }
744
745 if let usageCredits = featureMetadata?.usageCredits,
746 let data = try? JSONEncoder().encode(usageCredits) {
747 defaults.set(data, forKey: StorageKey.usageCredits)
748 }
749 }
750
751 static func currentBackup(defaults: UserDefaults = .standard) -> DomainDigBackup {
752 DataMigrationService.migrateIfNeeded(defaults: defaults)
753 return DomainDigBackup(
754 schemaVersion: DomainDigBackup.currentSchemaVersion,
755 exportedAt: Date(),
756 appVersion: AppVersion.current,
757 trackedDomains: loadTrackedDomains(defaults: defaults),
758 historyEntries: loadHistoryEntries(defaults: defaults),
759 auditSessions: loadAuditSessions(defaults: defaults),
760 workflows: loadWorkflows(defaults: defaults),
761 monitoringSettings: loadMonitoringSettings(defaults: defaults),
762 monitoringLogs: loadMonitoringLogs(defaults: defaults),
763 appSettings: loadAppSettings(defaults: defaults),
764 featureMetadata: loadFeatureMetadata(defaults: defaults)
765 )
766 }
767
768 static func backupData(defaults: UserDefaults = .standard) throws -> Data {
769 try makeEncoder().encode(currentBackup(defaults: defaults))
770 }
771
772 static func trackedDomainsExportData(defaults: UserDefaults = .standard) throws -> Data {
773 try makeEncoder().encode(
774 TrackedDomainsExport(
775 schemaVersion: DomainDigBackup.currentSchemaVersion,
776 exportedAt: Date(),
777 appVersion: AppVersion.current,
778 trackedDomains: loadTrackedDomains(defaults: defaults)
779 )
780 )
781 }
782
783 static func workflowsExportData(defaults: UserDefaults = .standard) throws -> Data {
784 try makeEncoder().encode(
785 WorkflowsExport(
786 schemaVersion: DomainDigBackup.currentSchemaVersion,
787 exportedAt: Date(),
788 appVersion: AppVersion.current,
789 workflows: loadWorkflows(defaults: defaults)
790 )
791 )
792 }
793
794 static func historyExportData(defaults: UserDefaults = .standard) throws -> Data {
795 try makeEncoder().encode(
796 HistoryExport(
797 schemaVersion: DomainDigBackup.currentSchemaVersion,
798 exportedAt: Date(),
799 appVersion: AppVersion.current,
800 historyEntries: loadHistoryEntries(defaults: defaults)
801 )
802 )
803 }
804
805 static func trackedDomainsCSV(defaults: UserDefaults = .standard) -> String {
806 DataPortabilityCSV.trackedDomains(loadTrackedDomains(defaults: defaults))
807 }
808
809 static func workflowsCSV(defaults: UserDefaults = .standard) -> String {
810 DataPortabilityCSV.workflows(loadWorkflows(defaults: defaults))
811 }
812
813 static func prepareImport(
814 data: Data,
815 fileName: String,
816 mode: DataPortabilityImportMode,
817 defaults: UserDefaults = .standard
818 ) throws -> DataImportPreview {
819 DataMigrationService.migrateIfNeeded(defaults: defaults)
820 let currentCounts = lifecycleSummary(defaults: defaults)
821
822 let payload = try decodeImportPayload(from: data, fileName: fileName)
823 let projectedCounts = projectCounts(for: payload, mode: mode, defaults: defaults)
824
825 return DataImportPreview(
826 fileName: fileName,
827 kind: payload.kind,
828 mode: mode,
829 summaryLines: payload.summaryLines(mode: mode),
830 warnings: payload.validationReport.warnings,
831 currentCounts: currentCounts,
832 projectedCounts: projectedCounts,
833 payload: payload
834 )
835 }
836
837 static func applyImport(
838 _ preview: DataImportPreview,
839 mode: DataPortabilityImportMode,
840 defaults: UserDefaults = .standard
841 ) throws -> DataImportResult {
842 switch preview.payload {
843 case .backup(let backup, let report):
844 try applyBackup(backup, mode: mode, defaults: defaults)
845 return DataImportResult(
846 kind: .backup,
847 mode: mode,
848 summary: "Imported backup with \(backup.trackedDomains.count) tracked domains, \(backup.historyEntries.count) history snapshots, \(backup.auditSessions?.count ?? 0) audit sessions, and \(backup.workflows.count) workflows.",
849 warnings: report.warnings
850 )
851 case .trackedDomains(let trackedDomains, let report):
852 applyTrackedDomainsImport(trackedDomains, mode: mode, defaults: defaults)
853 return DataImportResult(
854 kind: .trackedDomains,
855 mode: mode,
856 summary: "Imported \(trackedDomains.count) tracked domains.",
857 warnings: report.warnings
858 )
859 case .workflows(let workflows, let report):
860 applyWorkflowsImport(workflows, mode: mode, defaults: defaults)
861 return DataImportResult(
862 kind: .workflows,
863 mode: mode,
864 summary: "Imported \(workflows.count) workflows.",
865 warnings: report.warnings
866 )
867 }
868 }
869
870 static func validateBackup(data: Data, fileName: String = "backup.json") throws -> DataValidationReport {
871 switch try decodeImportPayload(from: data, fileName: fileName) {
872 case .backup(_, let report):
873 return report
874 case .trackedDomains(_, let report):
875 return report
876 case .workflows(_, let report):
877 return report
878 }
879 }
880
881 static func lifecycleSummary(defaults: UserDefaults = .standard) -> DataLifecycleSummary {
882 let appSettings = loadAppSettings(defaults: defaults)
883 let featureMetadata = loadFeatureMetadata(defaults: defaults)
884 let cachedItems = appSettings.recentSearches.count
885 + appSettings.savedDomains.count
886 + (featureMetadata.cachedEntitlement == nil ? 0 : 1)
887 + (featureMetadata.usageCredits == nil ? 0 : 1)
888
889 return DataLifecycleSummary(
890 trackedDomains: loadTrackedDomains(defaults: defaults).count,
891 historySnapshots: loadHistoryEntries(defaults: defaults).count,
892 auditSessions: loadAuditSessions(defaults: defaults).count,
893 workflows: loadWorkflows(defaults: defaults).count,
894 cachedItems: cachedItems,
895 monitoringLogs: loadMonitoringLogs(defaults: defaults).count
896 )
897 }
898
899 private static func decodeImportPayload(from data: Data, fileName: String) throws -> ImportPayload {
900 let decoder = JSONDecoder()
901
902 if let backup = try? decoder.decode(DomainDigBackup.self, from: data) {
903 if backup.schemaVersion > DomainDigBackup.currentSchemaVersion {
904 throw DataPortabilityError.unsupportedSchema(backup.schemaVersion)
905 }
906 let report = DataValidationService.validate(backup: backup)
907 if !report.errors.isEmpty {
908 throw DataPortabilityError.validationFailed(report.errors)
909 }
910 return .backup(backup, report)
911 }
912
913 if let backup = try? decodeLegacyBackup(from: data) {
914 let report = DataValidationService.validate(backup: backup)
915 if !report.errors.isEmpty {
916 throw DataPortabilityError.validationFailed(report.errors)
917 }
918 return .backup(backup, report)
919 }
920
921 if let export = try? decoder.decode(TrackedDomainsExport.self, from: data) {
922 let report = DataValidationService.validate(trackedDomains: export.trackedDomains)
923 if !report.errors.isEmpty {
924 throw DataPortabilityError.validationFailed(report.errors)
925 }
926 return .trackedDomains(export.trackedDomains, report)
927 }
928
929 if let export = try? decoder.decode(WorkflowsExport.self, from: data) {
930 let report = DataValidationService.validate(workflows: export.workflows)
931 if !report.errors.isEmpty {
932 throw DataPortabilityError.validationFailed(report.errors)
933 }
934 return .workflows(export.workflows, report)
935 }
936
937 if let trackedDomains = try? decoder.decode([TrackedDomain].self, from: data) {
938 let report = DataValidationService.validate(trackedDomains: trackedDomains)
939 if !report.errors.isEmpty {
940 throw DataPortabilityError.validationFailed(report.errors)
941 }
942 return .trackedDomains(trackedDomains, report)
943 }
944
945 if let workflows = try? decoder.decode([DomainWorkflow].self, from: data) {
946 let report = DataValidationService.validate(workflows: workflows)
947 if !report.errors.isEmpty {
948 throw DataPortabilityError.validationFailed(report.errors)
949 }
950 return .workflows(workflows, report)
951 }
952
953 let lowercasedFileName = fileName.lowercased()
954 if lowercasedFileName.hasSuffix(".csv") {
955 guard let string = String(data: data, encoding: .utf8) else {
956 throw DataPortabilityError.unreadableFile
957 }
958 if lowercasedFileName.contains("workflow") {
959 let workflows = try DataPortabilityCSV.parseWorkflows(from: string)
960 let report = DataValidationService.validate(workflows: workflows)
961 if !report.errors.isEmpty {
962 throw DataPortabilityError.validationFailed(report.errors)
963 }
964 return .workflows(workflows, report)
965 }
966
967 let trackedDomains = try DataPortabilityCSV.parseTrackedDomains(from: string)
968 let report = DataValidationService.validate(trackedDomains: trackedDomains)
969 if !report.errors.isEmpty {
970 throw DataPortabilityError.validationFailed(report.errors)
971 }
972 return .trackedDomains(trackedDomains, report)
973 }
974
975 throw DataPortabilityError.unsupportedFormat
976 }
977
978 private static func decodeLegacyBackup(from data: Data) throws -> DomainDigBackup {
979 struct LegacyBackup: Decodable {
980 let trackedDomains: [TrackedDomain]?
981 let historyEntries: [HistoryEntry]?
982 let workflows: [DomainWorkflow]?
983 let monitoringSettings: MonitoringSettings?
984 let monitoringLogs: [MonitoringLog]?
985 let recentSearches: [String]?
986 let savedDomains: [String]?
987 }
988
989 let legacy = try JSONDecoder().decode(LegacyBackup.self, from: data)
990 return DomainDigBackup(
991 schemaVersion: 0,
992 exportedAt: Date(),
993 appVersion: AppVersion.current,
994 trackedDomains: legacy.trackedDomains ?? [],
995 historyEntries: legacy.historyEntries ?? [],
996 auditSessions: nil,
997 workflows: legacy.workflows ?? [],
998 monitoringSettings: legacy.monitoringSettings,
999 monitoringLogs: legacy.monitoringLogs ?? [],
1000 appSettings: AppSettingsSnapshot(
1001 recentSearches: legacy.recentSearches ?? [],
1002 savedDomains: legacy.savedDomains ?? [],
1003 resolverURLString: DNSResolverOption.defaultURLString,
1004 appDensityRawValue: AppDensity.compact.rawValue
1005 ),
1006 featureMetadata: nil
1007 )
1008 }
1009
1010 private static func applyBackup(
1011 _ backup: DomainDigBackup,
1012 mode: DataPortabilityImportMode,
1013 defaults: UserDefaults
1014 ) throws {
1015 if mode == .replace {
1016 saveTrackedDomains(deduplicatedTrackedDomains(backup.trackedDomains), defaults: defaults)
1017 saveHistoryEntries(deduplicatedHistoryEntries(backup.historyEntries), defaults: defaults)
1018 saveAuditSessions(backup.auditSessions ?? [], defaults: defaults)
1019 saveWorkflows(deduplicatedWorkflows(backup.workflows), defaults: defaults)
1020 saveMonitoringSettings(
1021 MonitoringStorage.sanitizeSettings(
1022 backup.monitoringSettings ?? MonitoringSettings(),
1023 trackedDomains: deduplicatedTrackedDomains(backup.trackedDomains)
1024 ),
1025 defaults: defaults
1026 )
1027 saveMonitoringLogs(backup.monitoringLogs, defaults: defaults)
1028 saveAppSettings(backup.appSettings, defaults: defaults)
1029 saveFeatureMetadata(backup.featureMetadata, defaults: defaults)
1030 return
1031 }
1032
1033 let existingTrackedDomains = loadTrackedDomains(defaults: defaults)
1034 let mergedTrackedDomains = mergeTrackedDomains(existing: existingTrackedDomains, incoming: backup.trackedDomains)
1035 saveTrackedDomains(mergedTrackedDomains.domains, defaults: defaults)
1036
1037 let existingHistoryEntries = loadHistoryEntries(defaults: defaults)
1038 let remappedIncomingHistory = backup.historyEntries.map { remapHistoryEntry($0, using: mergedTrackedDomains.idMap) }
1039 saveHistoryEntries(
1040 mergeHistoryEntries(existing: existingHistoryEntries, incoming: remappedIncomingHistory),
1041 defaults: defaults
1042 )
1043
1044 let existingAuditSessions = loadAuditSessions(defaults: defaults)
1045 let mergedAuditSessions = mergeAuditSessions(existing: existingAuditSessions, incoming: backup.auditSessions ?? [])
1046 saveAuditSessions(mergedAuditSessions, defaults: defaults)
1047
1048 let existingWorkflows = loadWorkflows(defaults: defaults)
1049 saveWorkflows(mergeWorkflows(existing: existingWorkflows, incoming: backup.workflows), defaults: defaults)
1050
1051 let existingMonitoringLogs = loadMonitoringLogs(defaults: defaults)
1052 saveMonitoringLogs(mergeMonitoringLogs(existing: existingMonitoringLogs, incoming: backup.monitoringLogs), defaults: defaults)
1053
1054 let remappedMonitoringSettings = remapMonitoringSettings(
1055 backup.monitoringSettings ?? MonitoringSettings(),
1056 using: mergedTrackedDomains.idMap
1057 )
1058 saveMonitoringSettings(
1059 MonitoringStorage.sanitizeSettings(remappedMonitoringSettings, trackedDomains: mergedTrackedDomains.domains),
1060 defaults: defaults
1061 )
1062
1063 let mergedSettings = mergeAppSettings(existing: loadAppSettings(defaults: defaults), incoming: backup.appSettings)
1064 saveAppSettings(mergedSettings, defaults: defaults)
1065
1066 let mergedFeatureMetadata = mergeFeatureMetadata(existing: loadFeatureMetadata(defaults: defaults), incoming: backup.featureMetadata)
1067 saveFeatureMetadata(mergedFeatureMetadata, defaults: defaults)
1068 }
1069
1070 private static func applyTrackedDomainsImport(
1071 _ trackedDomains: [TrackedDomain],
1072 mode: DataPortabilityImportMode,
1073 defaults: UserDefaults
1074 ) {
1075 let importedDomains = deduplicatedTrackedDomains(trackedDomains)
1076 let finalDomains: [TrackedDomain]
1077 switch mode {
1078 case .merge:
1079 finalDomains = mergeTrackedDomains(existing: loadTrackedDomains(defaults: defaults), incoming: importedDomains).domains
1080 case .replace:
1081 finalDomains = importedDomains
1082 }
1083
1084 saveTrackedDomains(finalDomains, defaults: defaults)
1085 let sanitizedSettings = MonitoringStorage.sanitizeSettings(loadMonitoringSettings(defaults: defaults), trackedDomains: finalDomains)
1086 saveMonitoringSettings(sanitizedSettings, defaults: defaults)
1087 }
1088
1089 private static func applyWorkflowsImport(
1090 _ workflows: [DomainWorkflow],
1091 mode: DataPortabilityImportMode,
1092 defaults: UserDefaults
1093 ) {
1094 let importedWorkflows = deduplicatedWorkflows(workflows)
1095 let finalWorkflows: [DomainWorkflow]
1096 switch mode {
1097 case .merge:
1098 finalWorkflows = mergeWorkflows(existing: loadWorkflows(defaults: defaults), incoming: importedWorkflows)
1099 case .replace:
1100 finalWorkflows = importedWorkflows
1101 }
1102 saveWorkflows(finalWorkflows, defaults: defaults)
1103 }
1104
1105 private static func projectCounts(
1106 for payload: ImportPayload,
1107 mode: DataPortabilityImportMode,
1108 defaults: UserDefaults
1109 ) -> DataLifecycleSummary {
1110 let current = lifecycleSummary(defaults: defaults)
1111 switch payload {
1112 case .backup(let backup, _):
1113 if mode == .replace {
1114 return DataLifecycleSummary(
1115 trackedDomains: backup.trackedDomains.count,
1116 historySnapshots: backup.historyEntries.count,
1117 auditSessions: backup.auditSessions?.count ?? 0,
1118 workflows: backup.workflows.count,
1119 cachedItems: backup.appSettings.recentSearches.count + backup.appSettings.savedDomains.count + ((backup.featureMetadata?.cachedEntitlement == nil ? 0 : 1) + (backup.featureMetadata?.usageCredits == nil ? 0 : 1)),
1120 monitoringLogs: backup.monitoringLogs.count
1121 )
1122 }
1123
1124 return DataLifecycleSummary(
1125 trackedDomains: mergeTrackedDomains(existing: loadTrackedDomains(defaults: defaults), incoming: backup.trackedDomains).domains.count,
1126 historySnapshots: mergeHistoryEntries(existing: loadHistoryEntries(defaults: defaults), incoming: backup.historyEntries).count,
1127 auditSessions: mergeAuditSessions(existing: loadAuditSessions(defaults: defaults), incoming: backup.auditSessions ?? []).count,
1128 workflows: mergeWorkflows(existing: loadWorkflows(defaults: defaults), incoming: backup.workflows).count,
1129 cachedItems: max(current.cachedItems, backup.appSettings.recentSearches.count + backup.appSettings.savedDomains.count),
1130 monitoringLogs: mergeMonitoringLogs(existing: loadMonitoringLogs(defaults: defaults), incoming: backup.monitoringLogs).count
1131 )
1132 case .trackedDomains(let trackedDomains, _):
1133 let total = mode == .replace
1134 ? trackedDomains.count
1135 : mergeTrackedDomains(existing: loadTrackedDomains(defaults: defaults), incoming: trackedDomains).domains.count
1136 return DataLifecycleSummary(
1137 trackedDomains: total,
1138 historySnapshots: current.historySnapshots,
1139 auditSessions: current.auditSessions,
1140 workflows: current.workflows,
1141 cachedItems: current.cachedItems,
1142 monitoringLogs: current.monitoringLogs
1143 )
1144 case .workflows(let workflows, _):
1145 let total = mode == .replace
1146 ? workflows.count
1147 : mergeWorkflows(existing: loadWorkflows(defaults: defaults), incoming: workflows).count
1148 return DataLifecycleSummary(
1149 trackedDomains: current.trackedDomains,
1150 historySnapshots: current.historySnapshots,
1151 auditSessions: current.auditSessions,
1152 workflows: total,
1153 cachedItems: current.cachedItems,
1154 monitoringLogs: current.monitoringLogs
1155 )
1156 }
1157 }
1158
1159 private static func mergeTrackedDomains(existing: [TrackedDomain], incoming: [TrackedDomain]) -> MergedTrackedDomains {
1160 var mergedByDomain = Dictionary(uniqueKeysWithValues: existing.map { (normalizeDomain($0.domain), $0) })
1161 var idMap: [UUID: UUID] = [:]
1162
1163 for trackedDomain in incoming {
1164 let key = normalizeDomain(trackedDomain.domain)
1165 guard !key.isEmpty else { continue }
1166
1167 if let existingDomain = mergedByDomain[key] {
1168 let chosen = trackedDomain.updatedAt >= existingDomain.updatedAt
1169 ? mergeTrackedDomain(existing: existingDomain, incoming: trackedDomain)
1170 : mergeTrackedDomain(existing: trackedDomain, incoming: existingDomain)
1171 mergedByDomain[key] = chosen
1172 idMap[trackedDomain.id] = chosen.id
1173 } else {
1174 let normalized = normalizedTrackedDomain(trackedDomain)
1175 mergedByDomain[key] = normalized
1176 idMap[trackedDomain.id] = normalized.id
1177 }
1178 }
1179
1180 for trackedDomain in existing {
1181 idMap[trackedDomain.id] = mergedByDomain[normalizeDomain(trackedDomain.domain)]?.id ?? trackedDomain.id
1182 }
1183
1184 return MergedTrackedDomains(
1185 domains: mergedByDomain.values.sorted {
1186 if $0.updatedAt != $1.updatedAt {
1187 return $0.updatedAt > $1.updatedAt
1188 }
1189 return $0.domain.localizedCaseInsensitiveCompare($1.domain) == .orderedAscending
1190 },
1191 idMap: idMap
1192 )
1193 }
1194
1195 private static func mergeTrackedDomain(existing: TrackedDomain, incoming: TrackedDomain) -> TrackedDomain {
1196 let normalizedDomain = normalizeDomain(existing.domain.isEmpty ? incoming.domain : existing.domain)
1197 let winner = existing.updatedAt >= incoming.updatedAt ? existing : incoming
1198 let note = winner.note ?? existing.note ?? incoming.note
1199
1200 return TrackedDomain(
1201 id: existing.id,
1202 domain: normalizedDomain,
1203 createdAt: min(existing.createdAt, incoming.createdAt),
1204 updatedAt: max(existing.updatedAt, incoming.updatedAt),
1205 note: note,
1206 isPinned: existing.isPinned || incoming.isPinned,
1207 monitoringEnabled: existing.monitoringEnabled || incoming.monitoringEnabled,
1208 lastKnownAvailability: winner.lastKnownAvailability ?? existing.lastKnownAvailability ?? incoming.lastKnownAvailability,
1209 lastSnapshotID: winner.lastSnapshotID ?? existing.lastSnapshotID ?? incoming.lastSnapshotID,
1210 lastChangeSummary: winner.lastChangeSummary ?? existing.lastChangeSummary ?? incoming.lastChangeSummary,
1211 lastChangeSeverity: winner.lastChangeSeverity ?? existing.lastChangeSeverity ?? incoming.lastChangeSeverity,
1212 certificateWarningLevel: higherCertificateWarningLevel(existing.certificateWarningLevel, incoming.certificateWarningLevel),
1213 certificateDaysRemaining: winner.certificateDaysRemaining ?? existing.certificateDaysRemaining ?? incoming.certificateDaysRemaining,
1214 lastMonitoredAt: [existing.lastMonitoredAt, incoming.lastMonitoredAt].compactMap { $0 }.max(),
1215 lastAlertAt: [existing.lastAlertAt, incoming.lastAlertAt].compactMap { $0 }.max(),
1216 monitoringState: preferredMonitoringState(existing.monitoringState, incoming.monitoringState),
1217 pendingMonitoringAlerts: deduplicatedPendingAlerts(existing.pendingMonitoringAlerts + incoming.pendingMonitoringAlerts)
1218 )
1219 }
1220
1221 private static func mergeHistoryEntries(existing: [HistoryEntry], incoming: [HistoryEntry]) -> [HistoryEntry] {
1222 var mergedByKey: [String: HistoryEntry] = [:]
1223
1224 for entry in existing + incoming {
1225 let key = historyKey(for: entry)
1226 if let existingEntry = mergedByKey[key] {
1227 mergedByKey[key] = chooseBetterHistoryEntry(existingEntry, entry)
1228 } else {
1229 mergedByKey[key] = entry
1230 }
1231 }
1232
1233 return mergedByKey.values.sorted { lhs, rhs in
1234 if lhs.timestamp != rhs.timestamp {
1235 return lhs.timestamp > rhs.timestamp
1236 }
1237 return lhs.domain.localizedCaseInsensitiveCompare(rhs.domain) == .orderedAscending
1238 }
1239 }
1240
1241 private static func mergeAuditSessions(existing: [AuditSession], incoming: [AuditSession]) -> [AuditSession] {
1242 var merged = Dictionary(uniqueKeysWithValues: existing.map { ($0.id, $0) })
1243
1244 for session in incoming {
1245 if let existingSession = merged[session.id] {
1246 let winner = existingSession.findings.count >= session.findings.count ? existingSession : session
1247 let candidateNotes = [existingSession.notes, session.notes]
1248 .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
1249 .filter { !$0.isEmpty }
1250 let notes = candidateNotes.reduce(into: [String]()) { result, note in
1251 if !result.contains(note) {
1252 result.append(note)
1253 }
1254 }.joined(separator: "\n\n")
1255 merged[session.id] = AuditSession(
1256 id: existingSession.id,
1257 domain: existingSession.domain,
1258 createdAt: min(existingSession.createdAt, session.createdAt),
1259 reviewer: winner.reviewer,
1260 status: winner.status,
1261 evidence: winner.evidence,
1262 findings: winner.findings,
1263 notes: notes,
1264 checklist: winner.checklist
1265 )
1266 } else {
1267 merged[session.id] = session
1268 }
1269 }
1270
1271 return merged.values.sorted { $0.createdAt > $1.createdAt }
1272 }
1273
1274 private static func mergeWorkflows(existing: [DomainWorkflow], incoming: [DomainWorkflow]) -> [DomainWorkflow] {
1275 var merged = Dictionary(uniqueKeysWithValues: existing.map { ($0.id, normalizedWorkflow($0)) })
1276
1277 for workflow in incoming {
1278 let normalized = normalizedWorkflow(workflow)
1279 if let existingWorkflow = merged[workflow.id] {
1280 let winner = existingWorkflow.updatedAt >= normalized.updatedAt ? existingWorkflow : normalized
1281 merged[workflow.id] = DomainWorkflow(
1282 id: existingWorkflow.id,
1283 name: winner.name,
1284 domains: deduplicated(existingWorkflow.domains + normalized.domains),
1285 createdAt: min(existingWorkflow.createdAt, normalized.createdAt),
1286 updatedAt: max(existingWorkflow.updatedAt, normalized.updatedAt),
1287 notes: winner.notes ?? existingWorkflow.notes ?? normalized.notes
1288 )
1289 } else {
1290 merged[workflow.id] = normalized
1291 }
1292 }
1293
1294 return merged.values.sorted(by: workflowSort)
1295 }
1296
1297 private static func mergeMonitoringLogs(existing: [MonitoringLog], incoming: [MonitoringLog]) -> [MonitoringLog] {
1298 var merged: [UUID: MonitoringLog] = [:]
1299 for log in existing + incoming {
1300 if let current = merged[log.id] {
1301 merged[log.id] = log.timestamp >= current.timestamp ? log : current
1302 } else {
1303 merged[log.id] = log
1304 }
1305 }
1306 return merged.values.sorted { $0.timestamp > $1.timestamp }.prefix(MonitoringStorage.maxLogs).map { $0 }
1307 }
1308
1309 private static func mergeAppSettings(existing: AppSettingsSnapshot, incoming: AppSettingsSnapshot) -> AppSettingsSnapshot {
1310 AppSettingsSnapshot(
1311 recentSearches: deduplicated(incoming.recentSearches + existing.recentSearches).prefix(20).map { $0 },
1312 savedDomains: deduplicated(incoming.savedDomains + existing.savedDomains),
1313 resolverURLString: incoming.resolverURLString.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? existing.resolverURLString : incoming.resolverURLString,
1314 appDensityRawValue: incoming.appDensityRawValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? existing.appDensityRawValue : incoming.appDensityRawValue
1315 )
1316 }
1317
1318 private static func mergeFeatureMetadata(existing: FeatureMetadataSnapshot, incoming: FeatureMetadataSnapshot?) -> FeatureMetadataSnapshot {
1319 guard let incoming else { return existing }
1320
1321 let mergedUsageCredits: UsageCreditsSnapshot?
1322 switch (existing.usageCredits, incoming.usageCredits) {
1323 case let (.some(lhs), .some(rhs)):
1324 let mergedRemaining = Dictionary(uniqueKeysWithValues: UsageCreditFeature.allCases.map { feature in
1325 (feature, max(lhs.remainingByFeature[feature] ?? 0, rhs.remainingByFeature[feature] ?? 0))
1326 })
1327 mergedUsageCredits = UsageCreditsSnapshot(
1328 appVersion: rhs.appVersion,
1329 remainingByFeature: mergedRemaining
1330 )
1331 case (.some(let lhs), nil):
1332 mergedUsageCredits = lhs
1333 case (nil, .some(let rhs)):
1334 mergedUsageCredits = rhs
1335 case (nil, nil):
1336 mergedUsageCredits = nil
1337 }
1338
1339 return FeatureMetadataSnapshot(
1340 cachedEntitlement: incoming.cachedEntitlement ?? existing.cachedEntitlement,
1341 usageCredits: mergedUsageCredits
1342 )
1343 }
1344
1345 private static func remapMonitoringSettings(
1346 _ settings: MonitoringSettings,
1347 using idMap: [UUID: UUID]
1348 ) -> MonitoringSettings {
1349 var remapped = settings
1350 remapped.selectedDomainIDs = deduplicatedUUIDs(settings.selectedDomainIDs.compactMap { idMap[$0] ?? $0 })
1351 return remapped
1352 }
1353
1354 private static func remapHistoryEntry(_ entry: HistoryEntry, using idMap: [UUID: UUID]) -> HistoryEntry {
1355 var remapped = entry
1356 if let trackedDomainID = entry.trackedDomainID {
1357 remapped.trackedDomainID = idMap[trackedDomainID] ?? trackedDomainID
1358 }
1359 return remapped
1360 }
1361
1362 private static func chooseBetterHistoryEntry(_ lhs: HistoryEntry, _ rhs: HistoryEntry) -> HistoryEntry {
1363 if historyQualityScore(lhs) == historyQualityScore(rhs) {
1364 return lhs.timestamp >= rhs.timestamp ? lhs : rhs
1365 }
1366 return historyQualityScore(lhs) >= historyQualityScore(rhs) ? lhs : rhs
1367 }
1368
1369 private static func historyQualityScore(_ entry: HistoryEntry) -> Int {
1370 var score = 0
1371 score += entry.dnsSections.count
1372 score += entry.httpHeaders.count
1373 score += entry.reachabilityResults.count
1374 score += entry.redirectChain.count
1375 score += entry.subdomains.count
1376 score += entry.portScanResults.count
1377 score += entry.validationIssues.isEmpty ? 2 : 0
1378 score += entry.sslInfo == nil ? 0 : 4
1379 score += entry.ownership == nil ? 0 : 4
1380 score += entry.emailSecurity == nil ? 0 : 3
1381 score += entry.ipGeolocation == nil ? 0 : 2
1382 return score
1383 }
1384
1385 private static func historyKey(for entry: HistoryEntry) -> String {
1386 if entry.id != UUID() {
1387 return "id:\(entry.id.uuidString)"
1388 }
1389 return "domain:\(normalizeDomain(entry.domain))|timestamp:\(entry.timestamp.timeIntervalSince1970)"
1390 }
1391
1392 private nonisolated static func normalizeDomain(_ value: String) -> String {
1393 value
1394 .trimmingCharacters(in: .whitespacesAndNewlines)
1395 .replacingOccurrences(of: "https://", with: "")
1396 .replacingOccurrences(of: "http://", with: "")
1397 .components(separatedBy: "/")
1398 .first?
1399 .lowercased() ?? ""
1400 }
1401
1402 private static func normalizedTrackedDomain(_ trackedDomain: TrackedDomain) -> TrackedDomain {
1403 TrackedDomain(
1404 id: trackedDomain.id,
1405 domain: normalizeDomain(trackedDomain.domain),
1406 createdAt: trackedDomain.createdAt,
1407 updatedAt: trackedDomain.updatedAt,
1408 note: trackedDomain.note,
1409 isPinned: trackedDomain.isPinned,
1410 monitoringEnabled: trackedDomain.monitoringEnabled,
1411 lastKnownAvailability: trackedDomain.lastKnownAvailability,
1412 lastSnapshotID: trackedDomain.lastSnapshotID,
1413 lastChangeSummary: trackedDomain.lastChangeSummary,
1414 lastChangeSeverity: trackedDomain.lastChangeSeverity,
1415 certificateWarningLevel: trackedDomain.certificateWarningLevel,
1416 certificateDaysRemaining: trackedDomain.certificateDaysRemaining,
1417 lastMonitoredAt: trackedDomain.lastMonitoredAt,
1418 lastAlertAt: trackedDomain.lastAlertAt,
1419 monitoringState: trackedDomain.monitoringState,
1420 pendingMonitoringAlerts: trackedDomain.pendingMonitoringAlerts
1421 )
1422 }
1423
1424 private static func preferredMonitoringState(_ lhs: MonitoringState, _ rhs: MonitoringState) -> MonitoringState {
1425 let lhsDate = [lhs.lastCheck, lhs.lastAlertDate, lhs.lastChangeDate].compactMap { $0 }.max() ?? .distantPast
1426 let rhsDate = [rhs.lastCheck, rhs.lastAlertDate, rhs.lastChangeDate].compactMap { $0 }.max() ?? .distantPast
1427 return lhsDate >= rhsDate ? lhs : rhs
1428 }
1429
1430 private static func deduplicatedPendingAlerts(_ alerts: [MonitoringPendingAlert]) -> [MonitoringPendingAlert] {
1431 var uniqueByHash: [String: MonitoringPendingAlert] = [:]
1432 for alert in alerts {
1433 if let existing = uniqueByHash[alert.changeHash] {
1434 uniqueByHash[alert.changeHash] = existing.detectedAt >= alert.detectedAt ? existing : alert
1435 } else {
1436 uniqueByHash[alert.changeHash] = alert
1437 }
1438 }
1439 return uniqueByHash.values.sorted { $0.detectedAt < $1.detectedAt }
1440 }
1441
1442 private static func normalizedWorkflow(_ workflow: DomainWorkflow) -> DomainWorkflow {
1443 DomainWorkflow(
1444 id: workflow.id,
1445 name: workflow.name.trimmingCharacters(in: .whitespacesAndNewlines),
1446 domains: deduplicated(workflow.domains.map(Self.normalizeDomain).filter { !$0.isEmpty }),
1447 createdAt: workflow.createdAt,
1448 updatedAt: workflow.updatedAt,
1449 notes: workflow.notes?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty
1450 )
1451 }
1452
1453 private static func deduplicatedTrackedDomains(_ trackedDomains: [TrackedDomain]) -> [TrackedDomain] {
1454 mergeTrackedDomains(existing: [], incoming: trackedDomains).domains
1455 }
1456
1457 private static func deduplicatedHistoryEntries(_ historyEntries: [HistoryEntry]) -> [HistoryEntry] {
1458 mergeHistoryEntries(existing: [], incoming: historyEntries)
1459 }
1460
1461 private static func deduplicatedWorkflows(_ workflows: [DomainWorkflow]) -> [DomainWorkflow] {
1462 mergeWorkflows(existing: [], incoming: workflows)
1463 }
1464
1465 private nonisolated static func deduplicated(_ values: [String]) -> [String] {
1466 var seen = Set<String>()
1467 return values.filter { seen.insert($0).inserted }
1468 }
1469
1470 private nonisolated static func deduplicatedUUIDs(_ values: [UUID]) -> [UUID] {
1471 var seen = Set<UUID>()
1472 return values.filter { seen.insert($0).inserted }
1473 }
1474
1475 private nonisolated static func workflowSort(lhs: DomainWorkflow, rhs: DomainWorkflow) -> Bool {
1476 if lhs.updatedAt != rhs.updatedAt {
1477 return lhs.updatedAt > rhs.updatedAt
1478 }
1479 return lhs.name.localizedCaseInsensitiveCompare(rhs.name) == .orderedAscending
1480 }
1481
1482 private nonisolated static func higherCertificateWarningLevel(
1483 _ lhs: CertificateWarningLevel,
1484 _ rhs: CertificateWarningLevel
1485 ) -> CertificateWarningLevel {
1486 let rank: [CertificateWarningLevel: Int] = [
1487 .none: 0,
1488 .warning: 1,
1489 .critical: 2
1490 ]
1491 return (rank[lhs] ?? 0) >= (rank[rhs] ?? 0) ? lhs : rhs
1492 }
1493
1494 private static func makeEncoder() -> JSONEncoder {
1495 let encoder = JSONEncoder()
1496 encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
1497 return encoder
1498 }
1499}
1500
1501private struct MergedTrackedDomains {
1502 let domains: [TrackedDomain]
1503 let idMap: [UUID: UUID]
1504}
1505
1506private enum ImportPayload {
1507 case backup(DomainDigBackup, DataValidationReport)
1508 case trackedDomains([TrackedDomain], DataValidationReport)
1509 case workflows([DomainWorkflow], DataValidationReport)
1510
1511 var kind: DataPortabilityImportKind {
1512 switch self {
1513 case .backup:
1514 return .backup
1515 case .trackedDomains:
1516 return .trackedDomains
1517 case .workflows:
1518 return .workflows
1519 }
1520 }
1521
1522 var validationReport: DataValidationReport {
1523 switch self {
1524 case .backup(_, let report), .trackedDomains(_, let report), .workflows(_, let report):
1525 return report
1526 }
1527 }
1528
1529 func summaryLines(mode: DataPortabilityImportMode) -> [String] {
1530 switch self {
1531 case .backup(let backup, _):
1532 return [
1533 "Full backup import",
1534 "\(backup.trackedDomains.count) tracked domains",
1535 "\(backup.historyEntries.count) history snapshots",
1536 "\(backup.auditSessions?.count ?? 0) audit sessions",
1537 "\(backup.workflows.count) workflows",
1538 "\(backup.monitoringLogs.count) monitoring logs",
1539 mode == .replace ? "Replace mode will overwrite local backupable data." : "Merge mode will keep local data and consolidate duplicates."
1540 ]
1541 case .trackedDomains(let trackedDomains, _):
1542 return [
1543 "Tracked domains import",
1544 "\(trackedDomains.count) tracked domains",
1545 mode == .replace ? "Replace mode will overwrite the watchlist." : "Merge mode will consolidate duplicates by normalized domain."
1546 ]
1547 case .workflows(let workflows, _):
1548 return [
1549 "Workflow import",
1550 "\(workflows.count) workflows",
1551 mode == .replace ? "Replace mode will overwrite saved workflows." : "Merge mode will merge matching workflow IDs and deduplicate domains."
1552 ]
1553 }
1554 }
1555}
1556
1557private extension String {
1558 var nilIfEmpty: String? {
1559 isEmpty ? nil : self
1560 }
1561}