Commit 9999f876fe
Verified · cmc
Layout: unified · split
DomainDig.xcodeproj/project.pbxproj +4 −4
| @@ -267,7 +267,7 @@ | |||
| 267 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; | 267 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; |
| 268 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; | 268 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; |
| 269 | CODE_SIGN_STYLE = Automatic; | 269 | CODE_SIGN_STYLE = Automatic; |
| 270 | CURRENT_PROJECT_VERSION = 12; | 270 | CURRENT_PROJECT_VERSION = 13; |
| 271 | DEVELOPMENT_TEAM = ZCNAX3VL9D; | 271 | DEVELOPMENT_TEAM = ZCNAX3VL9D; |
| 272 | ENABLE_PREVIEWS = YES; | 272 | ENABLE_PREVIEWS = YES; |
| 273 | GENERATE_INFOPLIST_FILE = YES; | 273 | GENERATE_INFOPLIST_FILE = YES; |
| @@ -284,7 +284,7 @@ | |||
| 284 | "$(inherited)", | 284 | "$(inherited)", |
| 285 | "@executable_path/Frameworks", | 285 | "@executable_path/Frameworks", |
| 286 | ); | 286 | ); |
| 287 | MARKETING_VERSION = 1.9.0; | 287 | MARKETING_VERSION = 2.0.0; |
| 288 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig; | 288 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig; |
| 289 | PRODUCT_NAME = "$(TARGET_NAME)"; | 289 | PRODUCT_NAME = "$(TARGET_NAME)"; |
| 290 | STRING_CATALOG_GENERATE_SYMBOLS = YES; | 290 | STRING_CATALOG_GENERATE_SYMBOLS = YES; |
| @@ -303,7 +303,7 @@ | |||
| 303 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; | 303 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; |
| 304 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; | 304 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; |
| 305 | CODE_SIGN_STYLE = Automatic; | 305 | CODE_SIGN_STYLE = Automatic; |
| 306 | CURRENT_PROJECT_VERSION = 12; | 306 | CURRENT_PROJECT_VERSION = 13; |
| 307 | DEVELOPMENT_TEAM = ZCNAX3VL9D; | 307 | DEVELOPMENT_TEAM = ZCNAX3VL9D; |
| 308 | ENABLE_PREVIEWS = YES; | 308 | ENABLE_PREVIEWS = YES; |
| 309 | GENERATE_INFOPLIST_FILE = YES; | 309 | GENERATE_INFOPLIST_FILE = YES; |
| @@ -320,7 +320,7 @@ | |||
| 320 | "$(inherited)", | 320 | "$(inherited)", |
| 321 | "@executable_path/Frameworks", | 321 | "@executable_path/Frameworks", |
| 322 | ); | 322 | ); |
| 323 | MARKETING_VERSION = 1.9.0; | 323 | MARKETING_VERSION = 2.0.0; |
| 324 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig; | 324 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig; |
| 325 | PRODUCT_NAME = "$(TARGET_NAME)"; | 325 | PRODUCT_NAME = "$(TARGET_NAME)"; |
| 326 | STRING_CATALOG_GENERATE_SYMBOLS = YES; | 326 | STRING_CATALOG_GENERATE_SYMBOLS = YES; |
DomainDig/ContentView.swift +262 −25
| @@ -3,12 +3,16 @@ import SwiftUI | |||
| 3 | 3 | ||
| 4 | struct ContentView: View { | 4 | struct ContentView: View { |
| 5 | @State private var viewModel = DomainViewModel() | 5 | @State private var viewModel = DomainViewModel() |
| 6 | @State private var navigationPath = NavigationPath() | ||
| 6 | @FocusState private var domainFieldFocused: Bool | 7 | @FocusState private var domainFieldFocused: Bool |
| 7 | @State private var customPortInput = "" | 8 | @State private var customPortInput = "" |
| 8 | @State private var customPortsExpanded = false | 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 | var body: some View { | 14 | var body: some View { |
| 11 | NavigationStack { | 15 | NavigationStack(path: $navigationPath) { |
| 12 | ScrollView(.vertical) { | 16 | ScrollView(.vertical) { |
| 13 | VStack(spacing: 0) { | 17 | VStack(spacing: 0) { |
| 14 | inputSection | 18 | inputSection |
| @@ -16,16 +20,42 @@ struct ContentView: View { | |||
| 16 | actionButtons | 20 | actionButtons |
| 17 | SummaryView(fields: viewModel.summaryFields) | 21 | SummaryView(fields: viewModel.summaryFields) |
| 18 | .padding(.top, 8) | 22 | .padding(.top, 8) |
| 23 | if let changeSummary = viewModel.currentChangeSummary { | ||
| 24 | DomainChangeSummaryView(summary: changeSummary) | ||
| 25 | .padding(.top, 12) | ||
| 26 | } | ||
| 19 | DomainSectionView( | 27 | DomainSectionView( |
| 20 | rows: viewModel.domainRows, | 28 | rows: viewModel.domainRows, |
| 21 | suggestions: viewModel.suggestionRows, | 29 | suggestions: viewModel.suggestionRows, |
| 22 | showSuggestions: viewModel.availabilityResult?.status == .registered || viewModel.suggestionsLoading, | 30 | showSuggestions: viewModel.availabilityResult?.status == .registered || viewModel.suggestionsLoading, |
| 23 | availabilityLoading: viewModel.availabilityLoading, | 31 | availabilityLoading: viewModel.availabilityLoading, |
| 24 | suggestionsLoading: viewModel.suggestionsLoading, | 32 | suggestionsLoading: viewModel.suggestionsLoading, |
| 25 | isWatched: viewModel.isCurrentDomainWatched, | 33 | trackedDomain: viewModel.currentTrackedDomain, |
| 26 | onToggleWatch: { viewModel.toggleWatchedDomain() } | 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 | .padding(.top, 16) | 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 | DNSSectionView( | 59 | DNSSectionView( |
| 30 | dnssecLabel: viewModel.dnssecLabel, | 60 | dnssecLabel: viewModel.dnssecLabel, |
| 31 | sections: viewModel.dnsRows, | 61 | sections: viewModel.dnsRows, |
| @@ -96,28 +126,32 @@ struct ContentView: View { | |||
| 96 | .foregroundStyle(.secondary) | 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 | NavigationLink { | 129 | NavigationLink { |
| 112 | WatchlistView(viewModel: viewModel) | 130 | WatchlistView(viewModel: viewModel) |
| 113 | } label: { | 131 | } label: { |
| 114 | Image(systemName: "eye") | 132 | Image(systemName: "eye") |
| 115 | .foregroundStyle(.secondary) | 133 | .foregroundStyle(.secondary) |
| 116 | } | 134 | } |
| 117 | NavigationLink { | 135 | Menu { |
| 118 | SettingsView() | 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 | } label: { | 153 | } label: { |
| 120 | Image(systemName: "gearshape") | 154 | Image(systemName: "ellipsis.circle") |
| 121 | .foregroundStyle(.secondary) | 155 | .foregroundStyle(.secondary) |
| 122 | } | 156 | } |
| 123 | } | 157 | } |
| @@ -126,6 +160,41 @@ struct ContentView: View { | |||
| 126 | .onAppear { | 160 | .onAppear { |
| 127 | domainFieldFocused = true | 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 | private var inputSection: some View { | 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 | struct DomainSectionView: View { | 494 | struct DomainSectionView: View { |
| 296 | let rows: [InfoRowViewData] | 495 | let rows: [InfoRowViewData] |
| 297 | let suggestions: [DomainSuggestionViewData] | 496 | let suggestions: [DomainSuggestionViewData] |
| 298 | let showSuggestions: Bool | 497 | let showSuggestions: Bool |
| 299 | let availabilityLoading: Bool | 498 | let availabilityLoading: Bool |
| 300 | let suggestionsLoading: Bool | 499 | let suggestionsLoading: Bool |
| 301 | let isWatched: Bool | 500 | let trackedDomain: TrackedDomain? |
| 302 | let onToggleWatch: () -> Void | 501 | let trackingLimitMessage: String? |
| 502 | let onTrack: () -> Void | ||
| 503 | let onTogglePinned: () -> Void | ||
| 504 | let onEditNote: (() -> Void)? | ||
| 303 | 505 | ||
| 304 | var body: some View { | 506 | var body: some View { |
| 305 | VStack(alignment: .leading, spacing: 12) { | 507 | VStack(alignment: .leading, spacing: 12) { |
| 306 | HStack { | 508 | HStack { |
| 307 | SectionTitleView(title: "Domain") | 509 | SectionTitleView(title: "Domain") |
| 308 | Spacer() | 510 | Spacer() |
| 309 | Button(isWatched ? "Watching" : "Watch") { | 511 | if let trackedDomain { |
| 310 | onToggleWatch() | 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 | ForEach(rows) { row in | 540 | ForEach(rows) { row in |
| 317 | LabeledValueRow(row: row) | 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 | if availabilityLoading { | 550 | if availabilityLoading { |
| 320 | ProgressView("Checking availability…") | 551 | ProgressView("Checking availability…") |
| 321 | .appLoadingStyle() | 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 | private struct SettingsView: View { | 1128 | private struct SettingsView: View { |
| 892 | @AppStorage(DNSResolverOption.userDefaultsKey) | 1129 | @AppStorage(DNSResolverOption.userDefaultsKey) |
| 893 | private var storedResolverURL = DNSResolverOption.defaultURLString | 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 | struct LookupSnapshot { | 82 | struct LookupSnapshot { |
| 83 | let historyEntryID: UUID? | ||
| 83 | let domain: String | 84 | let domain: String |
| 84 | let timestamp: Date | 85 | let timestamp: Date |
| 86 | let trackedDomainID: UUID? | ||
| 85 | let resolverDisplayName: String | 87 | let resolverDisplayName: String |
| 86 | let resolverURLString: String | 88 | let resolverURLString: String |
| 87 | let totalLookupDurationMs: Int? | 89 | let totalLookupDurationMs: Int? |
| @@ -111,14 +113,17 @@ struct LookupSnapshot { | |||
| 111 | let redirectChainError: String? | 113 | let redirectChainError: String? |
| 112 | let portScanResults: [PortScanResult] | 114 | let portScanResults: [PortScanResult] |
| 113 | let portScanError: String? | 115 | let portScanError: String? |
| 116 | let changeSummary: DomainChangeSummary? | ||
| 114 | let isLive: Bool | 117 | let isLive: Bool |
| 115 | } | 118 | } |
| 116 | 119 | ||
| 117 | extension HistoryEntry { | 120 | extension HistoryEntry { |
| 118 | var snapshot: LookupSnapshot { | 121 | var snapshot: LookupSnapshot { |
| 119 | LookupSnapshot( | 122 | LookupSnapshot( |
| 123 | historyEntryID: id, | ||
| 120 | domain: domain, | 124 | domain: domain, |
| 121 | timestamp: timestamp, | 125 | timestamp: timestamp, |
| 126 | trackedDomainID: trackedDomainID, | ||
| 122 | resolverDisplayName: resolverDisplayName, | 127 | resolverDisplayName: resolverDisplayName, |
| 123 | resolverURLString: resolverURLString, | 128 | resolverURLString: resolverURLString, |
| 124 | totalLookupDurationMs: totalLookupDurationMs, | 129 | totalLookupDurationMs: totalLookupDurationMs, |
| @@ -148,6 +153,7 @@ extension HistoryEntry { | |||
| 148 | redirectChainError: redirectChainError, | 153 | redirectChainError: redirectChainError, |
| 149 | portScanResults: portScanResults, | 154 | portScanResults: portScanResults, |
| 150 | portScanError: portScanError, | 155 | portScanError: portScanError, |
| 156 | changeSummary: changeSummary, | ||
| 151 | isLive: false | 157 | isLive: false |
| 152 | ) | 158 | ) |
| 153 | } | 159 | } |
| @@ -211,6 +217,10 @@ final class DomainViewModel { | |||
| 211 | var hasRun = false | 217 | var hasRun = false |
| 212 | private(set) var searchedDomain: String = "" | 218 | private(set) var searchedDomain: String = "" |
| 213 | private(set) var lastLookupDurationMs: Int? | 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 | private var lookupTask: Task<Void, Never>? | 225 | private var lookupTask: Task<Void, Never>? |
| 216 | private var customPortScanTask: Task<Void, Never>? | 226 | private var customPortScanTask: Task<Void, Never>? |
| @@ -224,14 +234,9 @@ final class DomainViewModel { | |||
| 224 | private static let savedDomainsKey = "savedDomains" | 234 | private static let savedDomainsKey = "savedDomains" |
| 225 | var savedDomains: [String] = UserDefaults.standard.stringArray(forKey: savedDomainsKey) ?? [] | 235 | var savedDomains: [String] = UserDefaults.standard.stringArray(forKey: savedDomainsKey) ?? [] |
| 226 | 236 | ||
| 227 | private static let watchedDomainsKey = "watchedDomains" | 237 | private static let trackedDomainsKey = "trackedDomains" |
| 228 | var watchedDomains: [WatchedDomain] = { | 238 | private static let legacyWatchedDomainsKey = "watchedDomains" |
| 229 | guard let data = UserDefaults.standard.data(forKey: watchedDomainsKey), | 239 | var trackedDomains: [TrackedDomain] = DomainViewModel.loadTrackedDomains() |
| 230 | let domains = try? JSONDecoder().decode([WatchedDomain].self, from: data) else { | ||
| 231 | return [] | ||
| 232 | } | ||
| 233 | return domains | ||
| 234 | }() | ||
| 235 | 240 | ||
| 236 | private static let historyKey = "lookupHistory" | 241 | private static let historyKey = "lookupHistory" |
| 237 | private static let maxHistory = 50 | 242 | private static let maxHistory = 50 |
| @@ -276,8 +281,35 @@ final class DomainViewModel { | |||
| 276 | !searchedDomain.isEmpty && savedDomains.contains(where: { $0.lowercased() == searchedDomain.lowercased() }) | 281 | !searchedDomain.isEmpty && savedDomains.contains(where: { $0.lowercased() == searchedDomain.lowercased() }) |
| 277 | } | 282 | } |
| 278 | 283 | ||
| 279 | var isCurrentDomainWatched: Bool { | 284 | var sortedTrackedDomains: [TrackedDomain] { |
| 280 | !searchedDomain.isEmpty && watchedDomains.contains(where: { $0.domain.lowercased() == searchedDomain.lowercased() }) | 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 | var resolverDisplayName: String { | 315 | var resolverDisplayName: String { |
| @@ -299,8 +331,10 @@ final class DomainViewModel { | |||
| 299 | 331 | ||
| 300 | var currentSnapshot: LookupSnapshot { | 332 | var currentSnapshot: LookupSnapshot { |
| 301 | LookupSnapshot( | 333 | LookupSnapshot( |
| 334 | historyEntryID: nil, | ||
| 302 | domain: searchedDomain, | 335 | domain: searchedDomain, |
| 303 | timestamp: Date(), | 336 | timestamp: Date(), |
| 337 | trackedDomainID: currentTrackedDomain?.id, | ||
| 304 | resolverDisplayName: resolverDisplayName, | 338 | resolverDisplayName: resolverDisplayName, |
| 305 | resolverURLString: resolverURLString, | 339 | resolverURLString: resolverURLString, |
| 306 | totalLookupDurationMs: lastLookupDurationMs, | 340 | totalLookupDurationMs: lastLookupDurationMs, |
| @@ -330,6 +364,7 @@ final class DomainViewModel { | |||
| 330 | redirectChainError: redirectChainError, | 364 | redirectChainError: redirectChainError, |
| 331 | portScanResults: allPortScanResults, | 365 | portScanResults: allPortScanResults, |
| 332 | portScanError: combinedPortScanError, | 366 | portScanError: combinedPortScanError, |
| 367 | changeSummary: currentChangeSummary, | ||
| 333 | isLive: true | 368 | isLive: true |
| 334 | ) | 369 | ) |
| 335 | } | 370 | } |
| @@ -408,31 +443,84 @@ final class DomainViewModel { | |||
| 408 | UserDefaults.standard.set(savedDomains, forKey: Self.savedDomainsKey) | 443 | UserDefaults.standard.set(savedDomains, forKey: Self.savedDomainsKey) |
| 409 | } | 444 | } |
| 410 | 445 | ||
| 411 | func toggleWatchedDomain() { | 446 | @discardableResult |
| 412 | guard !searchedDomain.isEmpty else { return } | 447 | func trackCurrentDomain() -> Bool { |
| 413 | toggleWatchedDomain(domain: searchedDomain, availabilityStatus: availabilityResult?.status) | 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?) { | 452 | @discardableResult |
| 417 | guard !domain.isEmpty else { return } | 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() }) { | 457 | if trackedDomain(for: normalizedDomain) != nil { |
| 420 | watchedDomains.removeAll { $0.domain.lowercased() == domain.lowercased() } | 458 | return true |
| 421 | } else { | ||
| 422 | watchedDomains.insert( | ||
| 423 | WatchedDomain( | ||
| 424 | domain: domain, | ||
| 425 | lastKnownAvailability: availabilityStatus | ||
| 426 | ), | ||
| 427 | at: 0 | ||
| 428 | ) | ||
| 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) { | 479 | func refreshTrackedDomain(_ trackedDomain: TrackedDomain) { |
| 434 | watchedDomains.remove(atOffsets: offsets) | 480 | refreshingTrackedDomainID = trackedDomain.id |
| 435 | persistWatchedDomains() | 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 | func removeHistoryEntries(at offsets: IndexSet) { | 526 | func removeHistoryEntries(at offsets: IndexSet) { |
| @@ -440,6 +528,11 @@ final class DomainViewModel { | |||
| 440 | persistHistory() | 528 | persistHistory() |
| 441 | } | 529 | } |
| 442 | 530 | ||
| 531 | func clearHistory() { | ||
| 532 | history.removeAll() | ||
| 533 | persistHistory() | ||
| 534 | } | ||
| 535 | |||
| 443 | func clearRecentSearches() { | 536 | func clearRecentSearches() { |
| 444 | recentSearches.removeAll() | 537 | recentSearches.removeAll() |
| 445 | UserDefaults.standard.removeObject(forKey: Self.recentSearchesKey) | 538 | UserDefaults.standard.removeObject(forKey: Self.recentSearchesKey) |
| @@ -449,6 +542,7 @@ final class DomainViewModel { | |||
| 449 | UserDefaults.standard.set(entry.resolverURLString, forKey: DNSResolverOption.userDefaultsKey) | 542 | UserDefaults.standard.set(entry.resolverURLString, forKey: DNSResolverOption.userDefaultsKey) |
| 450 | domain = entry.domain | 543 | domain = entry.domain |
| 451 | run() | 544 | run() |
| 545 | rerunNavigationToken = UUID() | ||
| 452 | } | 546 | } |
| 453 | 547 | ||
| 454 | func reset() { | 548 | func reset() { |
| @@ -457,6 +551,9 @@ final class DomainViewModel { | |||
| 457 | hasRun = false | 551 | hasRun = false |
| 458 | searchedDomain = "" | 552 | searchedDomain = "" |
| 459 | lastLookupDurationMs = nil | 553 | lastLookupDurationMs = nil |
| 554 | currentDiffSections = [] | ||
| 555 | currentChangeSummary = nil | ||
| 556 | refreshingTrackedDomainID = nil | ||
| 460 | clearLookupState() | 557 | clearLookupState() |
| 461 | } | 558 | } |
| 462 | 559 | ||
| @@ -474,6 +571,8 @@ final class DomainViewModel { | |||
| 474 | addRecentSearch(target) | 571 | addRecentSearch(target) |
| 475 | searchedDomain = target | 572 | searchedDomain = target |
| 476 | hasRun = true | 573 | hasRun = true |
| 574 | currentDiffSections = [] | ||
| 575 | currentChangeSummary = nil | ||
| 477 | clearLookupState() | 576 | clearLookupState() |
| 478 | setAllLoadingStates(true) | 577 | setAllLoadingStates(true) |
| 479 | customPortScanLoading = false | 578 | customPortScanLoading = false |
| @@ -513,7 +612,12 @@ final class DomainViewModel { | |||
| 513 | } | 612 | } |
| 514 | 613 | ||
| 515 | func exportText() -> String { | 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 | private func performLookup(domain: String, lookupID: UUID) async { | 623 | private func performLookup(domain: String, lookupID: UUID) async { |
| @@ -555,6 +659,7 @@ final class DomainViewModel { | |||
| 555 | guard !Task.isCancelled, isCurrentLookup(lookupID) else { return } | 659 | guard !Task.isCancelled, isCurrentLookup(lookupID) else { return } |
| 556 | lastLookupDurationMs = lookupStartedAt.map { Int(Date().timeIntervalSince($0) * 1000) } | 660 | lastLookupDurationMs = lookupStartedAt.map { Int(Date().timeIntervalSince($0) * 1000) } |
| 557 | saveHistoryEntry(replaceLatest: false) | 661 | saveHistoryEntry(replaceLatest: false) |
| 662 | refreshingTrackedDomainID = nil | ||
| 558 | } | 663 | } |
| 559 | 664 | ||
| 560 | private func runDNS(domain: String, lookupID: UUID) async { | 665 | private func runDNS(domain: String, lookupID: UUID) async { |
| @@ -579,7 +684,7 @@ final class DomainViewModel { | |||
| 579 | guard !Task.isCancelled, isCurrentLookup(lookupID) else { return } | 684 | guard !Task.isCancelled, isCurrentLookup(lookupID) else { return } |
| 580 | availabilityResult = result | 685 | availabilityResult = result |
| 581 | availabilityLoading = false | 686 | availabilityLoading = false |
| 582 | updateWatchedDomainAvailability(for: result.domain, status: result.status) | 687 | updateTrackedDomainAvailability(for: result.domain, status: result.status) |
| 583 | } | 688 | } |
| 584 | 689 | ||
| 585 | private func runSSL(domain: String, lookupID: UUID) async { | 690 | private func runSSL(domain: String, lookupID: UUID) async { |
| @@ -801,9 +906,20 @@ final class DomainViewModel { | |||
| 801 | 906 | ||
| 802 | private func saveHistoryEntry(replaceLatest: Bool) { | 907 | private func saveHistoryEntry(replaceLatest: Bool) { |
| 803 | guard !searchedDomain.isEmpty else { return } | 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 | let entry = HistoryEntry( | 919 | let entry = HistoryEntry( |
| 805 | domain: searchedDomain, | 920 | domain: searchedDomain, |
| 806 | timestamp: Date(), | 921 | timestamp: timestamp, |
| 922 | trackedDomainID: trackedDomainID, | ||
| 807 | dnsSections: dnsSections, | 923 | dnsSections: dnsSections, |
| 808 | sslInfo: sslInfo, | 924 | sslInfo: sslInfo, |
| 809 | httpHeaders: httpHeaders, | 925 | httpHeaders: httpHeaders, |
| @@ -820,6 +936,12 @@ final class DomainViewModel { | |||
| 820 | resolverDisplayName: resolverDisplayName, | 936 | resolverDisplayName: resolverDisplayName, |
| 821 | resolverURLString: resolverURLString, | 937 | resolverURLString: resolverURLString, |
| 822 | totalLookupDurationMs: lastLookupDurationMs, | 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 | sslError: sslError, | 945 | sslError: sslError, |
| 824 | httpHeadersError: httpHeadersError, | 946 | httpHeadersError: httpHeadersError, |
| 825 | reachabilityError: reachabilityError, | 947 | reachabilityError: reachabilityError, |
| @@ -838,6 +960,14 @@ final class DomainViewModel { | |||
| 838 | history = Array(history.prefix(Self.maxHistory)) | 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 | persistHistory() | 971 | persistHistory() |
| 842 | } | 972 | } |
| 843 | 973 | ||
| @@ -847,18 +977,82 @@ final class DomainViewModel { | |||
| 847 | } | 977 | } |
| 848 | } | 978 | } |
| 849 | 979 | ||
| 850 | private func persistWatchedDomains() { | 980 | private func persistTrackedDomains() { |
| 851 | if let data = try? JSONEncoder().encode(watchedDomains) { | 981 | if let data = try? JSONEncoder().encode(trackedDomains) { |
| 852 | UserDefaults.standard.set(data, forKey: Self.watchedDomainsKey) | 982 | UserDefaults.standard.set(data, forKey: Self.trackedDomainsKey) |
| 853 | } | 983 | } |
| 854 | } | 984 | } |
| 855 | 985 | ||
| 856 | private func updateWatchedDomainAvailability(for domain: String, status: DomainAvailabilityStatus) { | 986 | private func updateTrackedDomainAvailability(for domain: String, status: DomainAvailabilityStatus) { |
| 857 | guard let index = watchedDomains.firstIndex(where: { $0.domain.lowercased() == domain.lowercased() }) else { | 987 | guard let index = trackedDomains.firstIndex(where: { $0.domain.caseInsensitiveCompare(domain) == .orderedSame }) else { |
| 858 | return | 988 | return |
| 859 | } | 989 | } |
| 860 | watchedDomains[index].lastKnownAvailability = status | 990 | trackedDomains[index].lastKnownAvailability = status |
| 861 | persistWatchedDomains() | 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 | private func addRecentSearch(_ domain: String) { | 1058 | private func addRecentSearch(_ domain: String) { |
| @@ -937,6 +1131,71 @@ final class DomainViewModel { | |||
| 937 | activeLookupID == lookupID | 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 | static func summaryFields(from snapshot: LookupSnapshot) -> [SummaryFieldViewData] { | 1199 | static func summaryFields(from snapshot: LookupSnapshot) -> [SummaryFieldViewData] { |
| 941 | [ | 1200 | [ |
| 942 | SummaryFieldViewData(label: "Domain", value: snapshot.domain.nonEmpty ?? "Unavailable", tone: .primary), | 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 | let exportDateFormatter = DateFormatter() | 1378 | let exportDateFormatter = DateFormatter() |
| 1115 | exportDateFormatter.dateFormat = "yyyy-MM-dd HH:mm" | 1379 | exportDateFormatter.dateFormat = "yyyy-MM-dd HH:mm" |
| 1116 | 1380 | ||
| @@ -1120,9 +1384,14 @@ final class DomainViewModel { | |||
| 1120 | "Date: \(exportDateFormatter.string(from: snapshot.timestamp))", | 1384 | "Date: \(exportDateFormatter.string(from: snapshot.timestamp))", |
| 1121 | "Mode: \(snapshot.isLive ? "Live" : "Snapshot")", | 1385 | "Mode: \(snapshot.isLive ? "Live" : "Snapshot")", |
| 1122 | "Resolver: \(snapshot.resolverDisplayName)", | 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 | func appendSection(_ title: String, body: () -> Void) { | 1395 | func appendSection(_ title: String, body: () -> Void) { |
| 1127 | lines.append("") | 1396 | lines.append("") |
| 1128 | lines.append(title) | 1397 | lines.append(title) |
| @@ -1134,6 +1403,36 @@ final class DomainViewModel { | |||
| 1134 | for item in summaryFields(from: snapshot) { | 1403 | for item in summaryFields(from: snapshot) { |
| 1135 | lines.append(" \(item.label): \(item.value)") | 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 | appendSection("Domain") { | 1438 | appendSection("Domain") { |
DomainDig/HistoryView.swift +50 −5
| @@ -2,6 +2,8 @@ import SwiftUI | |||
| 2 | 2 | ||
| 3 | struct HistoryView: View { | 3 | struct HistoryView: View { |
| 4 | @Bindable var viewModel: DomainViewModel | 4 | @Bindable var viewModel: DomainViewModel |
| 5 | @Environment(\.dismiss) private var dismiss | ||
| 6 | @State private var showClearAllConfirmation = false | ||
| 5 | 7 | ||
| 6 | private let dateFormatter: DateFormatter = { | 8 | private let dateFormatter: DateFormatter = { |
| 7 | let formatter = DateFormatter() | 9 | let formatter = DateFormatter() |
| @@ -50,8 +52,29 @@ struct HistoryView: View { | |||
| 50 | .navigationTitle("History") | 52 | .navigationTitle("History") |
| 51 | .toolbar { | 53 | .toolbar { |
| 52 | if !viewModel.history.isEmpty { | 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 | .preferredColorScheme(.dark) | 79 | .preferredColorScheme(.dark) |
| 57 | } | 80 | } |
| @@ -60,6 +83,7 @@ struct HistoryView: View { | |||
| 60 | struct HistoryDetailView: View { | 83 | struct HistoryDetailView: View { |
| 61 | @Bindable var viewModel: DomainViewModel | 84 | @Bindable var viewModel: DomainViewModel |
| 62 | let entry: HistoryEntry | 85 | let entry: HistoryEntry |
| 86 | @Environment(\.dismiss) private var dismiss | ||
| 63 | 87 | ||
| 64 | private let dateFormatter: DateFormatter = { | 88 | private let dateFormatter: DateFormatter = { |
| 65 | let formatter = DateFormatter() | 89 | let formatter = DateFormatter() |
| @@ -84,12 +108,30 @@ struct HistoryDetailView: View { | |||
| 84 | showSuggestions: entry.availabilityResult?.status == .registered && !entry.suggestions.isEmpty, | 108 | showSuggestions: entry.availabilityResult?.status == .registered && !entry.suggestions.isEmpty, |
| 85 | availabilityLoading: false, | 109 | availabilityLoading: false, |
| 86 | suggestionsLoading: false, | 110 | suggestionsLoading: false, |
| 87 | isWatched: viewModel.watchedDomains.contains(where: { $0.domain.lowercased() == entry.domain.lowercased() }), | 111 | trackedDomain: viewModel.trackedDomains.first(where: { $0.domain.lowercased() == entry.domain.lowercased() }), |
| 88 | onToggleWatch: { | 112 | trackingLimitMessage: nil, |
| 89 | viewModel.toggleWatchedDomain(domain: entry.domain, availabilityStatus: entry.availabilityResult?.status) | 113 | onTrack: { |
| 90 | } | 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 | .padding(.top, 16) | 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 | DNSSectionView( | 135 | DNSSectionView( |
| 94 | dnssecLabel: DomainViewModel.dnssecLabel(from: snapshot), | 136 | dnssecLabel: DomainViewModel.dnssecLabel(from: snapshot), |
| 95 | sections: DomainViewModel.dnsRows(from: snapshot), | 137 | sections: DomainViewModel.dnsRows(from: snapshot), |
| @@ -150,6 +192,9 @@ struct HistoryDetailView: View { | |||
| 150 | viewModel.rerunLookup(from: entry) | 192 | viewModel.rerunLookup(from: entry) |
| 151 | } | 193 | } |
| 152 | } | 194 | } |
| 195 | .onChange(of: viewModel.rerunNavigationToken) { _, _ in | ||
| 196 | dismiss() | ||
| 197 | } | ||
| 153 | .preferredColorScheme(.dark) | 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 | // MARK: - DNS Models | 99 | // MARK: - DNS Models |
| 52 | 100 | ||
| 53 | enum DNSRecordType: String, CaseIterable, Codable { | 101 | enum DNSRecordType: String, CaseIterable, Codable { |
| @@ -321,6 +369,7 @@ struct HistoryEntry: Identifiable, Codable { | |||
| 321 | var id = UUID() | 369 | var id = UUID() |
| 322 | let domain: String | 370 | let domain: String |
| 323 | let timestamp: Date | 371 | let timestamp: Date |
| 372 | var trackedDomainID: UUID? | ||
| 324 | let dnsSections: [DNSSection] | 373 | let dnsSections: [DNSSection] |
| 325 | let sslInfo: SSLCertificateInfo? | 374 | let sslInfo: SSLCertificateInfo? |
| 326 | let httpHeaders: [HTTPHeader] | 375 | let httpHeaders: [HTTPHeader] |
| @@ -337,6 +386,12 @@ struct HistoryEntry: Identifiable, Codable { | |||
| 337 | var resolverDisplayName: String | 386 | var resolverDisplayName: String |
| 338 | var resolverURLString: String | 387 | var resolverURLString: String |
| 339 | var totalLookupDurationMs: Int? | 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 | var sslError: String? | 395 | var sslError: String? |
| 341 | var httpHeadersError: String? | 396 | var httpHeadersError: String? |
| 342 | var reachabilityError: String? | 397 | var reachabilityError: String? |
| @@ -346,19 +401,22 @@ struct HistoryEntry: Identifiable, Codable { | |||
| 346 | var redirectChainError: String? | 401 | var redirectChainError: String? |
| 347 | var portScanError: String? | 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 | sslInfo: SSLCertificateInfo?, httpHeaders: [HTTPHeader], | 405 | sslInfo: SSLCertificateInfo?, httpHeaders: [HTTPHeader], |
| 351 | reachabilityResults: [PortReachability], ipGeolocation: IPGeolocation?, | 406 | reachabilityResults: [PortReachability], ipGeolocation: IPGeolocation?, |
| 352 | emailSecurity: EmailSecurityResult? = nil, mtaSts: MTASTSResult? = nil, ptrRecord: String? = nil, | 407 | emailSecurity: EmailSecurityResult? = nil, mtaSts: MTASTSResult? = nil, ptrRecord: String? = nil, |
| 353 | redirectChain: [RedirectHop] = [], portScanResults: [PortScanResult] = [], | 408 | redirectChain: [RedirectHop] = [], portScanResults: [PortScanResult] = [], |
| 354 | hstsPreloaded: Bool? = nil, availabilityResult: DomainAvailabilityResult? = nil, | 409 | hstsPreloaded: Bool? = nil, availabilityResult: DomainAvailabilityResult? = nil, |
| 355 | suggestions: [DomainSuggestionResult] = [], resolverDisplayName: String, resolverURLString: String, | 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 | reachabilityError: String? = nil, ipGeolocationError: String? = nil, | 414 | reachabilityError: String? = nil, ipGeolocationError: String? = nil, |
| 358 | emailSecurityError: String? = nil, ptrError: String? = nil, | 415 | emailSecurityError: String? = nil, ptrError: String? = nil, |
| 359 | redirectChainError: String? = nil, portScanError: String? = nil) { | 416 | redirectChainError: String? = nil, portScanError: String? = nil) { |
| 360 | self.domain = domain | 417 | self.domain = domain |
| 361 | self.timestamp = timestamp | 418 | self.timestamp = timestamp |
| 419 | self.trackedDomainID = trackedDomainID | ||
| 362 | self.dnsSections = dnsSections | 420 | self.dnsSections = dnsSections |
| 363 | self.sslInfo = sslInfo | 421 | self.sslInfo = sslInfo |
| 364 | self.httpHeaders = httpHeaders | 422 | self.httpHeaders = httpHeaders |
| @@ -375,6 +433,12 @@ struct HistoryEntry: Identifiable, Codable { | |||
| 375 | self.resolverDisplayName = resolverDisplayName | 433 | self.resolverDisplayName = resolverDisplayName |
| 376 | self.resolverURLString = resolverURLString | 434 | self.resolverURLString = resolverURLString |
| 377 | self.totalLookupDurationMs = totalLookupDurationMs | 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 | self.sslError = sslError | 442 | self.sslError = sslError |
| 379 | self.httpHeadersError = httpHeadersError | 443 | self.httpHeadersError = httpHeadersError |
| 380 | self.reachabilityError = reachabilityError | 444 | self.reachabilityError = reachabilityError |
| @@ -390,6 +454,7 @@ struct HistoryEntry: Identifiable, Codable { | |||
| 390 | id = try container.decodeIfPresent(UUID.self, forKey: .id) ?? UUID() | 454 | id = try container.decodeIfPresent(UUID.self, forKey: .id) ?? UUID() |
| 391 | domain = try container.decode(String.self, forKey: .domain) | 455 | domain = try container.decode(String.self, forKey: .domain) |
| 392 | timestamp = try container.decode(Date.self, forKey: .timestamp) | 456 | timestamp = try container.decode(Date.self, forKey: .timestamp) |
| 457 | trackedDomainID = try container.decodeIfPresent(UUID.self, forKey: .trackedDomainID) | ||
| 393 | dnsSections = try container.decode([DNSSection].self, forKey: .dnsSections) | 458 | dnsSections = try container.decode([DNSSection].self, forKey: .dnsSections) |
| 394 | sslInfo = try container.decodeIfPresent(SSLCertificateInfo.self, forKey: .sslInfo) | 459 | sslInfo = try container.decodeIfPresent(SSLCertificateInfo.self, forKey: .sslInfo) |
| 395 | httpHeaders = try container.decode([HTTPHeader].self, forKey: .httpHeaders) | 460 | httpHeaders = try container.decode([HTTPHeader].self, forKey: .httpHeaders) |
| @@ -406,6 +471,12 @@ struct HistoryEntry: Identifiable, Codable { | |||
| 406 | resolverDisplayName = try container.decodeIfPresent(String.self, forKey: .resolverDisplayName) ?? "Cloudflare" | 471 | resolverDisplayName = try container.decodeIfPresent(String.self, forKey: .resolverDisplayName) ?? "Cloudflare" |
| 407 | resolverURLString = try container.decodeIfPresent(String.self, forKey: .resolverURLString) ?? DNSResolverOption.defaultURLString | 472 | resolverURLString = try container.decodeIfPresent(String.self, forKey: .resolverURLString) ?? DNSResolverOption.defaultURLString |
| 408 | totalLookupDurationMs = try container.decodeIfPresent(Int.self, forKey: .totalLookupDurationMs) | 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 | sslError = try container.decodeIfPresent(String.self, forKey: .sslError) | 480 | sslError = try container.decodeIfPresent(String.self, forKey: .sslError) |
| 410 | httpHeadersError = try container.decodeIfPresent(String.self, forKey: .httpHeadersError) | 481 | httpHeadersError = try container.decodeIfPresent(String.self, forKey: .httpHeadersError) |
| 411 | reachabilityError = try container.decodeIfPresent(String.self, forKey: .reachabilityError) | 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 | var body: some View { | 7 | var body: some View { |
| 8 | List { | 8 | List { |
| 9 | if viewModel.watchedDomains.isEmpty { | 9 | if viewModel.sortedTrackedDomains.isEmpty { |
| 10 | Text("No watched domains") | 10 | Section { |
| 11 | .font(.system(.callout, design: .monospaced)) | 11 | VStack(alignment: .leading, spacing: 8) { |
| 12 | .foregroundStyle(.secondary) | 12 | Text("No tracked domains yet") |
| 13 | .listRowBackground(Color(.systemGray6).opacity(0.5)) | 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 | } else { | 22 | } else { |
| 15 | ForEach(viewModel.watchedDomains) { watchedDomain in | 23 | if let limitMessage = PremiumAccessService.trackedDomainLimitMessage(currentCount: viewModel.trackedDomains.count) { |
| 16 | Button { | 24 | Section { |
| 17 | viewModel.domain = watchedDomain.domain | 25 | Text(limitMessage) |
| 18 | dismiss() | 26 | .font(.system(.caption, design: .monospaced)) |
| 19 | viewModel.run() | 27 | .foregroundStyle(.secondary) |
| 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 | } | ||
| 29 | } | 28 | } |
| 30 | .listRowBackground(Color(.systemGray6).opacity(0.5)) | 29 | .listRowBackground(Color(.systemGray6).opacity(0.5)) |
| 31 | } | 30 | } |
| 32 | .onDelete { offsets in | 31 | |
| 33 | viewModel.removeWatchedDomains(at: offsets) | 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 | .background(Color.black) | 93 | .background(Color.black) |
| 39 | .navigationTitle("Watchlist") | 94 | .navigationTitle("Watchlist") |
| 40 | .toolbar { | 95 | .toolbar { |
| 41 | if !viewModel.watchedDomains.isEmpty { | 96 | if !viewModel.sortedTrackedDomains.isEmpty { |
| 42 | EditButton() | 97 | EditButton() |
| 43 | } | 98 | } |
| 44 | } | 99 | } |
| 100 | .onChange(of: viewModel.rerunNavigationToken) { _, _ in | ||
| 101 | dismiss() | ||
| 102 | } | ||
| 45 | .preferredColorScheme(.dark) | 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 | switch status { | 149 | switch status { |
| 50 | case .available: | 150 | case .available: |
| 51 | return "Available" | 151 | return "Available" |
| @@ -56,8 +156,33 @@ struct WatchlistView: View { | |||
| 56 | } | 156 | } |
| 57 | } | 157 | } |
| 58 | 158 | ||
| 59 | private func statusColor(_ status: DomainAvailabilityStatus?) -> Color { | 159 | @ViewBuilder |
| 60 | switch status { | 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 | case .available: | 186 | case .available: |
| 62 | return .green | 187 | return .green |
| 63 | case .registered: | 188 | case .registered: |
| @@ -66,4 +191,145 @@ struct WatchlistView: View { | |||
| 66 | return .secondary | 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 | } |