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