diff options
Diffstat (limited to 'DomainDig')
| -rw-r--r-- | DomainDig/ContentView.swift | 287 | ||||
| -rw-r--r-- | DomainDig/DomainDiffService.swift | 197 | ||||
| -rw-r--r-- | DomainDig/DomainViewModel.swift | 381 | ||||
| -rw-r--r-- | DomainDig/HistoryView.swift | 55 | ||||
| -rw-r--r-- | DomainDig/Models.swift | 75 | ||||
| -rw-r--r-- | DomainDig/PremiumAccessService.swift | 27 | ||||
| -rw-r--r-- | DomainDig/WatchlistView.swift | 316 |
7 files changed, 1240 insertions, 98 deletions
diff --git a/DomainDig/ContentView.swift b/DomainDig/ContentView.swift index f70bc6a..69f3af9 100644 --- a/DomainDig/ContentView.swift +++ b/DomainDig/ContentView.swift @@ -3,12 +3,16 @@ import SwiftUI struct ContentView: View { @State private var viewModel = DomainViewModel() + @State private var navigationPath = NavigationPath() @FocusState private var domainFieldFocused: Bool @State private var customPortInput = "" @State private var customPortsExpanded = false + @State private var trackingNoteDraft = "" + @State private var editingTrackedDomain: TrackedDomain? + @State private var showTrackLimitAlert = false var body: some View { - NavigationStack { + NavigationStack(path: $navigationPath) { ScrollView(.vertical) { VStack(spacing: 0) { inputSection @@ -16,16 +20,42 @@ struct ContentView: View { actionButtons SummaryView(fields: viewModel.summaryFields) .padding(.top, 8) + if let changeSummary = viewModel.currentChangeSummary { + DomainChangeSummaryView(summary: changeSummary) + .padding(.top, 12) + } DomainSectionView( rows: viewModel.domainRows, suggestions: viewModel.suggestionRows, showSuggestions: viewModel.availabilityResult?.status == .registered || viewModel.suggestionsLoading, availabilityLoading: viewModel.availabilityLoading, suggestionsLoading: viewModel.suggestionsLoading, - isWatched: viewModel.isCurrentDomainWatched, - onToggleWatch: { viewModel.toggleWatchedDomain() } + trackedDomain: viewModel.currentTrackedDomain, + trackingLimitMessage: viewModel.trackingLimitMessage, + onTrack: { + if !viewModel.trackCurrentDomain() { + showTrackLimitAlert = true + } + }, + onTogglePinned: { + guard let trackedDomain = viewModel.currentTrackedDomain else { return } + viewModel.togglePinned(for: trackedDomain) + }, + onEditNote: { + guard let trackedDomain = viewModel.currentTrackedDomain else { return } + trackingNoteDraft = trackedDomain.note ?? "" + editingTrackedDomain = trackedDomain + } ) .padding(.top, 16) + if !viewModel.currentDiffSections.isEmpty { + DomainDiffView( + title: "Latest Changes", + sections: viewModel.currentDiffSections, + showsUnchanged: false + ) + .padding(.top, 16) + } DNSSectionView( dnssecLabel: viewModel.dnssecLabel, sections: viewModel.dnsRows, @@ -97,27 +127,31 @@ struct ContentView: View { } } NavigationLink { - SavedDomainsView(viewModel: viewModel) - } label: { - Image(systemName: "bookmark") - .foregroundStyle(.secondary) - } - NavigationLink { - HistoryView(viewModel: viewModel) - } label: { - Image(systemName: "clock.arrow.trianglehead.counterclockwise.rotate.90") - .foregroundStyle(.secondary) - } - NavigationLink { WatchlistView(viewModel: viewModel) } label: { Image(systemName: "eye") .foregroundStyle(.secondary) } - NavigationLink { - SettingsView() + Menu { + NavigationLink { + HistoryView(viewModel: viewModel) + } label: { + Label("History", systemImage: "clock.arrow.trianglehead.counterclockwise.rotate.90") + } + + NavigationLink { + SavedDomainsView(viewModel: viewModel) + } label: { + Label("Saved Domains", systemImage: "bookmark") + } + + NavigationLink { + SettingsView() + } label: { + Label("Settings", systemImage: "gearshape") + } } label: { - Image(systemName: "gearshape") + Image(systemName: "ellipsis.circle") .foregroundStyle(.secondary) } } @@ -126,6 +160,41 @@ struct ContentView: View { .onAppear { domainFieldFocused = true } + .onChange(of: viewModel.rerunNavigationToken) { _, _ in + navigationPath = NavigationPath() + domainFieldFocused = false + } + .alert("Tracking limit reached", isPresented: $showTrackLimitAlert) { + Button("OK", role: .cancel) {} + } message: { + Text("Free version supports up to 3 tracked domains. More tracked domains will be available in a future Pro upgrade.") + } + .sheet(item: $editingTrackedDomain) { trackedDomain in + NavigationStack { + Form { + Section("Tracking Note") { + TextField("Optional note", text: $trackingNoteDraft, axis: .vertical) + .lineLimit(3...6) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + } + } + .navigationTitle(trackedDomain.domain) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { + editingTrackedDomain = nil + } + } + ToolbarItem(placement: .confirmationAction) { + Button("Save") { + viewModel.updateNote(trackingNoteDraft, for: trackedDomain) + editingTrackedDomain = nil + } + } + } + } + } } private var inputSection: some View { @@ -292,30 +361,192 @@ struct SummaryView: View { } } +struct DomainChangeSummaryView: View { + let summary: DomainChangeSummary + + var body: some View { + CardView(allowsHorizontalScroll: false) { + HStack { + Label(summary.hasChanges ? "Changed" : "Unchanged", systemImage: summary.hasChanges ? "arrow.triangle.2.circlepath" : "checkmark.circle") + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(summary.hasChanges ? .yellow : .green) + Spacer() + Text(summary.generatedAt, style: .time) + .font(.system(.caption2, design: .monospaced)) + .foregroundStyle(.secondary) + } + + Text(summary.changedSections.isEmpty ? "No meaningful changes detected." : summary.changedSections.joined(separator: " • ")) + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(.primary) + } + } +} + +struct DomainDiffView: View { + let title: String + let sections: [DomainDiffSection] + let showsUnchanged: Bool + + private var filteredSections: [DomainDiffSection] { + guard !showsUnchanged else { return sections } + return sections + .map { section in + DomainDiffSection( + title: section.title, + items: section.items.filter { $0.changeType != .unchanged } + ) + } + .filter { !$0.items.isEmpty } + } + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + SectionTitleView(title: title) + if filteredSections.isEmpty { + MessageCardView(text: "No comparison data available", isError: false) + } else { + ForEach(filteredSections) { section in + CardView(allowsHorizontalScroll: false) { + Text(section.title) + .font(.system(.subheadline, design: .monospaced)) + .fontWeight(.semibold) + .foregroundStyle(.cyan) + + ForEach(section.items) { item in + VStack(alignment: .leading, spacing: 4) { + HStack { + Text(item.label) + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(.secondary) + Spacer() + Text(changeLabel(for: item.changeType)) + .font(.system(.caption2, design: .monospaced)) + .foregroundStyle(changeColor(for: item.changeType)) + } + if let oldValue = item.oldValue { + Text("Old: \(oldValue)") + .font(.system(.caption2, design: .monospaced)) + .foregroundStyle(.secondary) + .textSelection(.enabled) + } + if let newValue = item.newValue { + Text("New: \(newValue)") + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(.primary) + .textSelection(.enabled) + } + } + } + } + } + } + } + } + + private func changeLabel(for changeType: DiffChangeType) -> String { + switch changeType { + case .added: + return "Added" + case .removed: + return "Removed" + case .changed: + return "Changed" + case .unchanged: + return "Unchanged" + } + } + + private func changeColor(for changeType: DiffChangeType) -> Color { + switch changeType { + case .added: + return .green + case .removed: + return .red + case .changed: + return .yellow + case .unchanged: + return .secondary + } + } +} + +struct TrackedDomainDetailHeaderView: View { + let trackedDomain: TrackedDomain + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + if let note = trackedDomain.note?.nilIfEmpty { + LabeledValueRow(row: InfoRowViewData(label: "Tracking Note", value: note, tone: .secondary)) + } + HStack(spacing: 8) { + if trackedDomain.isPinned { + Label("Pinned", systemImage: "pin.fill") + } + Text("Last refresh \(trackedDomain.updatedAt.formatted(date: .abbreviated, time: .shortened))") + } + .font(.system(.caption2, design: .monospaced)) + .foregroundStyle(.secondary) + } + } +} + struct DomainSectionView: View { let rows: [InfoRowViewData] let suggestions: [DomainSuggestionViewData] let showSuggestions: Bool let availabilityLoading: Bool let suggestionsLoading: Bool - let isWatched: Bool - let onToggleWatch: () -> Void + let trackedDomain: TrackedDomain? + let trackingLimitMessage: String? + let onTrack: () -> Void + let onTogglePinned: () -> Void + let onEditNote: (() -> Void)? var body: some View { VStack(alignment: .leading, spacing: 12) { HStack { SectionTitleView(title: "Domain") Spacer() - Button(isWatched ? "Watching" : "Watch") { - onToggleWatch() + if let trackedDomain { + HStack(spacing: 8) { + Text("Tracked") + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(.green) + Button { + onTogglePinned() + } label: { + Image(systemName: trackedDomain.isPinned ? "pin.fill" : "pin") + } + .buttonStyle(.bordered) + .font(.system(.caption, design: .monospaced)) + if let onEditNote { + Button("Note") { + onEditNote() + } + .buttonStyle(.bordered) + .font(.system(.caption, design: .monospaced)) + } + } + } else { + Button("Track") { + onTrack() + } + .buttonStyle(.bordered) + .font(.system(.caption, design: .monospaced)) } - .buttonStyle(.bordered) - .font(.system(.caption, design: .monospaced)) } - CardView { + CardView(allowsHorizontalScroll: false) { ForEach(rows) { row in LabeledValueRow(row: row) } + if let trackedDomain { + TrackedDomainDetailHeaderView(trackedDomain: trackedDomain) + .padding(.top, 4) + } else if let trackingLimitMessage { + MessageRowView(text: trackingLimitMessage, isError: false) + .padding(.top, 4) + } if availabilityLoading { ProgressView("Checking availability…") .appLoadingStyle() @@ -888,6 +1119,12 @@ private extension View { } } +private extension String { + var nilIfEmpty: String? { + isEmpty ? nil : self + } +} + private struct SettingsView: View { @AppStorage(DNSResolverOption.userDefaultsKey) private var storedResolverURL = DNSResolverOption.defaultURLString diff --git a/DomainDig/DomainDiffService.swift b/DomainDig/DomainDiffService.swift new file mode 100644 index 0000000..fa3d8b5 --- /dev/null +++ b/DomainDig/DomainDiffService.swift @@ -0,0 +1,197 @@ +import Foundation + +enum DiffChangeType: String, Codable { + case added + case removed + case changed + case unchanged +} + +struct DomainDiffItem: Identifiable, Equatable { + let id = UUID() + let label: String + let changeType: DiffChangeType + let oldValue: String? + let newValue: String? +} + +struct DomainDiffSection: Identifiable, Equatable { + let id = UUID() + let title: String + let items: [DomainDiffItem] +} + +enum DomainDiffService { + static func diff(from oldSnapshot: LookupSnapshot, to newSnapshot: LookupSnapshot) -> [DomainDiffSection] { + [ + section(title: "Availability", item: compare( + label: "Status", + oldValue: availabilityLabel(oldSnapshot.availabilityResult?.status), + newValue: availabilityLabel(newSnapshot.availabilityResult?.status) + )), + section(title: "Primary IP", item: compare( + label: "Address", + oldValue: primaryIP(from: oldSnapshot), + newValue: primaryIP(from: newSnapshot) + )), + dnsSection(from: oldSnapshot, to: newSnapshot), + section(title: "Redirect", item: compare( + label: "Final Target", + oldValue: finalRedirectURL(from: oldSnapshot), + newValue: finalRedirectURL(from: newSnapshot) + )), + tlsSection(from: oldSnapshot, to: newSnapshot), + httpSection(from: oldSnapshot, to: newSnapshot), + emailSection(from: oldSnapshot, to: newSnapshot) + ] + .filter { !$0.items.isEmpty } + } + + static func summary(from oldSnapshot: LookupSnapshot, to newSnapshot: LookupSnapshot, generatedAt: Date = Date()) -> DomainChangeSummary { + let changedSections = diff(from: oldSnapshot, to: newSnapshot) + .filter { $0.items.contains(where: { $0.changeType != .unchanged }) } + .map(\.title) + + return DomainChangeSummary( + hasChanges: !changedSections.isEmpty, + changedSections: changedSections, + generatedAt: generatedAt + ) + } + + private static func section(title: String, item: DomainDiffItem?) -> DomainDiffSection { + DomainDiffSection(title: title, items: item.map { [$0] } ?? []) + } + + private static func dnsSection(from oldSnapshot: LookupSnapshot, to newSnapshot: LookupSnapshot) -> DomainDiffSection { + let oldValue = normalizedDNSSummary(from: oldSnapshot) + let newValue = normalizedDNSSummary(from: newSnapshot) + return section(title: "DNS Records", item: compare(label: "Records", oldValue: oldValue, newValue: newValue)) + } + + private static func tlsSection(from oldSnapshot: LookupSnapshot, to newSnapshot: LookupSnapshot) -> DomainDiffSection { + var items: [DomainDiffItem] = [] + if let item = compare(label: "Issuer", oldValue: normalized(oldSnapshot.sslInfo?.issuer), newValue: normalized(newSnapshot.sslInfo?.issuer)) { + items.append(item) + } + if let item = compare(label: "Certificate", oldValue: tlsSummary(from: oldSnapshot), newValue: tlsSummary(from: newSnapshot)) { + items.append(item) + } + return DomainDiffSection(title: "TLS Certificate", items: items) + } + + private static func httpSection(from oldSnapshot: LookupSnapshot, to newSnapshot: LookupSnapshot) -> DomainDiffSection { + var items: [DomainDiffItem] = [] + if let item = compare(label: "HTTP Status", oldValue: httpStatusSummary(from: oldSnapshot), newValue: httpStatusSummary(from: newSnapshot)) { + items.append(item) + } + if let item = compare(label: "Security Grade", oldValue: normalized(oldSnapshot.httpSecurityGrade), newValue: normalized(newSnapshot.httpSecurityGrade)) { + items.append(item) + } + return DomainDiffSection(title: "HTTP", items: items) + } + + private static func emailSection(from oldSnapshot: LookupSnapshot, to newSnapshot: LookupSnapshot) -> DomainDiffSection { + section( + title: "Email Security", + item: compare( + label: "Summary", + oldValue: normalized(emailSummary(from: oldSnapshot)), + newValue: normalized(emailSummary(from: newSnapshot)) + ) + ) + } + + private static func compare(label: String, oldValue: String?, newValue: String?) -> DomainDiffItem? { + let oldValue = normalized(oldValue) + let newValue = normalized(newValue) + + guard oldValue != nil || newValue != nil else { + return nil + } + + let changeType: DiffChangeType + switch (oldValue, newValue) { + case let (old?, new?) where old == new: + changeType = .unchanged + case (nil, _?): + changeType = .added + case (_?, nil): + changeType = .removed + default: + changeType = .changed + } + + return DomainDiffItem(label: label, changeType: changeType, oldValue: oldValue, newValue: newValue) + } + + private static func normalized(_ value: String?) -> String? { + guard let value = value?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else { + return nil + } + return value.lowercased() + } + + private static func availabilityLabel(_ status: DomainAvailabilityStatus?) -> String? { + switch status { + case .available: + return "available" + case .registered: + return "registered" + case .unknown: + return "unknown" + case .none: + return nil + } + } + + private static func primaryIP(from snapshot: LookupSnapshot) -> String? { + snapshot.dnsSections.first(where: { $0.recordType == .A })?.records.first?.value + } + + private static func finalRedirectURL(from snapshot: LookupSnapshot) -> String? { + snapshot.redirectChain.last?.url + } + + private static func tlsSummary(from snapshot: LookupSnapshot) -> String? { + if let sslInfo = snapshot.sslInfo { + return "\(sslInfo.commonName) | \(sslInfo.validUntil.formatted(date: .abbreviated, time: .omitted))" + } + return snapshot.sslError + } + + private static func httpStatusSummary(from snapshot: LookupSnapshot) -> String? { + if let httpStatusCode = snapshot.httpStatusCode { + return "\(httpStatusCode)" + } + return snapshot.httpHeadersError + } + + private static func emailSummary(from snapshot: LookupSnapshot) -> String? { + if let emailSecurity = snapshot.emailSecurity { + return [ + "spf:\(emailSecurity.spf.found)", + "dmarc:\(emailSecurity.dmarc.found)", + "dkim:\(emailSecurity.dkim.found)", + "bimi:\(emailSecurity.bimi.found)", + "mta-sts:\(emailSecurity.mtaSts?.txtFound == true)" + ].joined(separator: "|") + } + return snapshot.emailSecurityError + } + + private static func normalizedDNSSummary(from snapshot: LookupSnapshot) -> String? { + let parts = snapshot.dnsSections + .sorted { $0.recordType.rawValue < $1.recordType.rawValue } + .map { section in + let values = (section.records + section.wildcardRecords) + .map(\.value) + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() } + .sorted() + .joined(separator: ",") + return "\(section.recordType.rawValue):\(values)" + } + .filter { !$0.hasSuffix(":") } + return parts.isEmpty ? nil : parts.joined(separator: "|") + } +} diff --git a/DomainDig/DomainViewModel.swift b/DomainDig/DomainViewModel.swift index 839790b..4a20338 100644 --- a/DomainDig/DomainViewModel.swift +++ b/DomainDig/DomainViewModel.swift @@ -80,8 +80,10 @@ struct DomainSuggestionViewData: Identifiable { } struct LookupSnapshot { + let historyEntryID: UUID? let domain: String let timestamp: Date + let trackedDomainID: UUID? let resolverDisplayName: String let resolverURLString: String let totalLookupDurationMs: Int? @@ -111,14 +113,17 @@ struct LookupSnapshot { let redirectChainError: 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, @@ -148,6 +153,7 @@ extension HistoryEntry { redirectChainError: redirectChainError, portScanResults: portScanResults, portScanError: portScanError, + changeSummary: changeSummary, isLive: false ) } @@ -211,6 +217,10 @@ final class DomainViewModel { var hasRun = false private(set) var searchedDomain: String = "" private(set) var lastLookupDurationMs: Int? + private(set) var currentDiffSections: [DomainDiffSection] = [] + private(set) var currentChangeSummary: DomainChangeSummary? + private(set) var refreshingTrackedDomainID: UUID? + private(set) var rerunNavigationToken = UUID() private var lookupTask: Task<Void, Never>? private var customPortScanTask: Task<Void, Never>? @@ -224,14 +234,9 @@ final class DomainViewModel { private static let savedDomainsKey = "savedDomains" var savedDomains: [String] = UserDefaults.standard.stringArray(forKey: savedDomainsKey) ?? [] - private static let watchedDomainsKey = "watchedDomains" - var watchedDomains: [WatchedDomain] = { - guard let data = UserDefaults.standard.data(forKey: watchedDomainsKey), - let domains = try? JSONDecoder().decode([WatchedDomain].self, from: data) else { - return [] - } - return domains - }() + private static let trackedDomainsKey = "trackedDomains" + private static let legacyWatchedDomainsKey = "watchedDomains" + var trackedDomains: [TrackedDomain] = DomainViewModel.loadTrackedDomains() private static let historyKey = "lookupHistory" private static let maxHistory = 50 @@ -276,8 +281,35 @@ final class DomainViewModel { !searchedDomain.isEmpty && savedDomains.contains(where: { $0.lowercased() == searchedDomain.lowercased() }) } - var isCurrentDomainWatched: Bool { - !searchedDomain.isEmpty && watchedDomains.contains(where: { $0.domain.lowercased() == searchedDomain.lowercased() }) + var sortedTrackedDomains: [TrackedDomain] { + trackedDomains.sorted { + if $0.isPinned != $1.isPinned { + return $0.isPinned && !$1.isPinned + } + if $0.updatedAt != $1.updatedAt { + return $0.updatedAt > $1.updatedAt + } + return $0.domain.localizedCaseInsensitiveCompare($1.domain) == .orderedAscending + } + } + + var currentTrackedDomain: TrackedDomain? { + guard !searchedDomain.isEmpty else { return nil } + return trackedDomain(for: searchedDomain) + } + + var isCurrentDomainTracked: Bool { + currentTrackedDomain != nil + } + + var trackingLimitMessage: String? { + guard currentTrackedDomain == nil else { return nil } + guard !PremiumAccessService.canAddTrackedDomain(currentCount: trackedDomains.count) else { return nil } + return "Free version supports up to 3 tracked domains. More tracked domains will be available in a future Pro upgrade." + } + + var canTrackCurrentDomain: Bool { + currentTrackedDomain != nil || PremiumAccessService.canAddTrackedDomain(currentCount: trackedDomains.count) } var resolverDisplayName: String { @@ -299,8 +331,10 @@ final class DomainViewModel { var currentSnapshot: LookupSnapshot { LookupSnapshot( + historyEntryID: nil, domain: searchedDomain, timestamp: Date(), + trackedDomainID: currentTrackedDomain?.id, resolverDisplayName: resolverDisplayName, resolverURLString: resolverURLString, totalLookupDurationMs: lastLookupDurationMs, @@ -330,6 +364,7 @@ final class DomainViewModel { redirectChainError: redirectChainError, portScanResults: allPortScanResults, portScanError: combinedPortScanError, + changeSummary: currentChangeSummary, isLive: true ) } @@ -408,31 +443,84 @@ final class DomainViewModel { UserDefaults.standard.set(savedDomains, forKey: Self.savedDomainsKey) } - func toggleWatchedDomain() { - guard !searchedDomain.isEmpty else { return } - toggleWatchedDomain(domain: searchedDomain, availabilityStatus: availabilityResult?.status) + @discardableResult + func trackCurrentDomain() -> Bool { + guard !searchedDomain.isEmpty else { return false } + return trackDomain(domain: searchedDomain, availabilityStatus: availabilityResult?.status) } - func toggleWatchedDomain(domain: String, availabilityStatus: DomainAvailabilityStatus?) { - guard !domain.isEmpty else { return } + @discardableResult + func trackDomain(domain: String, availabilityStatus: DomainAvailabilityStatus?) -> Bool { + let normalizedDomain = normalizedDomain(domain) + guard !normalizedDomain.isEmpty else { return false } - if watchedDomains.contains(where: { $0.domain.lowercased() == domain.lowercased() }) { - watchedDomains.removeAll { $0.domain.lowercased() == domain.lowercased() } - } else { - watchedDomains.insert( - WatchedDomain( - domain: domain, - lastKnownAvailability: availabilityStatus - ), - at: 0 - ) + if trackedDomain(for: normalizedDomain) != nil { + return true } - persistWatchedDomains() + + guard PremiumAccessService.canAddTrackedDomain(currentCount: trackedDomains.count) else { + return false + } + + trackedDomains.insert( + TrackedDomain( + domain: normalizedDomain, + createdAt: Date(), + updatedAt: Date(), + lastKnownAvailability: availabilityStatus + ), + at: 0 + ) + persistTrackedDomains() + linkTrackedDomainHistory(for: normalizedDomain) + return true } - func removeWatchedDomains(at offsets: IndexSet) { - watchedDomains.remove(atOffsets: offsets) - persistWatchedDomains() + func refreshTrackedDomain(_ trackedDomain: TrackedDomain) { + refreshingTrackedDomainID = trackedDomain.id + domain = trackedDomain.domain + run() + } + + func rerunInspection(for trackedDomain: TrackedDomain) { + domain = trackedDomain.domain + run() + rerunNavigationToken = UUID() + } + + func deleteTrackedDomains(at offsets: IndexSet) { + let ids = offsets.map { sortedTrackedDomains[$0].id } + trackedDomains.removeAll { ids.contains($0.id) } + history.indices.forEach { index in + if let trackedDomainID = history[index].trackedDomainID, ids.contains(trackedDomainID) { + history[index].trackedDomainID = nil + } + } + persistTrackedDomains() + persistHistory() + } + + func deleteTrackedDomain(_ trackedDomain: TrackedDomain) { + trackedDomains.removeAll { $0.id == trackedDomain.id } + history.indices.forEach { index in + if history[index].trackedDomainID == trackedDomain.id { + history[index].trackedDomainID = nil + } + } + persistTrackedDomains() + persistHistory() + } + + func togglePinned(for trackedDomain: TrackedDomain) { + guard let index = trackedDomains.firstIndex(where: { $0.id == trackedDomain.id }) else { return } + trackedDomains[index].isPinned.toggle() + persistTrackedDomains() + } + + func updateNote(_ note: String, for trackedDomain: TrackedDomain) { + guard let index = trackedDomains.firstIndex(where: { $0.id == trackedDomain.id }) else { return } + trackedDomains[index].note = note.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty + persistTrackedDomains() } func removeHistoryEntries(at offsets: IndexSet) { @@ -440,6 +528,11 @@ final class DomainViewModel { persistHistory() } + func clearHistory() { + history.removeAll() + persistHistory() + } + func clearRecentSearches() { recentSearches.removeAll() UserDefaults.standard.removeObject(forKey: Self.recentSearchesKey) @@ -449,6 +542,7 @@ final class DomainViewModel { UserDefaults.standard.set(entry.resolverURLString, forKey: DNSResolverOption.userDefaultsKey) domain = entry.domain run() + rerunNavigationToken = UUID() } func reset() { @@ -457,6 +551,9 @@ final class DomainViewModel { hasRun = false searchedDomain = "" lastLookupDurationMs = nil + currentDiffSections = [] + currentChangeSummary = nil + refreshingTrackedDomainID = nil clearLookupState() } @@ -474,6 +571,8 @@ final class DomainViewModel { addRecentSearch(target) searchedDomain = target hasRun = true + currentDiffSections = [] + currentChangeSummary = nil clearLookupState() setAllLoadingStates(true) customPortScanLoading = false @@ -513,7 +612,12 @@ final class DomainViewModel { } func exportText() -> String { - Self.formatExportText(from: currentSnapshot) + Self.formatExportText( + from: currentSnapshot, + trackedDomain: currentTrackedDomain, + changeSummary: currentChangeSummary, + diffSections: currentDiffSections + ) } private func performLookup(domain: String, lookupID: UUID) async { @@ -555,6 +659,7 @@ final class DomainViewModel { guard !Task.isCancelled, isCurrentLookup(lookupID) else { return } lastLookupDurationMs = lookupStartedAt.map { Int(Date().timeIntervalSince($0) * 1000) } saveHistoryEntry(replaceLatest: false) + refreshingTrackedDomainID = nil } private func runDNS(domain: String, lookupID: UUID) async { @@ -579,7 +684,7 @@ final class DomainViewModel { guard !Task.isCancelled, isCurrentLookup(lookupID) else { return } availabilityResult = result availabilityLoading = false - updateWatchedDomainAvailability(for: result.domain, status: result.status) + updateTrackedDomainAvailability(for: result.domain, status: result.status) } private func runSSL(domain: String, lookupID: UUID) async { @@ -801,9 +906,20 @@ final class DomainViewModel { private func saveHistoryEntry(replaceLatest: Bool) { guard !searchedDomain.isEmpty else { return } + + let trackedDomainID = trackedDomain(for: searchedDomain)?.id + let timestamp = Date() + let snapshot = currentSnapshot + let previousSnapshot = previousSnapshot(for: searchedDomain, trackedDomainID: trackedDomainID, replacingLatest: replaceLatest) + let changeSummary = previousSnapshot.map { DomainDiffService.summary(from: $0, to: snapshot, generatedAt: timestamp) } + + currentChangeSummary = changeSummary + currentDiffSections = previousSnapshot.map { DomainDiffService.diff(from: $0, to: snapshot) } ?? [] + let entry = HistoryEntry( domain: searchedDomain, - timestamp: Date(), + timestamp: timestamp, + trackedDomainID: trackedDomainID, dnsSections: dnsSections, sslInfo: sslInfo, httpHeaders: httpHeaders, @@ -820,6 +936,12 @@ final class DomainViewModel { resolverDisplayName: resolverDisplayName, resolverURLString: resolverURLString, totalLookupDurationMs: lastLookupDurationMs, + primaryIP: Self.primaryIPAddress(from: snapshot), + finalRedirectURL: Self.finalRedirectTarget(from: snapshot), + tlsStatusSummary: Self.httpsSummary(from: snapshot), + emailSecuritySummary: Self.emailSummary(from: snapshot), + httpGradeSummary: snapshot.httpSecurityGrade ?? snapshot.httpHeadersError, + changeSummary: changeSummary, sslError: sslError, httpHeadersError: httpHeadersError, reachabilityError: reachabilityError, @@ -838,6 +960,14 @@ final class DomainViewModel { history = Array(history.prefix(Self.maxHistory)) } } + + updateTrackedDomainSnapshotMetadata( + domain: searchedDomain, + snapshotID: entry.id, + availabilityStatus: availabilityResult?.status, + updatedAt: timestamp, + changeSummary: changeSummary + ) persistHistory() } @@ -847,18 +977,82 @@ final class DomainViewModel { } } - private func persistWatchedDomains() { - if let data = try? JSONEncoder().encode(watchedDomains) { - UserDefaults.standard.set(data, forKey: Self.watchedDomainsKey) + private func persistTrackedDomains() { + if let data = try? JSONEncoder().encode(trackedDomains) { + UserDefaults.standard.set(data, forKey: Self.trackedDomainsKey) } } - private func updateWatchedDomainAvailability(for domain: String, status: DomainAvailabilityStatus) { - guard let index = watchedDomains.firstIndex(where: { $0.domain.lowercased() == domain.lowercased() }) else { + private func updateTrackedDomainAvailability(for domain: String, status: DomainAvailabilityStatus) { + guard let index = trackedDomains.firstIndex(where: { $0.domain.caseInsensitiveCompare(domain) == .orderedSame }) else { return } - watchedDomains[index].lastKnownAvailability = status - persistWatchedDomains() + trackedDomains[index].lastKnownAvailability = status + persistTrackedDomains() + } + + private func updateTrackedDomainSnapshotMetadata( + domain: String, + snapshotID: UUID, + availabilityStatus: DomainAvailabilityStatus?, + updatedAt: Date, + changeSummary: DomainChangeSummary? + ) { + guard let index = trackedDomains.firstIndex(where: { $0.domain.caseInsensitiveCompare(domain) == .orderedSame }) else { + return + } + trackedDomains[index].lastSnapshotID = snapshotID + trackedDomains[index].lastKnownAvailability = availabilityStatus + trackedDomains[index].updatedAt = updatedAt + trackedDomains[index].lastChangeSummary = changeSummary + persistTrackedDomains() + } + + private func previousSnapshot(for domain: String, trackedDomainID: UUID?, replacingLatest: Bool) -> LookupSnapshot? { + let matchingEntries = history.filter { entry in + if let trackedDomainID { + return entry.trackedDomainID == trackedDomainID + } + return entry.domain.caseInsensitiveCompare(domain) == .orderedSame + } + + if replacingLatest { + return matchingEntries.dropFirst().first?.snapshot + } + return matchingEntries.first?.snapshot + } + + private func trackedDomain(for domain: String) -> TrackedDomain? { + trackedDomains.first { $0.domain.caseInsensitiveCompare(domain) == .orderedSame } + } + + private func normalizedDomain(_ domain: String) -> String { + domain.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + } + + private func linkTrackedDomainHistory(for domain: String) { + guard let trackedDomain = trackedDomain(for: domain) else { return } + var didChange = false + + for index in history.indices where history[index].domain.caseInsensitiveCompare(domain) == .orderedSame { + if history[index].trackedDomainID != trackedDomain.id { + history[index].trackedDomainID = trackedDomain.id + didChange = true + } + } + + if didChange { + persistHistory() + } + + if let latestEntry = history.first(where: { $0.domain.caseInsensitiveCompare(domain) == .orderedSame }), + let trackedIndex = trackedDomains.firstIndex(where: { $0.id == trackedDomain.id }) { + trackedDomains[trackedIndex].lastSnapshotID = latestEntry.id + trackedDomains[trackedIndex].lastChangeSummary = latestEntry.changeSummary + trackedDomains[trackedIndex].lastKnownAvailability = latestEntry.availabilityResult?.status + trackedDomains[trackedIndex].updatedAt = latestEntry.timestamp + persistTrackedDomains() + } } private func addRecentSearch(_ domain: String) { @@ -937,6 +1131,71 @@ final class DomainViewModel { activeLookupID == lookupID } + func recentSnapshots(for trackedDomain: TrackedDomain, limit: Int = 6) -> [HistoryEntry] { + history + .filter { $0.trackedDomainID == trackedDomain.id || $0.domain.caseInsensitiveCompare(trackedDomain.domain) == .orderedSame } + .sorted { $0.timestamp > $1.timestamp } + .prefix(limit) + .map { $0 } + } + + func diffSectionsForLatestSnapshots(of trackedDomain: TrackedDomain) -> [DomainDiffSection] { + let snapshots = recentSnapshots(for: trackedDomain, limit: 2) + guard snapshots.count == 2 else { return [] } + return DomainDiffService.diff(from: snapshots[1].snapshot, to: snapshots[0].snapshot) + } + + func latestChangeSummary(for trackedDomain: TrackedDomain) -> DomainChangeSummary? { + trackedDomain.lastChangeSummary ?? recentSnapshots(for: trackedDomain, limit: 1).first?.changeSummary + } + + func comparisonSnapshot(for entry: HistoryEntry) -> LookupSnapshot? { + let siblings = history.filter { candidate in + if let trackedDomainID = entry.trackedDomainID { + return candidate.trackedDomainID == trackedDomainID && candidate.id != entry.id + } + return candidate.domain.caseInsensitiveCompare(entry.domain) == .orderedSame && candidate.id != entry.id + } + .sorted { $0.timestamp > $1.timestamp } + + return siblings.first?.snapshot + } + + private static func loadTrackedDomains() -> [TrackedDomain] { + let defaults = UserDefaults.standard + let decoder = JSONDecoder() + + if let data = defaults.data(forKey: trackedDomainsKey), + let domains = try? decoder.decode([TrackedDomain].self, from: data) { + return deduplicatedTrackedDomains(domains) + } + + if let legacyData = defaults.data(forKey: legacyWatchedDomainsKey), + let legacyDomains = try? decoder.decode([WatchedDomain].self, from: legacyData) { + return deduplicatedTrackedDomains( + legacyDomains.map { + TrackedDomain( + id: $0.id, + domain: $0.domain.lowercased(), + createdAt: $0.createdAt, + updatedAt: $0.createdAt, + lastKnownAvailability: $0.lastKnownAvailability + ) + } + ) + } + + return [] + } + + private static func deduplicatedTrackedDomains(_ domains: [TrackedDomain]) -> [TrackedDomain] { + var seen = Set<String>() + return domains.filter { domain in + let key = domain.domain.lowercased() + return seen.insert(key).inserted + } + } + static func summaryFields(from snapshot: LookupSnapshot) -> [SummaryFieldViewData] { [ SummaryFieldViewData(label: "Domain", value: snapshot.domain.nonEmpty ?? "Unavailable", tone: .primary), @@ -1110,7 +1369,12 @@ final class DomainViewModel { } } - static func formatExportText(from snapshot: LookupSnapshot) -> String { + static func formatExportText( + from snapshot: LookupSnapshot, + trackedDomain: TrackedDomain?, + changeSummary: DomainChangeSummary?, + diffSections: [DomainDiffSection] + ) -> String { let exportDateFormatter = DateFormatter() exportDateFormatter.dateFormat = "yyyy-MM-dd HH:mm" @@ -1120,9 +1384,14 @@ final class DomainViewModel { "Date: \(exportDateFormatter.string(from: snapshot.timestamp))", "Mode: \(snapshot.isLive ? "Live" : "Snapshot")", "Resolver: \(snapshot.resolverDisplayName)", - "Lookup Duration: \(durationLabel(snapshot.totalLookupDurationMs))" + "Lookup Duration: \(durationLabel(snapshot.totalLookupDurationMs))", + "Tracked: \(trackedDomain == nil ? "No" : "Yes")" ] + if let note = trackedDomain?.note?.nilIfEmpty { + lines.append("Tracking Note: \(note)") + } + func appendSection(_ title: String, body: () -> Void) { lines.append("") lines.append(title) @@ -1134,6 +1403,36 @@ final class DomainViewModel { for item in summaryFields(from: snapshot) { lines.append(" \(item.label): \(item.value)") } + if let changeSummary { + lines.append(" Change Status: \(changeSummary.hasChanges ? "Changed" : "Unchanged")") + lines.append(" Changed Sections: \(changeSummary.changedSections.isEmpty ? "None" : changeSummary.changedSections.joined(separator: ", "))") + } + } + + appendSection("Tracking") { + if let trackedDomain { + lines.append(" Pinned: \(trackedDomain.isPinned ? "Yes" : "No")") + lines.append(" Last Refresh: \(exportDateFormatter.string(from: trackedDomain.updatedAt))") + lines.append(" Last Known Availability: \(availabilityLabel(trackedDomain.lastKnownAvailability))") + if let note = trackedDomain.note?.nilIfEmpty { + lines.append(" Note: \(note)") + } + } else { + lines.append(" This domain is not currently tracked.") + } + } + + appendSection("Diff Summary") { + if diffSections.isEmpty { + lines.append(" No comparison available") + } else { + for section in diffSections where section.items.contains(where: { $0.changeType != .unchanged }) { + lines.append(" \(section.title)") + for item in section.items where item.changeType != .unchanged { + lines.append(" \(item.label): \(item.oldValue ?? "None") -> \(item.newValue ?? "None")") + } + } + } } appendSection("Domain") { diff --git a/DomainDig/HistoryView.swift b/DomainDig/HistoryView.swift index 5bbbb91..bb9d255 100644 --- a/DomainDig/HistoryView.swift +++ b/DomainDig/HistoryView.swift @@ -2,6 +2,8 @@ import SwiftUI struct HistoryView: View { @Bindable var viewModel: DomainViewModel + @Environment(\.dismiss) private var dismiss + @State private var showClearAllConfirmation = false private let dateFormatter: DateFormatter = { let formatter = DateFormatter() @@ -50,8 +52,29 @@ struct HistoryView: View { .navigationTitle("History") .toolbar { if !viewModel.history.isEmpty { - EditButton() + ToolbarItemGroup(placement: .topBarTrailing) { + Menu { + Button("Clear All", role: .destructive) { + showClearAllConfirmation = true + } + } label: { + Image(systemName: "ellipsis.circle") + } + + EditButton() + } + } + } + .alert("Clear history?", isPresented: $showClearAllConfirmation) { + Button("Clear All", role: .destructive) { + viewModel.clearHistory() } + Button("Cancel", role: .cancel) {} + } message: { + Text("This will delete all saved history entries. This cannot be undone.") + } + .onChange(of: viewModel.rerunNavigationToken) { _, _ in + dismiss() } .preferredColorScheme(.dark) } @@ -60,6 +83,7 @@ struct HistoryView: View { struct HistoryDetailView: View { @Bindable var viewModel: DomainViewModel let entry: HistoryEntry + @Environment(\.dismiss) private var dismiss private let dateFormatter: DateFormatter = { let formatter = DateFormatter() @@ -84,12 +108,30 @@ struct HistoryDetailView: View { showSuggestions: entry.availabilityResult?.status == .registered && !entry.suggestions.isEmpty, availabilityLoading: false, suggestionsLoading: false, - isWatched: viewModel.watchedDomains.contains(where: { $0.domain.lowercased() == entry.domain.lowercased() }), - onToggleWatch: { - viewModel.toggleWatchedDomain(domain: entry.domain, availabilityStatus: entry.availabilityResult?.status) - } + trackedDomain: viewModel.trackedDomains.first(where: { $0.domain.lowercased() == entry.domain.lowercased() }), + trackingLimitMessage: nil, + onTrack: { + _ = viewModel.trackDomain(domain: entry.domain, availabilityStatus: entry.availabilityResult?.status) + }, + onTogglePinned: { + guard let trackedDomain = viewModel.trackedDomains.first(where: { $0.domain.lowercased() == entry.domain.lowercased() }) else { return } + viewModel.togglePinned(for: trackedDomain) + }, + onEditNote: nil ) .padding(.top, 16) + if let comparisonSnapshot = viewModel.comparisonSnapshot(for: entry) { + if let changeSummary = entry.changeSummary { + DomainChangeSummaryView(summary: changeSummary) + .padding(.top, 16) + } + DomainDiffView( + title: "Compared With Previous Snapshot", + sections: DomainDiffService.diff(from: comparisonSnapshot, to: snapshot), + showsUnchanged: false + ) + .padding(.top, 16) + } DNSSectionView( dnssecLabel: DomainViewModel.dnssecLabel(from: snapshot), sections: DomainViewModel.dnsRows(from: snapshot), @@ -150,6 +192,9 @@ struct HistoryDetailView: View { viewModel.rerunLookup(from: entry) } } + .onChange(of: viewModel.rerunNavigationToken) { _, _ in + dismiss() + } .preferredColorScheme(.dark) } diff --git a/DomainDig/Models.swift b/DomainDig/Models.swift index 5a836ba..e571da9 100644 --- a/DomainDig/Models.swift +++ b/DomainDig/Models.swift @@ -48,6 +48,54 @@ struct WatchedDomain: Codable, Identifiable { } } +struct DomainChangeSummary: Codable, Equatable { + let hasChanges: Bool + let changedSections: [String] + let generatedAt: Date +} + +enum PremiumCapability: String, Codable { + case unlimitedTrackedDomains + case automatedMonitoring + case pushAlerts + case batchTracking + case advancedExports +} + +struct TrackedDomain: Codable, Identifiable, Equatable { + let id: UUID + var domain: String + var createdAt: Date + var updatedAt: Date + var note: String? + var isPinned: Bool + var lastKnownAvailability: DomainAvailabilityStatus? + var lastSnapshotID: UUID? + var lastChangeSummary: DomainChangeSummary? + + init( + id: UUID = UUID(), + domain: String, + createdAt: Date = Date(), + updatedAt: Date = Date(), + note: String? = nil, + isPinned: Bool = false, + lastKnownAvailability: DomainAvailabilityStatus? = nil, + lastSnapshotID: UUID? = nil, + lastChangeSummary: DomainChangeSummary? = nil + ) { + self.id = id + self.domain = domain + self.createdAt = createdAt + self.updatedAt = updatedAt + self.note = note + self.isPinned = isPinned + self.lastKnownAvailability = lastKnownAvailability + self.lastSnapshotID = lastSnapshotID + self.lastChangeSummary = lastChangeSummary + } +} + // MARK: - DNS Models enum DNSRecordType: String, CaseIterable, Codable { @@ -321,6 +369,7 @@ struct HistoryEntry: Identifiable, Codable { var id = UUID() let domain: String let timestamp: Date + var trackedDomainID: UUID? let dnsSections: [DNSSection] let sslInfo: SSLCertificateInfo? let httpHeaders: [HTTPHeader] @@ -337,6 +386,12 @@ struct HistoryEntry: Identifiable, Codable { var resolverDisplayName: String var resolverURLString: String var totalLookupDurationMs: Int? + var primaryIP: String? + var finalRedirectURL: String? + var tlsStatusSummary: String? + var emailSecuritySummary: String? + var httpGradeSummary: String? + var changeSummary: DomainChangeSummary? var sslError: String? var httpHeadersError: String? var reachabilityError: String? @@ -346,19 +401,22 @@ struct HistoryEntry: Identifiable, Codable { var redirectChainError: String? var portScanError: String? - init(domain: String, timestamp: Date, dnsSections: [DNSSection], + init(domain: String, timestamp: Date, trackedDomainID: UUID? = nil, dnsSections: [DNSSection], sslInfo: SSLCertificateInfo?, httpHeaders: [HTTPHeader], reachabilityResults: [PortReachability], ipGeolocation: IPGeolocation?, emailSecurity: EmailSecurityResult? = nil, mtaSts: MTASTSResult? = nil, ptrRecord: String? = nil, redirectChain: [RedirectHop] = [], portScanResults: [PortScanResult] = [], hstsPreloaded: Bool? = nil, availabilityResult: DomainAvailabilityResult? = nil, suggestions: [DomainSuggestionResult] = [], resolverDisplayName: String, resolverURLString: String, - totalLookupDurationMs: Int? = nil, sslError: String? = nil, httpHeadersError: String? = nil, + totalLookupDurationMs: Int? = nil, primaryIP: String? = nil, finalRedirectURL: String? = nil, + tlsStatusSummary: String? = nil, emailSecuritySummary: String? = nil, httpGradeSummary: String? = nil, + changeSummary: DomainChangeSummary? = nil, sslError: String? = nil, httpHeadersError: String? = nil, reachabilityError: String? = nil, ipGeolocationError: String? = nil, emailSecurityError: String? = nil, ptrError: String? = nil, redirectChainError: String? = nil, portScanError: String? = nil) { self.domain = domain self.timestamp = timestamp + self.trackedDomainID = trackedDomainID self.dnsSections = dnsSections self.sslInfo = sslInfo self.httpHeaders = httpHeaders @@ -375,6 +433,12 @@ struct HistoryEntry: Identifiable, Codable { self.resolverDisplayName = resolverDisplayName self.resolverURLString = resolverURLString self.totalLookupDurationMs = totalLookupDurationMs + self.primaryIP = primaryIP + self.finalRedirectURL = finalRedirectURL + self.tlsStatusSummary = tlsStatusSummary + self.emailSecuritySummary = emailSecuritySummary + self.httpGradeSummary = httpGradeSummary + self.changeSummary = changeSummary self.sslError = sslError self.httpHeadersError = httpHeadersError self.reachabilityError = reachabilityError @@ -390,6 +454,7 @@ struct HistoryEntry: Identifiable, Codable { id = try container.decodeIfPresent(UUID.self, forKey: .id) ?? UUID() domain = try container.decode(String.self, forKey: .domain) timestamp = try container.decode(Date.self, forKey: .timestamp) + trackedDomainID = try container.decodeIfPresent(UUID.self, forKey: .trackedDomainID) dnsSections = try container.decode([DNSSection].self, forKey: .dnsSections) sslInfo = try container.decodeIfPresent(SSLCertificateInfo.self, forKey: .sslInfo) httpHeaders = try container.decode([HTTPHeader].self, forKey: .httpHeaders) @@ -406,6 +471,12 @@ struct HistoryEntry: Identifiable, Codable { resolverDisplayName = try container.decodeIfPresent(String.self, forKey: .resolverDisplayName) ?? "Cloudflare" resolverURLString = try container.decodeIfPresent(String.self, forKey: .resolverURLString) ?? DNSResolverOption.defaultURLString totalLookupDurationMs = try container.decodeIfPresent(Int.self, forKey: .totalLookupDurationMs) + primaryIP = try container.decodeIfPresent(String.self, forKey: .primaryIP) + finalRedirectURL = try container.decodeIfPresent(String.self, forKey: .finalRedirectURL) + tlsStatusSummary = try container.decodeIfPresent(String.self, forKey: .tlsStatusSummary) + emailSecuritySummary = try container.decodeIfPresent(String.self, forKey: .emailSecuritySummary) + httpGradeSummary = try container.decodeIfPresent(String.self, forKey: .httpGradeSummary) + changeSummary = try container.decodeIfPresent(DomainChangeSummary.self, forKey: .changeSummary) sslError = try container.decodeIfPresent(String.self, forKey: .sslError) httpHeadersError = try container.decodeIfPresent(String.self, forKey: .httpHeadersError) reachabilityError = try container.decodeIfPresent(String.self, forKey: .reachabilityError) diff --git a/DomainDig/PremiumAccessService.swift b/DomainDig/PremiumAccessService.swift new file mode 100644 index 0000000..698a2d2 --- /dev/null +++ b/DomainDig/PremiumAccessService.swift @@ -0,0 +1,27 @@ +import Foundation + +enum PremiumAccessService { + static let freeTrackedDomainLimit = 3 + + static func hasAccess(to capability: PremiumCapability) -> Bool { + switch capability { + case .unlimitedTrackedDomains, + .automatedMonitoring, + .pushAlerts, + .batchTracking, + .advancedExports: + return false + } + } + + static func trackedDomainLimitMessage(currentCount: Int) -> String? { + guard currentCount >= freeTrackedDomainLimit, !hasAccess(to: .unlimitedTrackedDomains) else { + return nil + } + return "More tracked domains will be available in a future Pro upgrade." + } + + static func canAddTrackedDomain(currentCount: Int) -> Bool { + hasAccess(to: .unlimitedTrackedDomains) || currentCount < freeTrackedDomainLimit + } +} diff --git a/DomainDig/WatchlistView.swift b/DomainDig/WatchlistView.swift index c44fc65..aea8707 100644 --- a/DomainDig/WatchlistView.swift +++ b/DomainDig/WatchlistView.swift @@ -6,31 +6,86 @@ struct WatchlistView: View { var body: some View { List { - if viewModel.watchedDomains.isEmpty { - Text("No watched domains") - .font(.system(.callout, design: .monospaced)) - .foregroundStyle(.secondary) - .listRowBackground(Color(.systemGray6).opacity(0.5)) + if viewModel.sortedTrackedDomains.isEmpty { + Section { + VStack(alignment: .leading, spacing: 8) { + Text("No tracked domains yet") + .font(.system(.callout, design: .monospaced)) + .foregroundStyle(.primary) + Text("Tracked domains appear here. Tracking is local and manual for now.") + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(.secondary) + } + .padding(.vertical, 8) + } + .listRowBackground(Color(.systemGray6).opacity(0.5)) } else { - ForEach(viewModel.watchedDomains) { watchedDomain in - Button { - viewModel.domain = watchedDomain.domain - dismiss() - viewModel.run() - } label: { - VStack(alignment: .leading, spacing: 4) { - Text(watchedDomain.domain) - .font(.system(.callout, design: .monospaced)) - .foregroundStyle(.primary) - Text(statusLabel(watchedDomain.lastKnownAvailability)) - .font(.system(.caption2, design: .monospaced)) - .foregroundStyle(statusColor(watchedDomain.lastKnownAvailability)) - } + if let limitMessage = PremiumAccessService.trackedDomainLimitMessage(currentCount: viewModel.trackedDomains.count) { + Section { + Text(limitMessage) + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(.secondary) } .listRowBackground(Color(.systemGray6).opacity(0.5)) } - .onDelete { offsets in - viewModel.removeWatchedDomains(at: offsets) + + Section { + ForEach(viewModel.sortedTrackedDomains) { trackedDomain in + NavigationLink { + TrackedDomainDetailView(viewModel: viewModel, trackedDomain: trackedDomain) + } label: { + WatchlistRowView( + trackedDomain: trackedDomain, + isRefreshing: viewModel.refreshingTrackedDomainID == trackedDomain.id + ) + } + .buttonStyle(.plain) + .swipeActions(edge: .leading, allowsFullSwipe: false) { + Button { + viewModel.togglePinned(for: trackedDomain) + } label: { + Label(trackedDomain.isPinned ? "Unpin" : "Pin", systemImage: trackedDomain.isPinned ? "pin.slash" : "pin") + } + .tint(.yellow) + } + .swipeActions(edge: .trailing, allowsFullSwipe: false) { + Button(role: .destructive) { + viewModel.deleteTrackedDomain(trackedDomain) + } label: { + Label("Delete", systemImage: "trash") + } + } + .contextMenu { + Button { + viewModel.refreshTrackedDomain(trackedDomain) + } label: { + Label("Refresh", systemImage: "arrow.clockwise") + } + + Button { + dismiss() + viewModel.rerunInspection(for: trackedDomain) + } label: { + Label("Open Inspection", systemImage: "magnifyingglass") + } + + Button { + viewModel.togglePinned(for: trackedDomain) + } label: { + Label(trackedDomain.isPinned ? "Unpin" : "Pin", systemImage: trackedDomain.isPinned ? "pin.slash" : "pin") + } + + Button(role: .destructive) { + viewModel.deleteTrackedDomain(trackedDomain) + } label: { + Label("Delete", systemImage: "trash") + } + } + .listRowBackground(Color(.systemGray6).opacity(0.5)) + } + .onDelete(perform: viewModel.deleteTrackedDomains) + } header: { + Text("Tracked Domains") } } } @@ -38,14 +93,59 @@ struct WatchlistView: View { .background(Color.black) .navigationTitle("Watchlist") .toolbar { - if !viewModel.watchedDomains.isEmpty { + if !viewModel.sortedTrackedDomains.isEmpty { EditButton() } } + .onChange(of: viewModel.rerunNavigationToken) { _, _ in + dismiss() + } .preferredColorScheme(.dark) } +} + +struct WatchlistRowView: View { + let trackedDomain: TrackedDomain + let isRefreshing: Bool + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + HStack(alignment: .firstTextBaseline, spacing: 8) { + if trackedDomain.isPinned { + Image(systemName: "pin.fill") + .font(.caption2) + .foregroundStyle(.yellow) + } + Text(trackedDomain.domain) + .font(.system(.callout, design: .monospaced)) + .foregroundStyle(.primary) + .lineLimit(2) + .multilineTextAlignment(.leading) + Spacer(minLength: 8) + statusBadge + } + + Text("Updated \(trackedDomain.updatedAt.formatted(date: .abbreviated, time: .shortened))") + .font(.system(.caption2, design: .monospaced)) + .foregroundStyle(.secondary) + + if let note = trackedDomain.note?.trimmingCharacters(in: .whitespacesAndNewlines), !note.isEmpty { + Text(note) + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(.secondary) + .lineLimit(2) + } else if let summary = trackedDomain.lastChangeSummary { + Text(summary.changedSections.isEmpty ? "No meaningful changes detected." : summary.changedSections.joined(separator: " • ")) + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(.secondary) + .lineLimit(2) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.vertical, 4) + } - private func statusLabel(_ status: DomainAvailabilityStatus?) -> String { + private func availabilityLabel(_ status: DomainAvailabilityStatus?) -> String { switch status { case .available: return "Available" @@ -56,8 +156,33 @@ struct WatchlistView: View { } } - private func statusColor(_ status: DomainAvailabilityStatus?) -> Color { - switch status { + @ViewBuilder + private var statusBadge: some View { + if isRefreshing { + HStack(spacing: 6) { + ProgressView() + .controlSize(.small) + Text("Refreshing") + .font(.system(.caption2, design: .monospaced)) + .foregroundStyle(.secondary) + } + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(Color(.systemGray5).opacity(0.6)) + .clipShape(Capsule()) + } else { + Text(availabilityLabel(trackedDomain.lastKnownAvailability)) + .font(.system(.caption2, design: .monospaced)) + .foregroundStyle(badgeColor) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(badgeBackground) + .clipShape(Capsule()) + } + } + + private var badgeColor: Color { + switch trackedDomain.lastKnownAvailability { case .available: return .green case .registered: @@ -66,4 +191,145 @@ struct WatchlistView: View { return .secondary } } + + private var badgeBackground: Color { + switch trackedDomain.lastKnownAvailability { + case .available: + return .green.opacity(0.16) + case .registered: + return .yellow.opacity(0.16) + case .unknown, .none: + return Color(.systemGray5).opacity(0.6) + } + } +} + +struct TrackedDomainDetailView: View { + @Bindable var viewModel: DomainViewModel + let trackedDomain: TrackedDomain + @Environment(\.dismiss) private var dismiss + + @State private var noteDraft = "" + @State private var isEditingNote = false + + private var liveTrackedDomain: TrackedDomain { + viewModel.trackedDomains.first(where: { $0.id == trackedDomain.id }) ?? trackedDomain + } + + private var latestSnapshots: [HistoryEntry] { + viewModel.recentSnapshots(for: liveTrackedDomain) + } + + private var latestDiffSections: [DomainDiffSection] { + viewModel.diffSectionsForLatestSnapshots(of: liveTrackedDomain) + } + + var body: some View { + List { + Section { + WatchlistRowView( + trackedDomain: liveTrackedDomain, + isRefreshing: viewModel.refreshingTrackedDomainID == liveTrackedDomain.id + ) + } + .listRowBackground(Color(.systemGray6).opacity(0.5)) + + Section { + Button { + viewModel.refreshTrackedDomain(liveTrackedDomain) + } label: { + Label("Manual Refresh", systemImage: "arrow.clockwise") + } + + Button { + viewModel.rerunInspection(for: liveTrackedDomain) + } label: { + Label("Re-run Inspection", systemImage: "magnifyingglass") + } + + Button { + viewModel.togglePinned(for: liveTrackedDomain) + } label: { + Label(liveTrackedDomain.isPinned ? "Unpin Domain" : "Pin Domain", systemImage: liveTrackedDomain.isPinned ? "pin.slash" : "pin") + } + + Button { + noteDraft = liveTrackedDomain.note ?? "" + isEditingNote = true + } label: { + Label(liveTrackedDomain.note == nil ? "Add Note" : "Edit Note", systemImage: "note.text") + } + } + .listRowBackground(Color(.systemGray6).opacity(0.5)) + + if let summary = viewModel.latestChangeSummary(for: liveTrackedDomain) { + Section("Latest Change Summary") { + DomainChangeSummaryView(summary: summary) + } + .listRowBackground(Color.clear) + } + + if !latestDiffSections.isEmpty { + Section("Latest Diff") { + DomainDiffView(title: "Latest Snapshot vs Previous", sections: latestDiffSections, showsUnchanged: false) + } + .listRowBackground(Color.clear) + } + + Section("Recent Snapshots") { + if latestSnapshots.isEmpty { + Text("No snapshots yet") + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(.secondary) + } else { + ForEach(latestSnapshots) { entry in + NavigationLink { + HistoryDetailView(viewModel: viewModel, entry: entry) + } label: { + VStack(alignment: .leading, spacing: 4) { + Text(entry.timestamp.formatted(date: .abbreviated, time: .shortened)) + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(.primary) + Text(entry.changeSummary?.hasChanges == true ? "Changed" : "Snapshot") + .font(.system(.caption2, design: .monospaced)) + .foregroundStyle(.secondary) + } + } + } + } + } + .listRowBackground(Color(.systemGray6).opacity(0.5)) + } + .scrollContentBackground(.hidden) + .background(Color.black) + .navigationTitle(liveTrackedDomain.domain) + .preferredColorScheme(.dark) + .onChange(of: viewModel.rerunNavigationToken) { _, _ in + dismiss() + } + .sheet(isPresented: $isEditingNote) { + NavigationStack { + Form { + Section("Tracking Note") { + TextField("Optional note", text: $noteDraft, axis: .vertical) + .lineLimit(3...6) + } + } + .navigationTitle("Edit Note") + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { + isEditingNote = false + } + } + ToolbarItem(placement: .confirmationAction) { + Button("Save") { + viewModel.updateNote(noteDraft, for: liveTrackedDomain) + isEditingNote = false + } + } + } + } + } + } } |
