summaryrefslogtreecommitdiff
path: root/DomainDig/DomainViewModel.swift
diff options
context:
space:
mode:
authorChristian Cleberg <[email protected]>2026-07-16 23:48:10 -0500
committerChristian Cleberg <[email protected]>2026-07-16 23:48:10 -0500
commitd8195992e519343d6d8ff7fb17aa98358800d913 (patch)
tree09ef08c1c06ac55c84299d5fd1251755fe0cac4f /DomainDig/DomainViewModel.swift
parent1edf5bc83c0dcc07246908af341f37e2c871edd7 (diff)
downloaddomain-dig-50c9027300afde38d0f2e08f0f249118eb3948f8.tar.gz
domain-dig-50c9027300afde38d0f2e08f0f249118eb3948f8.tar.bz2
domain-dig-50c9027300afde38d0f2e08f0f249118eb3948f8.zip
DomainDig v4.4.1: Consolidate Audit Mode and remove the CLI targetv4.4.1
- 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
Diffstat (limited to 'DomainDig/DomainViewModel.swift')
-rw-r--r--DomainDig/DomainViewModel.swift193
1 files changed, 193 insertions, 0 deletions
diff --git a/DomainDig/DomainViewModel.swift b/DomainDig/DomainViewModel.swift
index 88e2ec6..f0c4411 100644
--- a/DomainDig/DomainViewModel.swift
+++ b/DomainDig/DomainViewModel.swift
@@ -297,6 +297,7 @@ final class DomainViewModel {
private static let historyKey = "lookupHistory"
private static let maxHistory = 250
var history: [HistoryEntry] = DomainViewModel.loadHistoryEntries()
+ var auditSessions: [AuditSession] = DomainDataPortabilityService.loadAuditSessions()
private static let workflowsKey = "domainWorkflows"
var workflows: [DomainWorkflow] = DomainViewModel.loadWorkflows()
var historySearchText = ""
@@ -952,6 +953,7 @@ final class DomainViewModel {
savedDomains = DomainDataPortabilityService.loadSavedDomains()
trackedDomains = Self.loadTrackedDomains()
history = Self.loadHistoryEntries()
+ auditSessions = DomainDataPortabilityService.loadAuditSessions()
workflows = Self.loadWorkflows()
monitoringSettings = MonitoringStorage.sanitizeSettings(
MonitoringStorage.loadSettings(),
@@ -970,6 +972,7 @@ final class DomainViewModel {
savedDomains = []
trackedDomains = []
history = []
+ auditSessions = []
workflows = []
historySearchText = ""
historyDateFilter = .all
@@ -1464,6 +1467,154 @@ final class DomainViewModel {
return String(data: data, encoding: .utf8)
}
+ func audits(for domain: String) -> [AuditSession] {
+ auditSessions
+ .filter { $0.domain.caseInsensitiveCompare(domain) == .orderedSame }
+ .sorted { $0.createdAt > $1.createdAt }
+ }
+
+ func auditSession(withID id: UUID) -> AuditSession? {
+ auditSessions.first(where: { $0.id == id })
+ }
+
+ func auditTimeline(for domain: String) -> [AuditTimelinePoint] {
+ let sessions = audits(for: domain).sorted { $0.createdAt > $1.createdAt }
+ return sessions.map { session in
+ let repeatedIssues = sessions
+ .filter { $0.id != session.id }
+ .flatMap(\.findings)
+ .map { $0.title.lowercased() }
+ let repeatedIssueCount = session.findings.filter {
+ repeatedIssues.contains($0.title.lowercased())
+ }.count
+
+ return AuditTimelinePoint(
+ id: session.id,
+ sessionID: session.id,
+ domain: session.domain,
+ createdAt: session.createdAt,
+ status: session.status,
+ findingCount: session.findings.count,
+ openHighSeverityCount: session.findings.filter { $0.severity == .high && $0.status != .resolved }.count,
+ repeatedIssueCount: repeatedIssueCount
+ )
+ }
+ }
+
+ @discardableResult
+ func startAudit(for domain: String, reviewer: String? = nil) async -> AuditSession? {
+ let normalizedDomain = domain
+ .trimmingCharacters(in: .whitespacesAndNewlines)
+ .replacingOccurrences(of: "https://", with: "")
+ .replacingOccurrences(of: "http://", with: "")
+ .components(separatedBy: "/").first?
+ .lowercased() ?? domain.lowercased()
+ guard !normalizedDomain.isEmpty else { return nil }
+
+ let previous = historyEntries(for: normalizedDomain).first?.snapshot
+ let snapshot = await inspectionService.inspectSnapshot(domain: normalizedDomain, previousSnapshot: previous)
+ guard let entry = saveHistoryEntry(
+ from: snapshot,
+ replaceLatest: false,
+ updateCurrentState: searchedDomain.caseInsensitiveCompare(normalizedDomain) == .orderedSame
+ ) else {
+ return nil
+ }
+
+ let report = report(for: entry)
+ let historicalContext = Array(historyEntries(for: normalizedDomain).dropFirst().prefix(6)).map(\.snapshotSummary)
+ let screenshots = snapshotEvidenceAssets(from: report)
+ let session = AuditSession(
+ domain: normalizedDomain,
+ createdAt: entry.timestamp,
+ reviewer: (reviewer?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty) ?? Self.defaultAuditReviewer,
+ status: .draft,
+ evidence: AuditEvidenceSnapshot(
+ capturedAt: entry.timestamp,
+ lookup: entry,
+ report: report,
+ historicalContext: historicalContext,
+ screenshots: screenshots
+ ),
+ findings: [],
+ notes: "",
+ checklist: AuditChecklistArea.defaultItems
+ )
+
+ auditSessions.insert(session, at: 0)
+ persistAuditSessions()
+ return session
+ }
+
+ func updateAuditStatus(_ status: AuditStatus, sessionID: UUID) {
+ guard let index = auditSessions.firstIndex(where: { $0.id == sessionID }) else { return }
+ auditSessions[index].status = status
+ persistAuditSessions()
+ }
+
+ func updateAuditNotes(_ notes: String, sessionID: UUID) {
+ guard let index = auditSessions.firstIndex(where: { $0.id == sessionID }) else { return }
+ auditSessions[index].notes = notes.trimmingCharacters(in: .whitespacesAndNewlines)
+ persistAuditSessions()
+ }
+
+ func toggleAuditChecklistItem(sessionID: UUID, itemID: UUID) {
+ guard let sessionIndex = auditSessions.firstIndex(where: { $0.id == sessionID }),
+ let itemIndex = auditSessions[sessionIndex].checklist.firstIndex(where: { $0.id == itemID }) else {
+ return
+ }
+
+ auditSessions[sessionIndex].checklist[itemIndex].isComplete.toggle()
+ auditSessions[sessionIndex].checklist[itemIndex].completedAt = auditSessions[sessionIndex].checklist[itemIndex].isComplete ? Date() : nil
+ persistAuditSessions()
+ }
+
+ func addAuditFinding(
+ sessionID: UUID,
+ title: String,
+ severity: AuditFindingSeverity,
+ summary: String,
+ evidenceReferences: [String],
+ notes: String,
+ checklistAreas: [AuditChecklistArea]
+ ) {
+ guard let index = auditSessions.firstIndex(where: { $0.id == sessionID }) else { return }
+ let finding = AuditFinding(
+ title: title,
+ severity: severity,
+ summary: summary,
+ evidenceReferences: evidenceReferences,
+ notes: notes,
+ status: .open,
+ checklistAreas: checklistAreas
+ )
+ auditSessions[index].findings.insert(finding, at: 0)
+ persistAuditSessions()
+ }
+
+ func updateAuditFinding(_ finding: AuditFinding, sessionID: UUID) {
+ guard let sessionIndex = auditSessions.firstIndex(where: { $0.id == sessionID }),
+ let findingIndex = auditSessions[sessionIndex].findings.firstIndex(where: { $0.id == finding.id }) else {
+ return
+ }
+
+ var updatedFinding = finding
+ updatedFinding.updatedAt = Date()
+ auditSessions[sessionIndex].findings[findingIndex] = updatedFinding
+ persistAuditSessions()
+ }
+
+ func removeAuditFindings(at offsets: IndexSet, sessionID: UUID) {
+ guard let sessionIndex = auditSessions.firstIndex(where: { $0.id == sessionID }) else { return }
+ auditSessions[sessionIndex].findings.remove(atOffsets: offsets)
+ persistAuditSessions()
+ }
+
+ func exportAuditData(sessionID: UUID, format: AuditExportFormat) -> Data? {
+ guard let session = auditSession(withID: sessionID) else { return nil }
+ return try? AuditExporter.data(for: session, format: format)
+ }
+
func loadOwnershipHistory() async {
guard !searchedDomain.isEmpty else { return }
guard DataAccessService.hasAccess(to: .ownershipHistory) else {
@@ -2405,6 +2556,11 @@ final class DomainViewModel {
DomainDebugLog.signpostEnd("DomainViewModel.persistHistory", start: persistStartedAt, extra: "count=\(history.count)")
}
+ private func persistAuditSessions() {
+ DomainDataPortabilityService.saveAuditSessions(auditSessions)
+ refreshDataLifecycleSummary()
+ }
+
func setHistoryAutoPruneOption(_ option: HistoryAutoPruneOption) {
historyAutoPruneOption = option
UserDefaults.standard.set(option.rawValue, forKey: Self.historyAutoPruneKey)
@@ -3565,6 +3721,43 @@ final class DomainViewModel {
)
}
+ private static var defaultAuditReviewer: String {
+ let reviewer = NSFullUserName().trimmingCharacters(in: .whitespacesAndNewlines)
+ return reviewer.isEmpty ? "Local Reviewer" : reviewer
+ }
+
+ private func snapshotEvidenceAssets(from report: DomainReport) -> [AuditEvidenceAsset] {
+ var assets: [AuditEvidenceAsset] = []
+ if let finalURL = report.web.finalURL {
+ assets.append(AuditEvidenceAsset(title: "Final URL", kind: .document, reference: finalURL))
+ }
+ if let registrar = report.ownership?.registrar {
+ assets.append(AuditEvidenceAsset(title: "Registrar", kind: .document, reference: registrar))
+ }
+ if let primaryIP = report.dns.primaryIP {
+ assets.append(AuditEvidenceAsset(title: "Primary IP", kind: .document, reference: primaryIP))
+ }
+ if !report.web.redirectChain.isEmpty {
+ assets.append(
+ AuditEvidenceAsset(
+ title: "Redirect Chain",
+ kind: .document,
+ reference: report.web.redirectChain.map { "\($0.statusCode) \($0.url)" }.joined(separator: " | ")
+ )
+ )
+ }
+ if !report.web.headers.isEmpty {
+ assets.append(
+ AuditEvidenceAsset(
+ title: "Observed Headers",
+ kind: .document,
+ reference: report.web.headers.prefix(6).map { "\($0.name): \($0.value)" }.joined(separator: " | ")
+ )
+ )
+ }
+ return assets
+ }
+
private func placeholderSnapshot(for trackedDomain: TrackedDomain) -> LookupSnapshot {
LookupSnapshot(
historyEntryID: trackedDomain.lastSnapshotID,