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