diff options
| author | Christian Cleberg <[email protected]> | 2026-04-23 22:15:20 -0500 |
|---|---|---|
| committer | Christian Cleberg <[email protected]> | 2026-04-23 22:15:20 -0500 |
| commit | f045a5d339a796f40a9e958436ebc6f5314bdab8 (patch) | |
| tree | c1d122f3ed7a9110dfcd001c86a56ed67980dfcc | |
| parent | 6c23daaaba2a954220ece2afa7193dbdcad55c22 (diff) | |
| download | domain-dig-f045a5d339a796f40a9e958436ebc6f5314bdab8.tar.gz domain-dig-f045a5d339a796f40a9e958436ebc6f5314bdab8.tar.bz2 domain-dig-f045a5d339a796f40a9e958436ebc6f5314bdab8.zip | |
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
| -rw-r--r-- | DomainDataPortabilityService.swift | 1397 | ||||
| -rw-r--r-- | DomainDig.xcodeproj/project.pbxproj | 16 | ||||
| -rw-r--r-- | DomainDig/AppVersion.swift | 2 | ||||
| -rw-r--r-- | DomainDig/ContentView.swift | 377 | ||||
| -rw-r--r-- | DomainDig/DomainDigApp.swift | 13 | ||||
| -rw-r--r-- | DomainDig/DomainMonitoringService.swift | 613 | ||||
| -rw-r--r-- | DomainDig/DomainViewModel.swift | 295 | ||||
| -rw-r--r-- | DomainDig/FeatureAccessService.swift | 19 | ||||
| -rw-r--r-- | DomainDig/Info.plist | 8 | ||||
| -rw-r--r-- | DomainDig/LocalNotificationService.swift | 33 | ||||
| -rw-r--r-- | DomainDig/Models.swift | 223 | ||||
| -rw-r--r-- | DomainDig/MonitoringView.swift | 198 | ||||
| -rw-r--r-- | DomainDig/PaywallView.swift | 4 | ||||
| -rw-r--r-- | DomainDig/PremiumAccessService.swift | 6 | ||||
| -rw-r--r-- | DomainDig/RootTabView.swift | 7 | ||||
| -rw-r--r-- | DomainDig/WatchlistView.swift | 21 | ||||
| -rw-r--r-- | DomainDigCLI.swift | 186 |
17 files changed, 3326 insertions, 92 deletions
diff --git a/DomainDataPortabilityService.swift b/DomainDataPortabilityService.swift new file mode 100644 index 0000000..fabdfdd --- /dev/null +++ b/DomainDataPortabilityService.swift @@ -0,0 +1,1397 @@ +import Foundation +import UniformTypeIdentifiers + +struct DomainDigBackup: Codable { + static let currentSchemaVersion = 1 + + let schemaVersion: Int + let exportedAt: Date + let appVersion: String + let trackedDomains: [TrackedDomain] + let historyEntries: [HistoryEntry] + let workflows: [DomainWorkflow] + let monitoringSettings: MonitoringSettings? + let monitoringLogs: [MonitoringLog] + let appSettings: AppSettingsSnapshot + let featureMetadata: FeatureMetadataSnapshot? +} + +struct AppSettingsSnapshot: Codable { + let recentSearches: [String] + let savedDomains: [String] + let resolverURLString: String + let appDensityRawValue: String +} + +struct FeatureMetadataSnapshot: Codable { + let cachedEntitlement: PurchaseService.CachedEntitlement? + let usageCredits: UsageCreditsSnapshot? +} + +struct UsageCreditsSnapshot: Codable { + let appVersion: String + let remainingByFeature: [UsageCreditFeature: Int] +} + +struct TrackedDomainsExport: Codable { + let schemaVersion: Int + let exportedAt: Date + let appVersion: String + let trackedDomains: [TrackedDomain] +} + +struct WorkflowsExport: Codable { + let schemaVersion: Int + let exportedAt: Date + let appVersion: String + let workflows: [DomainWorkflow] +} + +struct HistoryExport: Codable { + let schemaVersion: Int + let exportedAt: Date + let appVersion: String + let historyEntries: [HistoryEntry] +} + +enum DataPortabilityImportMode: String, CaseIterable, Identifiable { + case merge + case replace + + var id: String { rawValue } + + var title: String { + switch self { + case .merge: + return "Merge" + case .replace: + return "Replace" + } + } + + var explanation: String { + switch self { + case .merge: + return "Keep local data and merge imported items." + case .replace: + return "Replace local data with the imported file." + } + } +} + +enum DataPortabilityImportKind: String { + case backup + case trackedDomains + case workflows +} + +struct DataLifecycleSummary: Equatable { + let trackedDomains: Int + let historySnapshots: Int + let workflows: Int + let cachedItems: Int + let monitoringLogs: Int +} + +struct DataValidationMessage: Identifiable, Equatable { + let id = UUID() + let text: String + let isError: Bool +} + +struct DataValidationReport { + let messages: [DataValidationMessage] + + var warnings: [String] { + messages.filter { !$0.isError }.map(\.text) + } + + var errors: [String] { + messages.filter(\.isError).map(\.text) + } +} + +struct DataImportPreview { + let fileName: String + let kind: DataPortabilityImportKind + let mode: DataPortabilityImportMode + let summaryLines: [String] + let warnings: [String] + let currentCounts: DataLifecycleSummary + let projectedCounts: DataLifecycleSummary + fileprivate let payload: ImportPayload +} + +struct DataImportResult { + let kind: DataPortabilityImportKind + let mode: DataPortabilityImportMode + let summary: String + let warnings: [String] +} + +enum DataPortabilityError: LocalizedError { + case unsupportedFormat + case unsupportedSchema(Int) + case invalidCSV(String) + case unreadableFile + case validationFailed([String]) + + var errorDescription: String? { + switch self { + case .unsupportedFormat: + return "The selected file is not a supported DomainDig import." + case .unsupportedSchema(let version): + return "Backup schema v\(version) is not supported by this version of DomainDig." + case .invalidCSV(let message): + return message + case .unreadableFile: + return "The selected file could not be read." + case .validationFailed(let messages): + return messages.joined(separator: "\n") + } + } +} + +enum DataPortabilityCSV { + static func trackedDomains(_ domains: [TrackedDomain]) -> String { + let header = [ + "id", + "domain", + "createdAt", + "updatedAt", + "note", + "isPinned", + "monitoringEnabled", + "lastKnownAvailability", + "certificateWarningLevel", + "certificateDaysRemaining", + "lastMonitoredAt", + "lastAlertAt" + ] + + let rows = domains.map { domain in + [ + domain.id.uuidString, + domain.domain, + DomainDataPortabilityService.formatISODate(domain.createdAt), + DomainDataPortabilityService.formatISODate(domain.updatedAt), + domain.note ?? "", + String(domain.isPinned), + String(domain.monitoringEnabled), + domain.lastKnownAvailability?.rawValue ?? "", + domain.certificateWarningLevel.rawValue, + domain.certificateDaysRemaining.map(String.init) ?? "", + domain.lastMonitoredAt.map(DomainDataPortabilityService.formatISODate) ?? "", + domain.lastAlertAt.map(DomainDataPortabilityService.formatISODate) ?? "" + ] + } + + return ([header] + rows).map(Self.csvLine).joined(separator: "\n") + } + + static func workflows(_ workflows: [DomainWorkflow]) -> String { + let header = ["id", "name", "domains", "createdAt", "updatedAt", "notes"] + let rows = workflows.map { workflow in + [ + workflow.id.uuidString, + workflow.name, + workflow.domains.joined(separator: "|"), + DomainDataPortabilityService.formatISODate(workflow.createdAt), + DomainDataPortabilityService.formatISODate(workflow.updatedAt), + workflow.notes ?? "" + ] + } + + return ([header] + rows).map(Self.csvLine).joined(separator: "\n") + } + + static func parseTrackedDomains(from string: String) throws -> [TrackedDomain] { + let rows = try parseRows(from: string) + guard let header = rows.first else { return [] } + let mappedRows = rows.dropFirst().map { dictionary(for: Array($0), header: header) } + + return mappedRows.compactMap { row in + let domain = Self.normalizedDomain(row["domain"]) + guard !domain.isEmpty else { return nil } + + let createdAt = parseDate(row["createdAt"]) ?? Date() + let updatedAt = parseDate(row["updatedAt"]) ?? createdAt + let availability = row["lastKnownAvailability"].flatMap(DomainAvailabilityStatus.init(rawValue:)) + let certificateLevel = row["certificateWarningLevel"].flatMap(CertificateWarningLevel.init(rawValue:)) ?? .none + + return TrackedDomain( + id: UUID(uuidString: row["id"] ?? "") ?? UUID(), + domain: domain, + createdAt: createdAt, + updatedAt: updatedAt, + note: row["note"]?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty, + isPinned: Self.parseBool(row["isPinned"]), + monitoringEnabled: row["monitoringEnabled"].map(Self.parseBool) ?? true, + lastKnownAvailability: availability, + certificateWarningLevel: certificateLevel, + certificateDaysRemaining: Int(row["certificateDaysRemaining"] ?? ""), + lastMonitoredAt: parseDate(row["lastMonitoredAt"]), + lastAlertAt: parseDate(row["lastAlertAt"]) + ) + } + } + + static func parseWorkflows(from string: String) throws -> [DomainWorkflow] { + let rows = try parseRows(from: string) + guard let header = rows.first else { return [] } + let mappedRows = rows.dropFirst().map { dictionary(for: Array($0), header: header) } + + return mappedRows.compactMap { row in + let name = row["name"]?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let domains = (row["domains"] ?? "") + .split(separator: "|") + .map { Self.normalizedDomain(String($0)) } + .filter { !$0.isEmpty } + + guard !name.isEmpty, !domains.isEmpty else { return nil } + + let createdAt = parseDate(row["createdAt"]) ?? Date() + let updatedAt = parseDate(row["updatedAt"]) ?? createdAt + return DomainWorkflow( + id: UUID(uuidString: row["id"] ?? "") ?? UUID(), + name: name, + domains: Self.deduplicated(domains), + createdAt: createdAt, + updatedAt: updatedAt, + notes: row["notes"]?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty + ) + } + } + + private nonisolated static func parseRows(from string: String) throws -> [[String]] { + var rows: [[String]] = [] + var currentRow: [String] = [] + var currentField = "" + var insideQuotes = false + + for character in string { + switch character { + case "\"": + insideQuotes.toggle() + case "," where !insideQuotes: + currentRow.append(currentField) + currentField = "" + case "\n" where !insideQuotes: + currentRow.append(currentField) + rows.append(currentRow) + currentRow = [] + currentField = "" + case "\r": + continue + default: + currentField.append(character) + } + } + + if insideQuotes { + throw DataPortabilityError.invalidCSV("The CSV file is malformed.") + } + + if !currentField.isEmpty || !currentRow.isEmpty { + currentRow.append(currentField) + rows.append(currentRow) + } + + return rows.filter { !$0.allSatisfy(\.isEmpty) } + } + + private nonisolated static func dictionary(for row: [String], header: [String]) -> [String: String] { + Dictionary(uniqueKeysWithValues: zip(header, row).map { ($0.0, $0.1) }) + } + + private nonisolated static func csvLine(_ values: [String]) -> String { + values.map { value in + let escaped = value.replacingOccurrences(of: "\"", with: "\"\"") + return "\"\(escaped)\"" + } + .joined(separator: ",") + } + + private nonisolated static func parseDate(_ value: String?) -> Date? { + guard let value, !value.isEmpty else { return nil } + return DomainDataPortabilityService.parseISODate(value) + } + + private nonisolated static func parseBool(_ value: String?) -> Bool { + guard let value else { return false } + return value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == "true" + } + + private nonisolated static func normalizedDomain(_ value: String?) -> String { + value? + .trimmingCharacters(in: .whitespacesAndNewlines) + .replacingOccurrences(of: "https://", with: "") + .replacingOccurrences(of: "http://", with: "") + .components(separatedBy: "/") + .first? + .lowercased() ?? "" + } + + private nonisolated static func deduplicated(_ values: [String]) -> [String] { + var seen = Set<String>() + return values.filter { seen.insert($0).inserted } + } +} + +enum DataMigrationService { + private static let migrationMarkerKey = "data.migrations.v3_4_0" + + static func migrateIfNeeded(defaults: UserDefaults = .standard) { + guard !defaults.bool(forKey: migrationMarkerKey) else { return } + + let trackedDomains = DomainDataPortabilityService.loadTrackedDomains(defaults: defaults) + DomainDataPortabilityService.saveTrackedDomains(trackedDomains, defaults: defaults) + + let historyEntries = DomainDataPortabilityService.loadHistoryEntries(defaults: defaults) + DomainDataPortabilityService.saveHistoryEntries(historyEntries, defaults: defaults) + + let workflows = DomainDataPortabilityService.loadWorkflows(defaults: defaults) + DomainDataPortabilityService.saveWorkflows(workflows, defaults: defaults) + + let monitoringSettings = DomainDataPortabilityService.loadMonitoringSettings(defaults: defaults) + let sanitizedMonitoringSettings = MonitoringStorage.sanitizeSettings(monitoringSettings, trackedDomains: trackedDomains) + DomainDataPortabilityService.saveMonitoringSettings(sanitizedMonitoringSettings, defaults: defaults) + + let monitoringLogs = DomainDataPortabilityService.loadMonitoringLogs(defaults: defaults) + DomainDataPortabilityService.saveMonitoringLogs(monitoringLogs, defaults: defaults) + + defaults.set(true, forKey: migrationMarkerKey) + } +} + +enum DataValidationService { + static func validate(backup: DomainDigBackup) -> DataValidationReport { + var messages: [DataValidationMessage] = [] + + if backup.schemaVersion > DomainDigBackup.currentSchemaVersion { + messages.append(.init(text: "This backup was created by a newer DomainDig version.", isError: true)) + } else if backup.schemaVersion < DomainDigBackup.currentSchemaVersion { + messages.append(.init(text: "Older backup schema detected. DomainDig will import using compatibility rules.", isError: false)) + } + + messages.append(contentsOf: validateTrackedDomains(backup.trackedDomains)) + messages.append(contentsOf: validateHistoryEntries(backup.historyEntries)) + messages.append(contentsOf: validateWorkflows(backup.workflows)) + + if backup.appSettings.resolverURLString.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + messages.append(.init(text: "Backup is missing a resolver URL. The default resolver will be used.", isError: false)) + } + + return DataValidationReport(messages: messages) + } + + static func validate(trackedDomains: [TrackedDomain]) -> DataValidationReport { + DataValidationReport(messages: validateTrackedDomains(trackedDomains)) + } + + static func validate(workflows: [DomainWorkflow]) -> DataValidationReport { + DataValidationReport(messages: validateWorkflows(workflows)) + } + + private static func validateTrackedDomains(_ trackedDomains: [TrackedDomain]) -> [DataValidationMessage] { + var messages: [DataValidationMessage] = [] + var seen = Set<String>() + + for trackedDomain in trackedDomains { + let normalized = normalizeDomain(trackedDomain.domain) + if normalized.isEmpty { + messages.append(.init(text: "A tracked domain entry is missing its domain name.", isError: true)) + continue + } + + if !seen.insert(normalized).inserted { + messages.append(.init(text: "Duplicate tracked domain found for \(normalized). Merge rules will consolidate it.", isError: false)) + } + } + + return messages + } + + private static func validateHistoryEntries(_ historyEntries: [HistoryEntry]) -> [DataValidationMessage] { + var messages: [DataValidationMessage] = [] + for entry in historyEntries { + if normalizeDomain(entry.domain).isEmpty { + messages.append(.init(text: "A history snapshot is missing its domain name.", isError: true)) + } + if entry.timestamp == .distantPast { + messages.append(.init(text: "A history snapshot is missing its timestamp.", isError: false)) + } + for issue in entry.validationIssues { + messages.append(.init(text: "History warning for \(entry.domain): \(issue)", isError: false)) + } + } + return messages + } + + private static func validateWorkflows(_ workflows: [DomainWorkflow]) -> [DataValidationMessage] { + var messages: [DataValidationMessage] = [] + for workflow in workflows { + if workflow.name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + messages.append(.init(text: "A workflow is missing its name.", isError: true)) + } + if workflow.domains.isEmpty { + messages.append(.init(text: "Workflow \(workflow.name) has no domains.", isError: false)) + } + if Set(workflow.domains.map(Self.normalizeDomain)).count != workflow.domains.count { + messages.append(.init(text: "Workflow \(workflow.name) contains duplicate domains. Import will deduplicate them.", isError: false)) + } + } + return messages + } + + private nonisolated static func normalizeDomain(_ value: String) -> String { + value + .trimmingCharacters(in: .whitespacesAndNewlines) + .replacingOccurrences(of: "https://", with: "") + .replacingOccurrences(of: "http://", with: "") + .components(separatedBy: "/") + .first? + .lowercased() ?? "" + } +} + +enum DomainDataPortabilityService { + static let backupUTType = UTType.json + static let csvUTType = UTType.commaSeparatedText + + fileprivate nonisolated static func formatISODate(_ date: Date) -> String { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return formatter.string(from: date) + } + + fileprivate nonisolated static func parseISODate(_ value: String) -> Date? { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return formatter.date(from: value) + } + + private enum StorageKey { + static let recentSearches = "recentSearches" + static let savedDomains = "savedDomains" + static let trackedDomains = "trackedDomains" + static let legacyWatchedDomains = "watchedDomains" + static let history = "lookupHistory" + static let workflows = "domainWorkflows" + static let monitoringSettings = "monitoring.settings" + static let monitoringLogs = "monitoring.logs" + static let appDensity = AppDensity.userDefaultsKey + static let resolverURL = DNSResolverOption.userDefaultsKey + static let purchaseCache = "purchase.cachedEntitlement" + static let usageCredits = "usageCredits.ledger" + } + + static func loadRecentSearches(defaults: UserDefaults = .standard) -> [String] { + defaults.stringArray(forKey: StorageKey.recentSearches) ?? [] + } + + static func saveRecentSearches(_ values: [String], defaults: UserDefaults = .standard) { + defaults.set(Array(values.prefix(20)), forKey: StorageKey.recentSearches) + } + + static func loadSavedDomains(defaults: UserDefaults = .standard) -> [String] { + defaults.stringArray(forKey: StorageKey.savedDomains) ?? [] + } + + static func saveSavedDomains(_ values: [String], defaults: UserDefaults = .standard) { + defaults.set(values, forKey: StorageKey.savedDomains) + } + + static func loadTrackedDomains(defaults: UserDefaults = .standard) -> [TrackedDomain] { + let decoder = JSONDecoder() + + if let data = defaults.data(forKey: StorageKey.trackedDomains), + let domains = try? decoder.decode([TrackedDomain].self, from: data) { + return deduplicatedTrackedDomains(domains) + } + + if let data = defaults.data(forKey: StorageKey.legacyWatchedDomains), + let legacy = try? decoder.decode([WatchedDomain].self, from: data) { + return deduplicatedTrackedDomains( + legacy.map { + TrackedDomain( + id: $0.id, + domain: normalizeDomain($0.domain), + createdAt: $0.createdAt, + updatedAt: $0.createdAt, + lastKnownAvailability: $0.lastKnownAvailability + ) + } + ) + } + + return [] + } + + static func saveTrackedDomains(_ trackedDomains: [TrackedDomain], defaults: UserDefaults = .standard) { + if let data = try? JSONEncoder().encode(deduplicatedTrackedDomains(trackedDomains)) { + defaults.set(data, forKey: StorageKey.trackedDomains) + } + defaults.removeObject(forKey: StorageKey.legacyWatchedDomains) + } + + static func loadHistoryEntries(defaults: UserDefaults = .standard) -> [HistoryEntry] { + guard let data = defaults.data(forKey: StorageKey.history) else { return [] } + + if let entries = try? JSONDecoder().decode([HistoryEntry].self, from: data) { + return deduplicatedHistoryEntries(entries) + } + + guard let rawArray = (try? JSONSerialization.jsonObject(with: data)) as? [Any] else { + return [] + } + + let decoder = JSONDecoder() + let entries = rawArray.compactMap { item -> HistoryEntry? in + guard JSONSerialization.isValidJSONObject(item), + let entryData = try? JSONSerialization.data(withJSONObject: item) else { + return nil + } + return try? decoder.decode(HistoryEntry.self, from: entryData) + } + return deduplicatedHistoryEntries(entries) + } + + static func saveHistoryEntries(_ historyEntries: [HistoryEntry], defaults: UserDefaults = .standard) { + if let data = try? JSONEncoder().encode(deduplicatedHistoryEntries(historyEntries)) { + defaults.set(data, forKey: StorageKey.history) + } + } + + static func loadWorkflows(defaults: UserDefaults = .standard) -> [DomainWorkflow] { + guard let data = defaults.data(forKey: StorageKey.workflows), + let workflows = try? JSONDecoder().decode([DomainWorkflow].self, from: data) else { + return [] + } + + return deduplicatedWorkflows(workflows).sorted(by: workflowSort) + } + + static func saveWorkflows(_ workflows: [DomainWorkflow], defaults: UserDefaults = .standard) { + if let data = try? JSONEncoder().encode(deduplicatedWorkflows(workflows)) { + defaults.set(data, forKey: StorageKey.workflows) + } + } + + static func loadMonitoringSettings(defaults: UserDefaults = .standard) -> MonitoringSettings { + guard let data = defaults.data(forKey: StorageKey.monitoringSettings), + let settings = try? JSONDecoder().decode(MonitoringSettings.self, from: data) else { + return MonitoringSettings() + } + return settings + } + + static func saveMonitoringSettings(_ settings: MonitoringSettings, defaults: UserDefaults = .standard) { + if let data = try? JSONEncoder().encode(settings) { + defaults.set(data, forKey: StorageKey.monitoringSettings) + } + } + + static func loadMonitoringLogs(defaults: UserDefaults = .standard) -> [MonitoringLog] { + guard let data = defaults.data(forKey: StorageKey.monitoringLogs), + let logs = try? JSONDecoder().decode([MonitoringLog].self, from: data) else { + return [] + } + return Array(logs.prefix(MonitoringStorage.maxLogs)) + } + + static func saveMonitoringLogs(_ logs: [MonitoringLog], defaults: UserDefaults = .standard) { + if let data = try? JSONEncoder().encode(Array(logs.prefix(MonitoringStorage.maxLogs))) { + defaults.set(data, forKey: StorageKey.monitoringLogs) + } + } + + static func loadAppSettings(defaults: UserDefaults = .standard) -> AppSettingsSnapshot { + AppSettingsSnapshot( + recentSearches: loadRecentSearches(defaults: defaults), + savedDomains: loadSavedDomains(defaults: defaults), + resolverURLString: defaults.string(forKey: StorageKey.resolverURL) ?? DNSResolverOption.defaultURLString, + appDensityRawValue: defaults.string(forKey: StorageKey.appDensity) ?? AppDensity.compact.rawValue + ) + } + + static func saveAppSettings(_ settings: AppSettingsSnapshot, defaults: UserDefaults = .standard) { + saveRecentSearches(settings.recentSearches, defaults: defaults) + saveSavedDomains(settings.savedDomains, defaults: defaults) + defaults.set(settings.resolverURLString, forKey: StorageKey.resolverURL) + defaults.set(settings.appDensityRawValue, forKey: StorageKey.appDensity) + } + + static func loadFeatureMetadata(defaults: UserDefaults = .standard) -> FeatureMetadataSnapshot { + let cachedEntitlement: PurchaseService.CachedEntitlement? + if let data = defaults.data(forKey: StorageKey.purchaseCache) { + cachedEntitlement = try? JSONDecoder().decode(PurchaseService.CachedEntitlement.self, from: data) + } else { + cachedEntitlement = nil + } + + let usageCredits: UsageCreditsSnapshot? + if let data = defaults.data(forKey: StorageKey.usageCredits) { + usageCredits = try? JSONDecoder().decode(UsageCreditsSnapshot.self, from: data) + } else { + usageCredits = nil + } + + return FeatureMetadataSnapshot(cachedEntitlement: cachedEntitlement, usageCredits: usageCredits) + } + + static func saveFeatureMetadata(_ featureMetadata: FeatureMetadataSnapshot?, defaults: UserDefaults = .standard) { + if let cachedEntitlement = featureMetadata?.cachedEntitlement, + let data = try? JSONEncoder().encode(cachedEntitlement) { + defaults.set(data, forKey: StorageKey.purchaseCache) + } + + if let usageCredits = featureMetadata?.usageCredits, + let data = try? JSONEncoder().encode(usageCredits) { + defaults.set(data, forKey: StorageKey.usageCredits) + } + } + + static func currentBackup(defaults: UserDefaults = .standard) -> DomainDigBackup { + DataMigrationService.migrateIfNeeded(defaults: defaults) + return DomainDigBackup( + schemaVersion: DomainDigBackup.currentSchemaVersion, + exportedAt: Date(), + appVersion: AppVersion.current, + trackedDomains: loadTrackedDomains(defaults: defaults), + historyEntries: loadHistoryEntries(defaults: defaults), + workflows: loadWorkflows(defaults: defaults), + monitoringSettings: loadMonitoringSettings(defaults: defaults), + monitoringLogs: loadMonitoringLogs(defaults: defaults), + appSettings: loadAppSettings(defaults: defaults), + featureMetadata: loadFeatureMetadata(defaults: defaults) + ) + } + + static func backupData(defaults: UserDefaults = .standard) throws -> Data { + try makeEncoder().encode(currentBackup(defaults: defaults)) + } + + static func trackedDomainsExportData(defaults: UserDefaults = .standard) throws -> Data { + try makeEncoder().encode( + TrackedDomainsExport( + schemaVersion: DomainDigBackup.currentSchemaVersion, + exportedAt: Date(), + appVersion: AppVersion.current, + trackedDomains: loadTrackedDomains(defaults: defaults) + ) + ) + } + + static func workflowsExportData(defaults: UserDefaults = .standard) throws -> Data { + try makeEncoder().encode( + WorkflowsExport( + schemaVersion: DomainDigBackup.currentSchemaVersion, + exportedAt: Date(), + appVersion: AppVersion.current, + workflows: loadWorkflows(defaults: defaults) + ) + ) + } + + static func historyExportData(defaults: UserDefaults = .standard) throws -> Data { + try makeEncoder().encode( + HistoryExport( + schemaVersion: DomainDigBackup.currentSchemaVersion, + exportedAt: Date(), + appVersion: AppVersion.current, + historyEntries: loadHistoryEntries(defaults: defaults) + ) + ) + } + + static func trackedDomainsCSV(defaults: UserDefaults = .standard) -> String { + DataPortabilityCSV.trackedDomains(loadTrackedDomains(defaults: defaults)) + } + + static func workflowsCSV(defaults: UserDefaults = .standard) -> String { + DataPortabilityCSV.workflows(loadWorkflows(defaults: defaults)) + } + + static func prepareImport( + data: Data, + fileName: String, + mode: DataPortabilityImportMode, + defaults: UserDefaults = .standard + ) throws -> DataImportPreview { + DataMigrationService.migrateIfNeeded(defaults: defaults) + let currentCounts = lifecycleSummary(defaults: defaults) + + let payload = try decodeImportPayload(from: data, fileName: fileName) + let projectedCounts = projectCounts(for: payload, mode: mode, defaults: defaults) + + return DataImportPreview( + fileName: fileName, + kind: payload.kind, + mode: mode, + summaryLines: payload.summaryLines(mode: mode), + warnings: payload.validationReport.warnings, + currentCounts: currentCounts, + projectedCounts: projectedCounts, + payload: payload + ) + } + + static func applyImport( + _ preview: DataImportPreview, + mode: DataPortabilityImportMode, + defaults: UserDefaults = .standard + ) throws -> DataImportResult { + switch preview.payload { + case .backup(let backup, let report): + try applyBackup(backup, mode: mode, defaults: defaults) + return DataImportResult( + kind: .backup, + mode: mode, + summary: "Imported backup with \(backup.trackedDomains.count) tracked domains, \(backup.historyEntries.count) history snapshots, and \(backup.workflows.count) workflows.", + warnings: report.warnings + ) + case .trackedDomains(let trackedDomains, let report): + applyTrackedDomainsImport(trackedDomains, mode: mode, defaults: defaults) + return DataImportResult( + kind: .trackedDomains, + mode: mode, + summary: "Imported \(trackedDomains.count) tracked domains.", + warnings: report.warnings + ) + case .workflows(let workflows, let report): + applyWorkflowsImport(workflows, mode: mode, defaults: defaults) + return DataImportResult( + kind: .workflows, + mode: mode, + summary: "Imported \(workflows.count) workflows.", + warnings: report.warnings + ) + } + } + + static func validateBackup(data: Data, fileName: String = "backup.json") throws -> DataValidationReport { + switch try decodeImportPayload(from: data, fileName: fileName) { + case .backup(_, let report): + return report + case .trackedDomains(_, let report): + return report + case .workflows(_, let report): + return report + } + } + + static func lifecycleSummary(defaults: UserDefaults = .standard) -> DataLifecycleSummary { + let appSettings = loadAppSettings(defaults: defaults) + let featureMetadata = loadFeatureMetadata(defaults: defaults) + let cachedItems = appSettings.recentSearches.count + + appSettings.savedDomains.count + + (featureMetadata.cachedEntitlement == nil ? 0 : 1) + + (featureMetadata.usageCredits == nil ? 0 : 1) + + return DataLifecycleSummary( + trackedDomains: loadTrackedDomains(defaults: defaults).count, + historySnapshots: loadHistoryEntries(defaults: defaults).count, + workflows: loadWorkflows(defaults: defaults).count, + cachedItems: cachedItems, + monitoringLogs: loadMonitoringLogs(defaults: defaults).count + ) + } + + private static func decodeImportPayload(from data: Data, fileName: String) throws -> ImportPayload { + let decoder = JSONDecoder() + + if let backup = try? decoder.decode(DomainDigBackup.self, from: data) { + if backup.schemaVersion > DomainDigBackup.currentSchemaVersion { + throw DataPortabilityError.unsupportedSchema(backup.schemaVersion) + } + let report = DataValidationService.validate(backup: backup) + if !report.errors.isEmpty { + throw DataPortabilityError.validationFailed(report.errors) + } + return .backup(backup, report) + } + + if let backup = try? decodeLegacyBackup(from: data) { + let report = DataValidationService.validate(backup: backup) + if !report.errors.isEmpty { + throw DataPortabilityError.validationFailed(report.errors) + } + return .backup(backup, report) + } + + if let export = try? decoder.decode(TrackedDomainsExport.self, from: data) { + let report = DataValidationService.validate(trackedDomains: export.trackedDomains) + if !report.errors.isEmpty { + throw DataPortabilityError.validationFailed(report.errors) + } + return .trackedDomains(export.trackedDomains, report) + } + + if let export = try? decoder.decode(WorkflowsExport.self, from: data) { + let report = DataValidationService.validate(workflows: export.workflows) + if !report.errors.isEmpty { + throw DataPortabilityError.validationFailed(report.errors) + } + return .workflows(export.workflows, report) + } + + if let trackedDomains = try? decoder.decode([TrackedDomain].self, from: data) { + let report = DataValidationService.validate(trackedDomains: trackedDomains) + if !report.errors.isEmpty { + throw DataPortabilityError.validationFailed(report.errors) + } + return .trackedDomains(trackedDomains, report) + } + + if let workflows = try? decoder.decode([DomainWorkflow].self, from: data) { + let report = DataValidationService.validate(workflows: workflows) + if !report.errors.isEmpty { + throw DataPortabilityError.validationFailed(report.errors) + } + return .workflows(workflows, report) + } + + let lowercasedFileName = fileName.lowercased() + if lowercasedFileName.hasSuffix(".csv") { + guard let string = String(data: data, encoding: .utf8) else { + throw DataPortabilityError.unreadableFile + } + if lowercasedFileName.contains("workflow") { + let workflows = try DataPortabilityCSV.parseWorkflows(from: string) + let report = DataValidationService.validate(workflows: workflows) + if !report.errors.isEmpty { + throw DataPortabilityError.validationFailed(report.errors) + } + return .workflows(workflows, report) + } + + let trackedDomains = try DataPortabilityCSV.parseTrackedDomains(from: string) + let report = DataValidationService.validate(trackedDomains: trackedDomains) + if !report.errors.isEmpty { + throw DataPortabilityError.validationFailed(report.errors) + } + return .trackedDomains(trackedDomains, report) + } + + throw DataPortabilityError.unsupportedFormat + } + + private static func decodeLegacyBackup(from data: Data) throws -> DomainDigBackup { + struct LegacyBackup: Decodable { + let trackedDomains: [TrackedDomain]? + let historyEntries: [HistoryEntry]? + let workflows: [DomainWorkflow]? + let monitoringSettings: MonitoringSettings? + let monitoringLogs: [MonitoringLog]? + let recentSearches: [String]? + let savedDomains: [String]? + } + + let legacy = try JSONDecoder().decode(LegacyBackup.self, from: data) + return DomainDigBackup( + schemaVersion: 0, + exportedAt: Date(), + appVersion: AppVersion.current, + trackedDomains: legacy.trackedDomains ?? [], + historyEntries: legacy.historyEntries ?? [], + workflows: legacy.workflows ?? [], + monitoringSettings: legacy.monitoringSettings, + monitoringLogs: legacy.monitoringLogs ?? [], + appSettings: AppSettingsSnapshot( + recentSearches: legacy.recentSearches ?? [], + savedDomains: legacy.savedDomains ?? [], + resolverURLString: DNSResolverOption.defaultURLString, + appDensityRawValue: AppDensity.compact.rawValue + ), + featureMetadata: nil + ) + } + + private static func applyBackup( + _ backup: DomainDigBackup, + mode: DataPortabilityImportMode, + defaults: UserDefaults + ) throws { + if mode == .replace { + saveTrackedDomains(deduplicatedTrackedDomains(backup.trackedDomains), defaults: defaults) + saveHistoryEntries(deduplicatedHistoryEntries(backup.historyEntries), defaults: defaults) + saveWorkflows(deduplicatedWorkflows(backup.workflows), defaults: defaults) + saveMonitoringSettings( + MonitoringStorage.sanitizeSettings( + backup.monitoringSettings ?? MonitoringSettings(), + trackedDomains: deduplicatedTrackedDomains(backup.trackedDomains) + ), + defaults: defaults + ) + saveMonitoringLogs(backup.monitoringLogs, defaults: defaults) + saveAppSettings(backup.appSettings, defaults: defaults) + saveFeatureMetadata(backup.featureMetadata, defaults: defaults) + return + } + + let existingTrackedDomains = loadTrackedDomains(defaults: defaults) + let mergedTrackedDomains = mergeTrackedDomains(existing: existingTrackedDomains, incoming: backup.trackedDomains) + saveTrackedDomains(mergedTrackedDomains.domains, defaults: defaults) + + let existingHistoryEntries = loadHistoryEntries(defaults: defaults) + let remappedIncomingHistory = backup.historyEntries.map { remapHistoryEntry($0, using: mergedTrackedDomains.idMap) } + saveHistoryEntries( + mergeHistoryEntries(existing: existingHistoryEntries, incoming: remappedIncomingHistory), + defaults: defaults + ) + + let existingWorkflows = loadWorkflows(defaults: defaults) + saveWorkflows(mergeWorkflows(existing: existingWorkflows, incoming: backup.workflows), defaults: defaults) + + let existingMonitoringLogs = loadMonitoringLogs(defaults: defaults) + saveMonitoringLogs(mergeMonitoringLogs(existing: existingMonitoringLogs, incoming: backup.monitoringLogs), defaults: defaults) + + let remappedMonitoringSettings = remapMonitoringSettings( + backup.monitoringSettings ?? MonitoringSettings(), + using: mergedTrackedDomains.idMap + ) + saveMonitoringSettings( + MonitoringStorage.sanitizeSettings(remappedMonitoringSettings, trackedDomains: mergedTrackedDomains.domains), + defaults: defaults + ) + + let mergedSettings = mergeAppSettings(existing: loadAppSettings(defaults: defaults), incoming: backup.appSettings) + saveAppSettings(mergedSettings, defaults: defaults) + + let mergedFeatureMetadata = mergeFeatureMetadata(existing: loadFeatureMetadata(defaults: defaults), incoming: backup.featureMetadata) + saveFeatureMetadata(mergedFeatureMetadata, defaults: defaults) + } + + private static func applyTrackedDomainsImport( + _ trackedDomains: [TrackedDomain], + mode: DataPortabilityImportMode, + defaults: UserDefaults + ) { + let importedDomains = deduplicatedTrackedDomains(trackedDomains) + let finalDomains: [TrackedDomain] + switch mode { + case .merge: + finalDomains = mergeTrackedDomains(existing: loadTrackedDomains(defaults: defaults), incoming: importedDomains).domains + case .replace: + finalDomains = importedDomains + } + + saveTrackedDomains(finalDomains, defaults: defaults) + let sanitizedSettings = MonitoringStorage.sanitizeSettings(loadMonitoringSettings(defaults: defaults), trackedDomains: finalDomains) + saveMonitoringSettings(sanitizedSettings, defaults: defaults) + } + + private static func applyWorkflowsImport( + _ workflows: [DomainWorkflow], + mode: DataPortabilityImportMode, + defaults: UserDefaults + ) { + let importedWorkflows = deduplicatedWorkflows(workflows) + let finalWorkflows: [DomainWorkflow] + switch mode { + case .merge: + finalWorkflows = mergeWorkflows(existing: loadWorkflows(defaults: defaults), incoming: importedWorkflows) + case .replace: + finalWorkflows = importedWorkflows + } + saveWorkflows(finalWorkflows, defaults: defaults) + } + + private static func projectCounts( + for payload: ImportPayload, + mode: DataPortabilityImportMode, + defaults: UserDefaults + ) -> DataLifecycleSummary { + let current = lifecycleSummary(defaults: defaults) + switch payload { + case .backup(let backup, _): + if mode == .replace { + return DataLifecycleSummary( + trackedDomains: backup.trackedDomains.count, + historySnapshots: backup.historyEntries.count, + workflows: backup.workflows.count, + cachedItems: backup.appSettings.recentSearches.count + backup.appSettings.savedDomains.count + ((backup.featureMetadata?.cachedEntitlement == nil ? 0 : 1) + (backup.featureMetadata?.usageCredits == nil ? 0 : 1)), + monitoringLogs: backup.monitoringLogs.count + ) + } + + return DataLifecycleSummary( + trackedDomains: mergeTrackedDomains(existing: loadTrackedDomains(defaults: defaults), incoming: backup.trackedDomains).domains.count, + historySnapshots: mergeHistoryEntries(existing: loadHistoryEntries(defaults: defaults), incoming: backup.historyEntries).count, + workflows: mergeWorkflows(existing: loadWorkflows(defaults: defaults), incoming: backup.workflows).count, + cachedItems: max(current.cachedItems, backup.appSettings.recentSearches.count + backup.appSettings.savedDomains.count), + monitoringLogs: mergeMonitoringLogs(existing: loadMonitoringLogs(defaults: defaults), incoming: backup.monitoringLogs).count + ) + case .trackedDomains(let trackedDomains, _): + let total = mode == .replace + ? trackedDomains.count + : mergeTrackedDomains(existing: loadTrackedDomains(defaults: defaults), incoming: trackedDomains).domains.count + return DataLifecycleSummary( + trackedDomains: total, + historySnapshots: current.historySnapshots, + workflows: current.workflows, + cachedItems: current.cachedItems, + monitoringLogs: current.monitoringLogs + ) + case .workflows(let workflows, _): + let total = mode == .replace + ? workflows.count + : mergeWorkflows(existing: loadWorkflows(defaults: defaults), incoming: workflows).count + return DataLifecycleSummary( + trackedDomains: current.trackedDomains, + historySnapshots: current.historySnapshots, + workflows: total, + cachedItems: current.cachedItems, + monitoringLogs: current.monitoringLogs + ) + } + } + + private static func mergeTrackedDomains(existing: [TrackedDomain], incoming: [TrackedDomain]) -> MergedTrackedDomains { + var mergedByDomain = Dictionary(uniqueKeysWithValues: existing.map { (normalizeDomain($0.domain), $0) }) + var idMap: [UUID: UUID] = [:] + + for trackedDomain in incoming { + let key = normalizeDomain(trackedDomain.domain) + guard !key.isEmpty else { continue } + + if let existingDomain = mergedByDomain[key] { + let chosen = trackedDomain.updatedAt >= existingDomain.updatedAt + ? mergeTrackedDomain(existing: existingDomain, incoming: trackedDomain) + : mergeTrackedDomain(existing: trackedDomain, incoming: existingDomain) + mergedByDomain[key] = chosen + idMap[trackedDomain.id] = chosen.id + } else { + let normalized = normalizedTrackedDomain(trackedDomain) + mergedByDomain[key] = normalized + idMap[trackedDomain.id] = normalized.id + } + } + + for trackedDomain in existing { + idMap[trackedDomain.id] = mergedByDomain[normalizeDomain(trackedDomain.domain)]?.id ?? trackedDomain.id + } + + return MergedTrackedDomains( + domains: mergedByDomain.values.sorted { + if $0.updatedAt != $1.updatedAt { + return $0.updatedAt > $1.updatedAt + } + return $0.domain.localizedCaseInsensitiveCompare($1.domain) == .orderedAscending + }, + idMap: idMap + ) + } + + private static func mergeTrackedDomain(existing: TrackedDomain, incoming: TrackedDomain) -> TrackedDomain { + let normalizedDomain = normalizeDomain(existing.domain.isEmpty ? incoming.domain : existing.domain) + let winner = existing.updatedAt >= incoming.updatedAt ? existing : incoming + let note = winner.note ?? existing.note ?? incoming.note + + return TrackedDomain( + id: existing.id, + domain: normalizedDomain, + createdAt: min(existing.createdAt, incoming.createdAt), + updatedAt: max(existing.updatedAt, incoming.updatedAt), + note: note, + isPinned: existing.isPinned || incoming.isPinned, + monitoringEnabled: existing.monitoringEnabled || incoming.monitoringEnabled, + lastKnownAvailability: winner.lastKnownAvailability ?? existing.lastKnownAvailability ?? incoming.lastKnownAvailability, + lastSnapshotID: winner.lastSnapshotID ?? existing.lastSnapshotID ?? incoming.lastSnapshotID, + lastChangeSummary: winner.lastChangeSummary ?? existing.lastChangeSummary ?? incoming.lastChangeSummary, + lastChangeSeverity: winner.lastChangeSeverity ?? existing.lastChangeSeverity ?? incoming.lastChangeSeverity, + certificateWarningLevel: higherCertificateWarningLevel(existing.certificateWarningLevel, incoming.certificateWarningLevel), + certificateDaysRemaining: winner.certificateDaysRemaining ?? existing.certificateDaysRemaining ?? incoming.certificateDaysRemaining, + lastMonitoredAt: [existing.lastMonitoredAt, incoming.lastMonitoredAt].compactMap { $0 }.max(), + lastAlertAt: [existing.lastAlertAt, incoming.lastAlertAt].compactMap { $0 }.max() + ) + } + + private static func mergeHistoryEntries(existing: [HistoryEntry], incoming: [HistoryEntry]) -> [HistoryEntry] { + var mergedByKey: [String: HistoryEntry] = [:] + + for entry in existing + incoming { + let key = historyKey(for: entry) + if let existingEntry = mergedByKey[key] { + mergedByKey[key] = chooseBetterHistoryEntry(existingEntry, entry) + } else { + mergedByKey[key] = entry + } + } + + return mergedByKey.values.sorted { lhs, rhs in + if lhs.timestamp != rhs.timestamp { + return lhs.timestamp > rhs.timestamp + } + return lhs.domain.localizedCaseInsensitiveCompare(rhs.domain) == .orderedAscending + } + } + + private static func mergeWorkflows(existing: [DomainWorkflow], incoming: [DomainWorkflow]) -> [DomainWorkflow] { + var merged = Dictionary(uniqueKeysWithValues: existing.map { ($0.id, normalizedWorkflow($0)) }) + + for workflow in incoming { + let normalized = normalizedWorkflow(workflow) + if let existingWorkflow = merged[workflow.id] { + let winner = existingWorkflow.updatedAt >= normalized.updatedAt ? existingWorkflow : normalized + merged[workflow.id] = DomainWorkflow( + id: existingWorkflow.id, + name: winner.name, + domains: deduplicated(existingWorkflow.domains + normalized.domains), + createdAt: min(existingWorkflow.createdAt, normalized.createdAt), + updatedAt: max(existingWorkflow.updatedAt, normalized.updatedAt), + notes: winner.notes ?? existingWorkflow.notes ?? normalized.notes + ) + } else { + merged[workflow.id] = normalized + } + } + + return merged.values.sorted(by: workflowSort) + } + + private static func mergeMonitoringLogs(existing: [MonitoringLog], incoming: [MonitoringLog]) -> [MonitoringLog] { + var merged: [UUID: MonitoringLog] = [:] + for log in existing + incoming { + if let current = merged[log.id] { + merged[log.id] = log.timestamp >= current.timestamp ? log : current + } else { + merged[log.id] = log + } + } + return merged.values.sorted { $0.timestamp > $1.timestamp }.prefix(MonitoringStorage.maxLogs).map { $0 } + } + + private static func mergeAppSettings(existing: AppSettingsSnapshot, incoming: AppSettingsSnapshot) -> AppSettingsSnapshot { + AppSettingsSnapshot( + recentSearches: deduplicated(incoming.recentSearches + existing.recentSearches).prefix(20).map { $0 }, + savedDomains: deduplicated(incoming.savedDomains + existing.savedDomains), + resolverURLString: incoming.resolverURLString.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? existing.resolverURLString : incoming.resolverURLString, + appDensityRawValue: incoming.appDensityRawValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? existing.appDensityRawValue : incoming.appDensityRawValue + ) + } + + private static func mergeFeatureMetadata(existing: FeatureMetadataSnapshot, incoming: FeatureMetadataSnapshot?) -> FeatureMetadataSnapshot { + guard let incoming else { return existing } + + let mergedUsageCredits: UsageCreditsSnapshot? + switch (existing.usageCredits, incoming.usageCredits) { + case let (.some(lhs), .some(rhs)): + let mergedRemaining = Dictionary(uniqueKeysWithValues: UsageCreditFeature.allCases.map { feature in + (feature, max(lhs.remainingByFeature[feature] ?? 0, rhs.remainingByFeature[feature] ?? 0)) + }) + mergedUsageCredits = UsageCreditsSnapshot( + appVersion: rhs.appVersion, + remainingByFeature: mergedRemaining + ) + case (.some(let lhs), nil): + mergedUsageCredits = lhs + case (nil, .some(let rhs)): + mergedUsageCredits = rhs + case (nil, nil): + mergedUsageCredits = nil + } + + return FeatureMetadataSnapshot( + cachedEntitlement: incoming.cachedEntitlement ?? existing.cachedEntitlement, + usageCredits: mergedUsageCredits + ) + } + + private static func remapMonitoringSettings( + _ settings: MonitoringSettings, + using idMap: [UUID: UUID] + ) -> MonitoringSettings { + var remapped = settings + remapped.selectedDomainIDs = deduplicatedUUIDs(settings.selectedDomainIDs.compactMap { idMap[$0] ?? $0 }) + return remapped + } + + private static func remapHistoryEntry(_ entry: HistoryEntry, using idMap: [UUID: UUID]) -> HistoryEntry { + var remapped = entry + if let trackedDomainID = entry.trackedDomainID { + remapped.trackedDomainID = idMap[trackedDomainID] ?? trackedDomainID + } + return remapped + } + + private static func chooseBetterHistoryEntry(_ lhs: HistoryEntry, _ rhs: HistoryEntry) -> HistoryEntry { + if historyQualityScore(lhs) == historyQualityScore(rhs) { + return lhs.timestamp >= rhs.timestamp ? lhs : rhs + } + return historyQualityScore(lhs) >= historyQualityScore(rhs) ? lhs : rhs + } + + private static func historyQualityScore(_ entry: HistoryEntry) -> Int { + var score = 0 + score += entry.dnsSections.count + score += entry.httpHeaders.count + score += entry.reachabilityResults.count + score += entry.redirectChain.count + score += entry.subdomains.count + score += entry.portScanResults.count + score += entry.validationIssues.isEmpty ? 2 : 0 + score += entry.sslInfo == nil ? 0 : 4 + score += entry.ownership == nil ? 0 : 4 + score += entry.emailSecurity == nil ? 0 : 3 + score += entry.ipGeolocation == nil ? 0 : 2 + return score + } + + private static func historyKey(for entry: HistoryEntry) -> String { + if entry.id != UUID() { + return "id:\(entry.id.uuidString)" + } + return "domain:\(normalizeDomain(entry.domain))|timestamp:\(entry.timestamp.timeIntervalSince1970)" + } + + private nonisolated static func normalizeDomain(_ value: String) -> String { + value + .trimmingCharacters(in: .whitespacesAndNewlines) + .replacingOccurrences(of: "https://", with: "") + .replacingOccurrences(of: "http://", with: "") + .components(separatedBy: "/") + .first? + .lowercased() ?? "" + } + + private static func normalizedTrackedDomain(_ trackedDomain: TrackedDomain) -> TrackedDomain { + TrackedDomain( + id: trackedDomain.id, + domain: normalizeDomain(trackedDomain.domain), + createdAt: trackedDomain.createdAt, + updatedAt: trackedDomain.updatedAt, + note: trackedDomain.note, + isPinned: trackedDomain.isPinned, + monitoringEnabled: trackedDomain.monitoringEnabled, + lastKnownAvailability: trackedDomain.lastKnownAvailability, + lastSnapshotID: trackedDomain.lastSnapshotID, + lastChangeSummary: trackedDomain.lastChangeSummary, + lastChangeSeverity: trackedDomain.lastChangeSeverity, + certificateWarningLevel: trackedDomain.certificateWarningLevel, + certificateDaysRemaining: trackedDomain.certificateDaysRemaining, + lastMonitoredAt: trackedDomain.lastMonitoredAt, + lastAlertAt: trackedDomain.lastAlertAt + ) + } + + private static func normalizedWorkflow(_ workflow: DomainWorkflow) -> DomainWorkflow { + DomainWorkflow( + id: workflow.id, + name: workflow.name.trimmingCharacters(in: .whitespacesAndNewlines), + domains: deduplicated(workflow.domains.map(Self.normalizeDomain).filter { !$0.isEmpty }), + createdAt: workflow.createdAt, + updatedAt: workflow.updatedAt, + notes: workflow.notes?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty + ) + } + + private static func deduplicatedTrackedDomains(_ trackedDomains: [TrackedDomain]) -> [TrackedDomain] { + mergeTrackedDomains(existing: [], incoming: trackedDomains).domains + } + + private static func deduplicatedHistoryEntries(_ historyEntries: [HistoryEntry]) -> [HistoryEntry] { + mergeHistoryEntries(existing: [], incoming: historyEntries) + } + + private static func deduplicatedWorkflows(_ workflows: [DomainWorkflow]) -> [DomainWorkflow] { + mergeWorkflows(existing: [], incoming: workflows) + } + + private nonisolated static func deduplicated(_ values: [String]) -> [String] { + var seen = Set<String>() + return values.filter { seen.insert($0).inserted } + } + + private nonisolated static func deduplicatedUUIDs(_ values: [UUID]) -> [UUID] { + var seen = Set<UUID>() + return values.filter { seen.insert($0).inserted } + } + + private nonisolated static func workflowSort(lhs: DomainWorkflow, rhs: DomainWorkflow) -> Bool { + if lhs.updatedAt != rhs.updatedAt { + return lhs.updatedAt > rhs.updatedAt + } + return lhs.name.localizedCaseInsensitiveCompare(rhs.name) == .orderedAscending + } + + private nonisolated static func higherCertificateWarningLevel( + _ lhs: CertificateWarningLevel, + _ rhs: CertificateWarningLevel + ) -> CertificateWarningLevel { + let rank: [CertificateWarningLevel: Int] = [ + .none: 0, + .warning: 1, + .critical: 2 + ] + return (rank[lhs] ?? 0) >= (rank[rhs] ?? 0) ? lhs : rhs + } + + private static func makeEncoder() -> JSONEncoder { + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + return encoder + } +} + +private struct MergedTrackedDomains { + let domains: [TrackedDomain] + let idMap: [UUID: UUID] +} + +private enum ImportPayload { + case backup(DomainDigBackup, DataValidationReport) + case trackedDomains([TrackedDomain], DataValidationReport) + case workflows([DomainWorkflow], DataValidationReport) + + var kind: DataPortabilityImportKind { + switch self { + case .backup: + return .backup + case .trackedDomains: + return .trackedDomains + case .workflows: + return .workflows + } + } + + var validationReport: DataValidationReport { + switch self { + case .backup(_, let report), .trackedDomains(_, let report), .workflows(_, let report): + return report + } + } + + func summaryLines(mode: DataPortabilityImportMode) -> [String] { + switch self { + case .backup(let backup, _): + return [ + "Full backup import", + "\(backup.trackedDomains.count) tracked domains", + "\(backup.historyEntries.count) history snapshots", + "\(backup.workflows.count) workflows", + "\(backup.monitoringLogs.count) monitoring logs", + mode == .replace ? "Replace mode will overwrite local backupable data." : "Merge mode will keep local data and consolidate duplicates." + ] + case .trackedDomains(let trackedDomains, _): + return [ + "Tracked domains import", + "\(trackedDomains.count) tracked domains", + mode == .replace ? "Replace mode will overwrite the watchlist." : "Merge mode will consolidate duplicates by normalized domain." + ] + case .workflows(let workflows, _): + return [ + "Workflow import", + "\(workflows.count) workflows", + mode == .replace ? "Replace mode will overwrite saved workflows." : "Merge mode will merge matching workflow IDs and deduplicate domains." + ] + } + } +} + +private extension String { + var nilIfEmpty: String? { + isEmpty ? nil : self + } +} diff --git a/DomainDig.xcodeproj/project.pbxproj b/DomainDig.xcodeproj/project.pbxproj index fc06760..22c4dfc 100644 --- a/DomainDig.xcodeproj/project.pbxproj +++ b/DomainDig.xcodeproj/project.pbxproj @@ -16,6 +16,8 @@ 8BBFEFCB2F987E8700E8E144 /* DomainReportBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BBFEF042F9874AE00E8E144 /* DomainReportBuilder.swift */; }; 8BBFEFCC2F987E8700E8E144 /* DomainReportExporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BBFEF052F9874AE00E8E144 /* DomainReportExporter.swift */; }; 8BBFEFCD2F987E8700E8E144 /* LookupSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BBFEF062F9874AE00E8E144 /* LookupSnapshot.swift */; }; + 8BF9DA862F9B13FB00EF41D5 /* DomainDataPortabilityService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BF9DA842F9B13FB00EF41D5 /* DomainDataPortabilityService.swift */; }; + 8BF9DA872F9B13FB00EF41D5 /* DomainDataPortabilityService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BF9DA842F9B13FB00EF41D5 /* DomainDataPortabilityService.swift */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ @@ -26,6 +28,7 @@ 8BBFEF052F9874AE00E8E144 /* DomainReportExporter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DomainReportExporter.swift; sourceTree = "<group>"; }; 8BBFEF062F9874AE00E8E144 /* LookupSnapshot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LookupSnapshot.swift; sourceTree = "<group>"; }; 8BF124F92F70000100933221 /* domaindig */ = {isa = PBXFileReference; explicitFileType = "compiled.mach-o.executable"; includeInIndex = 0; path = domaindig; sourceTree = BUILT_PRODUCTS_DIR; }; + 8BF9DA842F9B13FB00EF41D5 /* DomainDataPortabilityService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DomainDataPortabilityService.swift; sourceTree = "<group>"; }; /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ @@ -95,6 +98,7 @@ 8BBFEF032F9874AE00E8E144 /* DomainInspectionService.swift */, 8BBFEF052F9874AE00E8E144 /* DomainReportExporter.swift */, 8BBFEF022F9874AE00E8E144 /* DomainDigCLI.swift */, + 8BF9DA842F9B13FB00EF41D5 /* DomainDataPortabilityService.swift */, ); sourceTree = "<group>"; }; @@ -206,6 +210,7 @@ 8BBFEF092F9874AE00E8E144 /* DomainInspectionService.swift in Sources */, 8BBFEF0A2F9874AE00E8E144 /* DomainReportBuilder.swift in Sources */, 8BBFEF0B2F9874AE00E8E144 /* DomainReportExporter.swift in Sources */, + 8BF9DA872F9B13FB00EF41D5 /* DomainDataPortabilityService.swift in Sources */, 8BBFEF0C2F9874AE00E8E144 /* LookupSnapshot.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -215,6 +220,7 @@ buildActionMask = 2147483647; files = ( 8BBFEF082F9874AE00E8E144 /* DomainDigCLI.swift in Sources */, + 8BF9DA862F9B13FB00EF41D5 /* DomainDataPortabilityService.swift in Sources */, 8BBFEFCA2F987E8700E8E144 /* DomainInspectionService.swift in Sources */, 8BBFEFCB2F987E8700E8E144 /* DomainReportBuilder.swift in Sources */, 8BBFEFCC2F987E8700E8E144 /* DomainReportExporter.swift in Sources */, @@ -354,7 +360,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 22; + CURRENT_PROJECT_VERSION = 26; DEVELOPMENT_TEAM = ZCNAX3VL9D; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -371,7 +377,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 2.9.0; + MARKETING_VERSION = 3.4.0; PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = YES; @@ -390,7 +396,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 22; + CURRENT_PROJECT_VERSION = 26; DEVELOPMENT_TEAM = ZCNAX3VL9D; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -407,7 +413,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 2.9.0; + MARKETING_VERSION = 3.4.0; PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = YES; @@ -426,6 +432,7 @@ COPY_PHASE_STRIP = NO; GCC_DYNAMIC_NO_PIC = NO; GCC_OPTIMIZATION_LEVEL = 0; + IPHONEOS_DEPLOYMENT_TARGET = 17.6; PRODUCT_NAME = domaindig; }; name = Debug; @@ -435,6 +442,7 @@ buildSettings = { COPY_PHASE_STRIP = YES; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + IPHONEOS_DEPLOYMENT_TARGET = 17.6; PRODUCT_NAME = domaindig; }; name = Release; diff --git a/DomainDig/AppVersion.swift b/DomainDig/AppVersion.swift index f28a017..346beb4 100644 --- a/DomainDig/AppVersion.swift +++ b/DomainDig/AppVersion.swift @@ -2,6 +2,6 @@ import Foundation enum AppVersion { nonisolated static var current: String { - "3.2.0" + "3.4.0" } } diff --git a/DomainDig/ContentView.swift b/DomainDig/ContentView.swift index a4e8db3..e1d0a99 100644 --- a/DomainDig/ContentView.swift +++ b/DomainDig/ContentView.swift @@ -1,5 +1,6 @@ import MapKit import SwiftUI +import UniformTypeIdentifiers enum LookupInputMode: String, CaseIterable, Identifiable { case single @@ -2431,6 +2432,13 @@ struct SettingsView: View { @State private var showClearCacheConfirmation = false @State private var showClearWorkflowsConfirmation = false @State private var showClearTrackedDomainsConfirmation = false + @State private var importMode: DataPortabilityImportMode = .merge + @State private var showBackupImporter = false + @State private var showTrackedDomainsImporter = false + @State private var showWorkflowsImporter = false + @State private var pendingImportPreview: DataImportPreview? + @State private var pendingImportError: String? + @State private var showReplaceImportConfirmation = false private var customResolverError: String? { guard resolverOption == .custom else { @@ -2472,6 +2480,161 @@ struct SettingsView: View { } } + Section("Monitoring") { + Toggle( + "Enable Background Monitoring", + isOn: Binding( + get: { viewModel.monitoringSettings.isEnabled }, + set: { viewModel.setMonitoringEnabled($0) } + ) + ) + + Picker( + "Frequency", + selection: Binding( + get: { viewModel.monitoringSettings.frequency }, + set: { viewModel.setMonitoringFrequency($0) } + ) + ) { + ForEach(MonitoringFrequency.allCases) { frequency in + Text(frequency.title).tag(frequency) + } + } + + Picker( + "Domains", + selection: Binding( + get: { viewModel.monitoringSettings.scope }, + set: { viewModel.setMonitoringScope($0) } + ) + ) { + ForEach(MonitoringScope.allCases) { scope in + Text(scope.title).tag(scope) + } + } + + if viewModel.monitoringSettings.scope == .selectedOnly { + ForEach(viewModel.trackedDomains) { trackedDomain in + Toggle( + trackedDomain.domain, + isOn: Binding( + get: { viewModel.monitoringSettings.selectedDomainIDs.contains(trackedDomain.id) }, + set: { viewModel.setMonitoringSelection(for: trackedDomain, isSelected: $0) } + ) + ) + } + } + + Toggle( + "Local Alerts", + isOn: Binding( + get: { viewModel.monitoringSettings.alertsEnabled }, + set: { isEnabled in + if isEnabled { + Task { + await viewModel.requestMonitoringNotificationAuthorization() + } + } else { + viewModel.setMonitoringAlertsEnabled(false) + } + } + ) + ) + + Picker( + "Notify For", + selection: Binding( + get: { viewModel.monitoringSettings.alertFilter }, + set: { viewModel.setMonitoringAlertFilter($0) } + ) + ) { + ForEach(MonitoringAlertFilter.allCases) { filter in + Text(filter.title).tag(filter) + } + } + + LabeledContent("Background Refresh", value: DomainMonitoringScheduler.shared.backgroundRefreshStatusDescription()) + LabeledContent("Notification Access", value: notificationAuthorizationLabel) + + if let monitoringStatusMessage = viewModel.monitoringStatusMessage { + Text(monitoringStatusMessage) + .font(appDensity.font(.caption, design: .default)) + .foregroundStyle(.secondary) + } + + if !FeatureAccessService.hasAccess(to: .automatedMonitoring) { + Text("Background monitoring and alerts are available in Pro.") + .font(appDensity.font(.caption, design: .default)) + .foregroundStyle(.secondary) + } + } + + Section("Data Portability") { + Picker("Import Mode", selection: $importMode) { + ForEach(DataPortabilityImportMode.allCases) { mode in + Text(mode.title).tag(mode) + } + } + + Text(importMode.explanation) + .font(appDensity.font(.caption, design: .default)) + .foregroundStyle(.secondary) + + Button("Export Full Backup") { + exportFullBackup() + } + + Button("Import Backup") { + showBackupImporter = true + } + + Menu("Export Tracked Domains") { + Button("JSON") { + exportPortableTrackedDomainsJSON() + } + Button("CSV") { + exportPortableTrackedDomainsCSV() + } + } + + Button("Import Tracked Domains") { + showTrackedDomainsImporter = true + } + + Menu("Export Workflows") { + Button("JSON") { + exportPortableWorkflowsJSON() + } + Button("CSV") { + exportPortableWorkflowsCSV() + } + } + + Button("Import Workflows") { + showWorkflowsImporter = true + } + + Button("Export History") { + exportPortableHistoryJSON() + } + + LabeledContent("Tracked Domains", value: "\(viewModel.dataLifecycleSummary.trackedDomains)") + LabeledContent("History Snapshots", value: "\(viewModel.dataLifecycleSummary.historySnapshots)") + LabeledContent("Workflows", value: "\(viewModel.dataLifecycleSummary.workflows)") + LabeledContent("Cached Items", value: "\(viewModel.dataLifecycleSummary.cachedItems)") + LabeledContent("Monitoring Logs", value: "\(viewModel.dataLifecycleSummary.monitoringLogs)") + + 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.") + .font(appDensity.font(.caption, design: .default)) + .foregroundStyle(.secondary) + + if let portabilityStatusMessage = viewModel.portabilityStatusMessage { + Text(portabilityStatusMessage) + .font(appDensity.font(.caption, design: .default)) + .foregroundStyle(.secondary) + } + } + Section("Data") { Button("Clear History", role: .destructive) { showClearHistoryConfirmation = true @@ -2551,7 +2714,7 @@ struct SettingsView: View { Section("About") { LabeledContent("Version", value: appVersion) LabeledContent("Storage", value: "Local-only") - LabeledContent("Report Schema", value: "3.2.0") + LabeledContent("Backup Schema", value: "v\(DomainDigBackup.currentSchemaVersion)") } } .navigationTitle("Settings") @@ -2587,12 +2750,73 @@ struct SettingsView: View { } message: { Text("This removes the watchlist only. History and workflows stay intact.") } + .alert("Replace local data?", isPresented: $showReplaceImportConfirmation) { + Button("Replace", role: .destructive) { + applyPendingImport() + } + Button("Cancel", role: .cancel) {} + } message: { + Text("Replace mode overwrites local data covered by the imported file and may remove items that are only on this device.") + } + .alert("Import Error", isPresented: Binding( + get: { pendingImportError != nil }, + set: { if !$0 { pendingImportError = nil } } + )) { + Button("OK", role: .cancel) {} + } message: { + Text(pendingImportError ?? "The import could not be completed.") + } + .sheet(isPresented: Binding( + get: { pendingImportPreview != nil }, + set: { if !$0 { pendingImportPreview = nil } } + )) { + if let pendingImportPreview { + DataImportPreviewSheet( + preview: pendingImportPreview, + mode: importMode, + onCancel: { + self.pendingImportPreview = nil + }, + onApply: { + if importMode == .replace { + showReplaceImportConfirmation = true + } else { + applyPendingImport() + } + } + ) + } + } + .fileImporter( + isPresented: $showBackupImporter, + allowedContentTypes: [UTType.json], + allowsMultipleSelection: false + ) { result in + handleImportResult(result, expectedKind: .backup) + } + .fileImporter( + isPresented: $showTrackedDomainsImporter, + allowedContentTypes: [UTType.json, UTType.commaSeparatedText], + allowsMultipleSelection: false + ) { result in + handleImportResult(result, expectedKind: .trackedDomains) + } + .fileImporter( + isPresented: $showWorkflowsImporter, + allowedContentTypes: [UTType.json, UTType.commaSeparatedText], + allowsMultipleSelection: false + ) { result in + handleImportResult(result, expectedKind: .workflows) + } .onAppear { let currentResolverURL = storedResolverURL.trimmingCharacters(in: .whitespacesAndNewlines) resolverOption = DNSResolverOption.option(for: currentResolverURL) customResolverURL = resolverOption == .custom ? currentResolverURL : DNSResolverOption.defaultURLString + viewModel.refreshMonitoringState() + viewModel.refreshDataLifecycleSummary() Task { await viewModel.refreshUsageCredits() + await viewModel.refreshMonitoringAuthorizationStatus() } } .onChange(of: resolverOption) { _, newValue in @@ -2611,6 +2835,157 @@ struct SettingsView: View { private var appVersion: String { AppVersion.current } + + private var notificationAuthorizationLabel: String { + switch viewModel.monitoringNotificationStatus { + case .authorized, .provisional, .ephemeral: + return "Allowed" + case .denied: + return "Denied" + case .notDetermined: + return "Not Requested" + @unknown default: + return "Unknown" + } + } + + private func exportFullBackup() { + guard let data = viewModel.exportFullBackupData() else { return } + ExportPresenter.share(filename: portabilityFilename(suffix: "backup", fileExtension: "json"), data: data) + } + + private func exportPortableTrackedDomainsJSON() { + guard let data = viewModel.exportPortableTrackedDomainsJSONData() else { return } + ExportPresenter.share(filename: portabilityFilename(suffix: "tracked_domains", fileExtension: "json"), data: data) + } + + private func exportPortableTrackedDomainsCSV() { + ExportPresenter.share( + filename: portabilityFilename(suffix: "tracked_domains", fileExtension: "csv"), + contents: viewModel.exportPortableTrackedDomainsCSV() + ) + } + + private func exportPortableWorkflowsJSON() { + guard let data = viewModel.exportPortableWorkflowsJSONData() else { return } + ExportPresenter.share(filename: portabilityFilename(suffix: "workflows", fileExtension: "json"), data: data) + } + + private func exportPortableWorkflowsCSV() { + ExportPresenter.share( + filename: portabilityFilename(suffix: "workflows", fileExtension: "csv"), + contents: viewModel.exportPortableWorkflowsCSV() + ) + } + + private func exportPortableHistoryJSON() { + guard let data = viewModel.exportPortableHistoryJSONData() else { return } + ExportPresenter.share(filename: portabilityFilename(suffix: "history", fileExtension: "json"), data: data) + } + + private func handleImportResult( + _ result: Result<[URL], Error>, + expectedKind: DataPortabilityImportKind + ) { + do { + guard let url = try result.get().first else { return } + let shouldStopAccessing = url.startAccessingSecurityScopedResource() + defer { + if shouldStopAccessing { + url.stopAccessingSecurityScopedResource() + } + } + + let data = try Data(contentsOf: url) + let preview = try viewModel.prepareDataImport( + data: data, + fileName: url.lastPathComponent, + mode: importMode + ) + + guard preview.kind == expectedKind else { + pendingImportError = preview.kind == .backup + ? "That file is a full backup. Use Import Backup." + : "That file type does not match this import action." + return + } + + pendingImportPreview = preview + } catch { + pendingImportError = error.localizedDescription + } + } + + private func applyPendingImport() { + guard let pendingImportPreview else { return } + do { + _ = try viewModel.applyDataImport(pendingImportPreview, mode: importMode) + self.pendingImportPreview = nil + } catch { + pendingImportError = error.localizedDescription + } + } + + private func portabilityFilename(suffix: String, fileExtension: String) -> String { + let formatter = DateFormatter() + formatter.dateFormat = "yyyyMMdd_HHmmss" + return "\(formatter.string(from: Date()))_domaindig_\(suffix).\(fileExtension)" + } +} + +private struct DataImportPreviewSheet: View { + @Environment(\.dismiss) private var dismiss + + let preview: DataImportPreview + let mode: DataPortabilityImportMode + let onCancel: () -> Void + let onApply: () -> Void + + var body: some View { + NavigationStack { + List { + Section("Summary") { + ForEach(preview.summaryLines, id: \.self) { line in + Text(line) + } + } + + Section("Projected Counts") { + LabeledContent("Tracked Domains", value: "\(preview.projectedCounts.trackedDomains)") + LabeledContent("History Snapshots", value: "\(preview.projectedCounts.historySnapshots)") + LabeledContent("Workflows", value: "\(preview.projectedCounts.workflows)") + LabeledContent("Cached Items", value: "\(preview.projectedCounts.cachedItems)") + LabeledContent("Monitoring Logs", value: "\(preview.projectedCounts.monitoringLogs)") + } + + if !preview.warnings.isEmpty { + Section("Warnings") { + ForEach(preview.warnings, id: \.self) { warning in + Text(warning) + .foregroundStyle(.secondary) + } + } + } + } + .navigationTitle("Import Preview") + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { + onCancel() + dismiss() + } + } + ToolbarItem(placement: .confirmationAction) { + Button(mode == .replace ? "Replace" : "Import") { + onApply() + if mode == .merge { + dismiss() + } + } + } + } + } + } } #Preview { diff --git a/DomainDig/DomainDigApp.swift b/DomainDig/DomainDigApp.swift index ae81654..f7cd15e 100644 --- a/DomainDig/DomainDigApp.swift +++ b/DomainDig/DomainDigApp.swift @@ -9,12 +9,14 @@ import SwiftUI @main struct DomainDigApp: App { + @Environment(\.scenePhase) private var scenePhase @AppStorage(AppDensity.userDefaultsKey) private var density = AppDensity.compact.rawValue @State private var viewModel = DomainViewModel() @State private var purchaseService = PurchaseService.shared init() { LocalNotificationService.shared.configureForegroundPresentation() + DomainMonitoringScheduler.shared.registerBackgroundTask() } var body: some Scene { @@ -24,7 +26,18 @@ struct DomainDigApp: App { .task { let _ = purchaseService.currentTier await purchaseService.refreshEntitlements() + viewModel.refreshMonitoringState() + await viewModel.refreshMonitoringAuthorizationStatus() + viewModel.monitoringStatusMessage = DomainMonitoringScheduler.shared.syncSchedule() } } + .onChange(of: scenePhase) { _, newValue in + guard newValue == .active else { return } + viewModel.refreshMonitoringState() + Task { + await viewModel.refreshMonitoringAuthorizationStatus() + } + viewModel.monitoringStatusMessage = DomainMonitoringScheduler.shared.syncSchedule() + } } } diff --git a/DomainDig/DomainMonitoringService.swift b/DomainDig/DomainMonitoringService.swift new file mode 100644 index 0000000..5128c0b --- /dev/null +++ b/DomainDig/DomainMonitoringService.swift @@ -0,0 +1,613 @@ +import Foundation +import UserNotifications + +#if canImport(BackgroundTasks) +import BackgroundTasks +#endif + +#if canImport(UIKit) +import UIKit +#endif + +enum MonitoringStorage { + static let settingsKey = "monitoring.settings" + static let logsKey = "monitoring.logs" + static let trackedDomainsKey = "trackedDomains" + static let historyKey = "lookupHistory" + static let maxLogs = 40 + + static func loadSettings() -> MonitoringSettings { + DataMigrationService.migrateIfNeeded() + return DomainDataPortabilityService.loadMonitoringSettings() + } + + static func saveSettings(_ settings: MonitoringSettings) { + DomainDataPortabilityService.saveMonitoringSettings(settings) + } + + static func loadLogs() -> [MonitoringLog] { + DataMigrationService.migrateIfNeeded() + return DomainDataPortabilityService.loadMonitoringLogs() + } + + static func saveLogs(_ logs: [MonitoringLog]) { + DomainDataPortabilityService.saveMonitoringLogs(Array(logs.prefix(maxLogs))) + } + + static func loadTrackedDomains() -> [TrackedDomain] { + DataMigrationService.migrateIfNeeded() + return DomainDataPortabilityService.loadTrackedDomains() + } + + static func saveTrackedDomains(_ domains: [TrackedDomain]) { + DomainDataPortabilityService.saveTrackedDomains(domains) + } + + static func loadHistoryEntries() -> [HistoryEntry] { + DataMigrationService.migrateIfNeeded() + return DomainDataPortabilityService.loadHistoryEntries() + } + + static func saveHistoryEntries(_ entries: [HistoryEntry]) { + DomainDataPortabilityService.saveHistoryEntries(entries) + } + + static func sanitizeSettings(_ settings: MonitoringSettings, trackedDomains: [TrackedDomain]) -> MonitoringSettings { + let validIDs = Set(trackedDomains.map(\.id)) + var sanitized = settings + sanitized.selectedDomainIDs = sanitized.selectedDomainIDs.filter { validIDs.contains($0) } + return sanitized + } + + static func monitoredDomains(settings: MonitoringSettings, trackedDomains: [TrackedDomain]) -> [TrackedDomain] { + let sanitized = sanitizeSettings(settings, trackedDomains: trackedDomains) + switch sanitized.scope { + case .allTracked: + return trackedDomains.filter(\.monitoringEnabled) + case .selectedOnly: + let selected = Set(sanitized.selectedDomainIDs) + return trackedDomains.filter { selected.contains($0.id) && $0.monitoringEnabled } + } + } +} + +struct MonitoringRunOutcome { + let success: Bool + let message: String + let log: MonitoringLog? +} + +@MainActor +final class DomainMonitoringScheduler { + static let shared = DomainMonitoringScheduler() + static let taskIdentifier = "net.cleberg.DomainDig.monitor.refresh" + + private var isRegistered = false + + private init() {} + + func registerBackgroundTask() { + #if canImport(BackgroundTasks) + guard !isRegistered else { return } + isRegistered = BGTaskScheduler.shared.register(forTaskWithIdentifier: Self.taskIdentifier, using: nil) { task in + guard let refreshTask = task as? BGAppRefreshTask else { + task.setTaskCompleted(success: false) + return + } + self.handleAppRefresh(task: refreshTask) + } + #endif + } + + func backgroundRefreshStatusDescription() -> String { + #if canImport(UIKit) + switch UIApplication.shared.backgroundRefreshStatus { + case .available: + return "Available" + case .denied: + return "Disabled in Settings" + case .restricted: + return "Restricted by the system" + @unknown default: + return "Unknown" + } + #else + return "Unavailable on this platform" + #endif + } + + @discardableResult + func syncSchedule() -> String? { + #if canImport(BackgroundTasks) + let settings = MonitoringStorage.loadSettings() + guard settings.isEnabled, FeatureAccessService.hasAccess(to: .automatedMonitoring) else { + BGTaskScheduler.shared.cancel(taskRequestWithIdentifier: Self.taskIdentifier) + return nil + } + + #if canImport(UIKit) + guard UIApplication.shared.backgroundRefreshStatus == .available else { + BGTaskScheduler.shared.cancel(taskRequestWithIdentifier: Self.taskIdentifier) + return "Background refresh is unavailable." + } + #endif + + let request = BGAppRefreshTaskRequest(identifier: Self.taskIdentifier) + request.earliestBeginDate = Date( + timeIntervalSinceNow: max(settings.frequency.schedulingInterval, 15 * 60) + ) + + do { + BGTaskScheduler.shared.cancel(taskRequestWithIdentifier: Self.taskIdentifier) + try BGTaskScheduler.shared.submit(request) + return nil + } catch { + return "Could not schedule monitoring." + } + #else + return "Background monitoring is unavailable on this platform." + #endif + } + +#if canImport(BackgroundTasks) + private func handleAppRefresh(task: BGAppRefreshTask) { + _ = syncSchedule() + + let worker = Task { + let outcome = await DomainMonitoringService.shared.performMonitoring( + trigger: .background, + requireEnabledSetting: true + ) + task.setTaskCompleted(success: outcome.success) + } + + task.expirationHandler = { + worker.cancel() + } + } +#endif +} + +@MainActor +final class DomainMonitoringService { + static let shared = DomainMonitoringService() + + private let inspectionService = DomainInspectionService() + private let maxHistoryEntries = 250 + + func performMonitoring( + trigger: MonitoringRunTrigger, + requireEnabledSetting: Bool + ) async -> MonitoringRunOutcome { + let trackedDomains = MonitoringStorage.loadTrackedDomains() + var settings = MonitoringStorage.sanitizeSettings(MonitoringStorage.loadSettings(), trackedDomains: trackedDomains) + MonitoringStorage.saveSettings(settings) + + guard FeatureAccessService.hasAccess(to: .automatedMonitoring) else { + if settings.isEnabled { + settings.isEnabled = false + MonitoringStorage.saveSettings(settings) + await MainActor.run { + _ = DomainMonitoringScheduler.shared.syncSchedule() + } + } + return MonitoringRunOutcome(success: false, message: "Monitoring requires Pro.", log: nil) + } + + if requireEnabledSetting, !settings.isEnabled { + return MonitoringRunOutcome(success: false, message: "Monitoring is disabled.", log: nil) + } + + var history = MonitoringStorage.loadHistoryEntries() + var mutableTrackedDomains = trackedDomains + let eligibleDomains = MonitoringStorage.monitoredDomains(settings: settings, trackedDomains: mutableTrackedDomains) + + guard !eligibleDomains.isEmpty else { + let log = MonitoringLog( + timestamp: Date(), + trigger: trigger, + domainsChecked: 0, + changesFound: 0, + alertsTriggered: 0, + checkedDomains: [], + errors: ["No tracked domains are configured for monitoring."] + ) + saveLog(log) + return MonitoringRunOutcome(success: false, message: "No domains selected for monitoring.", log: log) + } + + let notificationsAuthorized: Bool + if settings.alertsEnabled { + notificationsAuthorized = await LocalNotificationService.shared.isAuthorizedForAlerts() + } else { + notificationsAuthorized = false + } + var results: [MonitoringDomainResult] = [] + var errors: [String] = [] + var alertsTriggered = 0 + + for trackedDomain in eligibleDomains { + guard !Task.isCancelled else { + return MonitoringRunOutcome(success: false, message: "Monitoring cancelled.", log: nil) + } + + let previousSnapshot = latestSnapshot(for: trackedDomain, history: history) + let inspectedSnapshot = await inspectionService.inspectSnapshot( + domain: trackedDomain.domain, + previousSnapshot: previousSnapshot + ) + let snapshot = Self.resolvedSnapshotAfterFallback(inspectedSnapshot, previousSnapshot: previousSnapshot) + let savedEntry: HistoryEntry? + if snapshot.statusMessage == nil { + savedEntry = persistSnapshot( + snapshot, + trackedDomainID: trackedDomain.id, + trackedDomains: &mutableTrackedDomains, + history: &history + ) + } else { + savedEntry = history.first(where: { $0.id == snapshot.historyEntryID }) + } + + let alertDescriptor = alertDescriptor( + previousSnapshot: previousSnapshot, + snapshot: snapshot, + entry: savedEntry + ) + + if let index = mutableTrackedDomains.firstIndex(where: { $0.id == trackedDomain.id }) { + mutableTrackedDomains[index].lastMonitoredAt = Date() + } + + if notificationsAuthorized, + let alertDescriptor, + alertDescriptor.severity >= settings.alertFilter.minimumSeverity { + await LocalNotificationService.shared.notifyMonitoringAlert( + domain: trackedDomain.domain, + message: alertDescriptor.message, + severity: alertDescriptor.severity + ) + alertsTriggered += 1 + if let index = mutableTrackedDomains.firstIndex(where: { $0.id == trackedDomain.id }) { + mutableTrackedDomains[index].lastAlertAt = Date() + } + } + + let result = MonitoringDomainResult( + domain: trackedDomain.domain, + historyEntryID: savedEntry?.id ?? snapshot.historyEntryID, + checkedAt: Date(), + didChange: snapshot.statusMessage == nil && savedEntry?.changeSummary?.hasChanges == true, + summaryMessage: snapshot.statusMessage + ?? savedEntry?.changeSummary?.message + ?? "No meaningful changes", + alertSeverity: alertDescriptor?.severity, + certificateWarningLevel: DomainDiffService.certificateWarningLevel(for: snapshot), + resultSource: snapshot.resultSource, + errorMessage: snapshot.statusMessage + ) + results.append(result) + + if let errorMessage = result.errorMessage { + errors.append("\(trackedDomain.domain): \(errorMessage)") + } + } + + MonitoringStorage.saveTrackedDomains(mutableTrackedDomains) + MonitoringStorage.saveHistoryEntries(history) + + let log = MonitoringLog( + timestamp: Date(), + trigger: trigger, + domainsChecked: results.count, + changesFound: results.filter(\.didChange).count, + alertsTriggered: alertsTriggered, + checkedDomains: results.sorted { + $0.domain.localizedCaseInsensitiveCompare($1.domain) == .orderedAscending + }, + errors: errors + ) + saveLog(log) + + return MonitoringRunOutcome( + success: errors.count < results.count, + message: log.summary, + log: log + ) + } + + private func saveLog(_ log: MonitoringLog) { + var logs = MonitoringStorage.loadLogs() + logs.insert(log, at: 0) + MonitoringStorage.saveLogs(logs) + } + + private func latestSnapshot(for trackedDomain: TrackedDomain, history: [HistoryEntry]) -> LookupSnapshot? { + history.first(where: { entry in + if let trackedDomainID = entry.trackedDomainID { + return trackedDomainID == trackedDomain.id + } + return entry.domain.caseInsensitiveCompare(trackedDomain.domain) == .orderedSame + })?.snapshot + } + + private func persistSnapshot( + _ snapshot: LookupSnapshot, + trackedDomainID: UUID, + trackedDomains: inout [TrackedDomain], + history: inout [HistoryEntry] + ) -> HistoryEntry? { + let previousSnapshot = latestSnapshot( + for: trackedDomains.first(where: { $0.id == trackedDomainID }) ?? TrackedDomain(domain: snapshot.domain), + history: history + ) + let analysis = DomainInsightEngine.analyze(snapshot: snapshot, previousSnapshot: previousSnapshot) + let changeSummary = previousSnapshot.map { + DomainDiffService.summary( + from: $0, + to: snapshot, + generatedAt: snapshot.timestamp, + riskAssessment: analysis.riskAssessment, + insights: analysis.insights + ) + } + + let entry = HistoryEntry( + domain: snapshot.domain, + timestamp: snapshot.timestamp, + trackedDomainID: trackedDomainID, + note: trackedDomains.first(where: { $0.id == trackedDomainID })?.note, + dnsSections: snapshot.dnsSections, + sslInfo: snapshot.sslInfo, + httpHeaders: snapshot.httpHeaders, + reachabilityResults: snapshot.reachabilityResults, + ipGeolocation: snapshot.ipGeolocation, + emailSecurity: snapshot.emailSecurity, + mtaSts: snapshot.emailSecurity?.mtaSts, + ownership: snapshot.ownership, + ownershipHistory: snapshot.ownershipHistory, + ptrRecord: snapshot.ptrRecord, + redirectChain: snapshot.redirectChain, + subdomains: snapshot.subdomains, + extendedSubdomains: snapshot.extendedSubdomains, + dnsHistory: snapshot.dnsHistory, + domainPricing: snapshot.domainPricing, + portScanResults: snapshot.portScanResults, + hstsPreloaded: snapshot.hstsPreloaded, + availabilityResult: snapshot.availabilityResult, + suggestions: snapshot.suggestions, + appVersion: snapshot.appVersion, + resultSource: snapshot.resultSource, + dataSources: snapshot.dataSources, + provenanceBySection: snapshot.provenanceBySection, + availabilityConfidence: snapshot.availabilityConfidence, + ownershipConfidence: snapshot.ownershipConfidence, + subdomainConfidence: snapshot.subdomainConfidence, + emailSecurityConfidence: snapshot.emailSecurityConfidence, + geolocationConfidence: snapshot.geolocationConfidence, + errorDetails: snapshot.errorDetails, + isPartialSnapshot: snapshot.isPartialSnapshot, + validationIssues: snapshot.validationIssues, + resolverDisplayName: snapshot.resolverDisplayName, + resolverURLString: snapshot.resolverURLString, + totalLookupDurationMs: snapshot.totalLookupDurationMs, + primaryIP: Self.primaryIPAddress(from: snapshot), + finalRedirectURL: snapshot.redirectChain.last?.url, + tlsStatusSummary: Self.tlsSummary(from: snapshot), + emailSecuritySummary: Self.emailSummary(from: snapshot), + httpGradeSummary: snapshot.httpSecurityGrade ?? snapshot.httpHeadersError, + changeSummary: changeSummary, + sslError: snapshot.sslError, + httpHeadersError: snapshot.httpHeadersError, + reachabilityError: snapshot.reachabilityError, + ipGeolocationError: snapshot.ipGeolocationError, + emailSecurityError: snapshot.emailSecurityError, + ownershipError: snapshot.ownershipError, + ownershipHistoryError: snapshot.ownershipHistoryError, + ptrError: snapshot.ptrError, + redirectChainError: snapshot.redirectChainError, + subdomainsError: snapshot.subdomainsError, + extendedSubdomainsError: snapshot.extendedSubdomainsError, + dnsHistoryError: snapshot.dnsHistoryError, + domainPricingError: snapshot.domainPricingError, + portScanError: snapshot.portScanError + ) + + history.insert(entry, at: 0) + if history.count > maxHistoryEntries { + history = Array(history.prefix(maxHistoryEntries)) + } + + if let index = trackedDomains.firstIndex(where: { $0.id == trackedDomainID }) { + trackedDomains[index].lastSnapshotID = entry.id + trackedDomains[index].lastKnownAvailability = snapshot.availabilityResult?.status + trackedDomains[index].updatedAt = snapshot.timestamp + trackedDomains[index].lastChangeSummary = changeSummary + trackedDomains[index].lastChangeSeverity = changeSummary?.severity + trackedDomains[index].certificateWarningLevel = DomainDiffService.certificateWarningLevel(for: snapshot) + trackedDomains[index].certificateDaysRemaining = snapshot.sslInfo?.daysUntilExpiry + } + + return entry + } + + private func alertDescriptor( + previousSnapshot: LookupSnapshot?, + snapshot: LookupSnapshot, + entry: HistoryEntry? + ) -> (severity: MonitoringAlertSeverity, message: String)? { + let changedLabels = Set( + (previousSnapshot.map { DomainDiffService.diff(from: $0, to: snapshot) } ?? []) + .flatMap(\.items) + .filter(\.hasChanges) + .map(\.label) + ) + + if changedLabels.contains("Availability") { + return (.critical, "Availability changed") + } + if changedLabels.contains("Primary IP") { + return (.critical, "Primary IP changed") + } + if changedLabels.contains("Redirect Target") { + return (.critical, "Redirect target changed") + } + + let ownershipLabels: Set<String> = [ + "Registrar", + "Registration Date", + "Expiration Date", + "Ownership Status", + "Abuse Contact" + ] + if !changedLabels.isDisjoint(with: ownershipLabels) { + return (.warning, "Ownership changed") + } + if changedLabels.contains("Nameservers") || changedLabels.contains(where: { $0.hasSuffix("Records") }) { + return (.warning, "DNS changed") + } + + let oldCertificateLevel = previousSnapshot.map { DomainDiffService.certificateWarningLevel(for: $0) } ?? .none + let newCertificateLevel = DomainDiffService.certificateWarningLevel(for: snapshot) + if newCertificateLevel != .none, newCertificateLevel != oldCertificateLevel { + let daysRemaining = snapshot.sslInfo?.daysUntilExpiry ?? 0 + return (.warning, "Certificate expires in \(daysRemaining) days") + } + + if entry?.changeSummary?.hasChanges == true, let message = entry?.changeSummary?.message { + return (.info, message) + } + + return nil + } + + private static func resolvedSnapshotAfterFallback( + _ snapshot: LookupSnapshot, + previousSnapshot: LookupSnapshot? + ) -> LookupSnapshot { + guard shouldFallbackToSnapshot(snapshot), let previousSnapshot else { + return snapshot + } + + return LookupSnapshot( + historyEntryID: previousSnapshot.historyEntryID, + domain: previousSnapshot.domain, + timestamp: previousSnapshot.timestamp, + trackedDomainID: previousSnapshot.trackedDomainID, + note: previousSnapshot.note, + appVersion: previousSnapshot.appVersion, + resolverDisplayName: previousSnapshot.resolverDisplayName, + resolverURLString: previousSnapshot.resolverURLString, + dataSources: previousSnapshot.dataSources, + provenanceBySection: previousSnapshot.provenanceBySection, + availabilityConfidence: previousSnapshot.availabilityConfidence, + ownershipConfidence: previousSnapshot.ownershipConfidence, + subdomainConfidence: previousSnapshot.subdomainConfidence, + emailSecurityConfidence: previousSnapshot.emailSecurityConfidence, + geolocationConfidence: previousSnapshot.geolocationConfidence, + errorDetails: previousSnapshot.errorDetails, + isPartialSnapshot: previousSnapshot.isPartialSnapshot, + validationIssues: previousSnapshot.validationIssues, + totalLookupDurationMs: previousSnapshot.totalLookupDurationMs, + dnsSections: previousSnapshot.dnsSections, + dnsError: previousSnapshot.dnsError, + availabilityResult: previousSnapshot.availabilityResult, + suggestions: previousSnapshot.suggestions, + sslInfo: previousSnapshot.sslInfo, + sslError: previousSnapshot.sslError, + hstsPreloaded: previousSnapshot.hstsPreloaded, + httpHeaders: previousSnapshot.httpHeaders, + httpSecurityGrade: previousSnapshot.httpSecurityGrade, + httpStatusCode: previousSnapshot.httpStatusCode, + httpResponseTimeMs: previousSnapshot.httpResponseTimeMs, + httpProtocol: previousSnapshot.httpProtocol, + http3Advertised: previousSnapshot.http3Advertised, + httpHeadersError: previousSnapshot.httpHeadersError, + reachabilityResults: previousSnapshot.reachabilityResults, + reachabilityError: previousSnapshot.reachabilityError, + ipGeolocation: previousSnapshot.ipGeolocation, + ipGeolocationError: previousSnapshot.ipGeolocationError, + emailSecurity: previousSnapshot.emailSecurity, + emailSecurityError: previousSnapshot.emailSecurityError, + ownership: previousSnapshot.ownership, + ownershipError: previousSnapshot.ownershipError, + ownershipHistory: previousSnapshot.ownershipHistory, + ownershipHistoryError: previousSnapshot.ownershipHistoryError, + ptrRecord: previousSnapshot.ptrRecord, + ptrError: previousSnapshot.ptrError, + redirectChain: previousSnapshot.redirectChain, + redirectChainError: previousSnapshot.redirectChainError, + subdomains: previousSnapshot.subdomains, + subdomainsError: previousSnapshot.subdomainsError, + extendedSubdomains: previousSnapshot.extendedSubdomains, + extendedSubdomainsError: previousSnapshot.extendedSubdomainsError, + dnsHistory: previousSnapshot.dnsHistory, + dnsHistoryError: previousSnapshot.dnsHistoryError, + domainPricing: previousSnapshot.domainPricing, + domainPricingError: previousSnapshot.domainPricingError, + portScanResults: previousSnapshot.portScanResults, + portScanError: previousSnapshot.portScanError, + changeSummary: previousSnapshot.changeSummary, + resultSource: .snapshot, + cachedSections: [], + statusMessage: "Last known result • \(previousSnapshot.timestamp.formatted(date: .abbreviated, time: .shortened))" + ) + } + + private static func shouldFallbackToSnapshot(_ snapshot: LookupSnapshot) -> Bool { + let candidateMessages = [ + snapshot.dnsError, + snapshot.httpHeadersError, + snapshot.sslError, + snapshot.ownershipError, + snapshot.subdomainsError, + snapshot.redirectChainError, + snapshot.ipGeolocationError + ] + .compactMap { $0?.lowercased() } + + guard !candidateMessages.isEmpty else { return false } + let connectivityFailure = candidateMessages.allSatisfy { message in + message.hasPrefix("network error:") + || message.hasPrefix("timeout:") + || message.hasPrefix("rate limit:") + } + + let hasMaterialData = !snapshot.dnsSections.isEmpty + || !snapshot.httpHeaders.isEmpty + || snapshot.sslInfo != nil + || snapshot.ownership != nil + || !snapshot.subdomains.isEmpty + + return connectivityFailure && !hasMaterialData + } + + private static func primaryIPAddress(from snapshot: LookupSnapshot) -> String? { + snapshot.dnsSections.first(where: { $0.recordType == .A })?.records.first?.value + } + + private static func tlsSummary(from snapshot: LookupSnapshot) -> String? { + guard let sslInfo = snapshot.sslInfo else { return snapshot.sslError } + switch DomainDiffService.certificateWarningLevel(for: snapshot) { + case .critical: + return "Critical (\(sslInfo.daysUntilExpiry)d)" + case .warning: + return "Warning (\(sslInfo.daysUntilExpiry)d)" + case .none: + return "Healthy (\(sslInfo.daysUntilExpiry)d)" + } + } + + private static func emailSummary(from snapshot: LookupSnapshot) -> String? { + if let emailSecurity = snapshot.emailSecurity { + return [ + "spf:\(emailSecurity.spf.found)", + "dmarc:\(emailSecurity.dmarc.found)", + "dkim:\(emailSecurity.dkim.found)", + "bimi:\(emailSecurity.bimi.found)", + "mta-sts:\(emailSecurity.mtaSts?.txtFound == true)" + ].joined(separator: "|") + } + return snapshot.emailSecurityError + } +} diff --git a/DomainDig/DomainViewModel.swift b/DomainDig/DomainViewModel.swift index 83d282c..527bf5e 100644 --- a/DomainDig/DomainViewModel.swift +++ b/DomainDig/DomainViewModel.swift @@ -1,5 +1,6 @@ import Foundation import SwiftUI +import UserNotifications enum ResultTone { case primary @@ -218,10 +219,10 @@ final class DomainViewModel { private static let recentSearchesKey = "recentSearches" private static let maxRecent = 20 - var recentSearches: [String] = UserDefaults.standard.stringArray(forKey: recentSearchesKey) ?? [] + var recentSearches: [String] = DomainDataPortabilityService.loadRecentSearches() private static let savedDomainsKey = "savedDomains" - var savedDomains: [String] = UserDefaults.standard.stringArray(forKey: savedDomainsKey) ?? [] + var savedDomains: [String] = DomainDataPortabilityService.loadSavedDomains() private static let trackedDomainsKey = "trackedDomains" private static let legacyWatchedDomainsKey = "watchedDomains" @@ -239,6 +240,13 @@ final class DomainViewModel { var watchlistSearchText = "" var watchlistFilter: WatchlistFilterOption = .all var watchlistSortOption: WatchlistSortOption = .pinned + var monitoringSettings: MonitoringSettings = MonitoringStorage.loadSettings() + var monitoringLogs: [MonitoringLog] = MonitoringStorage.loadLogs() + var monitoringRunInProgress = false + var monitoringStatusMessage: String? + var monitoringNotificationStatus: UNAuthorizationStatus = .notDetermined + var dataLifecycleSummary = DomainDataPortabilityService.lifecycleSummary() + var portabilityStatusMessage: String? var upgradePrompt: UpgradePromptContext? var isPaywallPresented = false @@ -598,12 +606,14 @@ final class DomainViewModel { } else { savedDomains.append(searchedDomain) } - UserDefaults.standard.set(savedDomains, forKey: Self.savedDomainsKey) + DomainDataPortabilityService.saveSavedDomains(savedDomains) + refreshDataLifecycleSummary() } func removeSavedDomains(at offsets: IndexSet) { savedDomains.remove(atOffsets: offsets) - UserDefaults.standard.set(savedDomains, forKey: Self.savedDomainsKey) + DomainDataPortabilityService.saveSavedDomains(savedDomains) + refreshDataLifecycleSummary() } @discardableResult @@ -636,6 +646,7 @@ final class DomainViewModel { at: 0 ) persistTrackedDomains() + sanitizeMonitoringSelection() linkTrackedDomainHistory(for: normalizedDomain) return true } @@ -664,6 +675,7 @@ final class DomainViewModel { } persistTrackedDomains() persistHistory() + sanitizeMonitoringSelection() } func deleteTrackedDomain(_ trackedDomain: TrackedDomain) { @@ -675,6 +687,7 @@ final class DomainViewModel { } persistTrackedDomains() persistHistory() + sanitizeMonitoringSelection() } func togglePinned(for trackedDomain: TrackedDomain) { @@ -702,6 +715,7 @@ final class DomainViewModel { func clearHistory() { history.removeAll() persistHistory() + refreshDataLifecycleSummary() } func clearLookupCache() { @@ -714,17 +728,157 @@ final class DomainViewModel { workflows.removeAll() latestWorkflowRunSummary = nil persistWorkflows() + refreshDataLifecycleSummary() } func clearTrackedDomains() { trackedDomains.removeAll() refreshingTrackedDomainID = nil persistTrackedDomains() + sanitizeMonitoringSelection() + refreshDataLifecycleSummary() } func clearRecentSearches() { recentSearches.removeAll() - UserDefaults.standard.removeObject(forKey: Self.recentSearchesKey) + DomainDataPortabilityService.saveRecentSearches([]) + refreshDataLifecycleSummary() + } + + func refreshMonitoringState() { + DataMigrationService.migrateIfNeeded() + trackedDomains = Self.loadTrackedDomains() + history = Self.loadHistoryEntries() + monitoringSettings = MonitoringStorage.sanitizeSettings( + MonitoringStorage.loadSettings(), + trackedDomains: trackedDomains + ) + monitoringLogs = MonitoringStorage.loadLogs() + persistMonitoringSettings() + refreshDataLifecycleSummary() + } + + func refreshDataLifecycleSummary() { + dataLifecycleSummary = DomainDataPortabilityService.lifecycleSummary() + } + + func refreshPersistedData() { + recentSearches = DomainDataPortabilityService.loadRecentSearches() + savedDomains = DomainDataPortabilityService.loadSavedDomains() + trackedDomains = Self.loadTrackedDomains() + history = Self.loadHistoryEntries() + workflows = Self.loadWorkflows() + monitoringSettings = MonitoringStorage.sanitizeSettings( + MonitoringStorage.loadSettings(), + trackedDomains: trackedDomains + ) + monitoringLogs = MonitoringStorage.loadLogs() + refreshDataLifecycleSummary() + } + + func refreshMonitoringAuthorizationStatus() async { + let settings = await UNUserNotificationCenter.current().notificationSettings() + monitoringNotificationStatus = settings.authorizationStatus + } + + func setMonitoringEnabled(_ isEnabled: Bool) { + guard !isEnabled || FeatureAccessService.hasAccess(to: .automatedMonitoring) else { + monitoringSettings.isEnabled = false + upgradePrompt = FeatureAccessService.upgradePrompt(for: .automatedMonitoring) + return + } + + monitoringSettings.isEnabled = isEnabled + persistMonitoringSettings() + monitoringStatusMessage = DomainMonitoringScheduler.shared.syncSchedule() + } + + func setMonitoringScope(_ scope: MonitoringScope) { + monitoringSettings.scope = scope + persistMonitoringSettings() + } + + func setMonitoringFrequency(_ frequency: MonitoringFrequency) { + guard FeatureAccessService.hasAccess(to: .automatedMonitoring) else { + upgradePrompt = FeatureAccessService.upgradePrompt(for: .automatedMonitoring) + return + } + monitoringSettings.frequency = frequency + persistMonitoringSettings() + monitoringStatusMessage = DomainMonitoringScheduler.shared.syncSchedule() + } + + func setMonitoringAlertFilter(_ filter: MonitoringAlertFilter) { + guard FeatureAccessService.hasAccess(to: .localAlerts) else { + monitoringSettings.alertsEnabled = false + upgradePrompt = FeatureAccessService.upgradePrompt(for: .localAlerts) + return + } + monitoringSettings.alertFilter = filter + persistMonitoringSettings() + } + + func setMonitoringAlertsEnabled(_ isEnabled: Bool) { + guard !isEnabled || FeatureAccessService.hasAccess(to: .localAlerts) else { + monitoringSettings.alertsEnabled = false + upgradePrompt = FeatureAccessService.upgradePrompt(for: .localAlerts) + return + } + monitoringSettings.alertsEnabled = isEnabled + persistMonitoringSettings() + } + + func setMonitoringSelection(for trackedDomain: TrackedDomain, isSelected: Bool) { + if isSelected { + if !monitoringSettings.selectedDomainIDs.contains(trackedDomain.id) { + monitoringSettings.selectedDomainIDs.append(trackedDomain.id) + } + } else { + monitoringSettings.selectedDomainIDs.removeAll { $0 == trackedDomain.id } + } + persistMonitoringSettings() + } + + func toggleMonitoring(for trackedDomain: TrackedDomain) { + guard FeatureAccessService.hasAccess(to: .automatedMonitoring) else { + upgradePrompt = FeatureAccessService.upgradePrompt(for: .automatedMonitoring) + return + } + guard let index = trackedDomains.firstIndex(where: { $0.id == trackedDomain.id }) else { return } + trackedDomains[index].monitoringEnabled.toggle() + MonitoringStorage.saveTrackedDomains(trackedDomains) + sanitizeMonitoringSelection() + } + + func requestMonitoringNotificationAuthorization() async { + let granted = await LocalNotificationService.shared.requestAuthorizationIfNeeded() + monitoringSettings.alertsEnabled = granted + persistMonitoringSettings() + await refreshMonitoringAuthorizationStatus() + } + + func runMonitoringNow() { + guard FeatureAccessService.hasAccess(to: .automatedMonitoring) else { + upgradePrompt = FeatureAccessService.upgradePrompt(for: .automatedMonitoring) + return + } + guard !monitoringRunInProgress else { return } + + monitoringRunInProgress = true + monitoringStatusMessage = nil + + Task { [weak self] in + guard let self else { return } + let outcome = await DomainMonitoringService.shared.performMonitoring( + trigger: .manual, + requireEnabledSetting: false + ) + await MainActor.run { + self.refreshMonitoringState() + self.monitoringRunInProgress = false + self.monitoringStatusMessage = outcome.message + } + } } func rerunLookup(from entry: HistoryEntry, useSnapshotResolver: Bool) { @@ -1159,6 +1313,45 @@ final class DomainViewModel { ) } + func exportFullBackupData() -> Data? { + try? DomainDataPortabilityService.backupData() + } + + func exportPortableTrackedDomainsJSONData() -> Data? { + try? DomainDataPortabilityService.trackedDomainsExportData() + } + + func exportPortableTrackedDomainsCSV() -> String { + DomainDataPortabilityService.trackedDomainsCSV() + } + + func exportPortableWorkflowsJSONData() -> Data? { + try? DomainDataPortabilityService.workflowsExportData() + } + + func exportPortableWorkflowsCSV() -> String { + DomainDataPortabilityService.workflowsCSV() + } + + func exportPortableHistoryJSONData() -> Data? { + try? DomainDataPortabilityService.historyExportData() + } + + func prepareDataImport( + data: Data, + fileName: String, + mode: DataPortabilityImportMode + ) throws -> DataImportPreview { + try DomainDataPortabilityService.prepareImport(data: data, fileName: fileName, mode: mode) + } + + func applyDataImport(_ preview: DataImportPreview, mode: DataPortabilityImportMode) throws -> DataImportResult { + let result = try DomainDataPortabilityService.applyImport(preview, mode: mode) + refreshPersistedData() + portabilityStatusMessage = result.summary + return result + } + func exportWorkflowText(summary: WorkflowRunSummary, changedOnly: Bool) -> String { let reports = workflowReports(from: summary, changedOnly: changedOnly) let base = DomainReportExporter.batchText( @@ -1806,9 +1999,8 @@ final class DomainViewModel { } private func persistHistory() { - if let data = try? JSONEncoder().encode(history) { - UserDefaults.standard.set(data, forKey: Self.historyKey) - } + DomainDataPortabilityService.saveHistoryEntries(history) + refreshDataLifecycleSummary() } func updateHistoryNote(_ note: String, for entry: HistoryEntry) { @@ -1818,9 +2010,18 @@ final class DomainViewModel { } private func persistTrackedDomains() { - if let data = try? JSONEncoder().encode(trackedDomains) { - UserDefaults.standard.set(data, forKey: Self.trackedDomainsKey) - } + DomainDataPortabilityService.saveTrackedDomains(trackedDomains) + refreshDataLifecycleSummary() + } + + private func persistMonitoringSettings() { + monitoringSettings = MonitoringStorage.sanitizeSettings(monitoringSettings, trackedDomains: trackedDomains) + MonitoringStorage.saveSettings(monitoringSettings) + } + + private func sanitizeMonitoringSelection() { + monitoringSettings = MonitoringStorage.sanitizeSettings(monitoringSettings, trackedDomains: trackedDomains) + MonitoringStorage.saveSettings(monitoringSettings) } private func updateTrackedDomainAvailability(for domain: String, status: DomainAvailabilityStatus) { @@ -1948,7 +2149,8 @@ final class DomainViewModel { if recentSearches.count > Self.maxRecent { recentSearches = Array(recentSearches.prefix(Self.maxRecent)) } - UserDefaults.standard.set(recentSearches, forKey: Self.recentSearchesKey) + DomainDataPortabilityService.saveRecentSearches(recentSearches) + refreshDataLifecycleSummary() } private func beginLookup(for target: String, cancelExistingTask: Bool = true) -> UUID { @@ -2510,76 +2712,23 @@ final class DomainViewModel { } private static func loadHistoryEntries() -> [HistoryEntry] { - let defaults = UserDefaults.standard - guard let data = defaults.data(forKey: historyKey) else { - return [] - } - - if let entries = try? JSONDecoder().decode([HistoryEntry].self, from: data) { - return entries - } - - guard let rawArray = (try? JSONSerialization.jsonObject(with: data)) as? [Any] else { - return [] - } - - let decoder = JSONDecoder() - return rawArray.compactMap { item in - guard JSONSerialization.isValidJSONObject(item), - let itemData = try? JSONSerialization.data(withJSONObject: item), - let entry = try? decoder.decode(HistoryEntry.self, from: itemData) else { - return nil - } - return entry - } + DataMigrationService.migrateIfNeeded() + return DomainDataPortabilityService.loadHistoryEntries() } private static func loadTrackedDomains() -> [TrackedDomain] { - let defaults = UserDefaults.standard - let decoder = JSONDecoder() - - if let data = defaults.data(forKey: trackedDomainsKey), - let domains = try? decoder.decode([TrackedDomain].self, from: data) { - return deduplicatedTrackedDomains(domains) - } - - if let legacyData = defaults.data(forKey: legacyWatchedDomainsKey), - let legacyDomains = try? decoder.decode([WatchedDomain].self, from: legacyData) { - return deduplicatedTrackedDomains( - legacyDomains.map { - TrackedDomain( - id: $0.id, - domain: $0.domain.lowercased(), - createdAt: $0.createdAt, - updatedAt: $0.createdAt, - lastKnownAvailability: $0.lastKnownAvailability - ) - } - ) - } - - return [] + DataMigrationService.migrateIfNeeded() + return DomainDataPortabilityService.loadTrackedDomains() } private func persistWorkflows() { - if let data = try? JSONEncoder().encode(workflows) { - UserDefaults.standard.set(data, forKey: Self.workflowsKey) - } + DomainDataPortabilityService.saveWorkflows(workflows) + refreshDataLifecycleSummary() } private static func loadWorkflows() -> [DomainWorkflow] { - let defaults = UserDefaults.standard - guard let data = defaults.data(forKey: workflowsKey), - let workflows = try? JSONDecoder().decode([DomainWorkflow].self, from: data) else { - return [] - } - - return workflows.sorted { lhs, rhs in - if lhs.updatedAt != rhs.updatedAt { - return lhs.updatedAt > rhs.updatedAt - } - return lhs.name.localizedCaseInsensitiveCompare(rhs.name) == .orderedAscending - } + DataMigrationService.migrateIfNeeded() + return DomainDataPortabilityService.loadWorkflows() } private static func deduplicatedTrackedDomains(_ domains: [TrackedDomain]) -> [TrackedDomain] { diff --git a/DomainDig/FeatureAccessService.swift b/DomainDig/FeatureAccessService.swift index 535ba7c..9fae3bd 100644 --- a/DomainDig/FeatureAccessService.swift +++ b/DomainDig/FeatureAccessService.swift @@ -25,6 +25,8 @@ enum FeatureCapability: String, CaseIterable, Identifiable { case limitedTracking case workflows case batchOperations + case automatedMonitoring + case localAlerts case advancedExports case ownershipHistory case dnsHistory @@ -45,6 +47,10 @@ enum FeatureCapability: String, CaseIterable, Identifiable { return "Workflows" case .batchOperations: return "Batch operations" + case .automatedMonitoring: + return "Background monitoring" + case .localAlerts: + return "Local alerts" case .advancedExports: return "Advanced exports" case .ownershipHistory: @@ -94,7 +100,16 @@ enum FeatureAccessService { case .pro: return FeatureEntitlements( tier: .pro, - capabilities: [.singleLookup, .basicHistory, .limitedTracking, .workflows, .batchOperations, .advancedExports], + capabilities: [ + .singleLookup, + .basicHistory, + .limitedTracking, + .workflows, + .batchOperations, + .automatedMonitoring, + .localAlerts, + .advancedExports + ], trackedDomainLimit: effectivelyUnlimitedTrackedDomains, workflowLimit: nil, batchSizeLimit: nil @@ -140,7 +155,7 @@ enum FeatureAccessService { static func upgradeMessage(for capability: FeatureCapability) -> String { switch capability { - case .workflows, .batchOperations, .advancedExports: + case .workflows, .batchOperations, .automatedMonitoring, .localAlerts, .advancedExports: return "Available in Pro" case .ownershipHistory, .dnsHistory, .extendedSubdomains, .domainPricing: return "Available in Data+" diff --git a/DomainDig/Info.plist b/DomainDig/Info.plist index e397175..a24a71e 100644 --- a/DomainDig/Info.plist +++ b/DomainDig/Info.plist @@ -6,5 +6,13 @@ <true/> <key>App Transport Security Settings (or NSAppTransportSecurity)</key> <dict/> + <key>BGTaskSchedulerPermittedIdentifiers</key> + <array> + <string>net.cleberg.DomainDig.monitor.refresh</string> + </array> + <key>UIBackgroundModes</key> + <array> + <string>fetch</string> + </array> </dict> </plist> diff --git a/DomainDig/LocalNotificationService.swift b/DomainDig/LocalNotificationService.swift index 450d844..7b43d67 100644 --- a/DomainDig/LocalNotificationService.swift +++ b/DomainDig/LocalNotificationService.swift @@ -27,6 +27,18 @@ final class LocalNotificationService { } } + func isAuthorizedForAlerts() async -> Bool { + let settings = await UNUserNotificationCenter.current().notificationSettings() + switch settings.authorizationStatus { + case .authorized, .provisional, .ephemeral: + return true + case .denied, .notDetermined: + return false + @unknown default: + return false + } + } + func notifyDomainEvent(domain: String, message: String, severity: ChangeSeverity) async { await schedule( identifier: "domain-change-\(domain)", @@ -45,6 +57,27 @@ final class LocalNotificationService { ) } + func notifyMonitoringAlert( + domain: String, + message: String, + severity: MonitoringAlertSeverity + ) async { + let interruptionLevel: UNNotificationInterruptionLevel + switch severity { + case .critical: + interruptionLevel = .timeSensitive + case .warning, .info: + interruptionLevel = .active + } + + await schedule( + identifier: "monitoring-\(domain)-\(UUID().uuidString)", + title: domain, + body: message, + interruptionLevel: interruptionLevel + ) + } + func notifySweepComplete(summary: BatchSweepSummary) async { let body = "\(summary.changedDomains) changed, \(summary.warningDomains) warnings, \(summary.unchangedDomains) unchanged" await schedule( diff --git a/DomainDig/Models.swift b/DomainDig/Models.swift index 530a89e..7715b4a 100644 --- a/DomainDig/Models.swift +++ b/DomainDig/Models.swift @@ -591,12 +591,15 @@ struct TrackedDomain: Codable, Identifiable, Equatable { var updatedAt: Date var note: String? var isPinned: Bool + var monitoringEnabled: Bool var lastKnownAvailability: DomainAvailabilityStatus? var lastSnapshotID: UUID? var lastChangeSummary: DomainChangeSummary? var lastChangeSeverity: ChangeSeverity? var certificateWarningLevel: CertificateWarningLevel var certificateDaysRemaining: Int? + var lastMonitoredAt: Date? + var lastAlertAt: Date? init( id: UUID = UUID(), @@ -605,12 +608,15 @@ struct TrackedDomain: Codable, Identifiable, Equatable { updatedAt: Date = Date(), note: String? = nil, isPinned: Bool = false, + monitoringEnabled: Bool = true, lastKnownAvailability: DomainAvailabilityStatus? = nil, lastSnapshotID: UUID? = nil, lastChangeSummary: DomainChangeSummary? = nil, lastChangeSeverity: ChangeSeverity? = nil, certificateWarningLevel: CertificateWarningLevel = .none, - certificateDaysRemaining: Int? = nil + certificateDaysRemaining: Int? = nil, + lastMonitoredAt: Date? = nil, + lastAlertAt: Date? = nil ) { self.id = id self.domain = domain @@ -618,12 +624,15 @@ struct TrackedDomain: Codable, Identifiable, Equatable { self.updatedAt = updatedAt self.note = note self.isPinned = isPinned + self.monitoringEnabled = monitoringEnabled self.lastKnownAvailability = lastKnownAvailability self.lastSnapshotID = lastSnapshotID self.lastChangeSummary = lastChangeSummary self.lastChangeSeverity = lastChangeSeverity self.certificateWarningLevel = certificateWarningLevel self.certificateDaysRemaining = certificateDaysRemaining + self.lastMonitoredAt = lastMonitoredAt + self.lastAlertAt = lastAlertAt } init(from decoder: Decoder) throws { @@ -634,12 +643,224 @@ struct TrackedDomain: Codable, Identifiable, Equatable { updatedAt = try container.decodeIfPresent(Date.self, forKey: .updatedAt) ?? createdAt note = try container.decodeIfPresent(String.self, forKey: .note) isPinned = try container.decodeIfPresent(Bool.self, forKey: .isPinned) ?? false + monitoringEnabled = try container.decodeIfPresent(Bool.self, forKey: .monitoringEnabled) ?? true lastKnownAvailability = try container.decodeIfPresent(DomainAvailabilityStatus.self, forKey: .lastKnownAvailability) lastSnapshotID = try container.decodeIfPresent(UUID.self, forKey: .lastSnapshotID) lastChangeSummary = try container.decodeIfPresent(DomainChangeSummary.self, forKey: .lastChangeSummary) lastChangeSeverity = try container.decodeIfPresent(ChangeSeverity.self, forKey: .lastChangeSeverity) ?? lastChangeSummary?.severity certificateWarningLevel = try container.decodeIfPresent(CertificateWarningLevel.self, forKey: .certificateWarningLevel) ?? .none certificateDaysRemaining = try container.decodeIfPresent(Int.self, forKey: .certificateDaysRemaining) + lastMonitoredAt = try container.decodeIfPresent(Date.self, forKey: .lastMonitoredAt) + lastAlertAt = try container.decodeIfPresent(Date.self, forKey: .lastAlertAt) + } +} + +enum MonitoringFrequency: String, Codable, CaseIterable, Identifiable { + case daily + case twiceDaily + + var id: String { rawValue } + + var title: String { + switch self { + case .daily: + return "Daily" + case .twiceDaily: + return "Twice Daily" + } + } + + var schedulingInterval: TimeInterval { + switch self { + case .daily: + return 24 * 60 * 60 + case .twiceDaily: + return 12 * 60 * 60 + } + } +} + +enum MonitoringScope: String, Codable, CaseIterable, Identifiable { + case allTracked + case selectedOnly + + var id: String { rawValue } + + var title: String { + switch self { + case .allTracked: + return "All Tracked" + case .selectedOnly: + return "Selected Only" + } + } +} + +enum MonitoringAlertSeverity: Int, Codable, CaseIterable, Comparable { + case info + case warning + case critical + + static func < (lhs: MonitoringAlertSeverity, rhs: MonitoringAlertSeverity) -> Bool { + lhs.rawValue < rhs.rawValue + } + + var title: String { + switch self { + case .info: + return "Info" + case .warning: + return "Warning" + case .critical: + return "Critical" + } + } +} + +enum MonitoringAlertFilter: String, Codable, CaseIterable, Identifiable { + case criticalOnly + case criticalAndWarnings + case allChanges + + var id: String { rawValue } + + var title: String { + switch self { + case .criticalOnly: + return "Critical Only" + case .criticalAndWarnings: + return "Critical + Warnings" + case .allChanges: + return "All Changes" + } + } + + var minimumSeverity: MonitoringAlertSeverity { + switch self { + case .criticalOnly: + return .critical + case .criticalAndWarnings: + return .warning + case .allChanges: + return .info + } + } +} + +enum MonitoringRunTrigger: String, Codable { + case manual + case background + case cli + + var title: String { + switch self { + case .manual: + return "Manual" + case .background: + return "Background" + case .cli: + return "CLI" + } + } +} + +struct MonitoringSettings: Codable, Equatable { + var isEnabled: Bool + var frequency: MonitoringFrequency + var scope: MonitoringScope + var selectedDomainIDs: [UUID] + var alertFilter: MonitoringAlertFilter + var alertsEnabled: Bool + + init( + isEnabled: Bool = false, + frequency: MonitoringFrequency = .daily, + scope: MonitoringScope = .allTracked, + selectedDomainIDs: [UUID] = [], + alertFilter: MonitoringAlertFilter = .criticalAndWarnings, + alertsEnabled: Bool = false + ) { + self.isEnabled = isEnabled + self.frequency = frequency + self.scope = scope + self.selectedDomainIDs = selectedDomainIDs + self.alertFilter = alertFilter + self.alertsEnabled = alertsEnabled + } +} + +struct MonitoringDomainResult: Codable, Identifiable, Equatable { + let id: UUID + let domain: String + let historyEntryID: UUID? + let checkedAt: Date + let didChange: Bool + let summaryMessage: String + let alertSeverity: MonitoringAlertSeverity? + let certificateWarningLevel: CertificateWarningLevel + let resultSource: LookupResultSource + let errorMessage: String? + + init( + id: UUID = UUID(), + domain: String, + historyEntryID: UUID?, + checkedAt: Date, + didChange: Bool, + summaryMessage: String, + alertSeverity: MonitoringAlertSeverity?, + certificateWarningLevel: CertificateWarningLevel, + resultSource: LookupResultSource, + errorMessage: String? = nil + ) { + self.id = id + self.domain = domain + self.historyEntryID = historyEntryID + self.checkedAt = checkedAt + self.didChange = didChange + self.summaryMessage = summaryMessage + self.alertSeverity = alertSeverity + self.certificateWarningLevel = certificateWarningLevel + self.resultSource = resultSource + self.errorMessage = errorMessage + } +} + +struct MonitoringLog: Codable, Identifiable, Equatable { + let id: UUID + let timestamp: Date + let trigger: MonitoringRunTrigger + let domainsChecked: Int + let changesFound: Int + let alertsTriggered: Int + let checkedDomains: [MonitoringDomainResult] + let errors: [String] + + init( + id: UUID = UUID(), + timestamp: Date, + trigger: MonitoringRunTrigger, + domainsChecked: Int, + changesFound: Int, + alertsTriggered: Int, + checkedDomains: [MonitoringDomainResult], + errors: [String] = [] + ) { + self.id = id + self.timestamp = timestamp + self.trigger = trigger + self.domainsChecked = domainsChecked + self.changesFound = changesFound + self.alertsTriggered = alertsTriggered + self.checkedDomains = checkedDomains + self.errors = errors + } + + var summary: String { + if errors.isEmpty { + return "\(changesFound) changes across \(domainsChecked) domains" + } + return "\(changesFound) changes, \(errors.count) errors" } } diff --git a/DomainDig/MonitoringView.swift b/DomainDig/MonitoringView.swift new file mode 100644 index 0000000..0b7a916 --- /dev/null +++ b/DomainDig/MonitoringView.swift @@ -0,0 +1,198 @@ +import SwiftUI + +struct MonitoringView: View { + @Environment(\.appDensity) private var appDensity + @Bindable var viewModel: DomainViewModel + + private var monitoredDomainsCount: Int { + MonitoringStorage.monitoredDomains( + settings: viewModel.monitoringSettings, + trackedDomains: viewModel.trackedDomains + ).count + } + + var body: some View { + List { + Section("Overview") { + VStack(alignment: .leading, spacing: 8) { + LabeledContent("Status", value: viewModel.monitoringSettings.isEnabled ? "Scheduled" : "Manual only") + LabeledContent("Domains", value: "\(monitoredDomainsCount)") + LabeledContent("Frequency", value: viewModel.monitoringSettings.frequency.title) + LabeledContent("Alerts", value: viewModel.monitoringSettings.alertsEnabled ? viewModel.monitoringSettings.alertFilter.title : "Off") + + if let monitoringStatusMessage = viewModel.monitoringStatusMessage, + !monitoringStatusMessage.isEmpty { + Text(monitoringStatusMessage) + .font(appDensity.font(.caption)) + .foregroundStyle(.secondary) + } + + Button(viewModel.monitoringRunInProgress ? "Monitoring…" : "Run Now") { + viewModel.runMonitoringNow() + } + .disabled(viewModel.monitoringRunInProgress) + } + .padding(.vertical, 4) + } + .listRowBackground(Color(.systemGray6).opacity(0.5)) + + if viewModel.monitoringLogs.isEmpty { + Section { + EmptyStateCardView( + title: "No Monitoring Runs Yet", + message: "Monitoring history appears here after manual or background runs finish.", + suggestion: "Enable monitoring in Settings or run a manual monitoring sweep.", + systemImage: "waveform.path.ecg" + ) + } + .listRowBackground(Color(.systemGray6).opacity(0.5)) + } else { + Section("Recent Runs") { + ForEach(viewModel.monitoringLogs) { log in + NavigationLink { + MonitoringLogDetailView(viewModel: viewModel, log: log) + } label: { + VStack(alignment: .leading, spacing: 6) { + HStack { + Text(log.trigger.title) + .font(appDensity.font(.callout)) + .foregroundStyle(.primary) + Spacer() + Text(log.timestamp.formatted(date: .abbreviated, time: .shortened)) + .font(appDensity.font(.caption2)) + .foregroundStyle(.secondary) + } + + Text(log.summary) + .font(appDensity.font(.caption)) + .foregroundStyle(.secondary) + + HStack(spacing: 8) { + metricBadge(title: "\(log.domainsChecked) checked") + if log.changesFound > 0 { + metricBadge(title: "\(log.changesFound) changed", tint: .orange) + } + if log.alertsTriggered > 0 { + metricBadge(title: "\(log.alertsTriggered) alerts", tint: .red) + } + } + } + .padding(.vertical, 4) + } + } + } + .listRowBackground(Color(.systemGray6).opacity(0.5)) + } + } + .scrollContentBackground(.hidden) + .background(Color.black) + .navigationTitle("Monitoring") + .preferredColorScheme(.dark) + .onAppear { + viewModel.refreshMonitoringState() + } + } + + private func metricBadge(title: String, tint: Color = .cyan) -> some View { + Text(title) + .font(appDensity.font(.caption2)) + .foregroundStyle(tint) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(tint.opacity(0.16)) + .clipShape(Capsule()) + } +} + +struct MonitoringLogDetailView: View { + @Environment(\.appDensity) private var appDensity + @Bindable var viewModel: DomainViewModel + let log: MonitoringLog + + var body: some View { + List { + Section("Summary") { + LabeledContent("Trigger", value: log.trigger.title) + LabeledContent("Checked", value: "\(log.domainsChecked)") + LabeledContent("Changes", value: "\(log.changesFound)") + LabeledContent("Alerts", value: "\(log.alertsTriggered)") + LabeledContent("Timestamp", value: log.timestamp.formatted(date: .abbreviated, time: .shortened)) + } + .listRowBackground(Color(.systemGray6).opacity(0.5)) + + Section("Domains") { + ForEach(log.checkedDomains) { result in + VStack(alignment: .leading, spacing: 6) { + HStack { + Text(result.domain) + .font(appDensity.font(.callout)) + .foregroundStyle(.primary) + Spacer() + if let alertSeverity = result.alertSeverity { + Text(alertSeverity.title.uppercased()) + .font(appDensity.font(.caption2)) + .foregroundStyle(color(for: alertSeverity)) + } + } + + Text(result.summaryMessage) + .font(appDensity.font(.caption)) + .foregroundStyle(.secondary) + + HStack(spacing: 10) { + Text(result.resultSource.label) + Text(result.didChange ? "Changed" : "No change") + if result.certificateWarningLevel != .none { + Text(result.certificateWarningLevel.title) + } + } + .font(appDensity.font(.caption2)) + .foregroundStyle(.secondary) + + if let errorMessage = result.errorMessage { + Text(errorMessage) + .font(appDensity.font(.caption2)) + .foregroundStyle(.yellow) + } + + if let historyEntryID = result.historyEntryID, + let entry = viewModel.history.first(where: { $0.id == historyEntryID }) { + NavigationLink("Open Snapshot") { + HistoryDetailView(viewModel: viewModel, entry: entry) + } + .font(appDensity.font(.caption)) + } + } + .padding(.vertical, 4) + } + } + .listRowBackground(Color(.systemGray6).opacity(0.5)) + + if !log.errors.isEmpty { + Section("Errors") { + ForEach(log.errors, id: \.self) { error in + Text(error) + .font(appDensity.font(.caption)) + .foregroundStyle(.secondary) + } + } + .listRowBackground(Color(.systemGray6).opacity(0.5)) + } + } + .scrollContentBackground(.hidden) + .background(Color.black) + .navigationTitle("Run Details") + .preferredColorScheme(.dark) + } + + private func color(for severity: MonitoringAlertSeverity) -> Color { + switch severity { + case .info: + return .secondary + case .warning: + return .yellow + case .critical: + return .red + } + } +} diff --git a/DomainDig/PaywallView.swift b/DomainDig/PaywallView.swift index d0293a8..90578c7 100644 --- a/DomainDig/PaywallView.swift +++ b/DomainDig/PaywallView.swift @@ -10,7 +10,7 @@ struct PaywallView: View { NavigationStack { List { Section { - Text("Pro unlocks workflows, scale, and exports. Data+ adds deeper external intelligence with local-first usage credits and no account requirement.") + Text("Pro unlocks workflows, scale, monitoring automation, and exports. Data+ adds deeper external intelligence with local-first usage credits and no account requirement.") .font(appDensity.font(.body, design: .default)) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) @@ -20,6 +20,8 @@ struct PaywallView: View { featureRow("Unlimited tracked domains") featureRow("Workflows") featureRow("Larger batch sizes") + featureRow("Background monitoring") + featureRow("Local alerts") featureRow("Advanced exports") } diff --git a/DomainDig/PremiumAccessService.swift b/DomainDig/PremiumAccessService.swift index 8de2626..35c172f 100644 --- a/DomainDig/PremiumAccessService.swift +++ b/DomainDig/PremiumAccessService.swift @@ -9,8 +9,10 @@ enum PremiumAccessService { return FeatureAccessService.hasAccess(to: .batchOperations) case .unlimitedTrackedDomains: return FeatureAccessService.currentTier != .free - case .automatedMonitoring, .pushAlerts: - return false + case .automatedMonitoring: + return FeatureAccessService.hasAccess(to: .automatedMonitoring) + case .pushAlerts: + return FeatureAccessService.hasAccess(to: .localAlerts) } } diff --git a/DomainDig/RootTabView.swift b/DomainDig/RootTabView.swift index 2d2b8ed..adf5702 100644 --- a/DomainDig/RootTabView.swift +++ b/DomainDig/RootTabView.swift @@ -21,6 +21,13 @@ struct RootTabView: View { } NavigationStack { + MonitoringView(viewModel: viewModel) + } + .tabItem { + Label("Monitoring", systemImage: "waveform.path.ecg") + } + + NavigationStack { HistoryView(viewModel: viewModel) } .tabItem { diff --git a/DomainDig/WatchlistView.swift b/DomainDig/WatchlistView.swift index b4e5011..968914f 100644 --- a/DomainDig/WatchlistView.swift +++ b/DomainDig/WatchlistView.swift @@ -287,6 +287,18 @@ struct WatchlistRowView: View { .font(appDensity.font(.caption2)) .foregroundStyle(.secondary) + HStack(spacing: 8) { + Text(trackedDomain.monitoringEnabled ? "Monitoring on" : "Monitoring off") + if let lastMonitoredAt = trackedDomain.lastMonitoredAt { + Text("Checked \(lastMonitoredAt.formatted(date: .omitted, time: .shortened))") + } + if let lastAlertAt = trackedDomain.lastAlertAt { + Text("Alert \(lastAlertAt.formatted(date: .omitted, time: .shortened))") + } + } + .font(appDensity.font(.caption2)) + .foregroundStyle(.secondary) + indicatorRow if let note = trackedDomain.note?.trimmingCharacters(in: .whitespacesAndNewlines), !note.isEmpty { @@ -400,6 +412,15 @@ struct TrackedDomainDetailView: View { } Button { + viewModel.toggleMonitoring(for: liveTrackedDomain) + } label: { + Label( + liveTrackedDomain.monitoringEnabled ? "Disable Monitoring" : "Enable Monitoring", + systemImage: liveTrackedDomain.monitoringEnabled ? "bell.slash" : "bell" + ) + } + + Button { noteDraft = liveTrackedDomain.note ?? "" isEditingNote = true } label: { diff --git a/DomainDigCLI.swift b/DomainDigCLI.swift index a6d2cf3..8c433d8 100644 --- a/DomainDigCLI.swift +++ b/DomainDigCLI.swift @@ -4,14 +4,24 @@ import Foundation struct DomainDigCLI { static func main() async { let arguments = Array(CommandLine.arguments.dropFirst()) + let wantsJSON = arguments.contains("--json") || arguments.contains("-j") guard let command = CommandLine.arguments.first else { - fputs("usage: domaindig <domain> [--json] [--ownership-history] [--dns-history] [--extended-subdomains] [--pricing] [--show-usage]\n", stderr) + fputs(usageText, stderr) Foundation.exit(1) } _ = command - let wantsJSON = arguments.contains("--json") || arguments.contains("-j") + if arguments.first == "backup" { + runBackupCommand(arguments: Array(arguments.dropFirst()), wantsJSON: wantsJSON) + return + } + + if arguments.first == "monitor" { + await runMonitorCommand(wantsJSON: wantsJSON) + return + } + let wantsOwnershipHistory = arguments.contains("--ownership-history") let wantsDNSHistory = arguments.contains("--dns-history") let wantsExtendedSubdomains = arguments.contains("--extended-subdomains") @@ -24,7 +34,7 @@ struct DomainDigCLI { .filter { !$0.isEmpty } guard !requestedDomains.isEmpty else { - fputs("usage: domaindig <domain> [--json] [--ownership-history] [--dns-history] [--extended-subdomains] [--pricing] [--show-usage]\n", stderr) + fputs(usageText, stderr) Foundation.exit(1) } @@ -252,10 +262,172 @@ struct DomainDigCLI { } private static func loadHistoryEntries() -> [HistoryEntry] { - guard let data = UserDefaults.standard.data(forKey: "lookupHistory"), - let entries = try? JSONDecoder().decode([HistoryEntry].self, from: data) else { - return [] + DomainDataPortabilityService.loadHistoryEntries() + } + + private static func runBackupCommand(arguments: [String], wantsJSON: Bool) { + guard let subcommand = arguments.first else { + fputs(usageText, stderr) + Foundation.exit(1) + } + + switch subcommand { + case "export": + do { + let data = try DomainDataPortabilityService.backupData() + let outputPath = arguments.dropFirst().first(where: { !$0.hasPrefix("-") }) + if let outputPath { + try data.write(to: URL(fileURLWithPath: outputPath), options: .atomic) + } else { + FileHandle.standardOutput.write(data) + if data.last != 0x0A { + FileHandle.standardOutput.write(Data([0x0A])) + } + } + } catch { + fputs("domaindig backup export: \(error.localizedDescription)\n", stderr) + Foundation.exit(1) + } + case "validate": + guard let path = arguments.dropFirst().first(where: { !$0.hasPrefix("-") }) else { + fputs("usage: domaindig backup validate <path>\n", stderr) + Foundation.exit(1) + } + + do { + let data = try Data(contentsOf: URL(fileURLWithPath: path)) + let report = try DomainDataPortabilityService.validateBackup(data: data, fileName: URL(fileURLWithPath: path).lastPathComponent) + if wantsJSON { + let payload = [ + "warnings": report.warnings, + "errors": report.errors + ] + let encoded = try JSONSerialization.data(withJSONObject: payload, options: [.prettyPrinted, .sortedKeys]) + FileHandle.standardOutput.write(encoded) + } else { + let lines = [ + "Warnings: \(report.warnings.count)", + "Errors: \(report.errors.count)" + ] + report.warnings.map { "warning: \($0)" } + report.errors.map { "error: \($0)" } + FileHandle.standardOutput.write(Data(lines.joined(separator: "\n").utf8)) + } + FileHandle.standardOutput.write(Data([0x0A])) + if !report.errors.isEmpty { + Foundation.exit(1) + } + } catch { + fputs("domaindig backup validate: \(error.localizedDescription)\n", stderr) + Foundation.exit(1) + } + case "import": + guard let path = arguments.dropFirst().first(where: { !$0.hasPrefix("-") }) else { + fputs("usage: domaindig backup import <path> [--replace]\n", stderr) + Foundation.exit(1) + } + + let mode: DataPortabilityImportMode = arguments.contains("--replace") ? .replace : .merge + + do { + let fileURL = URL(fileURLWithPath: path) + let data = try Data(contentsOf: fileURL) + let preview = try DomainDataPortabilityService.prepareImport( + data: data, + fileName: fileURL.lastPathComponent, + mode: mode + ) + guard preview.kind == .backup else { + fputs("domaindig backup import: expected a full backup file\n", stderr) + Foundation.exit(1) + } + let result = try DomainDataPortabilityService.applyImport(preview, mode: mode) + let lines = [result.summary] + result.warnings.map { "warning: \($0)" } + FileHandle.standardOutput.write(Data(lines.joined(separator: "\n").utf8)) + FileHandle.standardOutput.write(Data([0x0A])) + } catch { + fputs("domaindig backup import: \(error.localizedDescription)\n", stderr) + Foundation.exit(1) + } + default: + fputs(usageText, stderr) + Foundation.exit(1) + } + } + + private static func runMonitorCommand(wantsJSON: Bool) async { + guard FeatureAccessService.hasAccess(to: .automatedMonitoring) else { + fputs("domaindig monitor: monitoring requires Pro\n", stderr) + Foundation.exit(1) + } + + let outcome = await DomainMonitoringService.shared.performMonitoring( + trigger: .cli, + requireEnabledSetting: false + ) + + guard let log = outcome.log else { + fputs("domaindig monitor: \(outcome.message)\n", stderr) + Foundation.exit(1) + } + + do { + let output: Data + if wantsJSON { + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + output = try encoder.encode(log) + } else { + output = Data(monitoringTextSummary(for: log).utf8) + } + FileHandle.standardOutput.write(output) + if output.last != 0x0A { + FileHandle.standardOutput.write(Data([0x0A])) + } + if !outcome.success { + Foundation.exit(1) + } + } catch { + fputs("domaindig monitor: \(error.localizedDescription)\n", stderr) + Foundation.exit(1) + } + } + + private static func monitoringTextSummary(for log: MonitoringLog) -> String { + var lines = [ + "DomainDig Monitoring", + "====================", + "Trigger: \(log.trigger.title)", + "Timestamp: \(log.timestamp.formatted(date: .abbreviated, time: .shortened))", + "Checked: \(log.domainsChecked)", + "Changes: \(log.changesFound)", + "Alerts: \(log.alertsTriggered)", + "" + ] + + if log.checkedDomains.isEmpty { + lines.append("No domains were checked.") + } else { + for result in log.checkedDomains { + let severity = result.alertSeverity?.title ?? "None" + lines.append("\(result.domain): \(result.summaryMessage) [alert: \(severity)]") + } + } + + if !log.errors.isEmpty { + lines.append("") + lines.append("Errors:") + lines.append(contentsOf: log.errors.map { "- \($0)" }) } - return entries + + return lines.joined(separator: "\n") + } + + private static var usageText: String { + """ + usage: domaindig <domain> [--json] [--ownership-history] [--dns-history] [--extended-subdomains] [--pricing] [--show-usage] + domaindig monitor [--json] + domaindig backup export [path] + domaindig backup import <path> [--replace] + domaindig backup validate <path> [--json] + """ } } |
