Commit 9999f876fe
Verified · cmc
Layout: unified · split
DomainDig.xcodeproj/project.pbxproj +4 −4
| @@ -267,7 +267,7 @@ | ||
| 267 | 267 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; |
| 268 | 268 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; |
| 269 | 269 | CODE_SIGN_STYLE = Automatic; |
| 270 | CURRENT_PROJECT_VERSION = 12; | |
| 270 | CURRENT_PROJECT_VERSION = 13; | |
| 271 | 271 | DEVELOPMENT_TEAM = ZCNAX3VL9D; |
| 272 | 272 | ENABLE_PREVIEWS = YES; |
| 273 | 273 | GENERATE_INFOPLIST_FILE = YES; |
| @@ -284,7 +284,7 @@ | ||
| 284 | 284 | "$(inherited)", |
| 285 | 285 | "@executable_path/Frameworks", |
| 286 | 286 | ); |
| 287 | MARKETING_VERSION = 1.9.0; | |
| 287 | MARKETING_VERSION = 2.0.0; | |
| 288 | 288 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig; |
| 289 | 289 | PRODUCT_NAME = "$(TARGET_NAME)"; |
| 290 | 290 | STRING_CATALOG_GENERATE_SYMBOLS = YES; |
| @@ -303,7 +303,7 @@ | ||
| 303 | 303 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; |
| 304 | 304 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; |
| 305 | 305 | CODE_SIGN_STYLE = Automatic; |
| 306 | CURRENT_PROJECT_VERSION = 12; | |
| 306 | CURRENT_PROJECT_VERSION = 13; | |
| 307 | 307 | DEVELOPMENT_TEAM = ZCNAX3VL9D; |
| 308 | 308 | ENABLE_PREVIEWS = YES; |
| 309 | 309 | GENERATE_INFOPLIST_FILE = YES; |
| @@ -320,7 +320,7 @@ | ||
| 320 | 320 | "$(inherited)", |
| 321 | 321 | "@executable_path/Frameworks", |
| 322 | 322 | ); |
| 323 | MARKETING_VERSION = 1.9.0; | |
| 323 | MARKETING_VERSION = 2.0.0; | |
| 324 | 324 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig; |
| 325 | 325 | PRODUCT_NAME = "$(TARGET_NAME)"; |
| 326 | 326 | STRING_CATALOG_GENERATE_SYMBOLS = YES; |
DomainDig/ContentView.swift +262 −25
| @@ -3,12 +3,16 @@ import SwiftUI | ||
| 3 | 3 | |
| 4 | 4 | struct ContentView: View { |
| 5 | 5 | @State private var viewModel = DomainViewModel() |
| 6 | @State private var navigationPath = NavigationPath() | |
| 6 | 7 | @FocusState private var domainFieldFocused: Bool |
| 7 | 8 | @State private var customPortInput = "" |
| 8 | 9 | @State private var customPortsExpanded = false |
| 10 | @State private var trackingNoteDraft = "" | |
| 11 | @State private var editingTrackedDomain: TrackedDomain? | |
| 12 | @State private var showTrackLimitAlert = false | |
| 9 | 13 | |
| 10 | 14 | var body: some View { |
| 11 | NavigationStack { | |
| 15 | NavigationStack(path: $navigationPath) { | |
| 12 | 16 | ScrollView(.vertical) { |
| 13 | 17 | VStack(spacing: 0) { |
| 14 | 18 | inputSection |
| @@ -16,16 +20,42 @@ struct ContentView: View { | ||
| 16 | 20 | actionButtons |
| 17 | 21 | SummaryView(fields: viewModel.summaryFields) |
| 18 | 22 | .padding(.top, 8) |
| 23 | if let changeSummary = viewModel.currentChangeSummary { | |
| 24 | DomainChangeSummaryView(summary: changeSummary) | |
| 25 | .padding(.top, 12) | |
| 26 | } | |
| 19 | 27 | DomainSectionView( |
| 20 | 28 | rows: viewModel.domainRows, |
| 21 | 29 | suggestions: viewModel.suggestionRows, |
| 22 | 30 | showSuggestions: viewModel.availabilityResult?.status == .registered || viewModel.suggestionsLoading, |
| 23 | 31 | availabilityLoading: viewModel.availabilityLoading, |
| 24 | 32 | suggestionsLoading: viewModel.suggestionsLoading, |
| 25 | isWatched: viewModel.isCurrentDomainWatched, | |
| 26 | onToggleWatch: { viewModel.toggleWatchedDomain() } | |
| 33 | trackedDomain: viewModel.currentTrackedDomain, | |
| 34 | trackingLimitMessage: viewModel.trackingLimitMessage, | |
| 35 | onTrack: { | |
| 36 | if !viewModel.trackCurrentDomain() { | |
| 37 | showTrackLimitAlert = true | |
| 38 | } | |
| 39 | }, | |
| 40 | onTogglePinned: { | |
| 41 | guard let trackedDomain = viewModel.currentTrackedDomain else { return } | |
| 42 | viewModel.togglePinned(for: trackedDomain) | |
| 43 | }, | |
| 44 | onEditNote: { | |
| 45 | guard let trackedDomain = viewModel.currentTrackedDomain else { return } | |
| 46 | trackingNoteDraft = trackedDomain.note ?? "" | |
| 47 | editingTrackedDomain = trackedDomain | |
| 48 | } | |
| 27 | 49 | ) |
| 28 | 50 | .padding(.top, 16) |
| 51 | if !viewModel.currentDiffSections.isEmpty { | |
| 52 | DomainDiffView( | |
| 53 | title: "Latest Changes", | |
| 54 | sections: viewModel.currentDiffSections, | |
| 55 | showsUnchanged: false | |
| 56 | ) | |
| 57 | .padding(.top, 16) | |
| 58 | } | |
| 29 | 59 | DNSSectionView( |
| 30 | 60 | dnssecLabel: viewModel.dnssecLabel, |
| 31 | 61 | sections: viewModel.dnsRows, |
| @@ -96,28 +126,32 @@ struct ContentView: View { | ||
| 96 | 126 | .foregroundStyle(.secondary) |
| 97 | 127 | } |
| 98 | 128 | } |
| 99 | NavigationLink { | |
| 100 | SavedDomainsView(viewModel: viewModel) | |
| 101 | } label: { | |
| 102 | Image(systemName: "bookmark") | |
| 103 | .foregroundStyle(.secondary) | |
| 104 | } | |
| 105 | NavigationLink { | |
| 106 | HistoryView(viewModel: viewModel) | |
| 107 | } label: { | |
| 108 | Image(systemName: "clock.arrow.trianglehead.counterclockwise.rotate.90") | |
| 109 | .foregroundStyle(.secondary) | |
| 110 | } | |
| 111 | 129 | NavigationLink { |
| 112 | 130 | WatchlistView(viewModel: viewModel) |
| 113 | 131 | } label: { |
| 114 | 132 | Image(systemName: "eye") |
| 115 | 133 | .foregroundStyle(.secondary) |
| 116 | 134 | } |
| 117 | NavigationLink { | |
| 118 | SettingsView() | |
| 135 | Menu { | |
| 136 | NavigationLink { | |
| 137 | HistoryView(viewModel: viewModel) | |
| 138 | } label: { | |
| 139 | Label("History", systemImage: "clock.arrow.trianglehead.counterclockwise.rotate.90") | |
| 140 | } | |
| 141 | ||
| 142 | NavigationLink { | |
| 143 | SavedDomainsView(viewModel: viewModel) | |
| 144 | } label: { | |
| 145 | Label("Saved Domains", systemImage: "bookmark") | |
| 146 | } | |
| 147 | ||
| 148 | NavigationLink { | |
| 149 | SettingsView() | |
| 150 | } label: { | |
| 151 | Label("Settings", systemImage: "gearshape") | |
| 152 | } | |
| 119 | 153 | } label: { |
| 120 | Image(systemName: "gearshape") | |
| 154 | Image(systemName: "ellipsis.circle") | |
| 121 | 155 | .foregroundStyle(.secondary) |
| 122 | 156 | } |
| 123 | 157 | } |
| @@ -126,6 +160,41 @@ struct ContentView: View { | ||
| 126 | 160 | .onAppear { |
| 127 | 161 | domainFieldFocused = true |
| 128 | 162 | } |
| 163 | .onChange(of: viewModel.rerunNavigationToken) { _, _ in | |
| 164 | navigationPath = NavigationPath() | |
| 165 | domainFieldFocused = false | |
| 166 | } | |
| 167 | .alert("Tracking limit reached", isPresented: $showTrackLimitAlert) { | |
| 168 | Button("OK", role: .cancel) {} | |
| 169 | } message: { | |
| 170 | Text("Free version supports up to 3 tracked domains. More tracked domains will be available in a future Pro upgrade.") | |
| 171 | } | |
| 172 | .sheet(item: $editingTrackedDomain) { trackedDomain in | |
| 173 | NavigationStack { | |
| 174 | Form { | |
| 175 | Section("Tracking Note") { | |
| 176 | TextField("Optional note", text: $trackingNoteDraft, axis: .vertical) | |
| 177 | .lineLimit(3...6) | |
| 178 | .textInputAutocapitalization(.never) | |
| 179 | .autocorrectionDisabled() | |
| 180 | } | |
| 181 | } | |
| 182 | .navigationTitle(trackedDomain.domain) | |
| 183 | .toolbar { | |
| 184 | ToolbarItem(placement: .cancellationAction) { | |
| 185 | Button("Cancel") { | |
| 186 | editingTrackedDomain = nil | |
| 187 | } | |
| 188 | } | |
| 189 | ToolbarItem(placement: .confirmationAction) { | |
| 190 | Button("Save") { | |
| 191 | viewModel.updateNote(trackingNoteDraft, for: trackedDomain) | |
| 192 | editingTrackedDomain = nil | |
| 193 | } | |
| 194 | } | |
| 195 | } | |
| 196 | } | |
| 197 | } | |
| 129 | 198 | } |
| 130 | 199 | |
| 131 | 200 | private var inputSection: some View { |
| @@ -292,30 +361,192 @@ struct SummaryView: View { | ||
| 292 | 361 | } |
| 293 | 362 | } |
| 294 | 363 | |
| 364 | struct DomainChangeSummaryView: View { | |
| 365 | let summary: DomainChangeSummary | |
| 366 | ||
| 367 | var body: some View { | |
| 368 | CardView(allowsHorizontalScroll: false) { | |
| 369 | HStack { | |
| 370 | Label(summary.hasChanges ? "Changed" : "Unchanged", systemImage: summary.hasChanges ? "arrow.triangle.2.circlepath" : "checkmark.circle") | |
| 371 | .font(.system(.caption, design: .monospaced)) | |
| 372 | .foregroundStyle(summary.hasChanges ? .yellow : .green) | |
| 373 | Spacer() | |
| 374 | Text(summary.generatedAt, style: .time) | |
| 375 | .font(.system(.caption2, design: .monospaced)) | |
| 376 | .foregroundStyle(.secondary) | |
| 377 | } | |
| 378 | ||
| 379 | Text(summary.changedSections.isEmpty ? "No meaningful changes detected." : summary.changedSections.joined(separator: " • ")) | |
| 380 | .font(.system(.caption, design: .monospaced)) | |
| 381 | .foregroundStyle(.primary) | |
| 382 | } | |
| 383 | } | |
| 384 | } | |
| 385 | ||
| 386 | struct DomainDiffView: View { | |
| 387 | let title: String | |
| 388 | let sections: [DomainDiffSection] | |
| 389 | let showsUnchanged: Bool | |
| 390 | ||
| 391 | private var filteredSections: [DomainDiffSection] { | |
| 392 | guard !showsUnchanged else { return sections } | |
| 393 | return sections | |
| 394 | .map { section in | |
| 395 | DomainDiffSection( | |
| 396 | title: section.title, | |
| 397 | items: section.items.filter { $0.changeType != .unchanged } | |
| 398 | ) | |
| 399 | } | |
| 400 | .filter { !$0.items.isEmpty } | |
| 401 | } | |
| 402 | ||
| 403 | var body: some View { | |
| 404 | VStack(alignment: .leading, spacing: 12) { | |
| 405 | SectionTitleView(title: title) | |
| 406 | if filteredSections.isEmpty { | |
| 407 | MessageCardView(text: "No comparison data available", isError: false) | |
| 408 | } else { | |
| 409 | ForEach(filteredSections) { section in | |
| 410 | CardView(allowsHorizontalScroll: false) { | |
| 411 | Text(section.title) | |
| 412 | .font(.system(.subheadline, design: .monospaced)) | |
| 413 | .fontWeight(.semibold) | |
| 414 | .foregroundStyle(.cyan) | |
| 415 | ||
| 416 | ForEach(section.items) { item in | |
| 417 | VStack(alignment: .leading, spacing: 4) { | |
| 418 | HStack { | |
| 419 | Text(item.label) | |
| 420 | .font(.system(.caption, design: .monospaced)) | |
| 421 | .foregroundStyle(.secondary) | |
| 422 | Spacer() | |
| 423 | Text(changeLabel(for: item.changeType)) | |
| 424 | .font(.system(.caption2, design: .monospaced)) | |
| 425 | .foregroundStyle(changeColor(for: item.changeType)) | |
| 426 | } | |
| 427 | if let oldValue = item.oldValue { | |
| 428 | Text("Old: \(oldValue)") | |
| 429 | .font(.system(.caption2, design: .monospaced)) | |
| 430 | .foregroundStyle(.secondary) | |
| 431 | .textSelection(.enabled) | |
| 432 | } | |
| 433 | if let newValue = item.newValue { | |
| 434 | Text("New: \(newValue)") | |
| 435 | .font(.system(.caption, design: .monospaced)) | |
| 436 | .foregroundStyle(.primary) | |
| 437 | .textSelection(.enabled) | |
| 438 | } | |
| 439 | } | |
| 440 | } | |
| 441 | } | |
| 442 | } | |
| 443 | } | |
| 444 | } | |
| 445 | } | |
| 446 | ||
| 447 | private func changeLabel(for changeType: DiffChangeType) -> String { | |
| 448 | switch changeType { | |
| 449 | case .added: | |
| 450 | return "Added" | |
| 451 | case .removed: | |
| 452 | return "Removed" | |
| 453 | case .changed: | |
| 454 | return "Changed" | |
| 455 | case .unchanged: | |
| 456 | return "Unchanged" | |
| 457 | } | |
| 458 | } | |
| 459 | ||
| 460 | private func changeColor(for changeType: DiffChangeType) -> Color { | |
| 461 | switch changeType { | |
| 462 | case .added: | |
| 463 | return .green | |
| 464 | case .removed: | |
| 465 | return .red | |
| 466 | case .changed: | |
| 467 | return .yellow | |
| 468 | case .unchanged: | |
| 469 | return .secondary | |
| 470 | } | |
| 471 | } | |
| 472 | } | |
| 473 | ||
| 474 | struct TrackedDomainDetailHeaderView: View { | |
| 475 | let trackedDomain: TrackedDomain | |
| 476 | ||
| 477 | var body: some View { | |
| 478 | VStack(alignment: .leading, spacing: 4) { | |
| 479 | if let note = trackedDomain.note?.nilIfEmpty { | |
| 480 | LabeledValueRow(row: InfoRowViewData(label: "Tracking Note", value: note, tone: .secondary)) | |
| 481 | } | |
| 482 | HStack(spacing: 8) { | |
| 483 | if trackedDomain.isPinned { | |
| 484 | Label("Pinned", systemImage: "pin.fill") | |
| 485 | } | |
| 486 | Text("Last refresh \(trackedDomain.updatedAt.formatted(date: .abbreviated, time: .shortened))") | |
| 487 | } | |
| 488 | .font(.system(.caption2, design: .monospaced)) | |
| 489 | .foregroundStyle(.secondary) | |
| 490 | } | |
| 491 | } | |
| 492 | } | |
| 493 | ||
| 295 | 494 | struct DomainSectionView: View { |
| 296 | 495 | let rows: [InfoRowViewData] |
| 297 | 496 | let suggestions: [DomainSuggestionViewData] |
| 298 | 497 | let showSuggestions: Bool |
| 299 | 498 | let availabilityLoading: Bool |
| 300 | 499 | let suggestionsLoading: Bool |
| 301 | let isWatched: Bool | |
| 302 | let onToggleWatch: () -> Void | |
| 500 | let trackedDomain: TrackedDomain? | |
| 501 | let trackingLimitMessage: String? | |
| 502 | let onTrack: () -> Void | |
| 503 | let onTogglePinned: () -> Void | |
| 504 | let onEditNote: (() -> Void)? | |
| 303 | 505 | |
| 304 | 506 | var body: some View { |
| 305 | 507 | VStack(alignment: .leading, spacing: 12) { |
| 306 | 508 | HStack { |
| 307 | 509 | SectionTitleView(title: "Domain") |
| 308 | 510 | Spacer() |
| 309 | Button(isWatched ? "Watching" : "Watch") { | |
| 310 | onToggleWatch() | |
| 511 | if let trackedDomain { | |
| 512 | HStack(spacing: 8) { | |
| 513 | Text("Tracked") | |
| 514 | .font(.system(.caption, design: .monospaced)) | |
| 515 | .foregroundStyle(.green) | |
| 516 | Button { | |
| 517 | onTogglePinned() | |
| 518 | } label: { | |
| 519 | Image(systemName: trackedDomain.isPinned ? "pin.fill" : "pin") | |
| 520 | } | |
| 521 | .buttonStyle(.bordered) | |
| 522 | .font(.system(.caption, design: .monospaced)) | |
| 523 | if let onEditNote { | |
| 524 | Button("Note") { | |
| 525 | onEditNote() | |
| 526 | } | |
| 527 | .buttonStyle(.bordered) | |
| 528 | .font(.system(.caption, design: .monospaced)) | |
| 529 | } | |
| 530 | } | |
| 531 | } else { | |
| 532 | Button("Track") { | |
| 533 | onTrack() | |
| 534 | } | |
| 535 | .buttonStyle(.bordered) | |
| 536 | .font(.system(.caption, design: .monospaced)) | |
| 311 | 537 | } |
| 312 | .buttonStyle(.bordered) | |
| 313 | .font(.system(.caption, design: .monospaced)) | |
| 314 | 538 | } |
| 315 | CardView { | |
| 539 | CardView(allowsHorizontalScroll: false) { | |
| 316 | 540 | ForEach(rows) { row in |
| 317 | 541 | LabeledValueRow(row: row) |
| 318 | 542 | } |
| 543 | if let trackedDomain { | |
| 544 | TrackedDomainDetailHeaderView(trackedDomain: trackedDomain) | |
| 545 | .padding(.top, 4) | |
| 546 | } else if let trackingLimitMessage { | |
| 547 | MessageRowView(text: trackingLimitMessage, isError: false) | |
| 548 | .padding(.top, 4) | |
| 549 | } | |
| 319 | 550 | if availabilityLoading { |
| 320 | 551 | ProgressView("Checking availability…") |
| 321 | 552 | .appLoadingStyle() |
| @@ -888,6 +1119,12 @@ private extension View { | ||
| 888 | 1119 | } |
| 889 | 1120 | } |
| 890 | 1121 | |
| 1122 | private extension String { | |
| 1123 | var nilIfEmpty: String? { | |
| 1124 | isEmpty ? nil : self | |
| 1125 | } | |
| 1126 | } | |
| 1127 | ||
| 891 | 1128 | private struct SettingsView: View { |
| 892 | 1129 | @AppStorage(DNSResolverOption.userDefaultsKey) |
| 893 | 1130 | private var storedResolverURL = DNSResolverOption.defaultURLString |
DomainDig/DomainDiffService.swift added +197
| @@ -0,0 +1,197 @@ | ||
| 1 | import Foundation | |
| 2 | ||
| 3 | enum DiffChangeType: String, Codable { | |
| 4 | case added | |
| 5 | case removed | |
| 6 | case changed | |
| 7 | case unchanged | |
| 8 | } | |
| 9 | ||
| 10 | struct DomainDiffItem: Identifiable, Equatable { | |
| 11 | let id = UUID() | |
| 12 | let label: String | |
| 13 | let changeType: DiffChangeType | |
| 14 | let oldValue: String? | |
| 15 | let newValue: String? | |
| 16 | } | |
| 17 | ||
| 18 | struct DomainDiffSection: Identifiable, Equatable { | |
| 19 | let id = UUID() | |
| 20 | let title: String | |
| 21 | let items: [DomainDiffItem] | |
| 22 | } | |
| 23 | ||
| 24 | enum DomainDiffService { | |
| 25 | static func diff(from oldSnapshot: LookupSnapshot, to newSnapshot: LookupSnapshot) -> [DomainDiffSection] { | |
| 26 | [ | |
| 27 | section(title: "Availability", item: compare( | |
| 28 | label: "Status", | |
| 29 | oldValue: availabilityLabel(oldSnapshot.availabilityResult?.status), | |
| 30 | newValue: availabilityLabel(newSnapshot.availabilityResult?.status) | |
| 31 | )), | |
| 32 | section(title: "Primary IP", item: compare( | |
| 33 | label: "Address", | |
| 34 | oldValue: primaryIP(from: oldSnapshot), | |
| 35 | newValue: primaryIP(from: newSnapshot) | |
| 36 | )), | |
| 37 | dnsSection(from: oldSnapshot, to: newSnapshot), | |
| 38 | section(title: "Redirect", item: compare( | |
| 39 | label: "Final Target", | |
| 40 | oldValue: finalRedirectURL(from: oldSnapshot), | |
| 41 | newValue: finalRedirectURL(from: newSnapshot) | |
| 42 | )), | |
| 43 | tlsSection(from: oldSnapshot, to: newSnapshot), | |
| 44 | httpSection(from: oldSnapshot, to: newSnapshot), | |
| 45 | emailSection(from: oldSnapshot, to: newSnapshot) | |
| 46 | ] | |
| 47 | .filter { !$0.items.isEmpty } | |
| 48 | } | |
| 49 | ||
| 50 | static func summary(from oldSnapshot: LookupSnapshot, to newSnapshot: LookupSnapshot, generatedAt: Date = Date()) -> DomainChangeSummary { | |
| 51 | let changedSections = diff(from: oldSnapshot, to: newSnapshot) | |
| 52 | .filter { $0.items.contains(where: { $0.changeType != .unchanged }) } | |
| 53 | .map(\.title) | |
| 54 | ||
| 55 | return DomainChangeSummary( | |
| 56 | hasChanges: !changedSections.isEmpty, | |
| 57 | changedSections: changedSections, | |
| 58 | generatedAt: generatedAt | |
| 59 | ) | |
| 60 | } | |
| 61 | ||
| 62 | private static func section(title: String, item: DomainDiffItem?) -> DomainDiffSection { | |
| 63 | DomainDiffSection(title: title, items: item.map { [$0] } ?? []) | |
| 64 | } | |
| 65 | ||
| 66 | private static func dnsSection(from oldSnapshot: LookupSnapshot, to newSnapshot: LookupSnapshot) -> DomainDiffSection { | |
| 67 | let oldValue = normalizedDNSSummary(from: oldSnapshot) | |
| 68 | let newValue = normalizedDNSSummary(from: newSnapshot) | |
| 69 | return section(title: "DNS Records", item: compare(label: "Records", oldValue: oldValue, newValue: newValue)) | |
| 70 | } | |
| 71 | ||
| 72 | private static func tlsSection(from oldSnapshot: LookupSnapshot, to newSnapshot: LookupSnapshot) -> DomainDiffSection { | |
| 73 | var items: [DomainDiffItem] = [] | |
| 74 | if let item = compare(label: "Issuer", oldValue: normalized(oldSnapshot.sslInfo?.issuer), newValue: normalized(newSnapshot.sslInfo?.issuer)) { | |
| 75 | items.append(item) | |
| 76 | } | |
| 77 | if let item = compare(label: "Certificate", oldValue: tlsSummary(from: oldSnapshot), newValue: tlsSummary(from: newSnapshot)) { | |
| 78 | items.append(item) | |
| 79 | } | |
| 80 | return DomainDiffSection(title: "TLS Certificate", items: items) | |
| 81 | } | |
| 82 | ||
| 83 | private static func httpSection(from oldSnapshot: LookupSnapshot, to newSnapshot: LookupSnapshot) -> DomainDiffSection { | |
| 84 | var items: [DomainDiffItem] = [] | |
| 85 | if let item = compare(label: "HTTP Status", oldValue: httpStatusSummary(from: oldSnapshot), newValue: httpStatusSummary(from: newSnapshot)) { | |
| 86 | items.append(item) | |
| 87 | } | |
| 88 | if let item = compare(label: "Security Grade", oldValue: normalized(oldSnapshot.httpSecurityGrade), newValue: normalized(newSnapshot.httpSecurityGrade)) { | |
| 89 | items.append(item) | |
| 90 | } | |
| 91 | return DomainDiffSection(title: "HTTP", items: items) | |
| 92 | } | |
| 93 | ||
| 94 | private static func emailSection(from oldSnapshot: LookupSnapshot, to newSnapshot: LookupSnapshot) -> DomainDiffSection { | |
| 95 | section( | |
| 96 | title: "Email Security", | |
| 97 | item: compare( | |
| 98 | label: "Summary", | |
| 99 | oldValue: normalized(emailSummary(from: oldSnapshot)), | |
| 100 | newValue: normalized(emailSummary(from: newSnapshot)) | |
| 101 | ) | |
| 102 | ) | |
| 103 | } | |
| 104 | ||
| 105 | private static func compare(label: String, oldValue: String?, newValue: String?) -> DomainDiffItem? { | |
| 106 | let oldValue = normalized(oldValue) | |
| 107 | let newValue = normalized(newValue) | |
| 108 | ||
| 109 | guard oldValue != nil || newValue != nil else { | |
| 110 | return nil | |
| 111 | } | |
| 112 | ||
| 113 | let changeType: DiffChangeType | |
| 114 | switch (oldValue, newValue) { | |
| 115 | case let (old?, new?) where old == new: | |
| 116 | changeType = .unchanged | |
| 117 | case (nil, _?): | |
| 118 | changeType = .added | |
| 119 | case (_?, nil): | |
| 120 | changeType = .removed | |
| 121 | default: | |
| 122 | changeType = .changed | |
| 123 | } | |
| 124 | ||
| 125 | return DomainDiffItem(label: label, changeType: changeType, oldValue: oldValue, newValue: newValue) | |
| 126 | } | |
| 127 | ||
| 128 | private static func normalized(_ value: String?) -> String? { | |
| 129 | guard let value = value?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else { | |
| 130 | return nil | |
| 131 | } | |
| 132 | return value.lowercased() | |
| 133 | } | |
| 134 | ||
| 135 | private static func availabilityLabel(_ status: DomainAvailabilityStatus?) -> String? { | |
| 136 | switch status { | |
| 137 | case .available: | |
| 138 | return "available" | |
| 139 | case .registered: | |
| 140 | return "registered" | |
| 141 | case .unknown: | |
| 142 | return "unknown" | |
| 143 | case .none: | |
| 144 | return nil | |
| 145 | } | |
| 146 | } | |
| 147 | ||
| 148 | private static func primaryIP(from snapshot: LookupSnapshot) -> String? { | |
| 149 | snapshot.dnsSections.first(where: { $0.recordType == .A })?.records.first?.value | |
| 150 | } | |
| 151 | ||
| 152 | private static func finalRedirectURL(from snapshot: LookupSnapshot) -> String? { | |
| 153 | snapshot.redirectChain.last?.url | |
| 154 | } | |
| 155 | ||
| 156 | private static func tlsSummary(from snapshot: LookupSnapshot) -> String? { | |
| 157 | if let sslInfo = snapshot.sslInfo { | |
| 158 | return "\(sslInfo.commonName) | \(sslInfo.validUntil.formatted(date: .abbreviated, time: .omitted))" | |
| 159 | } | |
| 160 | return snapshot.sslError | |
| 161 | } | |
| 162 | ||
| 163 | private static func httpStatusSummary(from snapshot: LookupSnapshot) -> String? { | |
| 164 | if let httpStatusCode = snapshot.httpStatusCode { | |
| 165 | return "\(httpStatusCode)" | |
| 166 | } | |
| 167 | return snapshot.httpHeadersError | |
| 168 | } | |
| 169 | ||
| 170 | private static func emailSummary(from snapshot: LookupSnapshot) -> String? { | |
| 171 | if let emailSecurity = snapshot.emailSecurity { | |
| 172 | return [ | |
| 173 | "spf:\(emailSecurity.spf.found)", | |
| 174 | "dmarc:\(emailSecurity.dmarc.found)", | |
| 175 | "dkim:\(emailSecurity.dkim.found)", | |
| 176 | "bimi:\(emailSecurity.bimi.found)", | |
| 177 | "mta-sts:\(emailSecurity.mtaSts?.txtFound == true)" | |
| 178 | ].joined(separator: "|") | |
| 179 | } | |
| 180 | return snapshot.emailSecurityError | |
| 181 | } | |
| 182 | ||
| 183 | private static func normalizedDNSSummary(from snapshot: LookupSnapshot) -> String? { | |
| 184 | let parts = snapshot.dnsSections | |
| 185 | .sorted { $0.recordType.rawValue < $1.recordType.rawValue } | |
| 186 | .map { section in | |
| 187 | let values = (section.records + section.wildcardRecords) | |
| 188 | .map(\.value) | |
| 189 | .map { $0.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() } | |
| 190 | .sorted() | |
| 191 | .joined(separator: ",") | |
| 192 | return "\(section.recordType.rawValue):\(values)" | |
| 193 | } | |
| 194 | .filter { !$0.hasSuffix(":") } | |
| 195 | return parts.isEmpty ? nil : parts.joined(separator: "|") | |
| 196 | } | |
| 197 | } | |
DomainDig/DomainViewModel.swift +340 −41
| @@ -80,8 +80,10 @@ struct DomainSuggestionViewData: Identifiable { | ||
| 80 | 80 | } |
| 81 | 81 | |
| 82 | 82 | struct LookupSnapshot { |
| 83 | let historyEntryID: UUID? | |
| 83 | 84 | let domain: String |
| 84 | 85 | let timestamp: Date |
| 86 | let trackedDomainID: UUID? | |
| 85 | 87 | let resolverDisplayName: String |
| 86 | 88 | let resolverURLString: String |
| 87 | 89 | let totalLookupDurationMs: Int? |
| @@ -111,14 +113,17 @@ struct LookupSnapshot { | ||
| 111 | 113 | let redirectChainError: String? |
| 112 | 114 | let portScanResults: [PortScanResult] |
| 113 | 115 | let portScanError: String? |
| 116 | let changeSummary: DomainChangeSummary? | |
| 114 | 117 | let isLive: Bool |
| 115 | 118 | } |
| 116 | 119 | |
| 117 | 120 | extension HistoryEntry { |
| 118 | 121 | var snapshot: LookupSnapshot { |
| 119 | 122 | LookupSnapshot( |
| 123 | historyEntryID: id, | |
| 120 | 124 | domain: domain, |
| 121 | 125 | timestamp: timestamp, |
| 126 | trackedDomainID: trackedDomainID, | |
| 122 | 127 | resolverDisplayName: resolverDisplayName, |
| 123 | 128 | resolverURLString: resolverURLString, |
| 124 | 129 | totalLookupDurationMs: totalLookupDurationMs, |
| @@ -148,6 +153,7 @@ extension HistoryEntry { | ||
| 148 | 153 | redirectChainError: redirectChainError, |
| 149 | 154 | portScanResults: portScanResults, |
| 150 | 155 | portScanError: portScanError, |
| 156 | changeSummary: changeSummary, | |
| 151 | 157 | isLive: false |
| 152 | 158 | ) |
| 153 | 159 | } |
| @@ -211,6 +217,10 @@ final class DomainViewModel { | ||
| 211 | 217 | var hasRun = false |
| 212 | 218 | private(set) var searchedDomain: String = "" |
| 213 | 219 | private(set) var lastLookupDurationMs: Int? |
| 220 | private(set) var currentDiffSections: [DomainDiffSection] = [] | |
| 221 | private(set) var currentChangeSummary: DomainChangeSummary? | |
| 222 | private(set) var refreshingTrackedDomainID: UUID? | |
| 223 | private(set) var rerunNavigationToken = UUID() | |
| 214 | 224 | |
| 215 | 225 | private var lookupTask: Task<Void, Never>? |
| 216 | 226 | private var customPortScanTask: Task<Void, Never>? |
| @@ -224,14 +234,9 @@ final class DomainViewModel { | ||
| 224 | 234 | private static let savedDomainsKey = "savedDomains" |
| 225 | 235 | var savedDomains: [String] = UserDefaults.standard.stringArray(forKey: savedDomainsKey) ?? [] |
| 226 | 236 | |
| 227 | private static let watchedDomainsKey = "watchedDomains" | |
| 228 | var watchedDomains: [WatchedDomain] = { | |
| 229 | guard let data = UserDefaults.standard.data(forKey: watchedDomainsKey), | |
| 230 | let domains = try? JSONDecoder().decode([WatchedDomain].self, from: data) else { | |
| 231 | return [] | |
| 232 | } | |
| 233 | return domains | |
| 234 | }() | |
| 237 | private static let trackedDomainsKey = "trackedDomains" | |
| 238 | private static let legacyWatchedDomainsKey = "watchedDomains" | |
| 239 | var trackedDomains: [TrackedDomain] = DomainViewModel.loadTrackedDomains() | |
| 235 | 240 | |
| 236 | 241 | private static let historyKey = "lookupHistory" |
| 237 | 242 | private static let maxHistory = 50 |
| @@ -276,8 +281,35 @@ final class DomainViewModel { | ||
| 276 | 281 | !searchedDomain.isEmpty && savedDomains.contains(where: { $0.lowercased() == searchedDomain.lowercased() }) |
| 277 | 282 | } |
| 278 | 283 | |
| 279 | var isCurrentDomainWatched: Bool { | |
| 280 | !searchedDomain.isEmpty && watchedDomains.contains(where: { $0.domain.lowercased() == searchedDomain.lowercased() }) | |
| 284 | var sortedTrackedDomains: [TrackedDomain] { | |
| 285 | trackedDomains.sorted { | |
| 286 | if $0.isPinned != $1.isPinned { | |
| 287 | return $0.isPinned && !$1.isPinned | |
| 288 | } | |
| 289 | if $0.updatedAt != $1.updatedAt { | |
| 290 | return $0.updatedAt > $1.updatedAt | |
| 291 | } | |
| 292 | return $0.domain.localizedCaseInsensitiveCompare($1.domain) == .orderedAscending | |
| 293 | } | |
| 294 | } | |
| 295 | ||
| 296 | var currentTrackedDomain: TrackedDomain? { | |
| 297 | guard !searchedDomain.isEmpty else { return nil } | |
| 298 | return trackedDomain(for: searchedDomain) | |
| 299 | } | |
| 300 | ||
| 301 | var isCurrentDomainTracked: Bool { | |
| 302 | currentTrackedDomain != nil | |
| 303 | } | |
| 304 | ||
| 305 | var trackingLimitMessage: String? { | |
| 306 | guard currentTrackedDomain == nil else { return nil } | |
| 307 | guard !PremiumAccessService.canAddTrackedDomain(currentCount: trackedDomains.count) else { return nil } | |
| 308 | return "Free version supports up to 3 tracked domains. More tracked domains will be available in a future Pro upgrade." | |
| 309 | } | |
| 310 | ||
| 311 | var canTrackCurrentDomain: Bool { | |
| 312 | currentTrackedDomain != nil || PremiumAccessService.canAddTrackedDomain(currentCount: trackedDomains.count) | |
| 281 | 313 | } |
| 282 | 314 | |
| 283 | 315 | var resolverDisplayName: String { |
| @@ -299,8 +331,10 @@ final class DomainViewModel { | ||
| 299 | 331 | |
| 300 | 332 | var currentSnapshot: LookupSnapshot { |
| 301 | 333 | LookupSnapshot( |
| 334 | historyEntryID: nil, | |
| 302 | 335 | domain: searchedDomain, |
| 303 | 336 | timestamp: Date(), |
| 337 | trackedDomainID: currentTrackedDomain?.id, | |
| 304 | 338 | resolverDisplayName: resolverDisplayName, |
| 305 | 339 | resolverURLString: resolverURLString, |
| 306 | 340 | totalLookupDurationMs: lastLookupDurationMs, |
| @@ -330,6 +364,7 @@ final class DomainViewModel { | ||
| 330 | 364 | redirectChainError: redirectChainError, |
| 331 | 365 | portScanResults: allPortScanResults, |
| 332 | 366 | portScanError: combinedPortScanError, |
| 367 | changeSummary: currentChangeSummary, | |
| 333 | 368 | isLive: true |
| 334 | 369 | ) |
| 335 | 370 | } |
| @@ -408,31 +443,84 @@ final class DomainViewModel { | ||
| 408 | 443 | UserDefaults.standard.set(savedDomains, forKey: Self.savedDomainsKey) |
| 409 | 444 | } |
| 410 | 445 | |
| 411 | func toggleWatchedDomain() { | |
| 412 | guard !searchedDomain.isEmpty else { return } | |
| 413 | toggleWatchedDomain(domain: searchedDomain, availabilityStatus: availabilityResult?.status) | |
| 446 | @discardableResult | |
| 447 | func trackCurrentDomain() -> Bool { | |
| 448 | guard !searchedDomain.isEmpty else { return false } | |
| 449 | return trackDomain(domain: searchedDomain, availabilityStatus: availabilityResult?.status) | |
| 414 | 450 | } |
| 415 | 451 | |
| 416 | func toggleWatchedDomain(domain: String, availabilityStatus: DomainAvailabilityStatus?) { | |
| 417 | guard !domain.isEmpty else { return } | |
| 452 | @discardableResult | |
| 453 | func trackDomain(domain: String, availabilityStatus: DomainAvailabilityStatus?) -> Bool { | |
| 454 | let normalizedDomain = normalizedDomain(domain) | |
| 455 | guard !normalizedDomain.isEmpty else { return false } | |
| 418 | 456 | |
| 419 | if watchedDomains.contains(where: { $0.domain.lowercased() == domain.lowercased() }) { | |
| 420 | watchedDomains.removeAll { $0.domain.lowercased() == domain.lowercased() } | |
| 421 | } else { | |
| 422 | watchedDomains.insert( | |
| 423 | WatchedDomain( | |
| 424 | domain: domain, | |
| 425 | lastKnownAvailability: availabilityStatus | |
| 426 | ), | |
| 427 | at: 0 | |
| 428 | ) | |
| 457 | if trackedDomain(for: normalizedDomain) != nil { | |
| 458 | return true | |
| 429 | 459 | } |
| 430 | persistWatchedDomains() | |
| 460 | ||
| 461 | guard PremiumAccessService.canAddTrackedDomain(currentCount: trackedDomains.count) else { | |
| 462 | return false | |
| 463 | } | |
| 464 | ||
| 465 | trackedDomains.insert( | |
| 466 | TrackedDomain( | |
| 467 | domain: normalizedDomain, | |
| 468 | createdAt: Date(), | |
| 469 | updatedAt: Date(), | |
| 470 | lastKnownAvailability: availabilityStatus | |
| 471 | ), | |
| 472 | at: 0 | |
| 473 | ) | |
| 474 | persistTrackedDomains() | |
| 475 | linkTrackedDomainHistory(for: normalizedDomain) | |
| 476 | return true | |
| 431 | 477 | } |
| 432 | 478 | |
| 433 | func removeWatchedDomains(at offsets: IndexSet) { | |
| 434 | watchedDomains.remove(atOffsets: offsets) | |
| 435 | persistWatchedDomains() | |
| 479 | func refreshTrackedDomain(_ trackedDomain: TrackedDomain) { | |
| 480 | refreshingTrackedDomainID = trackedDomain.id | |
| 481 | domain = trackedDomain.domain | |
| 482 | run() | |
| 483 | } | |
| 484 | ||
| 485 | func rerunInspection(for trackedDomain: TrackedDomain) { | |
| 486 | domain = trackedDomain.domain | |
| 487 | run() | |
| 488 | rerunNavigationToken = UUID() | |
| 489 | } | |
| 490 | ||
| 491 | func deleteTrackedDomains(at offsets: IndexSet) { | |
| 492 | let ids = offsets.map { sortedTrackedDomains[$0].id } | |
| 493 | trackedDomains.removeAll { ids.contains($0.id) } | |
| 494 | history.indices.forEach { index in | |
| 495 | if let trackedDomainID = history[index].trackedDomainID, ids.contains(trackedDomainID) { | |
| 496 | history[index].trackedDomainID = nil | |
| 497 | } | |
| 498 | } | |
| 499 | persistTrackedDomains() | |
| 500 | persistHistory() | |
| 501 | } | |
| 502 | ||
| 503 | func deleteTrackedDomain(_ trackedDomain: TrackedDomain) { | |
| 504 | trackedDomains.removeAll { $0.id == trackedDomain.id } | |
| 505 | history.indices.forEach { index in | |
| 506 | if history[index].trackedDomainID == trackedDomain.id { | |
| 507 | history[index].trackedDomainID = nil | |
| 508 | } | |
| 509 | } | |
| 510 | persistTrackedDomains() | |
| 511 | persistHistory() | |
| 512 | } | |
| 513 | ||
| 514 | func togglePinned(for trackedDomain: TrackedDomain) { | |
| 515 | guard let index = trackedDomains.firstIndex(where: { $0.id == trackedDomain.id }) else { return } | |
| 516 | trackedDomains[index].isPinned.toggle() | |
| 517 | persistTrackedDomains() | |
| 518 | } | |
| 519 | ||
| 520 | func updateNote(_ note: String, for trackedDomain: TrackedDomain) { | |
| 521 | guard let index = trackedDomains.firstIndex(where: { $0.id == trackedDomain.id }) else { return } | |
| 522 | trackedDomains[index].note = note.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty | |
| 523 | persistTrackedDomains() | |
| 436 | 524 | } |
| 437 | 525 | |
| 438 | 526 | func removeHistoryEntries(at offsets: IndexSet) { |
| @@ -440,6 +528,11 @@ final class DomainViewModel { | ||
| 440 | 528 | persistHistory() |
| 441 | 529 | } |
| 442 | 530 | |
| 531 | func clearHistory() { | |
| 532 | history.removeAll() | |
| 533 | persistHistory() | |
| 534 | } | |
| 535 | ||
| 443 | 536 | func clearRecentSearches() { |
| 444 | 537 | recentSearches.removeAll() |
| 445 | 538 | UserDefaults.standard.removeObject(forKey: Self.recentSearchesKey) |
| @@ -449,6 +542,7 @@ final class DomainViewModel { | ||
| 449 | 542 | UserDefaults.standard.set(entry.resolverURLString, forKey: DNSResolverOption.userDefaultsKey) |
| 450 | 543 | domain = entry.domain |
| 451 | 544 | run() |
| 545 | rerunNavigationToken = UUID() | |
| 452 | 546 | } |
| 453 | 547 | |
| 454 | 548 | func reset() { |
| @@ -457,6 +551,9 @@ final class DomainViewModel { | ||
| 457 | 551 | hasRun = false |
| 458 | 552 | searchedDomain = "" |
| 459 | 553 | lastLookupDurationMs = nil |
| 554 | currentDiffSections = [] | |
| 555 | currentChangeSummary = nil | |
| 556 | refreshingTrackedDomainID = nil | |
| 460 | 557 | clearLookupState() |
| 461 | 558 | } |
| 462 | 559 | |
| @@ -474,6 +571,8 @@ final class DomainViewModel { | ||
| 474 | 571 | addRecentSearch(target) |
| 475 | 572 | searchedDomain = target |
| 476 | 573 | hasRun = true |
| 574 | currentDiffSections = [] | |
| 575 | currentChangeSummary = nil | |
| 477 | 576 | clearLookupState() |
| 478 | 577 | setAllLoadingStates(true) |
| 479 | 578 | customPortScanLoading = false |
| @@ -513,7 +612,12 @@ final class DomainViewModel { | ||
| 513 | 612 | } |
| 514 | 613 | |
| 515 | 614 | func exportText() -> String { |
| 516 | Self.formatExportText(from: currentSnapshot) | |
| 615 | Self.formatExportText( | |
| 616 | from: currentSnapshot, | |
| 617 | trackedDomain: currentTrackedDomain, | |
| 618 | changeSummary: currentChangeSummary, | |
| 619 | diffSections: currentDiffSections | |
| 620 | ) | |
| 517 | 621 | } |
| 518 | 622 | |
| 519 | 623 | private func performLookup(domain: String, lookupID: UUID) async { |
| @@ -555,6 +659,7 @@ final class DomainViewModel { | ||
| 555 | 659 | guard !Task.isCancelled, isCurrentLookup(lookupID) else { return } |
| 556 | 660 | lastLookupDurationMs = lookupStartedAt.map { Int(Date().timeIntervalSince($0) * 1000) } |
| 557 | 661 | saveHistoryEntry(replaceLatest: false) |
| 662 | refreshingTrackedDomainID = nil | |
| 558 | 663 | } |
| 559 | 664 | |
| 560 | 665 | private func runDNS(domain: String, lookupID: UUID) async { |
| @@ -579,7 +684,7 @@ final class DomainViewModel { | ||
| 579 | 684 | guard !Task.isCancelled, isCurrentLookup(lookupID) else { return } |
| 580 | 685 | availabilityResult = result |
| 581 | 686 | availabilityLoading = false |
| 582 | updateWatchedDomainAvailability(for: result.domain, status: result.status) | |
| 687 | updateTrackedDomainAvailability(for: result.domain, status: result.status) | |
| 583 | 688 | } |
| 584 | 689 | |
| 585 | 690 | private func runSSL(domain: String, lookupID: UUID) async { |
| @@ -801,9 +906,20 @@ final class DomainViewModel { | ||
| 801 | 906 | |
| 802 | 907 | private func saveHistoryEntry(replaceLatest: Bool) { |
| 803 | 908 | guard !searchedDomain.isEmpty else { return } |
| 909 | ||
| 910 | let trackedDomainID = trackedDomain(for: searchedDomain)?.id | |
| 911 | let timestamp = Date() | |
| 912 | let snapshot = currentSnapshot | |
| 913 | let previousSnapshot = previousSnapshot(for: searchedDomain, trackedDomainID: trackedDomainID, replacingLatest: replaceLatest) | |
| 914 | let changeSummary = previousSnapshot.map { DomainDiffService.summary(from: $0, to: snapshot, generatedAt: timestamp) } | |
| 915 | ||
| 916 | currentChangeSummary = changeSummary | |
| 917 | currentDiffSections = previousSnapshot.map { DomainDiffService.diff(from: $0, to: snapshot) } ?? [] | |
| 918 | ||
| 804 | 919 | let entry = HistoryEntry( |
| 805 | 920 | domain: searchedDomain, |
| 806 | timestamp: Date(), | |
| 921 | timestamp: timestamp, | |
| 922 | trackedDomainID: trackedDomainID, | |
| 807 | 923 | dnsSections: dnsSections, |
| 808 | 924 | sslInfo: sslInfo, |
| 809 | 925 | httpHeaders: httpHeaders, |
| @@ -820,6 +936,12 @@ final class DomainViewModel { | ||
| 820 | 936 | resolverDisplayName: resolverDisplayName, |
| 821 | 937 | resolverURLString: resolverURLString, |
| 822 | 938 | totalLookupDurationMs: lastLookupDurationMs, |
| 939 | primaryIP: Self.primaryIPAddress(from: snapshot), | |
| 940 | finalRedirectURL: Self.finalRedirectTarget(from: snapshot), | |
| 941 | tlsStatusSummary: Self.httpsSummary(from: snapshot), | |
| 942 | emailSecuritySummary: Self.emailSummary(from: snapshot), | |
| 943 | httpGradeSummary: snapshot.httpSecurityGrade ?? snapshot.httpHeadersError, | |
| 944 | changeSummary: changeSummary, | |
| 823 | 945 | sslError: sslError, |
| 824 | 946 | httpHeadersError: httpHeadersError, |
| 825 | 947 | reachabilityError: reachabilityError, |
| @@ -838,6 +960,14 @@ final class DomainViewModel { | ||
| 838 | 960 | history = Array(history.prefix(Self.maxHistory)) |
| 839 | 961 | } |
| 840 | 962 | } |
| 963 | ||
| 964 | updateTrackedDomainSnapshotMetadata( | |
| 965 | domain: searchedDomain, | |
| 966 | snapshotID: entry.id, | |
| 967 | availabilityStatus: availabilityResult?.status, | |
| 968 | updatedAt: timestamp, | |
| 969 | changeSummary: changeSummary | |
| 970 | ) | |
| 841 | 971 | persistHistory() |
| 842 | 972 | } |
| 843 | 973 | |
| @@ -847,18 +977,82 @@ final class DomainViewModel { | ||
| 847 | 977 | } |
| 848 | 978 | } |
| 849 | 979 | |
| 850 | private func persistWatchedDomains() { | |
| 851 | if let data = try? JSONEncoder().encode(watchedDomains) { | |
| 852 | UserDefaults.standard.set(data, forKey: Self.watchedDomainsKey) | |
| 980 | private func persistTrackedDomains() { | |
| 981 | if let data = try? JSONEncoder().encode(trackedDomains) { | |
| 982 | UserDefaults.standard.set(data, forKey: Self.trackedDomainsKey) | |
| 853 | 983 | } |
| 854 | 984 | } |
| 855 | 985 | |
| 856 | private func updateWatchedDomainAvailability(for domain: String, status: DomainAvailabilityStatus) { | |
| 857 | guard let index = watchedDomains.firstIndex(where: { $0.domain.lowercased() == domain.lowercased() }) else { | |
| 986 | private func updateTrackedDomainAvailability(for domain: String, status: DomainAvailabilityStatus) { | |
| 987 | guard let index = trackedDomains.firstIndex(where: { $0.domain.caseInsensitiveCompare(domain) == .orderedSame }) else { | |
| 858 | 988 | return |
| 859 | 989 | } |
| 860 | watchedDomains[index].lastKnownAvailability = status | |
| 861 | persistWatchedDomains() | |
| 990 | trackedDomains[index].lastKnownAvailability = status | |
| 991 | persistTrackedDomains() | |
| 992 | } | |
| 993 | ||
| 994 | private func updateTrackedDomainSnapshotMetadata( | |
| 995 | domain: String, | |
| 996 | snapshotID: UUID, | |
| 997 | availabilityStatus: DomainAvailabilityStatus?, | |
| 998 | updatedAt: Date, | |
| 999 | changeSummary: DomainChangeSummary? | |
| 1000 | ) { | |
| 1001 | guard let index = trackedDomains.firstIndex(where: { $0.domain.caseInsensitiveCompare(domain) == .orderedSame }) else { | |
| 1002 | return | |
| 1003 | } | |
| 1004 | trackedDomains[index].lastSnapshotID = snapshotID | |
| 1005 | trackedDomains[index].lastKnownAvailability = availabilityStatus | |
| 1006 | trackedDomains[index].updatedAt = updatedAt | |
| 1007 | trackedDomains[index].lastChangeSummary = changeSummary | |
| 1008 | persistTrackedDomains() | |
| 1009 | } | |
| 1010 | ||
| 1011 | private func previousSnapshot(for domain: String, trackedDomainID: UUID?, replacingLatest: Bool) -> LookupSnapshot? { | |
| 1012 | let matchingEntries = history.filter { entry in | |
| 1013 | if let trackedDomainID { | |
| 1014 | return entry.trackedDomainID == trackedDomainID | |
| 1015 | } | |
| 1016 | return entry.domain.caseInsensitiveCompare(domain) == .orderedSame | |
| 1017 | } | |
| 1018 | ||
| 1019 | if replacingLatest { | |
| 1020 | return matchingEntries.dropFirst().first?.snapshot | |
| 1021 | } | |
| 1022 | return matchingEntries.first?.snapshot | |
| 1023 | } | |
| 1024 | ||
| 1025 | private func trackedDomain(for domain: String) -> TrackedDomain? { | |
| 1026 | trackedDomains.first { $0.domain.caseInsensitiveCompare(domain) == .orderedSame } | |
| 1027 | } | |
| 1028 | ||
| 1029 | private func normalizedDomain(_ domain: String) -> String { | |
| 1030 | domain.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() | |
| 1031 | } | |
| 1032 | ||
| 1033 | private func linkTrackedDomainHistory(for domain: String) { | |
| 1034 | guard let trackedDomain = trackedDomain(for: domain) else { return } | |
| 1035 | var didChange = false | |
| 1036 | ||
| 1037 | for index in history.indices where history[index].domain.caseInsensitiveCompare(domain) == .orderedSame { | |
| 1038 | if history[index].trackedDomainID != trackedDomain.id { | |
| 1039 | history[index].trackedDomainID = trackedDomain.id | |
| 1040 | didChange = true | |
| 1041 | } | |
| 1042 | } | |
| 1043 | ||
| 1044 | if didChange { | |
| 1045 | persistHistory() | |
| 1046 | } | |
| 1047 | ||
| 1048 | if let latestEntry = history.first(where: { $0.domain.caseInsensitiveCompare(domain) == .orderedSame }), | |
| 1049 | let trackedIndex = trackedDomains.firstIndex(where: { $0.id == trackedDomain.id }) { | |
| 1050 | trackedDomains[trackedIndex].lastSnapshotID = latestEntry.id | |
| 1051 | trackedDomains[trackedIndex].lastChangeSummary = latestEntry.changeSummary | |
| 1052 | trackedDomains[trackedIndex].lastKnownAvailability = latestEntry.availabilityResult?.status | |
| 1053 | trackedDomains[trackedIndex].updatedAt = latestEntry.timestamp | |
| 1054 | persistTrackedDomains() | |
| 1055 | } | |
| 862 | 1056 | } |
| 863 | 1057 | |
| 864 | 1058 | private func addRecentSearch(_ domain: String) { |
| @@ -937,6 +1131,71 @@ final class DomainViewModel { | ||
| 937 | 1131 | activeLookupID == lookupID |
| 938 | 1132 | } |
| 939 | 1133 | |
| 1134 | func recentSnapshots(for trackedDomain: TrackedDomain, limit: Int = 6) -> [HistoryEntry] { | |
| 1135 | history | |
| 1136 | .filter { $0.trackedDomainID == trackedDomain.id || $0.domain.caseInsensitiveCompare(trackedDomain.domain) == .orderedSame } | |
| 1137 | .sorted { $0.timestamp > $1.timestamp } | |
| 1138 | .prefix(limit) | |
| 1139 | .map { $0 } | |
| 1140 | } | |
| 1141 | ||
| 1142 | func diffSectionsForLatestSnapshots(of trackedDomain: TrackedDomain) -> [DomainDiffSection] { | |
| 1143 | let snapshots = recentSnapshots(for: trackedDomain, limit: 2) | |
| 1144 | guard snapshots.count == 2 else { return [] } | |
| 1145 | return DomainDiffService.diff(from: snapshots[1].snapshot, to: snapshots[0].snapshot) | |
| 1146 | } | |
| 1147 | ||
| 1148 | func latestChangeSummary(for trackedDomain: TrackedDomain) -> DomainChangeSummary? { | |
| 1149 | trackedDomain.lastChangeSummary ?? recentSnapshots(for: trackedDomain, limit: 1).first?.changeSummary | |
| 1150 | } | |
| 1151 | ||
| 1152 | func comparisonSnapshot(for entry: HistoryEntry) -> LookupSnapshot? { | |
| 1153 | let siblings = history.filter { candidate in | |
| 1154 | if let trackedDomainID = entry.trackedDomainID { | |
| 1155 | return candidate.trackedDomainID == trackedDomainID && candidate.id != entry.id | |
| 1156 | } | |
| 1157 | return candidate.domain.caseInsensitiveCompare(entry.domain) == .orderedSame && candidate.id != entry.id | |
| 1158 | } | |
| 1159 | .sorted { $0.timestamp > $1.timestamp } | |
| 1160 | ||
| 1161 | return siblings.first?.snapshot | |
| 1162 | } | |
| 1163 | ||
| 1164 | private static func loadTrackedDomains() -> [TrackedDomain] { | |
| 1165 | let defaults = UserDefaults.standard | |
| 1166 | let decoder = JSONDecoder() | |
| 1167 | ||
| 1168 | if let data = defaults.data(forKey: trackedDomainsKey), | |
| 1169 | let domains = try? decoder.decode([TrackedDomain].self, from: data) { | |
| 1170 | return deduplicatedTrackedDomains(domains) | |
| 1171 | } | |
| 1172 | ||
| 1173 | if let legacyData = defaults.data(forKey: legacyWatchedDomainsKey), | |
| 1174 | let legacyDomains = try? decoder.decode([WatchedDomain].self, from: legacyData) { | |
| 1175 | return deduplicatedTrackedDomains( | |
| 1176 | legacyDomains.map { | |
| 1177 | TrackedDomain( | |
| 1178 | id: $0.id, | |
| 1179 | domain: $0.domain.lowercased(), | |
| 1180 | createdAt: $0.createdAt, | |
| 1181 | updatedAt: $0.createdAt, | |
| 1182 | lastKnownAvailability: $0.lastKnownAvailability | |
| 1183 | ) | |
| 1184 | } | |
| 1185 | ) | |
| 1186 | } | |
| 1187 | ||
| 1188 | return [] | |
| 1189 | } | |
| 1190 | ||
| 1191 | private static func deduplicatedTrackedDomains(_ domains: [TrackedDomain]) -> [TrackedDomain] { | |
| 1192 | var seen = Set<String>() | |
| 1193 | return domains.filter { domain in | |
| 1194 | let key = domain.domain.lowercased() | |
| 1195 | return seen.insert(key).inserted | |
| 1196 | } | |
| 1197 | } | |
| 1198 | ||
| 940 | 1199 | static func summaryFields(from snapshot: LookupSnapshot) -> [SummaryFieldViewData] { |
| 941 | 1200 | [ |
| 942 | 1201 | SummaryFieldViewData(label: "Domain", value: snapshot.domain.nonEmpty ?? "Unavailable", tone: .primary), |
| @@ -1110,7 +1369,12 @@ final class DomainViewModel { | ||
| 1110 | 1369 | } |
| 1111 | 1370 | } |
| 1112 | 1371 | |
| 1113 | static func formatExportText(from snapshot: LookupSnapshot) -> String { | |
| 1372 | static func formatExportText( | |
| 1373 | from snapshot: LookupSnapshot, | |
| 1374 | trackedDomain: TrackedDomain?, | |
| 1375 | changeSummary: DomainChangeSummary?, | |
| 1376 | diffSections: [DomainDiffSection] | |
| 1377 | ) -> String { | |
| 1114 | 1378 | let exportDateFormatter = DateFormatter() |
| 1115 | 1379 | exportDateFormatter.dateFormat = "yyyy-MM-dd HH:mm" |
| 1116 | 1380 | |
| @@ -1120,9 +1384,14 @@ final class DomainViewModel { | ||
| 1120 | 1384 | "Date: \(exportDateFormatter.string(from: snapshot.timestamp))", |
| 1121 | 1385 | "Mode: \(snapshot.isLive ? "Live" : "Snapshot")", |
| 1122 | 1386 | "Resolver: \(snapshot.resolverDisplayName)", |
| 1123 | "Lookup Duration: \(durationLabel(snapshot.totalLookupDurationMs))" | |
| 1387 | "Lookup Duration: \(durationLabel(snapshot.totalLookupDurationMs))", | |
| 1388 | "Tracked: \(trackedDomain == nil ? "No" : "Yes")" | |
| 1124 | 1389 | ] |
| 1125 | 1390 | |
| 1391 | if let note = trackedDomain?.note?.nilIfEmpty { | |
| 1392 | lines.append("Tracking Note: \(note)") | |
| 1393 | } | |
| 1394 | ||
| 1126 | 1395 | func appendSection(_ title: String, body: () -> Void) { |
| 1127 | 1396 | lines.append("") |
| 1128 | 1397 | lines.append(title) |
| @@ -1134,6 +1403,36 @@ final class DomainViewModel { | ||
| 1134 | 1403 | for item in summaryFields(from: snapshot) { |
| 1135 | 1404 | lines.append(" \(item.label): \(item.value)") |
| 1136 | 1405 | } |
| 1406 | if let changeSummary { | |
| 1407 | lines.append(" Change Status: \(changeSummary.hasChanges ? "Changed" : "Unchanged")") | |
| 1408 | lines.append(" Changed Sections: \(changeSummary.changedSections.isEmpty ? "None" : changeSummary.changedSections.joined(separator: ", "))") | |
| 1409 | } | |
| 1410 | } | |
| 1411 | ||
| 1412 | appendSection("Tracking") { | |
| 1413 | if let trackedDomain { | |
| 1414 | lines.append(" Pinned: \(trackedDomain.isPinned ? "Yes" : "No")") | |
| 1415 | lines.append(" Last Refresh: \(exportDateFormatter.string(from: trackedDomain.updatedAt))") | |
| 1416 | lines.append(" Last Known Availability: \(availabilityLabel(trackedDomain.lastKnownAvailability))") | |
| 1417 | if let note = trackedDomain.note?.nilIfEmpty { | |
| 1418 | lines.append(" Note: \(note)") | |
| 1419 | } | |
| 1420 | } else { | |
| 1421 | lines.append(" This domain is not currently tracked.") | |
| 1422 | } | |
| 1423 | } | |
| 1424 | ||
| 1425 | appendSection("Diff Summary") { | |
| 1426 | if diffSections.isEmpty { | |
| 1427 | lines.append(" No comparison available") | |
| 1428 | } else { | |
| 1429 | for section in diffSections where section.items.contains(where: { $0.changeType != .unchanged }) { | |
| 1430 | lines.append(" \(section.title)") | |
| 1431 | for item in section.items where item.changeType != .unchanged { | |
| 1432 | lines.append(" \(item.label): \(item.oldValue ?? "None") -> \(item.newValue ?? "None")") | |
| 1433 | } | |
| 1434 | } | |
| 1435 | } | |
| 1137 | 1436 | } |
| 1138 | 1437 | |
| 1139 | 1438 | appendSection("Domain") { |
DomainDig/HistoryView.swift +50 −5
| @@ -2,6 +2,8 @@ import SwiftUI | ||
| 2 | 2 | |
| 3 | 3 | struct HistoryView: View { |
| 4 | 4 | @Bindable var viewModel: DomainViewModel |
| 5 | @Environment(\.dismiss) private var dismiss | |
| 6 | @State private var showClearAllConfirmation = false | |
| 5 | 7 | |
| 6 | 8 | private let dateFormatter: DateFormatter = { |
| 7 | 9 | let formatter = DateFormatter() |
| @@ -50,8 +52,29 @@ struct HistoryView: View { | ||
| 50 | 52 | .navigationTitle("History") |
| 51 | 53 | .toolbar { |
| 52 | 54 | if !viewModel.history.isEmpty { |
| 53 | EditButton() | |
| 55 | ToolbarItemGroup(placement: .topBarTrailing) { | |
| 56 | Menu { | |
| 57 | Button("Clear All", role: .destructive) { | |
| 58 | showClearAllConfirmation = true | |
| 59 | } | |
| 60 | } label: { | |
| 61 | Image(systemName: "ellipsis.circle") | |
| 62 | } | |
| 63 | ||
| 64 | EditButton() | |
| 65 | } | |
| 66 | } | |
| 67 | } | |
| 68 | .alert("Clear history?", isPresented: $showClearAllConfirmation) { | |
| 69 | Button("Clear All", role: .destructive) { | |
| 70 | viewModel.clearHistory() | |
| 54 | 71 | } |
| 72 | Button("Cancel", role: .cancel) {} | |
| 73 | } message: { | |
| 74 | Text("This will delete all saved history entries. This cannot be undone.") | |
| 75 | } | |
| 76 | .onChange(of: viewModel.rerunNavigationToken) { _, _ in | |
| 77 | dismiss() | |
| 55 | 78 | } |
| 56 | 79 | .preferredColorScheme(.dark) |
| 57 | 80 | } |
| @@ -60,6 +83,7 @@ struct HistoryView: View { | ||
| 60 | 83 | struct HistoryDetailView: View { |
| 61 | 84 | @Bindable var viewModel: DomainViewModel |
| 62 | 85 | let entry: HistoryEntry |
| 86 | @Environment(\.dismiss) private var dismiss | |
| 63 | 87 | |
| 64 | 88 | private let dateFormatter: DateFormatter = { |
| 65 | 89 | let formatter = DateFormatter() |
| @@ -84,12 +108,30 @@ struct HistoryDetailView: View { | ||
| 84 | 108 | showSuggestions: entry.availabilityResult?.status == .registered && !entry.suggestions.isEmpty, |
| 85 | 109 | availabilityLoading: false, |
| 86 | 110 | suggestionsLoading: false, |
| 87 | isWatched: viewModel.watchedDomains.contains(where: { $0.domain.lowercased() == entry.domain.lowercased() }), | |
| 88 | onToggleWatch: { | |
| 89 | viewModel.toggleWatchedDomain(domain: entry.domain, availabilityStatus: entry.availabilityResult?.status) | |
| 90 | } | |
| 111 | trackedDomain: viewModel.trackedDomains.first(where: { $0.domain.lowercased() == entry.domain.lowercased() }), | |
| 112 | trackingLimitMessage: nil, | |
| 113 | onTrack: { | |
| 114 | _ = viewModel.trackDomain(domain: entry.domain, availabilityStatus: entry.availabilityResult?.status) | |
| 115 | }, | |
| 116 | onTogglePinned: { | |
| 117 | guard let trackedDomain = viewModel.trackedDomains.first(where: { $0.domain.lowercased() == entry.domain.lowercased() }) else { return } | |
| 118 | viewModel.togglePinned(for: trackedDomain) | |
| 119 | }, | |
| 120 | onEditNote: nil | |
| 91 | 121 | ) |
| 92 | 122 | .padding(.top, 16) |
| 123 | if let comparisonSnapshot = viewModel.comparisonSnapshot(for: entry) { | |
| 124 | if let changeSummary = entry.changeSummary { | |
| 125 | DomainChangeSummaryView(summary: changeSummary) | |
| 126 | .padding(.top, 16) | |
| 127 | } | |
| 128 | DomainDiffView( | |
| 129 | title: "Compared With Previous Snapshot", | |
| 130 | sections: DomainDiffService.diff(from: comparisonSnapshot, to: snapshot), | |
| 131 | showsUnchanged: false | |
| 132 | ) | |
| 133 | .padding(.top, 16) | |
| 134 | } | |
| 93 | 135 | DNSSectionView( |
| 94 | 136 | dnssecLabel: DomainViewModel.dnssecLabel(from: snapshot), |
| 95 | 137 | sections: DomainViewModel.dnsRows(from: snapshot), |
| @@ -150,6 +192,9 @@ struct HistoryDetailView: View { | ||
| 150 | 192 | viewModel.rerunLookup(from: entry) |
| 151 | 193 | } |
| 152 | 194 | } |
| 195 | .onChange(of: viewModel.rerunNavigationToken) { _, _ in | |
| 196 | dismiss() | |
| 197 | } | |
| 153 | 198 | .preferredColorScheme(.dark) |
| 154 | 199 | } |
| 155 | 200 | |
DomainDig/Models.swift +73 −2
| @@ -48,6 +48,54 @@ struct WatchedDomain: Codable, Identifiable { | ||
| 48 | 48 | } |
| 49 | 49 | } |
| 50 | 50 | |
| 51 | struct DomainChangeSummary: Codable, Equatable { | |
| 52 | let hasChanges: Bool | |
| 53 | let changedSections: [String] | |
| 54 | let generatedAt: Date | |
| 55 | } | |
| 56 | ||
| 57 | enum PremiumCapability: String, Codable { | |
| 58 | case unlimitedTrackedDomains | |
| 59 | case automatedMonitoring | |
| 60 | case pushAlerts | |
| 61 | case batchTracking | |
| 62 | case advancedExports | |
| 63 | } | |
| 64 | ||
| 65 | struct TrackedDomain: Codable, Identifiable, Equatable { | |
| 66 | let id: UUID | |
| 67 | var domain: String | |
| 68 | var createdAt: Date | |
| 69 | var updatedAt: Date | |
| 70 | var note: String? | |
| 71 | var isPinned: Bool | |
| 72 | var lastKnownAvailability: DomainAvailabilityStatus? | |
| 73 | var lastSnapshotID: UUID? | |
| 74 | var lastChangeSummary: DomainChangeSummary? | |
| 75 | ||
| 76 | init( | |
| 77 | id: UUID = UUID(), | |
| 78 | domain: String, | |
| 79 | createdAt: Date = Date(), | |
| 80 | updatedAt: Date = Date(), | |
| 81 | note: String? = nil, | |
| 82 | isPinned: Bool = false, | |
| 83 | lastKnownAvailability: DomainAvailabilityStatus? = nil, | |
| 84 | lastSnapshotID: UUID? = nil, | |
| 85 | lastChangeSummary: DomainChangeSummary? = nil | |
| 86 | ) { | |
| 87 | self.id = id | |
| 88 | self.domain = domain | |
| 89 | self.createdAt = createdAt | |
| 90 | self.updatedAt = updatedAt | |
| 91 | self.note = note | |
| 92 | self.isPinned = isPinned | |
| 93 | self.lastKnownAvailability = lastKnownAvailability | |
| 94 | self.lastSnapshotID = lastSnapshotID | |
| 95 | self.lastChangeSummary = lastChangeSummary | |
| 96 | } | |
| 97 | } | |
| 98 | ||
| 51 | 99 | // MARK: - DNS Models |
| 52 | 100 | |
| 53 | 101 | enum DNSRecordType: String, CaseIterable, Codable { |
| @@ -321,6 +369,7 @@ struct HistoryEntry: Identifiable, Codable { | ||
| 321 | 369 | var id = UUID() |
| 322 | 370 | let domain: String |
| 323 | 371 | let timestamp: Date |
| 372 | var trackedDomainID: UUID? | |
| 324 | 373 | let dnsSections: [DNSSection] |
| 325 | 374 | let sslInfo: SSLCertificateInfo? |
| 326 | 375 | let httpHeaders: [HTTPHeader] |
| @@ -337,6 +386,12 @@ struct HistoryEntry: Identifiable, Codable { | ||
| 337 | 386 | var resolverDisplayName: String |
| 338 | 387 | var resolverURLString: String |
| 339 | 388 | var totalLookupDurationMs: Int? |
| 389 | var primaryIP: String? | |
| 390 | var finalRedirectURL: String? | |
| 391 | var tlsStatusSummary: String? | |
| 392 | var emailSecuritySummary: String? | |
| 393 | var httpGradeSummary: String? | |
| 394 | var changeSummary: DomainChangeSummary? | |
| 340 | 395 | var sslError: String? |
| 341 | 396 | var httpHeadersError: String? |
| 342 | 397 | var reachabilityError: String? |
| @@ -346,19 +401,22 @@ struct HistoryEntry: Identifiable, Codable { | ||
| 346 | 401 | var redirectChainError: String? |
| 347 | 402 | var portScanError: String? |
| 348 | 403 | |
| 349 | init(domain: String, timestamp: Date, dnsSections: [DNSSection], | |
| 404 | init(domain: String, timestamp: Date, trackedDomainID: UUID? = nil, dnsSections: [DNSSection], | |
| 350 | 405 | sslInfo: SSLCertificateInfo?, httpHeaders: [HTTPHeader], |
| 351 | 406 | reachabilityResults: [PortReachability], ipGeolocation: IPGeolocation?, |
| 352 | 407 | emailSecurity: EmailSecurityResult? = nil, mtaSts: MTASTSResult? = nil, ptrRecord: String? = nil, |
| 353 | 408 | redirectChain: [RedirectHop] = [], portScanResults: [PortScanResult] = [], |
| 354 | 409 | hstsPreloaded: Bool? = nil, availabilityResult: DomainAvailabilityResult? = nil, |
| 355 | 410 | suggestions: [DomainSuggestionResult] = [], resolverDisplayName: String, resolverURLString: String, |
| 356 | totalLookupDurationMs: Int? = nil, sslError: String? = nil, httpHeadersError: String? = nil, | |
| 411 | totalLookupDurationMs: Int? = nil, primaryIP: String? = nil, finalRedirectURL: String? = nil, | |
| 412 | tlsStatusSummary: String? = nil, emailSecuritySummary: String? = nil, httpGradeSummary: String? = nil, | |
| 413 | changeSummary: DomainChangeSummary? = nil, sslError: String? = nil, httpHeadersError: String? = nil, | |
| 357 | 414 | reachabilityError: String? = nil, ipGeolocationError: String? = nil, |
| 358 | 415 | emailSecurityError: String? = nil, ptrError: String? = nil, |
| 359 | 416 | redirectChainError: String? = nil, portScanError: String? = nil) { |
| 360 | 417 | self.domain = domain |
| 361 | 418 | self.timestamp = timestamp |
| 419 | self.trackedDomainID = trackedDomainID | |
| 362 | 420 | self.dnsSections = dnsSections |
| 363 | 421 | self.sslInfo = sslInfo |
| 364 | 422 | self.httpHeaders = httpHeaders |
| @@ -375,6 +433,12 @@ struct HistoryEntry: Identifiable, Codable { | ||
| 375 | 433 | self.resolverDisplayName = resolverDisplayName |
| 376 | 434 | self.resolverURLString = resolverURLString |
| 377 | 435 | self.totalLookupDurationMs = totalLookupDurationMs |
| 436 | self.primaryIP = primaryIP | |
| 437 | self.finalRedirectURL = finalRedirectURL | |
| 438 | self.tlsStatusSummary = tlsStatusSummary | |
| 439 | self.emailSecuritySummary = emailSecuritySummary | |
| 440 | self.httpGradeSummary = httpGradeSummary | |
| 441 | self.changeSummary = changeSummary | |
| 378 | 442 | self.sslError = sslError |
| 379 | 443 | self.httpHeadersError = httpHeadersError |
| 380 | 444 | self.reachabilityError = reachabilityError |
| @@ -390,6 +454,7 @@ struct HistoryEntry: Identifiable, Codable { | ||
| 390 | 454 | id = try container.decodeIfPresent(UUID.self, forKey: .id) ?? UUID() |
| 391 | 455 | domain = try container.decode(String.self, forKey: .domain) |
| 392 | 456 | timestamp = try container.decode(Date.self, forKey: .timestamp) |
| 457 | trackedDomainID = try container.decodeIfPresent(UUID.self, forKey: .trackedDomainID) | |
| 393 | 458 | dnsSections = try container.decode([DNSSection].self, forKey: .dnsSections) |
| 394 | 459 | sslInfo = try container.decodeIfPresent(SSLCertificateInfo.self, forKey: .sslInfo) |
| 395 | 460 | httpHeaders = try container.decode([HTTPHeader].self, forKey: .httpHeaders) |
| @@ -406,6 +471,12 @@ struct HistoryEntry: Identifiable, Codable { | ||
| 406 | 471 | resolverDisplayName = try container.decodeIfPresent(String.self, forKey: .resolverDisplayName) ?? "Cloudflare" |
| 407 | 472 | resolverURLString = try container.decodeIfPresent(String.self, forKey: .resolverURLString) ?? DNSResolverOption.defaultURLString |
| 408 | 473 | totalLookupDurationMs = try container.decodeIfPresent(Int.self, forKey: .totalLookupDurationMs) |
| 474 | primaryIP = try container.decodeIfPresent(String.self, forKey: .primaryIP) | |
| 475 | finalRedirectURL = try container.decodeIfPresent(String.self, forKey: .finalRedirectURL) | |
| 476 | tlsStatusSummary = try container.decodeIfPresent(String.self, forKey: .tlsStatusSummary) | |
| 477 | emailSecuritySummary = try container.decodeIfPresent(String.self, forKey: .emailSecuritySummary) | |
| 478 | httpGradeSummary = try container.decodeIfPresent(String.self, forKey: .httpGradeSummary) | |
| 479 | changeSummary = try container.decodeIfPresent(DomainChangeSummary.self, forKey: .changeSummary) | |
| 409 | 480 | sslError = try container.decodeIfPresent(String.self, forKey: .sslError) |
| 410 | 481 | httpHeadersError = try container.decodeIfPresent(String.self, forKey: .httpHeadersError) |
| 411 | 482 | reachabilityError = try container.decodeIfPresent(String.self, forKey: .reachabilityError) |
DomainDig/PremiumAccessService.swift added +27
| @@ -0,0 +1,27 @@ | ||
| 1 | import Foundation | |
| 2 | ||
| 3 | enum PremiumAccessService { | |
| 4 | static let freeTrackedDomainLimit = 3 | |
| 5 | ||
| 6 | static func hasAccess(to capability: PremiumCapability) -> Bool { | |
| 7 | switch capability { | |
| 8 | case .unlimitedTrackedDomains, | |
| 9 | .automatedMonitoring, | |
| 10 | .pushAlerts, | |
| 11 | .batchTracking, | |
| 12 | .advancedExports: | |
| 13 | return false | |
| 14 | } | |
| 15 | } | |
| 16 | ||
| 17 | static func trackedDomainLimitMessage(currentCount: Int) -> String? { | |
| 18 | guard currentCount >= freeTrackedDomainLimit, !hasAccess(to: .unlimitedTrackedDomains) else { | |
| 19 | return nil | |
| 20 | } | |
| 21 | return "More tracked domains will be available in a future Pro upgrade." | |
| 22 | } | |
| 23 | ||
| 24 | static func canAddTrackedDomain(currentCount: Int) -> Bool { | |
| 25 | hasAccess(to: .unlimitedTrackedDomains) || currentCount < freeTrackedDomainLimit | |
| 26 | } | |
| 27 | } | |
DomainDig/WatchlistView.swift +291 −25
| @@ -6,31 +6,86 @@ struct WatchlistView: View { | ||
| 6 | 6 | |
| 7 | 7 | var body: some View { |
| 8 | 8 | List { |
| 9 | if viewModel.watchedDomains.isEmpty { | |
| 10 | Text("No watched domains") | |
| 11 | .font(.system(.callout, design: .monospaced)) | |
| 12 | .foregroundStyle(.secondary) | |
| 13 | .listRowBackground(Color(.systemGray6).opacity(0.5)) | |
| 9 | if viewModel.sortedTrackedDomains.isEmpty { | |
| 10 | Section { | |
| 11 | VStack(alignment: .leading, spacing: 8) { | |
| 12 | Text("No tracked domains yet") | |
| 13 | .font(.system(.callout, design: .monospaced)) | |
| 14 | .foregroundStyle(.primary) | |
| 15 | Text("Tracked domains appear here. Tracking is local and manual for now.") | |
| 16 | .font(.system(.caption, design: .monospaced)) | |
| 17 | .foregroundStyle(.secondary) | |
| 18 | } | |
| 19 | .padding(.vertical, 8) | |
| 20 | } | |
| 21 | .listRowBackground(Color(.systemGray6).opacity(0.5)) | |
| 14 | 22 | } else { |
| 15 | ForEach(viewModel.watchedDomains) { watchedDomain in | |
| 16 | Button { | |
| 17 | viewModel.domain = watchedDomain.domain | |
| 18 | dismiss() | |
| 19 | viewModel.run() | |
| 20 | } label: { | |
| 21 | VStack(alignment: .leading, spacing: 4) { | |
| 22 | Text(watchedDomain.domain) | |
| 23 | .font(.system(.callout, design: .monospaced)) | |
| 24 | .foregroundStyle(.primary) | |
| 25 | Text(statusLabel(watchedDomain.lastKnownAvailability)) | |
| 26 | .font(.system(.caption2, design: .monospaced)) | |
| 27 | .foregroundStyle(statusColor(watchedDomain.lastKnownAvailability)) | |
| 28 | } | |
| 23 | if let limitMessage = PremiumAccessService.trackedDomainLimitMessage(currentCount: viewModel.trackedDomains.count) { | |
| 24 | Section { | |
| 25 | Text(limitMessage) | |
| 26 | .font(.system(.caption, design: .monospaced)) | |
| 27 | .foregroundStyle(.secondary) | |
| 29 | 28 | } |
| 30 | 29 | .listRowBackground(Color(.systemGray6).opacity(0.5)) |
| 31 | 30 | } |
| 32 | .onDelete { offsets in | |
| 33 | viewModel.removeWatchedDomains(at: offsets) | |
| 31 | ||
| 32 | Section { | |
| 33 | ForEach(viewModel.sortedTrackedDomains) { trackedDomain in | |
| 34 | NavigationLink { | |
| 35 | TrackedDomainDetailView(viewModel: viewModel, trackedDomain: trackedDomain) | |
| 36 | } label: { | |
| 37 | WatchlistRowView( | |
| 38 | trackedDomain: trackedDomain, | |
| 39 | isRefreshing: viewModel.refreshingTrackedDomainID == trackedDomain.id | |
| 40 | ) | |
| 41 | } | |
| 42 | .buttonStyle(.plain) | |
| 43 | .swipeActions(edge: .leading, allowsFullSwipe: false) { | |
| 44 | Button { | |
| 45 | viewModel.togglePinned(for: trackedDomain) | |
| 46 | } label: { | |
| 47 | Label(trackedDomain.isPinned ? "Unpin" : "Pin", systemImage: trackedDomain.isPinned ? "pin.slash" : "pin") | |
| 48 | } | |
| 49 | .tint(.yellow) | |
| 50 | } | |
| 51 | .swipeActions(edge: .trailing, allowsFullSwipe: false) { | |
| 52 | Button(role: .destructive) { | |
| 53 | viewModel.deleteTrackedDomain(trackedDomain) | |
| 54 | } label: { | |
| 55 | Label("Delete", systemImage: "trash") | |
| 56 | } | |
| 57 | } | |
| 58 | .contextMenu { | |
| 59 | Button { | |
| 60 | viewModel.refreshTrackedDomain(trackedDomain) | |
| 61 | } label: { | |
| 62 | Label("Refresh", systemImage: "arrow.clockwise") | |
| 63 | } | |
| 64 | ||
| 65 | Button { | |
| 66 | dismiss() | |
| 67 | viewModel.rerunInspection(for: trackedDomain) | |
| 68 | } label: { | |
| 69 | Label("Open Inspection", systemImage: "magnifyingglass") | |
| 70 | } | |
| 71 | ||
| 72 | Button { | |
| 73 | viewModel.togglePinned(for: trackedDomain) | |
| 74 | } label: { | |
| 75 | Label(trackedDomain.isPinned ? "Unpin" : "Pin", systemImage: trackedDomain.isPinned ? "pin.slash" : "pin") | |
| 76 | } | |
| 77 | ||
| 78 | Button(role: .destructive) { | |
| 79 | viewModel.deleteTrackedDomain(trackedDomain) | |
| 80 | } label: { | |
| 81 | Label("Delete", systemImage: "trash") | |
| 82 | } | |
| 83 | } | |
| 84 | .listRowBackground(Color(.systemGray6).opacity(0.5)) | |
| 85 | } | |
| 86 | .onDelete(perform: viewModel.deleteTrackedDomains) | |
| 87 | } header: { | |
| 88 | Text("Tracked Domains") | |
| 34 | 89 | } |
| 35 | 90 | } |
| 36 | 91 | } |
| @@ -38,14 +93,59 @@ struct WatchlistView: View { | ||
| 38 | 93 | .background(Color.black) |
| 39 | 94 | .navigationTitle("Watchlist") |
| 40 | 95 | .toolbar { |
| 41 | if !viewModel.watchedDomains.isEmpty { | |
| 96 | if !viewModel.sortedTrackedDomains.isEmpty { | |
| 42 | 97 | EditButton() |
| 43 | 98 | } |
| 44 | 99 | } |
| 100 | .onChange(of: viewModel.rerunNavigationToken) { _, _ in | |
| 101 | dismiss() | |
| 102 | } | |
| 45 | 103 | .preferredColorScheme(.dark) |
| 46 | 104 | } |
| 105 | } | |
| 106 | ||
| 107 | struct WatchlistRowView: View { | |
| 108 | let trackedDomain: TrackedDomain | |
| 109 | let isRefreshing: Bool | |
| 110 | ||
| 111 | var body: some View { | |
| 112 | VStack(alignment: .leading, spacing: 6) { | |
| 113 | HStack(alignment: .firstTextBaseline, spacing: 8) { | |
| 114 | if trackedDomain.isPinned { | |
| 115 | Image(systemName: "pin.fill") | |
| 116 | .font(.caption2) | |
| 117 | .foregroundStyle(.yellow) | |
| 118 | } | |
| 119 | Text(trackedDomain.domain) | |
| 120 | .font(.system(.callout, design: .monospaced)) | |
| 121 | .foregroundStyle(.primary) | |
| 122 | .lineLimit(2) | |
| 123 | .multilineTextAlignment(.leading) | |
| 124 | Spacer(minLength: 8) | |
| 125 | statusBadge | |
| 126 | } | |
| 127 | ||
| 128 | Text("Updated \(trackedDomain.updatedAt.formatted(date: .abbreviated, time: .shortened))") | |
| 129 | .font(.system(.caption2, design: .monospaced)) | |
| 130 | .foregroundStyle(.secondary) | |
| 131 | ||
| 132 | if let note = trackedDomain.note?.trimmingCharacters(in: .whitespacesAndNewlines), !note.isEmpty { | |
| 133 | Text(note) | |
| 134 | .font(.system(.caption, design: .monospaced)) | |
| 135 | .foregroundStyle(.secondary) | |
| 136 | .lineLimit(2) | |
| 137 | } else if let summary = trackedDomain.lastChangeSummary { | |
| 138 | Text(summary.changedSections.isEmpty ? "No meaningful changes detected." : summary.changedSections.joined(separator: " • ")) | |
| 139 | .font(.system(.caption, design: .monospaced)) | |
| 140 | .foregroundStyle(.secondary) | |
| 141 | .lineLimit(2) | |
| 142 | } | |
| 143 | } | |
| 144 | .frame(maxWidth: .infinity, alignment: .leading) | |
| 145 | .padding(.vertical, 4) | |
| 146 | } | |
| 47 | 147 | |
| 48 | private func statusLabel(_ status: DomainAvailabilityStatus?) -> String { | |
| 148 | private func availabilityLabel(_ status: DomainAvailabilityStatus?) -> String { | |
| 49 | 149 | switch status { |
| 50 | 150 | case .available: |
| 51 | 151 | return "Available" |
| @@ -56,8 +156,33 @@ struct WatchlistView: View { | ||
| 56 | 156 | } |
| 57 | 157 | } |
| 58 | 158 | |
| 59 | private func statusColor(_ status: DomainAvailabilityStatus?) -> Color { | |
| 60 | switch status { | |
| 159 | @ViewBuilder | |
| 160 | private var statusBadge: some View { | |
| 161 | if isRefreshing { | |
| 162 | HStack(spacing: 6) { | |
| 163 | ProgressView() | |
| 164 | .controlSize(.small) | |
| 165 | Text("Refreshing") | |
| 166 | .font(.system(.caption2, design: .monospaced)) | |
| 167 | .foregroundStyle(.secondary) | |
| 168 | } | |
| 169 | .padding(.horizontal, 8) | |
| 170 | .padding(.vertical, 4) | |
| 171 | .background(Color(.systemGray5).opacity(0.6)) | |
| 172 | .clipShape(Capsule()) | |
| 173 | } else { | |
| 174 | Text(availabilityLabel(trackedDomain.lastKnownAvailability)) | |
| 175 | .font(.system(.caption2, design: .monospaced)) | |
| 176 | .foregroundStyle(badgeColor) | |
| 177 | .padding(.horizontal, 8) | |
| 178 | .padding(.vertical, 4) | |
| 179 | .background(badgeBackground) | |
| 180 | .clipShape(Capsule()) | |
| 181 | } | |
| 182 | } | |
| 183 | ||
| 184 | private var badgeColor: Color { | |
| 185 | switch trackedDomain.lastKnownAvailability { | |
| 61 | 186 | case .available: |
| 62 | 187 | return .green |
| 63 | 188 | case .registered: |
| @@ -66,4 +191,145 @@ struct WatchlistView: View { | ||
| 66 | 191 | return .secondary |
| 67 | 192 | } |
| 68 | 193 | } |
| 194 | ||
| 195 | private var badgeBackground: Color { | |
| 196 | switch trackedDomain.lastKnownAvailability { | |
| 197 | case .available: | |
| 198 | return .green.opacity(0.16) | |
| 199 | case .registered: | |
| 200 | return .yellow.opacity(0.16) | |
| 201 | case .unknown, .none: | |
| 202 | return Color(.systemGray5).opacity(0.6) | |
| 203 | } | |
| 204 | } | |
| 205 | } | |
| 206 | ||
| 207 | struct TrackedDomainDetailView: View { | |
| 208 | @Bindable var viewModel: DomainViewModel | |
| 209 | let trackedDomain: TrackedDomain | |
| 210 | @Environment(\.dismiss) private var dismiss | |
| 211 | ||
| 212 | @State private var noteDraft = "" | |
| 213 | @State private var isEditingNote = false | |
| 214 | ||
| 215 | private var liveTrackedDomain: TrackedDomain { | |
| 216 | viewModel.trackedDomains.first(where: { $0.id == trackedDomain.id }) ?? trackedDomain | |
| 217 | } | |
| 218 | ||
| 219 | private var latestSnapshots: [HistoryEntry] { | |
| 220 | viewModel.recentSnapshots(for: liveTrackedDomain) | |
| 221 | } | |
| 222 | ||
| 223 | private var latestDiffSections: [DomainDiffSection] { | |
| 224 | viewModel.diffSectionsForLatestSnapshots(of: liveTrackedDomain) | |
| 225 | } | |
| 226 | ||
| 227 | var body: some View { | |
| 228 | List { | |
| 229 | Section { | |
| 230 | WatchlistRowView( | |
| 231 | trackedDomain: liveTrackedDomain, | |
| 232 | isRefreshing: viewModel.refreshingTrackedDomainID == liveTrackedDomain.id | |
| 233 | ) | |
| 234 | } | |
| 235 | .listRowBackground(Color(.systemGray6).opacity(0.5)) | |
| 236 | ||
| 237 | Section { | |
| 238 | Button { | |
| 239 | viewModel.refreshTrackedDomain(liveTrackedDomain) | |
| 240 | } label: { | |
| 241 | Label("Manual Refresh", systemImage: "arrow.clockwise") | |
| 242 | } | |
| 243 | ||
| 244 | Button { | |
| 245 | viewModel.rerunInspection(for: liveTrackedDomain) | |
| 246 | } label: { | |
| 247 | Label("Re-run Inspection", systemImage: "magnifyingglass") | |
| 248 | } | |
| 249 | ||
| 250 | Button { | |
| 251 | viewModel.togglePinned(for: liveTrackedDomain) | |
| 252 | } label: { | |
| 253 | Label(liveTrackedDomain.isPinned ? "Unpin Domain" : "Pin Domain", systemImage: liveTrackedDomain.isPinned ? "pin.slash" : "pin") | |
| 254 | } | |
| 255 | ||
| 256 | Button { | |
| 257 | noteDraft = liveTrackedDomain.note ?? "" | |
| 258 | isEditingNote = true | |
| 259 | } label: { | |
| 260 | Label(liveTrackedDomain.note == nil ? "Add Note" : "Edit Note", systemImage: "note.text") | |
| 261 | } | |
| 262 | } | |
| 263 | .listRowBackground(Color(.systemGray6).opacity(0.5)) | |
| 264 | ||
| 265 | if let summary = viewModel.latestChangeSummary(for: liveTrackedDomain) { | |
| 266 | Section("Latest Change Summary") { | |
| 267 | DomainChangeSummaryView(summary: summary) | |
| 268 | } | |
| 269 | .listRowBackground(Color.clear) | |
| 270 | } | |
| 271 | ||
| 272 | if !latestDiffSections.isEmpty { | |
| 273 | Section("Latest Diff") { | |
| 274 | DomainDiffView(title: "Latest Snapshot vs Previous", sections: latestDiffSections, showsUnchanged: false) | |
| 275 | } | |
| 276 | .listRowBackground(Color.clear) | |
| 277 | } | |
| 278 | ||
| 279 | Section("Recent Snapshots") { | |
| 280 | if latestSnapshots.isEmpty { | |
| 281 | Text("No snapshots yet") | |
| 282 | .font(.system(.caption, design: .monospaced)) | |
| 283 | .foregroundStyle(.secondary) | |
| 284 | } else { | |
| 285 | ForEach(latestSnapshots) { entry in | |
| 286 | NavigationLink { | |
| 287 | HistoryDetailView(viewModel: viewModel, entry: entry) | |
| 288 | } label: { | |
| 289 | VStack(alignment: .leading, spacing: 4) { | |
| 290 | Text(entry.timestamp.formatted(date: .abbreviated, time: .shortened)) | |
| 291 | .font(.system(.caption, design: .monospaced)) | |
| 292 | .foregroundStyle(.primary) | |
| 293 | Text(entry.changeSummary?.hasChanges == true ? "Changed" : "Snapshot") | |
| 294 | .font(.system(.caption2, design: .monospaced)) | |
| 295 | .foregroundStyle(.secondary) | |
| 296 | } | |
| 297 | } | |
| 298 | } | |
| 299 | } | |
| 300 | } | |
| 301 | .listRowBackground(Color(.systemGray6).opacity(0.5)) | |
| 302 | } | |
| 303 | .scrollContentBackground(.hidden) | |
| 304 | .background(Color.black) | |
| 305 | .navigationTitle(liveTrackedDomain.domain) | |
| 306 | .preferredColorScheme(.dark) | |
| 307 | .onChange(of: viewModel.rerunNavigationToken) { _, _ in | |
| 308 | dismiss() | |
| 309 | } | |
| 310 | .sheet(isPresented: $isEditingNote) { | |
| 311 | NavigationStack { | |
| 312 | Form { | |
| 313 | Section("Tracking Note") { | |
| 314 | TextField("Optional note", text: $noteDraft, axis: .vertical) | |
| 315 | .lineLimit(3...6) | |
| 316 | } | |
| 317 | } | |
| 318 | .navigationTitle("Edit Note") | |
| 319 | .toolbar { | |
| 320 | ToolbarItem(placement: .cancellationAction) { | |
| 321 | Button("Cancel") { | |
| 322 | isEditingNote = false | |
| 323 | } | |
| 324 | } | |
| 325 | ToolbarItem(placement: .confirmationAction) { | |
| 326 | Button("Save") { | |
| 327 | viewModel.updateNote(noteDraft, for: liveTrackedDomain) | |
| 328 | isEditingNote = false | |
| 329 | } | |
| 330 | } | |
| 331 | } | |
| 332 | } | |
| 333 | } | |
| 334 | } | |
| 69 | 335 | } |