krz/domain-dig

an ios app for DNS & SSL analysis

clone: git clone https://gitbay.org/krz/domain-dig.git

v2.0.0: DomainDig/ContentView.swift · raw

   1import MapKit
   2import SwiftUI
   3
   4struct ContentView: View {
   5    @State private var viewModel = DomainViewModel()
   6    @State private var navigationPath = NavigationPath()
   7    @FocusState private var domainFieldFocused: Bool
   8    @State private var customPortInput = ""
   9    @State private var customPortsExpanded = false
  10    @State private var trackingNoteDraft = ""
  11    @State private var editingTrackedDomain: TrackedDomain?
  12    @State private var showTrackLimitAlert = false
  13
  14    var body: some View {
  15        NavigationStack(path: $navigationPath) {
  16            ScrollView(.vertical) {
  17                VStack(spacing: 0) {
  18                    inputSection
  19                    if viewModel.hasRun {
  20                        actionButtons
  21                        SummaryView(fields: viewModel.summaryFields)
  22                            .padding(.top, 8)
  23                        if let changeSummary = viewModel.currentChangeSummary {
  24                            DomainChangeSummaryView(summary: changeSummary)
  25                                .padding(.top, 12)
  26                        }
  27                        DomainSectionView(
  28                            rows: viewModel.domainRows,
  29                            suggestions: viewModel.suggestionRows,
  30                            showSuggestions: viewModel.availabilityResult?.status == .registered || viewModel.suggestionsLoading,
  31                            availabilityLoading: viewModel.availabilityLoading,
  32                            suggestionsLoading: viewModel.suggestionsLoading,
  33                            trackedDomain: viewModel.currentTrackedDomain,
  34                            trackingLimitMessage: viewModel.trackingLimitMessage,
  35                            onTrack: {
  36                                if !viewModel.trackCurrentDomain() {
  37                                    showTrackLimitAlert = true
  38                                }
  39                            },
  40                            onTogglePinned: {
  41                                guard let trackedDomain = viewModel.currentTrackedDomain else { return }
  42                                viewModel.togglePinned(for: trackedDomain)
  43                            },
  44                            onEditNote: {
  45                                guard let trackedDomain = viewModel.currentTrackedDomain else { return }
  46                                trackingNoteDraft = trackedDomain.note ?? ""
  47                                editingTrackedDomain = trackedDomain
  48                            }
  49                        )
  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                        }
  59                        DNSSectionView(
  60                            dnssecLabel: viewModel.dnssecLabel,
  61                            sections: viewModel.dnsRows,
  62                            ptrMessage: viewModel.ptrMessage,
  63                            loading: viewModel.dnsLoading || viewModel.ptrLoading,
  64                            sectionError: viewModel.dnsError
  65                        )
  66                        .padding(.top, 16)
  67                        WebSectionView(
  68                            certificateRows: viewModel.webCertificateRows,
  69                            sslInfo: viewModel.sslInfo,
  70                            sslLoading: viewModel.sslLoading || viewModel.hstsLoading,
  71                            sslError: viewModel.sslError,
  72                            responseRows: viewModel.webResponseRows,
  73                            headers: viewModel.httpHeaders,
  74                            headersLoading: viewModel.httpHeadersLoading,
  75                            headersError: viewModel.httpHeadersError,
  76                            redirects: viewModel.redirectRows,
  77                            redirectLoading: viewModel.redirectChainLoading,
  78                            redirectError: viewModel.redirectChainError,
  79                            finalURL: viewModel.currentSnapshot.redirectChain.last?.url
  80                        )
  81                        .padding(.top, 16)
  82                        EmailSectionView(
  83                            rows: viewModel.emailRows,
  84                            loading: viewModel.emailSecurityLoading,
  85                            error: viewModel.emailSecurityError
  86                        )
  87                        .padding(.top, 16)
  88                        NetworkSectionView(
  89                            reachabilityRows: viewModel.reachabilityRows,
  90                            reachabilityLoading: viewModel.reachabilityLoading,
  91                            reachabilityError: viewModel.reachabilityError,
  92                            locationRows: viewModel.locationRows,
  93                            geolocation: viewModel.ipGeolocation,
  94                            geolocationLoading: viewModel.ipGeolocationLoading,
  95                            geolocationError: viewModel.ipGeolocationError,
  96                            standardPortRows: viewModel.standardPortRows,
  97                            customPortRows: viewModel.customPortRows,
  98                            portScanLoading: viewModel.portScanLoading,
  99                            portScanError: viewModel.portScanError,
 100                            customPortScanLoading: viewModel.customPortScanLoading,
 101                            customPortScanError: viewModel.customPortScanError,
 102                            isCloudflareProxied: viewModel.isCloudflareProxied,
 103                            customPortsExpanded: $customPortsExpanded,
 104                            customPortInput: $customPortInput,
 105                            onScanCustomPorts: runCustomPortScan
 106                        )
 107                        .padding(.top, 16)
 108                    } else if !viewModel.recentSearches.isEmpty {
 109                        recentSearchesSection
 110                    }
 111                }
 112                .padding(.horizontal)
 113                .padding(.bottom, 32)
 114            }
 115            .background(Color.black)
 116            .navigationTitle("DomainDig")
 117            .toolbarColorScheme(.dark, for: .navigationBar)
 118            .preferredColorScheme(.dark)
 119            .toolbar {
 120                ToolbarItemGroup(placement: .topBarTrailing) {
 121                    if viewModel.hasRun {
 122                        Button {
 123                            viewModel.reset()
 124                        } label: {
 125                            Image(systemName: "xmark.circle")
 126                                .foregroundStyle(.secondary)
 127                        }
 128                    }
 129                    NavigationLink {
 130                        WatchlistView(viewModel: viewModel)
 131                    } label: {
 132                        Image(systemName: "eye")
 133                            .foregroundStyle(.secondary)
 134                    }
 135                    Menu {
 136                        NavigationLink {
 137                            HistoryView(viewModel: viewModel)
 138                        } label: {
 139                            Label("History", systemImage: "clock.arrow.trianglehead.counterclockwise.rotate.90")
 140                        }
 141
 142                        NavigationLink {
 143                            SavedDomainsView(viewModel: viewModel)
 144                        } label: {
 145                            Label("Saved Domains", systemImage: "bookmark")
 146                        }
 147
 148                        NavigationLink {
 149                            SettingsView()
 150                        } label: {
 151                            Label("Settings", systemImage: "gearshape")
 152                        }
 153                    } label: {
 154                        Image(systemName: "ellipsis.circle")
 155                            .foregroundStyle(.secondary)
 156                    }
 157                }
 158            }
 159        }
 160        .onAppear {
 161            domainFieldFocused = true
 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        }
 198    }
 199
 200    private var inputSection: some View {
 201        VStack(spacing: 12) {
 202            TextField("e.g. cleberg.net", text: $viewModel.domain)
 203                .font(.system(.title3, design: .monospaced))
 204                .textInputAutocapitalization(.never)
 205                .autocorrectionDisabled()
 206                .keyboardType(.URL)
 207                .padding(12)
 208                .background(Color(.systemGray6))
 209                .cornerRadius(8)
 210                .focused($domainFieldFocused)
 211                .onSubmit { viewModel.run() }
 212
 213            Button {
 214                domainFieldFocused = false
 215                viewModel.run()
 216            } label: {
 217                Text("Run")
 218                    .font(.headline)
 219                    .frame(maxWidth: .infinity)
 220                    .padding(.vertical, 12)
 221            }
 222            .buttonStyle(.borderedProminent)
 223            .disabled(viewModel.trimmedDomain.isEmpty)
 224        }
 225        .padding(.vertical, 16)
 226    }
 227
 228    private var actionButtons: some View {
 229        HStack {
 230            Spacer()
 231            if viewModel.resultsLoaded {
 232                Button {
 233                    viewModel.toggleSavedDomain()
 234                } label: {
 235                    Image(systemName: viewModel.isCurrentDomainSaved ? "bookmark.fill" : "bookmark")
 236                        .font(.system(.body))
 237                        .foregroundStyle(viewModel.isCurrentDomainSaved ? .yellow : .secondary)
 238                }
 239                Button {
 240                    shareResults()
 241                } label: {
 242                    Image(systemName: "square.and.arrow.up")
 243                        .font(.system(.body))
 244                        .foregroundStyle(.secondary)
 245                }
 246            }
 247        }
 248    }
 249
 250    private var recentSearchesSection: some View {
 251        VStack(alignment: .leading, spacing: 8) {
 252            HStack {
 253                Text("RECENT")
 254                    .font(.system(.caption2, design: .monospaced))
 255                    .foregroundStyle(.secondary)
 256                Spacer()
 257                Button("Clear") {
 258                    viewModel.clearRecentSearches()
 259                }
 260                .font(.system(.caption2, design: .monospaced))
 261                .foregroundStyle(.secondary)
 262            }
 263
 264            ForEach(viewModel.recentSearches, id: \.self) { domain in
 265                Button {
 266                    viewModel.domain = domain
 267                    domainFieldFocused = false
 268                    viewModel.run()
 269                } label: {
 270                    Text(domain)
 271                        .font(.system(.callout, design: .monospaced))
 272                        .foregroundStyle(.primary)
 273                        .frame(maxWidth: .infinity, alignment: .leading)
 274                        .padding(.vertical, 6)
 275                        .padding(.horizontal, 10)
 276                        .background(Color(.systemGray6).opacity(0.5))
 277                        .cornerRadius(6)
 278                }
 279            }
 280        }
 281        .padding(.top, 8)
 282    }
 283
 284    private func runCustomPortScan() {
 285        let ports = parsedCustomPorts(from: customPortInput)
 286        Task {
 287            await viewModel.runCustomPortScan(ports: ports)
 288        }
 289    }
 290
 291    private func parsedCustomPorts(from input: String) -> [UInt16] {
 292        let parts = input.split(separator: ",", omittingEmptySubsequences: true)
 293        var seen = Set<UInt16>()
 294        var ports: [UInt16] = []
 295
 296        for part in parts {
 297            let trimmed = part.trimmingCharacters(in: .whitespacesAndNewlines)
 298            guard let value = UInt16(trimmed), seen.insert(value).inserted else {
 299                continue
 300            }
 301            ports.append(value)
 302            if ports.count == 20 {
 303                break
 304            }
 305        }
 306
 307        return ports
 308    }
 309
 310    private func shareResults() {
 311        let text = viewModel.exportText()
 312        let dateFmt = DateFormatter()
 313        dateFmt.dateFormat = "yyyyMMdd_HHmmss"
 314        let timestamp = dateFmt.string(from: Date())
 315        let filename = "\(timestamp)_domaindigresults.txt"
 316        let tempURL = FileManager.default.temporaryDirectory.appendingPathComponent(filename)
 317
 318        do {
 319            try text.write(to: tempURL, atomically: true, encoding: .utf8)
 320        } catch {
 321            return
 322        }
 323
 324        let activityVC = UIActivityViewController(activityItems: [tempURL], applicationActivities: nil)
 325        guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
 326              let rootVC = windowScene.keyWindow?.rootViewController else { return }
 327        var presenter = rootVC
 328        while let presented = presenter.presentedViewController {
 329            presenter = presented
 330        }
 331        activityVC.popoverPresentationController?.sourceView = presenter.view
 332        presenter.present(activityVC, animated: true)
 333    }
 334}
 335
 336struct SummaryView: View {
 337    let fields: [SummaryFieldViewData]
 338
 339    var body: some View {
 340        VStack(alignment: .leading, spacing: 12) {
 341            SectionTitleView(title: "Summary")
 342            LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 8) {
 343                ForEach(fields) { field in
 344                    VStack(alignment: .leading, spacing: 4) {
 345                        Text(field.label)
 346                            .font(.system(.caption2, design: .monospaced))
 347                            .foregroundStyle(.secondary)
 348                        Text(field.value)
 349                            .font(.system(.caption, design: .monospaced))
 350                            .foregroundStyle(ResultColors.color(for: field.tone))
 351                            .lineLimit(2)
 352                            .textSelection(.enabled)
 353                    }
 354                    .frame(maxWidth: .infinity, alignment: .leading)
 355                    .padding(10)
 356                    .background(Color(.systemGray6).opacity(0.5))
 357                    .cornerRadius(6)
 358                }
 359            }
 360        }
 361    }
 362}
 363
 364struct 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
 386struct 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
 474struct 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
 494struct DomainSectionView: View {
 495    let rows: [InfoRowViewData]
 496    let suggestions: [DomainSuggestionViewData]
 497    let showSuggestions: Bool
 498    let availabilityLoading: Bool
 499    let suggestionsLoading: Bool
 500    let trackedDomain: TrackedDomain?
 501    let trackingLimitMessage: String?
 502    let onTrack: () -> Void
 503    let onTogglePinned: () -> Void
 504    let onEditNote: (() -> Void)?
 505
 506    var body: some View {
 507        VStack(alignment: .leading, spacing: 12) {
 508            HStack {
 509                SectionTitleView(title: "Domain")
 510                Spacer()
 511                if let trackedDomain {
 512                    HStack(spacing: 8) {
 513                        Text("Tracked")
 514                            .font(.system(.caption, design: .monospaced))
 515                            .foregroundStyle(.green)
 516                        Button {
 517                            onTogglePinned()
 518                        } label: {
 519                            Image(systemName: trackedDomain.isPinned ? "pin.fill" : "pin")
 520                        }
 521                        .buttonStyle(.bordered)
 522                        .font(.system(.caption, design: .monospaced))
 523                        if let onEditNote {
 524                            Button("Note") {
 525                                onEditNote()
 526                            }
 527                            .buttonStyle(.bordered)
 528                            .font(.system(.caption, design: .monospaced))
 529                        }
 530                    }
 531                } else {
 532                    Button("Track") {
 533                        onTrack()
 534                    }
 535                    .buttonStyle(.bordered)
 536                    .font(.system(.caption, design: .monospaced))
 537                }
 538            }
 539            CardView(allowsHorizontalScroll: false) {
 540                ForEach(rows) { row in
 541                    LabeledValueRow(row: row)
 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                }
 550                if availabilityLoading {
 551                    ProgressView("Checking availability…")
 552                        .appLoadingStyle()
 553                        .padding(.top, 4)
 554                }
 555                if showSuggestions {
 556                    Text("Suggestions")
 557                        .font(.system(.caption, design: .monospaced))
 558                        .foregroundStyle(.secondary)
 559                        .padding(.top, 4)
 560                    if suggestionsLoading {
 561                        ProgressView("Checking alternatives…")
 562                            .appLoadingStyle()
 563                    } else if suggestions.isEmpty {
 564                        MessageRowView(text: "No suggestions", isError: false)
 565                    } else {
 566                        ForEach(suggestions) { suggestion in
 567                            HStack {
 568                                Text(suggestion.domain)
 569                                    .font(.system(.caption, design: .monospaced))
 570                                    .foregroundStyle(.primary)
 571                                    .textSelection(.enabled)
 572                                Spacer()
 573                                Text(suggestion.status)
 574                                    .font(.system(.caption2, design: .monospaced))
 575                                    .foregroundStyle(ResultColors.color(for: suggestion.tone))
 576                            }
 577                        }
 578                    }
 579                }
 580            }
 581        }
 582    }
 583}
 584
 585struct DNSSectionView: View {
 586    let dnssecLabel: String?
 587    let sections: [DNSRecordSectionViewData]
 588    let ptrMessage: SectionMessageViewData?
 589    let loading: Bool
 590    let sectionError: String?
 591
 592    var body: some View {
 593        VStack(alignment: .leading, spacing: 12) {
 594            HStack(alignment: .top, spacing: 8) {
 595                SectionTitleView(title: "DNS")
 596                Spacer()
 597                if let dnssecLabel {
 598                    Text(dnssecLabel)
 599                        .font(.system(.caption2, design: .monospaced))
 600                        .foregroundStyle(.secondary)
 601                        .multilineTextAlignment(.trailing)
 602                }
 603            }
 604
 605            if loading {
 606                LoadingCardView(text: "Querying DNS…")
 607            } else if let sectionError, sections.isEmpty {
 608                MessageCardView(text: sectionError, isError: true)
 609            } else {
 610                ForEach(sections) { section in
 611                    CardView {
 612                        Text(section.title)
 613                            .font(.system(.subheadline, design: .monospaced))
 614                            .fontWeight(.semibold)
 615                            .foregroundStyle(.cyan)
 616
 617                        if let message = section.message {
 618                            MessageRowView(text: message.text, isError: message.isError)
 619                        }
 620
 621                        ForEach(section.rows) { row in
 622                            LabeledValueRow(row: row)
 623                        }
 624
 625                        if let wildcardTitle = section.wildcardTitle {
 626                            Text(wildcardTitle)
 627                                .font(.system(.caption, design: .monospaced))
 628                                .foregroundStyle(.secondary)
 629                                .padding(.top, 4)
 630                            ForEach(section.wildcardRows) { row in
 631                                LabeledValueRow(row: row)
 632                            }
 633                        }
 634                    }
 635                }
 636
 637                if let ptrMessage {
 638                    CardView {
 639                        Text("PTR")
 640                            .font(.system(.subheadline, design: .monospaced))
 641                            .fontWeight(.semibold)
 642                            .foregroundStyle(.cyan)
 643                        MessageRowView(text: ptrMessage.text, isError: ptrMessage.isError)
 644                    }
 645                }
 646            }
 647        }
 648    }
 649}
 650
 651struct WebSectionView: View {
 652    let certificateRows: [InfoRowViewData]
 653    let sslInfo: SSLCertificateInfo?
 654    let sslLoading: Bool
 655    let sslError: String?
 656    let responseRows: [InfoRowViewData]
 657    let headers: [HTTPHeader]
 658    let headersLoading: Bool
 659    let headersError: String?
 660    let redirects: [RedirectHopViewData]
 661    let redirectLoading: Bool
 662    let redirectError: String?
 663    let finalURL: String?
 664
 665    var body: some View {
 666        VStack(alignment: .leading, spacing: 12) {
 667            SectionTitleView(title: "Web")
 668
 669            CardView {
 670                Text("TLS")
 671                    .font(.system(.subheadline, design: .monospaced))
 672                    .fontWeight(.semibold)
 673                    .foregroundStyle(.cyan)
 674                if sslLoading {
 675                    ProgressView("Checking certificate…")
 676                        .appLoadingStyle()
 677                } else if let sslError {
 678                    MessageRowView(text: sslError, isError: true)
 679                } else {
 680                    ForEach(certificateRows) { row in
 681                        LabeledValueRow(row: row)
 682                    }
 683                    if let sslInfo, !sslInfo.subjectAltNames.isEmpty {
 684                        Text("SANs")
 685                            .font(.system(.caption2, design: .monospaced))
 686                            .foregroundStyle(.secondary)
 687                        ForEach(sslInfo.subjectAltNames, id: \.self) { san in
 688                            Text(san)
 689                                .font(.system(.caption, design: .monospaced))
 690                                .textSelection(.enabled)
 691                        }
 692                    }
 693                }
 694            }
 695
 696            CardView {
 697                Text("Headers")
 698                    .font(.system(.subheadline, design: .monospaced))
 699                    .fontWeight(.semibold)
 700                    .foregroundStyle(.cyan)
 701                if headersLoading {
 702                    ProgressView("Fetching headers…")
 703                        .appLoadingStyle()
 704                } else if let headersError {
 705                    MessageRowView(text: headersError, isError: true)
 706                } else {
 707                    ForEach(responseRows) { row in
 708                        LabeledValueRow(row: row)
 709                    }
 710                    if headers.isEmpty {
 711                        MessageRowView(text: "No HTTP headers returned", isError: false)
 712                    } else {
 713                        ForEach(headers) { header in
 714                            HStack(alignment: .top, spacing: 4) {
 715                                Text(header.name + ":")
 716                                    .font(.system(.caption, design: .monospaced))
 717                                    .foregroundStyle(header.isSecurityHeader ? .yellow : .cyan)
 718                                Text(header.value)
 719                                    .font(.system(.caption, design: .monospaced))
 720                                    .foregroundStyle(.primary)
 721                                    .textSelection(.enabled)
 722                            }
 723                        }
 724                    }
 725                }
 726            }
 727
 728            CardView {
 729                Text("Redirects")
 730                    .font(.system(.subheadline, design: .monospaced))
 731                    .fontWeight(.semibold)
 732                    .foregroundStyle(.cyan)
 733                if redirectLoading {
 734                    ProgressView("Tracing redirects…")
 735                        .appLoadingStyle()
 736                } else if let redirectError {
 737                    MessageRowView(text: redirectError, isError: true)
 738                } else if redirects.isEmpty {
 739                    MessageRowView(text: "No redirect data available", isError: false)
 740                } else {
 741                    if let finalURL {
 742                        LabeledValueRow(row: InfoRowViewData(label: "Final URL", value: finalURL, tone: .secondary))
 743                    }
 744                    ForEach(redirects) { redirect in
 745                        HStack(alignment: .top, spacing: 6) {
 746                            Text(redirect.stepLabel)
 747                                .font(.system(.caption, design: .monospaced))
 748                                .foregroundStyle(.secondary)
 749                                .frame(width: 16, alignment: .trailing)
 750                            Text(redirect.statusCode)
 751                                .font(.system(.caption, design: .monospaced))
 752                                .foregroundStyle(.cyan)
 753                                .frame(width: 36, alignment: .leading)
 754                            Text(redirect.url)
 755                                .font(.system(.caption, design: .monospaced))
 756                                .textSelection(.enabled)
 757                            if redirect.isFinal {
 758                                Text("(final)")
 759                                    .font(.system(.caption2, design: .monospaced))
 760                                    .foregroundStyle(.secondary)
 761                            }
 762                        }
 763                    }
 764                }
 765            }
 766        }
 767    }
 768}
 769
 770struct EmailSectionView: View {
 771    let rows: [EmailRowViewData]
 772    let loading: Bool
 773    let error: String?
 774
 775    var body: some View {
 776        VStack(alignment: .leading, spacing: 12) {
 777            SectionTitleView(title: "Email")
 778            CardView {
 779                if loading {
 780                    ProgressView("Checking email records…")
 781                        .appLoadingStyle()
 782                } else if let error {
 783                    MessageRowView(text: error, isError: true)
 784                } else if rows.isEmpty {
 785                    MessageRowView(text: "No email security records found", isError: false)
 786                } else {
 787                    ForEach(rows) { row in
 788                        VStack(alignment: .leading, spacing: 4) {
 789                            HStack(spacing: 8) {
 790                                Text(row.label)
 791                                    .font(.system(.caption, design: .monospaced))
 792                                    .foregroundStyle(.cyan)
 793                                    .frame(width: 76, alignment: .leading)
 794                                Text(row.status)
 795                                    .font(.system(.caption, design: .monospaced))
 796                                    .foregroundStyle(ResultColors.color(for: row.statusTone))
 797                            }
 798                            Text(row.detail)
 799                                .font(.system(.caption2, design: .monospaced))
 800                                .foregroundStyle(.primary)
 801                                .textSelection(.enabled)
 802                            if let auxiliaryDetail = row.auxiliaryDetail {
 803                                Text(auxiliaryDetail)
 804                                    .font(.system(.caption2, design: .monospaced))
 805                                    .foregroundStyle(.secondary)
 806                            }
 807                        }
 808                    }
 809                }
 810            }
 811        }
 812    }
 813}
 814
 815struct NetworkSectionView: View {
 816    let reachabilityRows: [ReachabilityRowViewData]
 817    let reachabilityLoading: Bool
 818    let reachabilityError: String?
 819    let locationRows: [InfoRowViewData]
 820    let geolocation: IPGeolocation?
 821    let geolocationLoading: Bool
 822    let geolocationError: String?
 823    let standardPortRows: [PortScanRowViewData]
 824    let customPortRows: [PortScanRowViewData]
 825    let portScanLoading: Bool
 826    let portScanError: String?
 827    let customPortScanLoading: Bool
 828    let customPortScanError: String?
 829    let isCloudflareProxied: Bool
 830    @Binding var customPortsExpanded: Bool
 831    @Binding var customPortInput: String
 832    let onScanCustomPorts: () -> Void
 833
 834    var body: some View {
 835        VStack(alignment: .leading, spacing: 12) {
 836            SectionTitleView(title: "Network")
 837
 838            CardView {
 839                Text("Reachability")
 840                    .font(.system(.subheadline, design: .monospaced))
 841                    .fontWeight(.semibold)
 842                    .foregroundStyle(.cyan)
 843                if reachabilityLoading {
 844                    ProgressView("Checking ports…")
 845                        .appLoadingStyle()
 846                } else if let reachabilityError {
 847                    MessageRowView(text: reachabilityError, isError: true)
 848                } else {
 849                    ForEach(reachabilityRows) { row in
 850                        HStack {
 851                            Text(row.portLabel)
 852                                .font(.system(.caption, design: .monospaced))
 853                            Spacer()
 854                            Text(row.latencyLabel)
 855                                .font(.system(.caption2, design: .monospaced))
 856                                .foregroundStyle(.secondary)
 857                            Text(row.statusLabel)
 858                                .font(.system(.caption, design: .monospaced))
 859                                .foregroundStyle(ResultColors.color(for: row.statusTone))
 860                        }
 861                    }
 862                }
 863            }
 864
 865            CardView(allowsHorizontalScroll: false) {
 866                Text("Location")
 867                    .font(.system(.subheadline, design: .monospaced))
 868                    .fontWeight(.semibold)
 869                    .foregroundStyle(.cyan)
 870                if geolocationLoading {
 871                    ProgressView("Looking up location…")
 872                        .appLoadingStyle()
 873                } else if let geolocationError, geolocation == nil {
 874                    MessageRowView(text: geolocationError, isError: geolocationError != "No A record available")
 875                } else if let geolocation {
 876                    ForEach(locationRows) { row in
 877                        LabeledValueRow(row: row)
 878                    }
 879                    if let latitude = geolocation.latitude, let longitude = geolocation.longitude {
 880                        let coordinate = CLLocationCoordinate2D(latitude: latitude, longitude: longitude)
 881                        Map(initialPosition: .region(MKCoordinateRegion(
 882                            center: coordinate,
 883                            span: MKCoordinateSpan(latitudeDelta: 1, longitudeDelta: 1)
 884                        ))) {
 885                            Marker(geolocation.ip, coordinate: coordinate)
 886                        }
 887                        .mapStyle(.standard)
 888                        .frame(maxWidth: .infinity)
 889                        .frame(height: 180)
 890                        .cornerRadius(8)
 891                    }
 892                } else {
 893                    MessageRowView(text: "No location data available", isError: false)
 894                }
 895            }
 896
 897            CardView(allowsHorizontalScroll: false) {
 898                Text("Port Scan")
 899                    .font(.system(.subheadline, design: .monospaced))
 900                    .fontWeight(.semibold)
 901                    .foregroundStyle(.cyan)
 902
 903                if isCloudflareProxied {
 904                    Text("Domain is behind Cloudflare's proxy. Results reflect the edge, not the origin.")
 905                        .font(.system(.caption2, design: .monospaced))
 906                        .foregroundStyle(.orange)
 907                        .fixedSize(horizontal: false, vertical: true)
 908                }
 909
 910                if portScanLoading {
 911                    ProgressView("Scanning ports…")
 912                        .appLoadingStyle()
 913                } else if let portScanError, standardPortRows.isEmpty {
 914                    MessageRowView(text: portScanError, isError: true)
 915                } else {
 916                    Text("Standard Ports")
 917                        .font(.system(.caption, design: .monospaced))
 918                        .foregroundStyle(.secondary)
 919                    PortRowsView(rows: standardPortRows)
 920                }
 921
 922                DisclosureGroup("Custom Ports", isExpanded: $customPortsExpanded) {
 923                    VStack(alignment: .leading, spacing: 10) {
 924                        TextField("8888, 9000, 27017", text: $customPortInput)
 925                            .font(.system(.caption, design: .monospaced))
 926                            .textInputAutocapitalization(.never)
 927                            .autocorrectionDisabled()
 928                            .keyboardType(.numberPad)
 929                            .padding(10)
 930                            .background(Color(.systemGray6).opacity(0.5))
 931                            .cornerRadius(6)
 932
 933                        Button("Scan") {
 934                            onScanCustomPorts()
 935                        }
 936                        .buttonStyle(.borderedProminent)
 937                        .disabled(customPortScanLoading)
 938
 939                        if customPortScanLoading {
 940                            ProgressView("Scanning custom ports…")
 941                                .appLoadingStyle()
 942                        } else if let customPortScanError {
 943                            MessageRowView(text: customPortScanError, isError: true)
 944                        } else {
 945                            PortRowsView(rows: customPortRows)
 946                        }
 947                    }
 948                    .padding(.top, 8)
 949                }
 950                .font(.system(.caption, design: .monospaced))
 951                .tint(.secondary)
 952            }
 953        }
 954    }
 955}
 956
 957struct PortRowsView: View {
 958    let rows: [PortScanRowViewData]
 959
 960    var body: some View {
 961        if rows.isEmpty {
 962            MessageRowView(text: "No results", isError: false)
 963        } else {
 964            ForEach(rows) { row in
 965                VStack(alignment: .leading, spacing: 2) {
 966                    HStack {
 967                        Text(row.portLabel)
 968                            .font(.system(.caption, design: .monospaced))
 969                            .frame(width: 52, alignment: .leading)
 970                        Text(row.service)
 971                            .font(.system(.caption, design: .monospaced))
 972                            .foregroundStyle(.primary)
 973                        Spacer()
 974                        if let durationLabel = row.durationLabel {
 975                            Text(durationLabel)
 976                                .font(.system(.caption2, design: .monospaced))
 977                                .foregroundStyle(.secondary)
 978                        }
 979                        Text(row.statusLabel)
 980                            .font(.system(.caption2, design: .monospaced))
 981                            .foregroundStyle(ResultColors.color(for: row.statusTone))
 982                    }
 983                    if let banner = row.banner {
 984                        Text(banner)
 985                            .font(.system(.caption2, design: .monospaced))
 986                            .foregroundStyle(.secondary)
 987                            .padding(.leading, 8)
 988                    }
 989                }
 990            }
 991        }
 992    }
 993}
 994
 995struct SectionTitleView: View {
 996    let title: String
 997
 998    var body: some View {
 999        Text(title)
1000            .font(.system(.headline))
1001            .foregroundStyle(.white)
1002    }
1003}
1004
1005struct CardView<Content: View>: View {
1006    let allowsHorizontalScroll: Bool
1007    let content: Content
1008
1009    init(allowsHorizontalScroll: Bool = true, @ViewBuilder content: () -> Content) {
1010        self.allowsHorizontalScroll = allowsHorizontalScroll
1011        self.content = content()
1012    }
1013
1014    var body: some View {
1015        Group {
1016            if allowsHorizontalScroll {
1017                ScrollView(.horizontal) {
1018                    cardContent
1019                        .scrollTargetLayout()
1020                }
1021                .scrollBounceBehavior(.basedOnSize, axes: .horizontal)
1022            } else {
1023                cardContent
1024                    .frame(maxWidth: .infinity, alignment: .leading)
1025            }
1026        }
1027        .frame(maxWidth: .infinity, alignment: .leading)
1028        .padding(10)
1029        .background(Color(.systemGray6).opacity(0.5))
1030        .cornerRadius(6)
1031    }
1032
1033    private var cardContent: some View {
1034        VStack(alignment: .leading, spacing: 6) {
1035            content
1036        }
1037    }
1038}
1039
1040struct LoadingCardView: View {
1041    let text: String
1042
1043    var body: some View {
1044        CardView {
1045            ProgressView(text)
1046                .appLoadingStyle()
1047                .frame(maxWidth: .infinity, alignment: .center)
1048        }
1049    }
1050}
1051
1052struct MessageCardView: View {
1053    let text: String
1054    let isError: Bool
1055
1056    var body: some View {
1057        CardView {
1058            MessageRowView(text: text, isError: isError)
1059        }
1060    }
1061}
1062
1063struct MessageRowView: View {
1064    let text: String
1065    let isError: Bool
1066
1067    var body: some View {
1068        Label(text, systemImage: isError ? "exclamationmark.triangle.fill" : "info.circle")
1069            .font(.system(.caption, design: .monospaced))
1070            .foregroundStyle(isError ? .red : .secondary)
1071    }
1072}
1073
1074struct LabeledValueRow: View {
1075    let row: InfoRowViewData
1076
1077    var body: some View {
1078        VStack(alignment: .leading, spacing: 2) {
1079            Text(row.label)
1080                .font(.system(.caption2, design: .monospaced))
1081                .foregroundStyle(.secondary)
1082            Text(row.value)
1083                .font(.system(.caption, design: .monospaced))
1084                .foregroundStyle(ResultColors.color(for: row.tone))
1085                .textSelection(.enabled)
1086        }
1087    }
1088}
1089
1090enum ResultColors {
1091    static func color(for tone: ResultTone) -> Color {
1092        switch tone {
1093        case .primary:
1094            return .primary
1095        case .secondary:
1096            return .secondary
1097        case .success:
1098            return .green
1099        case .warning:
1100            return .yellow
1101        case .failure:
1102            return .red
1103        }
1104    }
1105}
1106
1107extension DateFormatter {
1108    static let certDate: DateFormatter = {
1109        let formatter = DateFormatter()
1110        formatter.dateStyle = .medium
1111        formatter.timeStyle = .short
1112        return formatter
1113    }()
1114}
1115
1116private extension View {
1117    func appLoadingStyle() -> some View {
1118        font(.system(.caption, design: .monospaced))
1119    }
1120}
1121
1122private extension String {
1123    var nilIfEmpty: String? {
1124        isEmpty ? nil : self
1125    }
1126}
1127
1128private struct SettingsView: View {
1129    @AppStorage(DNSResolverOption.userDefaultsKey)
1130    private var storedResolverURL = DNSResolverOption.defaultURLString
1131
1132    @State private var resolverOption: DNSResolverOption = .cloudflare
1133    @State private var customResolverURL = DNSResolverOption.defaultURLString
1134
1135    private var customResolverError: String? {
1136        guard resolverOption == .custom else {
1137            return nil
1138        }
1139        return DNSResolverOption.isValidCustomURL(customResolverURL) ? nil : "Resolver URL must start with https://"
1140    }
1141
1142    var body: some View {
1143        Form {
1144            Section {
1145                Picker("Resolver", selection: $resolverOption) {
1146                    ForEach(DNSResolverOption.allCases) { option in
1147                        Text(option.title).tag(option)
1148                    }
1149                }
1150
1151                if resolverOption == .custom {
1152                    TextField("https://resolver.example/dns-query", text: $customResolverURL)
1153                        .textInputAutocapitalization(.never)
1154                        .autocorrectionDisabled()
1155                        .keyboardType(.URL)
1156
1157                    if let customResolverError {
1158                        Text(customResolverError)
1159                            .font(.caption)
1160                            .foregroundStyle(.red)
1161                    }
1162                }
1163            }
1164        }
1165        .navigationTitle("Settings")
1166        .onAppear {
1167            let currentResolverURL = storedResolverURL.trimmingCharacters(in: .whitespacesAndNewlines)
1168            resolverOption = DNSResolverOption.option(for: currentResolverURL)
1169            customResolverURL = resolverOption == .custom ? currentResolverURL : DNSResolverOption.defaultURLString
1170        }
1171        .onChange(of: resolverOption) { _, newValue in
1172            guard let presetURL = newValue.urlString else {
1173                storedResolverURL = customResolverURL.trimmingCharacters(in: .whitespacesAndNewlines)
1174                return
1175            }
1176            storedResolverURL = presetURL
1177        }
1178        .onChange(of: customResolverURL) { _, newValue in
1179            guard resolverOption == .custom else { return }
1180            storedResolverURL = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
1181        }
1182    }
1183}
1184
1185#Preview {
1186    ContentView()
1187}