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