Commit d8195992e5

d8195992e519343d6d8ff7fb17aa98358800d913

parent: 1edf5bc83c

Verified · cmc

cmc <hello@cleberg.net> · 2026-07-17 04:48 UTC

DomainDig v4.4.1: Consolidate Audit Mode and remove the CLI target

- Make DomainDig/DomainDig/Audit* the single active Audit Mode implementation
  (models, views, exporter) with an Audit tab and session/export UI
- Include audit sessions in backup/restore lifecycle counts, summaries, and
  merge behavior via DomainDataPortabilityService
- Remove the DomainDigCLI target, source file, scheme, and all project
  references; keep the shared inspection/report pipeline for the app
- Align AppVersion.current to 4.4.1 and refresh README/architecture docs
- Add RELEASE_ROADMAP.md

Layout: unified · split

Docs/ARCHITECTURE.md +73 −28
@@ -1,48 +1,93 @@
1# DomainDig v3.0.0 Architecture 1# DomainDig v4.4.1 Architecture
2 2
3## Overview 3## Overview
4 4
5DomainDig is a local-first inspection platform built around one canonical output model: `DomainReport`. 5DomainDig is a local-first inspection and audit app built around one canonical output model: `DomainReport`.
6 6
7Inspection flow: 7Inspection flow:
8 8
91. `DomainInspectionService.inspect(domain:)` gathers live and cached section data into `LookupSnapshot`. 91. `LookupRuntime` coordinates the section services that gather DNS, web, TLS, ownership, reachability, redirect, email, port, and enrichment data.
102. `DomainReportBuilder` converts the snapshot into a canonical `DomainReport`. 102. `DomainInspectionService` normalizes live and cached results into `LookupSnapshot`.
113. UI, exports, and CLI rendering derive from `DomainReport`. 113. `DomainReportBuilder` converts each snapshot into the canonical `DomainReport`.
124. SwiftUI screens, exports, and the local API render from `DomainReport` or data derived from it.
12 13
13`LookupSnapshot` remains an internal collection and persistence shape. `DomainReport` is the stable presentation and export contract. 14`LookupSnapshot` remains the internal persistence shape for raw inspection state. `DomainReport` is the stable presentation/export contract.
14 15
15## Canonical Report Lifecycle 16## App Layers
16 17
17- `LookupRuntime` coordinates section services. 18- Section services: network collection and local normalization only.
18- `DomainInspectionService` normalizes failures, provenance, cache state, and section metadata. 19- `LookupRuntime`: orchestrates section services for a single inspection.
19- `DomainReportBuilder` adds summaries, insights, risk scoring, change analysis, workflow context, and report metadata. 20- `DomainInspectionService`: builds inspection snapshots with provenance, cache state, and failure metadata.
20- `DomainReportExporter` renders TXT, CSV, and JSON from the same report payload. 21- `DomainReportBuilder`: assembles summaries, insights, risk scoring, workflow context, and report metadata.
21- `DomainDigCLI` prints exporter output directly so CLI output matches the app. 22- `DomainReportExporter`: renders TXT, CSV, and JSON output for app and local API use.
23- `DomainViewModel`: coordinates SwiftUI state, persistence, audit sessions, monitoring, workflows, batch operations, imports, and exports.
24- SwiftUI views: render screens and invoke view-model actions.
25
26## Audit Mode
27
28The app has one active Audit Mode implementation:
29
30- Models live in `DomainDig/DomainDig/AuditModels.swift`.
31- UI lives in `DomainDig/DomainDig/AuditViews.swift`.
32- Export rendering lives in `DomainDig/DomainDig/AuditExporter.swift`.
33- Persistence is owned by `DomainViewModel` through `DomainDataPortabilityService`.
34
35An audit session captures:
36
37- Domain and reviewer metadata
38- Session status
39- Point-in-time `HistoryEntry` and `DomainReport`
40- Historical snapshot context
41- Evidence asset references
42- Checklist progress
43- Findings with severity, status, evidence references, notes, and checklist areas
44- Reviewer notes
45
46Audit sessions are stored under the same local portability service as the rest of app data and are included in full backup/restore flows.
47
48The older standalone prototype files, `DomainDig/AuditMode.swift` and `DomainDig/AuditModeView.swift`, are preserved in the repository for reference but excluded from synchronized target membership. They are not the release audit path.
49
50## Data Portability
51
52`DomainDataPortabilityService` owns backup, import, validation, lifecycle counts, and merge/replace behavior for:
53
54- Tracked domains
55- History snapshots
56- Audit sessions
57- Workflows
58- Monitoring settings and logs
59- App settings
60- Local feature metadata
61
62Backup imports support merge and replace modes. Merge mode deduplicates by stable IDs or normalized domain keys, keeps local data where appropriate, and merges audit-session reviewer notes when the same audit session appears in multiple backups.
22 63
23## Feature Tiers 64## Feature Tiers
24 65
25The app now uses `FeatureAccessService` as the single feature gating surface. 66`FeatureAccessService`, `PremiumAccessService`, `PurchaseService`, and `UsageCreditService` provide the app's feature-gating surfaces.
67
68The app remains local-first. Purchase and entitlement code is local app infrastructure and does not introduce a hosted DomainDig backend.
69
70## Local API
71
72`LocalAPIService` is an automation surface over the same inspection/reporting pipeline:
26 73
27- `Free`: single lookup, basic history, limited tracking 74- `DomainInspectionService`
28- `Pro`: workflows, batch operations, advanced exports 75- `DomainReportBuilder`
29- `Data+`: future historical datasets and extended enrichment 76- `DomainReportExporter`
77- `LocalAPIModels`
30 78
31Current release behavior is static scaffolding only. There are no purchases, backend checks, or remote entitlements. 79The major-version roadmap calls for a stronger compatibility promise around this local API contract in `v5.0.0`.
32 80
33## Data Boundaries 81## Xcode Project Structure
34 82
35- Inspection services: network collection only 83`DomainDig.xcodeproj` uses filesystem-synchronized groups for the `DomainDig` folder. Target membership exclusions are therefore important release metadata. Files that should remain in the tree but not compile, such as retired prototypes, must be listed in the appropriate synchronized build file exception set.
36- `DomainReportBuilder`: canonical model assembly
37- `FeatureAccessService`: tier and capability checks
38- `DomainViewModel`: UI orchestration, persistence, batch coordination
39- Views: rendering and interaction only
40 84
41## Adding a New Data Source 85## Adding A New Data Source
42 86
431. Add the raw collection call to `LookupRuntime`. 871. Add the raw collection call to `LookupRuntime` or an existing section service.
442. Integrate it in `DomainInspectionService` with provenance, cache source, and normalized failures. 882. Integrate it in `DomainInspectionService` with provenance, cache source, and normalized failures.
453. Extend `LookupSnapshot` only if the raw result must persist. 893. Extend `LookupSnapshot` only if the raw result must persist.
464. Add the summarized representation to `DomainReportBuilder`. 904. Add summarized representation to `DomainReportBuilder`.
475. Expose it through `DomainReportExporter` if it should appear in TXT, CSV, JSON, or CLI. 915. Expose it through `DomainReportExporter` or `LocalAPIModels` when it is part of the external contract.
486. Render the new summary in SwiftUI using `DomainReport` fields. 926. Render it in SwiftUI from `DomainReport` fields or view-model state.
937. Update backup/restore only when the data is user-authored state or long-lived app state.
DomainDataPortabilityService.swift +86 −1
@@ -9,6 +9,7 @@ struct DomainDigBackup: Codable {
9 let appVersion: String 9 let appVersion: String
10 let trackedDomains: [TrackedDomain] 10 let trackedDomains: [TrackedDomain]
11 let historyEntries: [HistoryEntry] 11 let historyEntries: [HistoryEntry]
12 let auditSessions: [AuditSession]?
12 let workflows: [DomainWorkflow] 13 let workflows: [DomainWorkflow]
13 let monitoringSettings: MonitoringSettings? 14 let monitoringSettings: MonitoringSettings?
14 let monitoringLogs: [MonitoringLog] 15 let monitoringLogs: [MonitoringLog]
@@ -88,6 +89,7 @@ enum DataPortabilityImportKind: String {
88struct DataLifecycleSummary: Equatable { 89struct DataLifecycleSummary: Equatable {
89 let trackedDomains: Int 90 let trackedDomains: Int
90 let historySnapshots: Int 91 let historySnapshots: Int
92 let auditSessions: Int
91 let workflows: Int 93 let workflows: Int
92 let cachedItems: Int 94 let cachedItems: Int
93 let monitoringLogs: Int 95 let monitoringLogs: Int
@@ -352,6 +354,9 @@ enum DataMigrationService {
352 let historyEntries = DomainDataPortabilityService.loadHistoryEntries(defaults: defaults) 354 let historyEntries = DomainDataPortabilityService.loadHistoryEntries(defaults: defaults)
353 DomainDataPortabilityService.saveHistoryEntries(historyEntries, defaults: defaults) 355 DomainDataPortabilityService.saveHistoryEntries(historyEntries, defaults: defaults)
354 356
357 let auditSessions = DomainDataPortabilityService.loadAuditSessions(defaults: defaults)
358 DomainDataPortabilityService.saveAuditSessions(auditSessions, defaults: defaults)
359
355 let workflows = DomainDataPortabilityService.loadWorkflows(defaults: defaults) 360 let workflows = DomainDataPortabilityService.loadWorkflows(defaults: defaults)
356 DomainDataPortabilityService.saveWorkflows(workflows, defaults: defaults) 361 DomainDataPortabilityService.saveWorkflows(workflows, defaults: defaults)
357 362
@@ -378,6 +383,7 @@ enum DataValidationService {
378 383
379 messages.append(contentsOf: validateTrackedDomains(backup.trackedDomains)) 384 messages.append(contentsOf: validateTrackedDomains(backup.trackedDomains))
380 messages.append(contentsOf: validateHistoryEntries(backup.historyEntries)) 385 messages.append(contentsOf: validateHistoryEntries(backup.historyEntries))
386 messages.append(contentsOf: validateAuditSessions(backup.auditSessions ?? []))
381 messages.append(contentsOf: validateWorkflows(backup.workflows)) 387 messages.append(contentsOf: validateWorkflows(backup.workflows))
382 388
383 if backup.appSettings.resolverURLString.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { 389 if backup.appSettings.resolverURLString.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
@@ -430,6 +436,23 @@ enum DataValidationService {
430 return messages 436 return messages
431 } 437 }
432 438
439 private static func validateAuditSessions(_ auditSessions: [AuditSession]) -> [DataValidationMessage] {
440 var messages: [DataValidationMessage] = []
441 var seen = Set<UUID>()
442
443 for session in auditSessions {
444 if normalizeDomain(session.domain).isEmpty {
445 messages.append(.init(text: "An audit session is missing its domain name.", isError: true))
446 }
447
448 if !seen.insert(session.id).inserted {
449 messages.append(.init(text: "Duplicate audit session found for \(session.domain). Merge rules will consolidate it.", isError: false))
450 }
451 }
452
453 return messages
454 }
455
433 private static func validateWorkflows(_ workflows: [DomainWorkflow]) -> [DataValidationMessage] { 456 private static func validateWorkflows(_ workflows: [DomainWorkflow]) -> [DataValidationMessage] {
434 var messages: [DataValidationMessage] = [] 457 var messages: [DataValidationMessage] = []
435 for workflow in workflows { 458 for workflow in workflows {
@@ -479,6 +502,7 @@ enum DomainDataPortabilityService {
479 static let trackedDomains = "trackedDomains" 502 static let trackedDomains = "trackedDomains"
480 static let legacyWatchedDomains = "watchedDomains" 503 static let legacyWatchedDomains = "watchedDomains"
481 static let history = "lookupHistory" 504 static let history = "lookupHistory"
505 static let audits = "domainAudits"
482 static let workflows = "domainWorkflows" 506 static let workflows = "domainWorkflows"
483 static let monitoringSettings = "monitoring.settings" 507 static let monitoringSettings = "monitoring.settings"
484 static let monitoringLogs = "monitoring.logs" 508 static let monitoringLogs = "monitoring.logs"
@@ -565,6 +589,21 @@ enum DomainDataPortabilityService {
565 } 589 }
566 } 590 }
567 591
592 static func loadAuditSessions(defaults: UserDefaults = .standard) -> [AuditSession] {
593 guard let data = defaults.data(forKey: StorageKey.audits),
594 let sessions = try? JSONDecoder().decode([AuditSession].self, from: data) else {
595 return []
596 }
597 return sessions.sorted { $0.createdAt > $1.createdAt }
598 }
599
600 static func saveAuditSessions(_ auditSessions: [AuditSession], defaults: UserDefaults = .standard) {
601 let sessions = auditSessions.sorted { $0.createdAt > $1.createdAt }
602 if let data = try? JSONEncoder().encode(sessions) {
603 defaults.set(data, forKey: StorageKey.audits)
604 }
605 }
606
568 static func loadWorkflows(defaults: UserDefaults = .standard) -> [DomainWorkflow] { 607 static func loadWorkflows(defaults: UserDefaults = .standard) -> [DomainWorkflow] {
569 guard let data = defaults.data(forKey: StorageKey.workflows), 608 guard let data = defaults.data(forKey: StorageKey.workflows),
570 let workflows = try? JSONDecoder().decode([DomainWorkflow].self, from: data) else { 609 let workflows = try? JSONDecoder().decode([DomainWorkflow].self, from: data) else {
@@ -662,6 +701,7 @@ enum DomainDataPortabilityService {
662 appVersion: AppVersion.current, 701 appVersion: AppVersion.current,
663 trackedDomains: loadTrackedDomains(defaults: defaults), 702 trackedDomains: loadTrackedDomains(defaults: defaults),
664 historyEntries: loadHistoryEntries(defaults: defaults), 703 historyEntries: loadHistoryEntries(defaults: defaults),
704 auditSessions: loadAuditSessions(defaults: defaults),
665 workflows: loadWorkflows(defaults: defaults), 705 workflows: loadWorkflows(defaults: defaults),
666 monitoringSettings: loadMonitoringSettings(defaults: defaults), 706 monitoringSettings: loadMonitoringSettings(defaults: defaults),
667 monitoringLogs: loadMonitoringLogs(defaults: defaults), 707 monitoringLogs: loadMonitoringLogs(defaults: defaults),
@@ -750,7 +790,7 @@ enum DomainDataPortabilityService {
750 return DataImportResult( 790 return DataImportResult(
751 kind: .backup, 791 kind: .backup,
752 mode: mode, 792 mode: mode,
753 summary: "Imported backup with \(backup.trackedDomains.count) tracked domains, \(backup.historyEntries.count) history snapshots, and \(backup.workflows.count) workflows.", 793 summary: "Imported backup with \(backup.trackedDomains.count) tracked domains, \(backup.historyEntries.count) history snapshots, \(backup.auditSessions?.count ?? 0) audit sessions, and \(backup.workflows.count) workflows.",
754 warnings: report.warnings 794 warnings: report.warnings
755 ) 795 )
756 case .trackedDomains(let trackedDomains, let report): 796 case .trackedDomains(let trackedDomains, let report):
@@ -794,6 +834,7 @@ enum DomainDataPortabilityService {
794 return DataLifecycleSummary( 834 return DataLifecycleSummary(
795 trackedDomains: loadTrackedDomains(defaults: defaults).count, 835 trackedDomains: loadTrackedDomains(defaults: defaults).count,
796 historySnapshots: loadHistoryEntries(defaults: defaults).count, 836 historySnapshots: loadHistoryEntries(defaults: defaults).count,
837 auditSessions: loadAuditSessions(defaults: defaults).count,
797 workflows: loadWorkflows(defaults: defaults).count, 838 workflows: loadWorkflows(defaults: defaults).count,
798 cachedItems: cachedItems, 839 cachedItems: cachedItems,
799 monitoringLogs: loadMonitoringLogs(defaults: defaults).count 840 monitoringLogs: loadMonitoringLogs(defaults: defaults).count
@@ -897,6 +938,7 @@ enum DomainDataPortabilityService {
897 appVersion: AppVersion.current, 938 appVersion: AppVersion.current,
898 trackedDomains: legacy.trackedDomains ?? [], 939 trackedDomains: legacy.trackedDomains ?? [],
899 historyEntries: legacy.historyEntries ?? [], 940 historyEntries: legacy.historyEntries ?? [],
941 auditSessions: nil,
900 workflows: legacy.workflows ?? [], 942 workflows: legacy.workflows ?? [],
901 monitoringSettings: legacy.monitoringSettings, 943 monitoringSettings: legacy.monitoringSettings,
902 monitoringLogs: legacy.monitoringLogs ?? [], 944 monitoringLogs: legacy.monitoringLogs ?? [],
@@ -918,6 +960,7 @@ enum DomainDataPortabilityService {
918 if mode == .replace { 960 if mode == .replace {
919 saveTrackedDomains(deduplicatedTrackedDomains(backup.trackedDomains), defaults: defaults) 961 saveTrackedDomains(deduplicatedTrackedDomains(backup.trackedDomains), defaults: defaults)
920 saveHistoryEntries(deduplicatedHistoryEntries(backup.historyEntries), defaults: defaults) 962 saveHistoryEntries(deduplicatedHistoryEntries(backup.historyEntries), defaults: defaults)
963 saveAuditSessions(backup.auditSessions ?? [], defaults: defaults)
921 saveWorkflows(deduplicatedWorkflows(backup.workflows), defaults: defaults) 964 saveWorkflows(deduplicatedWorkflows(backup.workflows), defaults: defaults)
922 saveMonitoringSettings( 965 saveMonitoringSettings(
923 MonitoringStorage.sanitizeSettings( 966 MonitoringStorage.sanitizeSettings(
@@ -943,6 +986,10 @@ enum DomainDataPortabilityService {
943 defaults: defaults 986 defaults: defaults
944 ) 987 )
945 988
989 let existingAuditSessions = loadAuditSessions(defaults: defaults)
990 let mergedAuditSessions = mergeAuditSessions(existing: existingAuditSessions, incoming: backup.auditSessions ?? [])
991 saveAuditSessions(mergedAuditSessions, defaults: defaults)
992
946 let existingWorkflows = loadWorkflows(defaults: defaults) 993 let existingWorkflows = loadWorkflows(defaults: defaults)
947 saveWorkflows(mergeWorkflows(existing: existingWorkflows, incoming: backup.workflows), defaults: defaults) 994 saveWorkflows(mergeWorkflows(existing: existingWorkflows, incoming: backup.workflows), defaults: defaults)
948 995
@@ -1012,6 +1059,7 @@ enum DomainDataPortabilityService {
1012 return DataLifecycleSummary( 1059 return DataLifecycleSummary(
1013 trackedDomains: backup.trackedDomains.count, 1060 trackedDomains: backup.trackedDomains.count,
1014 historySnapshots: backup.historyEntries.count, 1061 historySnapshots: backup.historyEntries.count,
1062 auditSessions: backup.auditSessions?.count ?? 0,
1015 workflows: backup.workflows.count, 1063 workflows: backup.workflows.count,
1016 cachedItems: backup.appSettings.recentSearches.count + backup.appSettings.savedDomains.count + ((backup.featureMetadata?.cachedEntitlement == nil ? 0 : 1) + (backup.featureMetadata?.usageCredits == nil ? 0 : 1)), 1064 cachedItems: backup.appSettings.recentSearches.count + backup.appSettings.savedDomains.count + ((backup.featureMetadata?.cachedEntitlement == nil ? 0 : 1) + (backup.featureMetadata?.usageCredits == nil ? 0 : 1)),
1017 monitoringLogs: backup.monitoringLogs.count 1065 monitoringLogs: backup.monitoringLogs.count
@@ -1021,6 +1069,7 @@ enum DomainDataPortabilityService {
1021 return DataLifecycleSummary( 1069 return DataLifecycleSummary(
1022 trackedDomains: mergeTrackedDomains(existing: loadTrackedDomains(defaults: defaults), incoming: backup.trackedDomains).domains.count, 1070 trackedDomains: mergeTrackedDomains(existing: loadTrackedDomains(defaults: defaults), incoming: backup.trackedDomains).domains.count,
1023 historySnapshots: mergeHistoryEntries(existing: loadHistoryEntries(defaults: defaults), incoming: backup.historyEntries).count, 1071 historySnapshots: mergeHistoryEntries(existing: loadHistoryEntries(defaults: defaults), incoming: backup.historyEntries).count,
1072 auditSessions: mergeAuditSessions(existing: loadAuditSessions(defaults: defaults), incoming: backup.auditSessions ?? []).count,
1024 workflows: mergeWorkflows(existing: loadWorkflows(defaults: defaults), incoming: backup.workflows).count, 1073 workflows: mergeWorkflows(existing: loadWorkflows(defaults: defaults), incoming: backup.workflows).count,
1025 cachedItems: max(current.cachedItems, backup.appSettings.recentSearches.count + backup.appSettings.savedDomains.count), 1074 cachedItems: max(current.cachedItems, backup.appSettings.recentSearches.count + backup.appSettings.savedDomains.count),
1026 monitoringLogs: mergeMonitoringLogs(existing: loadMonitoringLogs(defaults: defaults), incoming: backup.monitoringLogs).count 1075 monitoringLogs: mergeMonitoringLogs(existing: loadMonitoringLogs(defaults: defaults), incoming: backup.monitoringLogs).count
@@ -1032,6 +1081,7 @@ enum DomainDataPortabilityService {
1032 return DataLifecycleSummary( 1081 return DataLifecycleSummary(
1033 trackedDomains: total, 1082 trackedDomains: total,
1034 historySnapshots: current.historySnapshots, 1083 historySnapshots: current.historySnapshots,
1084 auditSessions: current.auditSessions,
1035 workflows: current.workflows, 1085 workflows: current.workflows,
1036 cachedItems: current.cachedItems, 1086 cachedItems: current.cachedItems,
1037 monitoringLogs: current.monitoringLogs 1087 monitoringLogs: current.monitoringLogs
@@ -1043,6 +1093,7 @@ enum DomainDataPortabilityService {
1043 return DataLifecycleSummary( 1093 return DataLifecycleSummary(
1044 trackedDomains: current.trackedDomains, 1094 trackedDomains: current.trackedDomains,
1045 historySnapshots: current.historySnapshots, 1095 historySnapshots: current.historySnapshots,
1096 auditSessions: current.auditSessions,
1046 workflows: total, 1097 workflows: total,
1047 cachedItems: current.cachedItems, 1098 cachedItems: current.cachedItems,
1048 monitoringLogs: current.monitoringLogs 1099 monitoringLogs: current.monitoringLogs
@@ -1132,6 +1183,39 @@ enum DomainDataPortabilityService {
1132 } 1183 }
1133 } 1184 }
1134 1185
1186 private static func mergeAuditSessions(existing: [AuditSession], incoming: [AuditSession]) -> [AuditSession] {
1187 var merged = Dictionary(uniqueKeysWithValues: existing.map { ($0.id, $0) })
1188
1189 for session in incoming {
1190 if let existingSession = merged[session.id] {
1191 let winner = existingSession.findings.count >= session.findings.count ? existingSession : session
1192 let candidateNotes = [existingSession.notes, session.notes]
1193 .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
1194 .filter { !$0.isEmpty }
1195 let notes = candidateNotes.reduce(into: [String]()) { result, note in
1196 if !result.contains(note) {
1197 result.append(note)
1198 }
1199 }.joined(separator: "\n\n")
1200 merged[session.id] = AuditSession(
1201 id: existingSession.id,
1202 domain: existingSession.domain,
1203 createdAt: min(existingSession.createdAt, session.createdAt),
1204 reviewer: winner.reviewer,
1205 status: winner.status,
1206 evidence: winner.evidence,
1207 findings: winner.findings,
1208 notes: notes,
1209 checklist: winner.checklist
1210 )
1211 } else {
1212 merged[session.id] = session
1213 }
1214 }
1215
1216 return merged.values.sorted { $0.createdAt > $1.createdAt }
1217 }
1218
1135 private static func mergeWorkflows(existing: [DomainWorkflow], incoming: [DomainWorkflow]) -> [DomainWorkflow] { 1219 private static func mergeWorkflows(existing: [DomainWorkflow], incoming: [DomainWorkflow]) -> [DomainWorkflow] {
1136 var merged = Dictionary(uniqueKeysWithValues: existing.map { ($0.id, normalizedWorkflow($0)) }) 1220 var merged = Dictionary(uniqueKeysWithValues: existing.map { ($0.id, normalizedWorkflow($0)) })
1137 1221
@@ -1394,6 +1478,7 @@ private enum ImportPayload {
1394 "Full backup import", 1478 "Full backup import",
1395 "\(backup.trackedDomains.count) tracked domains", 1479 "\(backup.trackedDomains.count) tracked domains",
1396 "\(backup.historyEntries.count) history snapshots", 1480 "\(backup.historyEntries.count) history snapshots",
1481 "\(backup.auditSessions?.count ?? 0) audit sessions",
1397 "\(backup.workflows.count) workflows", 1482 "\(backup.workflows.count) workflows",
1398 "\(backup.monitoringLogs.count) monitoring logs", 1483 "\(backup.monitoringLogs.count) monitoring logs",
1399 mode == .replace ? "Replace mode will overwrite local backupable data." : "Merge mode will keep local data and consolidate duplicates." 1484 mode == .replace ? "Replace mode will overwrite local backupable data." : "Merge mode will keep local data and consolidate duplicates."
DomainDig.xcodeproj/project.pbxproj +6 −108
@@ -7,33 +7,23 @@
7 objects = { 7 objects = {
8 8
9/* Begin PBXBuildFile section */ 9/* Begin PBXBuildFile section */
10 8BBFEF082F9874AE00E8E144 /* DomainDigCLI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BBFEF022F9874AE00E8E144 /* DomainDigCLI.swift */; };
11 8BBFEF092F9874AE00E8E144 /* DomainInspectionService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BBFEF032F9874AE00E8E144 /* DomainInspectionService.swift */; }; 10 8BBFEF092F9874AE00E8E144 /* DomainInspectionService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BBFEF032F9874AE00E8E144 /* DomainInspectionService.swift */; };
12 8BBFEF0A2F9874AE00E8E144 /* DomainReportBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BBFEF042F9874AE00E8E144 /* DomainReportBuilder.swift */; }; 11 8BBFEF0A2F9874AE00E8E144 /* DomainReportBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BBFEF042F9874AE00E8E144 /* DomainReportBuilder.swift */; };
13 8BBFEF0B2F9874AE00E8E144 /* DomainReportExporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BBFEF052F9874AE00E8E144 /* DomainReportExporter.swift */; }; 12 8BBFEF0B2F9874AE00E8E144 /* DomainReportExporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BBFEF052F9874AE00E8E144 /* DomainReportExporter.swift */; };
14 8BBFEF0C2F9874AE00E8E144 /* LookupSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BBFEF062F9874AE00E8E144 /* LookupSnapshot.swift */; }; 13 8BBFEF0C2F9874AE00E8E144 /* LookupSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BBFEF062F9874AE00E8E144 /* LookupSnapshot.swift */; };
15 8BBFEFCA2F987E8700E8E144 /* DomainInspectionService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BBFEF032F9874AE00E8E144 /* DomainInspectionService.swift */; };
16 8BBFEFCB2F987E8700E8E144 /* DomainReportBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BBFEF042F9874AE00E8E144 /* DomainReportBuilder.swift */; };
17 8BBFEFCC2F987E8700E8E144 /* DomainReportExporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BBFEF052F9874AE00E8E144 /* DomainReportExporter.swift */; };
18 8BBFEFCD2F987E8700E8E144 /* LookupSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BBFEF062F9874AE00E8E144 /* LookupSnapshot.swift */; };
19 8BCA3CBE2F9C8D57004B742C /* LocalAPIModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BCA3CBC2F9C8D57004B742C /* LocalAPIModels.swift */; };
20 8BCA3CBF2F9C8D57004B742C /* LocalAPIService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BCA3CBD2F9C8D57004B742C /* LocalAPIService.swift */; };
21 8BCA3CC02F9C8D57004B742C /* LocalAPIModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BCA3CBC2F9C8D57004B742C /* LocalAPIModels.swift */; }; 14 8BCA3CC02F9C8D57004B742C /* LocalAPIModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BCA3CBC2F9C8D57004B742C /* LocalAPIModels.swift */; };
22 8BCA3CC12F9C8D57004B742C /* LocalAPIService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BCA3CBD2F9C8D57004B742C /* LocalAPIService.swift */; }; 15 8BCA3CC12F9C8D57004B742C /* LocalAPIService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BCA3CBD2F9C8D57004B742C /* LocalAPIService.swift */; };
23 8BF9DA862F9B13FB00EF41D5 /* DomainDataPortabilityService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BF9DA842F9B13FB00EF41D5 /* DomainDataPortabilityService.swift */; };
24 8BF9DA872F9B13FB00EF41D5 /* DomainDataPortabilityService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BF9DA842F9B13FB00EF41D5 /* DomainDataPortabilityService.swift */; }; 16 8BF9DA872F9B13FB00EF41D5 /* DomainDataPortabilityService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BF9DA842F9B13FB00EF41D5 /* DomainDataPortabilityService.swift */; };
25/* End PBXBuildFile section */ 17/* End PBXBuildFile section */
26 18
27/* Begin PBXFileReference section */ 19/* Begin PBXFileReference section */
28 8B7800692F6090E300933221 /* DomainDig.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = DomainDig.app; sourceTree = BUILT_PRODUCTS_DIR; }; 20 8B7800692F6090E300933221 /* DomainDig.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = DomainDig.app; sourceTree = BUILT_PRODUCTS_DIR; };
29 8BBFEF022F9874AE00E8E144 /* DomainDigCLI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DomainDigCLI.swift; sourceTree = "<group>"; };
30 8BBFEF032F9874AE00E8E144 /* DomainInspectionService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DomainInspectionService.swift; sourceTree = "<group>"; }; 21 8BBFEF032F9874AE00E8E144 /* DomainInspectionService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DomainInspectionService.swift; sourceTree = "<group>"; };
31 8BBFEF042F9874AE00E8E144 /* DomainReportBuilder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DomainReportBuilder.swift; sourceTree = "<group>"; }; 22 8BBFEF042F9874AE00E8E144 /* DomainReportBuilder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DomainReportBuilder.swift; sourceTree = "<group>"; };
32 8BBFEF052F9874AE00E8E144 /* DomainReportExporter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DomainReportExporter.swift; sourceTree = "<group>"; }; 23 8BBFEF052F9874AE00E8E144 /* DomainReportExporter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DomainReportExporter.swift; sourceTree = "<group>"; };
33 8BBFEF062F9874AE00E8E144 /* LookupSnapshot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LookupSnapshot.swift; sourceTree = "<group>"; }; 24 8BBFEF062F9874AE00E8E144 /* LookupSnapshot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LookupSnapshot.swift; sourceTree = "<group>"; };
34 8BCA3CBC2F9C8D57004B742C /* LocalAPIModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalAPIModels.swift; sourceTree = "<group>"; }; 25 8BCA3CBC2F9C8D57004B742C /* LocalAPIModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalAPIModels.swift; sourceTree = "<group>"; };
35 8BCA3CBD2F9C8D57004B742C /* LocalAPIService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalAPIService.swift; sourceTree = "<group>"; }; 26 8BCA3CBD2F9C8D57004B742C /* LocalAPIService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalAPIService.swift; sourceTree = "<group>"; };
36 8BF124F92F70000100933221 /* domaindig */ = {isa = PBXFileReference; explicitFileType = "compiled.mach-o.executable"; includeInIndex = 0; path = domaindig; sourceTree = BUILT_PRODUCTS_DIR; };
37 8BF9DA842F9B13FB00EF41D5 /* DomainDataPortabilityService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DomainDataPortabilityService.swift; sourceTree = "<group>"; }; 27 8BF9DA842F9B13FB00EF41D5 /* DomainDataPortabilityService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DomainDataPortabilityService.swift; sourceTree = "<group>"; };
38/* End PBXFileReference section */ 28/* End PBXFileReference section */
39 29
@@ -41,27 +31,12 @@
41 8B1B506D2F666F64005C246F /* Exceptions for "DomainDig" folder in "DomainDig" target */ = { 31 8B1B506D2F666F64005C246F /* Exceptions for "DomainDig" folder in "DomainDig" target */ = {
42 isa = PBXFileSystemSynchronizedBuildFileExceptionSet; 32 isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
43 membershipExceptions = ( 33 membershipExceptions = (
44 DomainDigCLI.swift, 34 AuditMode.swift,
35 AuditModeView.swift,
45 Info.plist, 36 Info.plist,
46 ); 37 );
47 target = 8B7800682F6090E300933221 /* DomainDig */; 38 target = 8B7800682F6090E300933221 /* DomainDig */;
48 }; 39 };
49 8BF124FA2F70000100933221 /* Exceptions for "DomainDig" folder in "DomainDigCLI" target */ = {
50 isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
51 membershipExceptions = (
52 BatchResultsView.swift,
53 BatchSweepSummaryView.swift,
54 ContentView.swift,
55 DomainDigApp.swift,
56 DomainViewModel.swift,
57 ExportPresenter.swift,
58 HistoryView.swift,
59 LocalNotificationService.swift,
60 SavedDomainsView.swift,
61 WatchlistView.swift,
62 );
63 target = 8BF124FB2F70000100933221 /* DomainDigCLI */;
64 };
65/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ 40/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
66 41
67/* Begin PBXFileSystemSynchronizedRootGroup section */ 42/* Begin PBXFileSystemSynchronizedRootGroup section */
@@ -69,7 +44,6 @@
69 isa = PBXFileSystemSynchronizedRootGroup; 44 isa = PBXFileSystemSynchronizedRootGroup;
70 exceptions = ( 45 exceptions = (
71 8B1B506D2F666F64005C246F /* Exceptions for "DomainDig" folder in "DomainDig" target */, 46 8B1B506D2F666F64005C246F /* Exceptions for "DomainDig" folder in "DomainDig" target */,
72 8BF124FA2F70000100933221 /* Exceptions for "DomainDig" folder in "DomainDigCLI" target */,
73 ); 47 );
74 path = DomainDig; 48 path = DomainDig;
75 sourceTree = "<group>"; 49 sourceTree = "<group>";
@@ -84,13 +58,6 @@
84 ); 58 );
85 runOnlyForDeploymentPostprocessing = 0; 59 runOnlyForDeploymentPostprocessing = 0;
86 }; 60 };
87 8BF124FD2F70000100933221 /* Frameworks */ = {
88 isa = PBXFrameworksBuildPhase;
89 buildActionMask = 2147483647;
90 files = (
91 );
92 runOnlyForDeploymentPostprocessing = 0;
93 };
94/* End PBXFrameworksBuildPhase section */ 61/* End PBXFrameworksBuildPhase section */
95 62
96/* Begin PBXGroup section */ 63/* Begin PBXGroup section */
@@ -103,7 +70,6 @@
103 8BBFEF042F9874AE00E8E144 /* DomainReportBuilder.swift */, 70 8BBFEF042F9874AE00E8E144 /* DomainReportBuilder.swift */,
104 8BBFEF032F9874AE00E8E144 /* DomainInspectionService.swift */, 71 8BBFEF032F9874AE00E8E144 /* DomainInspectionService.swift */,
105 8BBFEF052F9874AE00E8E144 /* DomainReportExporter.swift */, 72 8BBFEF052F9874AE00E8E144 /* DomainReportExporter.swift */,
106 8BBFEF022F9874AE00E8E144 /* DomainDigCLI.swift */,
107 8BF9DA842F9B13FB00EF41D5 /* DomainDataPortabilityService.swift */, 73 8BF9DA842F9B13FB00EF41D5 /* DomainDataPortabilityService.swift */,
108 8BCA3CBC2F9C8D57004B742C /* LocalAPIModels.swift */, 74 8BCA3CBC2F9C8D57004B742C /* LocalAPIModels.swift */,
109 8BCA3CBD2F9C8D57004B742C /* LocalAPIService.swift */, 75 8BCA3CBD2F9C8D57004B742C /* LocalAPIService.swift */,
@@ -114,7 +80,6 @@
114 isa = PBXGroup; 80 isa = PBXGroup;
115 children = ( 81 children = (
116 8B7800692F6090E300933221 /* DomainDig.app */, 82 8B7800692F6090E300933221 /* DomainDig.app */,
117 8BF124F92F70000100933221 /* domaindig */,
118 ); 83 );
119 name = Products; 84 name = Products;
120 sourceTree = "<group>"; 85 sourceTree = "<group>";
@@ -144,27 +109,6 @@
144 productReference = 8B7800692F6090E300933221 /* DomainDig.app */; 109 productReference = 8B7800692F6090E300933221 /* DomainDig.app */;
145 productType = "com.apple.product-type.application"; 110 productType = "com.apple.product-type.application";
146 }; 111 };
147 8BF124FB2F70000100933221 /* DomainDigCLI */ = {
148 isa = PBXNativeTarget;
149 buildConfigurationList = 8BBFF0292F987EF700E8E144 /* Build configuration list for PBXNativeTarget "DomainDigCLI" */;
150 buildPhases = (
151 8BF124FC2F70000100933221 /* Sources */,
152 8BF124FD2F70000100933221 /* Frameworks */,
153 );
154 buildRules = (
155 );
156 dependencies = (
157 );
158 fileSystemSynchronizedGroups = (
159 8B78006B2F6090E300933221 /* DomainDig */,
160 );
161 name = DomainDigCLI;
162 packageProductDependencies = (
163 );
164 productName = domaindig;
165 productReference = 8BF124F92F70000100933221 /* domaindig */;
166 productType = "com.apple.product-type.tool";
167 };
168/* End PBXNativeTarget section */ 112/* End PBXNativeTarget section */
169 113
170/* Begin PBXProject section */ 114/* Begin PBXProject section */
@@ -200,7 +144,6 @@
200 projectRoot = ""; 144 projectRoot = "";
201 targets = ( 145 targets = (
202 8B7800682F6090E300933221 /* DomainDig */, 146 8B7800682F6090E300933221 /* DomainDig */,
203 8BF124FB2F70000100933221 /* DomainDigCLI */,
204 ); 147 );
205 }; 148 };
206/* End PBXProject section */ 149/* End PBXProject section */
@@ -230,21 +173,6 @@
230 ); 173 );
231 runOnlyForDeploymentPostprocessing = 0; 174 runOnlyForDeploymentPostprocessing = 0;
232 }; 175 };
233 8BF124FC2F70000100933221 /* Sources */ = {
234 isa = PBXSourcesBuildPhase;
235 buildActionMask = 2147483647;
236 files = (
237 8BBFEF082F9874AE00E8E144 /* DomainDigCLI.swift in Sources */,
238 8BF9DA862F9B13FB00EF41D5 /* DomainDataPortabilityService.swift in Sources */,
239 8BBFEFCA2F987E8700E8E144 /* DomainInspectionService.swift in Sources */,
240 8BBFEFCB2F987E8700E8E144 /* DomainReportBuilder.swift in Sources */,
241 8BBFEFCC2F987E8700E8E144 /* DomainReportExporter.swift in Sources */,
242 8BBFEFCD2F987E8700E8E144 /* LookupSnapshot.swift in Sources */,
243 8BCA3CBE2F9C8D57004B742C /* LocalAPIModels.swift in Sources */,
244 8BCA3CBF2F9C8D57004B742C /* LocalAPIService.swift in Sources */,
245 );
246 runOnlyForDeploymentPostprocessing = 0;
247 };
248/* End PBXSourcesBuildPhase section */ 176/* End PBXSourcesBuildPhase section */
249 177
250/* Begin XCBuildConfiguration section */ 178/* Begin XCBuildConfiguration section */
@@ -378,7 +306,7 @@
378 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 306 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
379 CODE_SIGN_ENTITLEMENTS = DomainDig/DomainDig.entitlements; 307 CODE_SIGN_ENTITLEMENTS = DomainDig/DomainDig.entitlements;
380 CODE_SIGN_STYLE = Automatic; 308 CODE_SIGN_STYLE = Automatic;
381 CURRENT_PROJECT_VERSION = 35; 309 CURRENT_PROJECT_VERSION = 36;
382 DEVELOPMENT_TEAM = ZCNAX3VL9D; 310 DEVELOPMENT_TEAM = ZCNAX3VL9D;
383 ENABLE_PREVIEWS = YES; 311 ENABLE_PREVIEWS = YES;
384 GENERATE_INFOPLIST_FILE = YES; 312 GENERATE_INFOPLIST_FILE = YES;
@@ -395,7 +323,7 @@
395 "$(inherited)", 323 "$(inherited)",
396 "@executable_path/Frameworks", 324 "@executable_path/Frameworks",
397 ); 325 );
398 MARKETING_VERSION = 4.3.0; 326 MARKETING_VERSION = 4.4.1;
399 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig; 327 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig;
400 PRODUCT_NAME = "$(TARGET_NAME)"; 328 PRODUCT_NAME = "$(TARGET_NAME)";
401 STRING_CATALOG_GENERATE_SYMBOLS = YES; 329 STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -415,7 +343,7 @@
415 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 343 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
416 CODE_SIGN_ENTITLEMENTS = DomainDig/DomainDig.entitlements; 344 CODE_SIGN_ENTITLEMENTS = DomainDig/DomainDig.entitlements;
417 CODE_SIGN_STYLE = Automatic; 345 CODE_SIGN_STYLE = Automatic;
418 CURRENT_PROJECT_VERSION = 35; 346 CURRENT_PROJECT_VERSION = 36;
419 DEVELOPMENT_TEAM = ZCNAX3VL9D; 347 DEVELOPMENT_TEAM = ZCNAX3VL9D;
420 ENABLE_PREVIEWS = YES; 348 ENABLE_PREVIEWS = YES;
421 GENERATE_INFOPLIST_FILE = YES; 349 GENERATE_INFOPLIST_FILE = YES;
@@ -432,7 +360,7 @@
432 "$(inherited)", 360 "$(inherited)",
433 "@executable_path/Frameworks", 361 "@executable_path/Frameworks",
434 ); 362 );
435 MARKETING_VERSION = 4.3.0; 363 MARKETING_VERSION = 4.4.1;
436 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig; 364 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig;
437 PRODUCT_NAME = "$(TARGET_NAME)"; 365 PRODUCT_NAME = "$(TARGET_NAME)";
438 STRING_CATALOG_GENERATE_SYMBOLS = YES; 366 STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -445,27 +373,6 @@
445 }; 373 };
446 name = Release; 374 name = Release;
447 }; 375 };
448 8BBFF0272F987E8700E8E144 /* Debug */ = {
449 isa = XCBuildConfiguration;
450 buildSettings = {
451 COPY_PHASE_STRIP = NO;
452 GCC_DYNAMIC_NO_PIC = NO;
453 GCC_OPTIMIZATION_LEVEL = 0;
454 IPHONEOS_DEPLOYMENT_TARGET = 17.6;
455 PRODUCT_NAME = domaindig;
456 };
457 name = Debug;
458 };
459 8BBFF0282F987E8700E8E144 /* Release */ = {
460 isa = XCBuildConfiguration;
461 buildSettings = {
462 COPY_PHASE_STRIP = YES;
463 DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
464 IPHONEOS_DEPLOYMENT_TARGET = 17.6;
465 PRODUCT_NAME = domaindig;
466 };
467 name = Release;
468 };
469/* End XCBuildConfiguration section */ 376/* End XCBuildConfiguration section */
470 377
471/* Begin XCConfigurationList section */ 378/* Begin XCConfigurationList section */
@@ -487,15 +394,6 @@
487 defaultConfigurationIsVisible = 0; 394 defaultConfigurationIsVisible = 0;
488 defaultConfigurationName = Release; 395 defaultConfigurationName = Release;
489 }; 396 };
490 8BBFF0292F987EF700E8E144 /* Build configuration list for PBXNativeTarget "DomainDigCLI" */ = {
491 isa = XCConfigurationList;
492 buildConfigurations = (
493 8BBFF0272F987E8700E8E144 /* Debug */,
494 8BBFF0282F987E8700E8E144 /* Release */,
495 );
496 defaultConfigurationIsVisible = 0;
497 defaultConfigurationName = Release;
498 };
499/* End XCConfigurationList section */ 397/* End XCConfigurationList section */
500 }; 398 };
501 rootObject = 8B7800612F6090E300933221 /* Project object */; 399 rootObject = 8B7800612F6090E300933221 /* Project object */;
DomainDig.xcodeproj/xcuserdata/cmc.xcuserdatad/xcschemes/xcschememanagement.plist −5
@@ -9,11 +9,6 @@
9 <key>orderHint</key> 9 <key>orderHint</key>
10 <integer>0</integer> 10 <integer>0</integer>
11 </dict> 11 </dict>
12 <key>DomainDigCLI.xcscheme_^#shared#^_</key>
13 <dict>
14 <key>orderHint</key>
15 <integer>1</integer>
16 </dict>
17 </dict> 12 </dict>
18 <key>SuppressBuildableAutocreation</key> 13 <key>SuppressBuildableAutocreation</key>
19 <dict> 14 <dict>
DomainDig/AppVersion.swift +1 −1
@@ -2,6 +2,6 @@ import Foundation
2 2
3enum AppVersion { 3enum AppVersion {
4 nonisolated static var current: String { 4 nonisolated static var current: String {
5 "3.5.0" 5 "4.4.1"
6 } 6 }
7} 7}
DomainDig/ContentView.swift +24
@@ -44,6 +44,8 @@ struct ContentView: View {
44 @State private var showingCurrentDomainWorkflowSheet = false 44 @State private var showingCurrentDomainWorkflowSheet = false
45 @State private var showingBatchWorkflowSheet = false 45 @State private var showingBatchWorkflowSheet = false
46 @State private var showingTimeline = false 46 @State private var showingTimeline = false
47 @State private var showingAuditTimeline = false
48 @State private var auditStartInFlight = false
47 49
48 var body: some View { 50 var body: some View {
49 let _ = purchaseService.currentTier 51 let _ = purchaseService.currentTier
@@ -274,6 +276,11 @@ struct ContentView: View {
274 TimelineView(viewModel: viewModel, domain: viewModel.searchedDomain) 276 TimelineView(viewModel: viewModel, domain: viewModel.searchedDomain)
275 } 277 }
276 } 278 }
279 .sheet(isPresented: $showingAuditTimeline) {
280 NavigationStack {
281 AuditDomainTimelineView(viewModel: viewModel, domain: viewModel.searchedDomain)
282 }
283 }
277 } 284 }
278 285
279 private var manualBatchSummaryBinding: Binding<BatchSweepSummary?> { 286 private var manualBatchSummaryBinding: Binding<BatchSweepSummary?> {
@@ -508,6 +515,21 @@ struct ContentView: View {
508 Button("Add to workflow") { 515 Button("Add to workflow") {
509 showingCurrentDomainWorkflowSheet = true 516 showingCurrentDomainWorkflowSheet = true
510 } 517 }
518 Button(auditStartInFlight ? "Starting audit…" : "Start audit") {
519 Task {
520 auditStartInFlight = true
521 if await viewModel.startAudit(for: viewModel.searchedDomain) != nil {
522 showingAuditTimeline = true
523 }
524 auditStartInFlight = false
525 }
526 }
527 .disabled(auditStartInFlight)
528 if !viewModel.audits(for: viewModel.searchedDomain).isEmpty {
529 Button("View audits") {
530 showingAuditTimeline = true
531 }
532 }
511 if !viewModel.historyEntries(for: viewModel.searchedDomain).isEmpty { 533 if !viewModel.historyEntries(for: viewModel.searchedDomain).isEmpty {
512 Button("Open timeline") { 534 Button("Open timeline") {
513 showingTimeline = true 535 showingTimeline = true
@@ -3301,6 +3323,7 @@ private struct DataPortabilitySettingsView: View {
3301 Section("Local Data") { 3323 Section("Local Data") {
3302 LabeledContent("Tracked Domains", value: "\(viewModel.dataLifecycleSummary.trackedDomains)") 3324 LabeledContent("Tracked Domains", value: "\(viewModel.dataLifecycleSummary.trackedDomains)")
3303 LabeledContent("History Snapshots", value: "\(viewModel.dataLifecycleSummary.historySnapshots)") 3325 LabeledContent("History Snapshots", value: "\(viewModel.dataLifecycleSummary.historySnapshots)")
3326 LabeledContent("Audit Sessions", value: "\(viewModel.dataLifecycleSummary.auditSessions)")
3304 LabeledContent("Workflows", value: "\(viewModel.dataLifecycleSummary.workflows)") 3327 LabeledContent("Workflows", value: "\(viewModel.dataLifecycleSummary.workflows)")
3305 LabeledContent("Cached Items", value: "\(viewModel.dataLifecycleSummary.cachedItems)") 3328 LabeledContent("Cached Items", value: "\(viewModel.dataLifecycleSummary.cachedItems)")
3306 LabeledContent("Monitoring Logs", value: "\(viewModel.dataLifecycleSummary.monitoringLogs)") 3329 LabeledContent("Monitoring Logs", value: "\(viewModel.dataLifecycleSummary.monitoringLogs)")
@@ -3702,6 +3725,7 @@ private struct DataImportPreviewSheet: View {
3702 Section("Projected Counts") { 3725 Section("Projected Counts") {
3703 LabeledContent("Tracked Domains", value: "\(preview.projectedCounts.trackedDomains)") 3726 LabeledContent("Tracked Domains", value: "\(preview.projectedCounts.trackedDomains)")
3704 LabeledContent("History Snapshots", value: "\(preview.projectedCounts.historySnapshots)") 3727 LabeledContent("History Snapshots", value: "\(preview.projectedCounts.historySnapshots)")
3728 LabeledContent("Audit Sessions", value: "\(preview.projectedCounts.auditSessions)")
3705 LabeledContent("Workflows", value: "\(preview.projectedCounts.workflows)") 3729 LabeledContent("Workflows", value: "\(preview.projectedCounts.workflows)")
3706 LabeledContent("Cached Items", value: "\(preview.projectedCounts.cachedItems)") 3730 LabeledContent("Cached Items", value: "\(preview.projectedCounts.cachedItems)")
3707 LabeledContent("Monitoring Logs", value: "\(preview.projectedCounts.monitoringLogs)") 3731 LabeledContent("Monitoring Logs", value: "\(preview.projectedCounts.monitoringLogs)")
DomainDig/DomainDig/AuditExporter.swift added +157
@@ -0,0 +1,157 @@
1import Foundation
2
3#if canImport(UIKit)
4import UIKit
5#endif
6
7enum AuditExportFormat: String, CaseIterable, Identifiable {
8 case pdf
9 case json
10 case markdown
11
12 var id: String { rawValue }
13
14 var fileExtension: String { rawValue == "markdown" ? "md" : rawValue }
15}
16
17enum AuditExporter {
18 static func data(for session: AuditSession, format: AuditExportFormat) throws -> Data {
19 switch format {
20 case .json:
21 let encoder = JSONEncoder()
22 encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
23 encoder.dateEncodingStrategy = .iso8601
24 return try encoder.encode(session)
25 case .markdown:
26 return Data(markdown(for: session).utf8)
27 case .pdf:
28 return try pdfData(for: session)
29 }
30 }
31
32 static func markdown(for session: AuditSession) -> String {
33 var lines: [String] = [
34 "# DomainDig Audit",
35 "",
36 "- Domain: \(session.domain)",
37 "- Review Date: \(session.createdAt.formatted(date: .abbreviated, time: .shortened))",
38 "- Reviewer: \(session.reviewer)",
39 "- Status: \(session.status.title)",
40 "- Evidence Captured: \(session.evidence.capturedAt.formatted(date: .abbreviated, time: .shortened))",
41 "- Checklist Progress: \(session.completedChecklistCount)/\(session.checklist.count)",
42 ""
43 ]
44
45 if !session.notes.isEmpty {
46 lines.append("## Summary Notes")
47 lines.append(session.notes)
48 lines.append("")
49 }
50
51 lines.append("## Findings Summary")
52 if session.findings.isEmpty {
53 lines.append("No findings recorded.")
54 } else {
55 for finding in session.findings {
56 lines.append("- [\(finding.severity.title)] \(finding.title) (\(finding.status.title))")
57 }
58 }
59 lines.append("")
60
61 lines.append("## Key Risks")
62 if session.keyRisks.isEmpty {
63 lines.append("No medium or high-severity risks recorded.")
64 } else {
65 for finding in session.keyRisks {
66 lines.append("- \(finding.title): \(finding.summary)")
67 }
68 }
69 lines.append("")
70
71 lines.append("## Checklist")
72 for item in session.checklist {
73 lines.append("- [\(item.isComplete ? "x" : " ")] \(item.title): \(item.detail)")
74 }
75 lines.append("")
76
77 lines.append("## Findings")
78 if session.findings.isEmpty {
79 lines.append("No findings recorded.")
80 } else {
81 for finding in session.findings {
82 lines.append("### \(finding.title)")
83 lines.append("- Severity: \(finding.severity.title)")
84 lines.append("- Status: \(finding.status.title)")
85 if !finding.checklistAreas.isEmpty {
86 lines.append("- Checklist Areas: \(finding.checklistAreas.map(\.title).joined(separator: ", "))")
87 }
88 lines.append("- Summary: \(finding.summary)")
89 if !finding.evidenceReferences.isEmpty {
90 lines.append("- Evidence: \(finding.evidenceReferences.joined(separator: " | "))")
91 }
92 if !finding.notes.isEmpty {
93 lines.append("- Notes: \(finding.notes)")
94 }
95 lines.append("")
96 }
97 }
98
99 lines.append("## Evidence Snapshot")
100 lines.append("- Availability: \(availabilityTitle(session.evidence.report.availability))")
101 lines.append("- Risk Score: \(session.evidence.report.riskAssessment.score) (\(session.evidence.report.riskAssessment.level.title))")
102 lines.append("- TLS Status: \(session.evidence.report.web.tlsStatus)")
103 lines.append("- Final URL: \(session.evidence.report.web.finalURL ?? "Unavailable")")
104 lines.append("- Primary IP: \(session.evidence.report.dns.primaryIP ?? "Unavailable")")
105 lines.append("- Reachability: \(session.evidence.report.network.reachabilitySummary)")
106 lines.append("- Ownership: \(session.evidence.report.ownership?.registrar ?? "Unavailable")")
107 lines.append("- Historical Context Snapshots: \(session.evidence.historicalContext.count)")
108
109 return lines.joined(separator: "\n")
110 }
111
112 private static func availabilityTitle(_ status: DomainAvailabilityStatus) -> String {
113 switch status {
114 case .available:
115 return "Available"
116 case .registered:
117 return "Registered"
118 case .unknown:
119 return "Unknown"
120 }
121 }
122
123 private static func pdfData(for session: AuditSession) throws -> Data {
124 let markdown = markdown(for: session)
125 #if canImport(UIKit)
126 let renderer = UIGraphicsPDFRenderer(bounds: CGRect(x: 0, y: 0, width: 612, height: 792))
127 return renderer.pdfData { context in
128 let lines = markdown.components(separatedBy: .newlines)
129 let paragraphStyle = NSMutableParagraphStyle()
130 paragraphStyle.lineBreakMode = .byWordWrapping
131 let attributes: [NSAttributedString.Key: Any] = [
132 .font: UIFont.monospacedSystemFont(ofSize: 11, weight: .regular),
133 .paragraphStyle: paragraphStyle
134 ]
135
136 var yOffset: CGFloat = 36
137 context.beginPage()
138
139 for line in lines {
140 if yOffset > 744 {
141 context.beginPage()
142 yOffset = 36
143 }
144
145 let renderedLine = NSString(string: line.isEmpty ? " " : line)
146 renderedLine.draw(
147 in: CGRect(x: 36, y: yOffset, width: 540, height: 22),
148 withAttributes: attributes
149 )
150 yOffset += 16
151 }
152 }
153 #else
154 return Data(markdown.utf8)
155 #endif
156 }
157}
DomainDig/DomainDig/AuditModels.swift added +279
@@ -0,0 +1,279 @@
1import Foundation
2
3enum AuditStatus: String, Codable, Sendable, CaseIterable, Identifiable {
4 case draft
5 case inReview = "in_review"
6 case complete
7
8 var id: String { rawValue }
9
10 var title: String {
11 switch self {
12 case .draft:
13 return "Draft"
14 case .inReview:
15 return "In Review"
16 case .complete:
17 return "Complete"
18 }
19 }
20}
21
22enum AuditFindingSeverity: String, Codable, Sendable, CaseIterable, Identifiable {
23 case informational
24 case low
25 case medium
26 case high
27
28 var id: String { rawValue }
29
30 var title: String { rawValue.capitalized }
31}
32
33enum AuditFindingStatus: String, Codable, Sendable, CaseIterable, Identifiable {
34 case open
35 case reviewing
36 case resolved
37
38 var id: String { rawValue }
39
40 var title: String { rawValue.capitalized }
41}
42
43enum AuditChecklistArea: String, Codable, Sendable, CaseIterable, Identifiable {
44 case dnsReview
45 case certificateReview
46 case redirectReview
47 case headerReview
48 case ownershipReview
49 case infrastructureReview
50 case monitoringHistoryReview
51
52 var id: String { rawValue }
53
54 var title: String {
55 switch self {
56 case .dnsReview:
57 return "DNS Review"
58 case .certificateReview:
59 return "Certificate Review"
60 case .redirectReview:
61 return "Redirect Review"
62 case .headerReview:
63 return "Header Review"
64 case .ownershipReview:
65 return "Ownership Review"
66 case .infrastructureReview:
67 return "Infrastructure Review"
68 case .monitoringHistoryReview:
69 return "Monitoring History Review"
70 }
71 }
72
73 var prompt: String {
74 switch self {
75 case .dnsReview:
76 return "Validate authoritative records, nameservers, and DNSSEC posture."
77 case .certificateReview:
78 return "Review certificate validity, expiry, issuer, and transport security."
79 case .redirectReview:
80 return "Confirm redirect targets, hop count, and protocol transitions."
81 case .headerReview:
82 return "Check security headers and HTTP response posture."
83 case .ownershipReview:
84 return "Review registration, registrar, and ownership evidence."
85 case .infrastructureReview:
86 return "Assess reachability, exposed services, and network context."
87 case .monitoringHistoryReview:
88 return "Compare this audit with prior snapshots and repeated issues."
89 }
90 }
91}
92
93struct AuditChecklistItem: Identifiable, Codable, Sendable, Equatable {
94 let id: UUID
95 let area: AuditChecklistArea
96 var title: String
97 var detail: String
98 var isComplete: Bool
99 var completedAt: Date?
100
101 init(
102 id: UUID = UUID(),
103 area: AuditChecklistArea,
104 title: String,
105 detail: String,
106 isComplete: Bool = false,
107 completedAt: Date? = nil
108 ) {
109 self.id = id
110 self.area = area
111 self.title = title
112 self.detail = detail
113 self.isComplete = isComplete
114 self.completedAt = completedAt
115 }
116}
117
118enum AuditEvidenceAssetKind: String, Codable, Sendable {
119 case screenshot
120 case document
121 case note
122}
123
124struct AuditEvidenceAsset: Identifiable, Codable, Sendable, Equatable {
125 let id: UUID
126 var title: String
127 var kind: AuditEvidenceAssetKind
128 var reference: String
129
130 init(id: UUID = UUID(), title: String, kind: AuditEvidenceAssetKind, reference: String) {
131 self.id = id
132 self.title = title
133 self.kind = kind
134 self.reference = reference
135 }
136}
137
138struct AuditEvidenceSnapshot: Codable {
139 var capturedAt: Date
140 var lookup: HistoryEntry
141 var report: DomainReport
142 var historicalContext: [SnapshotSummary]
143 var screenshots: [AuditEvidenceAsset]
144}
145
146struct AuditFinding: Identifiable, Codable, Sendable, Equatable {
147 var id: UUID
148 var title: String
149 var severity: AuditFindingSeverity
150 var summary: String
151 var evidenceReferences: [String]
152 var notes: String
153 var status: AuditFindingStatus
154 var checklistAreas: [AuditChecklistArea]
155 var createdAt: Date
156 var updatedAt: Date
157
158 init(
159 id: UUID = UUID(),
160 title: String,
161 severity: AuditFindingSeverity,
162 summary: String,
163 evidenceReferences: [String],
164 notes: String,
165 status: AuditFindingStatus = .open,
166 checklistAreas: [AuditChecklistArea] = [],
167 createdAt: Date = Date(),
168 updatedAt: Date = Date()
169 ) {
170 self.id = id
171 self.title = title
172 self.severity = severity
173 self.summary = summary
174 self.evidenceReferences = evidenceReferences
175 self.notes = notes
176 self.status = status
177 self.checklistAreas = checklistAreas
178 self.createdAt = createdAt
179 self.updatedAt = updatedAt
180 }
181}
182
183struct AuditSession: Identifiable, Codable {
184 var id: UUID
185 var domain: String
186 var createdAt: Date
187 var reviewer: String
188 var status: AuditStatus
189 var evidence: AuditEvidenceSnapshot
190 var findings: [AuditFinding]
191 var notes: String
192 var checklist: [AuditChecklistItem]
193
194 init(
195 id: UUID = UUID(),
196 domain: String,
197 createdAt: Date = Date(),
198 reviewer: String,
199 status: AuditStatus,
200 evidence: AuditEvidenceSnapshot,
201 findings: [AuditFinding] = [],
202 notes: String = "",
203 checklist: [AuditChecklistItem] = AuditChecklistArea.defaultItems
204 ) {
205 self.id = id
206 self.domain = domain
207 self.createdAt = createdAt
208 self.reviewer = reviewer
209 self.status = status
210 self.evidence = evidence
211 self.findings = findings
212 self.notes = notes
213 self.checklist = checklist
214 }
215
216 var completedChecklistCount: Int {
217 checklist.filter(\.isComplete).count
218 }
219
220 var checklistProgress: Double {
221 guard !checklist.isEmpty else { return 0 }
222 return Double(completedChecklistCount) / Double(checklist.count)
223 }
224
225 var highestSeverity: AuditFindingSeverity? {
226 findings.max { lhs, rhs in
227 lhs.severity.sortOrder < rhs.severity.sortOrder
228 }?.severity
229 }
230
231 var keyRisks: [AuditFinding] {
232 findings
233 .filter { $0.severity == .high || $0.severity == .medium }
234 .sorted { lhs, rhs in
235 if lhs.severity.sortOrder == rhs.severity.sortOrder {
236 return lhs.updatedAt > rhs.updatedAt
237 }
238 return lhs.severity.sortOrder > rhs.severity.sortOrder
239 }
240 }
241}
242
243extension AuditFindingSeverity {
244 var sortOrder: Int {
245 switch self {
246 case .informational:
247 return 0
248 case .low:
249 return 1
250 case .medium:
251 return 2
252 case .high:
253 return 3
254 }
255 }
256}
257
258extension AuditChecklistArea {
259 static var defaultItems: [AuditChecklistItem] {
260 allCases.map { area in
261 AuditChecklistItem(
262 area: area,
263 title: area.title,
264 detail: area.prompt
265 )
266 }
267 }
268}
269
270struct AuditTimelinePoint: Identifiable, Equatable {
271 let id: UUID
272 let sessionID: UUID
273 let domain: String
274 let createdAt: Date
275 let status: AuditStatus
276 let findingCount: Int
277 let openHighSeverityCount: Int
278 let repeatedIssueCount: Int
279}
DomainDig/DomainDig/AuditViews.swift added +571
@@ -0,0 +1,571 @@
1import SwiftUI
2
3struct AuditListView: View {
4 @Environment(\.appDensity) private var appDensity
5 @Bindable var viewModel: DomainViewModel
6 @State private var auditStartInFlight = false
7
8 private var groupedSessions: [(domain: String, sessions: [AuditSession])] {
9 Dictionary(grouping: viewModel.auditSessions) { $0.domain.lowercased() }
10 .values
11 .map { sessions in
12 let sorted = sessions.sorted { $0.createdAt > $1.createdAt }
13 return (domain: sorted.first?.domain ?? "unknown", sessions: sorted)
14 }
15 .sorted { $0.domain < $1.domain }
16 }
17
18 var body: some View {
19 List {
20 if groupedSessions.isEmpty {
21 Section {
22 EmptyStateCardView(
23 title: "No Audits Yet",
24 message: "Audit Mode captures evidence, findings, checklist progress, and point-in-time review output for each domain assessment.",
25 suggestion: "Open Inspect for a domain and start an audit to create your first review session.",
26 systemImage: "checklist.unchecked",
27 showsCardBackground: false
28 )
29 }
30 .listRowBackground(Color(.systemGray6).opacity(0.5))
31 } else {
32 Section("Audit Domains") {
33 ForEach(groupedSessions, id: \.domain) { group in
34 NavigationLink {
35 AuditDomainTimelineView(viewModel: viewModel, domain: group.domain)
36 } label: {
37 VStack(alignment: .leading, spacing: appDensity.metrics.rowSpacing + 2) {
38 HStack {
39 Text(group.domain)
40 .font(appDensity.font(.callout, design: .default, weight: .semibold))
41 Spacer()
42 Text("\(group.sessions.count) audit\(group.sessions.count == 1 ? "" : "s")")
43 .font(appDensity.font(.caption2))
44 .foregroundStyle(.secondary)
45 }
46
47 if let latest = group.sessions.first {
48 Text(latest.findings.isEmpty ? "No findings recorded yet" : latest.findings.map(\.title).prefix(2).joined(separator: " • "))
49 .font(appDensity.font(.caption))
50 .foregroundStyle(.secondary)
51 .lineLimit(2)
52
53 HStack(spacing: 8) {
54 auditStatusBadge(latest.status)
55 if let severity = latest.highestSeverity {
56 findingSeverityBadge(severity)
57 }
58 Text("Checklist \(latest.completedChecklistCount)/\(latest.checklist.count)")
59 .font(appDensity.font(.caption2))
60 .foregroundStyle(.secondary)
61 }
62 }
63 }
64 .padding(.vertical, 4)
65 }
66 }
67 }
68 .listRowBackground(Color(.systemGray6).opacity(0.5))
69 }
70 }
71 .scrollContentBackground(.hidden)
72 .background(Color.black)
73 .navigationTitle("Audit Mode")
74 .toolbar {
75 if !viewModel.searchedDomain.isEmpty {
76 ToolbarItem(placement: .topBarTrailing) {
77 Button(auditStartInFlight ? "Auditing…" : "Start Audit") {
78 Task {
79 auditStartInFlight = true
80 _ = await viewModel.startAudit(for: viewModel.searchedDomain)
81 auditStartInFlight = false
82 }
83 }
84 .disabled(auditStartInFlight)
85 }
86 }
87 }
88 .preferredColorScheme(.dark)
89 }
90}
91
92struct AuditDomainTimelineView: View {
93 @Environment(\.appDensity) private var appDensity
94 @Bindable var viewModel: DomainViewModel
95 let domain: String
96 @State private var auditStartInFlight = false
97
98 private var sessions: [AuditSession] {
99 viewModel.audits(for: domain)
100 }
101
102 var body: some View {
103 List {
104 Section("Sessions") {
105 ForEach(sessions) { session in
106 NavigationLink {
107 AuditSessionDetailView(viewModel: viewModel, sessionID: session.id)
108 } label: {
109 VStack(alignment: .leading, spacing: appDensity.metrics.rowSpacing + 2) {
110 HStack {
111 Text(session.createdAt.formatted(date: .abbreviated, time: .shortened))
112 .font(appDensity.font(.callout))
113 Spacer()
114 auditStatusBadge(session.status)
115 }
116
117 Text("Reviewer: \(session.reviewer)")
118 .font(appDensity.font(.caption))
119 .foregroundStyle(.secondary)
120
121 HStack(spacing: 8) {
122 Text("\(session.findings.count) findings")
123 Text("Checklist \(session.completedChecklistCount)/\(session.checklist.count)")
124 if let severity = session.highestSeverity {
125 Text("Top \(severity.title)")
126 }
127 }
128 .font(appDensity.font(.caption2))
129 .foregroundStyle(.secondary)
130 }
131 .padding(.vertical, 4)
132 }
133 }
134 }
135 .listRowBackground(Color(.systemGray6).opacity(0.5))
136
137 Section("Trend") {
138 ForEach(viewModel.auditTimeline(for: domain)) { point in
139 VStack(alignment: .leading, spacing: 4) {
140 HStack {
141 Text(point.createdAt.formatted(date: .abbreviated, time: .shortened))
142 .font(appDensity.font(.caption, weight: .semibold))
143 Spacer()
144 Text("\(point.findingCount) findings")
145 .font(appDensity.font(.caption2))
146 .foregroundStyle(.secondary)
147 }
148 Text("Open high severity: \(point.openHighSeverityCount) • Repeated issues: \(point.repeatedIssueCount)")
149 .font(appDensity.font(.caption2))
150 .foregroundStyle(.secondary)
151 }
152 .padding(.vertical, 2)
153 }
154 }
155 .listRowBackground(Color(.systemGray6).opacity(0.5))
156 }
157 .scrollContentBackground(.hidden)
158 .background(Color.black)
159 .navigationTitle(domain)
160 .toolbar {
161 ToolbarItem(placement: .topBarTrailing) {
162 Button(auditStartInFlight ? "Auditing…" : "New Audit") {
163 Task {
164 auditStartInFlight = true
165 _ = await viewModel.startAudit(for: domain)
166 auditStartInFlight = false
167 }
168 }
169 .disabled(auditStartInFlight)
170 }
171 }
172 }
173}
174
175struct AuditSessionDetailView: View {
176 @Environment(\.appDensity) private var appDensity
177 @Bindable var viewModel: DomainViewModel
178 let sessionID: UUID
179
180 @State private var notesDraft = ""
181 @State private var showingFindingEditor = false
182 @State private var editingFinding: AuditFinding?
183
184 private var session: AuditSession? {
185 viewModel.auditSession(withID: sessionID)
186 }
187
188 var body: some View {
189 Group {
190 if let session {
191 List {
192 Section {
193 VStack(alignment: .leading, spacing: appDensity.metrics.cardSpacing) {
194 HStack {
195 VStack(alignment: .leading, spacing: 4) {
196 Text(session.domain)
197 .font(appDensity.font(.headline, design: .default, weight: .semibold))
198 Text("Reviewer: \(session.reviewer)")
199 .font(appDensity.font(.caption))
200 .foregroundStyle(.secondary)
201 }
202 Spacer()
203 auditStatusBadge(session.status)
204 }
205
206 Picker("Status", selection: Binding(
207 get: { session.status },
208 set: { viewModel.updateAuditStatus($0, sessionID: session.id) }
209 )) {
210 ForEach(AuditStatus.allCases) { status in
211 Text(status.title).tag(status)
212 }
213 }
214 .pickerStyle(.segmented)
215
216 Text("Captured \(session.evidence.capturedAt.formatted(date: .abbreviated, time: .shortened))")
217 .font(appDensity.font(.caption2))
218 .foregroundStyle(.secondary)
219 }
220 }
221 .listRowBackground(Color(.systemGray6).opacity(0.5))
222
223 Section("Audit Summary") {
224 LabeledContent("Findings", value: "\(session.findings.count)")
225 LabeledContent("Checklist", value: "\(session.completedChecklistCount)/\(session.checklist.count)")
226 LabeledContent("Risk Score", value: "\(session.evidence.report.riskAssessment.score)")
227 if let severity = session.highestSeverity {
228 LabeledContent("Highest Severity", value: severity.title)
229 }
230 }
231 .listRowBackground(Color(.systemGray6).opacity(0.5))
232
233 Section("Reviewer Notes") {
234 TextEditor(text: $notesDraft)
235 .frame(minHeight: 120)
236 .scrollContentBackground(.hidden)
237 .background(Color.clear)
238
239 Button("Save Notes") {
240 viewModel.updateAuditNotes(notesDraft, sessionID: session.id)
241 }
242 }
243 .listRowBackground(Color(.systemGray6).opacity(0.5))
244
245 Section("Checklist") {
246 ForEach(session.checklist) { item in
247 Button {
248 viewModel.toggleAuditChecklistItem(sessionID: session.id, itemID: item.id)
249 } label: {
250 HStack(alignment: .top, spacing: 10) {
251 Image(systemName: item.isComplete ? "checkmark.circle.fill" : "circle")
252 .foregroundStyle(item.isComplete ? Color.green : .secondary)
253 VStack(alignment: .leading, spacing: 4) {
254 Text(item.title)
255 .font(appDensity.font(.callout))
256 .foregroundStyle(.primary)
257 Text(item.detail)
258 .font(appDensity.font(.caption))
259 .foregroundStyle(.secondary)
260 }
261 Spacer()
262 }
263 }
264 .buttonStyle(.plain)
265 }
266 }
267 .listRowBackground(Color(.systemGray6).opacity(0.5))
268
269 Section("Findings") {
270 if session.findings.isEmpty {
271 Text("No findings recorded.")
272 .font(appDensity.font(.caption))
273 .foregroundStyle(.secondary)
274 } else {
275 ForEach(session.findings) { finding in
276 Button {
277 editingFinding = finding
278 } label: {
279 VStack(alignment: .leading, spacing: 6) {
280 HStack {
281 Text(finding.title)
282 .font(appDensity.font(.callout, design: .default, weight: .semibold))
283 .foregroundStyle(.primary)
284 Spacer()
285 findingSeverityBadge(finding.severity)
286 }
287 Text(finding.summary)
288 .font(appDensity.font(.caption))
289 .foregroundStyle(.secondary)
290 .lineLimit(2)
291 Text("\(finding.status.title) • \(finding.evidenceReferences.count) evidence refs")
292 .font(appDensity.font(.caption2))
293 .foregroundStyle(.secondary)
294 }
295 .padding(.vertical, 2)
296 }
297 .buttonStyle(.plain)
298 }
299 .onDelete { offsets in
300 viewModel.removeAuditFindings(at: offsets, sessionID: session.id)
301 }
302 }
303 }
304 .listRowBackground(Color(.systemGray6).opacity(0.5))
305
306 Section("Evidence Snapshot") {
307 LabeledContent("Availability", value: availabilityTitle(session.evidence.report.availability))
308 LabeledContent("Primary IP", value: session.evidence.report.dns.primaryIP ?? "Unavailable")
309 LabeledContent("TLS", value: session.evidence.report.web.tlsStatus)
310 LabeledContent("Final URL", value: session.evidence.report.web.finalURL ?? "Unavailable")
311 LabeledContent("Reachability", value: session.evidence.report.network.reachabilitySummary)
312 LabeledContent("Registrar", value: session.evidence.report.ownership?.registrar ?? "Unavailable")
313 }
314 .listRowBackground(Color(.systemGray6).opacity(0.5))
315
316 if !session.evidence.historicalContext.isEmpty {
317 Section("Historical Context") {
318 ForEach(session.evidence.historicalContext) { entry in
319 VStack(alignment: .leading, spacing: 4) {
320 Text(entry.timestamp.formatted(date: .abbreviated, time: .shortened))
321 .font(appDensity.font(.caption, weight: .semibold))
322 Text(entry.changeSummaryMessage ?? "No change summary")
323 .font(appDensity.font(.caption2))
324 .foregroundStyle(.secondary)
325 }
326 }
327 }
328 .listRowBackground(Color(.systemGray6).opacity(0.5))
329 }
330
331 Section("Audit Timeline") {
332 ForEach(viewModel.auditTimeline(for: session.domain)) { point in
333 VStack(alignment: .leading, spacing: 4) {
334 HStack {
335 Text(point.createdAt.formatted(date: .abbreviated, time: .shortened))
336 .font(appDensity.font(.caption, weight: .semibold))
337 Spacer()
338 Text(point.status.title)
339 .font(appDensity.font(.caption2))
340 .foregroundStyle(.secondary)
341 }
342 Text("Findings \(point.findingCount) • Open high \(point.openHighSeverityCount) • Repeated \(point.repeatedIssueCount)")
343 .font(appDensity.font(.caption2))
344 .foregroundStyle(.secondary)
345 }
346 }
347 }
348 .listRowBackground(Color(.systemGray6).opacity(0.5))
349 }
350 .scrollContentBackground(.hidden)
351 .background(Color.black)
352 .navigationTitle("Audit Session")
353 .toolbar {
354 ToolbarItemGroup(placement: .topBarTrailing) {
355 Button("Add Finding") {
356 editingFinding = nil
357 showingFindingEditor = true
358 }
359 Menu("Export") {
360 ForEach(AuditExportFormat.allCases) { format in
361 Button("Export \(format.fileExtension.uppercased())") {
362 guard let data = viewModel.exportAuditData(sessionID: session.id, format: format) else { return }
363 ExportPresenter.share(
364 filename: "\(session.domain)-audit-\(session.createdAt.ISO8601Format()).\(format.fileExtension)",
365 data: data
366 )
367 }
368 }
369 }
370 }
371 }
372 .onAppear {
373 notesDraft = session.notes
374 }
375 .onChange(of: session.notes) { _, newValue in
376 notesDraft = newValue
377 }
378 .sheet(isPresented: $showingFindingEditor) {
379 AuditFindingEditorView(
380 existingFinding: nil,
381 session: session
382 ) { draft in
383 viewModel.addAuditFinding(
384 sessionID: session.id,
385 title: draft.title,
386 severity: draft.severity,
387 summary: draft.summary,
388 evidenceReferences: draft.evidenceReferences,
389 notes: draft.notes,
390 checklistAreas: draft.checklistAreas
391 )
392 }
393 }
394 .sheet(item: $editingFinding) { finding in
395 AuditFindingEditorView(existingFinding: finding, session: session) { updated in
396 viewModel.updateAuditFinding(updated, sessionID: session.id)
397 }
398 }
399 } else {
400 ContentUnavailableView("Audit Session Missing", systemImage: "exclamationmark.triangle")
401 .background(Color.black)
402 }
403 }
404 .preferredColorScheme(.dark)
405 }
406}
407
408private struct AuditFindingEditorView: View {
409 @Environment(\.dismiss) private var dismiss
410
411 let existingFinding: AuditFinding?
412 let session: AuditSession
413 let onSave: (AuditFinding) -> Void
414
415 @State private var title: String
416 @State private var severity: AuditFindingSeverity
417 @State private var summary: String
418 @State private var evidenceReferencesText: String
419 @State private var notes: String
420 @State private var status: AuditFindingStatus
421 @State private var selectedAreas: Set<AuditChecklistArea>
422
423 init(existingFinding: AuditFinding?, session: AuditSession, onSave: @escaping (AuditFinding) -> Void) {
424 self.existingFinding = existingFinding
425 self.session = session
426 self.onSave = onSave
427 _title = State(initialValue: existingFinding?.title ?? "")
428 _severity = State(initialValue: existingFinding?.severity ?? .medium)
429 _summary = State(initialValue: existingFinding?.summary ?? "")
430 _evidenceReferencesText = State(initialValue: existingFinding?.evidenceReferences.joined(separator: "\n") ?? "")
431 _notes = State(initialValue: existingFinding?.notes ?? "")
432 _status = State(initialValue: existingFinding?.status ?? .open)
433 _selectedAreas = State(initialValue: Set(existingFinding?.checklistAreas ?? []))
434 }
435
436 var body: some View {
437 NavigationStack {
438 Form {
439 Section("Finding") {
440 TextField("Title", text: $title)
441 Picker("Severity", selection: $severity) {
442 ForEach(AuditFindingSeverity.allCases) { severity in
443 Text(severity.title).tag(severity)
444 }
445 }
446 Picker("Status", selection: $status) {
447 ForEach(AuditFindingStatus.allCases) { status in
448 Text(status.title).tag(status)
449 }
450 }
451 TextField("Summary", text: $summary, axis: .vertical)
452 .lineLimit(3...6)
453 }
454
455 Section("Evidence References") {
456 TextField("One reference per line", text: $evidenceReferencesText, axis: .vertical)
457 .lineLimit(4...8)
458 if !session.evidence.screenshots.isEmpty {
459 ForEach(session.evidence.screenshots) { asset in
460 Text("\(asset.title): \(asset.reference)")
461 .font(.caption)
462 .foregroundStyle(.secondary)
463 }
464 }
465 }
466
467 Section("Checklist Areas") {
468 ForEach(AuditChecklistArea.allCases) { area in
469 Button {
470 if selectedAreas.contains(area) {
471 selectedAreas.remove(area)
472 } else {
473 selectedAreas.insert(area)
474 }
475 } label: {
476 HStack {
477 Text(area.title)
478 Spacer()
479 Image(systemName: selectedAreas.contains(area) ? "checkmark.circle.fill" : "circle")
480 .foregroundStyle(selectedAreas.contains(area) ? Color.green : .secondary)
481 }
482 }
483 .buttonStyle(.plain)
484 }
485 }
486
487 Section("Notes") {
488 TextField("Optional notes", text: $notes, axis: .vertical)
489 .lineLimit(4...8)
490 }
491 }
492 .navigationTitle(existingFinding == nil ? "New Finding" : "Edit Finding")
493 .toolbar {
494 ToolbarItem(placement: .cancellationAction) {
495 Button("Cancel") {
496 dismiss()
497 }
498 }
499 ToolbarItem(placement: .confirmationAction) {
500 Button("Save") {
501 onSave(
502 AuditFinding(
503 id: existingFinding?.id ?? UUID(),
504 title: title.trimmingCharacters(in: .whitespacesAndNewlines),
505 severity: severity,
506 summary: summary.trimmingCharacters(in: .whitespacesAndNewlines),
507 evidenceReferences: evidenceReferencesText
508 .split(separator: "\n")
509 .map { String($0).trimmingCharacters(in: .whitespacesAndNewlines) }
510 .filter { !$0.isEmpty },
511 notes: notes.trimmingCharacters(in: .whitespacesAndNewlines),
512 status: status,
513 checklistAreas: Array(selectedAreas).sorted { $0.title < $1.title },
514 createdAt: existingFinding?.createdAt ?? Date(),
515 updatedAt: Date()
516 )
517 )
518 dismiss()
519 }
520 .disabled(title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || summary.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
521 }
522 }
523 }
524 }
525}
526
527private func auditStatusBadge(_ status: AuditStatus) -> some View {
528 let model: AppStatusBadgeModel
529 switch status {
530 case .draft:
531 model = .init(title: "Draft", systemImage: "square.and.pencil", foregroundColor: .yellow, backgroundColor: .yellow.opacity(0.16))
532 case .inReview:
533 model = .init(title: "In Review", systemImage: "doc.text.magnifyingglass", foregroundColor: .cyan, backgroundColor: .cyan.opacity(0.16))
534 case .complete:
535 model = .init(title: "Complete", systemImage: "checkmark.seal.fill", foregroundColor: .green, backgroundColor: .green.opacity(0.16))
536 }
537 return AppStatusBadgeView(model: model)
538}
539
540private func findingSeverityBadge(_ severity: AuditFindingSeverity) -> some View {
541 let color: Color
542 switch severity {
543 case .informational:
544 color = .secondary
545 case .low:
546 color = .green
547 case .medium:
548 color = .yellow
549 case .high:
550 color = .red
551 }
552 return AppStatusBadgeView(
553 model: .init(
554 title: severity.title,
555 systemImage: "exclamationmark.circle.fill",
556 foregroundColor: color,
557 backgroundColor: color.opacity(0.16)
558 )
559 )
560}
561
562private func availabilityTitle(_ status: DomainAvailabilityStatus) -> String {
563 switch status {
564 case .available:
565 return "Available"
566 case .registered:
567 return "Registered"
568 case .unknown:
569 return "Unknown"
570 }
571}
DomainDig/DomainViewModel.swift +193
@@ -297,6 +297,7 @@ final class DomainViewModel {
297 private static let historyKey = "lookupHistory" 297 private static let historyKey = "lookupHistory"
298 private static let maxHistory = 250 298 private static let maxHistory = 250
299 var history: [HistoryEntry] = DomainViewModel.loadHistoryEntries() 299 var history: [HistoryEntry] = DomainViewModel.loadHistoryEntries()
300 var auditSessions: [AuditSession] = DomainDataPortabilityService.loadAuditSessions()
300 private static let workflowsKey = "domainWorkflows" 301 private static let workflowsKey = "domainWorkflows"
301 var workflows: [DomainWorkflow] = DomainViewModel.loadWorkflows() 302 var workflows: [DomainWorkflow] = DomainViewModel.loadWorkflows()
302 var historySearchText = "" 303 var historySearchText = ""
@@ -952,6 +953,7 @@ final class DomainViewModel {
952 savedDomains = DomainDataPortabilityService.loadSavedDomains() 953 savedDomains = DomainDataPortabilityService.loadSavedDomains()
953 trackedDomains = Self.loadTrackedDomains() 954 trackedDomains = Self.loadTrackedDomains()
954 history = Self.loadHistoryEntries() 955 history = Self.loadHistoryEntries()
956 auditSessions = DomainDataPortabilityService.loadAuditSessions()
955 workflows = Self.loadWorkflows() 957 workflows = Self.loadWorkflows()
956 monitoringSettings = MonitoringStorage.sanitizeSettings( 958 monitoringSettings = MonitoringStorage.sanitizeSettings(
957 MonitoringStorage.loadSettings(), 959 MonitoringStorage.loadSettings(),
@@ -970,6 +972,7 @@ final class DomainViewModel {
970 savedDomains = [] 972 savedDomains = []
971 trackedDomains = [] 973 trackedDomains = []
972 history = [] 974 history = []
975 auditSessions = []
973 workflows = [] 976 workflows = []
974 historySearchText = "" 977 historySearchText = ""
975 historyDateFilter = .all 978 historyDateFilter = .all
@@ -1464,6 +1467,154 @@ final class DomainViewModel {
1464 return String(data: data, encoding: .utf8) 1467 return String(data: data, encoding: .utf8)
1465 } 1468 }
1466 1469
1470 func audits(for domain: String) -> [AuditSession] {
1471 auditSessions
1472 .filter { $0.domain.caseInsensitiveCompare(domain) == .orderedSame }
1473 .sorted { $0.createdAt > $1.createdAt }
1474 }
1475
1476 func auditSession(withID id: UUID) -> AuditSession? {
1477 auditSessions.first(where: { $0.id == id })
1478 }
1479
1480 func auditTimeline(for domain: String) -> [AuditTimelinePoint] {
1481 let sessions = audits(for: domain).sorted { $0.createdAt > $1.createdAt }
1482 return sessions.map { session in
1483 let repeatedIssues = sessions
1484 .filter { $0.id != session.id }
1485 .flatMap(\.findings)
1486 .map { $0.title.lowercased() }
1487 let repeatedIssueCount = session.findings.filter {
1488 repeatedIssues.contains($0.title.lowercased())
1489 }.count
1490
1491 return AuditTimelinePoint(
1492 id: session.id,
1493 sessionID: session.id,
1494 domain: session.domain,
1495 createdAt: session.createdAt,
1496 status: session.status,
1497 findingCount: session.findings.count,
1498 openHighSeverityCount: session.findings.filter { $0.severity == .high && $0.status != .resolved }.count,
1499 repeatedIssueCount: repeatedIssueCount
1500 )
1501 }
1502 }
1503
1504 @discardableResult
1505 func startAudit(for domain: String, reviewer: String? = nil) async -> AuditSession? {
1506 let normalizedDomain = domain
1507 .trimmingCharacters(in: .whitespacesAndNewlines)
1508 .replacingOccurrences(of: "https://", with: "")
1509 .replacingOccurrences(of: "http://", with: "")
1510 .components(separatedBy: "/").first?
1511 .lowercased() ?? domain.lowercased()
1512 guard !normalizedDomain.isEmpty else { return nil }
1513
1514 let previous = historyEntries(for: normalizedDomain).first?.snapshot
1515 let snapshot = await inspectionService.inspectSnapshot(domain: normalizedDomain, previousSnapshot: previous)
1516 guard let entry = saveHistoryEntry(
1517 from: snapshot,
1518 replaceLatest: false,
1519 updateCurrentState: searchedDomain.caseInsensitiveCompare(normalizedDomain) == .orderedSame
1520 ) else {
1521 return nil
1522 }
1523
1524 let report = report(for: entry)
1525 let historicalContext = Array(historyEntries(for: normalizedDomain).dropFirst().prefix(6)).map(\.snapshotSummary)
1526 let screenshots = snapshotEvidenceAssets(from: report)
1527 let session = AuditSession(
1528 domain: normalizedDomain,
1529 createdAt: entry.timestamp,
1530 reviewer: (reviewer?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty) ?? Self.defaultAuditReviewer,
1531 status: .draft,
1532 evidence: AuditEvidenceSnapshot(
1533 capturedAt: entry.timestamp,
1534 lookup: entry,
1535 report: report,
1536 historicalContext: historicalContext,
1537 screenshots: screenshots
1538 ),
1539 findings: [],
1540 notes: "",
1541 checklist: AuditChecklistArea.defaultItems
1542 )
1543
1544 auditSessions.insert(session, at: 0)
1545 persistAuditSessions()
1546 return session
1547 }
1548
1549 func updateAuditStatus(_ status: AuditStatus, sessionID: UUID) {
1550 guard let index = auditSessions.firstIndex(where: { $0.id == sessionID }) else { return }
1551 auditSessions[index].status = status
1552 persistAuditSessions()
1553 }
1554
1555 func updateAuditNotes(_ notes: String, sessionID: UUID) {
1556 guard let index = auditSessions.firstIndex(where: { $0.id == sessionID }) else { return }
1557 auditSessions[index].notes = notes.trimmingCharacters(in: .whitespacesAndNewlines)
1558 persistAuditSessions()
1559 }
1560
1561 func toggleAuditChecklistItem(sessionID: UUID, itemID: UUID) {
1562 guard let sessionIndex = auditSessions.firstIndex(where: { $0.id == sessionID }),
1563 let itemIndex = auditSessions[sessionIndex].checklist.firstIndex(where: { $0.id == itemID }) else {
1564 return
1565 }
1566
1567 auditSessions[sessionIndex].checklist[itemIndex].isComplete.toggle()
1568 auditSessions[sessionIndex].checklist[itemIndex].completedAt = auditSessions[sessionIndex].checklist[itemIndex].isComplete ? Date() : nil
1569 persistAuditSessions()
1570 }
1571
1572 func addAuditFinding(
1573 sessionID: UUID,
1574 title: String,
1575 severity: AuditFindingSeverity,
1576 summary: String,
1577 evidenceReferences: [String],
1578 notes: String,
1579 checklistAreas: [AuditChecklistArea]
1580 ) {
1581 guard let index = auditSessions.firstIndex(where: { $0.id == sessionID }) else { return }
1582 let finding = AuditFinding(
1583 title: title,
1584 severity: severity,
1585 summary: summary,
1586 evidenceReferences: evidenceReferences,
1587 notes: notes,
1588 status: .open,
1589 checklistAreas: checklistAreas
1590 )
1591 auditSessions[index].findings.insert(finding, at: 0)
1592 persistAuditSessions()
1593 }
1594
1595 func updateAuditFinding(_ finding: AuditFinding, sessionID: UUID) {
1596 guard let sessionIndex = auditSessions.firstIndex(where: { $0.id == sessionID }),
1597 let findingIndex = auditSessions[sessionIndex].findings.firstIndex(where: { $0.id == finding.id }) else {
1598 return
1599 }
1600
1601 var updatedFinding = finding
1602 updatedFinding.updatedAt = Date()
1603 auditSessions[sessionIndex].findings[findingIndex] = updatedFinding
1604 persistAuditSessions()
1605 }
1606
1607 func removeAuditFindings(at offsets: IndexSet, sessionID: UUID) {
1608 guard let sessionIndex = auditSessions.firstIndex(where: { $0.id == sessionID }) else { return }
1609 auditSessions[sessionIndex].findings.remove(atOffsets: offsets)
1610 persistAuditSessions()
1611 }
1612
1613 func exportAuditData(sessionID: UUID, format: AuditExportFormat) -> Data? {
1614 guard let session = auditSession(withID: sessionID) else { return nil }
1615 return try? AuditExporter.data(for: session, format: format)
1616 }
1617
1467 func loadOwnershipHistory() async { 1618 func loadOwnershipHistory() async {
1468 guard !searchedDomain.isEmpty else { return } 1619 guard !searchedDomain.isEmpty else { return }
1469 guard DataAccessService.hasAccess(to: .ownershipHistory) else { 1620 guard DataAccessService.hasAccess(to: .ownershipHistory) else {
@@ -2405,6 +2556,11 @@ final class DomainViewModel {
2405 DomainDebugLog.signpostEnd("DomainViewModel.persistHistory", start: persistStartedAt, extra: "count=\(history.count)") 2556 DomainDebugLog.signpostEnd("DomainViewModel.persistHistory", start: persistStartedAt, extra: "count=\(history.count)")
2406 } 2557 }
2407 2558
2559 private func persistAuditSessions() {
2560 DomainDataPortabilityService.saveAuditSessions(auditSessions)
2561 refreshDataLifecycleSummary()
2562 }
2563
2408 func setHistoryAutoPruneOption(_ option: HistoryAutoPruneOption) { 2564 func setHistoryAutoPruneOption(_ option: HistoryAutoPruneOption) {
2409 historyAutoPruneOption = option 2565 historyAutoPruneOption = option
2410 UserDefaults.standard.set(option.rawValue, forKey: Self.historyAutoPruneKey) 2566 UserDefaults.standard.set(option.rawValue, forKey: Self.historyAutoPruneKey)
@@ -3565,6 +3721,43 @@ final class DomainViewModel {
3565 ) 3721 )
3566 } 3722 }
3567 3723
3724 private static var defaultAuditReviewer: String {
3725 let reviewer = NSFullUserName().trimmingCharacters(in: .whitespacesAndNewlines)
3726 return reviewer.isEmpty ? "Local Reviewer" : reviewer
3727 }
3728
3729 private func snapshotEvidenceAssets(from report: DomainReport) -> [AuditEvidenceAsset] {
3730 var assets: [AuditEvidenceAsset] = []
3731 if let finalURL = report.web.finalURL {
3732 assets.append(AuditEvidenceAsset(title: "Final URL", kind: .document, reference: finalURL))
3733 }
3734 if let registrar = report.ownership?.registrar {
3735 assets.append(AuditEvidenceAsset(title: "Registrar", kind: .document, reference: registrar))
3736 }
3737 if let primaryIP = report.dns.primaryIP {
3738 assets.append(AuditEvidenceAsset(title: "Primary IP", kind: .document, reference: primaryIP))
3739 }
3740 if !report.web.redirectChain.isEmpty {
3741 assets.append(
3742 AuditEvidenceAsset(
3743 title: "Redirect Chain",
3744 kind: .document,
3745 reference: report.web.redirectChain.map { "\($0.statusCode) \($0.url)" }.joined(separator: " | ")
3746 )
3747 )
3748 }
3749 if !report.web.headers.isEmpty {
3750 assets.append(
3751 AuditEvidenceAsset(
3752 title: "Observed Headers",
3753 kind: .document,
3754 reference: report.web.headers.prefix(6).map { "\($0.name): \($0.value)" }.joined(separator: " | ")
3755 )
3756 )
3757 }
3758 return assets
3759 }
3760
3568 private func placeholderSnapshot(for trackedDomain: TrackedDomain) -> LookupSnapshot { 3761 private func placeholderSnapshot(for trackedDomain: TrackedDomain) -> LookupSnapshot {
3569 LookupSnapshot( 3762 LookupSnapshot(
3570 historyEntryID: trackedDomain.lastSnapshotID, 3763 historyEntryID: trackedDomain.lastSnapshotID,
DomainDig/RootTabView.swift +9 −9
@@ -2,9 +2,9 @@ import SwiftUI
2 2
3private enum RootTab: Hashable { 3private enum RootTab: Hashable {
4 case dashboard 4 case dashboard
5 case audit
5 case history 6 case history
6 case inspect 7 case inspect
7 case audit
8 case settings 8 case settings
9} 9}
10 10
@@ -25,6 +25,14 @@ struct RootTabView: View {
25 } 25 }
26 .tag(RootTab.dashboard) 26 .tag(RootTab.dashboard)
27 27
28 NavigationStack {
29 AuditListView(viewModel: viewModel)
30 }
31 .tabItem {
32 Label("Audit", systemImage: "checklist")
33 }
34 .tag(RootTab.audit)
35
28 NavigationStack { 36 NavigationStack {
29 HistoryView(viewModel: viewModel) 37 HistoryView(viewModel: viewModel)
30 } 38 }
@@ -39,14 +47,6 @@ struct RootTabView: View {
39 } 47 }
40 .tag(RootTab.inspect) 48 .tag(RootTab.inspect)
41 49
42 NavigationStack {
43 AuditModeView(viewModel: viewModel)
44 }
45 .tabItem {
46 Label("Audit", systemImage: "checklist")
47 }
48 .tag(RootTab.audit)
49
50 NavigationStack { 50 NavigationStack {
51 SettingsView(viewModel: viewModel) 51 SettingsView(viewModel: viewModel)
52 } 52 }
DomainDig/WatchlistView.swift +28
@@ -476,6 +476,8 @@ struct TrackedDomainDetailView: View {
476 @State private var isEditingNote = false 476 @State private var isEditingNote = false
477 @State private var showRerunOptions = false 477 @State private var showRerunOptions = false
478 @State private var shareEntity: ShareableEntity? 478 @State private var shareEntity: ShareableEntity?
479 @State private var showingAuditTimeline = false
480 @State private var auditStartInFlight = false
479 481
480 private var liveTrackedDomain: TrackedDomain { 482 private var liveTrackedDomain: TrackedDomain {
481 viewModel.trackedDomains.first(where: { $0.id == trackedDomain.id }) ?? trackedDomain 483 viewModel.trackedDomains.first(where: { $0.id == trackedDomain.id }) ?? trackedDomain
@@ -512,6 +514,27 @@ struct TrackedDomainDetailView: View {
512 Label("Re-run Inspection", systemImage: "magnifyingglass") 514 Label("Re-run Inspection", systemImage: "magnifyingglass")
513 } 515 }
514 516
517 Button {
518 Task {
519 auditStartInFlight = true
520 if await viewModel.startAudit(for: liveTrackedDomain.domain) != nil {
521 showingAuditTimeline = true
522 }
523 auditStartInFlight = false
524 }
525 } label: {
526 Label(auditStartInFlight ? "Starting Audit…" : "Start Audit", systemImage: "checklist")
527 }
528 .disabled(auditStartInFlight)
529
530 if !viewModel.audits(for: liveTrackedDomain.domain).isEmpty {
531 Button {
532 showingAuditTimeline = true
533 } label: {
534 Label("View Audits", systemImage: "clock.badge.checkmark")
535 }
536 }
537
515 Button { 538 Button {
516 viewModel.togglePinned(for: liveTrackedDomain) 539 viewModel.togglePinned(for: liveTrackedDomain)
517 } label: { 540 } label: {
@@ -651,5 +674,10 @@ struct TrackedDomainDetailView: View {
651 .sheet(item: $shareEntity) { entity in 674 .sheet(item: $shareEntity) { entity in
652 CloudSharingSheet(entity: entity, title: liveTrackedDomain.domain) 675 CloudSharingSheet(entity: entity, title: liveTrackedDomain.domain)
653 } 676 }
677 .sheet(isPresented: $showingAuditTimeline) {
678 NavigationStack {
679 AuditDomainTimelineView(viewModel: viewModel, domain: liveTrackedDomain.domain)
680 }
681 }
654 } 682 }
655} 683}
DomainDigCLI.swift deleted −525
@@ -1,525 +0,0 @@
1import Foundation
2
3@main
4struct DomainDigCLI {
5 static func main() async {
6 let arguments = Array(CommandLine.arguments.dropFirst())
7 let wantsJSON = arguments.contains("--json") || arguments.contains("-j")
8
9 guard let command = CommandLine.arguments.first else {
10 fputs(usageText, stderr)
11 Foundation.exit(1)
12 }
13 _ = command
14
15 if arguments.first == "backup" {
16 runBackupCommand(arguments: Array(arguments.dropFirst()), wantsJSON: wantsJSON)
17 return
18 }
19
20 if arguments.first == "history" {
21 runHistoryCommand(arguments: Array(arguments.dropFirst()), wantsJSON: wantsJSON)
22 return
23 }
24
25 if arguments.first == "diff" {
26 runDiffCommand(arguments: Array(arguments.dropFirst()), wantsJSON: wantsJSON)
27 return
28 }
29
30 if arguments.first == "monitor" {
31 await runMonitorCommand(wantsJSON: wantsJSON)
32 return
33 }
34
35 let wantsOwnershipHistory = arguments.contains("--ownership-history")
36 let wantsDNSHistory = arguments.contains("--dns-history")
37 let wantsExtendedSubdomains = arguments.contains("--extended-subdomains")
38 let wantsPricing = arguments.contains("--pricing")
39 let domains = arguments.filter { !$0.hasPrefix("-") }
40
41 let requestedDomains = domains
42 .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
43 .filter { !$0.isEmpty }
44
45 guard !requestedDomains.isEmpty else {
46 fputs(usageText, stderr)
47 Foundation.exit(1)
48 }
49
50 let inspectionService = DomainInspectionService()
51 let reportBuilder = DomainReportBuilder()
52 var reports: [DomainReport] = []
53 var seen = Set<String>()
54 for domain in requestedDomains {
55 let normalizedDomain = domain.lowercased()
56 guard seen.insert(normalizedDomain).inserted else { continue }
57 let snapshot = await inspectionService.inspectSnapshot(domain: domain)
58 let enrichedSnapshot = await enrichSnapshot(
59 snapshot,
60 wantsOwnershipHistory: wantsOwnershipHistory,
61 wantsDNSHistory: wantsDNSHistory,
62 wantsExtendedSubdomains: wantsExtendedSubdomains,
63 wantsPricing: wantsPricing
64 )
65 reports.append(reportBuilder.build(from: enrichedSnapshot))
66 }
67
68 do {
69 let data: Data
70 if reports.count == 1, let report = reports.first {
71 data = try DomainReportExporter.data(
72 for: report,
73 format: wantsJSON ? .json : .text
74 )
75 } else {
76 data = try DomainReportExporter.data(
77 for: reports,
78 format: wantsJSON ? .json : .text,
79 title: "DomainDig Batch Report"
80 )
81 }
82 FileHandle.standardOutput.write(data)
83 if data.last != 0x0A {
84 FileHandle.standardOutput.write(Data([0x0A]))
85 }
86 } catch {
87 fputs("domaindig: \(error.localizedDescription)\n", stderr)
88 Foundation.exit(1)
89 }
90 }
91
92 private static func enrichSnapshot(
93 _ snapshot: LookupSnapshot,
94 wantsOwnershipHistory: Bool,
95 wantsDNSHistory: Bool,
96 wantsExtendedSubdomains: Bool,
97 wantsPricing: Bool
98 ) async -> LookupSnapshot {
99 guard FeatureAccessService.currentTier == .proPlus else {
100 return snapshot
101 }
102
103 let historyEntries = loadHistoryEntries()
104 var ownershipHistory = snapshot.ownershipHistory
105 var ownershipHistoryError = snapshot.ownershipHistoryError
106 var dnsHistory = snapshot.dnsHistory
107 var dnsHistoryError = snapshot.dnsHistoryError
108 var extendedSubdomains = snapshot.extendedSubdomains
109 var extendedSubdomainsError = snapshot.extendedSubdomainsError
110 var domainPricing = snapshot.domainPricing
111 var domainPricingError = snapshot.domainPricingError
112
113 if wantsOwnershipHistory {
114 let outcome = await ExternalDataService.shared.ownershipHistory(
115 domain: snapshot.domain,
116 currentOwnership: snapshot.ownership,
117 historyEntries: historyEntries
118 )
119 switch outcome.value {
120 case let .success(events):
121 ownershipHistory = events
122 ownershipHistoryError = nil
123 case let .empty(message):
124 ownershipHistoryError = message
125 case let .error(message):
126 ownershipHistoryError = message
127 }
128 }
129
130 if wantsDNSHistory {
131 let outcome = await ExternalDataService.shared.dnsHistory(
132 domain: snapshot.domain,
133 dnsSections: snapshot.dnsSections,
134 historyEntries: historyEntries
135 )
136 switch outcome.value {
137 case let .success(events):
138 dnsHistory = events
139 dnsHistoryError = nil
140 case let .empty(message):
141 dnsHistoryError = message
142 case let .error(message):
143 dnsHistoryError = message
144 }
145 }
146
147 if wantsExtendedSubdomains {
148 let outcome = await ExternalDataService.shared.extendedSubdomains(
149 domain: snapshot.domain,
150 existing: snapshot.subdomains
151 )
152 switch outcome.value {
153 case let .success(results):
154 extendedSubdomains = results
155 extendedSubdomainsError = nil
156 case let .empty(message):
157 extendedSubdomainsError = message
158 case let .error(message):
159 extendedSubdomainsError = message
160 }
161 }
162
163 if wantsPricing {
164 let outcome = await ExternalDataService.shared.pricing(domain: snapshot.domain)
165 switch outcome.value {
166 case let .success(pricing):
167 domainPricing = pricing
168 domainPricingError = nil
169 case let .empty(message), let .error(message):
170 domainPricingError = message
171 }
172 }
173
174 return LookupSnapshot(
175 historyEntryID: snapshot.historyEntryID,
176 domain: snapshot.domain,
177 timestamp: snapshot.timestamp,
178 trackedDomainID: snapshot.trackedDomainID,
179 note: snapshot.note,
180 appVersion: snapshot.appVersion,
181 resolverDisplayName: snapshot.resolverDisplayName,
182 resolverURLString: snapshot.resolverURLString,
183 dataSources: snapshot.dataSources,
184 provenanceBySection: snapshot.provenanceBySection,
185 availabilityConfidence: snapshot.availabilityConfidence,
186 ownershipConfidence: snapshot.ownershipConfidence,
187 subdomainConfidence: snapshot.subdomainConfidence,
188 emailSecurityConfidence: snapshot.emailSecurityConfidence,
189 geolocationConfidence: snapshot.geolocationConfidence,
190 errorDetails: snapshot.errorDetails,
191 isPartialSnapshot: snapshot.isPartialSnapshot,
192 validationIssues: snapshot.validationIssues,
193 totalLookupDurationMs: snapshot.totalLookupDurationMs,
194 snapshotIndex: snapshot.snapshotIndex,
195 previousSnapshotID: snapshot.previousSnapshotID,
196 changeCount: snapshot.changeCount,
197 severitySummary: snapshot.severitySummary,
198 dnsSections: snapshot.dnsSections,
199 dnsError: snapshot.dnsError,
200 availabilityResult: snapshot.availabilityResult,
201 suggestions: snapshot.suggestions,
202 sslInfo: snapshot.sslInfo,
203 sslError: snapshot.sslError,
204 hstsPreloaded: snapshot.hstsPreloaded,
205 httpHeaders: snapshot.httpHeaders,
206 httpSecurityGrade: snapshot.httpSecurityGrade,
207 httpStatusCode: snapshot.httpStatusCode,
208 httpResponseTimeMs: snapshot.httpResponseTimeMs,
209 httpProtocol: snapshot.httpProtocol,
210 http3Advertised: snapshot.http3Advertised,
211 httpHeadersError: snapshot.httpHeadersError,
212 reachabilityResults: snapshot.reachabilityResults,
213 reachabilityError: snapshot.reachabilityError,
214 ipGeolocation: snapshot.ipGeolocation,
215 ipGeolocationError: snapshot.ipGeolocationError,
216 emailSecurity: snapshot.emailSecurity,
217 emailSecurityError: snapshot.emailSecurityError,
218 ownership: snapshot.ownership,
219 ownershipError: snapshot.ownershipError,
220 ownershipHistory: ownershipHistory,
221 ownershipHistoryError: ownershipHistoryError,
222 inferredProvider: snapshot.inferredProvider,
223 priorProviders: snapshot.priorProviders,
224 domainClassification: snapshot.domainClassification,
225 ownershipTransitions: snapshot.ownershipTransitions,
226 hostingTransitions: snapshot.hostingTransitions,
227 subdomainHistory: snapshot.subdomainHistory,
228 riskSignals: snapshot.riskSignals,
229 intelligenceTimeline: snapshot.intelligenceTimeline,
230 ptrRecord: snapshot.ptrRecord,
231 ptrError: snapshot.ptrError,
232 redirectChain: snapshot.redirectChain,
233 redirectChainError: snapshot.redirectChainError,
234 subdomains: snapshot.subdomains,
235 subdomainsError: snapshot.subdomainsError,
236 extendedSubdomains: extendedSubdomains,
237 extendedSubdomainsError: extendedSubdomainsError,
238 dnsHistory: dnsHistory,
239 dnsHistoryError: dnsHistoryError,
240 domainPricing: domainPricing,
241 domainPricingError: domainPricingError,
242 portScanResults: snapshot.portScanResults,
243 portScanError: snapshot.portScanError,
244 changeSummary: snapshot.changeSummary,
245 resultSource: snapshot.resultSource,
246 cachedSections: snapshot.cachedSections,
247 statusMessage: snapshot.statusMessage
248 )
249 }
250
251 private static func loadHistoryEntries() -> [HistoryEntry] {
252 DomainDataPortabilityService.loadHistoryEntries()
253 }
254
255 private static func runHistoryCommand(arguments: [String], wantsJSON: Bool) {
256 guard let domain = arguments.first(where: { !$0.hasPrefix("-") }) else {
257 fputs("usage: domaindig history <domain> [--json]\n", stderr)
258 Foundation.exit(1)
259 }
260
261 let entries = loadHistoryEntries()
262 .filter { $0.domain.caseInsensitiveCompare(domain) == .orderedSame }
263 .sorted { $0.timestamp > $1.timestamp }
264
265 if wantsJSON {
266 let encoder = JSONEncoder()
267 encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
268 encoder.dateEncodingStrategy = .iso8601
269 let payload = entries.map { entry in
270 [
271 "id": entry.id.uuidString,
272 "timestamp": ISO8601DateFormatter().string(from: entry.timestamp),
273 "changeSummary": entry.changeSummary?.message ?? "No change summary",
274 "changeCount": "\(entry.changeCount)",
275 "severity": entry.severitySummary?.title ?? "N/A"
276 ]
277 }
278 if let data = try? JSONSerialization.data(withJSONObject: payload, options: [.prettyPrinted, .sortedKeys]) {
279 FileHandle.standardOutput.write(data)
280 FileHandle.standardOutput.write(Data([0x0A]))
281 return
282 }
283 }
284
285 let lines = entries.map { entry in
286 [
287 entry.id.uuidString,
288 entry.timestamp.formatted(date: .abbreviated, time: .shortened),
289 entry.changeSummary?.message ?? "No change summary"
290 ].joined(separator: " | ")
291 }
292
293 FileHandle.standardOutput.write(Data((lines.isEmpty ? "No history found.\n" : lines.joined(separator: "\n") + "\n").utf8))
294 }
295
296 private static func runDiffCommand(arguments: [String], wantsJSON: Bool) {
297 guard let domain = arguments.first(where: { !$0.hasPrefix("-") }) else {
298 fputs("usage: domaindig diff <domain> --from <id> --to <id> [--json]\n", stderr)
299 Foundation.exit(1)
300 }
301
302 guard let fromID = optionValue(named: "--from", in: arguments),
303 let toID = optionValue(named: "--to", in: arguments),
304 let fromUUID = UUID(uuidString: fromID),
305 let toUUID = UUID(uuidString: toID) else {
306 fputs("domaindig diff: --from and --to must be valid snapshot IDs\n", stderr)
307 Foundation.exit(1)
308 }
309
310 let entries = loadHistoryEntries().filter { $0.domain.caseInsensitiveCompare(domain) == .orderedSame }
311 guard let fromEntry = entries.first(where: { $0.id == fromUUID }),
312 let toEntry = entries.first(where: { $0.id == toUUID }) else {
313 fputs("domaindig diff: snapshots not found for domain\n", stderr)
314 Foundation.exit(1)
315 }
316
317 let orderedEntries = [fromEntry, toEntry].sorted { $0.timestamp < $1.timestamp }
318 let diff = DiffService.compare(from: orderedEntries[0].snapshot, to: orderedEntries[1].snapshot)
319
320 if wantsJSON {
321 let encoder = JSONEncoder()
322 encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
323 encoder.dateEncodingStrategy = .iso8601
324 if let data = try? encoder.encode(diff) {
325 FileHandle.standardOutput.write(data)
326 FileHandle.standardOutput.write(Data([0x0A]))
327 return
328 }
329 }
330
331 var lines = [
332 "DomainDig Diff",
333 "Domain: \(domain)",
334 "From: \(orderedEntries[0].id.uuidString)",
335 "To: \(orderedEntries[1].id.uuidString)"
336 ]
337
338 for section in diff.sections where section.hasChanges {
339 lines.append("")
340 lines.append(section.title)
341 for item in section.items where item.hasChanges {
342 lines.append("\(item.changeType.marker) \(item.label): \(item.oldValue ?? "none") -> \(item.newValue ?? "none")")
343 }
344 }
345
346 FileHandle.standardOutput.write(Data((lines.joined(separator: "\n") + "\n").utf8))
347 }
348
349 private static func optionValue(named name: String, in arguments: [String]) -> String? {
350 guard let index = arguments.firstIndex(of: name), arguments.indices.contains(index + 1) else {
351 return nil
352 }
353 return arguments[index + 1]
354 }
355
356 private static func runBackupCommand(arguments: [String], wantsJSON: Bool) {
357 guard let subcommand = arguments.first else {
358 fputs(usageText, stderr)
359 Foundation.exit(1)
360 }
361
362 switch subcommand {
363 case "export":
364 do {
365 let data = try DomainDataPortabilityService.backupData()
366 let outputPath = arguments.dropFirst().first(where: { !$0.hasPrefix("-") })
367 if let outputPath {
368 try data.write(to: URL(fileURLWithPath: outputPath), options: .atomic)
369 } else {
370 FileHandle.standardOutput.write(data)
371 if data.last != 0x0A {
372 FileHandle.standardOutput.write(Data([0x0A]))
373 }
374 }
375 } catch {
376 fputs("domaindig backup export: \(error.localizedDescription)\n", stderr)
377 Foundation.exit(1)
378 }
379 case "validate":
380 guard let path = arguments.dropFirst().first(where: { !$0.hasPrefix("-") }) else {
381 fputs("usage: domaindig backup validate <path>\n", stderr)
382 Foundation.exit(1)
383 }
384
385 do {
386 let data = try Data(contentsOf: URL(fileURLWithPath: path))
387 let report = try DomainDataPortabilityService.validateBackup(data: data, fileName: URL(fileURLWithPath: path).lastPathComponent)
388 if wantsJSON {
389 let payload = [
390 "warnings": report.warnings,
391 "errors": report.errors
392 ]
393 let encoded = try JSONSerialization.data(withJSONObject: payload, options: [.prettyPrinted, .sortedKeys])
394 FileHandle.standardOutput.write(encoded)
395 } else {
396 let lines = [
397 "Warnings: \(report.warnings.count)",
398 "Errors: \(report.errors.count)"
399 ] + report.warnings.map { "warning: \($0)" } + report.errors.map { "error: \($0)" }
400 FileHandle.standardOutput.write(Data(lines.joined(separator: "\n").utf8))
401 }
402 FileHandle.standardOutput.write(Data([0x0A]))
403 if !report.errors.isEmpty {
404 Foundation.exit(1)
405 }
406 } catch {
407 fputs("domaindig backup validate: \(error.localizedDescription)\n", stderr)
408 Foundation.exit(1)
409 }
410 case "import":
411 guard let path = arguments.dropFirst().first(where: { !$0.hasPrefix("-") }) else {
412 fputs("usage: domaindig backup import <path> [--replace]\n", stderr)
413 Foundation.exit(1)
414 }
415
416 let mode: DataPortabilityImportMode = arguments.contains("--replace") ? .replace : .merge
417
418 do {
419 let fileURL = URL(fileURLWithPath: path)
420 let data = try Data(contentsOf: fileURL)
421 let preview = try DomainDataPortabilityService.prepareImport(
422 data: data,
423 fileName: fileURL.lastPathComponent,
424 mode: mode
425 )
426 guard preview.kind == .backup else {
427 fputs("domaindig backup import: expected a full backup file\n", stderr)
428 Foundation.exit(1)
429 }
430 let result = try DomainDataPortabilityService.applyImport(preview, mode: mode)
431 let lines = [result.summary] + result.warnings.map { "warning: \($0)" }
432 FileHandle.standardOutput.write(Data(lines.joined(separator: "\n").utf8))
433 FileHandle.standardOutput.write(Data([0x0A]))
434 } catch {
435 fputs("domaindig backup import: \(error.localizedDescription)\n", stderr)
436 Foundation.exit(1)
437 }
438 default:
439 fputs(usageText, stderr)
440 Foundation.exit(1)
441 }
442 }
443
444 private static func runMonitorCommand(wantsJSON: Bool) async {
445 guard FeatureAccessService.hasAccess(to: .automatedMonitoring) else {
446 fputs("domaindig monitor: monitoring requires Pro\n", stderr)
447 Foundation.exit(1)
448 }
449
450 let outcome = await DomainMonitoringService.shared.performMonitoring(
451 trigger: .cli,
452 requireEnabledSetting: false
453 )
454
455 guard let log = outcome.log else {
456 fputs("domaindig monitor: \(outcome.message)\n", stderr)
457 Foundation.exit(1)
458 }
459
460 do {
461 let output: Data
462 if wantsJSON {
463 let encoder = JSONEncoder()
464 encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
465 output = try encoder.encode(log)
466 } else {
467 output = Data(monitoringTextSummary(for: log).utf8)
468 }
469 FileHandle.standardOutput.write(output)
470 if output.last != 0x0A {
471 FileHandle.standardOutput.write(Data([0x0A]))
472 }
473 if !outcome.success {
474 Foundation.exit(1)
475 }
476 } catch {
477 fputs("domaindig monitor: \(error.localizedDescription)\n", stderr)
478 Foundation.exit(1)
479 }
480 }
481
482 private static func monitoringTextSummary(for log: MonitoringLog) -> String {
483 var lines = [
484 "DomainDig Monitoring",
485 "====================",
486 "Trigger: \(log.trigger.title)",
487 "Timestamp: \(log.timestamp.formatted(date: .abbreviated, time: .shortened))",
488 "Checked: \(log.domainsChecked)",
489 "Changes: \(log.changesFound)",
490 "Alerts: \(log.alertsTriggered)",
491 ""
492 ]
493
494 if log.checkedDomains.isEmpty {
495 lines.append("No domains were checked.")
496 } else {
497 for result in log.checkedDomains {
498 let severity = result.alertSeverity?.title ?? "None"
499 lines.append("\(result.domain): \(result.summaryMessage) [alert: \(severity)]")
500 }
501 }
502
503 if !log.errors.isEmpty {
504 lines.append("")
505 lines.append("Errors:")
506 lines.append(contentsOf: log.errors.map { "- \($0)" })
507 }
508
509 return lines.joined(separator: "\n")
510 }
511
512 private static var usageText: String {
513 """
514 usage: domaindig <domain> [--json] [--ownership-history] [--dns-history] [--extended-subdomains] [--pricing]
515 domaindig history <domain> [--json]
516 domaindig diff <domain> --from <id> --to <id> [--json]
517 domaindig monitor [--json]
518 domaindig backup export [path]
519 domaindig backup import <path> [--replace]
520 domaindig backup validate <path> [--json]
521
522 note: the CLI remains local-only and does not sync with iCloud yet.
523 """
524 }
525}
README.md +42 −26
@@ -1,34 +1,32 @@
1# domain-dig 1# DomainDig
2 2
3[![Security Rating](https://sonarcloud.io/api/project_badges/measure?project=zerolabsco_domain-dig&metric=security_rating)](https://sonarcloud.io/summary/new_code?id=zerolabsco_domain-dig) 3[![Security Rating](https://sonarcloud.io/api/project_badges/measure?project=zerolabsco_domain-dig&metric=security_rating)](https://sonarcloud.io/summary/new_code?id=zerolabsco_domain-dig)
4[![Reliability Rating](https://sonarcloud.io/api/project_badges/measure?project=zerolabsco_domain-dig&metric=reliability_rating)](https://sonarcloud.io/summary/new_code?id=zerolabsco_domain-dig) 4[![Reliability Rating](https://sonarcloud.io/api/project_badges/measure?project=zerolabsco_domain-dig&metric=reliability_rating)](https://sonarcloud.io/summary/new_code?id=zerolabsco_domain-dig)
5 5
6Domain Dig is a minimal iOS to perform DNS lookups. 6DomainDig is a local-first iOS domain inspection toolkit for DNS, web, ownership, monitoring, reporting, and audit workflows. The app gathers a point-in-time domain snapshot, normalizes it into a canonical `DomainReport`, and keeps user data on device unless the user exports, shares, or syncs it.
7 7
8## Features 8## Current Release Target
9 9
10Data provided by a Domain Dig search: 10Immediate release target: `v4.4.1`.
11 11
12- Reachability 12This patch release focuses on release readiness: resolving duplicate Audit Mode implementations, aligning app/project version metadata, preserving audit persistence and backup/restore coverage, and refreshing docs for the current app surface.
13- Redirect Chain
14- DNS Records
15- Email Security
16- SSL/TLS Certificate
17- HTTP Headers
18 - "Grade" for header security
19- IP Location
20- Open Ports
21 - Custom port checks
22 13
23Extra features: 14## Features
24 15
25- Save favorite domains for quick reuse 16- Domain inspection for DNS records, email security, TLS certificates, HTTP headers, redirects, IP geolocation, reachability, open ports, RDAP, ownership, subdomain discovery, and availability.
26- Check historical log of searches and their results 17- Canonical `DomainReport` output used by the app UI and exports.
27- Change DNS resolver to Cloudflare, Google, Quad9, or a custom resolver 18- History snapshots with change summaries, risk scoring, notes, and saved domain context.
19- Dashboard, watchlist, monitoring, workflows, batch results, integrations, and data portability screens.
20- Audit Mode with sessions, checklist progress, reviewer notes, findings, evidence snapshots, audit timelines, and markdown/json/pdf export.
21- Backup and restore for tracked domains, history, audit sessions, workflows, monitoring settings/logs, app settings, and local feature metadata.
22- Local-first operation with no required backend.
23- Optional local API surface for automation-compatible report output.
28 24
29## Contributing 25## Data And Privacy
30 26
31Contributions are welcome. Keep changes scoped, include tests when behavior changes, and open a pull request with a clear summary of the user-facing impact. 27DomainDig stores local app data in on-device persistence. Backup exports can include tracked domains, lookup history, audit sessions, workflow definitions, monitoring configuration, monitoring logs, app settings, and cached feature metadata. Imports are processed on device.
28
29Network inspection requests are made only to perform the requested domain checks or configured resolver lookups. The app does not require a hosted DomainDig backend.
32 30
33## Development 31## Development
34 32
@@ -36,23 +34,41 @@ Contributions are welcome. Keep changes scoped, include tests when behavior chan
36 34
37- Xcode with current iOS SDK support 35- Xcode with current iOS SDK support
38- iOS Simulator or physical iOS device 36- iOS Simulator or physical iOS device
37- Swift/Xcode support for filesystem-synchronized groups used by the project
39 38
40### Getting Started 39### Getting Started
41 40
421. Clone the repository: 411. Clone the repository.
43 ```sh 42 ```sh
44 git clone https://git.sr.ht/~ccleberg/DomainDig 43 git clone https://github.com/zerolabsco/domain-dig.git
45 ``` 44 ```
462. Open the project in Xcode. 452. Open `DomainDig.xcodeproj` in Xcode.
473. Build and run the app on a simulator or device. 463. Select the `DomainDig` scheme.
474. Build and run on a simulator or device.
48
49### Useful Checks
50
51```sh
52xcodebuild -project DomainDig.xcodeproj -scheme DomainDig -destination 'platform=iOS Simulator,name=iPhone 16' build
53```
54
55The app and local API share the canonical report pipeline through `DomainInspectionService`, `DomainReportBuilder`, and `DomainReportExporter`.
56
57## Release Planning
58
59See `RELEASE_ROADMAP.md` for the semver release plan from `v4.4.1` through the planned `v5.0.0` stabilization milestone.
60
61## Contributing
62
63Contributions are welcome. Keep changes scoped, include tests or build verification when behavior changes, and open a pull request with a clear summary of user-facing impact.
48 64
49## Security 65## Security
50 66
51If you discover a security issue, see SECURITY.md. 67If you discover a security issue, see `SECURITY.md`.
52 68
53## License 69## License
54 70
55This project is licensed under the GPL 3.0 or later. See LICENSE. 71This project is licensed under GPL 3.0 or later. See `LICENSE`.
56 72
57## Contact 73## Contact
58 74
RELEASE_ROADMAP.md added +90
@@ -0,0 +1,90 @@
1# DomainDig Release Roadmap
2
3Priority lens: **new user-facing features.** The inspection engine is already
4deep (DNS, DNSSEC, CAA, TLS, TLSA/DANE, email security incl. BIMI/MTA-STS, RDAP,
5ports, geolocation, subdomains, availability). The next several releases invest
6in *reach and surfacing* — getting that data onto more iOS surfaces and into more
7workflows — rather than adding raw protocol checks.
8
9Current version: `v4.4.1`.
10
11## v4.4.1 Patch: Release Readiness (in progress)
12
13Finish the audit-mode consolidation already on the working tree, then ship a
14clean release candidate.
15
16- Resolve duplicate Audit Mode implementations; retire the prototype
17 `AuditMode.swift` / `AuditModeView.swift` and keep the `DomainDig/DomainDig/Audit*`
18 path as the single active implementation.
19- Align `AppVersion.current`, Xcode marketing version, and build number.
20- Confirm audit sessions are included in backup/restore counts, summaries, and
21 merge behavior.
22- Refresh README and architecture docs to match the shipping surface.
23
24Gate: clean Xcode build/archive before tagging.
25
26## v4.5.0 Minor: Home Screen & Shortcuts Reach
27
28Goal: put DomainDig data and actions where the user already is.
29
30- **WidgetKit widgets** (Home Screen + Lock Screen) for pinned/watchlist domains:
31 certificate expiry countdown, monitoring status, last-change indicator.
32- **App Intents / Shortcuts**: "Inspect domain", "Add to watchlist", "Run sweep"
33 as intents usable from Shortcuts, Spotlight, and the Action button.
34- Deep links from widgets and intents into the relevant domain detail screen.
35- Polished audit-mode ergonomics carried over from the prior roadmap (timeline
36 presentation, checklist/finding editing, markdown/json/pdf export affordances).
37
38## v4.6.0 Minor: Alerts, Glances & iPad
39
40Goal: make monitoring and results feel first-class across contexts.
41
42- **Live Activities** for in-flight sweeps and active monitoring alerts
43 (cert-expiry and change events at a glance).
44- **Share extension**: "Dig this domain" from Safari and the system share sheet.
45- **iPad-optimized layout** using `NavigationSplitView` (the app currently ships
46 an iPhone-style stack on iPad); adapt watchlist/detail as a two-column layout.
47- Richer, actionable notification content building on the existing
48 `LocalNotificationService` triggers.
49
50## v4.7.0 Minor: Intelligence & Comparison
51
52Goal: help users interpret and organize, not just collect.
53
54- **Domain-vs-domain comparison** (side-by-side), extending the existing
55 time-based `DiffService` to compare two distinct domains.
56- **Reputation / blocklist signals** as a new optional data source (currently
57 absent); surfaced in the report and available to monitoring alerts.
58- **Tags / folders and saved views** for the watchlist to organize large sets.
59
60## v4.8.0 Minor: Reporting & Sharing
61
62Goal: turn point-in-time snapshots into shareable, scheduled deliverables.
63
64- Scheduled report generation (markdown/json/pdf) for tracked domains.
65- Stronger share affordances for reports and audit evidence.
66- Export polish and consistency across app and local API output.
67
68## v5.0.0 Major: Contract Stabilization & Engineering Health
69
70Goal: earn long-term compatibility promises — and pay down the debt that the
71feature releases above will accumulate.
72
73- Define migration policy for persisted snapshots, backups, audits, workflows,
74 and settings.
75- Stabilize the public local API response contract; document compatibility
76 guarantees and planned deprecations.
77- **Establish a test target.** The project currently has no XCTest target and no
78 tests; add one and cover the deterministic core first — `DomainReportBuilder`,
79 `DomainReportExporter`, `DomainDataPortabilityService` (merge/replace dedup),
80 and `DiffService` — before locking down external contracts.
81- **Decompose the god-files** behind that test net: `DomainViewModel.swift`
82 (~4.7k lines) and `ContentView.swift` (~3.8k lines) into focused units
83 (audit, monitoring, workflows, portability).
84
85## Cross-cutting note
86
87New feature surfaces (widgets, intents, extensions) each add a target and a
88persistence/entitlement seam. Add at least characterization tests for
89`DomainDataPortabilityService` and the report builders **before** v4.7.0, so the
90v5.0.0 contract and refactor work has a safety net rather than starting from zero.