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