diff options
| author | Christian Cleberg <[email protected]> | 2026-04-21 22:54:40 -0500 |
|---|---|---|
| committer | Christian Cleberg <[email protected]> | 2026-04-21 22:54:40 -0500 |
| commit | e73d58ee5dd43eaeaaa717f7781cc22256df30f0 (patch) | |
| tree | 2eb84058b37f4edfb6cf6ad7205c08a2af374938 /DomainDig | |
| parent | 22ecca12b4fe0e0850401754c674632f322d919d (diff) | |
| download | domain-dig-e73d58ee5dd43eaeaaa717f7781cc22256df30f0.tar.gz domain-dig-e73d58ee5dd43eaeaaa717f7781cc22256df30f0.tar.bz2 domain-dig-e73d58ee5dd43eaeaaa717f7781cc22256df30f0.zip | |
feat(v2.4.0): add JSON output, CLI foundation, and shared report layer
* introduce DomainReport as canonical output model
* add JSON export for single and batch results
* create DomainReportBuilder for reusable report construction
* add CLI target using shared inspection pipeline
* refactor services for UI-independent usage
* ensure consistency across TXT, CSV, and JSON outputs
Diffstat (limited to 'DomainDig')
| -rw-r--r-- | DomainDig/ContentView.swift | 56 | ||||
| -rw-r--r-- | DomainDig/DomainViewModel.swift | 430 | ||||
| -rw-r--r-- | DomainDig/ExportPresenter.swift | 6 | ||||
| -rw-r--r-- | DomainDig/WatchlistView.swift | 29 |
4 files changed, 134 insertions, 387 deletions
diff --git a/DomainDig/ContentView.swift b/DomainDig/ContentView.swift index 261b7ca..c5fc1fa 100644 --- a/DomainDig/ContentView.swift +++ b/DomainDig/ContentView.swift @@ -304,10 +304,13 @@ struct ContentView: View { } Menu { Button("Export TXT") { - shareSingleResults(asCSV: false) + shareSingleResults(format: .text) } Button("Export CSV") { - shareSingleResults(asCSV: true) + shareSingleResults(format: .csv) + } + Button("Export JSON") { + shareSingleResults(format: .json) } } label: { Image(systemName: "square.and.arrow.up") @@ -332,10 +335,13 @@ struct ContentView: View { if !viewModel.currentBatchResultEntries.isEmpty { Menu { Button("Export Batch TXT") { - shareBatchResults(asCSV: false) + shareBatchResults(format: .text) } Button("Export Batch CSV") { - shareBatchResults(asCSV: true) + shareBatchResults(format: .csv) + } + Button("Export Batch JSON") { + shareBatchResults(format: .json) } } label: { Label("Export", systemImage: "square.and.arrow.up") @@ -412,33 +418,51 @@ struct ContentView: View { return ports } - private func shareSingleResults(asCSV: Bool) { - let (filename, contents) = exportPayload( + private func shareSingleResults(format: DomainExportFormat) { + let (filename, data) = exportPayload( prefix: "domaindig_single", + format: format, text: viewModel.exportText(), csv: viewModel.exportCSV(), - asCSV: asCSV + json: viewModel.exportJSONData() ) - ExportPresenter.share(filename: filename, contents: contents) + ExportPresenter.share(filename: filename, data: data) } - private func shareBatchResults(asCSV: Bool) { - let (filename, contents) = exportPayload( + private func shareBatchResults(format: DomainExportFormat) { + let (filename, data) = exportPayload( prefix: "domaindig_batch", + format: format, text: viewModel.exportBatchText(), csv: viewModel.exportBatchCSV(), - asCSV: asCSV + json: viewModel.exportBatchJSONData() ) - ExportPresenter.share(filename: filename, contents: contents) + ExportPresenter.share(filename: filename, data: data) } - private func exportPayload(prefix: String, text: String, csv: String, asCSV: Bool) -> (String, String) { + private func exportPayload( + prefix: String, + format: DomainExportFormat, + text: String, + csv: String, + json: Data? + ) -> (String, Data) { let formatter = DateFormatter() formatter.dateFormat = "yyyyMMdd_HHmmss" let timestamp = formatter.string(from: Date()) - let fileExtension = asCSV ? "csv" : "txt" - let filename = "\(timestamp)_\(prefix).\(fileExtension)" - return (filename, asCSV ? csv : text) + let filename = "\(timestamp)_\(prefix).\(format.fileExtension)" + let data: Data + + switch format { + case .text: + data = Data(text.utf8) + case .csv: + data = Data(csv.utf8) + case .json: + data = json ?? Data("[]".utf8) + } + + return (filename, data) } } diff --git a/DomainDig/DomainViewModel.swift b/DomainDig/DomainViewModel.swift index e895ce6..71209dc 100644 --- a/DomainDig/DomainViewModel.swift +++ b/DomainDig/DomainViewModel.swift @@ -91,94 +91,6 @@ struct DomainSuggestionViewData: Identifiable { let tone: ResultTone } -struct LookupSnapshot { - let historyEntryID: UUID? - let domain: String - let timestamp: Date - let trackedDomainID: UUID? - let resolverDisplayName: String - let resolverURLString: String - let totalLookupDurationMs: Int? - let dnsSections: [DNSSection] - let dnsError: String? - let availabilityResult: DomainAvailabilityResult? - let suggestions: [DomainSuggestionResult] - let sslInfo: SSLCertificateInfo? - let sslError: String? - let hstsPreloaded: Bool? - let httpHeaders: [HTTPHeader] - let httpSecurityGrade: String? - let httpStatusCode: Int? - let httpResponseTimeMs: Int? - let httpProtocol: String? - let http3Advertised: Bool - let httpHeadersError: String? - let reachabilityResults: [PortReachability] - let reachabilityError: String? - let ipGeolocation: IPGeolocation? - let ipGeolocationError: String? - let emailSecurity: EmailSecurityResult? - let emailSecurityError: String? - let ownership: DomainOwnership? - let ownershipError: String? - let ptrRecord: String? - let ptrError: String? - let redirectChain: [RedirectHop] - let redirectChainError: String? - let subdomains: [DiscoveredSubdomain] - let subdomainsError: String? - let portScanResults: [PortScanResult] - let portScanError: String? - let changeSummary: DomainChangeSummary? - let isLive: Bool -} - -extension HistoryEntry { - var snapshot: LookupSnapshot { - LookupSnapshot( - historyEntryID: id, - domain: domain, - timestamp: timestamp, - trackedDomainID: trackedDomainID, - resolverDisplayName: resolverDisplayName, - resolverURLString: resolverURLString, - totalLookupDurationMs: totalLookupDurationMs, - dnsSections: dnsSections, - dnsError: nil, - availabilityResult: availabilityResult, - suggestions: suggestions, - sslInfo: sslInfo, - sslError: sslError, - hstsPreloaded: hstsPreloaded, - httpHeaders: httpHeaders, - httpSecurityGrade: HTTPSecurityGrade.grade(for: httpHeaders).rawValue, - httpStatusCode: nil, - httpResponseTimeMs: nil, - httpProtocol: nil, - http3Advertised: false, - httpHeadersError: httpHeadersError, - reachabilityResults: reachabilityResults, - reachabilityError: reachabilityError, - ipGeolocation: ipGeolocation, - ipGeolocationError: ipGeolocationError, - emailSecurity: emailSecurity, - emailSecurityError: emailSecurityError, - ownership: ownership, - ownershipError: ownershipError, - ptrRecord: ptrRecord, - ptrError: ptrError, - redirectChain: redirectChain, - redirectChainError: redirectChainError, - subdomains: subdomains, - subdomainsError: subdomainsError, - portScanResults: portScanResults, - portScanError: portScanError, - changeSummary: changeSummary, - isLive: false - ) - } -} - private struct BatchLookupPayload { let snapshot: LookupSnapshot } @@ -271,6 +183,7 @@ final class DomainViewModel { private var lookupStartedAt: Date? private var activeBatchDomains: [String] = [] private var lastBatchStartedAt: Date? + private let reportBuilder = DomainReportBuilder() private static let recentSearchesKey = "recentSearches" private static let maxRecent = 20 @@ -484,6 +397,18 @@ final class DomainViewModel { ) } + var currentReport: DomainReport? { + guard !searchedDomain.isEmpty else { return nil } + return reportBuilder.build( + from: currentSnapshot, + previousSnapshot: previousSnapshot( + for: searchedDomain, + trackedDomainID: currentTrackedDomain?.id, + replacingLatest: false + ) + ) + } + var summaryFields: [SummaryFieldViewData] { Self.summaryFields(from: currentSnapshot) } @@ -768,64 +693,55 @@ final class DomainViewModel { } func exportText() -> String { - Self.formatExportText( - from: currentSnapshot, - trackedDomain: currentTrackedDomain, - changeSummary: currentChangeSummary, - diffSections: currentDiffSections - ) + guard let currentReport else { return "No results available." } + return DomainReportExporter.text(for: currentReport) } func exportCSV() -> String { - Self.formatCSV(from: [currentSnapshot]) + guard let currentReport else { return DomainReportExporter.csv(for: []) } + return DomainReportExporter.csv(for: [currentReport]) + } + + func exportJSONData() -> Data? { + guard let currentReport else { return nil } + return try? DomainReportExporter.data(for: currentReport, format: .json) } func exportBatchText() -> String { - Self.formatBatchExportText( - title: batchLookupSource == .watchlistRefresh ? "Tracked Domains Export" : "Batch Results Export", - entries: currentBatchResultEntries.map { entry in - ( - snapshot: entry.snapshot, - trackedDomain: trackedDomains.first(where: { tracked in - tracked.id == entry.trackedDomainID || - tracked.domain.caseInsensitiveCompare(entry.domain) == .orderedSame - }), - changeSummary: entry.changeSummary, - diffSections: comparisonSnapshot(for: entry).map { DomainDiffService.diff(from: $0, to: entry.snapshot) } ?? [] - ) - } + DomainReportExporter.batchText( + for: currentBatchReports(), + title: batchLookupSource == .watchlistRefresh ? "Tracked Domains Export" : "Batch Results Export" ) } func exportBatchCSV() -> String { - Self.formatCSV(from: currentBatchResultEntries.map(\.snapshot)) + DomainReportExporter.csv(for: currentBatchReports()) + } + + func exportBatchJSONData() -> Data? { + try? DomainReportExporter.data( + for: currentBatchReports(), + format: .json, + title: batchLookupSource == .watchlistRefresh ? "Tracked Domains Export" : "Batch Results Export" + ) } func exportTrackedDomainsCSV(domains: [TrackedDomain]) -> String { - Self.formatCSV(from: exportSnapshots(for: domains)) + DomainReportExporter.csv(for: reports(for: domains)) } func exportTrackedDomainsText(domains: [TrackedDomain]) -> String { - let latestEntries = latestSnapshots(for: domains) - return Self.formatBatchExportText( - title: "Tracked Domains Export", - entries: domains.map { trackedDomain in - if let entry = latestEntries.first(where: { $0.trackedDomainID == trackedDomain.id || $0.domain.caseInsensitiveCompare(trackedDomain.domain) == .orderedSame }) { - return ( - snapshot: entry.snapshot, - trackedDomain: trackedDomain, - changeSummary: entry.changeSummary, - diffSections: comparisonSnapshot(for: entry).map { DomainDiffService.diff(from: $0, to: entry.snapshot) } ?? [] - ) - } + DomainReportExporter.batchText( + for: reports(for: domains), + title: "Tracked Domains Export" + ) + } - return ( - snapshot: placeholderSnapshot(for: trackedDomain), - trackedDomain: trackedDomain, - changeSummary: trackedDomain.lastChangeSummary, - diffSections: [] - ) - } + func exportTrackedDomainsJSONData(domains: [TrackedDomain]) -> Data? { + try? DomainReportExporter.data( + for: reports(for: domains), + format: .json, + title: "Tracked Domains Export" ) } @@ -1130,239 +1046,8 @@ final class DomainViewModel { private static func performBatchLookup(domain: String) async -> BatchLookupPayload? { guard !Task.isCancelled else { return nil } - - let startedAt = Date() - let resolverDisplayName = DNSLookupService.currentResolverDisplayName() - let resolverURLString = DNSLookupService.currentResolverURLString() - - async let dnsResult = DNSLookupService.lookupAll(domain: domain) - async let availabilityResult = DomainAvailabilityService.check(domain: domain) - async let sslResult = SSLCheckService.check(domain: domain) - async let hstsResult = SSLCheckService.checkHSTSPreload(domain: domain) - async let httpResult = HTTPHeadersService.fetch(domain: domain) - async let reachabilityResult = ReachabilityService.checkAll(domain: domain) - async let ownershipResult = DomainOwnershipService.lookup(domain: domain) - async let redirectResult = RedirectChainService.trace(domain: domain) - async let subdomainResult = SubdomainDiscoveryService.discover(for: domain) - async let portScanResult = PortScanService.scanAll(domain: domain) - - let resolvedDNS = await dnsResult - let availability = await availabilityResult - let resolvedSSL = await sslResult - let hsts = await hstsResult - let http = await httpResult - let reachability = await reachabilityResult - let resolvedOwnership = await ownershipResult - let redirects = await redirectResult - let resolvedSubdomains = await subdomainResult - let ports = await portScanResult - + let snapshot = await DomainInspectionService().inspectSnapshot(domain: domain) guard !Task.isCancelled else { return nil } - - let dnsSections: [DNSSection] - let dnsError: String? - switch resolvedDNS { - case let .success(sections): - dnsSections = sections - dnsError = nil - case let .empty(message), let .error(message): - dnsSections = [] - dnsError = message - } - - let sslInfo: SSLCertificateInfo? - let sslError: String? - switch resolvedSSL { - case let .success(info): - sslInfo = info - sslError = nil - case let .empty(message), let .error(message): - sslInfo = nil - sslError = message - } - - let httpHeaders: [HTTPHeader] - let httpSecurityGrade: String? - let httpStatusCode: Int? - let httpResponseTimeMs: Int? - let httpProtocol: String? - let http3Advertised: Bool - let httpHeadersError: String? - switch http { - case let .success(result): - httpHeaders = result.headers - httpSecurityGrade = HTTPSecurityGrade.grade(for: result.headers).rawValue - httpStatusCode = result.statusCode - httpResponseTimeMs = result.responseTimeMs - httpProtocol = result.httpProtocol - http3Advertised = result.http3Advertised - httpHeadersError = nil - case let .empty(message), let .error(message): - httpHeaders = [] - httpSecurityGrade = nil - httpStatusCode = nil - httpResponseTimeMs = nil - httpProtocol = nil - http3Advertised = false - httpHeadersError = message - } - - let reachabilityResults: [PortReachability] - let reachabilityError: String? - switch reachability { - case let .success(results): - reachabilityResults = results - reachabilityError = nil - case let .empty(message), let .error(message): - reachabilityResults = [] - reachabilityError = message - } - - let redirectChain: [RedirectHop] - let redirectChainError: String? - switch redirects { - case let .success(hops): - redirectChain = hops - redirectChainError = nil - case let .empty(message), let .error(message): - redirectChain = [] - redirectChainError = message - } - - let portScanResults: [PortScanResult] - let portScanError: String? - switch ports { - case let .success(results): - portScanResults = await enrichOpenPortBanners(results, domain: domain) - portScanError = nil - case let .empty(message), let .error(message): - portScanResults = [] - portScanError = message - } - - let txtRecords = dnsSections.first(where: { $0.recordType == .TXT })?.records ?? [] - let primaryIP = dnsSections.first(where: { $0.recordType == .A })?.records.first?.value - - async let emailResult = EmailSecurityService.analyze(domain: domain, txtRecords: txtRecords) - async let suggestions = availability.status == .registered ? DomainAvailabilityService.suggestions(for: domain) : [] - - let resolvedEmail = await emailResult - let resolvedSuggestions = await suggestions - let resolvedPTR: ServiceResult<String>? - let resolvedGeo: ServiceResult<IPGeolocation>? - if let primaryIP { - resolvedPTR = await ReverseDNSService.lookup(ip: primaryIP, resolverURLString: resolverURLString) - resolvedGeo = await IPGeolocationService.lookup(ip: primaryIP) - } else { - resolvedPTR = nil - resolvedGeo = nil - } - - guard !Task.isCancelled else { return nil } - - let emailSecurity: EmailSecurityResult? - let emailSecurityError: String? - switch resolvedEmail { - case let .success(result): - emailSecurity = result - emailSecurityError = nil - case let .empty(message), let .error(message): - emailSecurity = nil - emailSecurityError = message - } - - let ownership: DomainOwnership? - let ownershipError: String? - switch resolvedOwnership { - case let .success(result): - ownership = result - ownershipError = nil - case let .empty(message), let .error(message): - ownership = nil - ownershipError = message - } - - let ptrRecord: String? - let ptrError: String? - switch resolvedPTR { - case let .success(record): - ptrRecord = record - ptrError = nil - case let .empty(message), let .error(message): - ptrRecord = nil - ptrError = message - case .none: - ptrRecord = nil - ptrError = "No A record available" - } - - let ipGeolocation: IPGeolocation? - let ipGeolocationError: String? - switch resolvedGeo { - case let .success(result): - ipGeolocation = result - ipGeolocationError = nil - case let .empty(message), let .error(message): - ipGeolocation = nil - ipGeolocationError = message - case .none: - ipGeolocation = nil - ipGeolocationError = "No A record available" - } - - let subdomains: [DiscoveredSubdomain] - let subdomainsError: String? - switch resolvedSubdomains { - case let .success(result): - subdomains = result - subdomainsError = nil - case let .empty(message), let .error(message): - subdomains = [] - subdomainsError = message - } - - let snapshot = LookupSnapshot( - historyEntryID: nil, - domain: domain, - timestamp: Date(), - trackedDomainID: nil, - resolverDisplayName: resolverDisplayName, - resolverURLString: resolverURLString, - totalLookupDurationMs: Int(Date().timeIntervalSince(startedAt) * 1000), - dnsSections: dnsSections, - dnsError: dnsError, - availabilityResult: availability, - suggestions: resolvedSuggestions, - sslInfo: sslInfo, - sslError: sslError, - hstsPreloaded: hsts, - httpHeaders: httpHeaders, - httpSecurityGrade: httpSecurityGrade, - httpStatusCode: httpStatusCode, - httpResponseTimeMs: httpResponseTimeMs, - httpProtocol: httpProtocol, - http3Advertised: http3Advertised, - httpHeadersError: httpHeadersError, - reachabilityResults: reachabilityResults, - reachabilityError: reachabilityError, - ipGeolocation: ipGeolocation, - ipGeolocationError: ipGeolocationError, - emailSecurity: emailSecurity, - emailSecurityError: emailSecurityError, - ownership: ownership, - ownershipError: ownershipError, - ptrRecord: ptrRecord, - ptrError: ptrError, - redirectChain: redirectChain, - redirectChainError: redirectChainError, - subdomains: subdomains, - subdomainsError: subdomainsError, - portScanResults: portScanResults, - portScanError: portScanError, - changeSummary: nil, - isLive: false - ) - return BatchLookupPayload(snapshot: snapshot) } @@ -1967,6 +1652,29 @@ final class DomainViewModel { } } + private func currentBatchReports() -> [DomainReport] { + currentBatchResultEntries.map(report(for:)) + } + + private func reports(for domains: [TrackedDomain]) -> [DomainReport] { + let latestEntries = latestSnapshots(for: domains) + + return domains.map { trackedDomain in + if let entry = latestEntries.first(where: { + $0.trackedDomainID == trackedDomain.id || + $0.domain.caseInsensitiveCompare(trackedDomain.domain) == .orderedSame + }) { + return report(for: entry) + } + + return reportBuilder.build(from: placeholderSnapshot(for: trackedDomain)) + } + } + + private func report(for entry: HistoryEntry) -> DomainReport { + reportBuilder.build(from: entry, previousSnapshot: comparisonSnapshot(for: entry)) + } + private func placeholderSnapshot(for trackedDomain: TrackedDomain) -> LookupSnapshot { LookupSnapshot( historyEntryID: trackedDomain.lastSnapshotID, diff --git a/DomainDig/ExportPresenter.swift b/DomainDig/ExportPresenter.swift index 82ec7ae..2dbd1fc 100644 --- a/DomainDig/ExportPresenter.swift +++ b/DomainDig/ExportPresenter.swift @@ -3,10 +3,14 @@ import UIKit enum ExportPresenter { static func share(filename: String, contents: String) { + share(filename: filename, data: Data(contents.utf8)) + } + + static func share(filename: String, data: Data) { let url = FileManager.default.temporaryDirectory.appendingPathComponent(filename) do { - try contents.write(to: url, atomically: true, encoding: .utf8) + try data.write(to: url, options: .atomic) } catch { return } diff --git a/DomainDig/WatchlistView.swift b/DomainDig/WatchlistView.swift index 1f03a0d..7e42b53 100644 --- a/DomainDig/WatchlistView.swift +++ b/DomainDig/WatchlistView.swift @@ -143,11 +143,15 @@ struct WatchlistView: View { .disabled(viewModel.batchLookupRunning) Button("Export TXT") { - shareTrackedDomains(asCSV: false) + shareTrackedDomains(format: .text) } Button("Export CSV") { - shareTrackedDomains(asCSV: true) + shareTrackedDomains(format: .csv) + } + + Button("Export JSON") { + shareTrackedDomains(format: .json) } } label: { Image(systemName: "line.3.horizontal.decrease.circle") @@ -178,16 +182,23 @@ struct WatchlistView: View { domains.forEach(viewModel.deleteTrackedDomain) } - private func shareTrackedDomains(asCSV: Bool) { + private func shareTrackedDomains(format: DomainExportFormat) { let formatter = DateFormatter() formatter.dateFormat = "yyyyMMdd_HHmmss" let timestamp = formatter.string(from: Date()) - let fileExtension = asCSV ? "csv" : "txt" - let filename = "\(timestamp)_domaindig_watchlist.\(fileExtension)" - let contents = asCSV - ? viewModel.exportTrackedDomainsCSV(domains: viewModel.filteredTrackedDomains) - : viewModel.exportTrackedDomainsText(domains: viewModel.filteredTrackedDomains) - ExportPresenter.share(filename: filename, contents: contents) + let filename = "\(timestamp)_domaindig_watchlist.\(format.fileExtension)" + let data: Data + + switch format { + case .text: + data = Data(viewModel.exportTrackedDomainsText(domains: viewModel.filteredTrackedDomains).utf8) + case .csv: + data = Data(viewModel.exportTrackedDomainsCSV(domains: viewModel.filteredTrackedDomains).utf8) + case .json: + data = viewModel.exportTrackedDomainsJSONData(domains: viewModel.filteredTrackedDomains) ?? Data("[]".utf8) + } + + ExportPresenter.share(filename: filename, data: data) } } |
