krz/domain-dig

an ios app for DNS & SSL analysis

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

v1.7.1: DomainDig/ContentView.swift · raw

   1import SwiftUI
   2import MapKit
   3
   4struct ContentView: View {
   5    @State private var viewModel = DomainViewModel()
   6    @FocusState private var domainFieldFocused: Bool
   7    @State private var customPortInput = ""
   8    @State private var customPortsExpanded = false
   9
  10    var body: some View {
  11        NavigationStack {
  12            ScrollView(.vertical) {
  13                VStack(spacing: 0) {
  14                    inputSection
  15                    if viewModel.hasRun {
  16                        actionButtons
  17                        reachabilitySection
  18                        redirectChainSection
  19                        dnsResultsSection
  20                        emailSecuritySection
  21                        sslResultsSection
  22                        httpHeadersSection
  23                        ipGeolocationSection
  24                        portScanSection
  25                    } else if !viewModel.recentSearches.isEmpty {
  26                        recentSearchesSection
  27                    }
  28                }
  29                .padding(.horizontal)
  30                .padding(.bottom, 32)
  31            }
  32            .background(Color.black)
  33            .navigationTitle("DomainDig")
  34            .toolbarColorScheme(.dark, for: .navigationBar)
  35            .preferredColorScheme(.dark)
  36            .toolbar {
  37                ToolbarItemGroup(placement: .topBarTrailing) {
  38                    if viewModel.hasRun {
  39                        Button {
  40                            viewModel.reset()
  41                        } label: {
  42                            Image(systemName: "xmark.circle")
  43                                .foregroundStyle(.secondary)
  44                        }
  45                    }
  46                    NavigationLink {
  47                        SavedDomainsView(viewModel: viewModel)
  48                    } label: {
  49                        Image(systemName: "bookmark")
  50                            .foregroundStyle(.secondary)
  51                    }
  52                    NavigationLink {
  53                        HistoryView(viewModel: viewModel)
  54                    } label: {
  55                        Image(systemName: "clock.arrow.trianglehead.counterclockwise.rotate.90")
  56                            .foregroundStyle(.secondary)
  57                    }
  58                }
  59                ToolbarItem(placement: .topBarTrailing) {
  60                    NavigationLink {
  61                        SettingsView()
  62                    } label: {
  63                        Image(systemName: "gearshape")
  64                            .foregroundStyle(.secondary)
  65                    }
  66                }
  67            }
  68        }
  69        .onAppear {
  70            domainFieldFocused = true
  71        }
  72    }
  73
  74    // MARK: - Input
  75
  76    private var inputSection: some View {
  77        VStack(spacing: 12) {
  78            TextField("e.g. cleberg.net", text: $viewModel.domain)
  79                .font(.system(.title3, design: .monospaced))
  80                .textInputAutocapitalization(.never)
  81                .autocorrectionDisabled()
  82                .keyboardType(.URL)
  83                .padding(12)
  84                .background(Color(.systemGray6))
  85                .cornerRadius(8)
  86                .focused($domainFieldFocused)
  87                .onSubmit { viewModel.run() }
  88
  89            Button {
  90                domainFieldFocused = false
  91                viewModel.run()
  92            } label: {
  93                Text("Run")
  94                    .font(.headline)
  95                    .frame(maxWidth: .infinity)
  96                    .padding(.vertical, 12)
  97            }
  98            .buttonStyle(.borderedProminent)
  99            .disabled(viewModel.trimmedDomain.isEmpty)
 100        }
 101        .padding(.vertical, 16)
 102    }
 103
 104    // MARK: - Action Buttons (Share + Bookmark)
 105
 106    private var actionButtons: some View {
 107        HStack {
 108            Spacer()
 109            if viewModel.resultsLoaded {
 110                Button {
 111                    viewModel.toggleSavedDomain()
 112                } label: {
 113                    Image(systemName: viewModel.isCurrentDomainSaved ? "bookmark.fill" : "bookmark")
 114                        .font(.system(.body))
 115                        .foregroundStyle(viewModel.isCurrentDomainSaved ? .yellow : .secondary)
 116                }
 117                Button {
 118                    shareResults()
 119                } label: {
 120                    Image(systemName: "square.and.arrow.up")
 121                        .font(.system(.body))
 122                        .foregroundStyle(.secondary)
 123                }
 124            }
 125        }
 126        .padding(.top, 8)
 127    }
 128
 129    // MARK: - Recent Searches
 130
 131    private var recentSearchesSection: some View {
 132        VStack(alignment: .leading, spacing: 8) {
 133            HStack {
 134                Text("RECENT")
 135                    .font(.system(.caption2, design: .monospaced))
 136                    .foregroundStyle(.secondary)
 137                Spacer()
 138                Button("Clear") {
 139                    viewModel.clearRecentSearches()
 140                }
 141                .font(.system(.caption2, design: .monospaced))
 142                .foregroundStyle(.secondary)
 143            }
 144
 145            ForEach(viewModel.recentSearches, id: \.self) { domain in
 146                Button {
 147                    viewModel.domain = domain
 148                    domainFieldFocused = false
 149                    viewModel.run()
 150                } label: {
 151                    Text(domain)
 152                        .font(.system(.callout, design: .monospaced))
 153                        .foregroundStyle(.primary)
 154                        .frame(maxWidth: .infinity, alignment: .leading)
 155                        .padding(.vertical, 6)
 156                        .padding(.horizontal, 10)
 157                        .background(Color(.systemGray6).opacity(0.5))
 158                        .cornerRadius(6)
 159                }
 160            }
 161        }
 162        .padding(.top, 8)
 163    }
 164
 165    // MARK: - Reachability
 166
 167    private var reachabilitySection: some View {
 168        VStack(alignment: .leading, spacing: 12) {
 169            sectionHeader("Reachability")
 170
 171            if viewModel.reachabilityLoading {
 172                ProgressView("Checking ports…")
 173                    .frame(maxWidth: .infinity, alignment: .center)
 174                    .padding()
 175            } else if let error = viewModel.reachabilityError {
 176                errorLabel(error)
 177            } else {
 178                VStack(alignment: .leading, spacing: 4) {
 179                    ForEach(viewModel.reachabilityResults) { result in
 180                        HStack(spacing: 8) {
 181                            Circle()
 182                                .fill(result.reachable ? Color.green : Color.red)
 183                                .frame(width: 8, height: 8)
 184                            Text("Port \(result.port)")
 185                                .font(.system(.caption, design: .monospaced))
 186                            if result.reachable, let ms = result.latencyMs {
 187                                Text("\(ms)ms")
 188                                    .font(.system(.caption, design: .monospaced))
 189                                    .foregroundStyle(.secondary)
 190                            } else if !result.reachable {
 191                                Text("")
 192                                    .font(.system(.caption, design: .monospaced))
 193                                    .foregroundStyle(.secondary)
 194                            }
 195                            Spacer()
 196                            Text(result.reachable ? "Reachable" : "Unreachable")
 197                                .font(.system(.caption, design: .monospaced))
 198                                .foregroundStyle(result.reachable ? .green : .red)
 199                        }
 200                    }
 201                }
 202                .padding(10)
 203                .background(Color(.systemGray6).opacity(0.5))
 204                .cornerRadius(6)
 205            }
 206        }
 207        .padding(.top, 8)
 208    }
 209
 210    // MARK: - Redirect Chain
 211
 212    private var redirectChainSection: some View {
 213        VStack(alignment: .leading, spacing: 12) {
 214            sectionHeader("Redirect Chain")
 215
 216            if viewModel.redirectChainLoading {
 217                ProgressView("Tracing redirects…")
 218                    .frame(maxWidth: .infinity, alignment: .center)
 219                    .padding()
 220            } else if let error = viewModel.redirectChainError {
 221                errorLabel(error)
 222            } else if viewModel.redirectChain.isEmpty {
 223                Text("No redirect data")
 224                    .font(.system(.caption, design: .monospaced))
 225                    .foregroundStyle(.secondary)
 226                    .padding(8)
 227            } else if viewModel.redirectChain.count == 1,
 228                      let only = viewModel.redirectChain.first,
 229                      only.isFinal, !(300...399).contains(only.statusCode) {
 230                Text("No redirects — direct connection")
 231                    .font(.system(.caption, design: .monospaced))
 232                    .foregroundStyle(.secondary)
 233                    .padding(10)
 234                    .frame(maxWidth: .infinity, alignment: .leading)
 235                    .background(Color(.systemGray6).opacity(0.5))
 236                    .cornerRadius(6)
 237            } else {
 238                horizontallyScrollableCard {
 239                    ForEach(viewModel.redirectChain) { hop in
 240                        HStack(alignment: .top, spacing: 6) {
 241                            Text("\(hop.stepNumber)")
 242                                .font(.system(.caption, design: .monospaced))
 243                                .foregroundStyle(.secondary)
 244                                .frame(width: 16, alignment: .trailing)
 245                            Text("\(hop.statusCode)")
 246                                .font(.system(.caption, design: .monospaced))
 247                                .foregroundStyle(.cyan)
 248                                .frame(width: 30, alignment: .leading)
 249                            Text(hop.url)
 250                                .font(.system(.caption, design: .monospaced))
 251                                .foregroundStyle(.primary)
 252                                .textSelection(.enabled)
 253                            if hop.isFinal {
 254                                Text("(final)")
 255                                    .font(.system(.caption2, design: .monospaced))
 256                                    .foregroundStyle(.secondary)
 257                            }
 258                        }
 259                    }
 260                }
 261            }
 262        }
 263        .padding(.top, 16)
 264    }
 265
 266    // MARK: - DNS Results
 267
 268    private var dnsResultsSection: some View {
 269        VStack(alignment: .leading, spacing: 12) {
 270            HStack(alignment: .top, spacing: 8) {
 271                sectionHeader("DNS Records")
 272                Spacer()
 273                if let dnssecSigned = dnssecStatus {
 274                    Text(dnssecSigned ? "DNSSEC ✓" : "DNSSEC ✗")
 275                        .font(.system(.caption2, design: .monospaced))
 276                        .foregroundStyle(dnssecSigned ? .green : .red)
 277                        .padding(.top, 1)
 278                }
 279            }
 280
 281            if viewModel.dnsLoading {
 282                ProgressView("Querying DNS…")
 283                    .frame(maxWidth: .infinity, alignment: .center)
 284                    .padding()
 285            } else if let error = viewModel.dnsError {
 286                errorLabel(error)
 287            } else {
 288                ForEach(viewModel.dnsSections) { section in
 289                    dnsRecordSection(section)
 290                    if section.recordType == .A {
 291                        ptrRow
 292                    }
 293                }
 294            }
 295        }
 296        .padding(.top, 16)
 297    }
 298
 299    private var ptrRow: some View {
 300        horizontallyScrollableCard {
 301            Text("PTR (Reverse DNS)")
 302                .font(.system(.subheadline, design: .monospaced))
 303                .fontWeight(.semibold)
 304                .foregroundStyle(.cyan)
 305
 306            if viewModel.ptrLoading {
 307                ProgressView()
 308                    .frame(maxWidth: .infinity, alignment: .center)
 309                    .padding(4)
 310            } else if let ptr = viewModel.ptrRecord {
 311                Text(ptr)
 312                    .font(.system(.caption, design: .monospaced))
 313                    .foregroundStyle(.primary)
 314                    .textSelection(.enabled)
 315            } else {
 316                Text("No PTR record found")
 317                    .font(.system(.caption, design: .monospaced))
 318                    .foregroundStyle(.secondary)
 319            }
 320        }
 321    }
 322
 323    private func dnsRecordSection(_ section: DNSSection) -> some View {
 324        horizontallyScrollableCard {
 325            Text(section.recordType.rawValue)
 326                .font(.system(.subheadline, design: .monospaced))
 327                .fontWeight(.semibold)
 328                .foregroundStyle(.cyan)
 329
 330            if let error = section.error {
 331                errorLabel(error)
 332            } else if section.records.isEmpty {
 333                Text("No records found")
 334                    .font(.system(.caption, design: .monospaced))
 335                    .foregroundStyle(.secondary)
 336            } else {
 337                dnsRecordRows(section.records)
 338            }
 339
 340            if !section.wildcardRecords.isEmpty {
 341                Text("*.\(viewModel.searchedDomain)")
 342                    .font(.system(.caption, design: .monospaced))
 343                    .fontWeight(.medium)
 344                    .foregroundStyle(.cyan.opacity(0.7))
 345                    .padding(.top, 4)
 346
 347                dnsRecordRows(section.wildcardRecords)
 348            }
 349        }
 350    }
 351
 352    private func dnsRecordRows(_ records: [DNSRecord]) -> some View {
 353        ForEach(records) { record in
 354            HStack(alignment: .top) {
 355                Text(record.value)
 356                    .font(.system(.caption, design: .monospaced))
 357                    .foregroundStyle(.primary)
 358                    .textSelection(.enabled)
 359                Spacer()
 360                Text("TTL \(record.ttl)")
 361                    .font(.system(.caption2, design: .monospaced))
 362                    .foregroundStyle(.secondary)
 363            }
 364        }
 365    }
 366
 367    // MARK: - Email Security
 368
 369    @State private var expandedEmailField: String?
 370
 371    private var emailSecuritySection: some View {
 372        VStack(alignment: .leading, spacing: 12) {
 373            sectionHeader("Email Security")
 374
 375            if viewModel.emailSecurityLoading {
 376                ProgressView("Checking email records…")
 377                    .frame(maxWidth: .infinity, alignment: .center)
 378                    .padding()
 379            } else if let error = viewModel.emailSecurityError {
 380                errorLabel(error)
 381            } else if let email = viewModel.emailSecurity {
 382                horizontallyScrollableCard(spacing: 6) {
 383                    emailSecurityRow("SPF", record: email.spf)
 384                    emailSecurityRow("DMARC", record: email.dmarc)
 385                    emailSecurityRow("DKIM", record: email.dkim)
 386                    emailSecurityRow("MTA-STS", mtaSts: email.mtaSts)
 387                    emailSecurityRow("BIMI", record: email.bimi)
 388                }
 389            }
 390        }
 391        .frame(maxWidth: .infinity, alignment: .leading)
 392        .padding(.top, 16)
 393    }
 394
 395    private func emailSecurityRow(_ label: String, record: EmailSecurityRecord) -> some View {
 396        VStack(alignment: .leading, spacing: 2) {
 397            HStack(spacing: 8) {
 398                Text(label)
 399                    .font(.system(.caption, design: .monospaced))
 400                    .fontWeight(.semibold)
 401                    .frame(width: 72, alignment: .leading)
 402                Text(record.found ? "" : "")
 403                    .font(.system(.caption, design: .monospaced))
 404                    .foregroundStyle(record.found ? .green : .red)
 405                if let value = record.value {
 406                    let isExpanded = expandedEmailField == label
 407                    let displayValue = isExpanded ? value : String(value.prefix(80))
 408                    Text(displayValue)
 409                        .font(.system(.caption2, design: .monospaced))
 410                        .foregroundStyle(.primary)
 411                        .textSelection(.enabled)
 412                        .lineLimit(isExpanded ? nil : 1)
 413                        .onTapGesture {
 414                            withAnimation {
 415                                expandedEmailField = isExpanded ? nil : label
 416                            }
 417                        }
 418                    if let selector = record.matchedSelector {
 419                        Text("(selector: \(selector))")
 420                            .font(.system(.caption2, design: .monospaced))
 421                            .foregroundStyle(.secondary)
 422                    }
 423                } else {
 424                    Text("No record found")
 425                        .font(.system(.caption2, design: .monospaced))
 426                        .foregroundStyle(.secondary)
 427                }
 428            }
 429        }
 430    }
 431
 432    private func emailSecurityRow(_ label: String, mtaSts: MTASTSResult?) -> some View {
 433        VStack(alignment: .leading, spacing: 2) {
 434            HStack(spacing: 8) {
 435                Text(label)
 436                    .font(.system(.caption, design: .monospaced))
 437                    .fontWeight(.semibold)
 438                    .frame(width: 72, alignment: .leading)
 439                Text(mtaSts?.txtFound == true ? "" : "")
 440                    .font(.system(.caption, design: .monospaced))
 441                    .foregroundStyle(mtaSts?.txtFound == true ? .green : .red)
 442                if let policyMode = mtaSts?.policyMode {
 443                    Text(policyMode)
 444                        .font(.system(.caption2, design: .monospaced))
 445                        .foregroundStyle(.primary)
 446                        .textSelection(.enabled)
 447                } else {
 448                    Text(mtaSts?.txtFound == true ? "Policy unavailable" : "No record found")
 449                        .font(.system(.caption2, design: .monospaced))
 450                        .foregroundStyle(.secondary)
 451                }
 452            }
 453        }
 454    }
 455
 456    // MARK: - SSL Results
 457
 458    private var sslResultsSection: some View {
 459        VStack(alignment: .leading, spacing: 12) {
 460            sectionHeader("SSL / TLS Certificate")
 461
 462            if viewModel.sslLoading {
 463                ProgressView("Checking certificate…")
 464                    .frame(maxWidth: .infinity, alignment: .center)
 465                    .padding()
 466            } else if let error = viewModel.sslError {
 467                errorLabel(error)
 468            } else if let info = viewModel.sslInfo {
 469                sslDetail(info, domain: viewModel.searchedDomain)
 470            }
 471        }
 472        .padding(.top, 16)
 473    }
 474
 475    private func sslDetail(_ info: SSLCertificateInfo, domain: String) -> some View {
 476        horizontallyScrollableCard(spacing: 8) {
 477            certRow("Common Name", info.commonName)
 478            certRow("Issuer", info.issuer)
 479
 480            VStack(alignment: .leading, spacing: 2) {
 481                Text("SANs")
 482                    .font(.system(.caption2, design: .monospaced))
 483                    .foregroundStyle(.secondary)
 484                ForEach(info.subjectAltNames, id: \.self) { san in
 485                    Text(san)
 486                        .font(.system(.caption, design: .monospaced))
 487                        .textSelection(.enabled)
 488                }
 489            }
 490
 491            let formatter = DateFormatter.certDate
 492            certRow("Valid From", formatter.string(from: info.validFrom))
 493            certRow("Valid Until", formatter.string(from: info.validUntil))
 494
 495            VStack(alignment: .leading, spacing: 2) {
 496                Text("Days Until Expiry")
 497                    .font(.system(.caption2, design: .monospaced))
 498                    .foregroundStyle(.secondary)
 499                Text("\(info.daysUntilExpiry)")
 500                    .font(.system(.caption, design: .monospaced))
 501                    .fontWeight(.bold)
 502                    .foregroundStyle(expiryColor(info.daysUntilExpiry))
 503            }
 504
 505            certRow("Chain Depth", "\(info.chainDepth)")
 506            if viewModel.hstsLoading {
 507                hstsLoadingRow
 508            } else if let hstsPreloaded = viewModel.hstsPreloaded {
 509                hstsStatusRow(hstsPreloaded)
 510            }
 511            if let tlsVersion = info.tlsVersion {
 512                certRow("TLS Version", tlsVersion)
 513            }
 514            if let cipherSuite = info.cipherSuite {
 515                certRow("Cipher Suite", cipherSuite)
 516            }
 517            if !info.chain.isEmpty {
 518                VStack(alignment: .leading, spacing: 6) {
 519                    Text("Certificate Chain")
 520                        .font(.system(.caption2, design: .monospaced))
 521                        .foregroundStyle(.secondary)
 522                    ForEach(Array(info.chain.enumerated()), id: \.offset) { index, certificate in
 523                        DisclosureGroup {
 524                            Text(certificate.issuer)
 525                                .font(.system(.caption, design: .monospaced))
 526                                .foregroundStyle(.secondary)
 527                                .textSelection(.enabled)
 528                        } label: {
 529                            Text(certificate.subject)
 530                                .font(.system(.caption, design: .monospaced))
 531                                .foregroundStyle(.primary)
 532                                .textSelection(.enabled)
 533                        }
 534                        .tint(index == 0 ? .cyan : .secondary)
 535                    }
 536                }
 537            }
 538            Link("View on crt.sh →", destination: URL(string: "https://crt.sh/?q=\(domain)")!)
 539                .font(.system(.caption, design: .monospaced))
 540                .foregroundStyle(.cyan)
 541        }
 542    }
 543
 544    // MARK: - HTTP Headers
 545
 546    private var httpHeadersSection: some View {
 547        VStack(alignment: .leading, spacing: 12) {
 548            HStack(spacing: 8) {
 549                sectionHeader("HTTP Headers")
 550                if let grade = viewModel.httpSecurityGrade {
 551                    Text(grade)
 552                        .font(.system(.caption, design: .monospaced))
 553                        .foregroundStyle(httpSecurityGradeColor(for: grade))
 554                        .padding(.horizontal, 8)
 555                        .padding(.vertical, 2)
 556                        .background(httpSecurityGradeColor(for: grade).opacity(0.18))
 557                        .clipShape(RoundedRectangle(cornerRadius: 6, style: .continuous))
 558                }
 559                Spacer()
 560            }
 561
 562            if viewModel.httpHeadersLoading {
 563                ProgressView("Fetching headers…")
 564                    .frame(maxWidth: .infinity, alignment: .center)
 565                    .padding()
 566            } else if let error = viewModel.httpHeadersError {
 567                errorLabel(error)
 568            } else {
 569                horizontallyScrollableCard {
 570                    if !httpStatusSummaryParts.isEmpty || http3AvailabilityNote != nil {
 571                        HStack(alignment: .top, spacing: 0) {
 572                            ForEach(Array(httpStatusSummaryParts.enumerated()), id: \.offset) { index, part in
 573                                if index > 0 {
 574                                    Text("  ")
 575                                        .font(.system(.caption, design: .monospaced))
 576                                }
 577                                Text(part.text)
 578                                    .font(.system(.caption, design: .monospaced))
 579                                    .foregroundStyle(part.color)
 580                            }
 581                            if let http3AvailabilityNote {
 582                                Text("  ")
 583                                    .font(.system(.caption, design: .monospaced))
 584                                Text(http3AvailabilityNote)
 585                                    .font(.system(.caption, design: .monospaced))
 586                                    .foregroundStyle(.secondary)
 587                            }
 588                        }
 589                    }
 590                    ForEach(viewModel.httpHeaders) { header in
 591                        HStack(alignment: .top, spacing: 4) {
 592                            Text(header.name + ":")
 593                                .font(.system(.caption, design: .monospaced))
 594                                .foregroundStyle(header.isSecurityHeader ? .yellow : .cyan)
 595                            Text(header.value)
 596                                .font(.system(.caption, design: .monospaced))
 597                                .foregroundStyle(.primary)
 598                                .textSelection(.enabled)
 599                        }
 600                    }
 601                }
 602            }
 603        }
 604        .padding(.top, 16)
 605    }
 606
 607    // MARK: - IP Geolocation
 608
 609    private var ipGeolocationSection: some View {
 610        VStack(alignment: .leading, spacing: 12) {
 611            sectionHeader("IP Location")
 612
 613            if viewModel.ipGeolocationLoading {
 614                ProgressView("Looking up location…")
 615                    .frame(maxWidth: .infinity, alignment: .center)
 616                    .padding()
 617            } else if let geo = viewModel.ipGeolocation {
 618                ipGeolocationDetail(geo)
 619            } else if let error = viewModel.ipGeolocationError {
 620                if error == "No A record available" {
 621                    Text("No location data available")
 622                        .font(.system(.caption, design: .monospaced))
 623                        .foregroundStyle(.secondary)
 624                        .padding(8)
 625                } else {
 626                    errorLabel(error)
 627                }
 628            }
 629        }
 630        .padding(.top, 16)
 631    }
 632
 633    private func ipGeolocationDetail(_ geo: IPGeolocation) -> some View {
 634        VStack(alignment: .leading, spacing: 6) {
 635            horizontallyScrollableContent(spacing: 6) {
 636                certRow("IP", geo.ip)
 637                if let org = geo.org {
 638                    certRow("Org / ISP", org)
 639                }
 640                let location = [geo.city, geo.region, geo.country_name].compactMap { $0 }.joined(separator: ", ")
 641                if !location.isEmpty {
 642                    certRow("Location", location)
 643                }
 644            }
 645
 646            if let lat = geo.latitude, let lon = geo.longitude {
 647                let coordinate = CLLocationCoordinate2D(latitude: lat, longitude: lon)
 648                Map(initialPosition: .region(MKCoordinateRegion(
 649                    center: coordinate,
 650                    span: MKCoordinateSpan(latitudeDelta: 1, longitudeDelta: 1)
 651                ))) {
 652                    Marker(geo.ip, coordinate: coordinate)
 653                }
 654                .mapStyle(.standard)
 655                .frame(maxWidth: .infinity)
 656                .frame(height: 180)
 657                .cornerRadius(8)
 658            }
 659        }
 660        .padding(10)
 661        .background(Color(.systemGray6).opacity(0.5))
 662        .cornerRadius(6)
 663    }
 664
 665    // MARK: - Port Scan
 666
 667    private var portScanSection: some View {
 668        VStack(alignment: .leading, spacing: 12) {
 669            sectionHeader("Open Ports")
 670
 671            if viewModel.portScanLoading {
 672                ProgressView("Scanning ports…")
 673                    .frame(maxWidth: .infinity, alignment: .center)
 674                    .padding()
 675            } else if let error = viewModel.portScanError {
 676                errorLabel(error)
 677            } else {
 678                VStack(alignment: .leading, spacing: 12) {
 679                    if viewModel.isCloudflareProxied {
 680                        Text("Domain is behind Cloudflare's proxy. Results reflect what CF's edge exposes, not the origin. CF only proxies ports: 80, 443, 2052–2053, 2082–2083, 2086–2087, 2095–2096, 8080, 8443, 8880.")
 681                            .font(.system(.caption2, design: .monospaced))
 682                            .foregroundStyle(.orange)
 683                            .padding(8)
 684                            .background(Color.orange.opacity(0.1))
 685                            .cornerRadius(6)
 686                    }
 687                    portScanResultsCard(viewModel.portScanResults)
 688
 689                    DisclosureGroup("Custom Ports", isExpanded: $customPortsExpanded) {
 690                        VStack(alignment: .leading, spacing: 10) {
 691                            TextField("8888, 9000, 27017", text: $customPortInput)
 692                                .font(.system(.caption, design: .monospaced))
 693                                .textInputAutocapitalization(.never)
 694                                .autocorrectionDisabled()
 695                                .keyboardType(.numberPad)
 696                                .padding(10)
 697                                .background(Color(.systemGray6).opacity(0.5))
 698                                .cornerRadius(6)
 699
 700                            Button("Scan") {
 701                                let ports = parsedCustomPorts(from: customPortInput)
 702                                Task {
 703                                    await viewModel.runCustomPortScan(ports: ports)
 704                                }
 705                            }
 706                            .buttonStyle(.borderedProminent)
 707                            .tint(.blue)
 708                            .disabled(viewModel.customPortScanLoading)
 709
 710                            if viewModel.customPortScanLoading {
 711                                ProgressView("Scanning custom ports…")
 712                                    .font(.system(.caption, design: .monospaced))
 713                            } else if let error = viewModel.customPortScanError {
 714                                errorLabel(error)
 715                            } else if !viewModel.customPortResults.isEmpty {
 716                                portScanResultsCard(viewModel.customPortResults)
 717                            }
 718                        }
 719                        .padding(.top, 8)
 720                    }
 721                    .font(.system(.caption, design: .monospaced))
 722                    .tint(.secondary)
 723                }
 724                .padding(10)
 725                .background(Color(.systemGray6).opacity(0.5))
 726                .cornerRadius(6)
 727            }
 728        }
 729        .padding(.top, 16)
 730    }
 731
 732    // MARK: - Helpers
 733
 734    private func sectionHeader(_ title: String) -> some View {
 735        Text(title)
 736            .font(.system(.headline, design: .default))
 737            .foregroundStyle(.white)
 738    }
 739
 740    private var dnssecStatus: Bool? {
 741        viewModel.dnsSections.compactMap(\.dnssecSigned).first
 742    }
 743
 744    private func certRow(_ label: String, _ value: String) -> some View {
 745        VStack(alignment: .leading, spacing: 2) {
 746            Text(label)
 747                .font(.system(.caption2, design: .monospaced))
 748                .foregroundStyle(.secondary)
 749            Text(value)
 750                .font(.system(.caption, design: .monospaced))
 751                .textSelection(.enabled)
 752        }
 753    }
 754
 755    private var hstsLoadingRow: some View {
 756        VStack(alignment: .leading, spacing: 2) {
 757            Text("HSTS Preload")
 758                .font(.system(.caption2, design: .monospaced))
 759                .foregroundStyle(.secondary)
 760            ProgressView()
 761                .controlSize(.small)
 762        }
 763    }
 764
 765    private func hstsStatusRow(_ isPreloaded: Bool) -> some View {
 766        VStack(alignment: .leading, spacing: 2) {
 767            Text("HSTS Preload")
 768                .font(.system(.caption2, design: .monospaced))
 769                .foregroundStyle(.secondary)
 770            Text(isPreloaded ? "Preloaded" : "Not preloaded")
 771                .font(.system(.caption, design: .monospaced))
 772                .foregroundStyle(isPreloaded ? .green : .secondary)
 773        }
 774    }
 775
 776    private func horizontallyScrollableCard<Content: View>(
 777        spacing: CGFloat = 4,
 778        @ViewBuilder content: () -> Content
 779    ) -> some View {
 780        horizontallyScrollableContent(spacing: spacing) {
 781            content()
 782        }
 783        .padding(10)
 784        .background(Color(.systemGray6).opacity(0.5))
 785        .cornerRadius(6)
 786    }
 787
 788    private func horizontallyScrollableContent<Content: View>(
 789        spacing: CGFloat = 4,
 790        @ViewBuilder content: () -> Content
 791    ) -> some View {
 792        ScrollView(.horizontal) {
 793            VStack(alignment: .leading, spacing: spacing) {
 794                content()
 795            }
 796            .scrollTargetLayout()
 797        }
 798        .scrollBounceBehavior(.basedOnSize, axes: .horizontal)
 799        .frame(maxWidth: .infinity, alignment: .leading)
 800    }
 801
 802    private func errorLabel(_ message: String) -> some View {
 803        Label(message, systemImage: "exclamationmark.triangle.fill")
 804            .font(.system(.caption, design: .monospaced))
 805            .foregroundStyle(.red)
 806            .padding(8)
 807    }
 808
 809    private func portScanResultsCard(_ results: [PortScanResult]) -> some View {
 810        VStack(alignment: .leading, spacing: 4) {
 811            ForEach(results) { result in
 812                VStack(alignment: .leading, spacing: 2) {
 813                    HStack(spacing: 8) {
 814                        Circle()
 815                            .fill(result.open ? Color.green : Color(.systemGray4))
 816                            .frame(width: 8, height: 8)
 817                        Text("\(result.port)")
 818                            .font(.system(.caption, design: .monospaced))
 819                            .lineLimit(1)
 820                            .frame(width: 52, alignment: .leading)
 821                        Text(result.service)
 822                            .font(.system(.caption, design: .monospaced))
 823                            .foregroundStyle(result.open ? .primary : .secondary)
 824                        Spacer()
 825                        if result.open {
 826                            Text("Open")
 827                                .font(.system(.caption2, design: .monospaced))
 828                                .foregroundStyle(.green)
 829                        }
 830                    }
 831
 832                    if let banner = result.banner {
 833                        Text(banner)
 834                            .font(.system(.caption2, design: .monospaced))
 835                            .foregroundStyle(.secondary)
 836                            .lineLimit(1)
 837                            .padding(.leading, 16)
 838                    }
 839                }
 840            }
 841        }
 842    }
 843
 844    private func parsedCustomPorts(from input: String) -> [UInt16] {
 845        let parts = input.split(separator: ",", omittingEmptySubsequences: true)
 846        var seen = Set<UInt16>()
 847        var ports: [UInt16] = []
 848
 849        for part in parts {
 850            let trimmed = part.trimmingCharacters(in: .whitespacesAndNewlines)
 851            guard let value = UInt16(trimmed), seen.insert(value).inserted else {
 852                continue
 853            }
 854            ports.append(value)
 855            if ports.count == 20 {
 856                break
 857            }
 858        }
 859
 860        return ports
 861    }
 862
 863    private var httpStatusSummaryParts: [(text: String, color: Color)] {
 864        var parts: [(text: String, color: Color)] = []
 865
 866        if let statusCode = viewModel.httpStatusCode {
 867            parts.append(("HTTP \(statusCode)", .cyan))
 868        }
 869        if let responseTimeMs = viewModel.httpResponseTimeMs {
 870            parts.append(("\(responseTimeMs)ms", .secondary))
 871        }
 872        if let httpProtocol = viewModel.httpProtocol {
 873            parts.append((httpProtocol, .secondary))
 874        }
 875
 876        return parts
 877    }
 878
 879    private var http3AvailabilityNote: String? {
 880        guard viewModel.http3Advertised, viewModel.httpProtocol != "HTTP/3" else {
 881            return nil
 882        }
 883        return "(HTTP/3 available)"
 884    }
 885
 886    private func httpSecurityGradeColor(for grade: String) -> Color {
 887        switch grade {
 888        case "A", "B":
 889            .green
 890        case "C":
 891            .yellow
 892        case "D", "F":
 893            .red
 894        default:
 895            .secondary
 896        }
 897    }
 898
 899    private func expiryColor(_ days: Int) -> Color {
 900        if days < 30 { return .red }
 901        if days < 60 { return .yellow }
 902        return .green
 903    }
 904
 905    private func shareResults() {
 906        let text = viewModel.exportText()
 907        let dateFmt = DateFormatter()
 908        dateFmt.dateFormat = "yyyyMMdd_HHmmss"
 909        let timestamp = dateFmt.string(from: Date())
 910        let filename = "\(timestamp)_domaindigresults.txt"
 911        let tempURL = FileManager.default.temporaryDirectory.appendingPathComponent(filename)
 912
 913        do {
 914            try text.write(to: tempURL, atomically: true, encoding: .utf8)
 915        } catch {
 916            return
 917        }
 918
 919        let activityVC = UIActivityViewController(activityItems: [tempURL], applicationActivities: nil)
 920        guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
 921              let rootVC = windowScene.keyWindow?.rootViewController else { return }
 922        var presenter = rootVC
 923        while let presented = presenter.presentedViewController {
 924            presenter = presented
 925        }
 926        activityVC.popoverPresentationController?.sourceView = presenter.view
 927        presenter.present(activityVC, animated: true)
 928    }
 929}
 930
 931extension DateFormatter {
 932    static let certDate: DateFormatter = {
 933        let f = DateFormatter()
 934        f.dateStyle = .medium
 935        f.timeStyle = .short
 936        return f
 937    }()
 938}
 939
 940private struct SettingsView: View {
 941    @AppStorage(DNSResolverOption.userDefaultsKey)
 942    private var storedResolverURL = DNSResolverOption.defaultURLString
 943
 944    @State private var resolverOption: DNSResolverOption = .cloudflare
 945    @State private var customResolverURL = DNSResolverOption.defaultURLString
 946
 947    private var customResolverError: String? {
 948        guard resolverOption == .custom else {
 949            return nil
 950        }
 951
 952        return DNSResolverOption.isValidCustomURL(customResolverURL)
 953            ? nil
 954            : "Resolver URL must start with https://"
 955    }
 956
 957    var body: some View {
 958        Form {
 959            Section {
 960                Picker("Resolver", selection: $resolverOption) {
 961                    ForEach(DNSResolverOption.allCases) { option in
 962                        Text(option.title).tag(option)
 963                    }
 964                }
 965
 966                if resolverOption == .custom {
 967                    TextField("https://resolver.example/dns-query", text: $customResolverURL)
 968                        .textInputAutocapitalization(.never)
 969                        .autocorrectionDisabled()
 970                        .keyboardType(.URL)
 971
 972                    if let customResolverError {
 973                        Text(customResolverError)
 974                            .font(.caption)
 975                            .foregroundStyle(.red)
 976                    }
 977                }
 978            }
 979        }
 980        .navigationTitle("Settings")
 981        .onAppear {
 982            let currentResolverURL = storedResolverURL.trimmingCharacters(in: .whitespacesAndNewlines)
 983            resolverOption = DNSResolverOption.option(for: currentResolverURL)
 984            customResolverURL = resolverOption == .custom
 985                ? currentResolverURL
 986                : DNSResolverOption.defaultURLString
 987        }
 988        .onChange(of: resolverOption) { _, newValue in
 989            guard let presetURL = newValue.urlString else {
 990                storedResolverURL = customResolverURL.trimmingCharacters(in: .whitespacesAndNewlines)
 991                return
 992            }
 993            storedResolverURL = presetURL
 994        }
 995        .onChange(of: customResolverURL) { _, newValue in
 996            guard resolverOption == .custom else {
 997                return
 998            }
 999            storedResolverURL = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
1000        }
1001    }
1002}
1003
1004#Preview {
1005    ContentView()
1006}