summaryrefslogtreecommitdiff
path: root/DomainDig
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
parent1edf5bc83c0dcc07246908af341f37e2c871edd7 (diff)
downloaddomain-dig-4.4.1.tar.gz
domain-dig-4.4.1.tar.bz2
domain-dig-4.4.1.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')
-rw-r--r--DomainDig/AppVersion.swift2
-rw-r--r--DomainDig/ContentView.swift24
-rw-r--r--DomainDig/DomainDig/AuditExporter.swift157
-rw-r--r--DomainDig/DomainDig/AuditModels.swift279
-rw-r--r--DomainDig/DomainDig/AuditViews.swift571
-rw-r--r--DomainDig/DomainViewModel.swift193
-rw-r--r--DomainDig/RootTabView.swift18
-rw-r--r--DomainDig/WatchlistView.swift28
8 files changed, 1262 insertions, 10 deletions
diff --git a/DomainDig/AppVersion.swift b/DomainDig/AppVersion.swift
index cc675b0..07d3664 100644
--- a/DomainDig/AppVersion.swift
+++ b/DomainDig/AppVersion.swift
@@ -2,6 +2,6 @@ import Foundation
enum AppVersion {
nonisolated static var current: String {
- "3.5.0"
+ "4.4.1"
}
}
diff --git a/DomainDig/ContentView.swift b/DomainDig/ContentView.swift
index 5a469e1..30d693e 100644
--- a/DomainDig/ContentView.swift
+++ b/DomainDig/ContentView.swift
@@ -44,6 +44,8 @@ struct ContentView: View {
@State private var showingCurrentDomainWorkflowSheet = false
@State private var showingBatchWorkflowSheet = false
@State private var showingTimeline = false
+ @State private var showingAuditTimeline = false
+ @State private var auditStartInFlight = false
var body: some View {
let _ = purchaseService.currentTier
@@ -274,6 +276,11 @@ struct ContentView: View {
TimelineView(viewModel: viewModel, domain: viewModel.searchedDomain)
}
}
+ .sheet(isPresented: $showingAuditTimeline) {
+ NavigationStack {
+ AuditDomainTimelineView(viewModel: viewModel, domain: viewModel.searchedDomain)
+ }
+ }
}
private var manualBatchSummaryBinding: Binding<BatchSweepSummary?> {
@@ -508,6 +515,21 @@ struct ContentView: View {
Button("Add to workflow") {
showingCurrentDomainWorkflowSheet = true
}
+ Button(auditStartInFlight ? "Starting audit…" : "Start audit") {
+ Task {
+ auditStartInFlight = true
+ if await viewModel.startAudit(for: viewModel.searchedDomain) != nil {
+ showingAuditTimeline = true
+ }
+ auditStartInFlight = false
+ }
+ }
+ .disabled(auditStartInFlight)
+ if !viewModel.audits(for: viewModel.searchedDomain).isEmpty {
+ Button("View audits") {
+ showingAuditTimeline = true
+ }
+ }
if !viewModel.historyEntries(for: viewModel.searchedDomain).isEmpty {
Button("Open timeline") {
showingTimeline = true
@@ -3301,6 +3323,7 @@ private struct DataPortabilitySettingsView: View {
Section("Local Data") {
LabeledContent("Tracked Domains", value: "\(viewModel.dataLifecycleSummary.trackedDomains)")
LabeledContent("History Snapshots", value: "\(viewModel.dataLifecycleSummary.historySnapshots)")
+ LabeledContent("Audit Sessions", value: "\(viewModel.dataLifecycleSummary.auditSessions)")
LabeledContent("Workflows", value: "\(viewModel.dataLifecycleSummary.workflows)")
LabeledContent("Cached Items", value: "\(viewModel.dataLifecycleSummary.cachedItems)")
LabeledContent("Monitoring Logs", value: "\(viewModel.dataLifecycleSummary.monitoringLogs)")
@@ -3702,6 +3725,7 @@ private struct DataImportPreviewSheet: View {
Section("Projected Counts") {
LabeledContent("Tracked Domains", value: "\(preview.projectedCounts.trackedDomains)")
LabeledContent("History Snapshots", value: "\(preview.projectedCounts.historySnapshots)")
+ LabeledContent("Audit Sessions", value: "\(preview.projectedCounts.auditSessions)")
LabeledContent("Workflows", value: "\(preview.projectedCounts.workflows)")
LabeledContent("Cached Items", value: "\(preview.projectedCounts.cachedItems)")
LabeledContent("Monitoring Logs", value: "\(preview.projectedCounts.monitoringLogs)")
diff --git a/DomainDig/DomainDig/AuditExporter.swift b/DomainDig/DomainDig/AuditExporter.swift
new file mode 100644
index 0000000..0d9a098
--- /dev/null
+++ b/DomainDig/DomainDig/AuditExporter.swift
@@ -0,0 +1,157 @@
+import Foundation
+
+#if canImport(UIKit)
+import UIKit
+#endif
+
+enum AuditExportFormat: String, CaseIterable, Identifiable {
+ case pdf
+ case json
+ case markdown
+
+ var id: String { rawValue }
+
+ var fileExtension: String { rawValue == "markdown" ? "md" : rawValue }
+}
+
+enum AuditExporter {
+ static func data(for session: AuditSession, format: AuditExportFormat) throws -> Data {
+ switch format {
+ case .json:
+ let encoder = JSONEncoder()
+ encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
+ encoder.dateEncodingStrategy = .iso8601
+ return try encoder.encode(session)
+ case .markdown:
+ return Data(markdown(for: session).utf8)
+ case .pdf:
+ return try pdfData(for: session)
+ }
+ }
+
+ static func markdown(for session: AuditSession) -> String {
+ var lines: [String] = [
+ "# DomainDig Audit",
+ "",
+ "- Domain: \(session.domain)",
+ "- Review Date: \(session.createdAt.formatted(date: .abbreviated, time: .shortened))",
+ "- Reviewer: \(session.reviewer)",
+ "- Status: \(session.status.title)",
+ "- Evidence Captured: \(session.evidence.capturedAt.formatted(date: .abbreviated, time: .shortened))",
+ "- Checklist Progress: \(session.completedChecklistCount)/\(session.checklist.count)",
+ ""
+ ]
+
+ if !session.notes.isEmpty {
+ lines.append("## Summary Notes")
+ lines.append(session.notes)
+ lines.append("")
+ }
+
+ lines.append("## Findings Summary")
+ if session.findings.isEmpty {
+ lines.append("No findings recorded.")
+ } else {
+ for finding in session.findings {
+ lines.append("- [\(finding.severity.title)] \(finding.title) (\(finding.status.title))")
+ }
+ }
+ lines.append("")
+
+ lines.append("## Key Risks")
+ if session.keyRisks.isEmpty {
+ lines.append("No medium or high-severity risks recorded.")
+ } else {
+ for finding in session.keyRisks {
+ lines.append("- \(finding.title): \(finding.summary)")
+ }
+ }
+ lines.append("")
+
+ lines.append("## Checklist")
+ for item in session.checklist {
+ lines.append("- [\(item.isComplete ? "x" : " ")] \(item.title): \(item.detail)")
+ }
+ lines.append("")
+
+ lines.append("## Findings")
+ if session.findings.isEmpty {
+ lines.append("No findings recorded.")
+ } else {
+ for finding in session.findings {
+ lines.append("### \(finding.title)")
+ lines.append("- Severity: \(finding.severity.title)")
+ lines.append("- Status: \(finding.status.title)")
+ if !finding.checklistAreas.isEmpty {
+ lines.append("- Checklist Areas: \(finding.checklistAreas.map(\.title).joined(separator: ", "))")
+ }
+ lines.append("- Summary: \(finding.summary)")
+ if !finding.evidenceReferences.isEmpty {
+ lines.append("- Evidence: \(finding.evidenceReferences.joined(separator: " | "))")
+ }
+ if !finding.notes.isEmpty {
+ lines.append("- Notes: \(finding.notes)")
+ }
+ lines.append("")
+ }
+ }
+
+ lines.append("## Evidence Snapshot")
+ lines.append("- Availability: \(availabilityTitle(session.evidence.report.availability))")
+ lines.append("- Risk Score: \(session.evidence.report.riskAssessment.score) (\(session.evidence.report.riskAssessment.level.title))")
+ lines.append("- TLS Status: \(session.evidence.report.web.tlsStatus)")
+ lines.append("- Final URL: \(session.evidence.report.web.finalURL ?? "Unavailable")")
+ lines.append("- Primary IP: \(session.evidence.report.dns.primaryIP ?? "Unavailable")")
+ lines.append("- Reachability: \(session.evidence.report.network.reachabilitySummary)")
+ lines.append("- Ownership: \(session.evidence.report.ownership?.registrar ?? "Unavailable")")
+ lines.append("- Historical Context Snapshots: \(session.evidence.historicalContext.count)")
+
+ return lines.joined(separator: "\n")
+ }
+
+ private static func availabilityTitle(_ status: DomainAvailabilityStatus) -> String {
+ switch status {
+ case .available:
+ return "Available"
+ case .registered:
+ return "Registered"
+ case .unknown:
+ return "Unknown"
+ }
+ }
+
+ private static func pdfData(for session: AuditSession) throws -> Data {
+ let markdown = markdown(for: session)
+ #if canImport(UIKit)
+ let renderer = UIGraphicsPDFRenderer(bounds: CGRect(x: 0, y: 0, width: 612, height: 792))
+ return renderer.pdfData { context in
+ let lines = markdown.components(separatedBy: .newlines)
+ let paragraphStyle = NSMutableParagraphStyle()
+ paragraphStyle.lineBreakMode = .byWordWrapping
+ let attributes: [NSAttributedString.Key: Any] = [
+ .font: UIFont.monospacedSystemFont(ofSize: 11, weight: .regular),
+ .paragraphStyle: paragraphStyle
+ ]
+
+ var yOffset: CGFloat = 36
+ context.beginPage()
+
+ for line in lines {
+ if yOffset > 744 {
+ context.beginPage()
+ yOffset = 36
+ }
+
+ let renderedLine = NSString(string: line.isEmpty ? " " : line)
+ renderedLine.draw(
+ in: CGRect(x: 36, y: yOffset, width: 540, height: 22),
+ withAttributes: attributes
+ )
+ yOffset += 16
+ }
+ }
+ #else
+ return Data(markdown.utf8)
+ #endif
+ }
+}
diff --git a/DomainDig/DomainDig/AuditModels.swift b/DomainDig/DomainDig/AuditModels.swift
new file mode 100644
index 0000000..28eb9c9
--- /dev/null
+++ b/DomainDig/DomainDig/AuditModels.swift
@@ -0,0 +1,279 @@
+import Foundation
+
+enum AuditStatus: String, Codable, Sendable, CaseIterable, Identifiable {
+ case draft
+ case inReview = "in_review"
+ case complete
+
+ var id: String { rawValue }
+
+ var title: String {
+ switch self {
+ case .draft:
+ return "Draft"
+ case .inReview:
+ return "In Review"
+ case .complete:
+ return "Complete"
+ }
+ }
+}
+
+enum AuditFindingSeverity: String, Codable, Sendable, CaseIterable, Identifiable {
+ case informational
+ case low
+ case medium
+ case high
+
+ var id: String { rawValue }
+
+ var title: String { rawValue.capitalized }
+}
+
+enum AuditFindingStatus: String, Codable, Sendable, CaseIterable, Identifiable {
+ case open
+ case reviewing
+ case resolved
+
+ var id: String { rawValue }
+
+ var title: String { rawValue.capitalized }
+}
+
+enum AuditChecklistArea: String, Codable, Sendable, CaseIterable, Identifiable {
+ case dnsReview
+ case certificateReview
+ case redirectReview
+ case headerReview
+ case ownershipReview
+ case infrastructureReview
+ case monitoringHistoryReview
+
+ var id: String { rawValue }
+
+ var title: String {
+ switch self {
+ case .dnsReview:
+ return "DNS Review"
+ case .certificateReview:
+ return "Certificate Review"
+ case .redirectReview:
+ return "Redirect Review"
+ case .headerReview:
+ return "Header Review"
+ case .ownershipReview:
+ return "Ownership Review"
+ case .infrastructureReview:
+ return "Infrastructure Review"
+ case .monitoringHistoryReview:
+ return "Monitoring History Review"
+ }
+ }
+
+ var prompt: String {
+ switch self {
+ case .dnsReview:
+ return "Validate authoritative records, nameservers, and DNSSEC posture."
+ case .certificateReview:
+ return "Review certificate validity, expiry, issuer, and transport security."
+ case .redirectReview:
+ return "Confirm redirect targets, hop count, and protocol transitions."
+ case .headerReview:
+ return "Check security headers and HTTP response posture."
+ case .ownershipReview:
+ return "Review registration, registrar, and ownership evidence."
+ case .infrastructureReview:
+ return "Assess reachability, exposed services, and network context."
+ case .monitoringHistoryReview:
+ return "Compare this audit with prior snapshots and repeated issues."
+ }
+ }
+}
+
+struct AuditChecklistItem: Identifiable, Codable, Sendable, Equatable {
+ let id: UUID
+ let area: AuditChecklistArea
+ var title: String
+ var detail: String
+ var isComplete: Bool
+ var completedAt: Date?
+
+ init(
+ id: UUID = UUID(),
+ area: AuditChecklistArea,
+ title: String,
+ detail: String,
+ isComplete: Bool = false,
+ completedAt: Date? = nil
+ ) {
+ self.id = id
+ self.area = area
+ self.title = title
+ self.detail = detail
+ self.isComplete = isComplete
+ self.completedAt = completedAt
+ }
+}
+
+enum AuditEvidenceAssetKind: String, Codable, Sendable {
+ case screenshot
+ case document
+ case note
+}
+
+struct AuditEvidenceAsset: Identifiable, Codable, Sendable, Equatable {
+ let id: UUID
+ var title: String
+ var kind: AuditEvidenceAssetKind
+ var reference: String
+
+ init(id: UUID = UUID(), title: String, kind: AuditEvidenceAssetKind, reference: String) {
+ self.id = id
+ self.title = title
+ self.kind = kind
+ self.reference = reference
+ }
+}
+
+struct AuditEvidenceSnapshot: Codable {
+ var capturedAt: Date
+ var lookup: HistoryEntry
+ var report: DomainReport
+ var historicalContext: [SnapshotSummary]
+ var screenshots: [AuditEvidenceAsset]
+}
+
+struct AuditFinding: Identifiable, Codable, Sendable, Equatable {
+ var id: UUID
+ var title: String
+ var severity: AuditFindingSeverity
+ var summary: String
+ var evidenceReferences: [String]
+ var notes: String
+ var status: AuditFindingStatus
+ var checklistAreas: [AuditChecklistArea]
+ var createdAt: Date
+ var updatedAt: Date
+
+ init(
+ id: UUID = UUID(),
+ title: String,
+ severity: AuditFindingSeverity,
+ summary: String,
+ evidenceReferences: [String],
+ notes: String,
+ status: AuditFindingStatus = .open,
+ checklistAreas: [AuditChecklistArea] = [],
+ createdAt: Date = Date(),
+ updatedAt: Date = Date()
+ ) {
+ self.id = id
+ self.title = title
+ self.severity = severity
+ self.summary = summary
+ self.evidenceReferences = evidenceReferences
+ self.notes = notes
+ self.status = status
+ self.checklistAreas = checklistAreas
+ self.createdAt = createdAt
+ self.updatedAt = updatedAt
+ }
+}
+
+struct AuditSession: Identifiable, Codable {
+ var id: UUID
+ var domain: String
+ var createdAt: Date
+ var reviewer: String
+ var status: AuditStatus
+ var evidence: AuditEvidenceSnapshot
+ var findings: [AuditFinding]
+ var notes: String
+ var checklist: [AuditChecklistItem]
+
+ init(
+ id: UUID = UUID(),
+ domain: String,
+ createdAt: Date = Date(),
+ reviewer: String,
+ status: AuditStatus,
+ evidence: AuditEvidenceSnapshot,
+ findings: [AuditFinding] = [],
+ notes: String = "",
+ checklist: [AuditChecklistItem] = AuditChecklistArea.defaultItems
+ ) {
+ self.id = id
+ self.domain = domain
+ self.createdAt = createdAt
+ self.reviewer = reviewer
+ self.status = status
+ self.evidence = evidence
+ self.findings = findings
+ self.notes = notes
+ self.checklist = checklist
+ }
+
+ var completedChecklistCount: Int {
+ checklist.filter(\.isComplete).count
+ }
+
+ var checklistProgress: Double {
+ guard !checklist.isEmpty else { return 0 }
+ return Double(completedChecklistCount) / Double(checklist.count)
+ }
+
+ var highestSeverity: AuditFindingSeverity? {
+ findings.max { lhs, rhs in
+ lhs.severity.sortOrder < rhs.severity.sortOrder
+ }?.severity
+ }
+
+ var keyRisks: [AuditFinding] {
+ findings
+ .filter { $0.severity == .high || $0.severity == .medium }
+ .sorted { lhs, rhs in
+ if lhs.severity.sortOrder == rhs.severity.sortOrder {
+ return lhs.updatedAt > rhs.updatedAt
+ }
+ return lhs.severity.sortOrder > rhs.severity.sortOrder
+ }
+ }
+}
+
+extension AuditFindingSeverity {
+ var sortOrder: Int {
+ switch self {
+ case .informational:
+ return 0
+ case .low:
+ return 1
+ case .medium:
+ return 2
+ case .high:
+ return 3
+ }
+ }
+}
+
+extension AuditChecklistArea {
+ static var defaultItems: [AuditChecklistItem] {
+ allCases.map { area in
+ AuditChecklistItem(
+ area: area,
+ title: area.title,
+ detail: area.prompt
+ )
+ }
+ }
+}
+
+struct AuditTimelinePoint: Identifiable, Equatable {
+ let id: UUID
+ let sessionID: UUID
+ let domain: String
+ let createdAt: Date
+ let status: AuditStatus
+ let findingCount: Int
+ let openHighSeverityCount: Int
+ let repeatedIssueCount: Int
+}
diff --git a/DomainDig/DomainDig/AuditViews.swift b/DomainDig/DomainDig/AuditViews.swift
new file mode 100644
index 0000000..8602bb3
--- /dev/null
+++ b/DomainDig/DomainDig/AuditViews.swift
@@ -0,0 +1,571 @@
+import SwiftUI
+
+struct AuditListView: View {
+ @Environment(\.appDensity) private var appDensity
+ @Bindable var viewModel: DomainViewModel
+ @State private var auditStartInFlight = false
+
+ private var groupedSessions: [(domain: String, sessions: [AuditSession])] {
+ Dictionary(grouping: viewModel.auditSessions) { $0.domain.lowercased() }
+ .values
+ .map { sessions in
+ let sorted = sessions.sorted { $0.createdAt > $1.createdAt }
+ return (domain: sorted.first?.domain ?? "unknown", sessions: sorted)
+ }
+ .sorted { $0.domain < $1.domain }
+ }
+
+ var body: some View {
+ List {
+ if groupedSessions.isEmpty {
+ Section {
+ EmptyStateCardView(
+ title: "No Audits Yet",
+ message: "Audit Mode captures evidence, findings, checklist progress, and point-in-time review output for each domain assessment.",
+ suggestion: "Open Inspect for a domain and start an audit to create your first review session.",
+ systemImage: "checklist.unchecked",
+ showsCardBackground: false
+ )
+ }
+ .listRowBackground(Color(.systemGray6).opacity(0.5))
+ } else {
+ Section("Audit Domains") {
+ ForEach(groupedSessions, id: \.domain) { group in
+ NavigationLink {
+ AuditDomainTimelineView(viewModel: viewModel, domain: group.domain)
+ } label: {
+ VStack(alignment: .leading, spacing: appDensity.metrics.rowSpacing + 2) {
+ HStack {
+ Text(group.domain)
+ .font(appDensity.font(.callout, design: .default, weight: .semibold))
+ Spacer()
+ Text("\(group.sessions.count) audit\(group.sessions.count == 1 ? "" : "s")")
+ .font(appDensity.font(.caption2))
+ .foregroundStyle(.secondary)
+ }
+
+ if let latest = group.sessions.first {
+ Text(latest.findings.isEmpty ? "No findings recorded yet" : latest.findings.map(\.title).prefix(2).joined(separator: " • "))
+ .font(appDensity.font(.caption))
+ .foregroundStyle(.secondary)
+ .lineLimit(2)
+
+ HStack(spacing: 8) {
+ auditStatusBadge(latest.status)
+ if let severity = latest.highestSeverity {
+ findingSeverityBadge(severity)
+ }
+ Text("Checklist \(latest.completedChecklistCount)/\(latest.checklist.count)")
+ .font(appDensity.font(.caption2))
+ .foregroundStyle(.secondary)
+ }
+ }
+ }
+ .padding(.vertical, 4)
+ }
+ }
+ }
+ .listRowBackground(Color(.systemGray6).opacity(0.5))
+ }
+ }
+ .scrollContentBackground(.hidden)
+ .background(Color.black)
+ .navigationTitle("Audit Mode")
+ .toolbar {
+ if !viewModel.searchedDomain.isEmpty {
+ ToolbarItem(placement: .topBarTrailing) {
+ Button(auditStartInFlight ? "Auditing…" : "Start Audit") {
+ Task {
+ auditStartInFlight = true
+ _ = await viewModel.startAudit(for: viewModel.searchedDomain)
+ auditStartInFlight = false
+ }
+ }
+ .disabled(auditStartInFlight)
+ }
+ }
+ }
+ .preferredColorScheme(.dark)
+ }
+}
+
+struct AuditDomainTimelineView: View {
+ @Environment(\.appDensity) private var appDensity
+ @Bindable var viewModel: DomainViewModel
+ let domain: String
+ @State private var auditStartInFlight = false
+
+ private var sessions: [AuditSession] {
+ viewModel.audits(for: domain)
+ }
+
+ var body: some View {
+ List {
+ Section("Sessions") {
+ ForEach(sessions) { session in
+ NavigationLink {
+ AuditSessionDetailView(viewModel: viewModel, sessionID: session.id)
+ } label: {
+ VStack(alignment: .leading, spacing: appDensity.metrics.rowSpacing + 2) {
+ HStack {
+ Text(session.createdAt.formatted(date: .abbreviated, time: .shortened))
+ .font(appDensity.font(.callout))
+ Spacer()
+ auditStatusBadge(session.status)
+ }
+
+ Text("Reviewer: \(session.reviewer)")
+ .font(appDensity.font(.caption))
+ .foregroundStyle(.secondary)
+
+ HStack(spacing: 8) {
+ Text("\(session.findings.count) findings")
+ Text("Checklist \(session.completedChecklistCount)/\(session.checklist.count)")
+ if let severity = session.highestSeverity {
+ Text("Top \(severity.title)")
+ }
+ }
+ .font(appDensity.font(.caption2))
+ .foregroundStyle(.secondary)
+ }
+ .padding(.vertical, 4)
+ }
+ }
+ }
+ .listRowBackground(Color(.systemGray6).opacity(0.5))
+
+ Section("Trend") {
+ ForEach(viewModel.auditTimeline(for: domain)) { point in
+ VStack(alignment: .leading, spacing: 4) {
+ HStack {
+ Text(point.createdAt.formatted(date: .abbreviated, time: .shortened))
+ .font(appDensity.font(.caption, weight: .semibold))
+ Spacer()
+ Text("\(point.findingCount) findings")
+ .font(appDensity.font(.caption2))
+ .foregroundStyle(.secondary)
+ }
+ Text("Open high severity: \(point.openHighSeverityCount) • Repeated issues: \(point.repeatedIssueCount)")
+ .font(appDensity.font(.caption2))
+ .foregroundStyle(.secondary)
+ }
+ .padding(.vertical, 2)
+ }
+ }
+ .listRowBackground(Color(.systemGray6).opacity(0.5))
+ }
+ .scrollContentBackground(.hidden)
+ .background(Color.black)
+ .navigationTitle(domain)
+ .toolbar {
+ ToolbarItem(placement: .topBarTrailing) {
+ Button(auditStartInFlight ? "Auditing…" : "New Audit") {
+ Task {
+ auditStartInFlight = true
+ _ = await viewModel.startAudit(for: domain)
+ auditStartInFlight = false
+ }
+ }
+ .disabled(auditStartInFlight)
+ }
+ }
+ }
+}
+
+struct AuditSessionDetailView: View {
+ @Environment(\.appDensity) private var appDensity
+ @Bindable var viewModel: DomainViewModel
+ let sessionID: UUID
+
+ @State private var notesDraft = ""
+ @State private var showingFindingEditor = false
+ @State private var editingFinding: AuditFinding?
+
+ private var session: AuditSession? {
+ viewModel.auditSession(withID: sessionID)
+ }
+
+ var body: some View {
+ Group {
+ if let session {
+ List {
+ Section {
+ VStack(alignment: .leading, spacing: appDensity.metrics.cardSpacing) {
+ HStack {
+ VStack(alignment: .leading, spacing: 4) {
+ Text(session.domain)
+ .font(appDensity.font(.headline, design: .default, weight: .semibold))
+ Text("Reviewer: \(session.reviewer)")
+ .font(appDensity.font(.caption))
+ .foregroundStyle(.secondary)
+ }
+ Spacer()
+ auditStatusBadge(session.status)
+ }
+
+ Picker("Status", selection: Binding(
+ get: { session.status },
+ set: { viewModel.updateAuditStatus($0, sessionID: session.id) }
+ )) {
+ ForEach(AuditStatus.allCases) { status in
+ Text(status.title).tag(status)
+ }
+ }
+ .pickerStyle(.segmented)
+
+ Text("Captured \(session.evidence.capturedAt.formatted(date: .abbreviated, time: .shortened))")
+ .font(appDensity.font(.caption2))
+ .foregroundStyle(.secondary)
+ }
+ }
+ .listRowBackground(Color(.systemGray6).opacity(0.5))
+
+ Section("Audit Summary") {
+ LabeledContent("Findings", value: "\(session.findings.count)")
+ LabeledContent("Checklist", value: "\(session.completedChecklistCount)/\(session.checklist.count)")
+ LabeledContent("Risk Score", value: "\(session.evidence.report.riskAssessment.score)")
+ if let severity = session.highestSeverity {
+ LabeledContent("Highest Severity", value: severity.title)
+ }
+ }
+ .listRowBackground(Color(.systemGray6).opacity(0.5))
+
+ Section("Reviewer Notes") {
+ TextEditor(text: $notesDraft)
+ .frame(minHeight: 120)
+ .scrollContentBackground(.hidden)
+ .background(Color.clear)
+
+ Button("Save Notes") {
+ viewModel.updateAuditNotes(notesDraft, sessionID: session.id)
+ }
+ }
+ .listRowBackground(Color(.systemGray6).opacity(0.5))
+
+ Section("Checklist") {
+ ForEach(session.checklist) { item in
+ Button {
+ viewModel.toggleAuditChecklistItem(sessionID: session.id, itemID: item.id)
+ } label: {
+ HStack(alignment: .top, spacing: 10) {
+ Image(systemName: item.isComplete ? "checkmark.circle.fill" : "circle")
+ .foregroundStyle(item.isComplete ? Color.green : .secondary)
+ VStack(alignment: .leading, spacing: 4) {
+ Text(item.title)
+ .font(appDensity.font(.callout))
+ .foregroundStyle(.primary)
+ Text(item.detail)
+ .font(appDensity.font(.caption))
+ .foregroundStyle(.secondary)
+ }
+ Spacer()
+ }
+ }
+ .buttonStyle(.plain)
+ }
+ }
+ .listRowBackground(Color(.systemGray6).opacity(0.5))
+
+ Section("Findings") {
+ if session.findings.isEmpty {
+ Text("No findings recorded.")
+ .font(appDensity.font(.caption))
+ .foregroundStyle(.secondary)
+ } else {
+ ForEach(session.findings) { finding in
+ Button {
+ editingFinding = finding
+ } label: {
+ VStack(alignment: .leading, spacing: 6) {
+ HStack {
+ Text(finding.title)
+ .font(appDensity.font(.callout, design: .default, weight: .semibold))
+ .foregroundStyle(.primary)
+ Spacer()
+ findingSeverityBadge(finding.severity)
+ }
+ Text(finding.summary)
+ .font(appDensity.font(.caption))
+ .foregroundStyle(.secondary)
+ .lineLimit(2)
+ Text("\(finding.status.title) • \(finding.evidenceReferences.count) evidence refs")
+ .font(appDensity.font(.caption2))
+ .foregroundStyle(.secondary)
+ }
+ .padding(.vertical, 2)
+ }
+ .buttonStyle(.plain)
+ }
+ .onDelete { offsets in
+ viewModel.removeAuditFindings(at: offsets, sessionID: session.id)
+ }
+ }
+ }
+ .listRowBackground(Color(.systemGray6).opacity(0.5))
+
+ Section("Evidence Snapshot") {
+ LabeledContent("Availability", value: availabilityTitle(session.evidence.report.availability))
+ LabeledContent("Primary IP", value: session.evidence.report.dns.primaryIP ?? "Unavailable")
+ LabeledContent("TLS", value: session.evidence.report.web.tlsStatus)
+ LabeledContent("Final URL", value: session.evidence.report.web.finalURL ?? "Unavailable")
+ LabeledContent("Reachability", value: session.evidence.report.network.reachabilitySummary)
+ LabeledContent("Registrar", value: session.evidence.report.ownership?.registrar ?? "Unavailable")
+ }
+ .listRowBackground(Color(.systemGray6).opacity(0.5))
+
+ if !session.evidence.historicalContext.isEmpty {
+ Section("Historical Context") {
+ ForEach(session.evidence.historicalContext) { entry in
+ VStack(alignment: .leading, spacing: 4) {
+ Text(entry.timestamp.formatted(date: .abbreviated, time: .shortened))
+ .font(appDensity.font(.caption, weight: .semibold))
+ Text(entry.changeSummaryMessage ?? "No change summary")
+ .font(appDensity.font(.caption2))
+ .foregroundStyle(.secondary)
+ }
+ }
+ }
+ .listRowBackground(Color(.systemGray6).opacity(0.5))
+ }
+
+ Section("Audit Timeline") {
+ ForEach(viewModel.auditTimeline(for: session.domain)) { point in
+ VStack(alignment: .leading, spacing: 4) {
+ HStack {
+ Text(point.createdAt.formatted(date: .abbreviated, time: .shortened))
+ .font(appDensity.font(.caption, weight: .semibold))
+ Spacer()
+ Text(point.status.title)
+ .font(appDensity.font(.caption2))
+ .foregroundStyle(.secondary)
+ }
+ Text("Findings \(point.findingCount) • Open high \(point.openHighSeverityCount) • Repeated \(point.repeatedIssueCount)")
+ .font(appDensity.font(.caption2))
+ .foregroundStyle(.secondary)
+ }
+ }
+ }
+ .listRowBackground(Color(.systemGray6).opacity(0.5))
+ }
+ .scrollContentBackground(.hidden)
+ .background(Color.black)
+ .navigationTitle("Audit Session")
+ .toolbar {
+ ToolbarItemGroup(placement: .topBarTrailing) {
+ Button("Add Finding") {
+ editingFinding = nil
+ showingFindingEditor = true
+ }
+ Menu("Export") {
+ ForEach(AuditExportFormat.allCases) { format in
+ Button("Export \(format.fileExtension.uppercased())") {
+ guard let data = viewModel.exportAuditData(sessionID: session.id, format: format) else { return }
+ ExportPresenter.share(
+ filename: "\(session.domain)-audit-\(session.createdAt.ISO8601Format()).\(format.fileExtension)",
+ data: data
+ )
+ }
+ }
+ }
+ }
+ }
+ .onAppear {
+ notesDraft = session.notes
+ }
+ .onChange(of: session.notes) { _, newValue in
+ notesDraft = newValue
+ }
+ .sheet(isPresented: $showingFindingEditor) {
+ AuditFindingEditorView(
+ existingFinding: nil,
+ session: session
+ ) { draft in
+ viewModel.addAuditFinding(
+ sessionID: session.id,
+ title: draft.title,
+ severity: draft.severity,
+ summary: draft.summary,
+ evidenceReferences: draft.evidenceReferences,
+ notes: draft.notes,
+ checklistAreas: draft.checklistAreas
+ )
+ }
+ }
+ .sheet(item: $editingFinding) { finding in
+ AuditFindingEditorView(existingFinding: finding, session: session) { updated in
+ viewModel.updateAuditFinding(updated, sessionID: session.id)
+ }
+ }
+ } else {
+ ContentUnavailableView("Audit Session Missing", systemImage: "exclamationmark.triangle")
+ .background(Color.black)
+ }
+ }
+ .preferredColorScheme(.dark)
+ }
+}
+
+private struct AuditFindingEditorView: View {
+ @Environment(\.dismiss) private var dismiss
+
+ let existingFinding: AuditFinding?
+ let session: AuditSession
+ let onSave: (AuditFinding) -> Void
+
+ @State private var title: String
+ @State private var severity: AuditFindingSeverity
+ @State private var summary: String
+ @State private var evidenceReferencesText: String
+ @State private var notes: String
+ @State private var status: AuditFindingStatus
+ @State private var selectedAreas: Set<AuditChecklistArea>
+
+ init(existingFinding: AuditFinding?, session: AuditSession, onSave: @escaping (AuditFinding) -> Void) {
+ self.existingFinding = existingFinding
+ self.session = session
+ self.onSave = onSave
+ _title = State(initialValue: existingFinding?.title ?? "")
+ _severity = State(initialValue: existingFinding?.severity ?? .medium)
+ _summary = State(initialValue: existingFinding?.summary ?? "")
+ _evidenceReferencesText = State(initialValue: existingFinding?.evidenceReferences.joined(separator: "\n") ?? "")
+ _notes = State(initialValue: existingFinding?.notes ?? "")
+ _status = State(initialValue: existingFinding?.status ?? .open)
+ _selectedAreas = State(initialValue: Set(existingFinding?.checklistAreas ?? []))
+ }
+
+ var body: some View {
+ NavigationStack {
+ Form {
+ Section("Finding") {
+ TextField("Title", text: $title)
+ Picker("Severity", selection: $severity) {
+ ForEach(AuditFindingSeverity.allCases) { severity in
+ Text(severity.title).tag(severity)
+ }
+ }
+ Picker("Status", selection: $status) {
+ ForEach(AuditFindingStatus.allCases) { status in
+ Text(status.title).tag(status)
+ }
+ }
+ TextField("Summary", text: $summary, axis: .vertical)
+ .lineLimit(3...6)
+ }
+
+ Section("Evidence References") {
+ TextField("One reference per line", text: $evidenceReferencesText, axis: .vertical)
+ .lineLimit(4...8)
+ if !session.evidence.screenshots.isEmpty {
+ ForEach(session.evidence.screenshots) { asset in
+ Text("\(asset.title): \(asset.reference)")
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ }
+ }
+ }
+
+ Section("Checklist Areas") {
+ ForEach(AuditChecklistArea.allCases) { area in
+ Button {
+ if selectedAreas.contains(area) {
+ selectedAreas.remove(area)
+ } else {
+ selectedAreas.insert(area)
+ }
+ } label: {
+ HStack {
+ Text(area.title)
+ Spacer()
+ Image(systemName: selectedAreas.contains(area) ? "checkmark.circle.fill" : "circle")
+ .foregroundStyle(selectedAreas.contains(area) ? Color.green : .secondary)
+ }
+ }
+ .buttonStyle(.plain)
+ }
+ }
+
+ Section("Notes") {
+ TextField("Optional notes", text: $notes, axis: .vertical)
+ .lineLimit(4...8)
+ }
+ }
+ .navigationTitle(existingFinding == nil ? "New Finding" : "Edit Finding")
+ .toolbar {
+ ToolbarItem(placement: .cancellationAction) {
+ Button("Cancel") {
+ dismiss()
+ }
+ }
+ ToolbarItem(placement: .confirmationAction) {
+ Button("Save") {
+ onSave(
+ AuditFinding(
+ id: existingFinding?.id ?? UUID(),
+ title: title.trimmingCharacters(in: .whitespacesAndNewlines),
+ severity: severity,
+ summary: summary.trimmingCharacters(in: .whitespacesAndNewlines),
+ evidenceReferences: evidenceReferencesText
+ .split(separator: "\n")
+ .map { String($0).trimmingCharacters(in: .whitespacesAndNewlines) }
+ .filter { !$0.isEmpty },
+ notes: notes.trimmingCharacters(in: .whitespacesAndNewlines),
+ status: status,
+ checklistAreas: Array(selectedAreas).sorted { $0.title < $1.title },
+ createdAt: existingFinding?.createdAt ?? Date(),
+ updatedAt: Date()
+ )
+ )
+ dismiss()
+ }
+ .disabled(title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || summary.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
+ }
+ }
+ }
+ }
+}
+
+private func auditStatusBadge(_ status: AuditStatus) -> some View {
+ let model: AppStatusBadgeModel
+ switch status {
+ case .draft:
+ model = .init(title: "Draft", systemImage: "square.and.pencil", foregroundColor: .yellow, backgroundColor: .yellow.opacity(0.16))
+ case .inReview:
+ model = .init(title: "In Review", systemImage: "doc.text.magnifyingglass", foregroundColor: .cyan, backgroundColor: .cyan.opacity(0.16))
+ case .complete:
+ model = .init(title: "Complete", systemImage: "checkmark.seal.fill", foregroundColor: .green, backgroundColor: .green.opacity(0.16))
+ }
+ return AppStatusBadgeView(model: model)
+}
+
+private func findingSeverityBadge(_ severity: AuditFindingSeverity) -> some View {
+ let color: Color
+ switch severity {
+ case .informational:
+ color = .secondary
+ case .low:
+ color = .green
+ case .medium:
+ color = .yellow
+ case .high:
+ color = .red
+ }
+ return AppStatusBadgeView(
+ model: .init(
+ title: severity.title,
+ systemImage: "exclamationmark.circle.fill",
+ foregroundColor: color,
+ backgroundColor: color.opacity(0.16)
+ )
+ )
+}
+
+private func availabilityTitle(_ status: DomainAvailabilityStatus) -> String {
+ switch status {
+ case .available:
+ return "Available"
+ case .registered:
+ return "Registered"
+ case .unknown:
+ return "Unknown"
+ }
+}
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,
diff --git a/DomainDig/RootTabView.swift b/DomainDig/RootTabView.swift
index 524e8e9..f2c9f6c 100644
--- a/DomainDig/RootTabView.swift
+++ b/DomainDig/RootTabView.swift
@@ -2,9 +2,9 @@ import SwiftUI
private enum RootTab: Hashable {
case dashboard
+ case audit
case history
case inspect
- case audit
case settings
}
@@ -26,6 +26,14 @@ struct RootTabView: View {
.tag(RootTab.dashboard)
NavigationStack {
+ AuditListView(viewModel: viewModel)
+ }
+ .tabItem {
+ Label("Audit", systemImage: "checklist")
+ }
+ .tag(RootTab.audit)
+
+ NavigationStack {
HistoryView(viewModel: viewModel)
}
.tabItem {
@@ -40,14 +48,6 @@ struct RootTabView: View {
.tag(RootTab.inspect)
NavigationStack {
- AuditModeView(viewModel: viewModel)
- }
- .tabItem {
- Label("Audit", systemImage: "checklist")
- }
- .tag(RootTab.audit)
-
- NavigationStack {
SettingsView(viewModel: viewModel)
}
.tabItem {
diff --git a/DomainDig/WatchlistView.swift b/DomainDig/WatchlistView.swift
index d092f65..d91dd5d 100644
--- a/DomainDig/WatchlistView.swift
+++ b/DomainDig/WatchlistView.swift
@@ -476,6 +476,8 @@ struct TrackedDomainDetailView: View {
@State private var isEditingNote = false
@State private var showRerunOptions = false
@State private var shareEntity: ShareableEntity?
+ @State private var showingAuditTimeline = false
+ @State private var auditStartInFlight = false
private var liveTrackedDomain: TrackedDomain {
viewModel.trackedDomains.first(where: { $0.id == trackedDomain.id }) ?? trackedDomain
@@ -513,6 +515,27 @@ struct TrackedDomainDetailView: View {
}
Button {
+ Task {
+ auditStartInFlight = true
+ if await viewModel.startAudit(for: liveTrackedDomain.domain) != nil {
+ showingAuditTimeline = true
+ }
+ auditStartInFlight = false
+ }
+ } label: {
+ Label(auditStartInFlight ? "Starting Audit…" : "Start Audit", systemImage: "checklist")
+ }
+ .disabled(auditStartInFlight)
+
+ if !viewModel.audits(for: liveTrackedDomain.domain).isEmpty {
+ Button {
+ showingAuditTimeline = true
+ } label: {
+ Label("View Audits", systemImage: "clock.badge.checkmark")
+ }
+ }
+
+ Button {
viewModel.togglePinned(for: liveTrackedDomain)
} label: {
Label(liveTrackedDomain.isPinned ? "Unpin Domain" : "Pin Domain", systemImage: liveTrackedDomain.isPinned ? "pin.slash" : "pin")
@@ -651,5 +674,10 @@ struct TrackedDomainDetailView: View {
.sheet(item: $shareEntity) { entity in
CloudSharingSheet(entity: entity, title: liveTrackedDomain.domain)
}
+ .sheet(isPresented: $showingAuditTimeline) {
+ NavigationStack {
+ AuditDomainTimelineView(viewModel: viewModel, domain: liveTrackedDomain.domain)
+ }
+ }
}
}