| @@ -8,7 +8,18 @@ enum LookupInputMode: String, CaseIterable, Identifiable { |
| 8 | 8 | var id: String { rawValue } |
| 9 | 9 | } |
| 10 | 10 | |
| 11 | enum ResultSection: String, Hashable { |
| 12 | case domain |
| 13 | case ownership |
| 14 | case dns |
| 15 | case web |
| 16 | case email |
| 17 | case network |
| 18 | case subdomains |
| 19 | } |
| 20 | |
| 11 | 21 | struct ContentView: View { |
| 22 | @Environment(\.appDensity) private var appDensity |
| 12 | 23 | @State private var viewModel = DomainViewModel() |
| 13 | 24 | @State private var navigationPath = NavigationPath() |
| 14 | 25 | @FocusState private var domainFieldFocused: Bool |
| @@ -18,6 +29,7 @@ struct ContentView: View { |
| 18 | 29 | @State private var editingTrackedDomain: TrackedDomain? |
| 19 | 30 | @State private var showTrackLimitAlert = false |
| 20 | 31 | @State private var inputMode: LookupInputMode = .single |
| 32 | @State private var collapsedSections: Set<ResultSection> = [.network] |
| 21 | 33 | |
| 22 | 34 | var body: some View { |
| 23 | 35 | NavigationStack(path: $navigationPath) { |
| @@ -26,21 +38,22 @@ struct ContentView: View { |
| 26 | 38 | inputSection |
| 27 | 39 | if !viewModel.batchResults.isEmpty || viewModel.batchLookupRunning { |
| 28 | 40 | batchSection |
| 29 | | .padding(.top, 8) |
| 41 | .padding(.top, appDensity.metrics.cardSpacing) |
| 30 | 42 | } |
| 31 | 43 | if viewModel.hasRun { |
| 32 | 44 | actionButtons |
| 33 | | if let statusMessage = viewModel.currentStatusMessage ?? (viewModel.currentResultSource != .live ? viewModel.currentResultSource.label : nil) { |
| 45 | if let statusMessage = resultStatusMessage { |
| 34 | 46 | LookupStatusBannerView(message: statusMessage, resultSource: viewModel.currentResultSource) |
| 35 | | .padding(.top, 8) |
| 47 | .padding(.top, appDensity.metrics.cardSpacing) |
| 36 | 48 | } |
| 37 | 49 | SummaryView(fields: viewModel.summaryFields) |
| 38 | | .padding(.top, 8) |
| 50 | .padding(.top, appDensity.metrics.cardSpacing) |
| 39 | 51 | if let changeSummary = viewModel.currentChangeSummary { |
| 40 | 52 | DomainChangeSummaryView(summary: changeSummary) |
| 41 | | .padding(.top, 12) |
| 53 | .padding(.top, appDensity.metrics.cardSpacing) |
| 42 | 54 | } |
| 43 | 55 | DomainSectionView( |
| 56 | isCollapsed: sectionCollapsedBinding(.domain), |
| 44 | 57 | rows: viewModel.domainRows, |
| 45 | 58 | suggestions: viewModel.suggestionRows, |
| 46 | 59 | showSuggestions: viewModel.availabilityResult?.status == .registered || viewModel.suggestionsLoading, |
| @@ -63,38 +76,42 @@ struct ContentView: View { |
| 63 | 76 | editingTrackedDomain = trackedDomain |
| 64 | 77 | } |
| 65 | 78 | ) |
| 66 | | .padding(.top, 16) |
| 79 | .padding(.top, appDensity.metrics.sectionSpacing) |
| 67 | 80 | OwnershipSectionView( |
| 81 | isCollapsed: sectionCollapsedBinding(.ownership), |
| 68 | 82 | rows: viewModel.ownershipRows, |
| 69 | 83 | loading: viewModel.ownershipLoading, |
| 70 | 84 | error: viewModel.ownershipError, |
| 71 | 85 | showsHistoryPlaceholder: !DataAccessService.hasAccess(to: .ownershipHistory) |
| 72 | 86 | ) |
| 73 | | .padding(.top, 16) |
| 87 | .padding(.top, appDensity.metrics.sectionSpacing) |
| 74 | 88 | SubdomainsSectionView( |
| 89 | isCollapsed: sectionCollapsedBinding(.subdomains), |
| 75 | 90 | rows: viewModel.subdomainRows, |
| 76 | 91 | loading: viewModel.subdomainsLoading, |
| 77 | 92 | error: viewModel.subdomainsError, |
| 78 | 93 | showsExtendedPlaceholder: !DataAccessService.hasAccess(to: .extendedSubdomains) |
| 79 | 94 | ) |
| 80 | | .padding(.top, 16) |
| 95 | .padding(.top, appDensity.metrics.sectionSpacing) |
| 81 | 96 | if !viewModel.currentDiffSections.isEmpty { |
| 82 | 97 | DomainDiffView( |
| 83 | 98 | title: "Latest Changes", |
| 84 | 99 | sections: viewModel.currentDiffSections, |
| 85 | 100 | showsUnchanged: false |
| 86 | 101 | ) |
| 87 | | .padding(.top, 16) |
| 102 | .padding(.top, appDensity.metrics.sectionSpacing) |
| 88 | 103 | } |
| 89 | 104 | DNSSectionView( |
| 105 | isCollapsed: sectionCollapsedBinding(.dns), |
| 90 | 106 | dnssecLabel: viewModel.dnssecLabel, |
| 91 | 107 | sections: viewModel.dnsRows, |
| 92 | 108 | ptrMessage: viewModel.ptrMessage, |
| 93 | 109 | loading: viewModel.dnsLoading || viewModel.ptrLoading, |
| 94 | 110 | sectionError: viewModel.dnsError |
| 95 | 111 | ) |
| 96 | | .padding(.top, 16) |
| 112 | .padding(.top, appDensity.metrics.sectionSpacing) |
| 97 | 113 | WebSectionView( |
| 114 | isCollapsed: sectionCollapsedBinding(.web), |
| 98 | 115 | certificateRows: viewModel.webCertificateRows, |
| 99 | 116 | sslInfo: viewModel.sslInfo, |
| 100 | 117 | sslLoading: viewModel.sslLoading || viewModel.hstsLoading, |
| @@ -108,14 +125,16 @@ struct ContentView: View { |
| 108 | 125 | redirectError: viewModel.redirectChainError, |
| 109 | 126 | finalURL: viewModel.currentSnapshot.redirectChain.last?.url |
| 110 | 127 | ) |
| 111 | | .padding(.top, 16) |
| 128 | .padding(.top, appDensity.metrics.sectionSpacing) |
| 112 | 129 | EmailSectionView( |
| 130 | isCollapsed: sectionCollapsedBinding(.email), |
| 113 | 131 | rows: viewModel.emailRows, |
| 114 | 132 | loading: viewModel.emailSecurityLoading, |
| 115 | 133 | error: viewModel.emailSecurityError |
| 116 | 134 | ) |
| 117 | | .padding(.top, 16) |
| 135 | .padding(.top, appDensity.metrics.sectionSpacing) |
| 118 | 136 | NetworkSectionView( |
| 137 | isCollapsed: sectionCollapsedBinding(.network), |
| 119 | 138 | reachabilityRows: viewModel.reachabilityRows, |
| 120 | 139 | reachabilityLoading: viewModel.reachabilityLoading, |
| 121 | 140 | reachabilityError: viewModel.reachabilityError, |
| @@ -134,7 +153,7 @@ struct ContentView: View { |
| 134 | 153 | customPortInput: $customPortInput, |
| 135 | 154 | onScanCustomPorts: runCustomPortScan |
| 136 | 155 | ) |
| 137 | | .padding(.top, 16) |
| 156 | .padding(.top, appDensity.metrics.sectionSpacing) |
| 138 | 157 | } else if !viewModel.recentSearches.isEmpty { |
| 139 | 158 | recentSearchesSection |
| 140 | 159 | } |
| @@ -142,7 +161,34 @@ struct ContentView: View { |
| 142 | 161 | .padding(.horizontal) |
| 143 | 162 | .padding(.bottom, 32) |
| 144 | 163 | } |
| 145 | | .background(Color.black) |
| 164 | .safeAreaInset(edge: .top) { |
| 165 | if viewModel.hasRun { |
| 166 | StickyLookupSummaryView( |
| 167 | domain: viewModel.searchedDomain, |
| 168 | availability: viewModel.availabilityResult?.status, |
| 169 | primaryIP: currentPrimaryIP, |
| 170 | sslInfo: viewModel.sslInfo, |
| 171 | sslError: viewModel.sslError, |
| 172 | emailSecurity: viewModel.emailSecurity, |
| 173 | emailError: viewModel.emailSecurityError, |
| 174 | changeSummary: viewModel.currentChangeSummary |
| 175 | ) |
| 176 | .padding(.horizontal) |
| 177 | .padding(.top, 6) |
| 178 | .background { |
| 179 | Rectangle() |
| 180 | .fill(.ultraThinMaterial) |
| 181 | .opacity(0.96) |
| 182 | } |
| 183 | } |
| 184 | } |
| 185 | .background( |
| 186 | LinearGradient( |
| 187 | colors: [Color.black, Color(.systemGray6).opacity(0.12)], |
| 188 | startPoint: .top, |
| 189 | endPoint: .bottom |
| 190 | ) |
| 191 | ) |
| 146 | 192 | .navigationTitle("DomainDig") |
| 147 | 193 | .toolbarColorScheme(.dark, for: .navigationBar) |
| 148 | 194 | .preferredColorScheme(.dark) |
| @@ -176,7 +222,7 @@ struct ContentView: View { |
| 176 | 222 | } |
| 177 | 223 | |
| 178 | 224 | NavigationLink { |
| 179 | | SettingsView() |
| 225 | SettingsView(viewModel: viewModel) |
| 180 | 226 | } label: { |
| 181 | 227 | Label("Settings", systemImage: "gearshape") |
| 182 | 228 | } |
| @@ -190,6 +236,9 @@ struct ContentView: View { |
| 190 | 236 | .onAppear { |
| 191 | 237 | domainFieldFocused = true |
| 192 | 238 | } |
| 239 | .onChange(of: viewModel.searchedDomain) { _, _ in |
| 240 | collapsedSections = defaultCollapsedSections |
| 241 | } |
| 193 | 242 | .onChange(of: viewModel.rerunNavigationToken) { _, _ in |
| 194 | 243 | navigationPath = NavigationPath() |
| 195 | 244 | domainFieldFocused = false |
| @@ -228,7 +277,7 @@ struct ContentView: View { |
| 228 | 277 | } |
| 229 | 278 | |
| 230 | 279 | private var inputSection: some View { |
| 231 | | VStack(spacing: 12) { |
| 280 | VStack(spacing: appDensity.metrics.cardSpacing + 2) { |
| 232 | 281 | Picker("Mode", selection: $inputMode) { |
| 233 | 282 | Text("Single").tag(LookupInputMode.single) |
| 234 | 283 | Text("Bulk").tag(LookupInputMode.bulk) |
| @@ -237,13 +286,14 @@ struct ContentView: View { |
| 237 | 286 | |
| 238 | 287 | if inputMode == .single { |
| 239 | 288 | TextField("e.g. cleberg.net", text: $viewModel.domain) |
| 240 | | .font(.system(.title3, design: .monospaced)) |
| 289 | .font(appDensity.font(.title3, design: .monospaced)) |
| 241 | 290 | .textInputAutocapitalization(.never) |
| 242 | 291 | .autocorrectionDisabled() |
| 243 | 292 | .keyboardType(.URL) |
| 244 | | .padding(12) |
| 293 | .padding(.horizontal, 12) |
| 294 | .padding(.vertical, appDensity.metrics.controlVerticalPadding) |
| 245 | 295 | .background(Color(.systemGray6)) |
| 246 | | .cornerRadius(8) |
| 296 | .clipShape(RoundedRectangle(cornerRadius: appDensity.metrics.cardCornerRadius)) |
| 247 | 297 | .focused($domainFieldFocused) |
| 248 | 298 | .onSubmit { viewModel.run() } |
| 249 | 299 | |
| @@ -252,16 +302,16 @@ struct ContentView: View { |
| 252 | 302 | viewModel.run() |
| 253 | 303 | } label: { |
| 254 | 304 | Text("Run") |
| 255 | | .font(.headline) |
| 305 | .font(appDensity.font(.headline, design: .default, weight: .semibold)) |
| 256 | 306 | .frame(maxWidth: .infinity) |
| 257 | | .padding(.vertical, 12) |
| 307 | .frame(minHeight: appDensity.metrics.controlMinHeight) |
| 258 | 308 | } |
| 259 | 309 | .buttonStyle(.borderedProminent) |
| 260 | 310 | .disabled(viewModel.trimmedDomain.isEmpty) |
| 261 | 311 | } else { |
| 262 | | VStack(alignment: .leading, spacing: 8) { |
| 312 | VStack(alignment: .leading, spacing: appDensity.metrics.cardSpacing) { |
| 263 | 313 | Text("Paste domains separated by new lines or commas.") |
| 264 | | .font(.system(.caption, design: .monospaced)) |
| 314 | .font(appDensity.font(.caption)) |
| 265 | 315 | .foregroundStyle(.secondary) |
| 266 | 316 | |
| 267 | 317 | TextField( |
| @@ -269,30 +319,31 @@ struct ContentView: View { |
| 269 | 319 | text: $viewModel.bulkInput, |
| 270 | 320 | axis: .vertical |
| 271 | 321 | ) |
| 272 | | .font(.system(.body, design: .monospaced)) |
| 322 | .font(appDensity.font(.body)) |
| 273 | 323 | .textInputAutocapitalization(.never) |
| 274 | 324 | .autocorrectionDisabled() |
| 275 | 325 | .keyboardType(.URL) |
| 276 | 326 | .lineLimit(4...10) |
| 277 | | .padding(12) |
| 327 | .padding(.horizontal, 12) |
| 328 | .padding(.vertical, appDensity.metrics.controlVerticalPadding) |
| 278 | 329 | .background(Color(.systemGray6)) |
| 279 | | .cornerRadius(8) |
| 330 | .clipShape(RoundedRectangle(cornerRadius: appDensity.metrics.cardCornerRadius)) |
| 280 | 331 | |
| 281 | 332 | Button { |
| 282 | 333 | domainFieldFocused = false |
| 283 | 334 | viewModel.runBulkLookup() |
| 284 | 335 | } label: { |
| 285 | 336 | Text(viewModel.batchLookupRunning ? "Running Batch…" : "Run Batch") |
| 286 | | .font(.headline) |
| 337 | .font(appDensity.font(.headline, design: .default, weight: .semibold)) |
| 287 | 338 | .frame(maxWidth: .infinity) |
| 288 | | .padding(.vertical, 12) |
| 339 | .frame(minHeight: appDensity.metrics.controlMinHeight) |
| 289 | 340 | } |
| 290 | 341 | .buttonStyle(.borderedProminent) |
| 291 | 342 | .disabled(viewModel.bulkInput.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || viewModel.batchLookupRunning) |
| 292 | 343 | } |
| 293 | 344 | } |
| 294 | 345 | } |
| 295 | | .padding(.vertical, 16) |
| 346 | .padding(.vertical, appDensity.metrics.sectionSpacing) |
| 296 | 347 | } |
| 297 | 348 | |
| 298 | 349 | private var actionButtons: some View { |
| @@ -303,7 +354,7 @@ struct ContentView: View { |
| 303 | 354 | viewModel.toggleSavedDomain() |
| 304 | 355 | } label: { |
| 305 | 356 | Image(systemName: viewModel.isCurrentDomainSaved ? "bookmark.fill" : "bookmark") |
| 306 | | .font(.system(.body)) |
| 357 | .font(appDensity.font(.body, design: .default)) |
| 307 | 358 | .foregroundStyle(viewModel.isCurrentDomainSaved ? .yellow : .secondary) |
| 308 | 359 | } |
| 309 | 360 | Menu { |
| @@ -318,7 +369,7 @@ struct ContentView: View { |
| 318 | 369 | } |
| 319 | 370 | } label: { |
| 320 | 371 | Image(systemName: "square.and.arrow.up") |
| 321 | | .font(.system(.body)) |
| 372 | .font(appDensity.font(.body, design: .default)) |
| 322 | 373 | .foregroundStyle(.secondary) |
| 323 | 374 | } |
| 324 | 375 | } |
| @@ -326,7 +377,7 @@ struct ContentView: View { |
| 326 | 377 | } |
| 327 | 378 | |
| 328 | 379 | private var batchSection: some View { |
| 329 | | VStack(alignment: .leading, spacing: 12) { |
| 380 | VStack(alignment: .leading, spacing: appDensity.metrics.cardSpacing) { |
| 330 | 381 | HStack { |
| 331 | 382 | Spacer() |
| 332 | 383 | if viewModel.batchLookupRunning { |
| @@ -334,7 +385,7 @@ struct ContentView: View { |
| 334 | 385 | viewModel.cancelBatchLookup() |
| 335 | 386 | } |
| 336 | 387 | .buttonStyle(.bordered) |
| 337 | | .font(.system(.caption, design: .monospaced)) |
| 388 | .font(appDensity.font(.caption)) |
| 338 | 389 | } |
| 339 | 390 | if !viewModel.currentBatchResultEntries.isEmpty { |
| 340 | 391 | Menu { |
| @@ -349,7 +400,7 @@ struct ContentView: View { |
| 349 | 400 | } |
| 350 | 401 | } label: { |
| 351 | 402 | Label("Export", systemImage: "square.and.arrow.up") |
| 352 | | .font(.system(.caption, design: .monospaced)) |
| 403 | .font(appDensity.font(.caption)) |
| 353 | 404 | } |
| 354 | 405 | .buttonStyle(.bordered) |
| 355 | 406 | } |
| @@ -363,16 +414,16 @@ struct ContentView: View { |
| 363 | 414 | } |
| 364 | 415 | |
| 365 | 416 | private var recentSearchesSection: some View { |
| 366 | | VStack(alignment: .leading, spacing: 8) { |
| 417 | VStack(alignment: .leading, spacing: appDensity.metrics.cardSpacing) { |
| 367 | 418 | HStack { |
| 368 | 419 | Text("RECENT") |
| 369 | | .font(.system(.caption2, design: .monospaced)) |
| 420 | .font(appDensity.font(.caption2)) |
| 370 | 421 | .foregroundStyle(.secondary) |
| 371 | 422 | Spacer() |
| 372 | 423 | Button("Clear") { |
| 373 | 424 | viewModel.clearRecentSearches() |
| 374 | 425 | } |
| 375 | | .font(.system(.caption2, design: .monospaced)) |
| 426 | .font(appDensity.font(.caption2)) |
| 376 | 427 | .foregroundStyle(.secondary) |
| 377 | 428 | } |
| 378 | 429 | |
| @@ -383,17 +434,17 @@ struct ContentView: View { |
| 383 | 434 | viewModel.run() |
| 384 | 435 | } label: { |
| 385 | 436 | Text(domain) |
| 386 | | .font(.system(.callout, design: .monospaced)) |
| 437 | .font(appDensity.font(.callout)) |
| 387 | 438 | .foregroundStyle(.primary) |
| 388 | 439 | .frame(maxWidth: .infinity, alignment: .leading) |
| 389 | | .padding(.vertical, 6) |
| 440 | .padding(.vertical, 8) |
| 390 | 441 | .padding(.horizontal, 10) |
| 391 | 442 | .background(Color(.systemGray6).opacity(0.5)) |
| 392 | | .cornerRadius(6) |
| 443 | .clipShape(RoundedRectangle(cornerRadius: appDensity.metrics.cardCornerRadius)) |
| 393 | 444 | } |
| 394 | 445 | } |
| 395 | 446 | } |
| 396 | | .padding(.top, 8) |
| 447 | .padding(.top, appDensity.metrics.cardSpacing) |
| 397 | 448 | } |
| 398 | 449 | |
| 399 | 450 | private func runCustomPortScan() { |
| @@ -468,37 +519,125 @@ struct ContentView: View { |
| 468 | 519 | |
| 469 | 520 | return (filename, data) |
| 470 | 521 | } |
| 522 | |
| 523 | private var defaultCollapsedSections: Set<ResultSection> { |
| 524 | var sections: Set<ResultSection> = [] |
| 525 | if viewModel.standardPortRows.count + viewModel.customPortRows.count > 6 || currentPrimaryIP == nil { |
| 526 | sections.insert(.network) |
| 527 | } |
| 528 | return sections |
| 529 | } |
| 530 | |
| 531 | private var currentPrimaryIP: String? { |
| 532 | viewModel.currentSnapshot.dnsSections.first(where: { $0.recordType == .A })?.records.first?.value |
| 533 | } |
| 534 | |
| 535 | private var resultStatusMessage: String? { |
| 536 | if let currentStatusMessage = viewModel.currentStatusMessage { |
| 537 | return currentStatusMessage |
| 538 | } |
| 539 | |
| 540 | if viewModel.currentResultSource != .live { |
| 541 | return viewModel.currentResultSource.label |
| 542 | } |
| 543 | |
| 544 | return nil |
| 545 | } |
| 546 | |
| 547 | private func sectionCollapsedBinding(_ section: ResultSection) -> Binding<Bool> { |
| 548 | Binding( |
| 549 | get: { collapsedSections.contains(section) }, |
| 550 | set: { isCollapsed in |
| 551 | if isCollapsed { |
| 552 | collapsedSections.insert(section) |
| 553 | } else { |
| 554 | collapsedSections.remove(section) |
| 555 | } |
| 556 | } |
| 557 | ) |
| 558 | } |
| 471 | 559 | } |
| 472 | 560 | |
| 473 | 561 | struct SummaryView: View { |
| 562 | @Environment(\.appDensity) private var appDensity |
| 474 | 563 | let fields: [SummaryFieldViewData] |
| 475 | 564 | |
| 476 | 565 | var body: some View { |
| 477 | | VStack(alignment: .leading, spacing: 12) { |
| 566 | VStack(alignment: .leading, spacing: appDensity.metrics.cardSpacing) { |
| 478 | 567 | SectionTitleView(title: "Summary") |
| 479 | | LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 8) { |
| 568 | LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: appDensity.metrics.cardSpacing) { |
| 480 | 569 | ForEach(fields) { field in |
| 481 | | VStack(alignment: .leading, spacing: 4) { |
| 570 | VStack(alignment: .leading, spacing: appDensity.metrics.rowSpacing) { |
| 482 | 571 | Text(field.label) |
| 483 | | .font(.system(.caption2, design: .monospaced)) |
| 572 | .font(appDensity.font(.caption2)) |
| 484 | 573 | .foregroundStyle(.secondary) |
| 485 | 574 | Text(field.value) |
| 486 | | .font(.system(.caption, design: .monospaced)) |
| 575 | .font(appDensity.font(.caption)) |
| 487 | 576 | .foregroundStyle(ResultColors.color(for: field.tone)) |
| 488 | 577 | .lineLimit(2) |
| 489 | 578 | .textSelection(.enabled) |
| 490 | 579 | } |
| 580 | .frame(minHeight: appDensity.metrics.rowMinHeight + 12, alignment: .topLeading) |
| 491 | 581 | .frame(maxWidth: .infinity, alignment: .leading) |
| 492 | | .padding(10) |
| 582 | .padding(appDensity.metrics.cardPadding) |
| 493 | 583 | .background(Color(.systemGray6).opacity(0.5)) |
| 494 | | .cornerRadius(6) |
| 584 | .clipShape(RoundedRectangle(cornerRadius: appDensity.metrics.cardCornerRadius)) |
| 495 | 585 | } |
| 496 | 586 | } |
| 497 | 587 | } |
| 498 | 588 | } |
| 499 | 589 | } |
| 500 | 590 | |
| 591 | struct StickyLookupSummaryView: View { |
| 592 | @Environment(\.appDensity) private var appDensity |
| 593 | |
| 594 | let domain: String |
| 595 | let availability: DomainAvailabilityStatus? |
| 596 | let primaryIP: String? |
| 597 | let sslInfo: SSLCertificateInfo? |
| 598 | let sslError: String? |
| 599 | let emailSecurity: EmailSecurityResult? |
| 600 | let emailError: String? |
| 601 | let changeSummary: DomainChangeSummary? |
| 602 | |
| 603 | var body: some View { |
| 604 | CardView(allowsHorizontalScroll: false) { |
| 605 | VStack(alignment: .leading, spacing: appDensity.metrics.cardSpacing) { |
| 606 | HStack(alignment: .center, spacing: 10) { |
| 607 | Text(domain) |
| 608 | .font(appDensity.font(.headline, weight: .semibold)) |
| 609 | .foregroundStyle(.primary) |
| 610 | .lineLimit(1) |
| 611 | Spacer(minLength: 6) |
| 612 | AppCopyButton(value: domain, label: "Copy domain") |
| 613 | } |
| 614 | |
| 615 | ScrollView(.horizontal, showsIndicators: false) { |
| 616 | HStack(spacing: 8) { |
| 617 | AppStatusBadgeView(model: AppStatusFactory.availability(availability)) |
| 618 | AppStatusBadgeView(model: AppStatusFactory.tls(sslInfo: sslInfo, error: sslError)) |
| 619 | AppStatusBadgeView(model: AppStatusFactory.email(emailSecurity, error: emailError)) |
| 620 | AppStatusBadgeView(model: AppStatusFactory.change(changeSummary)) |
| 621 | } |
| 622 | } |
| 623 | |
| 624 | if let primaryIP { |
| 625 | HStack(spacing: 8) { |
| 626 | Label(primaryIP, systemImage: "network") |
| 627 | .font(appDensity.font(.caption)) |
| 628 | .foregroundStyle(.secondary) |
| 629 | Spacer(minLength: 6) |
| 630 | AppCopyButton(value: primaryIP, label: "Copy IP") |
| 631 | } |
| 632 | } |
| 633 | } |
| 634 | } |
| 635 | .shadow(color: .black.opacity(0.12), radius: 14, y: 6) |
| 636 | } |
| 637 | } |
| 638 | |
| 501 | 639 | struct LookupStatusBannerView: View { |
| 640 | @Environment(\.appDensity) private var appDensity |
| 502 | 641 | let message: String |
| 503 | 642 | let resultSource: LookupResultSource |
| 504 | 643 | |
| @@ -507,14 +646,14 @@ struct LookupStatusBannerView: View { |
| 507 | 646 | Image(systemName: iconName) |
| 508 | 647 | .font(.caption) |
| 509 | 648 | Text(message) |
| 510 | | .font(.system(.caption, design: .monospaced)) |
| 649 | .font(appDensity.font(.caption)) |
| 511 | 650 | Spacer() |
| 512 | 651 | } |
| 513 | 652 | .foregroundStyle(color) |
| 514 | | .padding(8) |
| 653 | .padding(appDensity.metrics.cardPadding - 2) |
| 515 | 654 | .frame(maxWidth: .infinity, alignment: .leading) |
| 516 | 655 | .background(color.opacity(0.12)) |
| 517 | | .clipShape(RoundedRectangle(cornerRadius: 8)) |
| 656 | .clipShape(RoundedRectangle(cornerRadius: appDensity.metrics.cardCornerRadius)) |
| 518 | 657 | } |
| 519 | 658 | |
| 520 | 659 | private var color: Color { |
| @@ -545,29 +684,30 @@ struct LookupStatusBannerView: View { |
| 545 | 684 | } |
| 546 | 685 | |
| 547 | 686 | struct DomainChangeSummaryView: View { |
| 687 | @Environment(\.appDensity) private var appDensity |
| 548 | 688 | let summary: DomainChangeSummary |
| 549 | 689 | |
| 550 | 690 | var body: some View { |
| 551 | 691 | CardView(allowsHorizontalScroll: false) { |
| 552 | 692 | HStack { |
| 553 | 693 | Label(summary.hasChanges ? "Changed" : "Stable", systemImage: summary.hasChanges ? "arrow.triangle.2.circlepath" : "checkmark.circle") |
| 554 | | .font(.system(.caption, design: .monospaced)) |
| 694 | .font(appDensity.font(.caption)) |
| 555 | 695 | .foregroundStyle(summary.hasChanges ? severityColor(summary.severity) : .green) |
| 556 | 696 | Spacer() |
| 557 | 697 | Text(summary.severity.title.uppercased()) |
| 558 | | .font(.system(.caption2, design: .monospaced)) |
| 698 | .font(appDensity.font(.caption2)) |
| 559 | 699 | .foregroundStyle(summary.hasChanges ? severityColor(summary.severity) : .secondary) |
| 560 | 700 | .padding(.horizontal, 8) |
| 561 | 701 | .padding(.vertical, 4) |
| 562 | 702 | .background((summary.hasChanges ? severityColor(summary.severity) : .secondary).opacity(0.16)) |
| 563 | 703 | .clipShape(Capsule()) |
| 564 | 704 | Text(summary.generatedAt, style: .time) |
| 565 | | .font(.system(.caption2, design: .monospaced)) |
| 705 | .font(appDensity.font(.caption2)) |
| 566 | 706 | .foregroundStyle(.secondary) |
| 567 | 707 | } |
| 568 | 708 | |
| 569 | 709 | Text(summary.message) |
| 570 | | .font(.system(.caption, design: .monospaced)) |
| 710 | .font(appDensity.font(.caption)) |
| 571 | 711 | .foregroundStyle(.primary) |
| 572 | 712 | } |
| 573 | 713 | } |
| @@ -770,6 +910,8 @@ struct TrackedDomainDetailHeaderView: View { |
| 770 | 910 | } |
| 771 | 911 | |
| 772 | 912 | struct DomainSectionView: View { |
| 913 | @Environment(\.appDensity) private var appDensity |
| 914 | @Binding var isCollapsed: Bool |
| 773 | 915 | let rows: [InfoRowViewData] |
| 774 | 916 | let suggestions: [DomainSuggestionViewData] |
| 775 | 917 | let showSuggestions: Bool |
| @@ -782,38 +924,34 @@ struct DomainSectionView: View { |
| 782 | 924 | let onEditNote: (() -> Void)? |
| 783 | 925 | |
| 784 | 926 | var body: some View { |
| 785 | | VStack(alignment: .leading, spacing: 12) { |
| 786 | | HStack { |
| 787 | | SectionTitleView(title: "Domain") |
| 788 | | Spacer() |
| 789 | | if let trackedDomain { |
| 790 | | HStack(spacing: 8) { |
| 791 | | Text("Tracked") |
| 792 | | .font(.system(.caption, design: .monospaced)) |
| 793 | | .foregroundStyle(.green) |
| 794 | | Button { |
| 795 | | onTogglePinned() |
| 796 | | } label: { |
| 797 | | Image(systemName: trackedDomain.isPinned ? "pin.fill" : "pin") |
| 927 | CollapsibleSectionView(title: "Domain", isCollapsed: $isCollapsed) { |
| 928 | if let trackedDomain { |
| 929 | HStack(spacing: 8) { |
| 930 | AppStatusBadgeView(model: .init(title: "Tracked", systemImage: "eye.fill", foregroundColor: .green, backgroundColor: .green.opacity(0.16))) |
| 931 | Button { |
| 932 | onTogglePinned() |
| 933 | } label: { |
| 934 | Image(systemName: trackedDomain.isPinned ? "pin.fill" : "pin") |
| 935 | } |
| 936 | .buttonStyle(.bordered) |
| 937 | .font(appDensity.font(.caption)) |
| 938 | if let onEditNote { |
| 939 | Button("Note") { |
| 940 | onEditNote() |
| 798 | 941 | } |
| 799 | 942 | .buttonStyle(.bordered) |
| 800 | | .font(.system(.caption, design: .monospaced)) |
| 801 | | if let onEditNote { |
| 802 | | Button("Note") { |
| 803 | | onEditNote() |
| 804 | | } |
| 805 | | .buttonStyle(.bordered) |
| 806 | | .font(.system(.caption, design: .monospaced)) |
| 807 | | } |
| 808 | | } |
| 809 | | } else { |
| 810 | | Button("Track") { |
| 811 | | onTrack() |
| 943 | .font(appDensity.font(.caption)) |
| 812 | 944 | } |
| 813 | | .buttonStyle(.bordered) |
| 814 | | .font(.system(.caption, design: .monospaced)) |
| 815 | 945 | } |
| 946 | } else { |
| 947 | Button("Track") { |
| 948 | AppHaptics.track() |
| 949 | onTrack() |
| 950 | } |
| 951 | .buttonStyle(.bordered) |
| 952 | .font(appDensity.font(.caption)) |
| 816 | 953 | } |
| 954 | } content: { |
| 817 | 955 | CardView(allowsHorizontalScroll: false) { |
| 818 | 956 | ForEach(rows) { row in |
| 819 | 957 | LabeledValueRow(row: row) |
| @@ -832,7 +970,7 @@ struct DomainSectionView: View { |
| 832 | 970 | } |
| 833 | 971 | if showSuggestions { |
| 834 | 972 | Text("Suggestions") |
| 835 | | .font(.system(.caption, design: .monospaced)) |
| 973 | .font(appDensity.font(.caption)) |
| 836 | 974 | .foregroundStyle(.secondary) |
| 837 | 975 | .padding(.top, 4) |
| 838 | 976 | if suggestionsLoading { |
| @@ -844,13 +982,11 @@ struct DomainSectionView: View { |
| 844 | 982 | ForEach(suggestions) { suggestion in |
| 845 | 983 | HStack { |
| 846 | 984 | Text(suggestion.domain) |
| 847 | | .font(.system(.caption, design: .monospaced)) |
| 985 | .font(appDensity.font(.caption)) |
| 848 | 986 | .foregroundStyle(.primary) |
| 849 | 987 | .textSelection(.enabled) |
| 850 | 988 | Spacer() |
| 851 | | Text(suggestion.status) |
| 852 | | .font(.system(.caption2, design: .monospaced)) |
| 853 | | .foregroundStyle(ResultColors.color(for: suggestion.tone)) |
| 989 | AppStatusBadgeView(model: AppStatusFactory.availability(suggestion.status == "Available" ? .available : .registered)) |
| 854 | 990 | } |
| 855 | 991 | } |
| 856 | 992 | } |
| @@ -861,14 +997,14 @@ struct DomainSectionView: View { |
| 861 | 997 | } |
| 862 | 998 | |
| 863 | 999 | struct OwnershipSectionView: View { |
| 1000 | @Binding var isCollapsed: Bool |
| 864 | 1001 | let rows: [InfoRowViewData] |
| 865 | 1002 | let loading: Bool |
| 866 | 1003 | let error: String? |
| 867 | 1004 | let showsHistoryPlaceholder: Bool |
| 868 | 1005 | |
| 869 | 1006 | var body: some View { |
| 870 | | VStack(alignment: .leading, spacing: 12) { |
| 871 | | SectionTitleView(title: "Ownership") |
| 1007 | CollapsibleSectionView(title: "Ownership", isCollapsed: $isCollapsed) { |
| 872 | 1008 | CardView(allowsHorizontalScroll: false) { |
| 873 | 1009 | if loading { |
| 874 | 1010 | ProgressView("Fetching RDAP ownership…") |
| @@ -892,21 +1028,15 @@ struct OwnershipSectionView: View { |
| 892 | 1028 | } |
| 893 | 1029 | |
| 894 | 1030 | struct SubdomainsSectionView: View { |
| 1031 | @Environment(\.appDensity) private var appDensity |
| 1032 | @Binding var isCollapsed: Bool |
| 895 | 1033 | let rows: [SubdomainRowViewData] |
| 896 | 1034 | let loading: Bool |
| 897 | 1035 | let error: String? |
| 898 | 1036 | let showsExtendedPlaceholder: Bool |
| 899 | 1037 | |
| 900 | 1038 | var body: some View { |
| 901 | | VStack(alignment: .leading, spacing: 12) { |
| 902 | | HStack { |
| 903 | | SectionTitleView(title: "Subdomains") |
| 904 | | Spacer() |
| 905 | | Text("\(rows.count)") |
| 906 | | .font(.system(.caption2, design: .monospaced)) |
| 907 | | .foregroundStyle(.secondary) |
| 908 | | } |
| 909 | | |
| 1039 | CollapsibleSectionView(title: "Subdomains", isCollapsed: $isCollapsed, subtitle: "\(rows.count) found") { |
| 910 | 1040 | CardView(allowsHorizontalScroll: false) { |
| 911 | 1041 | if loading { |
| 912 | 1042 | ProgressView("Checking certificate transparency…") |
| @@ -921,7 +1051,7 @@ struct SubdomainsSectionView: View { |
| 921 | 1051 | ForEach(rows) { row in |
| 922 | 1052 | HStack(spacing: 8) { |
| 923 | 1053 | Text(row.hostname) |
| 924 | | .font(.system(.caption, design: .monospaced)) |
| 1054 | .font(appDensity.font(.caption)) |
| 925 | 1055 | .foregroundStyle(.primary) |
| 926 | 1056 | .textSelection(.enabled) |
| 927 | 1057 | Spacer() |
| @@ -947,6 +1077,7 @@ struct SubdomainsSectionView: View { |
| 947 | 1077 | } |
| 948 | 1078 | |
| 949 | 1079 | struct DNSSectionView: View { |
| 1080 | @Binding var isCollapsed: Bool |
| 950 | 1081 | let dnssecLabel: String? |
| 951 | 1082 | let sections: [DNSRecordSectionViewData] |
| 952 | 1083 | let ptrMessage: SectionMessageViewData? |
| @@ -954,18 +1085,7 @@ struct DNSSectionView: View { |
| 954 | 1085 | let sectionError: String? |
| 955 | 1086 | |
| 956 | 1087 | var body: some View { |
| 957 | | VStack(alignment: .leading, spacing: 12) { |
| 958 | | HStack(alignment: .top, spacing: 8) { |
| 959 | | SectionTitleView(title: "DNS") |
| 960 | | Spacer() |
| 961 | | if let dnssecLabel { |
| 962 | | Text(dnssecLabel) |
| 963 | | .font(.system(.caption2, design: .monospaced)) |
| 964 | | .foregroundStyle(.secondary) |
| 965 | | .multilineTextAlignment(.trailing) |
| 966 | | } |
| 967 | | } |
| 968 | | |
| 1088 | CollapsibleSectionView(title: "DNS", isCollapsed: $isCollapsed, subtitle: dnssecLabel) { |
| 969 | 1089 | if loading { |
| 970 | 1090 | LoadingCardView(text: "Querying DNS…") |
| 971 | 1091 | } else if let sectionError, sections.isEmpty { |
| @@ -1013,6 +1133,8 @@ struct DNSSectionView: View { |
| 1013 | 1133 | } |
| 1014 | 1134 | |
| 1015 | 1135 | struct WebSectionView: View { |
| 1136 | @Environment(\.appDensity) private var appDensity |
| 1137 | @Binding var isCollapsed: Bool |
| 1016 | 1138 | let certificateRows: [InfoRowViewData] |
| 1017 | 1139 | let sslInfo: SSLCertificateInfo? |
| 1018 | 1140 | let sslLoading: Bool |
| @@ -1027,14 +1149,15 @@ struct WebSectionView: View { |
| 1027 | 1149 | let finalURL: String? |
| 1028 | 1150 | |
| 1029 | 1151 | var body: some View { |
| 1030 | | VStack(alignment: .leading, spacing: 12) { |
| 1031 | | SectionTitleView(title: "Web") |
| 1032 | | |
| 1152 | CollapsibleSectionView(title: "Web", isCollapsed: $isCollapsed) { |
| 1033 | 1153 | CardView { |
| 1034 | | Text("TLS") |
| 1035 | | .font(.system(.subheadline, design: .monospaced)) |
| 1036 | | .fontWeight(.semibold) |
| 1037 | | .foregroundStyle(.cyan) |
| 1154 | HStack { |
| 1155 | Text("TLS") |
| 1156 | .font(appDensity.font(.subheadline, weight: .semibold)) |
| 1157 | .foregroundStyle(.cyan) |
| 1158 | Spacer() |
| 1159 | AppStatusBadgeView(model: AppStatusFactory.tls(sslInfo: sslInfo, error: sslError)) |
| 1160 | } |
| 1038 | 1161 | if sslLoading { |
| 1039 | 1162 | ProgressView("Checking certificate…") |
| 1040 | 1163 | .appLoadingStyle() |
| @@ -1046,12 +1169,16 @@ struct WebSectionView: View { |
| 1046 | 1169 | } |
| 1047 | 1170 | if let sslInfo, !sslInfo.subjectAltNames.isEmpty { |
| 1048 | 1171 | Text("SANs") |
| 1049 | | .font(.system(.caption2, design: .monospaced)) |
| 1172 | .font(appDensity.font(.caption2)) |
| 1050 | 1173 | .foregroundStyle(.secondary) |
| 1051 | 1174 | ForEach(sslInfo.subjectAltNames, id: \.self) { san in |
| 1052 | | Text(san) |
| 1053 | | .font(.system(.caption, design: .monospaced)) |
| 1054 | | .textSelection(.enabled) |
| 1175 | HStack(spacing: 8) { |
| 1176 | Text(san) |
| 1177 | .font(appDensity.font(.caption)) |
| 1178 | .textSelection(.enabled) |
| 1179 | Spacer() |
| 1180 | AppCopyButton(value: san, label: "Copy certificate SAN") |
| 1181 | } |
| 1055 | 1182 | } |
| 1056 | 1183 | } |
| 1057 | 1184 | } |
| @@ -1059,8 +1186,7 @@ struct WebSectionView: View { |
| 1059 | 1186 | |
| 1060 | 1187 | CardView { |
| 1061 | 1188 | Text("Headers") |
| 1062 | | .font(.system(.subheadline, design: .monospaced)) |
| 1063 | | .fontWeight(.semibold) |
| 1189 | .font(appDensity.font(.subheadline, weight: .semibold)) |
| 1064 | 1190 | .foregroundStyle(.cyan) |
| 1065 | 1191 | if headersLoading { |
| 1066 | 1192 | ProgressView("Fetching headers…") |
| @@ -1077,10 +1203,10 @@ struct WebSectionView: View { |
| 1077 | 1203 | ForEach(headers) { header in |
| 1078 | 1204 | HStack(alignment: .top, spacing: 4) { |
| 1079 | 1205 | Text(header.name + ":") |
| 1080 | | .font(.system(.caption, design: .monospaced)) |
| 1206 | .font(appDensity.font(.caption)) |
| 1081 | 1207 | .foregroundStyle(header.isSecurityHeader ? .yellow : .cyan) |
| 1082 | 1208 | Text(header.value) |
| 1083 | | .font(.system(.caption, design: .monospaced)) |
| 1209 | .font(appDensity.font(.caption)) |
| 1084 | 1210 | .foregroundStyle(.primary) |
| 1085 | 1211 | .textSelection(.enabled) |
| 1086 | 1212 | } |
| @@ -1090,10 +1216,15 @@ struct WebSectionView: View { |
| 1090 | 1216 | } |
| 1091 | 1217 | |
| 1092 | 1218 | CardView { |
| 1093 | | Text("Redirects") |
| 1094 | | .font(.system(.subheadline, design: .monospaced)) |
| 1095 | | .fontWeight(.semibold) |
| 1096 | | .foregroundStyle(.cyan) |
| 1219 | HStack { |
| 1220 | Text("Redirects") |
| 1221 | .font(appDensity.font(.subheadline, weight: .semibold)) |
| 1222 | .foregroundStyle(.cyan) |
| 1223 | Spacer() |
| 1224 | if let finalURL { |
| 1225 | AppCopyButton(value: finalURL, label: "Copy redirect URL") |
| 1226 | } |
| 1227 | } |
| 1097 | 1228 | if redirectLoading { |
| 1098 | 1229 | ProgressView("Tracing redirects…") |
| 1099 | 1230 | .appLoadingStyle() |
| @@ -1108,19 +1239,20 @@ struct WebSectionView: View { |
| 1108 | 1239 | ForEach(redirects) { redirect in |
| 1109 | 1240 | HStack(alignment: .top, spacing: 6) { |
| 1110 | 1241 | Text(redirect.stepLabel) |
| 1111 | | .font(.system(.caption, design: .monospaced)) |
| 1242 | .font(appDensity.font(.caption)) |
| 1112 | 1243 | .foregroundStyle(.secondary) |
| 1113 | 1244 | .frame(width: 16, alignment: .trailing) |
| 1114 | 1245 | Text(redirect.statusCode) |
| 1115 | | .font(.system(.caption, design: .monospaced)) |
| 1246 | .font(appDensity.font(.caption)) |
| 1116 | 1247 | .foregroundStyle(.cyan) |
| 1117 | 1248 | .frame(width: 36, alignment: .leading) |
| 1118 | 1249 | Text(redirect.url) |
| 1119 | | .font(.system(.caption, design: .monospaced)) |
| 1250 | .font(appDensity.font(.caption)) |
| 1120 | 1251 | .textSelection(.enabled) |
| 1252 | AppCopyButton(value: redirect.url, label: "Copy redirect URL") |
| 1121 | 1253 | if redirect.isFinal { |
| 1122 | 1254 | Text("(final)") |
| 1123 | | .font(.system(.caption2, design: .monospaced)) |
| 1255 | .font(appDensity.font(.caption2)) |
| 1124 | 1256 | .foregroundStyle(.secondary) |
| 1125 | 1257 | } |
| 1126 | 1258 | } |
| @@ -1132,14 +1264,20 @@ struct WebSectionView: View { |
| 1132 | 1264 | } |
| 1133 | 1265 | |
| 1134 | 1266 | struct EmailSectionView: View { |
| 1267 | @Environment(\.appDensity) private var appDensity |
| 1268 | @Binding var isCollapsed: Bool |
| 1135 | 1269 | let rows: [EmailRowViewData] |
| 1136 | 1270 | let loading: Bool |
| 1137 | 1271 | let error: String? |
| 1138 | 1272 | |
| 1139 | 1273 | var body: some View { |
| 1140 | | VStack(alignment: .leading, spacing: 12) { |
| 1141 | | SectionTitleView(title: "Email") |
| 1274 | CollapsibleSectionView(title: "Email", isCollapsed: $isCollapsed) { |
| 1142 | 1275 | CardView { |
| 1276 | HStack { |
| 1277 | Spacer() |
| 1278 | AppStatusBadgeView(model: AppStatusFactory.email(nil, error: error)) |
| 1279 | .opacity(loading ? 0 : 1) |
| 1280 | } |
| 1143 | 1281 | if loading { |
| 1144 | 1282 | ProgressView("Checking email records…") |
| 1145 | 1283 | .appLoadingStyle() |
| @@ -1152,20 +1290,18 @@ struct EmailSectionView: View { |
| 1152 | 1290 | VStack(alignment: .leading, spacing: 4) { |
| 1153 | 1291 | HStack(spacing: 8) { |
| 1154 | 1292 | Text(row.label) |
| 1155 | | .font(.system(.caption, design: .monospaced)) |
| 1293 | .font(appDensity.font(.caption)) |
| 1156 | 1294 | .foregroundStyle(.cyan) |
| 1157 | 1295 | .frame(width: 76, alignment: .leading) |
| 1158 | | Text(row.status) |
| 1159 | | .font(.system(.caption, design: .monospaced)) |
| 1160 | | .foregroundStyle(ResultColors.color(for: row.statusTone)) |
| 1296 | AppStatusBadgeView(model: emailRowBadge(row)) |
| 1161 | 1297 | } |
| 1162 | 1298 | Text(row.detail) |
| 1163 | | .font(.system(.caption2, design: .monospaced)) |
| 1299 | .font(appDensity.font(.caption2)) |
| 1164 | 1300 | .foregroundStyle(.primary) |
| 1165 | 1301 | .textSelection(.enabled) |
| 1166 | 1302 | if let auxiliaryDetail = row.auxiliaryDetail { |
| 1167 | 1303 | Text(auxiliaryDetail) |
| 1168 | | .font(.system(.caption2, design: .monospaced)) |
| 1304 | .font(appDensity.font(.caption2)) |
| 1169 | 1305 | .foregroundStyle(.secondary) |
| 1170 | 1306 | } |
| 1171 | 1307 | } |
| @@ -1174,9 +1310,24 @@ struct EmailSectionView: View { |
| 1174 | 1310 | } |
| 1175 | 1311 | } |
| 1176 | 1312 | } |
| 1313 | |
| 1314 | private func emailRowBadge(_ row: EmailRowViewData) -> AppStatusBadgeModel { |
| 1315 | switch row.statusTone { |
| 1316 | case .success: |
| 1317 | return .init(title: row.status, systemImage: "checkmark.shield.fill", foregroundColor: .green, backgroundColor: .green.opacity(0.16)) |
| 1318 | case .warning: |
| 1319 | return .init(title: row.status, systemImage: "shield.lefthalf.filled", foregroundColor: .yellow, backgroundColor: .yellow.opacity(0.16)) |
| 1320 | case .failure: |
| 1321 | return .init(title: row.status, systemImage: "minus.circle", foregroundColor: .secondary, backgroundColor: Color(.systemGray5).opacity(0.55)) |
| 1322 | case .primary, .secondary: |
| 1323 | return .init(title: row.status, systemImage: "circle", foregroundColor: .secondary, backgroundColor: Color(.systemGray5).opacity(0.55)) |
| 1324 | } |
| 1325 | } |
| 1177 | 1326 | } |
| 1178 | 1327 | |
| 1179 | 1328 | struct NetworkSectionView: View { |
| 1329 | @Environment(\.appDensity) private var appDensity |
| 1330 | @Binding var isCollapsed: Bool |
| 1180 | 1331 | let reachabilityRows: [ReachabilityRowViewData] |
| 1181 | 1332 | let reachabilityLoading: Bool |
| 1182 | 1333 | let reachabilityError: String? |
| @@ -1196,13 +1347,10 @@ struct NetworkSectionView: View { |
| 1196 | 1347 | let onScanCustomPorts: () -> Void |
| 1197 | 1348 | |
| 1198 | 1349 | var body: some View { |
| 1199 | | VStack(alignment: .leading, spacing: 12) { |
| 1200 | | SectionTitleView(title: "Network") |
| 1201 | | |
| 1350 | CollapsibleSectionView(title: "Network", isCollapsed: $isCollapsed) { |
| 1202 | 1351 | CardView { |
| 1203 | 1352 | Text("Reachability") |
| 1204 | | .font(.system(.subheadline, design: .monospaced)) |
| 1205 | | .fontWeight(.semibold) |
| 1353 | .font(appDensity.font(.subheadline, weight: .semibold)) |
| 1206 | 1354 | .foregroundStyle(.cyan) |
| 1207 | 1355 | if reachabilityLoading { |
| 1208 | 1356 | ProgressView("Checking ports…") |
| @@ -1213,14 +1361,12 @@ struct NetworkSectionView: View { |
| 1213 | 1361 | ForEach(reachabilityRows) { row in |
| 1214 | 1362 | HStack { |
| 1215 | 1363 | Text(row.portLabel) |
| 1216 | | .font(.system(.caption, design: .monospaced)) |
| 1364 | .font(appDensity.font(.caption)) |
| 1217 | 1365 | Spacer() |
| 1218 | 1366 | Text(row.latencyLabel) |
| 1219 | | .font(.system(.caption2, design: .monospaced)) |
| 1367 | .font(appDensity.font(.caption2)) |
| 1220 | 1368 | .foregroundStyle(.secondary) |
| 1221 | | Text(row.statusLabel) |
| 1222 | | .font(.system(.caption, design: .monospaced)) |
| 1223 | | .foregroundStyle(ResultColors.color(for: row.statusTone)) |
| 1369 | AppStatusBadgeView(model: reachabilityBadge(row)) |
| 1224 | 1370 | } |
| 1225 | 1371 | } |
| 1226 | 1372 | } |
| @@ -1228,8 +1374,7 @@ struct NetworkSectionView: View { |
| 1228 | 1374 | |
| 1229 | 1375 | CardView(allowsHorizontalScroll: false) { |
| 1230 | 1376 | Text("Location") |
| 1231 | | .font(.system(.subheadline, design: .monospaced)) |
| 1232 | | .fontWeight(.semibold) |
| 1377 | .font(appDensity.font(.subheadline, weight: .semibold)) |
| 1233 | 1378 | .foregroundStyle(.cyan) |
| 1234 | 1379 | if geolocationLoading { |
| 1235 | 1380 | ProgressView("Looking up location…") |
| @@ -1260,13 +1405,12 @@ struct NetworkSectionView: View { |
| 1260 | 1405 | |
| 1261 | 1406 | CardView(allowsHorizontalScroll: false) { |
| 1262 | 1407 | Text("Port Scan") |
| 1263 | | .font(.system(.subheadline, design: .monospaced)) |
| 1264 | | .fontWeight(.semibold) |
| 1408 | .font(appDensity.font(.subheadline, weight: .semibold)) |
| 1265 | 1409 | .foregroundStyle(.cyan) |
| 1266 | 1410 | |
| 1267 | 1411 | if isCloudflareProxied { |
| 1268 | 1412 | Text("Domain is behind Cloudflare's proxy. Results reflect the edge, not the origin.") |
| 1269 | | .font(.system(.caption2, design: .monospaced)) |
| 1413 | .font(appDensity.font(.caption2)) |
| 1270 | 1414 | .foregroundStyle(.orange) |
| 1271 | 1415 | .fixedSize(horizontal: false, vertical: true) |
| 1272 | 1416 | } |
| @@ -1286,15 +1430,16 @@ struct NetworkSectionView: View { |
| 1286 | 1430 | DisclosureGroup("Custom Ports", isExpanded: $customPortsExpanded) { |
| 1287 | 1431 | VStack(alignment: .leading, spacing: 10) { |
| 1288 | 1432 | TextField("8888, 9000, 27017", text: $customPortInput) |
| 1289 | | .font(.system(.caption, design: .monospaced)) |
| 1433 | .font(appDensity.font(.caption)) |
| 1290 | 1434 | .textInputAutocapitalization(.never) |
| 1291 | 1435 | .autocorrectionDisabled() |
| 1292 | 1436 | .keyboardType(.numberPad) |
| 1293 | 1437 | .padding(10) |
| 1294 | 1438 | .background(Color(.systemGray6).opacity(0.5)) |
| 1295 | | .cornerRadius(6) |
| 1439 | .clipShape(RoundedRectangle(cornerRadius: appDensity.metrics.cardCornerRadius)) |
| 1296 | 1440 | |
| 1297 | 1441 | Button("Scan") { |
| 1442 | AppHaptics.refresh() |
| 1298 | 1443 | onScanCustomPorts() |
| 1299 | 1444 | } |
| 1300 | 1445 | .buttonStyle(.borderedProminent) |
| @@ -1316,9 +1461,23 @@ struct NetworkSectionView: View { |
| 1316 | 1461 | } |
| 1317 | 1462 | } |
| 1318 | 1463 | } |
| 1464 | |
| 1465 | private func reachabilityBadge(_ row: ReachabilityRowViewData) -> AppStatusBadgeModel { |
| 1466 | switch row.statusTone { |
| 1467 | case .success: |
| 1468 | return .init(title: row.statusLabel, systemImage: "checkmark.circle.fill", foregroundColor: .green, backgroundColor: .green.opacity(0.16)) |
| 1469 | case .warning: |
| 1470 | return .init(title: row.statusLabel, systemImage: "exclamationmark.triangle.fill", foregroundColor: .yellow, backgroundColor: .yellow.opacity(0.16)) |
| 1471 | case .failure: |
| 1472 | return .init(title: row.statusLabel, systemImage: "xmark.circle.fill", foregroundColor: .red, backgroundColor: .red.opacity(0.16)) |
| 1473 | case .primary, .secondary: |
| 1474 | return .init(title: row.statusLabel, systemImage: "circle", foregroundColor: .secondary, backgroundColor: Color(.systemGray5).opacity(0.55)) |
| 1475 | } |
| 1476 | } |
| 1319 | 1477 | } |
| 1320 | 1478 | |
| 1321 | 1479 | struct PortRowsView: View { |
| 1480 | @Environment(\.appDensity) private var appDensity |
| 1322 | 1481 | let rows: [PortScanRowViewData] |
| 1323 | 1482 | |
| 1324 | 1483 | var body: some View { |
| @@ -1326,47 +1485,61 @@ struct PortRowsView: View { |
| 1326 | 1485 | MessageRowView(text: "No results", isError: false) |
| 1327 | 1486 | } else { |
| 1328 | 1487 | ForEach(rows) { row in |
| 1329 | | VStack(alignment: .leading, spacing: 2) { |
| 1488 | VStack(alignment: .leading, spacing: appDensity.metrics.rowSpacing - 1) { |
| 1330 | 1489 | HStack { |
| 1331 | 1490 | Text(row.portLabel) |
| 1332 | | .font(.system(.caption, design: .monospaced)) |
| 1491 | .font(appDensity.font(.caption)) |
| 1333 | 1492 | .frame(width: 52, alignment: .leading) |
| 1334 | 1493 | Text(row.service) |
| 1335 | | .font(.system(.caption, design: .monospaced)) |
| 1494 | .font(appDensity.font(.caption)) |
| 1336 | 1495 | .foregroundStyle(.primary) |
| 1337 | 1496 | Spacer() |
| 1338 | 1497 | if let durationLabel = row.durationLabel { |
| 1339 | 1498 | Text(durationLabel) |
| 1340 | | .font(.system(.caption2, design: .monospaced)) |
| 1499 | .font(appDensity.font(.caption2)) |
| 1341 | 1500 | .foregroundStyle(.secondary) |
| 1342 | 1501 | } |
| 1343 | | Text(row.statusLabel) |
| 1344 | | .font(.system(.caption2, design: .monospaced)) |
| 1345 | | .foregroundStyle(ResultColors.color(for: row.statusTone)) |
| 1502 | AppStatusBadgeView(model: portBadge(row)) |
| 1346 | 1503 | } |
| 1347 | 1504 | if let banner = row.banner { |
| 1348 | 1505 | Text(banner) |
| 1349 | | .font(.system(.caption2, design: .monospaced)) |
| 1506 | .font(appDensity.font(.caption2)) |
| 1350 | 1507 | .foregroundStyle(.secondary) |
| 1351 | 1508 | .padding(.leading, 8) |
| 1352 | 1509 | } |
| 1353 | 1510 | } |
| 1511 | .frame(minHeight: appDensity.metrics.rowMinHeight, alignment: .topLeading) |
| 1354 | 1512 | } |
| 1355 | 1513 | } |
| 1356 | 1514 | } |
| 1515 | |
| 1516 | private func portBadge(_ row: PortScanRowViewData) -> AppStatusBadgeModel { |
| 1517 | switch row.statusTone { |
| 1518 | case .success: |
| 1519 | return .init(title: row.statusLabel, systemImage: "checkmark.circle.fill", foregroundColor: .green, backgroundColor: .green.opacity(0.16)) |
| 1520 | case .warning: |
| 1521 | return .init(title: row.statusLabel, systemImage: "exclamationmark.triangle.fill", foregroundColor: .yellow, backgroundColor: .yellow.opacity(0.16)) |
| 1522 | case .failure: |
| 1523 | return .init(title: row.statusLabel, systemImage: "xmark.circle.fill", foregroundColor: .red, backgroundColor: .red.opacity(0.16)) |
| 1524 | case .primary, .secondary: |
| 1525 | return .init(title: row.statusLabel, systemImage: "circle", foregroundColor: .secondary, backgroundColor: Color(.systemGray5).opacity(0.55)) |
| 1526 | } |
| 1527 | } |
| 1357 | 1528 | } |
| 1358 | 1529 | |
| 1359 | 1530 | struct SectionTitleView: View { |
| 1531 | @Environment(\.appDensity) private var appDensity |
| 1360 | 1532 | let title: String |
| 1361 | 1533 | |
| 1362 | 1534 | var body: some View { |
| 1363 | 1535 | Text(title) |
| 1364 | | .font(.system(.headline)) |
| 1536 | .font(appDensity.font(.headline, design: .default, weight: .semibold)) |
| 1365 | 1537 | .foregroundStyle(.white) |
| 1366 | 1538 | } |
| 1367 | 1539 | } |
| 1368 | 1540 | |
| 1369 | 1541 | struct CardView<Content: View>: View { |
| 1542 | @Environment(\.appDensity) private var appDensity |
| 1370 | 1543 | let allowsHorizontalScroll: Bool |
| 1371 | 1544 | let content: Content |
| 1372 | 1545 | |
| @@ -1389,13 +1562,13 @@ struct CardView<Content: View>: View { |
| 1389 | 1562 | } |
| 1390 | 1563 | } |
| 1391 | 1564 | .frame(maxWidth: .infinity, alignment: .leading) |
| 1392 | | .padding(10) |
| 1565 | .padding(appDensity.metrics.cardPadding) |
| 1393 | 1566 | .background(Color(.systemGray6).opacity(0.5)) |
| 1394 | | .cornerRadius(6) |
| 1567 | .clipShape(RoundedRectangle(cornerRadius: appDensity.metrics.cardCornerRadius)) |
| 1395 | 1568 | } |
| 1396 | 1569 | |
| 1397 | 1570 | private var cardContent: some View { |
| 1398 | | VStack(alignment: .leading, spacing: 6) { |
| 1571 | VStack(alignment: .leading, spacing: appDensity.metrics.cardSpacing) { |
| 1399 | 1572 | content |
| 1400 | 1573 | } |
| 1401 | 1574 | } |
| @@ -1425,29 +1598,40 @@ struct MessageCardView: View { |
| 1425 | 1598 | } |
| 1426 | 1599 | |
| 1427 | 1600 | struct MessageRowView: View { |
| 1601 | @Environment(\.appDensity) private var appDensity |
| 1428 | 1602 | let text: String |
| 1429 | 1603 | let isError: Bool |
| 1430 | 1604 | |
| 1431 | 1605 | var body: some View { |
| 1432 | 1606 | Label(text, systemImage: isError ? "exclamationmark.triangle.fill" : "info.circle") |
| 1433 | | .font(.system(.caption, design: .monospaced)) |
| 1607 | .font(appDensity.font(.caption)) |
| 1434 | 1608 | .foregroundStyle(isError ? .red : .secondary) |
| 1435 | 1609 | } |
| 1436 | 1610 | } |
| 1437 | 1611 | |
| 1438 | 1612 | struct LabeledValueRow: View { |
| 1613 | @Environment(\.appDensity) private var appDensity |
| 1439 | 1614 | let row: InfoRowViewData |
| 1440 | 1615 | |
| 1441 | 1616 | var body: some View { |
| 1442 | | VStack(alignment: .leading, spacing: 2) { |
| 1443 | | Text(row.label) |
| 1444 | | .font(.system(.caption2, design: .monospaced)) |
| 1445 | | .foregroundStyle(.secondary) |
| 1446 | | Text(row.value) |
| 1447 | | .font(.system(.caption, design: .monospaced)) |
| 1448 | | .foregroundStyle(ResultColors.color(for: row.tone)) |
| 1449 | | .textSelection(.enabled) |
| 1617 | VStack(alignment: .leading, spacing: appDensity.metrics.rowSpacing - 1) { |
| 1618 | HStack(alignment: .top, spacing: 8) { |
| 1619 | VStack(alignment: .leading, spacing: appDensity.metrics.rowSpacing - 1) { |
| 1620 | Text(row.label) |
| 1621 | .font(appDensity.font(.caption2)) |
| 1622 | .foregroundStyle(.secondary) |
| 1623 | Text(row.value) |
| 1624 | .font(appDensity.font(.caption)) |
| 1625 | .foregroundStyle(ResultColors.color(for: row.tone)) |
| 1626 | .textSelection(.enabled) |
| 1627 | } |
| 1628 | Spacer(minLength: 6) |
| 1629 | if !row.value.isEmpty, row.value != "Unavailable" { |
| 1630 | AppCopyButton(value: row.value, label: "Copy \(row.label)") |
| 1631 | } |
| 1632 | } |
| 1450 | 1633 | } |
| 1634 | .frame(minHeight: appDensity.metrics.rowMinHeight, alignment: .topLeading) |
| 1451 | 1635 | } |
| 1452 | 1636 | } |
| 1453 | 1637 | |
| @@ -1490,11 +1674,17 @@ private extension String { |
| 1490 | 1674 | } |
| 1491 | 1675 | |
| 1492 | 1676 | private struct SettingsView: View { |
| 1677 | @Environment(\.appDensity) private var appDensity |
| 1678 | @Bindable var viewModel: DomainViewModel |
| 1493 | 1679 | @AppStorage(DNSResolverOption.userDefaultsKey) |
| 1494 | 1680 | private var storedResolverURL = DNSResolverOption.defaultURLString |
| 1681 | @AppStorage(AppDensity.userDefaultsKey) |
| 1682 | private var storedDensity = AppDensity.compact.rawValue |
| 1495 | 1683 | |
| 1496 | 1684 | @State private var resolverOption: DNSResolverOption = .cloudflare |
| 1497 | 1685 | @State private var customResolverURL = DNSResolverOption.defaultURLString |
| 1686 | @State private var showClearHistoryConfirmation = false |
| 1687 | @State private var showClearCacheConfirmation = false |
| 1498 | 1688 | |
| 1499 | 1689 | private var customResolverError: String? { |
| 1500 | 1690 | guard resolverOption == .custom else { |
| @@ -1505,7 +1695,15 @@ private struct SettingsView: View { |
| 1505 | 1695 | |
| 1506 | 1696 | var body: some View { |
| 1507 | 1697 | Form { |
| 1508 | | Section { |
| 1698 | Section("Display") { |
| 1699 | Picker("Density", selection: $storedDensity) { |
| 1700 | ForEach(AppDensity.allCases) { density in |
| 1701 | Text(density.title).tag(density.rawValue) |
| 1702 | } |
| 1703 | } |
| 1704 | } |
| 1705 | |
| 1706 | Section("Network") { |
| 1509 | 1707 | Picker("Resolver", selection: $resolverOption) { |
| 1510 | 1708 | ForEach(DNSResolverOption.allCases) { option in |
| 1511 | 1709 | Text(option.title).tag(option) |
| @@ -1520,13 +1718,45 @@ private struct SettingsView: View { |
| 1520 | 1718 | |
| 1521 | 1719 | if let customResolverError { |
| 1522 | 1720 | Text(customResolverError) |
| 1523 | | .font(.caption) |
| 1721 | .font(appDensity.font(.caption, design: .default)) |
| 1524 | 1722 | .foregroundStyle(.red) |
| 1525 | 1723 | } |
| 1526 | 1724 | } |
| 1527 | 1725 | } |
| 1726 | |
| 1727 | Section("Data") { |
| 1728 | Button("Clear History", role: .destructive) { |
| 1729 | showClearHistoryConfirmation = true |
| 1730 | } |
| 1731 | |
| 1732 | Button("Clear Cache", role: .destructive) { |
| 1733 | showClearCacheConfirmation = true |
| 1734 | } |
| 1735 | } |
| 1736 | |
| 1737 | Section("About") { |
| 1738 | LabeledContent("Version", value: appVersion) |
| 1739 | LabeledContent("Storage", value: "Local-only") |
| 1740 | LabeledContent("Focus", value: "Readable domain inspection") |
| 1741 | } |
| 1528 | 1742 | } |
| 1529 | 1743 | .navigationTitle("Settings") |
| 1744 | .alert("Clear history?", isPresented: $showClearHistoryConfirmation) { |
| 1745 | Button("Clear", role: .destructive) { |
| 1746 | viewModel.clearHistory() |
| 1747 | } |
| 1748 | Button("Cancel", role: .cancel) {} |
| 1749 | } message: { |
| 1750 | Text("This removes saved lookup snapshots from this device.") |
| 1751 | } |
| 1752 | .alert("Clear cache?", isPresented: $showClearCacheConfirmation) { |
| 1753 | Button("Clear", role: .destructive) { |
| 1754 | viewModel.clearLookupCache() |
| 1755 | } |
| 1756 | Button("Cancel", role: .cancel) {} |
| 1757 | } message: { |
| 1758 | Text("This clears the in-memory lookup cache and cancels any cached in-flight work.") |
| 1759 | } |
| 1530 | 1760 | .onAppear { |
| 1531 | 1761 | let currentResolverURL = storedResolverURL.trimmingCharacters(in: .whitespacesAndNewlines) |
| 1532 | 1762 | resolverOption = DNSResolverOption.option(for: currentResolverURL) |
| @@ -1544,6 +1774,10 @@ private struct SettingsView: View { |
| 1544 | 1774 | storedResolverURL = newValue.trimmingCharacters(in: .whitespacesAndNewlines) |
| 1545 | 1775 | } |
| 1546 | 1776 | } |
| 1777 | |
| 1778 | private var appVersion: String { |
| 1779 | Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "2.6.0" |
| 1780 | } |
| 1547 | 1781 | } |
| 1548 | 1782 | |
| 1549 | 1783 | #Preview { |