summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorChristian Cleberg <[email protected]>2026-04-23 22:15:20 -0500
committerChristian Cleberg <[email protected]>2026-04-23 22:15:20 -0500
commitf045a5d339a796f40a9e958436ebc6f5314bdab8 (patch)
treec1d122f3ed7a9110dfcd001c86a56ed67980dfcc
parent6c23daaaba2a954220ece2afa7193dbdcad55c22 (diff)
downloaddomain-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.swift1397
-rw-r--r--DomainDig.xcodeproj/project.pbxproj16
-rw-r--r--DomainDig/AppVersion.swift2
-rw-r--r--DomainDig/ContentView.swift377
-rw-r--r--DomainDig/DomainDigApp.swift13
-rw-r--r--DomainDig/DomainMonitoringService.swift613
-rw-r--r--DomainDig/DomainViewModel.swift295
-rw-r--r--DomainDig/FeatureAccessService.swift19
-rw-r--r--DomainDig/Info.plist8
-rw-r--r--DomainDig/LocalNotificationService.swift33
-rw-r--r--DomainDig/Models.swift223
-rw-r--r--DomainDig/MonitoringView.swift198
-rw-r--r--DomainDig/PaywallView.swift4
-rw-r--r--DomainDig/PremiumAccessService.swift6
-rw-r--r--DomainDig/RootTabView.swift7
-rw-r--r--DomainDig/WatchlistView.swift21
-rw-r--r--DomainDigCLI.swift186
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 NSApp​Transport​Security)</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]
+ """
}
}