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