krz/domain-dig
an ios app for DNS & SSL analysis
clone: git clone https://gitbay.org/krz/domain-dig.git
main: DomainDig/DomainViewModel.swift · raw
1import Foundation
2import SwiftUI
3import UserNotifications
4
5enum ResultTone {
6 case primary
7 case secondary
8 case success
9 case warning
10 case failure
11}
12
13struct SummaryFieldViewData: Identifiable {
14 let id = UUID()
15 let label: String
16 let value: String
17 let tone: ResultTone
18}
19
20/// How VoiceOver should pronounce a row's value.
21///
22/// DNS records, cipher suites, and the like are read as prose by default, which
23/// mangles load-bearing punctuation (`;`, `~`, `_`) and technical tokens. See
24/// `Docs/ACCESSIBILITY.md`.
25enum RowSpeechStyle {
26 /// Normal prose.
27 case plain
28 /// Record values and identifiers: include punctuation, use code heuristics.
29 case technical
30}
31
32struct InfoRowViewData: Identifiable {
33 let id = UUID()
34 let label: String
35 let value: String
36 let tone: ResultTone
37 var speechStyle: RowSpeechStyle = .plain
38}
39
40struct SectionMessageViewData {
41 let text: String
42 let isError: Bool
43}
44
45struct DNSRecordSectionViewData: Identifiable {
46 let id = UUID()
47 let title: String
48 let rows: [InfoRowViewData]
49 let wildcardRows: [InfoRowViewData]
50 let wildcardTitle: String?
51 let message: SectionMessageViewData?
52}
53
54struct EmailRowViewData: Identifiable {
55 let id = UUID()
56 let label: String
57 let status: String
58 let statusTone: ResultTone
59 let detail: String
60 let auxiliaryDetail: String?
61}
62
63struct RedirectHopViewData: Identifiable {
64 let id = UUID()
65 let stepLabel: String
66 let statusCode: String
67 let url: String
68 let isFinal: Bool
69}
70
71struct ReachabilityRowViewData: Identifiable {
72 let id = UUID()
73 let portLabel: String
74 let latencyLabel: String
75 let statusLabel: String
76 let statusTone: ResultTone
77}
78
79struct PortScanRowViewData: Identifiable {
80 let id = UUID()
81 let portLabel: String
82 let service: String
83 let statusLabel: String
84 let statusTone: ResultTone
85 let banner: String?
86 let durationLabel: String?
87}
88
89struct SubdomainRowViewData: Identifiable {
90 let id: String
91 let hostname: String
92 let isInteresting: Bool
93
94 init(hostname: String, isInteresting: Bool) {
95 self.id = hostname
96 self.hostname = hostname
97 self.isInteresting = isInteresting
98 }
99}
100
101struct DomainSuggestionViewData: Identifiable {
102 let id: UUID
103 let domain: String
104 let availabilityStatus: DomainAvailabilityStatus
105 let status: String
106 let tone: ResultTone
107}
108
109private struct BatchLookupPayload {
110 let snapshot: LookupSnapshot
111}
112
113struct PortfolioDomainStatus: Identifiable {
114 let trackedDomain: TrackedDomain
115 let latestEntry: HistoryEntry?
116 let report: DomainReport
117 let apexDomain: String
118 let health: DomainHealth
119 let lastChangeDate: Date?
120 let lastMonitoringFailure: Date?
121 let instabilityScore: Int
122 let certificateExpiryState: CertificateWarningLevel
123 let certificateDaysRemaining: Int?
124 let isUnreachable: Bool
125 let recentDNSChange: Bool
126 let recentCriticalChange: Bool
127 let recentFailureCount: Int
128
129 var id: UUID { trackedDomain.id }
130}
131
132struct PortfolioActivityItem: Identifiable {
133 let id: String
134 let trackedDomainID: UUID
135 let domain: String
136 let message: String
137 let timestamp: Date
138 let health: DomainHealth
139 let systemImage: String
140}
141
142struct PortfolioAttentionItem: Identifiable {
143 let id: String
144 let trackedDomainID: UUID
145 let domain: String
146 let reason: String
147 let timestamp: Date
148 let health: DomainHealth
149}
150
151struct PortfolioGroup: Identifiable {
152 let apexDomain: String
153 let domains: [PortfolioDomainStatus]
154
155 var id: String { apexDomain }
156}
157
158struct PortfolioDashboardData {
159 let snapshot: PortfolioSnapshot
160 let domainStates: [PortfolioDomainStatus]
161 let recentActivity: [PortfolioActivityItem]
162 let attentionRequired: [PortfolioAttentionItem]
163 let expiringSoon: [PortfolioDomainStatus]
164 let groups: [PortfolioGroup]
165}
166
167private struct PortfolioDashboardStamp: Equatable {
168 let trackedDomainSignature: [String]
169 let historySignature: [String]
170 let monitoringSignature: [String]
171}
172
173@MainActor
174@Observable
175final class DomainViewModel {
176 var domain: String = ""
177 var bulkInput: String = ""
178
179 var dnsSections: [DNSSection] = []
180 var dnsLoading = false
181 var dnsError: String?
182 var availabilityResult: DomainAvailabilityResult?
183 var availabilityLoading = false
184 var suggestions: [DomainSuggestionResult] = []
185 var suggestionsLoading = false
186
187 var sslInfo: SSLCertificateInfo?
188 var sslLoading = false
189 var sslError: String?
190 var hstsPreloaded: Bool?
191 var hstsLoading = false
192
193 var httpHeaders: [HTTPHeader] = []
194 var httpSecurityGrade: String?
195 var httpStatusCode: Int?
196 var httpResponseTimeMs: Int?
197 var httpProtocol: String?
198 var http3Advertised = false
199 var httpHeadersLoading = false
200 var httpHeadersError: String?
201
202 var reachabilityResults: [PortReachability] = []
203 var reachabilityLoading = false
204 var reachabilityError: String?
205
206 var ipGeolocation: IPGeolocation?
207 var ipGeolocationLoading = false
208 var ipGeolocationError: String?
209
210 var emailSecurity: EmailSecurityResult?
211 var emailSecurityLoading = false
212 var emailSecurityError: String?
213
214 var ownershipResult: DomainOwnership?
215 var ownershipLoading = false
216 var ownershipError: String?
217 var ownershipHistory: [DomainOwnershipHistoryEvent] = []
218 var ownershipHistoryLoading = false
219 var ownershipHistoryError: String?
220
221 var ptrRecord: String?
222 var ptrLoading = false
223 var ptrError: String?
224
225 var redirectChain: [RedirectHop] = []
226 var redirectChainLoading = false
227 var redirectChainError: String?
228
229 var subdomains: [DiscoveredSubdomain] = []
230 var subdomainsLoading = false
231 var subdomainsError: String?
232 var extendedSubdomains: [DiscoveredSubdomain] = []
233 var extendedSubdomainsLoading = false
234 var extendedSubdomainsError: String?
235 var dnsHistory: [DNSHistoryEvent] = []
236 var dnsHistoryLoading = false
237 var dnsHistoryError: String?
238 var domainPricing: DomainPricingInsight?
239 var domainPricingLoading = false
240 var domainPricingError: String?
241 var reputation: DomainReputationResult?
242 var reputationLoading = false
243 var reputationError: String?
244 var usageCredits: [UsageCreditFeature: UsageCreditStatus] = DomainViewModel.defaultUsageCredits()
245
246 var portScanResults: [PortScanResult] = []
247 var portScanLoading = false
248 var portScanError: String?
249 var customPortResults: [PortScanResult] = []
250 var customPortScanLoading = false
251 var customPortScanError: String?
252
253 var hasRun = false
254 private(set) var searchedDomain: String = ""
255 private(set) var lastLookupDurationMs: Int?
256 private(set) var currentDiffSections: [DomainDiffSection] = []
257 private(set) var currentChangeSummary: DomainChangeSummary?
258 private(set) var ownershipDiff: [DomainDiffItem] = []
259 private(set) var refreshingTrackedDomainID: UUID?
260 private(set) var rerunNavigationToken = UUID()
261 private(set) var batchResults: [BatchLookupResult] = []
262 private(set) var batchLookupSource: BatchLookupSource = .manual
263 private(set) var batchCurrentDomain: String?
264 private(set) var batchCompletedCount = 0
265 private(set) var batchTotalCount = 0
266 private(set) var batchLookupRunning = false
267 var latestBatchSweepSummary: BatchSweepSummary?
268 var latestWorkflowRunSummary: WorkflowRunSummary?
269 private(set) var notificationsAuthorized = false
270
271 private var lookupTask: Task<Void, Never>?
272 private var customPortScanTask: Task<Void, Never>?
273 private var batchTask: Task<Void, Never>?
274 private var activeLookupID = UUID()
275 private var lookupStartedAt: Date?
276 private var activeBatchDomains: [String] = []
277 private var lastBatchStartedAt: Date?
278 var activeWorkflowRunID: UUID?
279 var activeWorkflowRunName: String?
280 private var historyPersistenceSuspended = false
281 private var trackedDomainsPersistenceSuspended = false
282 private var historyPersistenceDirty = false
283 private var trackedDomainsPersistenceDirty = false
284 private let reportBuilder = DomainReportBuilder()
285 private let inspectionService = DomainInspectionService()
286 private var cachedPortfolioDashboardData: PortfolioDashboardData?
287 private var cachedPortfolioDashboardStamp: PortfolioDashboardStamp?
288 private(set) var currentResultSource: LookupResultSource = .live
289 private(set) var currentCachedSections: [LookupSectionKind] = []
290 private(set) var currentStatusMessage: String?
291 private(set) var currentSnapshotTimestamp = Date()
292 private(set) var currentHistoryEntryID: UUID?
293 private(set) var currentReport: DomainReport?
294
295 private static let recentSearchesKey = "recentSearches"
296 private static let maxRecent = 20
297 var recentSearches: [String] = DomainDataPortabilityService.loadRecentSearches()
298
299 private static let savedDomainsKey = "savedDomains"
300 var savedDomains: [String] = DomainDataPortabilityService.loadSavedDomains()
301
302 private static let trackedDomainsKey = "trackedDomains"
303 private static let legacyWatchedDomainsKey = "watchedDomains"
304 var trackedDomains: [TrackedDomain] = DomainViewModel.loadTrackedDomains()
305
306 private static let historyKey = "lookupHistory"
307 private static let maxHistory = 250
308 var history: [HistoryEntry] = DomainViewModel.loadHistoryEntries()
309 var auditSessions: [AuditSession] = DomainDataPortabilityService.loadAuditSessions()
310 private static let workflowsKey = "domainWorkflows"
311 var workflows: [DomainWorkflow] = DomainViewModel.loadWorkflows()
312 var historySearchText = ""
313 var historyDateFilter: HistoryDateFilter = .all
314 var historyChangeFilter: ChangeFilterOption = .all
315 var historySortOption: HistorySortOption = .newest
316 var timelineGrouping: TimelineGroupingOption = .relativeDay
317 var timelineDomainFilter = ""
318 var watchlistSearchText = ""
319 var watchlistFilter: WatchlistFilterOption = .all
320 var watchlistSortOption: WatchlistSortOption = .pinned
321 var watchlistTagFilter: String?
322 var watchlistSavedViews: [WatchlistSavedView] = DomainViewModel.loadWatchlistSavedViews()
323 var dashboardSearchText = ""
324 var dashboardFilter: PortfolioFilterOption = .all
325 var monitoringSettings: MonitoringSettings = MonitoringStorage.loadSettings()
326 var monitoringLogs: [MonitoringLog] = MonitoringStorage.loadLogs()
327 var monitoringRunInProgress = false
328 var monitoringStatusMessage: String?
329 var monitoringNotificationStatus: UNAuthorizationStatus = .notDetermined
330 var dataLifecycleSummary = DomainDataPortabilityService.lifecycleSummary()
331 var portabilityStatusMessage: String?
332 var upgradePrompt: UpgradePromptContext?
333 var isPaywallPresented = false
334 var selectedSnapshotIDs = Set<UUID>()
335 var activeDomainDiff: DomainDiff?
336 var activeDiffChangeIndex = 0
337
338 private static let historyAutoPruneKey = "historyAutoPrune"
339 var historyAutoPruneOption: HistoryAutoPruneOption = DomainViewModel.loadHistoryAutoPruneOption()
340
341 var trimmedDomain: String {
342 domain
343 .trimmingCharacters(in: .whitespacesAndNewlines)
344 .replacingOccurrences(of: "https://", with: "")
345 .replacingOccurrences(of: "http://", with: "")
346 .components(separatedBy: "/").first ?? ""
347 }
348
349 var resultsLoaded: Bool {
350 hasRun &&
351 !dnsLoading &&
352 !availabilityLoading &&
353 !suggestionsLoading &&
354 !sslLoading &&
355 !hstsLoading &&
356 !httpHeadersLoading &&
357 !reachabilityLoading &&
358 !ipGeolocationLoading &&
359 !emailSecurityLoading &&
360 !ownershipLoading &&
361 !ptrLoading &&
362 !redirectChainLoading &&
363 !subdomainsLoading &&
364 !portScanLoading &&
365 !customPortScanLoading
366 }
367
368 var activeLoadingLabels: [String] {
369 var labels: [String] = []
370 if availabilityLoading { labels.append("Availability") }
371 if dnsLoading { labels.append("DNS") }
372 if sslLoading || hstsLoading { labels.append("TLS") }
373 if httpHeadersLoading { labels.append("HTTP") }
374 if ownershipLoading { labels.append("Ownership") }
375 if ownershipHistoryLoading { labels.append("Ownership History") }
376 if emailSecurityLoading { labels.append("Email") }
377 if subdomainsLoading { labels.append("Subdomains") }
378 if extendedSubdomainsLoading { labels.append("Extended Subdomains") }
379 if dnsHistoryLoading { labels.append("DNS History") }
380 if domainPricingLoading { labels.append("Pricing") }
381 if redirectChainLoading { labels.append("Redirects") }
382 if reachabilityLoading { labels.append("Reachability") }
383 if ipGeolocationLoading { labels.append("Geolocation") }
384 if ptrLoading { labels.append("PTR") }
385 if portScanLoading { labels.append("Port Scan") }
386 if customPortScanLoading { labels.append("Custom Ports") }
387 return labels
388 }
389
390 var isCloudflareProxied: Bool {
391 httpHeaders.contains { $0.name.lowercased() == "cf-ray" }
392 }
393
394 var isCurrentDomainSaved: Bool {
395 !searchedDomain.isEmpty && savedDomains.contains(where: { $0.lowercased() == searchedDomain.lowercased() })
396 }
397
398 var sortedTrackedDomains: [TrackedDomain] {
399 sortedTrackedDomains(from: trackedDomains, using: .pinned)
400 }
401
402 var filteredHistory: [HistoryEntry] {
403 let calendar = Calendar.current
404 let now = Date()
405 let query = historySearchText.trimmingCharacters(in: .whitespacesAndNewlines)
406
407 return history
408 .lazy
409 .filter { entry in
410 query.isEmpty || entry.domain.localizedCaseInsensitiveContains(query)
411 }
412 .filter { entry in
413 switch self.historyDateFilter {
414 case .today:
415 return calendar.isDate(entry.timestamp, inSameDayAs: now)
416 case .last7Days:
417 guard let startDate = calendar.date(byAdding: .day, value: -7, to: now) else { return true }
418 return entry.timestamp >= startDate
419 case .all:
420 return true
421 }
422 }
423 .filter { entry in
424 switch self.historyChangeFilter {
425 case .all:
426 return true
427 case .changed:
428 return entry.changeSummary?.hasChanges == true
429 case .unchanged:
430 return entry.changeSummary?.hasChanges != true
431 }
432 }
433 .sorted(by: historySortPredicate)
434 }
435
436 var filteredTrackedDomains: [TrackedDomain] {
437 let query = watchlistSearchText.trimmingCharacters(in: .whitespacesAndNewlines)
438 let filtered = trackedDomains.filter { trackedDomain in
439 if !query.isEmpty, !trackedDomain.domain.localizedCaseInsensitiveContains(query) {
440 return false
441 }
442
443 if let watchlistTagFilter, !trackedDomain.tags.contains(watchlistTagFilter) {
444 return false
445 }
446
447 switch watchlistFilter {
448 case .all:
449 return true
450 case .pinnedOnly:
451 return trackedDomain.isPinned
452 case .changedOnly:
453 return trackedDomain.lastChangeSummary?.hasChanges == true
454 }
455 }
456
457 return sortedTrackedDomains(from: filtered, using: watchlistSortOption)
458 }
459
460 var portfolioDashboardData: PortfolioDashboardData {
461 let stamp = portfolioDashboardStamp()
462 if let cachedPortfolioDashboardData, cachedPortfolioDashboardStamp == stamp {
463 return cachedPortfolioDashboardData
464 }
465
466 let domainStates = buildPortfolioDomainStates()
467 let recentActivity = buildPortfolioActivity(from: domainStates)
468 let attentionRequired = buildAttentionQueue(from: domainStates)
469 let expiringSoon = domainStates
470 .filter { $0.certificateExpiryState != .none }
471 .sorted {
472 ($0.certificateDaysRemaining ?? .max, $0.trackedDomain.domain)
473 < ($1.certificateDaysRemaining ?? .max, $1.trackedDomain.domain)
474 }
475 let groups = buildPortfolioGroups(from: domainStates)
476 let snapshot = PortfolioSnapshot(
477 totalDomains: domainStates.count,
478 healthyCount: domainStates.filter { $0.health == .healthy }.count,
479 warningCount: domainStates.filter { $0.health == .warning }.count,
480 criticalCount: domainStates.filter { $0.health == .critical }.count,
481 changedLast24h: domainStates.filter {
482 guard let lastChangeDate = $0.lastChangeDate else { return false }
483 return Date().timeIntervalSince(lastChangeDate) <= 24 * 60 * 60
484 }.count,
485 expiringSoonCount: expiringSoon.count,
486 unreachableCount: domainStates.filter(\.isUnreachable).count
487 )
488 let data = PortfolioDashboardData(
489 snapshot: snapshot,
490 domainStates: domainStates,
491 recentActivity: recentActivity,
492 attentionRequired: attentionRequired,
493 expiringSoon: expiringSoon,
494 groups: groups
495 )
496 cachedPortfolioDashboardStamp = stamp
497 cachedPortfolioDashboardData = data
498 return data
499 }
500
501 var filteredPortfolioDomainStates: [PortfolioDomainStatus] {
502 let query = dashboardSearchText.trimmingCharacters(in: .whitespacesAndNewlines)
503 return portfolioDashboardData.domainStates.filter { state in
504 matchesPortfolioFilter(state) && matchesDashboardSearch(state, query: query)
505 }
506 }
507
508 var filteredPortfolioGroups: [PortfolioGroup] {
509 buildPortfolioGroups(from: filteredPortfolioDomainStates)
510 }
511
512 var filteredPortfolioRecentActivity: [PortfolioActivityItem] {
513 let visibleDomainIDs = Set(filteredPortfolioDomainStates.map(\.trackedDomain.id))
514 return portfolioDashboardData.recentActivity.filter { visibleDomainIDs.contains($0.trackedDomainID) }
515 }
516
517 var filteredPortfolioAttentionRequired: [PortfolioAttentionItem] {
518 let visibleDomainIDs = Set(filteredPortfolioDomainStates.map(\.trackedDomain.id))
519 return portfolioDashboardData.attentionRequired.filter { visibleDomainIDs.contains($0.trackedDomainID) }
520 }
521
522 var filteredPortfolioExpiringSoon: [PortfolioDomainStatus] {
523 let visibleDomainIDs = Set(filteredPortfolioDomainStates.map(\.trackedDomain.id))
524 return portfolioDashboardData.expiringSoon.filter { visibleDomainIDs.contains($0.trackedDomain.id) }
525 }
526
527 var timelineDomains: [String] {
528 let query = timelineDomainFilter.trimmingCharacters(in: .whitespacesAndNewlines)
529 let domains = history.map(\.domain)
530 let filtered = query.isEmpty
531 ? domains
532 : domains.filter { $0.localizedCaseInsensitiveContains(query) }
533 return Array(Set(filtered)).sorted()
534 }
535
536 var batchProgressLabel: String {
537 guard batchTotalCount > 0 else { return "No active batch" }
538 if !batchLookupRunning, batchCompletedCount >= batchTotalCount {
539 return "\(batchCompletedCount)/\(batchTotalCount) • Complete"
540 }
541 let domainLabel = activeBatchDomains.first ?? batchCurrentDomain ?? "Preparing"
542 return "\(batchCompletedCount)/\(batchTotalCount) • \(domainLabel)"
543 }
544
545 var currentBatchResultEntries: [HistoryEntry] {
546 batchResults.compactMap { result in
547 guard let historyEntryID = result.historyEntryID else { return nil }
548 return history.first(where: { $0.id == historyEntryID })
549 }
550 }
551
552 var currentTrackedDomain: TrackedDomain? {
553 guard !searchedDomain.isEmpty else { return nil }
554 return trackedDomain(for: searchedDomain)
555 }
556
557 var currentDomainWorkflows: [DomainWorkflow] {
558 guard !searchedDomain.isEmpty else { return [] }
559 return workflowsContaining(domain: searchedDomain)
560 }
561
562 var isCurrentDomainTracked: Bool {
563 currentTrackedDomain != nil
564 }
565
566 var trackingLimitMessage: String? {
567 FeatureAccessService.trackedDomainLimitMessage(currentCount: trackedDomains.count)
568 }
569
570 var canTrackCurrentDomain: Bool {
571 FeatureAccessService.canAddTrackedDomain(currentCount: trackedDomains.count)
572 }
573
574 var resolverDisplayName: String {
575 DNSLookupService.currentResolverDisplayName()
576 }
577
578 var resolverURLString: String {
579 DNSLookupService.currentResolverURLString()
580 }
581
582 var allPortScanResults: [PortScanResult] {
583 (portScanResults + customPortResults).sorted {
584 if $0.kind == $1.kind {
585 return $0.port < $1.port
586 }
587 return $0.kind == .standard
588 }
589 }
590
591 var currentSnapshot: LookupSnapshot {
592 LookupSnapshot(
593 historyEntryID: currentHistoryEntryID,
594 domain: searchedDomain,
595 timestamp: currentSnapshotTimestamp,
596 trackedDomainID: currentTrackedDomain?.id,
597 note: currentHistoryEntry?.note ?? currentTrackedDomain?.note,
598 appVersion: AppVersion.current,
599 resolverDisplayName: resolverDisplayName,
600 resolverURLString: resolverURLString,
601 dataSources: currentHistoryEntry?.dataSources ?? [],
602 provenanceBySection: currentHistoryEntry?.provenanceBySection ?? [:],
603 availabilityConfidence: currentHistoryEntry?.availabilityConfidence,
604 ownershipConfidence: currentHistoryEntry?.ownershipConfidence,
605 subdomainConfidence: currentHistoryEntry?.subdomainConfidence,
606 emailSecurityConfidence: currentHistoryEntry?.emailSecurityConfidence,
607 geolocationConfidence: currentHistoryEntry?.geolocationConfidence,
608 errorDetails: currentHistoryEntry?.errorDetails ?? [:],
609 isPartialSnapshot: currentHistoryEntry?.isPartialSnapshot ?? false,
610 validationIssues: currentHistoryEntry?.validationIssues ?? [],
611 totalLookupDurationMs: lastLookupDurationMs,
612 snapshotIndex: currentHistoryEntry?.snapshotIndex,
613 previousSnapshotID: currentHistoryEntry?.previousSnapshotID,
614 changeCount: currentHistoryEntry?.changeCount ?? currentChangeSummary?.changedSections.count ?? 0,
615 severitySummary: currentHistoryEntry?.severitySummary ?? currentChangeSummary?.severity,
616 dnsSections: dnsSections,
617 dnsError: dnsError,
618 availabilityResult: availabilityResult,
619 suggestions: suggestions,
620 sslInfo: sslInfo,
621 sslError: sslError,
622 hstsPreloaded: hstsPreloaded,
623 httpHeaders: httpHeaders,
624 httpSecurityGrade: httpSecurityGrade,
625 httpStatusCode: httpStatusCode,
626 httpResponseTimeMs: httpResponseTimeMs,
627 httpProtocol: httpProtocol,
628 http3Advertised: http3Advertised,
629 httpHeadersError: httpHeadersError,
630 reachabilityResults: reachabilityResults,
631 reachabilityError: reachabilityError,
632 ipGeolocation: ipGeolocation,
633 ipGeolocationError: ipGeolocationError,
634 emailSecurity: emailSecurity,
635 emailSecurityError: emailSecurityError,
636 ownership: ownershipResult,
637 ownershipError: ownershipError,
638 ownershipHistory: ownershipHistory,
639 ownershipHistoryError: ownershipHistoryError,
640 inferredProvider: currentHistoryEntry?.inferredProvider ?? currentReport?.inferredProvider,
641 priorProviders: currentHistoryEntry?.priorProviders ?? currentReport?.priorProviders ?? [],
642 domainClassification: currentHistoryEntry?.domainClassification ?? currentReport?.domainClassification,
643 ownershipTransitions: currentHistoryEntry?.ownershipTransitions ?? currentReport?.ownershipTransitions ?? [],
644 hostingTransitions: currentHistoryEntry?.hostingTransitions ?? currentReport?.hostingTransitions ?? [],
645 subdomainHistory: currentHistoryEntry?.subdomainHistory ?? currentReport?.subdomainHistory ?? [],
646 riskSignals: currentHistoryEntry?.riskSignals ?? currentReport?.riskSignals ?? [],
647 intelligenceTimeline: currentHistoryEntry?.intelligenceTimeline ?? currentReport?.intelligenceTimeline ?? [],
648 ptrRecord: ptrRecord,
649 ptrError: ptrError,
650 redirectChain: redirectChain,
651 redirectChainError: redirectChainError,
652 subdomains: subdomains,
653 subdomainsError: subdomainsError,
654 extendedSubdomains: extendedSubdomains,
655 extendedSubdomainsError: extendedSubdomainsError,
656 dnsHistory: dnsHistory,
657 dnsHistoryError: dnsHistoryError,
658 domainPricing: domainPricing,
659 domainPricingError: domainPricingError,
660 reputation: reputation,
661 reputationError: reputationError,
662 portScanResults: allPortScanResults,
663 portScanError: combinedPortScanError,
664 changeSummary: currentChangeSummary,
665 resultSource: currentResultSource,
666 cachedSections: currentCachedSections,
667 statusMessage: currentStatusMessage
668 )
669 }
670
671 private var currentHistoryEntry: HistoryEntry? {
672 guard let currentHistoryEntryID else { return nil }
673 return history.first(where: { $0.id == currentHistoryEntryID })
674 }
675
676 var currentRiskAssessment: DomainRiskAssessment? {
677 currentReport?.riskAssessment
678 }
679
680 var currentInsights: [String] {
681 currentReport?.insights ?? []
682 }
683
684 var currentSubdomainGroups: [SubdomainGroup] {
685 currentReport?.subdomainGroups ?? []
686 }
687
688 var currentDNSPatterns: DNSPatternSummary? {
689 currentReport?.dns.patternSummary
690 }
691
692 var currentEmailAssessment: EmailSecuritySummary? {
693 currentReport?.email
694 }
695
696 var currentTLSSummary: WebResultSummary? {
697 currentReport?.web
698 }
699
700 var ownershipHistoryCreditStatus: UsageCreditStatus {
701 usageCredits[.ownershipHistory] ?? Self.fallbackCreditStatus(for: .ownershipHistory)
702 }
703
704 var dnsHistoryCreditStatus: UsageCreditStatus {
705 usageCredits[.dnsHistory] ?? Self.fallbackCreditStatus(for: .dnsHistory)
706 }
707
708 var extendedSubdomainsCreditStatus: UsageCreditStatus {
709 usageCredits[.extendedSubdomains] ?? Self.fallbackCreditStatus(for: .extendedSubdomains)
710 }
711
712 var combinedSubdomains: [DiscoveredSubdomain] {
713 let existingHosts = Set(subdomains.map { $0.hostname.lowercased() })
714 return subdomains + extendedSubdomains.filter { !existingHosts.contains($0.hostname.lowercased()) }
715 }
716
717 var summaryFields: [SummaryFieldViewData] {
718 Self.summaryFields(from: currentSnapshot)
719 }
720
721 var domainRows: [InfoRowViewData] {
722 Self.domainRows(from: currentSnapshot)
723 }
724
725 var dnsRows: [DNSRecordSectionViewData] {
726 Self.dnsRows(from: currentSnapshot)
727 }
728
729 var suggestionRows: [DomainSuggestionViewData] {
730 Self.suggestionRows(from: currentSnapshot)
731 }
732
733 var dnssecLabel: String? {
734 Self.dnssecLabel(from: currentSnapshot)
735 }
736
737 var ptrMessage: SectionMessageViewData? {
738 Self.ptrMessage(from: currentSnapshot)
739 }
740
741 var webCertificateRows: [InfoRowViewData] {
742 Self.webCertificateRows(from: currentSnapshot)
743 }
744
745 var webResponseRows: [InfoRowViewData] {
746 Self.webResponseRows(from: currentSnapshot)
747 }
748
749 var redirectRows: [RedirectHopViewData] {
750 Self.redirectRows(from: currentSnapshot)
751 }
752
753 var emailRows: [EmailRowViewData] {
754 Self.emailRows(from: currentSnapshot)
755 }
756
757 var ownershipRows: [InfoRowViewData] {
758 Self.ownershipRows(from: currentSnapshot)
759 }
760
761 var subdomainRows: [SubdomainRowViewData] {
762 Self.subdomainRows(from: combinedSubdomains)
763 }
764
765 var reachabilityRows: [ReachabilityRowViewData] {
766 Self.reachabilityRows(from: currentSnapshot)
767 }
768
769 var locationRows: [InfoRowViewData] {
770 Self.locationRows(from: currentSnapshot)
771 }
772
773 var standardPortRows: [PortScanRowViewData] {
774 Self.portRows(from: currentSnapshot, kind: .standard)
775 }
776
777 var customPortRows: [PortScanRowViewData] {
778 Self.portRows(from: currentSnapshot, kind: .custom)
779 }
780
781 var combinedPortScanError: String? {
782 [portScanError, customPortScanError].compactMap { $0 }.joined(separator: "\n").nilIfEmpty
783 }
784
785 func toggleSavedDomain() {
786 if isCurrentDomainSaved {
787 savedDomains.removeAll { $0.lowercased() == searchedDomain.lowercased() }
788 } else {
789 savedDomains.append(searchedDomain)
790 }
791 DomainDataPortabilityService.saveSavedDomains(savedDomains)
792 CloudSyncService.shared.markAppSettingsChanged()
793 refreshDataLifecycleSummary()
794 }
795
796 func removeSavedDomains(at offsets: IndexSet) {
797 savedDomains.remove(atOffsets: offsets)
798 DomainDataPortabilityService.saveSavedDomains(savedDomains)
799 CloudSyncService.shared.markAppSettingsChanged()
800 refreshDataLifecycleSummary()
801 }
802
803 @discardableResult
804 func trackCurrentDomain() -> Bool {
805 guard !searchedDomain.isEmpty else { return false }
806 return trackDomain(domain: searchedDomain, availabilityStatus: availabilityResult?.status)
807 }
808
809 @discardableResult
810 func trackDomain(domain: String, availabilityStatus: DomainAvailabilityStatus?) -> Bool {
811 let normalizedDomain = normalizedDomain(domain)
812 guard !normalizedDomain.isEmpty else { return false }
813
814 if trackedDomain(for: normalizedDomain) != nil {
815 return true
816 }
817
818 guard PremiumAccessService.canAddTrackedDomain(currentCount: trackedDomains.count) else {
819 upgradePrompt = FeatureAccessService.upgradePromptForTrackedDomains(currentCount: trackedDomains.count)
820 return false
821 }
822
823 trackedDomains.insert(
824 TrackedDomain(
825 domain: normalizedDomain,
826 createdAt: Date(),
827 updatedAt: Date(),
828 lastKnownAvailability: availabilityStatus,
829 collaboration: CollaborationMetadata(
830 scope: .privateDatabase,
831 ownership: .owner,
832 permission: .editable
833 )
834 ),
835 at: 0
836 )
837 persistTrackedDomains()
838 sanitizeMonitoringSelection()
839 linkTrackedDomainHistory(for: normalizedDomain)
840 return true
841 }
842
843 func refreshTrackedDomain(_ trackedDomain: TrackedDomain) {
844 refreshingTrackedDomainID = trackedDomain.id
845 domain = trackedDomain.domain
846 Task { [weak self] in
847 guard let self else { return }
848 self.notificationsAuthorized = await LocalNotificationService.shared.requestAuthorizationIfNeeded()
849 }
850 run()
851 }
852
853 func rerunInspection(for trackedDomain: TrackedDomain) {
854 rerunInspection(for: trackedDomain, useSnapshotResolver: false)
855 }
856
857 func deleteTrackedDomains(at offsets: IndexSet) {
858 let removedDomains = offsets.map { sortedTrackedDomains[$0] }
859 let ids = removedDomains.map(\.id)
860 CloudSyncService.shared.recordTrackedDomainReset(removedDomains)
861 trackedDomains.removeAll { ids.contains($0.id) }
862 history.indices.forEach { index in
863 if let trackedDomainID = history[index].trackedDomainID, ids.contains(trackedDomainID) {
864 history[index].trackedDomainID = nil
865 }
866 }
867 persistTrackedDomains()
868 persistHistory()
869 sanitizeMonitoringSelection()
870 }
871
872 func deleteTrackedDomain(_ trackedDomain: TrackedDomain) {
873 guard canDelete(trackedDomain) else { return }
874 CloudSyncService.shared.recordTrackedDomainDeletion(trackedDomain)
875 trackedDomains.removeAll { $0.id == trackedDomain.id }
876 history.indices.forEach { index in
877 if history[index].trackedDomainID == trackedDomain.id {
878 history[index].trackedDomainID = nil
879 }
880 }
881 persistTrackedDomains()
882 persistHistory()
883 sanitizeMonitoringSelection()
884 }
885
886 func togglePinned(for trackedDomain: TrackedDomain) {
887 guard canEdit(trackedDomain) else { return }
888 guard let index = trackedDomains.firstIndex(where: { $0.id == trackedDomain.id }) else { return }
889 trackedDomains[index].isPinned.toggle()
890 trackedDomains[index].updatedAt = Date()
891 persistTrackedDomains()
892 }
893
894 func updateNote(_ note: String, for trackedDomain: TrackedDomain) {
895 guard canEdit(trackedDomain) else { return }
896 guard let index = trackedDomains.firstIndex(where: { $0.id == trackedDomain.id }) else { return }
897 trackedDomains[index].note = note.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty
898 trackedDomains[index].updatedAt = Date()
899 CloudSyncService.shared.markNoteChanged(for: trackedDomains[index].domain, updatedAt: trackedDomains[index].updatedAt)
900 persistTrackedDomains()
901 }
902
903 func updateTags(_ tags: [String], for trackedDomain: TrackedDomain) {
904 guard canEdit(trackedDomain) else { return }
905 guard let index = trackedDomains.firstIndex(where: { $0.id == trackedDomain.id }) else { return }
906 let normalized = Self.normalizedTags(tags)
907 trackedDomains[index].tags = normalized
908 trackedDomains[index].updatedAt = Date()
909 persistTrackedDomains()
910 }
911
912 /// All tags currently in use across the watchlist, sorted for stable display.
913 var allWatchlistTags: [String] {
914 Array(Set(trackedDomains.flatMap(\.tags))).sorted()
915 }
916
917 func saveCurrentWatchlistView(name: String) {
918 let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines)
919 guard !trimmedName.isEmpty else { return }
920 let view = WatchlistSavedView(
921 name: trimmedName,
922 tag: watchlistTagFilter,
923 filter: watchlistFilter,
924 sort: watchlistSortOption
925 )
926 watchlistSavedViews.append(view)
927 persistWatchlistSavedViews()
928 }
929
930 func applyWatchlistSavedView(_ view: WatchlistSavedView) {
931 watchlistTagFilter = view.tag
932 watchlistFilter = view.filter
933 watchlistSortOption = view.sort
934 }
935
936 func deleteWatchlistSavedViews(at offsets: IndexSet) {
937 watchlistSavedViews.remove(atOffsets: offsets)
938 persistWatchlistSavedViews()
939 }
940
941 private static let watchlistSavedViewsKey = "watchlistSavedViews"
942
943 private static func loadWatchlistSavedViews() -> [WatchlistSavedView] {
944 guard let data = UserDefaults.standard.data(forKey: watchlistSavedViewsKey),
945 let views = try? JSONDecoder().decode([WatchlistSavedView].self, from: data)
946 else { return [] }
947 return views
948 }
949
950 private func persistWatchlistSavedViews() {
951 guard let data = try? JSONEncoder().encode(watchlistSavedViews) else { return }
952 UserDefaults.standard.set(data, forKey: Self.watchlistSavedViewsKey)
953 }
954
955 private static func normalizedTags(_ tags: [String]) -> [String] {
956 var seen = Set<String>()
957 var normalized: [String] = []
958 for tag in tags {
959 let trimmed = tag.trimmingCharacters(in: .whitespacesAndNewlines)
960 guard !trimmed.isEmpty, seen.insert(trimmed.lowercased()).inserted else { continue }
961 normalized.append(trimmed)
962 }
963 return normalized.sorted()
964 }
965
966 func removeHistoryEntries(at offsets: IndexSet) {
967 history.remove(atOffsets: offsets)
968 persistHistory()
969 }
970
971 func removeHistoryEntries(withIDs ids: [UUID]) {
972 history.removeAll { ids.contains($0.id) }
973 persistHistory()
974 }
975
976 func clearHistory() {
977 history.removeAll()
978 persistHistory()
979 clearMonitoringLogs()
980 refreshDataLifecycleSummary()
981 }
982
983 func clearLookupCache() {
984 Task {
985 await LookupRuntime.shared.clearCache()
986 }
987 }
988
989 func clearWorkflows() {
990 CloudSyncService.shared.recordWorkflowReset(workflows)
991 workflows.removeAll()
992 latestWorkflowRunSummary = nil
993 persistWorkflows()
994 refreshDataLifecycleSummary()
995 }
996
997 func clearTrackedDomains() {
998 CloudSyncService.shared.recordTrackedDomainReset(trackedDomains)
999 trackedDomains.removeAll()
1000 refreshingTrackedDomainID = nil
1001 persistTrackedDomains()
1002 sanitizeMonitoringSelection()
1003 clearMonitoringLogs()
1004 refreshDataLifecycleSummary()
1005 }
1006
1007 func clearRecentSearches() {
1008 recentSearches.removeAll()
1009 DomainDataPortabilityService.saveRecentSearches([])
1010 CloudSyncService.shared.markAppSettingsChanged()
1011 refreshDataLifecycleSummary()
1012 }
1013
1014 func refreshMonitoringState() {
1015 #if DEBUG
1016 // Runs right after fixture seeding in the app task (and again on every
1017 // scene activation); the disk reload below would wipe the fixtures.
1018 if auditFixturesActive { return }
1019 #endif
1020 DataMigrationService.migrateIfNeeded()
1021 trackedDomains = Self.loadTrackedDomains()
1022 history = Self.loadHistoryEntries()
1023 monitoringSettings = MonitoringStorage.sanitizeSettings(
1024 MonitoringStorage.loadSettings(),
1025 trackedDomains: trackedDomains
1026 )
1027 monitoringLogs = MonitoringStorage.loadLogs()
1028 persistMonitoringSettings()
1029 refreshDataLifecycleSummary()
1030 }
1031
1032 func refreshDataLifecycleSummary() {
1033 dataLifecycleSummary = DomainDataPortabilityService.lifecycleSummary()
1034 }
1035
1036 func refreshPersistedData() {
1037 #if DEBUG
1038 // A reload from disk would silently replace the in-memory fixtures.
1039 if auditFixturesActive { return }
1040 #endif
1041 recentSearches = DomainDataPortabilityService.loadRecentSearches()
1042 savedDomains = DomainDataPortabilityService.loadSavedDomains()
1043 trackedDomains = Self.loadTrackedDomains()
1044 history = Self.loadHistoryEntries()
1045 auditSessions = DomainDataPortabilityService.loadAuditSessions()
1046 workflows = Self.loadWorkflows()
1047 monitoringSettings = MonitoringStorage.sanitizeSettings(
1048 MonitoringStorage.loadSettings(),
1049 trackedDomains: trackedDomains
1050 )
1051 monitoringLogs = MonitoringStorage.loadLogs()
1052 refreshDataLifecycleSummary()
1053 }
1054
1055 func applyLocalDataReset() async {
1056 domain = ""
1057 bulkInput = ""
1058 reset()
1059
1060 recentSearches = []
1061 savedDomains = []
1062 trackedDomains = []
1063 history = []
1064 auditSessions = []
1065 workflows = []
1066 historySearchText = ""
1067 historyDateFilter = .all
1068 historyChangeFilter = .all
1069 historySortOption = .newest
1070 timelineGrouping = .relativeDay
1071 timelineDomainFilter = ""
1072 watchlistSearchText = ""
1073 watchlistFilter = .all
1074 watchlistSortOption = .pinned
1075 dashboardSearchText = ""
1076 dashboardFilter = .all
1077 monitoringSettings = MonitoringSettings()
1078 monitoringLogs = []
1079 notificationsAuthorized = false
1080 monitoringRunInProgress = false
1081 monitoringStatusMessage = nil
1082 portabilityStatusMessage = "All local data removed."
1083 upgradePrompt = nil
1084 isPaywallPresented = false
1085 selectedSnapshotIDs.removeAll()
1086 activeDomainDiff = nil
1087 activeDiffChangeIndex = 0
1088 latestBatchSweepSummary = nil
1089 latestWorkflowRunSummary = nil
1090 historyAutoPruneOption = Self.loadHistoryAutoPruneOption()
1091 refreshDataLifecycleSummary()
1092 await refreshUsageCredits()
1093 await refreshMonitoringAuthorizationStatus()
1094 }
1095
1096 func rerunLookup(from entry: HistoryEntry, useSnapshotResolver: Bool) {
1097 if useSnapshotResolver {
1098 UserDefaults.standard.set(entry.resolverURLString, forKey: DNSResolverOption.userDefaultsKey)
1099 }
1100 domain = entry.domain
1101 run()
1102 rerunNavigationToken = UUID()
1103 }
1104
1105 func rerunInspection(for trackedDomain: TrackedDomain, useSnapshotResolver: Bool) {
1106 if useSnapshotResolver, let snapshot = latestSnapshot(for: trackedDomain) {
1107 UserDefaults.standard.set(snapshot.resolverURLString, forKey: DNSResolverOption.userDefaultsKey)
1108 }
1109 domain = trackedDomain.domain
1110 run()
1111 rerunNavigationToken = UUID()
1112 }
1113
1114 func openInspection(for domain: String) {
1115 self.domain = normalizedDomain(domain)
1116 run()
1117 rerunNavigationToken = UUID()
1118 }
1119
1120 func reset() {
1121 lookupTask?.cancel()
1122 customPortScanTask?.cancel()
1123 batchTask?.cancel()
1124 hasRun = false
1125 searchedDomain = ""
1126 lastLookupDurationMs = nil
1127 currentDiffSections = []
1128 currentChangeSummary = nil
1129 ownershipDiff = []
1130 currentReport = nil
1131 refreshingTrackedDomainID = nil
1132 clearBatchState()
1133 clearLookupState()
1134 }
1135
1136 func clearPresentedResults() {
1137 reset()
1138 }
1139
1140 func run() {
1141 let target = trimmedDomain
1142 guard !target.isEmpty else { return }
1143 clearBatchState()
1144 let lookupID = beginLookup(for: target)
1145
1146 lookupTask = Task { [weak self] in
1147 guard let self else { return }
1148 _ = await self.performLookup(domain: target, lookupID: lookupID)
1149 }
1150 }
1151
1152 func runBulkLookup() {
1153 let domains = parsedDomains(from: bulkInput)
1154 guard !domains.isEmpty else { return }
1155 guard FeatureAccessService.canRunBatch(domainCount: domains.count) else {
1156 upgradePrompt = FeatureAccessService.upgradePromptForBatch(domainCount: domains.count)
1157 return
1158 }
1159 startBatchLookup(domains: domains, source: .manual)
1160 }
1161
1162 func refreshAllTrackedDomains() {
1163 guard FeatureAccessService.canRunBatch(domainCount: sortedTrackedDomains.count) else {
1164 upgradePrompt = FeatureAccessService.upgradePromptForBatch(domainCount: sortedTrackedDomains.count)
1165 return
1166 }
1167 startBatchLookup(domains: sortedTrackedDomains.map(\.domain), source: .watchlistRefresh)
1168 }
1169
1170 func cancelBatchLookup() {
1171 batchTask?.cancel()
1172 batchLookupRunning = false
1173 batchCurrentDomain = nil
1174 activeBatchDomains = []
1175 refreshingTrackedDomainID = nil
1176
1177 for index in batchResults.indices where batchResults[index].status == .pending || batchResults[index].status == .running {
1178 batchResults[index] = BatchLookupResult(
1179 id: batchResults[index].id,
1180 domain: batchResults[index].domain,
1181 historyEntryID: batchResults[index].historyEntryID,
1182 availability: batchResults[index].availability,
1183 primaryIP: batchResults[index].primaryIP,
1184 quickStatus: "Cancelled",
1185 summaryMessage: batchResults[index].summaryMessage,
1186 changeSeverity: batchResults[index].changeSeverity,
1187 changeClassification: batchResults[index].changeClassification,
1188 certificateWarningLevel: batchResults[index].certificateWarningLevel,
1189 riskScore: batchResults[index].riskScore,
1190 riskLevel: batchResults[index].riskLevel,
1191 timestamp: Date(),
1192 status: .failed,
1193 errorMessage: "Lookup cancelled"
1194 )
1195 }
1196 }
1197
1198 func canEdit(_ trackedDomain: TrackedDomain) -> Bool {
1199 trackedDomain.collaboration?.canEdit ?? true
1200 }
1201
1202 func canDelete(_ trackedDomain: TrackedDomain) -> Bool {
1203 trackedDomain.collaboration?.isOwner ?? true
1204 }
1205
1206 func collaborationLabel(for trackedDomain: TrackedDomain) -> String? {
1207 guard let collaboration = trackedDomain.collaboration, collaboration.isShared else { return nil }
1208 return "\(collaboration.ownership.title) • \(collaboration.permission.title)"
1209 }
1210
1211 func runCustomPortScan(ports: [UInt16]) async {
1212 guard !searchedDomain.isEmpty else {
1213 customPortScanError = "Run a domain lookup first"
1214 return
1215 }
1216
1217 guard !ports.isEmpty else {
1218 customPortScanError = "Enter at least one valid port"
1219 customPortResults = []
1220 return
1221 }
1222
1223 customPortScanTask?.cancel()
1224 let domain = searchedDomain
1225 let lookupID = activeLookupID
1226
1227 customPortScanLoading = true
1228 customPortScanError = nil
1229 customPortResults = []
1230
1231 customPortScanTask = Task { [weak self] in
1232 guard let self else { return }
1233 let result = await PortScanService.scanPorts(domain: domain, ports: ports, timeout: 3.0)
1234 guard !Task.isCancelled, self.isCurrentLookup(lookupID) else { return }
1235 self.applyCustomPortResult(result)
1236 }
1237 }
1238
1239 @discardableResult
1240 func startAudit(for domain: String, reviewer: String? = nil) async -> AuditSession? {
1241 let normalizedDomain = domain
1242 .trimmingCharacters(in: .whitespacesAndNewlines)
1243 .replacingOccurrences(of: "https://", with: "")
1244 .replacingOccurrences(of: "http://", with: "")
1245 .components(separatedBy: "/").first?
1246 .lowercased() ?? domain.lowercased()
1247 guard !normalizedDomain.isEmpty else { return nil }
1248
1249 let previous = historyEntries(for: normalizedDomain).first?.snapshot
1250 let snapshot = await inspectionService.inspectSnapshot(domain: normalizedDomain, previousSnapshot: previous)
1251 guard let entry = saveHistoryEntry(
1252 from: snapshot,
1253 replaceLatest: false,
1254 updateCurrentState: searchedDomain.caseInsensitiveCompare(normalizedDomain) == .orderedSame
1255 ) else {
1256 return nil
1257 }
1258
1259 let report = report(for: entry)
1260 let historicalContext = Array(historyEntries(for: normalizedDomain).dropFirst().prefix(6)).map(\.snapshotSummary)
1261 let screenshots = snapshotEvidenceAssets(from: report)
1262 let session = AuditSession(
1263 domain: normalizedDomain,
1264 createdAt: entry.timestamp,
1265 reviewer: (reviewer?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty) ?? Self.defaultAuditReviewer,
1266 status: .draft,
1267 evidence: AuditEvidenceSnapshot(
1268 capturedAt: entry.timestamp,
1269 lookup: entry,
1270 report: report,
1271 historicalContext: historicalContext,
1272 screenshots: screenshots
1273 ),
1274 findings: [],
1275 notes: "",
1276 checklist: AuditChecklistArea.defaultItems
1277 )
1278
1279 auditSessions.insert(session, at: 0)
1280 persistAuditSessions()
1281 return session
1282 }
1283
1284 func loadOwnershipHistory() async {
1285 guard !searchedDomain.isEmpty else { return }
1286 guard DataAccessService.hasAccess(to: .ownershipHistory) else {
1287 upgradePrompt = FeatureAccessService.upgradePrompt(for: .ownershipHistory)
1288 return
1289 }
1290 guard ownershipHistory.isEmpty else { return }
1291
1292 ownershipHistoryLoading = true
1293 ownershipHistoryError = nil
1294
1295 let outcome = await ExternalDataService.shared.ownershipHistory(
1296 domain: searchedDomain,
1297 currentOwnership: ownershipResult,
1298 historyEntries: history
1299 )
1300
1301 switch outcome.value {
1302 case let .success(events):
1303 ownershipHistory = events
1304 ownershipHistoryError = nil
1305 case let .empty(message):
1306 ownershipHistory = []
1307 ownershipHistoryError = message
1308 case let .error(message):
1309 ownershipHistory = []
1310 ownershipHistoryError = conciseExternalMessage(message, fallback: "Ownership history unavailable")
1311 }
1312
1313 ownershipHistoryLoading = false
1314 _ = saveHistoryEntry(replaceLatest: true)
1315 }
1316
1317 func loadDNSHistory() async {
1318 guard !searchedDomain.isEmpty else { return }
1319 guard DataAccessService.hasAccess(to: .dnsHistory) else {
1320 upgradePrompt = FeatureAccessService.upgradePrompt(for: .dnsHistory)
1321 return
1322 }
1323 guard dnsHistory.isEmpty else { return }
1324
1325 dnsHistoryLoading = true
1326 dnsHistoryError = nil
1327
1328 let outcome = await ExternalDataService.shared.dnsHistory(
1329 domain: searchedDomain,
1330 dnsSections: dnsSections,
1331 historyEntries: history
1332 )
1333
1334 switch outcome.value {
1335 case let .success(events):
1336 dnsHistory = events
1337 dnsHistoryError = nil
1338 case let .empty(message):
1339 dnsHistory = []
1340 dnsHistoryError = message
1341 case let .error(message):
1342 dnsHistory = []
1343 dnsHistoryError = conciseExternalMessage(message, fallback: "DNS history unavailable")
1344 }
1345
1346 dnsHistoryLoading = false
1347 _ = saveHistoryEntry(replaceLatest: true)
1348 }
1349
1350 func loadExtendedSubdomains() async {
1351 guard !searchedDomain.isEmpty else { return }
1352 guard DataAccessService.hasAccess(to: .extendedSubdomains) else {
1353 upgradePrompt = FeatureAccessService.upgradePrompt(for: .extendedSubdomains)
1354 return
1355 }
1356 guard extendedSubdomains.isEmpty else { return }
1357
1358 extendedSubdomainsLoading = true
1359 extendedSubdomainsError = nil
1360
1361 let outcome = await ExternalDataService.shared.extendedSubdomains(
1362 domain: searchedDomain,
1363 existing: subdomains
1364 )
1365
1366 switch outcome.value {
1367 case let .success(results):
1368 extendedSubdomains = results
1369 extendedSubdomainsError = nil
1370 case let .empty(message):
1371 extendedSubdomains = []
1372 extendedSubdomainsError = message
1373 case let .error(message):
1374 extendedSubdomains = []
1375 extendedSubdomainsError = conciseExternalMessage(message, fallback: "Extended subdomains unavailable")
1376 }
1377
1378 extendedSubdomainsLoading = false
1379 _ = saveHistoryEntry(replaceLatest: true)
1380 }
1381
1382 func refreshUsageCredits() async {
1383 let statuses = await UsageCreditService.shared.allStatuses()
1384 usageCredits = Dictionary(uniqueKeysWithValues: statuses.map { ($0.feature, $0) })
1385 }
1386
1387 private func performLookup(domain: String, lookupID: UUID) async -> HistoryEntry? {
1388 let lookupStartedAt = DomainDebugLog.signpostStart("DomainViewModel.performLookup", domain: domain)
1389 let previous = previousSnapshot(
1390 for: domain,
1391 trackedDomainID: currentTrackedDomain?.id,
1392 replacingLatest: false
1393 )
1394 let inspectedSnapshot = await inspectionService.inspectSnapshot(domain: domain, previousSnapshot: previous)
1395 DomainDebugLog.debug("DomainViewModel.performLookup inspectionReturned domain=\(domain)")
1396 guard !Task.isCancelled, isCurrentLookup(lookupID) else { return nil }
1397
1398 let snapshot = Self.resolvedSnapshotAfterFallback(inspectedSnapshot, previousSnapshot: previous)
1399 let applyStartedAt = DomainDebugLog.signpostStart("DomainViewModel.applySnapshot", domain: domain)
1400 applySnapshot(snapshot)
1401 DomainDebugLog.signpostEnd("DomainViewModel.applySnapshot", start: applyStartedAt, domain: domain)
1402 lastLookupDurationMs = snapshot.totalLookupDurationMs
1403 refreshingTrackedDomainID = nil
1404
1405 if DataAccessService.hasAccess(to: .domainPricing), domainPricing == nil {
1406 DomainDebugLog.debug("DomainViewModel.performLookup loadingPricing domain=\(domain)")
1407 await refreshDomainPricing(for: snapshot.domain, persistAfterFetch: false)
1408 }
1409
1410 if DataAccessService.hasAccess(to: .reputation), reputation == nil {
1411 DomainDebugLog.debug("DomainViewModel.performLookup loadingReputation domain=\(domain)")
1412 await refreshReputation(for: snapshot.domain, persistAfterFetch: false)
1413 }
1414
1415 guard snapshot.statusMessage == nil else {
1416 return history.first(where: { $0.id == snapshot.historyEntryID })
1417 }
1418
1419 let saveStartedAt = DomainDebugLog.signpostStart("DomainViewModel.saveHistoryEntry", domain: domain)
1420 let entry = saveHistoryEntry(replaceLatest: false, reuseCurrentAnalysis: true)
1421 DomainDebugLog.signpostEnd("DomainViewModel.saveHistoryEntry", start: saveStartedAt, domain: domain)
1422 DomainDebugLog.signpostEnd("DomainViewModel.performLookup", start: lookupStartedAt, domain: domain)
1423 return entry
1424 }
1425
1426 private func applySnapshot(_ snapshot: LookupSnapshot) {
1427 currentHistoryEntryID = snapshot.historyEntryID
1428 currentSnapshotTimestamp = snapshot.timestamp
1429 currentResultSource = snapshot.resultSource
1430 currentCachedSections = snapshot.cachedSections
1431 currentStatusMessage = snapshot.statusMessage
1432 currentDiffSections = []
1433 ownershipDiff = []
1434 let reportStartedAt = DomainDebugLog.signpostStart("DomainViewModel.reportBuilder.build", domain: snapshot.domain)
1435 currentReport = reportBuilder.build(
1436 from: snapshot,
1437 previousSnapshot: previousSnapshot(
1438 for: snapshot.domain,
1439 trackedDomainID: snapshot.trackedDomainID ?? trackedDomain(for: snapshot.domain)?.id,
1440 replacingLatest: false
1441 ),
1442 historyEntries: historyEntries(for: snapshot.domain)
1443 )
1444 DomainDebugLog.signpostEnd("DomainViewModel.reportBuilder.build", start: reportStartedAt, domain: snapshot.domain)
1445 currentChangeSummary = currentReport?.changeSummary ?? snapshot.changeSummary
1446
1447 dnsSections = snapshot.dnsSections
1448 dnsError = snapshot.dnsError
1449 availabilityResult = snapshot.availabilityResult
1450 suggestions = snapshot.suggestions
1451 sslInfo = snapshot.sslInfo
1452 sslError = snapshot.sslError
1453 hstsPreloaded = snapshot.hstsPreloaded
1454 httpHeaders = snapshot.httpHeaders
1455 httpSecurityGrade = snapshot.httpSecurityGrade
1456 httpStatusCode = snapshot.httpStatusCode
1457 httpResponseTimeMs = snapshot.httpResponseTimeMs
1458 httpProtocol = snapshot.httpProtocol
1459 http3Advertised = snapshot.http3Advertised
1460 httpHeadersError = snapshot.httpHeadersError
1461 reachabilityResults = snapshot.reachabilityResults
1462 reachabilityError = snapshot.reachabilityError
1463 ipGeolocation = snapshot.ipGeolocation
1464 ipGeolocationError = snapshot.ipGeolocationError
1465 emailSecurity = snapshot.emailSecurity
1466 emailSecurityError = snapshot.emailSecurityError
1467 ownershipResult = snapshot.ownership
1468 ownershipError = snapshot.ownershipError
1469 ownershipHistory = snapshot.ownershipHistory
1470 ownershipHistoryError = snapshot.ownershipHistoryError
1471 ptrRecord = snapshot.ptrRecord
1472 ptrError = snapshot.ptrError
1473 redirectChain = snapshot.redirectChain
1474 redirectChainError = snapshot.redirectChainError
1475 subdomains = snapshot.subdomains
1476 subdomainsError = snapshot.subdomainsError
1477 extendedSubdomains = snapshot.extendedSubdomains
1478 extendedSubdomainsError = snapshot.extendedSubdomainsError
1479 dnsHistory = snapshot.dnsHistory
1480 dnsHistoryError = snapshot.dnsHistoryError
1481 domainPricing = snapshot.domainPricing
1482 domainPricingError = snapshot.domainPricingError
1483 portScanResults = snapshot.portScanResults.filter { $0.kind == .standard }
1484 customPortResults = snapshot.portScanResults.filter { $0.kind == .custom }
1485 portScanError = snapshot.portScanError
1486 customPortScanError = nil
1487
1488 dnsLoading = false
1489 availabilityLoading = false
1490 suggestionsLoading = false
1491 sslLoading = false
1492 hstsLoading = false
1493 httpHeadersLoading = false
1494 reachabilityLoading = false
1495 ipGeolocationLoading = false
1496 emailSecurityLoading = false
1497 ownershipLoading = false
1498 ownershipHistoryLoading = false
1499 ptrLoading = false
1500 redirectChainLoading = false
1501 subdomainsLoading = false
1502 extendedSubdomainsLoading = false
1503 dnsHistoryLoading = false
1504 domainPricingLoading = false
1505 portScanLoading = false
1506 customPortScanLoading = false
1507 }
1508
1509 private static func resolvedSnapshotAfterFallback(
1510 _ snapshot: LookupSnapshot,
1511 previousSnapshot: LookupSnapshot?
1512 ) -> LookupSnapshot {
1513 guard shouldFallbackToSnapshot(snapshot), let previousSnapshot else {
1514 return snapshot
1515 }
1516
1517 return LookupSnapshot(
1518 historyEntryID: previousSnapshot.historyEntryID,
1519 domain: previousSnapshot.domain,
1520 timestamp: previousSnapshot.timestamp,
1521 trackedDomainID: previousSnapshot.trackedDomainID,
1522 note: previousSnapshot.note,
1523 appVersion: previousSnapshot.appVersion,
1524 resolverDisplayName: previousSnapshot.resolverDisplayName,
1525 resolverURLString: previousSnapshot.resolverURLString,
1526 dataSources: previousSnapshot.dataSources,
1527 provenanceBySection: previousSnapshot.provenanceBySection,
1528 availabilityConfidence: previousSnapshot.availabilityConfidence,
1529 ownershipConfidence: previousSnapshot.ownershipConfidence,
1530 subdomainConfidence: previousSnapshot.subdomainConfidence,
1531 emailSecurityConfidence: previousSnapshot.emailSecurityConfidence,
1532 geolocationConfidence: previousSnapshot.geolocationConfidence,
1533 errorDetails: previousSnapshot.errorDetails,
1534 isPartialSnapshot: previousSnapshot.isPartialSnapshot,
1535 validationIssues: previousSnapshot.validationIssues,
1536 totalLookupDurationMs: previousSnapshot.totalLookupDurationMs,
1537 snapshotIndex: previousSnapshot.snapshotIndex,
1538 previousSnapshotID: previousSnapshot.previousSnapshotID,
1539 changeCount: previousSnapshot.changeCount,
1540 severitySummary: previousSnapshot.severitySummary,
1541 dnsSections: previousSnapshot.dnsSections,
1542 dnsError: previousSnapshot.dnsError,
1543 availabilityResult: previousSnapshot.availabilityResult,
1544 suggestions: previousSnapshot.suggestions,
1545 sslInfo: previousSnapshot.sslInfo,
1546 sslError: previousSnapshot.sslError,
1547 hstsPreloaded: previousSnapshot.hstsPreloaded,
1548 httpHeaders: previousSnapshot.httpHeaders,
1549 httpSecurityGrade: previousSnapshot.httpSecurityGrade,
1550 httpStatusCode: previousSnapshot.httpStatusCode,
1551 httpResponseTimeMs: previousSnapshot.httpResponseTimeMs,
1552 httpProtocol: previousSnapshot.httpProtocol,
1553 http3Advertised: previousSnapshot.http3Advertised,
1554 httpHeadersError: previousSnapshot.httpHeadersError,
1555 reachabilityResults: previousSnapshot.reachabilityResults,
1556 reachabilityError: previousSnapshot.reachabilityError,
1557 ipGeolocation: previousSnapshot.ipGeolocation,
1558 ipGeolocationError: previousSnapshot.ipGeolocationError,
1559 emailSecurity: previousSnapshot.emailSecurity,
1560 emailSecurityError: previousSnapshot.emailSecurityError,
1561 ownership: previousSnapshot.ownership,
1562 ownershipError: previousSnapshot.ownershipError,
1563 ownershipHistory: previousSnapshot.ownershipHistory,
1564 ownershipHistoryError: previousSnapshot.ownershipHistoryError,
1565 inferredProvider: previousSnapshot.inferredProvider,
1566 priorProviders: previousSnapshot.priorProviders,
1567 domainClassification: previousSnapshot.domainClassification,
1568 ownershipTransitions: previousSnapshot.ownershipTransitions,
1569 hostingTransitions: previousSnapshot.hostingTransitions,
1570 subdomainHistory: previousSnapshot.subdomainHistory,
1571 riskSignals: previousSnapshot.riskSignals,
1572 intelligenceTimeline: previousSnapshot.intelligenceTimeline,
1573 ptrRecord: previousSnapshot.ptrRecord,
1574 ptrError: previousSnapshot.ptrError,
1575 redirectChain: previousSnapshot.redirectChain,
1576 redirectChainError: previousSnapshot.redirectChainError,
1577 subdomains: previousSnapshot.subdomains,
1578 subdomainsError: previousSnapshot.subdomainsError,
1579 extendedSubdomains: previousSnapshot.extendedSubdomains,
1580 extendedSubdomainsError: previousSnapshot.extendedSubdomainsError,
1581 dnsHistory: previousSnapshot.dnsHistory,
1582 dnsHistoryError: previousSnapshot.dnsHistoryError,
1583 domainPricing: previousSnapshot.domainPricing,
1584 domainPricingError: previousSnapshot.domainPricingError,
1585 reputation: previousSnapshot.reputation,
1586 reputationError: previousSnapshot.reputationError,
1587 portScanResults: previousSnapshot.portScanResults,
1588 portScanError: previousSnapshot.portScanError,
1589 changeSummary: previousSnapshot.changeSummary,
1590 resultSource: .snapshot,
1591 cachedSections: [],
1592 statusMessage: "Last known result • \(previousSnapshot.timestamp.formatted(date: .abbreviated, time: .shortened))"
1593 )
1594 }
1595
1596 private static func shouldFallbackToSnapshot(_ snapshot: LookupSnapshot) -> Bool {
1597 let candidateMessages = [
1598 snapshot.dnsError,
1599 snapshot.httpHeadersError,
1600 snapshot.sslError,
1601 snapshot.ownershipError,
1602 snapshot.subdomainsError,
1603 snapshot.redirectChainError,
1604 snapshot.ipGeolocationError
1605 ]
1606 .compactMap { $0?.lowercased() }
1607
1608 guard !candidateMessages.isEmpty else { return false }
1609 let failedDueToConnectivity = candidateMessages.allSatisfy { message in
1610 message.hasPrefix("network error:") || message.hasPrefix("timeout:") || message.hasPrefix("rate limit:")
1611 }
1612
1613 let hasMaterialData = !snapshot.dnsSections.isEmpty
1614 || !snapshot.httpHeaders.isEmpty
1615 || snapshot.sslInfo != nil
1616 || snapshot.ownership != nil
1617 || !snapshot.subdomains.isEmpty
1618
1619 return failedDueToConnectivity && !hasMaterialData
1620 }
1621
1622 private func runDNS(domain: String, lookupID: UUID) async {
1623 let result = await DNSLookupService.lookupAll(domain: domain)
1624 guard !Task.isCancelled, isCurrentLookup(lookupID) else { return }
1625 switch result {
1626 case let .success(sections):
1627 dnsSections = sections
1628 dnsError = nil
1629 case let .empty(message), let .error(message):
1630 dnsSections = []
1631 dnsError = message
1632 }
1633 dnsLoading = false
1634 }
1635
1636 private func runAvailability(domain: String, lookupID: UUID) async {
1637 let result = await DomainAvailabilityService.check(domain: domain)
1638 guard !Task.isCancelled, isCurrentLookup(lookupID) else { return }
1639 availabilityResult = result
1640 availabilityLoading = false
1641 updateTrackedDomainAvailability(for: result.domain, status: result.status)
1642 }
1643
1644 private func runSSL(domain: String, lookupID: UUID) async {
1645 let result = await SSLCheckService.check(domain: domain)
1646 guard !Task.isCancelled, isCurrentLookup(lookupID) else { return }
1647 switch result {
1648 case let .success(info):
1649 sslInfo = info
1650 sslError = nil
1651 case let .empty(message), let .error(message):
1652 sslInfo = nil
1653 sslError = message
1654 }
1655 sslLoading = false
1656 }
1657
1658 private func runHSTSPreload(domain: String, lookupID: UUID) async {
1659 let result = await SSLCheckService.checkHSTSPreload(domain: domain)
1660 guard !Task.isCancelled, isCurrentLookup(lookupID) else { return }
1661 hstsPreloaded = result
1662 hstsLoading = false
1663 }
1664
1665 private func runHTTPHeaders(domain: String, lookupID: UUID) async {
1666 let result = await HTTPHeadersService.fetch(domain: domain)
1667 guard !Task.isCancelled, isCurrentLookup(lookupID) else { return }
1668 switch result {
1669 case let .success(headersResult):
1670 httpHeaders = headersResult.headers
1671 httpSecurityGrade = HTTPSecurityGrade.grade(for: headersResult.headers).rawValue
1672 httpStatusCode = headersResult.statusCode
1673 httpResponseTimeMs = headersResult.responseTimeMs
1674 httpProtocol = headersResult.httpProtocol
1675 http3Advertised = headersResult.http3Advertised
1676 httpHeadersError = nil
1677 case let .empty(message), let .error(message):
1678 httpHeaders = []
1679 httpSecurityGrade = nil
1680 httpStatusCode = nil
1681 httpResponseTimeMs = nil
1682 httpProtocol = nil
1683 http3Advertised = false
1684 httpHeadersError = message
1685 }
1686 httpHeadersLoading = false
1687 }
1688
1689 private func runReachability(domain: String, lookupID: UUID) async {
1690 let result = await ReachabilityService.checkAll(domain: domain)
1691 guard !Task.isCancelled, isCurrentLookup(lookupID) else { return }
1692 switch result {
1693 case let .success(results):
1694 reachabilityResults = results
1695 reachabilityError = nil
1696 case let .empty(message), let .error(message):
1697 reachabilityResults = []
1698 reachabilityError = message
1699 }
1700 reachabilityLoading = false
1701 }
1702
1703 private func runEmailSecurity(domain: String, txtRecords: [DNSRecord], lookupID: UUID) async {
1704 let result = await EmailSecurityService.analyze(domain: domain, txtRecords: txtRecords)
1705 guard !Task.isCancelled, isCurrentLookup(lookupID) else { return }
1706 switch result {
1707 case let .success(emailResult):
1708 emailSecurity = emailResult
1709 emailSecurityError = nil
1710 case let .empty(message), let .error(message):
1711 emailSecurity = nil
1712 emailSecurityError = message
1713 }
1714 emailSecurityLoading = false
1715 }
1716
1717 private func runOwnership(domain: String, lookupID: UUID) async {
1718 let result = await DomainOwnershipService.lookup(domain: domain)
1719 guard !Task.isCancelled, isCurrentLookup(lookupID) else { return }
1720 switch result {
1721 case let .success(ownership):
1722 ownershipResult = ownership
1723 ownershipError = nil
1724 case let .empty(message), let .error(message):
1725 ownershipResult = nil
1726 ownershipError = message
1727 }
1728 ownershipLoading = false
1729 }
1730
1731 private func runReverseDNS(ip: String, lookupID: UUID) async {
1732 let result = await ReverseDNSService.lookup(ip: ip, resolverURLString: resolverURLString)
1733 guard !Task.isCancelled, isCurrentLookup(lookupID) else { return }
1734 switch result {
1735 case let .success(record):
1736 ptrRecord = record
1737 ptrError = nil
1738 case let .empty(message), let .error(message):
1739 ptrRecord = nil
1740 ptrError = message
1741 }
1742 ptrLoading = false
1743 }
1744
1745 private func runRedirectChain(domain: String, lookupID: UUID) async {
1746 let result = await RedirectChainService.trace(domain: domain)
1747 guard !Task.isCancelled, isCurrentLookup(lookupID) else { return }
1748 switch result {
1749 case let .success(hops):
1750 redirectChain = hops
1751 redirectChainError = nil
1752 case let .empty(message), let .error(message):
1753 redirectChain = []
1754 redirectChainError = message
1755 }
1756 redirectChainLoading = false
1757 }
1758
1759 private func runSubdomains(domain: String, lookupID: UUID) async {
1760 let result = await SubdomainDiscoveryService.discover(for: domain)
1761 guard !Task.isCancelled, isCurrentLookup(lookupID) else { return }
1762 switch result {
1763 case let .success(results):
1764 subdomains = results
1765 subdomainsError = nil
1766 case let .empty(message), let .error(message):
1767 subdomains = []
1768 subdomainsError = message
1769 }
1770 subdomainsLoading = false
1771 }
1772
1773 private func runPortScan(domain: String, lookupID: UUID) async {
1774 let result = await PortScanService.scanAll(domain: domain)
1775 switch result {
1776 case let .success(results):
1777 let enrichedResults = await enrichOpenPortBanners(in: results, domain: domain)
1778 guard !Task.isCancelled, isCurrentLookup(lookupID) else { return }
1779 portScanResults = enrichedResults
1780 portScanError = nil
1781 case let .empty(message), let .error(message):
1782 guard !Task.isCancelled, isCurrentLookup(lookupID) else { return }
1783 portScanResults = []
1784 portScanError = message
1785 }
1786 portScanLoading = false
1787 }
1788
1789 private func runIPGeolocation(ip: String, lookupID: UUID) async {
1790 let result = await IPGeolocationService.lookup(ip: ip)
1791 guard !Task.isCancelled, isCurrentLookup(lookupID) else { return }
1792 switch result {
1793 case let .success(geolocation):
1794 ipGeolocation = geolocation
1795 ipGeolocationError = nil
1796 case let .empty(message), let .error(message):
1797 ipGeolocation = nil
1798 ipGeolocationError = message
1799 }
1800 ipGeolocationLoading = false
1801 }
1802
1803 private func finishDependentWithoutPrimaryIP(lookupID: UUID) async {
1804 guard !Task.isCancelled, isCurrentLookup(lookupID) else { return }
1805 ptrLoading = false
1806 ptrError = "No A record available"
1807 ipGeolocationLoading = false
1808 ipGeolocationError = "No A record available"
1809 }
1810
1811 private func runSuggestions(domain: String, lookupID: UUID) async {
1812 let results = await DomainAvailabilityService.suggestions(for: domain)
1813 guard !Task.isCancelled, isCurrentLookup(lookupID) else { return }
1814 suggestions = results
1815 suggestionsLoading = false
1816 }
1817
1818 private func applyCustomPortResult(_ result: ServiceResult<[PortScanResult]>) {
1819 switch result {
1820 case let .success(results):
1821 customPortResults = results
1822 customPortScanError = nil
1823 _ = saveHistoryEntry(replaceLatest: true)
1824 case let .empty(message), let .error(message):
1825 customPortResults = []
1826 customPortScanError = message
1827 }
1828 customPortScanLoading = false
1829 }
1830
1831 private static func performBatchLookup(domain: String, previousSnapshot: LookupSnapshot?) async -> BatchLookupPayload? {
1832 guard !Task.isCancelled else { return nil }
1833 let inspectionService = DomainInspectionService()
1834 let snapshot = await inspectionService.inspectSnapshot(domain: domain, previousSnapshot: previousSnapshot)
1835 guard !Task.isCancelled else { return nil }
1836 return BatchLookupPayload(snapshot: resolvedSnapshotAfterFallback(snapshot, previousSnapshot: previousSnapshot))
1837 }
1838
1839 private static func enrichOpenPortBanners(_ results: [PortScanResult], domain: String) async -> [PortScanResult] {
1840 let banners = await withTaskGroup(of: (UInt16, String?).self, returning: [UInt16: String].self) { group in
1841 for result in results where result.open {
1842 group.addTask {
1843 let banner = await PortScanService.grabBanner(host: domain, port: result.port)
1844 return (result.port, banner)
1845 }
1846 }
1847
1848 var collected: [UInt16: String] = [:]
1849 for await (port, banner) in group {
1850 if let banner {
1851 collected[port] = banner
1852 }
1853 }
1854 return collected
1855 }
1856
1857 return results.map { result in
1858 var updated = result
1859 updated.banner = banners[result.port]
1860 return updated
1861 }
1862 }
1863
1864 private func enrichOpenPortBanners(in results: [PortScanResult], domain: String) async -> [PortScanResult] {
1865 await Self.enrichOpenPortBanners(results, domain: domain)
1866 }
1867
1868 @discardableResult
1869 private func saveHistoryEntry(replaceLatest: Bool, reuseCurrentAnalysis: Bool = false) -> HistoryEntry? {
1870 guard !searchedDomain.isEmpty else { return nil }
1871 return saveHistoryEntry(
1872 from: currentSnapshot,
1873 replaceLatest: replaceLatest,
1874 updateCurrentState: true,
1875 reuseCurrentAnalysis: reuseCurrentAnalysis
1876 )
1877 }
1878
1879 @discardableResult
1880 private func saveHistoryEntry(
1881 from snapshot: LookupSnapshot,
1882 replaceLatest: Bool,
1883 updateCurrentState: Bool,
1884 reuseCurrentAnalysis: Bool = false
1885 ) -> HistoryEntry? {
1886 let trackedDomainID = snapshot.trackedDomainID ?? trackedDomain(for: snapshot.domain)?.id
1887 let previousSnapshot = previousSnapshot(for: snapshot.domain, trackedDomainID: trackedDomainID, replacingLatest: replaceLatest)
1888 let domainHistoryEntries = history.filter {
1889 $0.domain.caseInsensitiveCompare(snapshot.domain) == .orderedSame
1890 }
1891 let analysis = reuseCurrentAnalysis ? nil : DomainInsightEngine.analyze(snapshot: snapshot, previousSnapshot: previousSnapshot)
1892 let intelligence = DomainIntelligenceService.derive(
1893 snapshot: snapshot,
1894 previousSnapshot: previousSnapshot,
1895 historyEntries: domainHistoryEntries
1896 )
1897 let changeSummary = reuseCurrentAnalysis
1898 ? currentChangeSummary ?? snapshot.changeSummary
1899 : previousSnapshot.map {
1900 DiffService.summary(
1901 from: $0,
1902 to: snapshot,
1903 generatedAt: snapshot.timestamp,
1904 riskAssessment: analysis?.riskAssessment,
1905 insights: analysis?.insights
1906 )
1907 }
1908 let domainDiff = reuseCurrentAnalysis
1909 ? previousSnapshot.map {
1910 DomainDiff(
1911 domain: snapshot.domain,
1912 fromTimestamp: $0.timestamp,
1913 toTimestamp: snapshot.timestamp,
1914 sections: currentDiffSections,
1915 changedSectionIDs: currentDiffSections.filter(\.hasChanges).map(\.id),
1916 changedSectionTitles: currentDiffSections.filter(\.hasChanges).map(\.title),
1917 contextNote: currentChangeSummary?.contextNote
1918 )
1919 }
1920 : previousSnapshot.map { DiffService.compare(from: $0, to: snapshot) }
1921 let diffSections = domainDiff?.sections ?? currentDiffSections
1922 let previousSnapshotID = previousSnapshot?.historyEntryID
1923 let nextSnapshotIndex = nextSnapshotIndex(for: snapshot.domain, trackedDomainID: trackedDomainID)
1924
1925 if updateCurrentState {
1926 currentChangeSummary = changeSummary
1927 currentDiffSections = diffSections
1928 ownershipDiff = diffSections.first(where: { $0.title == "Ownership" })?.items.filter(\.hasChanges) ?? []
1929 if !reuseCurrentAnalysis {
1930 currentReport = reportBuilder.build(
1931 from: snapshot,
1932 previousSnapshot: previousSnapshot,
1933 historyEntries: domainHistoryEntries
1934 )
1935 }
1936 }
1937
1938 let entry = HistoryEntry(
1939 identity: HistoryEntry.Identity(
1940 domain: snapshot.domain,
1941 timestamp: snapshot.timestamp,
1942 trackedDomainID: trackedDomainID,
1943 note: currentHistoryEntry?.note
1944 ),
1945 inspection: .init(snapshot: snapshot),
1946 registration: HistoryEntry.Registration(
1947 ownership: snapshot.ownership,
1948 ownershipHistory: snapshot.ownershipHistory,
1949 inferredProvider: intelligence.inferredProvider,
1950 priorProviders: intelligence.priorProviders,
1951 domainClassification: intelligence.domainClassification,
1952 ownershipTransitions: intelligence.ownershipTransitions,
1953 hostingTransitions: intelligence.hostingTransitions,
1954 domainPricing: snapshot.domainPricing
1955 ),
1956 intelligence: HistoryEntry.Intelligence(
1957 subdomainHistory: intelligence.subdomainHistory,
1958 riskSignals: intelligence.riskSignals,
1959 intelligenceTimeline: intelligence.timelineEvents,
1960 reputation: snapshot.reputation
1961 ),
1962 provenance: .init(snapshot: snapshot),
1963 summary: HistoryEntry.Summary(
1964 primaryIP: Self.primaryIPAddress(from: snapshot),
1965 finalRedirectURL: Self.finalRedirectTarget(from: snapshot),
1966 tlsStatusSummary: Self.httpsSummary(from: snapshot),
1967 emailSecuritySummary: Self.emailSummary(from: snapshot),
1968 httpGradeSummary: snapshot.httpSecurityGrade ?? snapshot.httpHeadersError,
1969 changeSummary: changeSummary,
1970 snapshotIndex: nextSnapshotIndex,
1971 previousSnapshotID: previousSnapshotID,
1972 changeCount: domainDiff?.changeCount ?? changeSummary?.changedSections.count ?? 0,
1973 severitySummary: changeSummary?.severity
1974 ),
1975 failures: .init(snapshot: snapshot)
1976 )
1977
1978 if updateCurrentState {
1979 currentHistoryEntryID = entry.id
1980 }
1981
1982 if replaceLatest, !history.isEmpty, history[0].domain.caseInsensitiveCompare(snapshot.domain) == .orderedSame {
1983 history[0] = entry
1984 } else {
1985 history.insert(entry, at: 0)
1986 trimHistoryToLimit()
1987 }
1988
1989 updateTrackedDomainSnapshotMetadata(
1990 domain: snapshot.domain,
1991 snapshotID: entry.id,
1992 availabilityStatus: snapshot.availabilityResult?.status,
1993 updatedAt: snapshot.timestamp,
1994 changeSummary: changeSummary,
1995 changeSeverity: changeSummary?.severity,
1996 certificateWarningLevel: DomainDiffService.certificateWarningLevel(for: snapshot),
1997 certificateDaysRemaining: snapshot.sslInfo?.daysUntilExpiry
1998 )
1999 persistHistory()
2000 notifyIfNeeded(for: entry, snapshot: snapshot, previousSnapshot: previousSnapshot)
2001 return entry
2002 }
2003
2004 private func persistHistory() {
2005 if historyPersistenceSuspended {
2006 historyPersistenceDirty = true
2007 return
2008 }
2009 let persistStartedAt = DomainDebugLog.signpostStart("DomainViewModel.persistHistory")
2010 DomainDataPortabilityService.saveHistoryEntries(history)
2011 refreshDataLifecycleSummary()
2012 DomainDebugLog.signpostEnd("DomainViewModel.persistHistory", start: persistStartedAt, extra: "count=\(history.count)")
2013 }
2014
2015 func persistAuditSessions() {
2016 DomainDataPortabilityService.saveAuditSessions(auditSessions)
2017 refreshDataLifecycleSummary()
2018 }
2019
2020 func setHistoryAutoPruneOption(_ option: HistoryAutoPruneOption) {
2021 historyAutoPruneOption = option
2022 UserDefaults.standard.set(option.rawValue, forKey: Self.historyAutoPruneKey)
2023 trimHistoryToLimit()
2024 persistHistory()
2025 }
2026
2027 func updateHistoryNote(_ note: String, for entry: HistoryEntry) {
2028 guard let index = history.firstIndex(where: { $0.id == entry.id }) else { return }
2029 history[index].note = note.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty
2030 persistHistory()
2031 }
2032
2033 #if DEBUG
2034 /// True when this session was launched with `DOMAIN_DIG_SEED_FIXTURES`.
2035 /// Blocks tracked-domain persistence, widget-store writes, and persisted-data
2036 /// reloads so fixture data stays strictly in-memory — the audit suite relies
2037 /// on every launch starting from the same state.
2038 private(set) var auditFixturesActive = false
2039
2040 func seedAuditFixturesIfRequested() {
2041 guard AuditFixtures.requested, !auditFixturesActive else { return }
2042 auditFixturesActive = true
2043 trackedDomains = AuditFixtures.trackedDomains
2044 batchResults = AuditFixtures.batchResults
2045 }
2046 #endif
2047
2048 private func persistTrackedDomains() {
2049 #if DEBUG
2050 if auditFixturesActive { return }
2051 #endif
2052 if trackedDomainsPersistenceSuspended {
2053 trackedDomainsPersistenceDirty = true
2054 return
2055 }
2056 DomainDataPortabilityService.saveTrackedDomains(trackedDomains)
2057 CloudSyncService.shared.scheduleSyncIfNeeded()
2058 refreshDataLifecycleSummary()
2059 refreshWidgetData()
2060 }
2061
2062 private func beginBulkPersistenceDeferral() {
2063 historyPersistenceSuspended = true
2064 trackedDomainsPersistenceSuspended = true
2065 historyPersistenceDirty = false
2066 trackedDomainsPersistenceDirty = false
2067 }
2068
2069 private func endBulkPersistenceDeferral() {
2070 historyPersistenceSuspended = false
2071 trackedDomainsPersistenceSuspended = false
2072
2073 if trackedDomainsPersistenceDirty {
2074 trackedDomainsPersistenceDirty = false
2075 DomainDataPortabilityService.saveTrackedDomains(trackedDomains)
2076 CloudSyncService.shared.scheduleSyncIfNeeded()
2077 }
2078
2079 if historyPersistenceDirty {
2080 historyPersistenceDirty = false
2081 DomainDataPortabilityService.saveHistoryEntries(history)
2082 }
2083
2084 refreshDataLifecycleSummary()
2085 }
2086
2087 private func trimHistoryToLimit() {
2088 let hardLimit = historyAutoPruneOption.keepCount ?? Self.maxHistory
2089 history = Array(history.prefix(min(hardLimit, Self.maxHistory)))
2090 }
2091
2092 private func nextSnapshotIndex(for domain: String, trackedDomainID: UUID?) -> Int {
2093 let siblings = history.filter { entry in
2094 if let trackedDomainID {
2095 return entry.trackedDomainID == trackedDomainID
2096 }
2097 return entry.domain.caseInsensitiveCompare(domain) == .orderedSame
2098 }
2099 let existingMax = siblings.compactMap(\.snapshotIndex).max() ?? siblings.count
2100 return existingMax + 1
2101 }
2102
2103 func persistMonitoringSettings(localActivationConfirmed: Bool = false) {
2104 monitoringSettings = MonitoringStorage.sanitizeSettings(monitoringSettings, trackedDomains: trackedDomains)
2105 MonitoringStorage.saveSettings(monitoringSettings)
2106 CloudSyncService.shared.markMonitoringSettingsChanged(localActivationConfirmed: localActivationConfirmed)
2107 }
2108
2109 func sanitizeMonitoringSelection() {
2110 monitoringSettings = MonitoringStorage.sanitizeSettings(monitoringSettings, trackedDomains: trackedDomains)
2111 MonitoringStorage.saveSettings(monitoringSettings)
2112 CloudSyncService.shared.markMonitoringSettingsChanged(localActivationConfirmed: false)
2113 }
2114
2115 private func clearMonitoringLogs() {
2116 monitoringLogs.removeAll()
2117 monitoringStatusMessage = nil
2118 MonitoringStorage.saveLogs([])
2119 }
2120
2121 private func updateTrackedDomainAvailability(for domain: String, status: DomainAvailabilityStatus) {
2122 guard let index = trackedDomains.firstIndex(where: { $0.domain.caseInsensitiveCompare(domain) == .orderedSame }) else {
2123 return
2124 }
2125 trackedDomains[index].lastKnownAvailability = status
2126 persistTrackedDomains()
2127 }
2128
2129 private func updateTrackedDomainSnapshotMetadata(
2130 domain: String,
2131 snapshotID: UUID,
2132 availabilityStatus: DomainAvailabilityStatus?,
2133 updatedAt: Date,
2134 changeSummary: DomainChangeSummary?,
2135 changeSeverity: ChangeSeverity?,
2136 certificateWarningLevel: CertificateWarningLevel,
2137 certificateDaysRemaining: Int?
2138 ) {
2139 guard let index = trackedDomains.firstIndex(where: { $0.domain.caseInsensitiveCompare(domain) == .orderedSame }) else {
2140 return
2141 }
2142 trackedDomains[index].lastSnapshotID = snapshotID
2143 trackedDomains[index].lastKnownAvailability = availabilityStatus
2144 trackedDomains[index].updatedAt = updatedAt
2145 trackedDomains[index].lastChangeSummary = changeSummary
2146 trackedDomains[index].lastChangeSeverity = changeSeverity
2147 trackedDomains[index].certificateWarningLevel = certificateWarningLevel
2148 trackedDomains[index].certificateDaysRemaining = certificateDaysRemaining
2149 persistTrackedDomains()
2150 }
2151
2152 private func notifyIfNeeded(for entry: HistoryEntry, snapshot: LookupSnapshot, previousSnapshot: LookupSnapshot?) {
2153 guard notificationsAuthorized, entry.trackedDomainID != nil else { return }
2154
2155 Task {
2156 if let summary = entry.changeSummary, summary.hasChanges {
2157 await LocalNotificationService.shared.notifyDomainEvent(
2158 domain: entry.domain,
2159 message: summary.message,
2160 severity: summary.severity
2161 )
2162 }
2163
2164 let certificateWarningLevel = DomainDiffService.certificateWarningLevel(for: snapshot)
2165 if certificateWarningLevel == .critical, let daysRemaining = snapshot.sslInfo?.daysUntilExpiry {
2166 await LocalNotificationService.shared.notifyCertificateWarning(
2167 domain: entry.domain,
2168 daysRemaining: daysRemaining
2169 )
2170 }
2171
2172 if let previousStatus = previousSnapshot?.availabilityResult?.status,
2173 let newStatus = snapshot.availabilityResult?.status,
2174 previousStatus != newStatus {
2175 await LocalNotificationService.shared.notifyDomainEvent(
2176 domain: entry.domain,
2177 message: "Availability changed",
2178 severity: .high
2179 )
2180 }
2181 }
2182 }
2183
2184 private func previousSnapshot(for domain: String, trackedDomainID: UUID?, replacingLatest: Bool) -> LookupSnapshot? {
2185 let matchingEntries = history.filter { entry in
2186 if let trackedDomainID {
2187 return entry.trackedDomainID == trackedDomainID
2188 }
2189 return entry.domain.caseInsensitiveCompare(domain) == .orderedSame
2190 }
2191
2192 if replacingLatest {
2193 return matchingEntries.dropFirst().first?.snapshot
2194 }
2195 return matchingEntries.first?.snapshot
2196 }
2197
2198 private func trackedDomain(for domain: String) -> TrackedDomain? {
2199 trackedDomains.first { $0.domain.caseInsensitiveCompare(domain) == .orderedSame }
2200 }
2201
2202 func normalizedDomain(_ domain: String) -> String {
2203 domain
2204 .trimmingCharacters(in: .whitespacesAndNewlines)
2205 .replacingOccurrences(of: "https://", with: "")
2206 .replacingOccurrences(of: "http://", with: "")
2207 .components(separatedBy: "/")
2208 .first?
2209 .lowercased() ?? ""
2210 }
2211
2212 private func linkTrackedDomainHistory(for domain: String) {
2213 guard let trackedDomain = trackedDomain(for: domain) else { return }
2214 var didChange = false
2215
2216 for index in history.indices where history[index].domain.caseInsensitiveCompare(domain) == .orderedSame {
2217 if history[index].trackedDomainID != trackedDomain.id {
2218 history[index].trackedDomainID = trackedDomain.id
2219 didChange = true
2220 }
2221 }
2222
2223 if didChange {
2224 persistHistory()
2225 }
2226
2227 if let latestEntry = history.first(where: { $0.domain.caseInsensitiveCompare(domain) == .orderedSame }),
2228 let trackedIndex = trackedDomains.firstIndex(where: { $0.id == trackedDomain.id }) {
2229 trackedDomains[trackedIndex].lastSnapshotID = latestEntry.id
2230 trackedDomains[trackedIndex].lastChangeSummary = latestEntry.changeSummary
2231 trackedDomains[trackedIndex].lastChangeSeverity = latestEntry.changeSummary?.severity
2232 trackedDomains[trackedIndex].lastKnownAvailability = latestEntry.availabilityResult?.status
2233 trackedDomains[trackedIndex].certificateWarningLevel = DomainDiffService.certificateWarningLevel(for: latestEntry.snapshot)
2234 trackedDomains[trackedIndex].certificateDaysRemaining = latestEntry.sslInfo?.daysUntilExpiry
2235 trackedDomains[trackedIndex].updatedAt = latestEntry.timestamp
2236 persistTrackedDomains()
2237 }
2238 }
2239
2240 private func addRecentSearch(_ domain: String) {
2241 recentSearches.removeAll { $0.lowercased() == domain.lowercased() }
2242 recentSearches.insert(domain, at: 0)
2243 if recentSearches.count > Self.maxRecent {
2244 recentSearches = Array(recentSearches.prefix(Self.maxRecent))
2245 }
2246 DomainDataPortabilityService.saveRecentSearches(recentSearches)
2247 CloudSyncService.shared.markAppSettingsChanged()
2248 refreshDataLifecycleSummary()
2249 }
2250
2251 private func beginLookup(for target: String, cancelExistingTask: Bool = true) -> UUID {
2252 if cancelExistingTask {
2253 lookupTask?.cancel()
2254 }
2255 customPortScanTask?.cancel()
2256
2257 let lookupID = UUID()
2258 activeLookupID = lookupID
2259 lookupStartedAt = Date()
2260 lastLookupDurationMs = nil
2261 addRecentSearch(target)
2262 searchedDomain = target
2263 hasRun = true
2264 currentHistoryEntryID = nil
2265 currentSnapshotTimestamp = Date()
2266 currentResultSource = .live
2267 currentCachedSections = []
2268 currentStatusMessage = nil
2269 currentDiffSections = []
2270 currentChangeSummary = nil
2271 currentReport = nil
2272 ownershipDiff = []
2273 clearLookupState()
2274 setAllLoadingStates(true)
2275 customPortScanLoading = false
2276 return lookupID
2277 }
2278
2279 func startBatchLookup(domains: [String], source: BatchLookupSource, workflow: DomainWorkflow? = nil) {
2280 guard !domains.isEmpty else { return }
2281 guard !batchLookupRunning else { return }
2282
2283 let now = Date()
2284 if let lastBatchStartedAt, now.timeIntervalSince(lastBatchStartedAt) < 1 {
2285 return
2286 }
2287
2288 lastBatchStartedAt = now
2289 clearBatchState()
2290 batchLookupSource = source
2291 activeWorkflowRunID = workflow?.id
2292 activeWorkflowRunName = workflow?.name
2293 batchTotalCount = domains.count
2294 batchLookupRunning = true
2295 batchResults = domains.map {
2296 BatchLookupResult(
2297 domain: $0,
2298 historyEntryID: nil,
2299 availability: nil,
2300 primaryIP: nil,
2301 quickStatus: "Pending",
2302 timestamp: Date(),
2303 status: .pending
2304 )
2305 }
2306
2307 lookupTask?.cancel()
2308 customPortScanTask?.cancel()
2309 batchTask?.cancel()
2310
2311 SweepActivityController.shared.begin(
2312 title: source == .watchlistRefresh ? "Watchlist Sweep" : "Batch Lookup",
2313 total: domains.count
2314 )
2315
2316 batchTask = Task { [weak self] in
2317 guard let self else { return }
2318 self.notificationsAuthorized = await LocalNotificationService.shared.requestAuthorizationIfNeeded()
2319 await self.runBatchLookup(domains: domains, source: source)
2320 }
2321 }
2322
2323 private func clearBatchState() {
2324 batchResults = []
2325 batchLookupSource = .manual
2326 batchCurrentDomain = nil
2327 batchCompletedCount = 0
2328 batchTotalCount = 0
2329 batchLookupRunning = false
2330 latestBatchSweepSummary = nil
2331 latestWorkflowRunSummary = nil
2332 activeBatchDomains = []
2333 activeWorkflowRunID = nil
2334 activeWorkflowRunName = nil
2335 batchTask = nil
2336 }
2337
2338 private func parsedDomains(from input: String) -> [String] {
2339 let separators = CharacterSet(charactersIn: ",\n")
2340 var seen = Set<String>()
2341
2342 return input
2343 .components(separatedBy: separators)
2344 .map(normalizedDomain)
2345 .filter { !$0.isEmpty }
2346 .filter { seen.insert($0).inserted }
2347 }
2348
2349 private func runBatchLookup(domains: [String], source: BatchLookupSource) async {
2350 let concurrencyLimit = min(source == .watchlistRefresh ? 4 : 3, max(domains.count, 1))
2351 var nextIndex = 0
2352 beginBulkPersistenceDeferral()
2353
2354 await withTaskGroup(of: (String, BatchLookupPayload?).self) { group in
2355 for _ in 0..<concurrencyLimit {
2356 guard nextIndex < domains.count else { break }
2357 let domain = domains[nextIndex]
2358 nextIndex += 1
2359 enqueueBatchLookup(domain: domain, source: source, group: &group)
2360 }
2361
2362 while let (domain, payload) = await group.next() {
2363 completeBatchLookup(domain: domain, payload: payload)
2364
2365 if nextIndex < domains.count, !Task.isCancelled {
2366 let nextDomain = domains[nextIndex]
2367 nextIndex += 1
2368 enqueueBatchLookup(domain: nextDomain, source: source, group: &group)
2369 }
2370 }
2371 }
2372
2373 endBulkPersistenceDeferral()
2374 finishBatchLookup(source: source)
2375 }
2376
2377 private func enqueueBatchLookup(
2378 domain: String,
2379 source: BatchLookupSource,
2380 group: inout TaskGroup<(String, BatchLookupPayload?)>
2381 ) {
2382 activeBatchDomains.append(domain)
2383 batchCurrentDomain = activeBatchDomains.first
2384 if source == .watchlistRefresh {
2385 refreshingTrackedDomainID = trackedDomain(for: domain)?.id
2386 }
2387 updateBatchResult(domain: domain, status: .running, quickStatus: "Running", entry: nil, errorMessage: nil)
2388 let previousSnapshot = previousSnapshot(for: domain, trackedDomainID: trackedDomain(for: domain)?.id, replacingLatest: false)
2389
2390 group.addTask { [domain, previousSnapshot] in
2391 let payload = await Self.performBatchLookup(domain: domain, previousSnapshot: previousSnapshot)
2392 return (domain, payload)
2393 }
2394 }
2395
2396 private func completeBatchLookup(domain: String, payload: BatchLookupPayload?) {
2397 activeBatchDomains.removeAll { $0.caseInsensitiveCompare(domain) == .orderedSame }
2398 batchCurrentDomain = activeBatchDomains.first
2399
2400 guard let payload else {
2401 updateBatchResult(
2402 domain: domain,
2403 status: .failed,
2404 quickStatus: "Failed",
2405 entry: nil,
2406 resultSource: .live,
2407 errorMessage: "Lookup cancelled"
2408 )
2409 batchCompletedCount += 1
2410 SweepActivityController.shared.update(
2411 completed: batchCompletedCount,
2412 total: batchTotalCount,
2413 currentDomain: batchCurrentDomain
2414 )
2415 return
2416 }
2417
2418 let entry = payload.snapshot.historyEntryID.flatMap { id in
2419 history.first(where: { $0.id == id })
2420 } ?? saveHistoryEntry(from: payload.snapshot, replaceLatest: false, updateCurrentState: false)
2421 let certificateWarningLevel = DomainDiffService.certificateWarningLevel(for: payload.snapshot)
2422 let riskAssessment = entry?.changeSummary?.riskAssessment ?? DomainInsightEngine.analyze(snapshot: payload.snapshot).riskAssessment
2423 let quickStatus: String
2424 if entry?.changeSummary?.hasChanges == true {
2425 if entry?.changeSummary?.impactClassification == .critical {
2426 quickStatus = "Critical"
2427 } else {
2428 quickStatus = entry?.changeSummary?.severity == .high ? "High" : "Changed"
2429 }
2430 } else if certificateWarningLevel != .none {
2431 quickStatus = certificateWarningLevel == .critical ? "Critical" : "Warning"
2432 } else if riskAssessment.level == .high {
2433 quickStatus = "High"
2434 } else {
2435 quickStatus = "Unchanged"
2436 }
2437
2438 updateBatchResult(
2439 domain: domain,
2440 status: .completed,
2441 quickStatus: quickStatus,
2442 entry: entry,
2443 resultSource: payload.snapshot.resultSource,
2444 errorMessage: payload.snapshot.statusMessage
2445 )
2446 batchCompletedCount += 1
2447 SweepActivityController.shared.update(
2448 completed: batchCompletedCount,
2449 total: batchTotalCount,
2450 currentDomain: batchCurrentDomain
2451 )
2452 }
2453
2454 private func finishBatchLookup(source: BatchLookupSource) {
2455 batchLookupRunning = false
2456 batchCurrentDomain = nil
2457 activeBatchDomains = []
2458 refreshingTrackedDomainID = nil
2459 batchTask = nil
2460
2461 let changedCount = batchResults.filter { $0.quickStatus == "Changed" || $0.quickStatus == "High" || $0.quickStatus == "Critical" }.count
2462 let unchangedCount = batchResults.filter { $0.quickStatus == "Unchanged" && $0.status == .completed }.count
2463 let warningCount = batchResults.filter {
2464 $0.certificateWarningLevel != .none
2465 || $0.changeClassification == .warning
2466 || $0.changeClassification == .critical
2467 || $0.riskLevel == .high
2468 }.count
2469
2470 let summary = BatchSweepSummary(
2471 source: source,
2472 totalDomains: batchResults.count,
2473 changedDomains: changedCount,
2474 unchangedDomains: unchangedCount,
2475 warningDomains: warningCount,
2476 results: batchResults.sorted { lhs, rhs in
2477 if lhs.status != rhs.status {
2478 return lhs.status.rawValue < rhs.status.rawValue
2479 }
2480 return lhs.domain.localizedCaseInsensitiveCompare(rhs.domain) == .orderedAscending
2481 },
2482 generatedAt: Date()
2483 )
2484 latestBatchSweepSummary = summary
2485 SweepActivityController.shared.end(changed: changedCount, warnings: warningCount)
2486 AppAccessibility.announce(
2487 "Sweep complete. \(summary.results.count) domains, \(changedCount) changed, \(warningCount) warnings."
2488 )
2489
2490 if source == .workflow, let activeWorkflowRunID, let activeWorkflowRunName {
2491 let workflowReports: [DomainReport] = summary.results.compactMap { result in
2492 guard let entry = historyEntry(for: result) else { return nil }
2493 return report(for: entry)
2494 }
2495 latestWorkflowRunSummary = WorkflowRunSummary(
2496 workflowID: activeWorkflowRunID,
2497 workflowName: activeWorkflowRunName,
2498 totalDomains: batchResults.count,
2499 changedDomains: changedCount,
2500 unchangedDomains: unchangedCount,
2501 warningDomains: warningCount,
2502 results: summary.results,
2503 workflowInsights: DomainInsightEngine.workflowInsights(for: workflowReports),
2504 generatedAt: summary.generatedAt
2505 )
2506 }
2507
2508 if notificationsAuthorized, source != .workflow {
2509 Task {
2510 await LocalNotificationService.shared.notifySweepComplete(summary: summary)
2511 }
2512 }
2513 }
2514
2515 private func updateBatchResult(
2516 domain: String,
2517 status: BatchLookupStatus,
2518 quickStatus: String,
2519 entry: HistoryEntry?,
2520 resultSource: LookupResultSource = .live,
2521 errorMessage: String?
2522 ) {
2523 guard let index = batchResults.firstIndex(where: { $0.domain.caseInsensitiveCompare(domain) == .orderedSame }) else {
2524 return
2525 }
2526
2527 batchResults[index] = BatchLookupResult(
2528 id: batchResults[index].id,
2529 domain: domain,
2530 historyEntryID: entry?.id,
2531 resultSource: resultSource,
2532 availability: entry?.availabilityResult?.status,
2533 primaryIP: entry?.primaryIP,
2534 quickStatus: quickStatus,
2535 summaryMessage: entry?.changeSummary?.message,
2536 changeSeverity: entry?.changeSummary?.severity,
2537 changeClassification: entry?.changeSummary?.impactClassification,
2538 certificateWarningLevel: entry.map { DomainDiffService.certificateWarningLevel(for: $0.snapshot) } ?? batchResults[index].certificateWarningLevel,
2539 riskScore: entry.map { $0.changeSummary?.riskAssessment?.score ?? report(for: $0).riskAssessment.score },
2540 riskLevel: entry.map { $0.changeSummary?.riskAssessment?.level ?? report(for: $0).riskAssessment.level },
2541 timestamp: entry?.timestamp ?? Date(),
2542 status: status,
2543 errorMessage: errorMessage
2544 )
2545 }
2546
2547 private func clearLookupState() {
2548 dnsSections = []
2549 dnsError = nil
2550 dnsLoading = false
2551 availabilityResult = nil
2552 availabilityLoading = false
2553 suggestions = []
2554 suggestionsLoading = false
2555 sslInfo = nil
2556 sslError = nil
2557 sslLoading = false
2558 hstsPreloaded = nil
2559 hstsLoading = false
2560 httpHeaders = []
2561 httpSecurityGrade = nil
2562 httpStatusCode = nil
2563 httpResponseTimeMs = nil
2564 httpProtocol = nil
2565 http3Advertised = false
2566 httpHeadersError = nil
2567 httpHeadersLoading = false
2568 reachabilityResults = []
2569 reachabilityError = nil
2570 reachabilityLoading = false
2571 ipGeolocation = nil
2572 ipGeolocationError = nil
2573 ipGeolocationLoading = false
2574 emailSecurity = nil
2575 emailSecurityError = nil
2576 emailSecurityLoading = false
2577 ownershipResult = nil
2578 ownershipError = nil
2579 ownershipLoading = false
2580 ownershipHistory = []
2581 ownershipHistoryError = nil
2582 ownershipHistoryLoading = false
2583 ptrRecord = nil
2584 ptrError = nil
2585 ptrLoading = false
2586 redirectChain = []
2587 redirectChainError = nil
2588 redirectChainLoading = false
2589 subdomains = []
2590 subdomainsError = nil
2591 subdomainsLoading = false
2592 extendedSubdomains = []
2593 extendedSubdomainsError = nil
2594 extendedSubdomainsLoading = false
2595 dnsHistory = []
2596 dnsHistoryError = nil
2597 dnsHistoryLoading = false
2598 domainPricing = nil
2599 domainPricingError = nil
2600 domainPricingLoading = false
2601 portScanResults = []
2602 portScanError = nil
2603 portScanLoading = false
2604 customPortResults = []
2605 customPortScanError = nil
2606 customPortScanLoading = false
2607 currentHistoryEntryID = nil
2608 currentSnapshotTimestamp = Date()
2609 currentResultSource = .live
2610 currentCachedSections = []
2611 currentStatusMessage = nil
2612 currentReport = nil
2613 }
2614
2615 private func setAllLoadingStates(_ loading: Bool) {
2616 dnsLoading = loading
2617 availabilityLoading = loading
2618 suggestionsLoading = loading
2619 sslLoading = loading
2620 hstsLoading = loading
2621 httpHeadersLoading = loading
2622 reachabilityLoading = loading
2623 ipGeolocationLoading = loading
2624 emailSecurityLoading = loading
2625 ownershipLoading = loading
2626 ownershipHistoryLoading = false
2627 ptrLoading = loading
2628 redirectChainLoading = loading
2629 subdomainsLoading = loading
2630 extendedSubdomainsLoading = false
2631 dnsHistoryLoading = false
2632 domainPricingLoading = false
2633 portScanLoading = loading
2634 }
2635
2636 private func primaryIPAddress(from sections: [DNSSection]) -> String? {
2637 sections.first(where: { $0.recordType == .A })?.records.first?.value
2638 }
2639
2640 private func isCurrentLookup(_ lookupID: UUID) -> Bool {
2641 activeLookupID == lookupID
2642 }
2643
2644 func recentSnapshots(for trackedDomain: TrackedDomain, limit: Int = 6) -> [HistoryEntry] {
2645 history
2646 .filter { $0.trackedDomainID == trackedDomain.id || $0.domain.caseInsensitiveCompare(trackedDomain.domain) == .orderedSame }
2647 .sorted { $0.timestamp > $1.timestamp }
2648 .prefix(limit)
2649 .map { $0 }
2650 }
2651
2652 func latestSnapshots(for domains: [TrackedDomain]) -> [HistoryEntry] {
2653 domains.compactMap { trackedDomain in
2654 recentSnapshots(for: trackedDomain, limit: 1).first
2655 }
2656 }
2657
2658 func diffSectionsForLatestSnapshots(of trackedDomain: TrackedDomain) -> [DomainDiffSection] {
2659 let snapshots = recentSnapshots(for: trackedDomain, limit: 2)
2660 guard snapshots.count == 2 else { return [] }
2661 return DomainDiffService.diff(from: snapshots[1].snapshot, to: snapshots[0].snapshot)
2662 }
2663
2664 func latestChangeSummary(for trackedDomain: TrackedDomain) -> DomainChangeSummary? {
2665 trackedDomain.lastChangeSummary ?? recentSnapshots(for: trackedDomain, limit: 1).first?.changeSummary
2666 }
2667
2668 func latestSnapshot(for trackedDomain: TrackedDomain) -> LookupSnapshot? {
2669 recentSnapshots(for: trackedDomain, limit: 1).first?.snapshot
2670 }
2671
2672 func trackedDomain(withID id: UUID) -> TrackedDomain? {
2673 trackedDomains.first(where: { $0.id == id })
2674 }
2675
2676 private func portfolioDashboardStamp() -> PortfolioDashboardStamp {
2677 PortfolioDashboardStamp(
2678 trackedDomainSignature: trackedDomains
2679 .sorted { $0.id.uuidString < $1.id.uuidString }
2680 .map {
2681 [
2682 $0.id.uuidString,
2683 $0.updatedAt.timeIntervalSinceReferenceDate.formatted(.number.precision(.fractionLength(3))),
2684 String($0.pendingMonitoringAlerts.count),
2685 $0.lastMonitoredAt?.timeIntervalSinceReferenceDate.formatted(.number.precision(.fractionLength(3))) ?? "0",
2686 $0.lastAlertAt?.timeIntervalSinceReferenceDate.formatted(.number.precision(.fractionLength(3))) ?? "0"
2687 ].joined(separator: "|")
2688 },
2689 historySignature: history
2690 .prefix(250)
2691 .map {
2692 [
2693 $0.id.uuidString,
2694 $0.timestamp.timeIntervalSinceReferenceDate.formatted(.number.precision(.fractionLength(3))),
2695 String($0.changeCount),
2696 $0.severitySummary?.rawValue.description ?? "-"
2697 ].joined(separator: "|")
2698 },
2699 monitoringSignature: monitoringLogs
2700 .prefix(100)
2701 .map {
2702 [
2703 $0.id.uuidString,
2704 $0.timestamp.timeIntervalSinceReferenceDate.formatted(.number.precision(.fractionLength(3))),
2705 String($0.alertsTriggered),
2706 String($0.changesFound)
2707 ].joined(separator: "|")
2708 }
2709 )
2710 }
2711
2712 private func buildPortfolioDomainStates() -> [PortfolioDomainStatus] {
2713 let now = Date()
2714 return sortedTrackedDomains(from: trackedDomains, using: .pinned).map { trackedDomain in
2715 let recentEntries = recentSnapshots(for: trackedDomain, limit: 8)
2716 let latestEntry = recentEntries.first
2717 let report = latestEntry.map { self.report(for: $0) } ?? reportBuilder.build(from: placeholderSnapshot(for: trackedDomain))
2718 let recentMonitoringResults = monitoringLogs
2719 .flatMap(\.checkedDomains)
2720 .filter { $0.domain.caseInsensitiveCompare(trackedDomain.domain) == .orderedSame }
2721 .sorted { $0.checkedAt > $1.checkedAt }
2722 let recentFailureResults = recentMonitoringResults.filter {
2723 let isFailure = $0.errorMessage != nil || ($0.alertSeverity ?? .info) >= .warning
2724 return isFailure && now.timeIntervalSince($0.checkedAt) <= 7 * 24 * 60 * 60
2725 }
2726 let recentChangeCount = recentEntries.filter {
2727 guard let changeSummary = $0.changeSummary, changeSummary.hasChanges else { return false }
2728 return now.timeIntervalSince($0.timestamp) <= 7 * 24 * 60 * 60
2729 }.count
2730 let recentDNSChange = recentEntries.contains {
2731 guard let changeSummary = $0.changeSummary else { return false }
2732 return changeSummary.changedSections.contains(where: { $0.localizedCaseInsensitiveContains("dns") })
2733 && now.timeIntervalSince($0.timestamp) <= 7 * 24 * 60 * 60
2734 }
2735 let recentCriticalChange = recentEntries.contains {
2736 guard let changeSummary = $0.changeSummary else { return false }
2737 return changeSummary.impactClassification == .critical
2738 && now.timeIntervalSince($0.timestamp) <= 7 * 24 * 60 * 60
2739 }
2740 let certificateExpiryState = trackedDomain.certificateWarningLevel == .none
2741 ? report.certificateExpiryState
2742 : trackedDomain.certificateWarningLevel
2743 let isUnreachable: Bool = {
2744 if let latestEntry {
2745 if !latestEntry.reachabilityResults.isEmpty {
2746 return !latestEntry.reachabilityResults.contains(where: \.reachable)
2747 }
2748 if latestEntry.reachabilityError != nil {
2749 return true
2750 }
2751 }
2752 return recentFailureResults.contains { ($0.alertSeverity ?? .info) == .critical && $0.errorMessage != nil }
2753 }()
2754 let hasInvalidTLS = latestEntry?.sslInfo == nil && latestEntry?.sslError != nil
2755 let instabilityScore = DomainHealth.instabilityScore(
2756 recentChangeCount: recentChangeCount,
2757 recentFailureCount: recentFailureResults.count,
2758 pendingAlertCount: trackedDomain.pendingMonitoringAlerts.count,
2759 hasRecentDNSChange: recentDNSChange
2760 )
2761 let health = DomainHealth.classify(
2762 certificateExpiryState: certificateExpiryState,
2763 isReachable: !isUnreachable,
2764 recentMonitoringFailureCount: recentFailureResults.count,
2765 hasRecentDNSChange: recentDNSChange,
2766 instabilityScore: instabilityScore,
2767 hasRecentCriticalChange: recentCriticalChange,
2768 hasInvalidTLS: hasInvalidTLS
2769 )
2770
2771 return PortfolioDomainStatus(
2772 trackedDomain: trackedDomain,
2773 latestEntry: latestEntry,
2774 report: report,
2775 apexDomain: apexDomain(for: trackedDomain.domain),
2776 health: health,
2777 lastChangeDate: latestEntry?.changeSummary?.hasChanges == true
2778 ? latestEntry?.timestamp
2779 : trackedDomain.monitoringState.lastChangeDate ?? report.lastChangeDate,
2780 lastMonitoringFailure: recentFailureResults.first?.checkedAt,
2781 instabilityScore: instabilityScore,
2782 certificateExpiryState: certificateExpiryState,
2783 certificateDaysRemaining: trackedDomain.certificateDaysRemaining ?? latestEntry?.sslInfo?.daysUntilExpiry,
2784 isUnreachable: isUnreachable,
2785 recentDNSChange: recentDNSChange,
2786 recentCriticalChange: recentCriticalChange,
2787 recentFailureCount: recentFailureResults.count
2788 )
2789 }
2790 }
2791
2792 private func buildPortfolioActivity(from domainStates: [PortfolioDomainStatus]) -> [PortfolioActivityItem] {
2793 var items: [PortfolioActivityItem] = []
2794 let healthByDomainID = Dictionary(uniqueKeysWithValues: domainStates.map { ($0.trackedDomain.id, $0.health) })
2795
2796 for state in domainStates {
2797 if let latestEntry = state.latestEntry,
2798 let changeSummary = latestEntry.changeSummary,
2799 changeSummary.hasChanges {
2800 items.append(
2801 PortfolioActivityItem(
2802 id: "history|\(latestEntry.id.uuidString)",
2803 trackedDomainID: state.trackedDomain.id,
2804 domain: state.trackedDomain.domain,
2805 message: portfolioHistoryMessage(for: latestEntry),
2806 timestamp: latestEntry.timestamp,
2807 health: state.health,
2808 systemImage: portfolioHistoryIcon(for: latestEntry)
2809 )
2810 )
2811 }
2812 }
2813
2814 for log in monitoringLogs.prefix(25) {
2815 for result in log.checkedDomains where result.didChange || result.errorMessage != nil || result.certificateWarningLevel != .none {
2816 guard let trackedDomain = trackedDomain(for: result.domain) else { continue }
2817 items.append(
2818 PortfolioActivityItem(
2819 id: "monitoring|\(log.id.uuidString)|\(result.id.uuidString)",
2820 trackedDomainID: trackedDomain.id,
2821 domain: trackedDomain.domain,
2822 message: portfolioMonitoringMessage(for: result),
2823 timestamp: result.checkedAt,
2824 health: healthByDomainID[trackedDomain.id] ?? (result.alertSeverity == .critical ? .critical : .warning),
2825 systemImage: portfolioMonitoringIcon(for: result)
2826 )
2827 )
2828 }
2829 }
2830
2831 var seen = Set<String>()
2832 return items
2833 .sorted { $0.timestamp > $1.timestamp }
2834 .filter { item in
2835 let key = "\(item.domain.lowercased())|\(item.message.lowercased())"
2836 return seen.insert(key).inserted
2837 }
2838 }
2839
2840 private func buildAttentionQueue(from domainStates: [PortfolioDomainStatus]) -> [PortfolioAttentionItem] {
2841 domainStates.compactMap { state in
2842 guard state.health != .healthy else { return nil }
2843 let reason: String
2844 let timestamp: Date
2845
2846 if state.isUnreachable {
2847 reason = "Endpoint is unreachable"
2848 timestamp = state.lastMonitoringFailure ?? state.trackedDomain.updatedAt
2849 } else if state.certificateExpiryState == .critical {
2850 reason = "Certificate is expiring in under 14 days"
2851 timestamp = state.lastChangeDate ?? state.trackedDomain.updatedAt
2852 } else if state.certificateExpiryState == .warning {
2853 reason = "Certificate expires within 30 days"
2854 timestamp = state.lastChangeDate ?? state.trackedDomain.updatedAt
2855 } else if state.recentFailureCount >= 2 || state.instabilityScore >= 70 {
2856 reason = "Repeated instability detected"
2857 timestamp = state.lastMonitoringFailure ?? state.trackedDomain.updatedAt
2858 } else if state.recentDNSChange {
2859 reason = "DNS changed recently"
2860 timestamp = state.lastChangeDate ?? state.trackedDomain.updatedAt
2861 } else if state.recentCriticalChange {
2862 reason = "Recent critical change detected"
2863 timestamp = state.lastChangeDate ?? state.trackedDomain.updatedAt
2864 } else {
2865 reason = "Needs attention"
2866 timestamp = state.trackedDomain.updatedAt
2867 }
2868
2869 return PortfolioAttentionItem(
2870 id: "\(state.trackedDomain.id.uuidString)|\(reason)",
2871 trackedDomainID: state.trackedDomain.id,
2872 domain: state.trackedDomain.domain,
2873 reason: reason,
2874 timestamp: timestamp,
2875 health: state.health
2876 )
2877 }
2878 .sorted { lhs, rhs in
2879 if lhs.health != rhs.health {
2880 return healthRank(lhs.health) > healthRank(rhs.health)
2881 }
2882 return lhs.timestamp > rhs.timestamp
2883 }
2884 }
2885
2886 private func buildPortfolioGroups(from domainStates: [PortfolioDomainStatus]) -> [PortfolioGroup] {
2887 Dictionary(grouping: domainStates, by: \.apexDomain)
2888 .map { apexDomain, domains in
2889 PortfolioGroup(
2890 apexDomain: apexDomain,
2891 domains: domains.sorted { lhs, rhs in
2892 if lhs.health != rhs.health {
2893 return healthRank(lhs.health) > healthRank(rhs.health)
2894 }
2895 return lhs.trackedDomain.domain.localizedCaseInsensitiveCompare(rhs.trackedDomain.domain) == .orderedAscending
2896 }
2897 )
2898 }
2899 .sorted { lhs, rhs in
2900 if let lhsMostSevere = lhs.domains.map(\.health).map(healthRank).max(),
2901 let rhsMostSevere = rhs.domains.map(\.health).map(healthRank).max(),
2902 lhsMostSevere != rhsMostSevere {
2903 return lhsMostSevere > rhsMostSevere
2904 }
2905 return lhs.apexDomain.localizedCaseInsensitiveCompare(rhs.apexDomain) == .orderedAscending
2906 }
2907 }
2908
2909 private func matchesPortfolioFilter(_ state: PortfolioDomainStatus) -> Bool {
2910 switch dashboardFilter {
2911 case .all:
2912 return true
2913 case .healthy:
2914 return state.health == .healthy
2915 case .warning:
2916 return state.health == .warning
2917 case .critical:
2918 return state.health == .critical
2919 case .changed:
2920 guard let lastChangeDate = state.lastChangeDate else { return false }
2921 return Date().timeIntervalSince(lastChangeDate) <= 24 * 60 * 60
2922 case .expiring:
2923 return state.certificateExpiryState != .none
2924 case .unreachable:
2925 return state.isUnreachable
2926 }
2927 }
2928
2929 private func matchesDashboardSearch(_ state: PortfolioDomainStatus, query: String) -> Bool {
2930 guard !query.isEmpty else { return true }
2931 let normalizedQuery = query.lowercased()
2932 return state.trackedDomain.domain.lowercased().contains(normalizedQuery)
2933 || state.apexDomain.lowercased().contains(normalizedQuery)
2934 }
2935
2936 private func portfolioHistoryMessage(for entry: HistoryEntry) -> String {
2937 guard let summary = entry.changeSummary else {
2938 return "Configuration changed for \(entry.domain)"
2939 }
2940 if summary.changedSections.contains(where: { $0.localizedCaseInsensitiveContains("dns") }) {
2941 return "DNS changed for \(entry.domain)"
2942 }
2943 if summary.changedSections.contains(where: {
2944 $0.localizedCaseInsensitiveContains("certificate")
2945 || $0.localizedCaseInsensitiveContains("tls")
2946 }) {
2947 return "Certificate updated for \(entry.domain)"
2948 }
2949 if summary.changedSections.contains(where: { $0.localizedCaseInsensitiveContains("redirect") }) {
2950 return "Redirect chain changed for \(entry.domain)"
2951 }
2952 return summary.message
2953 }
2954
2955 private func portfolioHistoryIcon(for entry: HistoryEntry) -> String {
2956 guard let summary = entry.changeSummary else { return "clock.arrow.trianglehead.counterclockwise.rotate.90" }
2957 if summary.changedSections.contains(where: { $0.localizedCaseInsensitiveContains("dns") }) {
2958 return "point.3.connected.trianglepath.dotted"
2959 }
2960 if summary.changedSections.contains(where: {
2961 $0.localizedCaseInsensitiveContains("certificate")
2962 || $0.localizedCaseInsensitiveContains("tls")
2963 }) {
2964 return "lock.rotation"
2965 }
2966 if summary.changedSections.contains(where: { $0.localizedCaseInsensitiveContains("redirect") }) {
2967 return "arrow.triangle.branch"
2968 }
2969 return "clock.arrow.trianglehead.counterclockwise.rotate.90"
2970 }
2971
2972 private func portfolioMonitoringMessage(for result: MonitoringDomainResult) -> String {
2973 if result.errorMessage != nil {
2974 return "Monitoring failed for \(result.domain)"
2975 }
2976 if result.certificateWarningLevel != .none {
2977 return "Certificate needs attention for \(result.domain)"
2978 }
2979 if result.didChange {
2980 return result.summaryMessage.isEmpty ? "Monitoring detected a change for \(result.domain)" : result.summaryMessage
2981 }
2982 return "Monitoring updated \(result.domain)"
2983 }
2984
2985 private func portfolioMonitoringIcon(for result: MonitoringDomainResult) -> String {
2986 if result.errorMessage != nil {
2987 return "xmark.octagon.fill"
2988 }
2989 if result.certificateWarningLevel != .none {
2990 return "exclamationmark.triangle.fill"
2991 }
2992 return "waveform.path.ecg"
2993 }
2994
2995 private func apexDomain(for domain: String) -> String {
2996 let parts = domain
2997 .lowercased()
2998 .split(separator: ".")
2999 .map(String.init)
3000 guard parts.count > 2 else { return domain.lowercased() }
3001 return parts.suffix(2).joined(separator: ".")
3002 }
3003
3004 private func healthRank(_ health: DomainHealth) -> Int {
3005 switch health {
3006 case .healthy:
3007 return 0
3008 case .warning:
3009 return 1
3010 case .critical:
3011 return 2
3012 }
3013 }
3014
3015 private func exportSnapshots(for domains: [TrackedDomain]) -> [LookupSnapshot] {
3016 let latestEntries = latestSnapshots(for: domains)
3017
3018 return domains.map { trackedDomain in
3019 if let entry = latestEntries.first(where: { $0.trackedDomainID == trackedDomain.id || $0.domain.caseInsensitiveCompare(trackedDomain.domain) == .orderedSame }) {
3020 return entry.snapshot
3021 }
3022 return placeholderSnapshot(for: trackedDomain)
3023 }
3024 }
3025
3026 func currentBatchReports() -> [DomainReport] {
3027 currentBatchResultEntries.map { entry in
3028 report(for: entry, workflowContext: activeWorkflowContext)
3029 }
3030 }
3031
3032 func workflowReports(from summary: WorkflowRunSummary, changedOnly: Bool) -> [DomainReport] {
3033 let filteredResults = changedOnly ? summary.results.filter(\.hasMeaningfulChange) : summary.results
3034 return filteredResults.compactMap { result in
3035 guard let entry = historyEntry(for: result) else { return nil }
3036 return report(
3037 for: entry,
3038 workflowContext: DomainWorkflowContext(
3039 workflowID: summary.workflowID,
3040 workflowName: summary.workflowName,
3041 source: "workflow"
3042 )
3043 )
3044 }
3045 }
3046
3047 func reports(for domains: [TrackedDomain]) -> [DomainReport] {
3048 let latestEntries = latestSnapshots(for: domains)
3049
3050 return domains.map { trackedDomain in
3051 if let entry = latestEntries.first(where: {
3052 $0.trackedDomainID == trackedDomain.id ||
3053 $0.domain.caseInsensitiveCompare(trackedDomain.domain) == .orderedSame
3054 }) {
3055 return report(for: entry)
3056 }
3057
3058 return reportBuilder.build(from: placeholderSnapshot(for: trackedDomain))
3059 }
3060 }
3061
3062 func timelineReports(for domain: String) -> [DomainReport] {
3063 historyEntries(for: domain).map { report(for: $0) }
3064 }
3065
3066 private func report(for entry: HistoryEntry, workflowContext: DomainWorkflowContext? = nil) -> DomainReport {
3067 reportBuilder.build(
3068 from: entry,
3069 previousSnapshot: comparisonSnapshot(for: entry),
3070 workflowContext: workflowContext,
3071 historyEntries: historyEntries(for: entry.domain)
3072 )
3073 }
3074
3075 private var activeWorkflowContext: DomainWorkflowContext? {
3076 guard batchLookupSource == .workflow, let activeWorkflowRunID, let activeWorkflowRunName else {
3077 return nil
3078 }
3079 return DomainWorkflowContext(
3080 workflowID: activeWorkflowRunID,
3081 workflowName: activeWorkflowRunName,
3082 source: "workflow"
3083 )
3084 }
3085
3086 private static var defaultAuditReviewer: String {
3087 let reviewer = NSFullUserName().trimmingCharacters(in: .whitespacesAndNewlines)
3088 return reviewer.isEmpty ? "Local Reviewer" : reviewer
3089 }
3090
3091 private func snapshotEvidenceAssets(from report: DomainReport) -> [AuditEvidenceAsset] {
3092 var assets: [AuditEvidenceAsset] = []
3093 if let finalURL = report.web.finalURL {
3094 assets.append(AuditEvidenceAsset(title: "Final URL", kind: .document, reference: finalURL))
3095 }
3096 if let registrar = report.ownership?.registrar {
3097 assets.append(AuditEvidenceAsset(title: "Registrar", kind: .document, reference: registrar))
3098 }
3099 if let primaryIP = report.dns.primaryIP {
3100 assets.append(AuditEvidenceAsset(title: "Primary IP", kind: .document, reference: primaryIP))
3101 }
3102 if !report.web.redirectChain.isEmpty {
3103 assets.append(
3104 AuditEvidenceAsset(
3105 title: "Redirect Chain",
3106 kind: .document,
3107 reference: report.web.redirectChain.map { "\($0.statusCode) \($0.url)" }.joined(separator: " | ")
3108 )
3109 )
3110 }
3111 if !report.web.headers.isEmpty {
3112 assets.append(
3113 AuditEvidenceAsset(
3114 title: "Observed Headers",
3115 kind: .document,
3116 reference: report.web.headers.prefix(6).map { "\($0.name): \($0.value)" }.joined(separator: " | ")
3117 )
3118 )
3119 }
3120 return assets
3121 }
3122
3123 private func placeholderSnapshot(for trackedDomain: TrackedDomain) -> LookupSnapshot {
3124 LookupSnapshot(
3125 historyEntryID: trackedDomain.lastSnapshotID,
3126 domain: trackedDomain.domain,
3127 timestamp: trackedDomain.updatedAt,
3128 trackedDomainID: trackedDomain.id,
3129 note: trackedDomain.note,
3130 appVersion: AppVersion.current,
3131 resolverDisplayName: resolverDisplayName,
3132 resolverURLString: resolverURLString,
3133 dataSources: [],
3134 provenanceBySection: [:],
3135 availabilityConfidence: nil,
3136 ownershipConfidence: nil,
3137 subdomainConfidence: nil,
3138 emailSecurityConfidence: nil,
3139 geolocationConfidence: nil,
3140 errorDetails: [:],
3141 isPartialSnapshot: true,
3142 validationIssues: ["No stored snapshot data available"],
3143 totalLookupDurationMs: nil,
3144 snapshotIndex: nil,
3145 previousSnapshotID: nil,
3146 changeCount: 0,
3147 severitySummary: trackedDomain.lastChangeSeverity,
3148 dnsSections: [],
3149 dnsError: nil,
3150 availabilityResult: DomainAvailabilityResult(domain: trackedDomain.domain, status: trackedDomain.lastKnownAvailability ?? .unknown),
3151 suggestions: [],
3152 sslInfo: nil,
3153 sslError: nil,
3154 hstsPreloaded: nil,
3155 httpHeaders: [],
3156 httpSecurityGrade: nil,
3157 httpStatusCode: nil,
3158 httpResponseTimeMs: nil,
3159 httpProtocol: nil,
3160 http3Advertised: false,
3161 httpHeadersError: nil,
3162 reachabilityResults: [],
3163 reachabilityError: nil,
3164 ipGeolocation: nil,
3165 ipGeolocationError: nil,
3166 emailSecurity: nil,
3167 emailSecurityError: nil,
3168 ownership: nil,
3169 ownershipError: nil,
3170 ownershipHistory: [],
3171 ownershipHistoryError: nil,
3172 inferredProvider: nil,
3173 priorProviders: [],
3174 domainClassification: nil,
3175 ownershipTransitions: [],
3176 hostingTransitions: [],
3177 subdomainHistory: [],
3178 riskSignals: [],
3179 intelligenceTimeline: [],
3180 ptrRecord: nil,
3181 ptrError: nil,
3182 redirectChain: [],
3183 redirectChainError: nil,
3184 subdomains: [],
3185 subdomainsError: nil,
3186 extendedSubdomains: [],
3187 extendedSubdomainsError: nil,
3188 dnsHistory: [],
3189 dnsHistoryError: nil,
3190 domainPricing: nil,
3191 domainPricingError: nil,
3192 reputation: nil,
3193 reputationError: nil,
3194 portScanResults: [],
3195 portScanError: nil,
3196 changeSummary: trackedDomain.lastChangeSummary,
3197 resultSource: .snapshot,
3198 cachedSections: [],
3199 statusMessage: nil
3200 )
3201 }
3202
3203 private static func loadHistoryEntries() -> [HistoryEntry] {
3204 DataMigrationService.migrateIfNeeded()
3205 return DomainDataPortabilityService.loadHistoryEntries()
3206 }
3207
3208 private static func loadHistoryAutoPruneOption() -> HistoryAutoPruneOption {
3209 guard let rawValue = UserDefaults.standard.string(forKey: historyAutoPruneKey),
3210 let option = HistoryAutoPruneOption(rawValue: rawValue) else {
3211 return .unlimited
3212 }
3213 return option
3214 }
3215
3216 private static func loadTrackedDomains() -> [TrackedDomain] {
3217 DataMigrationService.migrateIfNeeded()
3218 return DomainDataPortabilityService.loadTrackedDomains()
3219 }
3220
3221 func persistWorkflows() {
3222 DomainDataPortabilityService.saveWorkflows(workflows)
3223 CloudSyncService.shared.scheduleSyncIfNeeded()
3224 refreshDataLifecycleSummary()
3225 }
3226
3227 static func loadWorkflows() -> [DomainWorkflow] {
3228 DataMigrationService.migrateIfNeeded()
3229 return DomainDataPortabilityService.loadWorkflows()
3230 }
3231
3232 private static func deduplicatedTrackedDomains(_ domains: [TrackedDomain]) -> [TrackedDomain] {
3233 var seen = Set<String>()
3234 return domains.filter { domain in
3235 let key = domain.domain.lowercased()
3236 return seen.insert(key).inserted
3237 }
3238 }
3239
3240 func normalizedDomains(_ domains: [String]) -> [String] {
3241 var seen = Set<String>()
3242 return domains
3243 .map(normalizedDomain)
3244 .filter { !$0.isEmpty }
3245 .filter { seen.insert($0).inserted }
3246 }
3247
3248 private func historySortPredicate(lhs: HistoryEntry, rhs: HistoryEntry) -> Bool {
3249 switch historySortOption {
3250 case .newest:
3251 return lhs.timestamp > rhs.timestamp
3252 case .oldest:
3253 return lhs.timestamp < rhs.timestamp
3254 case .domain:
3255 let domainOrder = lhs.domain.localizedCaseInsensitiveCompare(rhs.domain)
3256 if domainOrder != .orderedSame {
3257 return domainOrder == .orderedAscending
3258 }
3259 return lhs.timestamp > rhs.timestamp
3260 }
3261 }
3262
3263 private func sortedTrackedDomains(from domains: [TrackedDomain], using sortOption: WatchlistSortOption) -> [TrackedDomain] {
3264 domains.sorted { lhs, rhs in
3265 switch sortOption {
3266 case .pinned:
3267 if lhs.isPinned != rhs.isPinned {
3268 return lhs.isPinned && !rhs.isPinned
3269 }
3270 if lhs.updatedAt != rhs.updatedAt {
3271 return lhs.updatedAt > rhs.updatedAt
3272 }
3273 return lhs.domain.localizedCaseInsensitiveCompare(rhs.domain) == .orderedAscending
3274 case .recentlyUpdated:
3275 if lhs.updatedAt != rhs.updatedAt {
3276 return lhs.updatedAt > rhs.updatedAt
3277 }
3278 return lhs.domain.localizedCaseInsensitiveCompare(rhs.domain) == .orderedAscending
3279 case .alphabetical:
3280 let domainOrder = lhs.domain.localizedCaseInsensitiveCompare(rhs.domain)
3281 if domainOrder != .orderedSame {
3282 return domainOrder == .orderedAscending
3283 }
3284 return lhs.updatedAt > rhs.updatedAt
3285 }
3286 }
3287 }
3288
3289 static func summaryFields(from snapshot: LookupSnapshot) -> [SummaryFieldViewData] {
3290 [
3291 SummaryFieldViewData(label: "Domain", value: snapshot.domain.nilIfEmpty ?? "Unavailable", tone: .primary),
3292 SummaryFieldViewData(label: "Observed IP", value: primaryIPAddress(from: snapshot) ?? "Unavailable", tone: .primary),
3293 SummaryFieldViewData(label: "Observed Redirect", value: finalRedirectTarget(from: snapshot) ?? "Unavailable", tone: .secondary),
3294 SummaryFieldViewData(label: "Inference", value: availabilityInference(from: snapshot), tone: availabilityTone(snapshot.availabilityResult?.status)),
3295 SummaryFieldViewData(label: "Observed TLS", value: httpsSummary(from: snapshot), tone: httpsSummaryTone(from: snapshot)),
3296 SummaryFieldViewData(label: "Certificate", value: certificateStatusLabel(from: snapshot), tone: certificateStatusTone(from: snapshot)),
3297 SummaryFieldViewData(label: "Source", value: snapshot.statusMessage ?? snapshot.resultSource.label, tone: sourceTone(for: snapshot))
3298 ]
3299 }
3300
3301 static func domainRows(from snapshot: LookupSnapshot) -> [InfoRowViewData] {
3302 var rows = [
3303 InfoRowViewData(label: "Domain", value: snapshot.domain, tone: .primary),
3304 InfoRowViewData(label: "Resolver", value: snapshot.resolverDisplayName, tone: .secondary),
3305 InfoRowViewData(label: "Collected", value: snapshot.timestamp.formatted(date: .abbreviated, time: .shortened), tone: .secondary),
3306 InfoRowViewData(label: snapshot.statusMessage == nil ? "Result" : "Snapshot", value: snapshot.statusMessage ?? snapshot.resultSource.label, tone: sourceTone(for: snapshot)),
3307 InfoRowViewData(label: "Lookup Duration", value: durationLabel(snapshot.totalLookupDurationMs), tone: .secondary)
3308 ]
3309 rows.insert(
3310 InfoRowViewData(
3311 label: "Observed Availability",
3312 value: snapshot.availabilityResult?.status == .unknown ? "No direct registration proof" : "Status collected",
3313 tone: .secondary
3314 ),
3315 at: 1
3316 )
3317 rows.insert(
3318 InfoRowViewData(
3319 label: "Inference",
3320 value: availabilityInference(from: snapshot),
3321 tone: availabilityTone(snapshot.availabilityResult?.status)
3322 ),
3323 at: 2
3324 )
3325 if let confidence = snapshot.availabilityConfidence {
3326 rows.insert(
3327 InfoRowViewData(label: "Confidence", value: confidence.title, tone: .secondary),
3328 at: 3
3329 )
3330 }
3331 if let pricing = snapshot.domainPricing {
3332 rows.append(
3333 InfoRowViewData(
3334 label: "External Price",
3335 value: pricing.estimatedPrice ?? "Unavailable",
3336 tone: .secondary
3337 )
3338 )
3339 if let premiumIndicator = pricing.premiumIndicator {
3340 rows.append(
3341 InfoRowViewData(
3342 label: "Premium",
3343 value: premiumIndicator ? "Yes" : "No",
3344 tone: premiumIndicator ? .warning : .secondary
3345 )
3346 )
3347 }
3348 if let resaleSignal = pricing.resaleSignal {
3349 rows.append(InfoRowViewData(label: "Resale", value: resaleSignal, tone: .secondary))
3350 }
3351 if let auctionSignal = pricing.auctionSignal {
3352 rows.append(InfoRowViewData(label: "Auction", value: auctionSignal, tone: .secondary))
3353 }
3354 }
3355 if let reputation = snapshot.reputation {
3356 let tone: ResultTone
3357 switch reputation.status {
3358 case .clean: tone = .success
3359 case .listed: tone = .failure
3360 case .unknown: tone = .secondary
3361 }
3362 let value = reputation.status == .listed && !reputation.listedSources.isEmpty
3363 ? "\(reputation.status.title) (\(reputation.listedSources.joined(separator: ", ")))"
3364 : reputation.status.title
3365 rows.append(InfoRowViewData(label: "Reputation", value: value, tone: tone))
3366 }
3367 if let certificateStatus = certificateBadgeLabel(from: snapshot) {
3368 rows.insert(
3369 InfoRowViewData(
3370 label: "Certificate",
3371 value: certificateStatus,
3372 tone: certificateStatusTone(from: snapshot)
3373 ),
3374 at: 2
3375 )
3376 }
3377 return rows
3378 }
3379
3380 static func suggestionRows(from snapshot: LookupSnapshot) -> [DomainSuggestionViewData] {
3381 snapshot.suggestions.map {
3382 DomainSuggestionViewData(
3383 id: $0.id,
3384 domain: $0.domain,
3385 availabilityStatus: $0.status,
3386 status: availabilityLabel($0.status),
3387 tone: availabilityTone($0.status)
3388 )
3389 }
3390 }
3391
3392 static func subdomainRows(from subdomains: [DiscoveredSubdomain]) -> [SubdomainRowViewData] {
3393 subdomains.map { subdomain in
3394 SubdomainRowViewData(
3395 hostname: subdomain.hostname,
3396 isInteresting: subdomain.isExtended || isInterestingSubdomain(subdomain.hostname)
3397 )
3398 }
3399 }
3400
3401 static func dnsRows(from snapshot: LookupSnapshot) -> [DNSRecordSectionViewData] {
3402 snapshot.dnsSections.map { section in
3403 DNSRecordSectionViewData(
3404 title: section.recordType.rawValue,
3405 rows: section.records.map { InfoRowViewData(label: "TTL \($0.ttl)", value: $0.value, tone: .primary, speechStyle: .technical) },
3406 wildcardRows: section.wildcardRecords.map { InfoRowViewData(label: "TTL \($0.ttl)", value: $0.value, tone: .primary, speechStyle: .technical) },
3407 wildcardTitle: section.wildcardRecords.isEmpty ? nil : "*.\(snapshot.domain)",
3408 message: section.error.map { SectionMessageViewData(text: $0, isError: true) } ??
3409 ((section.records.isEmpty && section.wildcardRecords.isEmpty) ? SectionMessageViewData(text: "No records found", isError: false) : nil)
3410 )
3411 }
3412 }
3413
3414 static func dnssecLabel(from snapshot: LookupSnapshot) -> String? {
3415 guard let signed = snapshot.dnsSections.compactMap(\.dnssecSigned).first else { return nil }
3416 return "Resolver-reported DNSSEC (not full validation): \(signed ? "Yes" : "No")"
3417 }
3418
3419 static func ptrMessage(from snapshot: LookupSnapshot) -> SectionMessageViewData? {
3420 if let ptrRecord = snapshot.ptrRecord {
3421 return SectionMessageViewData(text: ptrRecord, isError: false)
3422 }
3423 if let ptrError = snapshot.ptrError {
3424 return SectionMessageViewData(text: ptrError, isError: ptrError != "No A record available" && ptrError != "No PTR record found")
3425 }
3426 return nil
3427 }
3428
3429 static func webCertificateRows(from snapshot: LookupSnapshot) -> [InfoRowViewData] {
3430 guard let sslInfo = snapshot.sslInfo else { return [] }
3431 var rows = [
3432 InfoRowViewData(label: "Common Name", value: sslInfo.commonName, tone: .primary),
3433 InfoRowViewData(label: "Issuer", value: sslInfo.issuer, tone: .primary),
3434 InfoRowViewData(label: "Valid From", value: certificateDateFormatter.string(from: sslInfo.validFrom), tone: .secondary),
3435 InfoRowViewData(label: "Valid Until", value: certificateDateFormatter.string(from: sslInfo.validUntil), tone: .secondary),
3436 InfoRowViewData(label: "Days Until Expiry", value: "\(sslInfo.daysUntilExpiry)", tone: certificateTone(daysRemaining: sslInfo.daysUntilExpiry)),
3437 InfoRowViewData(label: "Chain Depth", value: "\(sslInfo.chainDepth)", tone: .secondary)
3438 ]
3439 if let tlsVersion = sslInfo.tlsVersion {
3440 rows.append(InfoRowViewData(label: "TLS Version", value: tlsVersion, tone: .secondary))
3441 }
3442 if let cipherSuite = sslInfo.cipherSuite {
3443 rows.append(InfoRowViewData(label: "Cipher Suite", value: cipherSuite, tone: .secondary, speechStyle: .technical))
3444 }
3445 if let hstsPreloaded = snapshot.hstsPreloaded {
3446 rows.append(InfoRowViewData(label: "HSTS Preload", value: hstsPreloaded ? "Preloaded" : "Not preloaded", tone: hstsPreloaded ? .success : .secondary))
3447 }
3448 return rows
3449 }
3450
3451 static func webResponseRows(from snapshot: LookupSnapshot) -> [InfoRowViewData] {
3452 var rows: [InfoRowViewData] = []
3453 if let httpStatusCode = snapshot.httpStatusCode {
3454 rows.append(InfoRowViewData(label: "Status", value: "\(httpStatusCode)", tone: .primary))
3455 }
3456 if let httpResponseTimeMs = snapshot.httpResponseTimeMs {
3457 rows.append(InfoRowViewData(label: "Response Time", value: "\(httpResponseTimeMs) ms", tone: .secondary))
3458 }
3459 if let httpProtocol = snapshot.httpProtocol {
3460 rows.append(InfoRowViewData(label: "Protocol", value: httpProtocol, tone: .secondary))
3461 }
3462 if let httpSecurityGrade = snapshot.httpSecurityGrade {
3463 rows.append(InfoRowViewData(label: "Security Grade", value: httpSecurityGrade, tone: securityGradeTone(httpSecurityGrade)))
3464 }
3465 if snapshot.http3Advertised {
3466 rows.append(InfoRowViewData(label: "HTTP/3", value: "Advertised", tone: .secondary))
3467 }
3468 return rows
3469 }
3470
3471 static func redirectRows(from snapshot: LookupSnapshot) -> [RedirectHopViewData] {
3472 snapshot.redirectChain.map {
3473 RedirectHopViewData(
3474 stepLabel: "\($0.stepNumber)",
3475 statusCode: "\($0.statusCode)",
3476 url: $0.url,
3477 isFinal: $0.isFinal
3478 )
3479 }
3480 }
3481
3482 static func emailRows(from snapshot: LookupSnapshot) -> [EmailRowViewData] {
3483 guard let emailSecurity = snapshot.emailSecurity else { return [] }
3484 return [
3485 EmailRowViewData(label: "SPF", status: emailSecurity.spf.found ? "Present" : "Missing", statusTone: emailSecurity.spf.found ? .success : .warning, detail: emailSecurity.spf.value ?? "No record found", auxiliaryDetail: nil),
3486 EmailRowViewData(label: "DMARC", status: emailSecurity.dmarc.found ? "Present" : "Missing", statusTone: emailSecurity.dmarc.found ? .success : .warning, detail: emailSecurity.dmarc.value ?? "No record found", auxiliaryDetail: nil),
3487 EmailRowViewData(label: "DKIM", status: emailSecurity.dkim.found ? "Present" : "Missing", statusTone: emailSecurity.dkim.found ? .success : .warning, detail: emailSecurity.dkim.value ?? "No record found", auxiliaryDetail: emailSecurity.dkim.matchedSelector.map { "Selector: \($0)" }),
3488 EmailRowViewData(label: "MTA-STS", status: emailSecurity.mtaSts?.txtFound == true ? "Present" : "Missing", statusTone: emailSecurity.mtaSts?.txtFound == true ? .success : .warning, detail: emailSecurity.mtaSts?.policyMode ?? (emailSecurity.mtaSts?.txtFound == true ? "Policy unavailable" : "No record found"), auxiliaryDetail: nil),
3489 EmailRowViewData(label: "BIMI", status: emailSecurity.bimi.found ? "Present" : "Missing", statusTone: emailSecurity.bimi.found ? .success : .warning, detail: emailSecurity.bimi.value ?? "No record found", auxiliaryDetail: nil)
3490 ]
3491 }
3492
3493 static func ownershipRows(from snapshot: LookupSnapshot) -> [InfoRowViewData] {
3494 let ownership = snapshot.ownership
3495
3496 return [
3497 InfoRowViewData(label: "Registrar", value: ownership?.registrar ?? "Unavailable", tone: ownership?.registrar == nil ? .secondary : .primary),
3498 InfoRowViewData(label: "Registered", value: ownership?.createdDate.map(ownershipDateFormatter.string(from:)) ?? "Unavailable", tone: ownership?.createdDate == nil ? .secondary : .primary),
3499 InfoRowViewData(label: "Expires", value: ownership?.expirationDate.map(ownershipDateFormatter.string(from:)) ?? "Unavailable", tone: ownership?.expirationDate == nil ? .secondary : .primary),
3500 InfoRowViewData(label: "Status", value: ownership?.status.nilIfEmpty?.joined(separator: ", ") ?? "Unavailable", tone: ownership?.status.isEmpty == false ? .primary : .secondary),
3501 InfoRowViewData(label: "Nameservers", value: ownership?.nameservers.nilIfEmpty?.joined(separator: ", ") ?? "Unavailable", tone: ownership?.nameservers.isEmpty == false ? .primary : .secondary),
3502 InfoRowViewData(label: "Abuse Contact", value: ownership?.abuseEmail ?? "Unavailable", tone: ownership?.abuseEmail == nil ? .secondary : .primary)
3503 ]
3504 }
3505
3506 static func subdomainRows(from snapshot: LookupSnapshot) -> [SubdomainRowViewData] {
3507 snapshot.subdomains.map { subdomain in
3508 SubdomainRowViewData(
3509 hostname: subdomain.hostname,
3510 isInteresting: isInterestingSubdomain(subdomain.hostname)
3511 )
3512 }
3513 }
3514
3515 static func reachabilityRows(from snapshot: LookupSnapshot) -> [ReachabilityRowViewData] {
3516 snapshot.reachabilityResults.map {
3517 ReachabilityRowViewData(
3518 portLabel: "Port \($0.port)",
3519 latencyLabel: $0.latencyMs.map { "\($0) ms" } ?? "—",
3520 statusLabel: $0.reachable ? "Reachable" : "Unreachable",
3521 statusTone: $0.reachable ? .success : .failure
3522 )
3523 }
3524 }
3525
3526 static func locationRows(from snapshot: LookupSnapshot) -> [InfoRowViewData] {
3527 guard let ipGeolocation = snapshot.ipGeolocation else { return [] }
3528 var rows = [InfoRowViewData(label: "IP", value: ipGeolocation.ip, tone: .primary)]
3529 if let org = ipGeolocation.org {
3530 rows.append(InfoRowViewData(label: "Org / ISP", value: org, tone: .secondary))
3531 }
3532 let location = [ipGeolocation.city, ipGeolocation.region, ipGeolocation.countryName].compactMap { $0 }.joined(separator: ", ")
3533 if !location.isEmpty {
3534 rows.append(InfoRowViewData(label: "Location", value: location, tone: .secondary))
3535 }
3536 if let latitude = ipGeolocation.latitude, let longitude = ipGeolocation.longitude {
3537 rows.append(InfoRowViewData(label: "Coordinates", value: "\(latitude), \(longitude)", tone: .secondary))
3538 }
3539 return rows
3540 }
3541
3542 static func portRows(from snapshot: LookupSnapshot, kind: PortScanKind) -> [PortScanRowViewData] {
3543 snapshot.portScanResults
3544 .filter { $0.kind == kind }
3545 .map {
3546 PortScanRowViewData(
3547 portLabel: "\($0.port)",
3548 service: $0.service,
3549 statusLabel: $0.open ? "Open" : "Closed",
3550 statusTone: $0.open ? .success : .secondary,
3551 banner: $0.banner,
3552 durationLabel: $0.durationMs.map { "\($0) ms" }
3553 )
3554 }
3555 }
3556
3557 static func formatBatchExportText(
3558 title: String,
3559 entries: [(snapshot: LookupSnapshot, trackedDomain: TrackedDomain?, changeSummary: DomainChangeSummary?, diffSections: [DomainDiffSection])]
3560 ) -> String {
3561 guard !entries.isEmpty else {
3562 return "\(title)\nNo results available."
3563 }
3564
3565 var lines = [title, String(repeating: "=", count: title.count), ""]
3566 for (index, entry) in entries.enumerated() {
3567 if index > 0 {
3568 lines.append("")
3569 lines.append(String(repeating: "=", count: 48))
3570 lines.append("")
3571 }
3572
3573 lines.append(
3574 formatExportText(
3575 from: entry.snapshot,
3576 trackedDomain: entry.trackedDomain,
3577 changeSummary: entry.changeSummary,
3578 diffSections: entry.diffSections
3579 )
3580 )
3581 }
3582 return lines.joined(separator: "\n")
3583 }
3584
3585 static func formatCSV(from snapshots: [LookupSnapshot]) -> String {
3586 let headers = [
3587 "domain",
3588 "availability",
3589 "primary_ip",
3590 "redirect_target",
3591 "tls_status",
3592 "http_status_grade",
3593 "email_security_summary",
3594 "registrar",
3595 "ownership_expires",
3596 "ownership_status",
3597 "ownership_nameservers",
3598 "subdomain_count",
3599 "subdomains",
3600 "last_updated"
3601 ]
3602
3603 let rows = snapshots.map { snapshot in
3604 [
3605 snapshot.domain,
3606 availabilityLabel(snapshot.availabilityResult?.status),
3607 primaryIPAddress(from: snapshot) ?? "",
3608 finalRedirectTarget(from: snapshot) ?? "",
3609 httpsSummary(from: snapshot),
3610 httpStatusGradeSummary(from: snapshot),
3611 emailSummary(from: snapshot),
3612 snapshot.ownership?.registrar ?? "",
3613 snapshot.ownership?.expirationDate.map(csvDateFormatter.string(from:)) ?? "",
3614 snapshot.ownership?.status.joined(separator: " | ") ?? "",
3615 snapshot.ownership?.nameservers.joined(separator: " | ") ?? "",
3616 "\(snapshot.subdomains.count)",
3617 snapshot.subdomains.map(\.hostname).joined(separator: " | "),
3618 csvDateFormatter.string(from: snapshot.timestamp)
3619 ]
3620 }
3621
3622 return ([headers] + rows)
3623 .map { row in row.map(csvEscaped).joined(separator: ",") }
3624 .joined(separator: "\n")
3625 }
3626
3627 static func formatExportText(
3628 from snapshot: LookupSnapshot,
3629 trackedDomain: TrackedDomain?,
3630 changeSummary: DomainChangeSummary?,
3631 diffSections: [DomainDiffSection]
3632 ) -> String {
3633 let exportDateFormatter = DateFormatter()
3634 exportDateFormatter.dateFormat = "yyyy-MM-dd HH:mm"
3635
3636 var lines: [String] = [
3637 "DomainDig Export",
3638 "Domain: \(snapshot.domain)",
3639 "Date: \(exportDateFormatter.string(from: snapshot.timestamp))",
3640 "Mode: \(snapshot.statusMessage ?? snapshot.resultSource.label)",
3641 "Resolver: \(snapshot.resolverDisplayName)",
3642 "Lookup Duration: \(durationLabel(snapshot.totalLookupDurationMs))",
3643 "Tracked: \(trackedDomain == nil ? "No" : "Yes")"
3644 ]
3645
3646 if let note = trackedDomain?.note?.nilIfEmpty {
3647 lines.append("Tracking Note: \(note)")
3648 }
3649
3650 func appendSection(_ title: String, body: () -> Void) {
3651 lines.append("")
3652 lines.append(title)
3653 lines.append(String(repeating: "-", count: title.count))
3654 body()
3655 }
3656
3657 appendSection("Summary") {
3658 for item in summaryFields(from: snapshot) {
3659 lines.append(" \(item.label): \(item.value)")
3660 }
3661 if let changeSummary {
3662 lines.append(" Change Summary: \(changeSummary.message)")
3663 lines.append(" Severity: \(changeSummary.severity.title)")
3664 lines.append(" Changed Sections: \(changeSummary.changedSections.isEmpty ? "None" : changeSummary.changedSections.joined(separator: ", "))")
3665 lines.append(" Compared At: \(exportDateFormatter.string(from: changeSummary.generatedAt))")
3666 }
3667 }
3668
3669 appendSection("Tracking") {
3670 if let trackedDomain {
3671 lines.append(" Pinned: \(trackedDomain.isPinned ? "Yes" : "No")")
3672 lines.append(" Last Refresh: \(exportDateFormatter.string(from: trackedDomain.updatedAt))")
3673 lines.append(" Last Known Availability: \(availabilityLabel(trackedDomain.lastKnownAvailability))")
3674 if let note = trackedDomain.note?.nilIfEmpty {
3675 lines.append(" Note: \(note)")
3676 }
3677 } else {
3678 lines.append(" This domain is not currently tracked.")
3679 }
3680 }
3681
3682 appendSection("Diff Summary") {
3683 if diffSections.isEmpty {
3684 lines.append(" No comparison available")
3685 } else {
3686 for section in diffSections where section.items.contains(where: { $0.changeType != .unchanged }) {
3687 lines.append(" \(section.title)")
3688 for item in section.items where item.changeType != .unchanged {
3689 lines.append(" [\(item.changeType.rawValue.capitalized)] \(item.label): \(item.oldValue ?? "None") -> \(item.newValue ?? "None")")
3690 }
3691 }
3692 }
3693 }
3694
3695 appendSection("Domain") {
3696 for row in domainRows(from: snapshot) {
3697 lines.append(" \(row.label): \(row.value)")
3698 }
3699 if snapshot.suggestions.isEmpty {
3700 lines.append(" Suggestions: None")
3701 } else {
3702 lines.append(" Suggestions:")
3703 for suggestion in snapshot.suggestions {
3704 lines.append(" \(suggestion.domain): \(availabilityLabel(suggestion.status))")
3705 }
3706 }
3707 }
3708
3709 appendSection("Ownership") {
3710 for row in ownershipRows(from: snapshot) {
3711 lines.append(" \(row.label): \(row.value)")
3712 }
3713 if let ownershipError = snapshot.ownershipError, snapshot.ownership == nil {
3714 lines.append(" Source: \(ownershipError)")
3715 }
3716 if !DataAccessService.hasAccess(to: .ownershipHistory) {
3717 lines.append(" Ownership history (coming soon)")
3718 }
3719 }
3720
3721 appendSection("Subdomains") {
3722 lines.append(" Count: \(snapshot.subdomains.count)")
3723 if snapshot.subdomains.isEmpty {
3724 lines.append(" \(snapshot.subdomainsError ?? "No passive subdomains found")")
3725 } else {
3726 for subdomain in subdomainRows(from: snapshot) {
3727 let marker = subdomain.isInteresting ? " [interesting]" : ""
3728 lines.append(" \(subdomain.hostname)\(marker)")
3729 }
3730 }
3731 if !DataAccessService.hasAccess(to: .extendedSubdomains) {
3732 lines.append(" Extended subdomain discovery (Pro+)")
3733 }
3734 }
3735
3736 appendSection("DNS") {
3737 if let dnsError = snapshot.dnsError {
3738 lines.append(" Error: \(dnsError)")
3739 }
3740 if let dnssecLabel = dnssecLabel(from: snapshot) {
3741 lines.append(" \(dnssecLabel)")
3742 }
3743 for section in dnsRows(from: snapshot) {
3744 lines.append(" \(section.title)")
3745 if let message = section.message {
3746 lines.append(" \(message.isError ? "Error" : "Info"): \(message.text)")
3747 }
3748 for row in section.rows {
3749 lines.append(" \(row.value) (\(row.label))")
3750 }
3751 if let wildcardTitle = section.wildcardTitle {
3752 lines.append(" \(wildcardTitle)")
3753 for row in section.wildcardRows {
3754 lines.append(" \(row.value) (\(row.label))")
3755 }
3756 }
3757 }
3758 if let ptrRecord = snapshot.ptrRecord {
3759 lines.append(" PTR: \(ptrRecord)")
3760 } else if let ptrError = snapshot.ptrError {
3761 lines.append(" PTR Error: \(ptrError)")
3762 }
3763 }
3764
3765 appendSection("Web") {
3766 if let sslError = snapshot.sslError {
3767 lines.append(" TLS Error: \(sslError)")
3768 } else {
3769 for row in webCertificateRows(from: snapshot) {
3770 lines.append(" \(row.label): \(row.value)")
3771 }
3772 }
3773
3774 if let httpHeadersError = snapshot.httpHeadersError {
3775 lines.append(" Headers Error: \(httpHeadersError)")
3776 } else {
3777 for row in webResponseRows(from: snapshot) {
3778 lines.append(" \(row.label): \(row.value)")
3779 }
3780 if snapshot.httpHeaders.isEmpty {
3781 lines.append(" Headers: No headers returned")
3782 } else {
3783 lines.append(" Headers:")
3784 for header in snapshot.httpHeaders {
3785 lines.append(" \(header.name): \(header.value)")
3786 }
3787 }
3788 }
3789
3790 if let redirectChainError = snapshot.redirectChainError {
3791 lines.append(" Redirect Error: \(redirectChainError)")
3792 } else if snapshot.redirectChain.isEmpty {
3793 lines.append(" Redirects: No redirect data available")
3794 } else {
3795 lines.append(" Redirects:")
3796 for hop in redirectRows(from: snapshot) {
3797 lines.append(" \(hop.stepLabel). \(hop.statusCode) \(hop.url)\(hop.isFinal ? " (final)" : "")")
3798 }
3799 }
3800 }
3801
3802 appendSection("Email") {
3803 if let emailSecurityError = snapshot.emailSecurityError {
3804 lines.append(" Error: \(emailSecurityError)")
3805 } else if emailRows(from: snapshot).isEmpty {
3806 lines.append(" No email security records found")
3807 } else {
3808 for row in emailRows(from: snapshot) {
3809 lines.append(" \(row.label): \(row.status)")
3810 lines.append(" \(row.detail)")
3811 if let auxiliaryDetail = row.auxiliaryDetail {
3812 lines.append(" \(auxiliaryDetail)")
3813 }
3814 }
3815 }
3816 }
3817
3818 appendSection("Network") {
3819 if let reachabilityError = snapshot.reachabilityError {
3820 lines.append(" Reachability Error: \(reachabilityError)")
3821 } else if reachabilityRows(from: snapshot).isEmpty {
3822 lines.append(" Reachability: No results")
3823 } else {
3824 lines.append(" Reachability:")
3825 for row in reachabilityRows(from: snapshot) {
3826 lines.append(" \(row.portLabel): \(row.statusLabel) \(row.latencyLabel)")
3827 }
3828 }
3829
3830 if let ipGeolocationError = snapshot.ipGeolocationError, snapshot.ipGeolocation == nil {
3831 lines.append(" Location Error: \(ipGeolocationError)")
3832 } else if locationRows(from: snapshot).isEmpty {
3833 lines.append(" Location: No data")
3834 } else {
3835 lines.append(" Location:")
3836 for row in locationRows(from: snapshot) {
3837 lines.append(" \(row.label): \(row.value)")
3838 }
3839 }
3840
3841 if let portScanError = snapshot.portScanError, snapshot.portScanResults.isEmpty {
3842 lines.append(" Port Scan Error: \(portScanError)")
3843 }
3844
3845 lines.append(" Standard Ports:")
3846 let standardRows = portRows(from: snapshot, kind: .standard)
3847 if standardRows.isEmpty {
3848 lines.append(" No results")
3849 } else {
3850 for row in standardRows {
3851 lines.append(" \(row.portLabel) \(row.service): \(row.statusLabel)\(row.durationLabel.map { " \($0)" } ?? "")")
3852 if let banner = row.banner {
3853 lines.append(" Banner: \(banner)")
3854 }
3855 }
3856 }
3857
3858 lines.append(" Custom Ports:")
3859 let customRows = portRows(from: snapshot, kind: .custom)
3860 if customRows.isEmpty {
3861 lines.append(" No results")
3862 } else {
3863 for row in customRows {
3864 lines.append(" \(row.portLabel) \(row.service): \(row.statusLabel)\(row.durationLabel.map { " \($0)" } ?? "")")
3865 if let banner = row.banner {
3866 lines.append(" Banner: \(banner)")
3867 }
3868 }
3869 }
3870 }
3871
3872 return lines.joined(separator: "\n")
3873 }
3874
3875 private static func primaryIPAddress(from snapshot: LookupSnapshot) -> String? {
3876 snapshot.dnsSections.first(where: { $0.recordType == .A })?.records.first?.value
3877 }
3878
3879 private static func finalRedirectTarget(from snapshot: LookupSnapshot) -> String? {
3880 snapshot.redirectChain.last?.url
3881 }
3882
3883 private static func httpStatusGradeSummary(from snapshot: LookupSnapshot) -> String {
3884 let parts = [snapshot.httpStatusCode.map(String.init), snapshot.httpSecurityGrade].compactMap { $0 }
3885 if !parts.isEmpty {
3886 return parts.joined(separator: " / ")
3887 }
3888 return snapshot.httpHeadersError ?? "Unavailable"
3889 }
3890
3891 private static func httpsSummary(from snapshot: LookupSnapshot) -> String {
3892 if snapshot.sslInfo != nil {
3893 return "Valid"
3894 }
3895 if let sslError = snapshot.sslError {
3896 return sslError.localizedCaseInsensitiveContains("certificate") ? "Invalid" : "Failed"
3897 }
3898 return "Unavailable"
3899 }
3900
3901 private static func httpsSummaryTone(from snapshot: LookupSnapshot) -> ResultTone {
3902 if snapshot.sslInfo != nil {
3903 return .success
3904 }
3905 return snapshot.sslError == nil ? .secondary : .failure
3906 }
3907
3908 private static func emailSummary(from snapshot: LookupSnapshot) -> String {
3909 guard let emailSecurity = snapshot.emailSecurity else {
3910 return snapshot.emailSecurityError ?? "Unavailable"
3911 }
3912 return "SPF \(emailSecurity.spf.found ? "Yes" : "No") / DMARC \(emailSecurity.dmarc.found ? "Yes" : "No")"
3913 }
3914
3915 private static func certificateStatusLabel(from snapshot: LookupSnapshot) -> String {
3916 guard let sslInfo = snapshot.sslInfo else {
3917 return snapshot.sslError ?? "Unavailable"
3918 }
3919
3920 switch DomainDiffService.certificateWarningLevel(for: snapshot) {
3921 case .critical:
3922 return "Critical (\(sslInfo.daysUntilExpiry)d)"
3923 case .warning:
3924 return "Warning (\(sslInfo.daysUntilExpiry)d)"
3925 case .none:
3926 return "Healthy (\(sslInfo.daysUntilExpiry)d)"
3927 }
3928 }
3929
3930 private static func certificateBadgeLabel(from snapshot: LookupSnapshot) -> String? {
3931 guard snapshot.sslInfo != nil else { return nil }
3932 return certificateStatusLabel(from: snapshot)
3933 }
3934
3935 private static func certificateStatusTone(from snapshot: LookupSnapshot) -> ResultTone {
3936 guard let daysRemaining = snapshot.sslInfo?.daysUntilExpiry else {
3937 return snapshot.sslError == nil ? .secondary : .failure
3938 }
3939 return certificateTone(daysRemaining: daysRemaining)
3940 }
3941
3942 private static func certificateTone(daysRemaining: Int) -> ResultTone {
3943 if daysRemaining < 14 {
3944 return .failure
3945 }
3946 if daysRemaining < 30 {
3947 return .warning
3948 }
3949 return .success
3950 }
3951
3952 private static func availabilityLabel(_ status: DomainAvailabilityStatus?) -> String {
3953 switch status {
3954 case .available:
3955 return "Available"
3956 case .registered:
3957 return "Registered"
3958 case .unknown, .none:
3959 return "Unknown"
3960 }
3961 }
3962
3963 private static func availabilityInference(from snapshot: LookupSnapshot) -> String {
3964 switch snapshot.availabilityResult?.status {
3965 case .registered:
3966 return "Likely registered"
3967 case .available:
3968 return "Possibly available"
3969 case .unknown, .none:
3970 return "Unclear"
3971 }
3972 }
3973
3974 private static func availabilityTone(_ status: DomainAvailabilityStatus?) -> ResultTone {
3975 switch status {
3976 case .available:
3977 return .success
3978 case .registered:
3979 return .warning
3980 case .unknown, .none:
3981 return .secondary
3982 }
3983 }
3984
3985 private static func sourceTone(for snapshot: LookupSnapshot) -> ResultTone {
3986 if snapshot.statusMessage != nil {
3987 return .warning
3988 }
3989
3990 switch snapshot.resultSource {
3991 case .live:
3992 return .success
3993 case .cached:
3994 return .secondary
3995 case .mixed:
3996 return .warning
3997 case .snapshot:
3998 return .warning
3999 }
4000 }
4001
4002 private static func securityGradeTone(_ grade: String) -> ResultTone {
4003 switch grade {
4004 case "A", "B":
4005 return .success
4006 case "C":
4007 return .warning
4008 case "D", "F":
4009 return .failure
4010 default:
4011 return .secondary
4012 }
4013 }
4014
4015 private static func durationLabel(_ durationMs: Int?) -> String {
4016 durationMs.map { "\($0) ms" } ?? "Unavailable"
4017 }
4018
4019 private static let certificateDateFormatter: DateFormatter = {
4020 let formatter = DateFormatter()
4021 formatter.dateStyle = .medium
4022 formatter.timeStyle = .short
4023 return formatter
4024 }()
4025
4026 private static let ownershipDateFormatter: DateFormatter = {
4027 let formatter = DateFormatter()
4028 formatter.dateStyle = .medium
4029 formatter.timeStyle = .none
4030 return formatter
4031 }()
4032
4033 private static let csvDateFormatter: ISO8601DateFormatter = {
4034 let formatter = ISO8601DateFormatter()
4035 formatter.formatOptions = [.withInternetDateTime]
4036 return formatter
4037 }()
4038
4039 private func refreshDomainPricing(for domain: String, persistAfterFetch: Bool) async {
4040 domainPricingLoading = true
4041 let outcome = await ExternalDataService.shared.pricing(domain: domain)
4042
4043 switch outcome.value {
4044 case let .success(pricing):
4045 domainPricing = pricing
4046 domainPricingError = nil
4047 case let .empty(message), let .error(message):
4048 domainPricing = nil
4049 domainPricingError = conciseExternalMessage(message, fallback: "External pricing unavailable")
4050 }
4051
4052 domainPricingLoading = false
4053
4054 if persistAfterFetch {
4055 _ = saveHistoryEntry(replaceLatest: true)
4056 }
4057 }
4058
4059 private func refreshReputation(for domain: String, persistAfterFetch: Bool) async {
4060 reputationLoading = true
4061 let outcome = await ExternalDataService.shared.reputation(domain: domain)
4062
4063 switch outcome.value {
4064 case let .success(result):
4065 reputation = result
4066 reputationError = nil
4067 case let .empty(message), let .error(message):
4068 reputation = nil
4069 reputationError = conciseExternalMessage(message, fallback: "Reputation check unavailable")
4070 }
4071
4072 reputationLoading = false
4073
4074 if persistAfterFetch {
4075 _ = saveHistoryEntry(replaceLatest: true)
4076 }
4077 }
4078
4079 private func conciseExternalMessage(_ message: String, fallback: String) -> String {
4080 let trimmed = message.trimmingCharacters(in: .whitespacesAndNewlines)
4081 if trimmed.isEmpty {
4082 return fallback
4083 }
4084 if trimmed.localizedCaseInsensitiveContains("rate") {
4085 return "Rate limited. Try again later."
4086 }
4087 if trimmed.localizedCaseInsensitiveContains("invalid") {
4088 return "External data was invalid."
4089 }
4090 if trimmed.localizedCaseInsensitiveContains("network") {
4091 return "External data is offline."
4092 }
4093 return trimmed
4094 }
4095
4096 private static func defaultUsageCredits() -> [UsageCreditFeature: UsageCreditStatus] {
4097 Dictionary(uniqueKeysWithValues: UsageCreditFeature.allCases.map { feature in
4098 (feature, fallbackCreditStatus(for: feature))
4099 })
4100 }
4101
4102 private static func fallbackCreditStatus(for feature: UsageCreditFeature) -> UsageCreditStatus {
4103 UsageCreditStatus(
4104 feature: feature,
4105 remaining: feature.defaultAllowance,
4106 total: feature.defaultAllowance,
4107 resetContext: "Resets with app version \(AppVersion.current)"
4108 )
4109 }
4110
4111 private static func csvEscaped(_ value: String) -> String {
4112 let escaped = value.replacingOccurrences(of: "\"", with: "\"\"")
4113 return "\"\(escaped)\""
4114 }
4115
4116 private static func isInterestingSubdomain(_ hostname: String) -> Bool {
4117 let keywords = ["admin", "api", "dev", "staging", "test", "internal"]
4118 let labels = hostname.lowercased().split(separator: ".").map(String.init)
4119 return labels.contains { label in
4120 keywords.contains(where: { label.contains($0) })
4121 }
4122 }
4123}
4124
4125private extension String {
4126 var nilIfEmpty: String? {
4127 isEmpty ? nil : self
4128 }
4129}
4130
4131private extension Array where Element == String {
4132 var nilIfEmpty: [String]? {
4133 isEmpty ? nil : self
4134 }
4135}