Commit 2c38b5edee
Verified · cmc
Layout: unified · split
DomainDig/ContentView.swift +69 −1
| @@ -544,7 +544,19 @@ struct ContentView: View { | ||
| 544 | 544 | |
| 545 | 545 | private var httpHeadersSection: some View { |
| 546 | 546 | VStack(alignment: .leading, spacing: 12) { |
| 547 | sectionHeader("HTTP Headers") | |
| 547 | HStack(spacing: 8) { | |
| 548 | sectionHeader("HTTP Headers") | |
| 549 | if let grade = viewModel.httpSecurityGrade { | |
| 550 | Text(grade) | |
| 551 | .font(.system(.caption, design: .monospaced)) | |
| 552 | .foregroundStyle(httpSecurityGradeColor(for: grade)) | |
| 553 | .padding(.horizontal, 8) | |
| 554 | .padding(.vertical, 2) | |
| 555 | .background(httpSecurityGradeColor(for: grade).opacity(0.18)) | |
| 556 | .clipShape(RoundedRectangle(cornerRadius: 6, style: .continuous)) | |
| 557 | } | |
| 558 | Spacer() | |
| 559 | } | |
| 548 | 560 | |
| 549 | 561 | if viewModel.httpHeadersLoading { |
| 550 | 562 | ProgressView("Fetching headers…") |
| @@ -554,6 +566,26 @@ struct ContentView: View { | ||
| 554 | 566 | errorLabel(error) |
| 555 | 567 | } else { |
| 556 | 568 | horizontallyScrollableCard { |
| 569 | if !httpStatusSummaryParts.isEmpty || http3AvailabilityNote != nil { | |
| 570 | HStack(alignment: .top, spacing: 0) { | |
| 571 | ForEach(Array(httpStatusSummaryParts.enumerated()), id: \.offset) { index, part in | |
| 572 | if index > 0 { | |
| 573 | Text(" ") | |
| 574 | .font(.system(.caption, design: .monospaced)) | |
| 575 | } | |
| 576 | Text(part.text) | |
| 577 | .font(.system(.caption, design: .monospaced)) | |
| 578 | .foregroundStyle(part.color) | |
| 579 | } | |
| 580 | if let http3AvailabilityNote { | |
| 581 | Text(" ") | |
| 582 | .font(.system(.caption, design: .monospaced)) | |
| 583 | Text(http3AvailabilityNote) | |
| 584 | .font(.system(.caption, design: .monospaced)) | |
| 585 | .foregroundStyle(.secondary) | |
| 586 | } | |
| 587 | } | |
| 588 | } | |
| 557 | 589 | ForEach(viewModel.httpHeaders) { header in |
| 558 | 590 | HStack(alignment: .top, spacing: 4) { |
| 559 | 591 | Text(header.name + ":") |
| @@ -750,6 +782,42 @@ struct ContentView: View { | ||
| 750 | 782 | .padding(8) |
| 751 | 783 | } |
| 752 | 784 | |
| 785 | private var httpStatusSummaryParts: [(text: String, color: Color)] { | |
| 786 | var parts: [(text: String, color: Color)] = [] | |
| 787 | ||
| 788 | if let statusCode = viewModel.httpStatusCode { | |
| 789 | parts.append(("HTTP \(statusCode)", .cyan)) | |
| 790 | } | |
| 791 | if let responseTimeMs = viewModel.httpResponseTimeMs { | |
| 792 | parts.append(("\(responseTimeMs)ms", .secondary)) | |
| 793 | } | |
| 794 | if let httpProtocol = viewModel.httpProtocol { | |
| 795 | parts.append((httpProtocol, .secondary)) | |
| 796 | } | |
| 797 | ||
| 798 | return parts | |
| 799 | } | |
| 800 | ||
| 801 | private var http3AvailabilityNote: String? { | |
| 802 | guard viewModel.http3Advertised, viewModel.httpProtocol != "HTTP/3" else { | |
| 803 | return nil | |
| 804 | } | |
| 805 | return "(HTTP/3 available)" | |
| 806 | } | |
| 807 | ||
| 808 | private func httpSecurityGradeColor(for grade: String) -> Color { | |
| 809 | switch grade { | |
| 810 | case "A", "B": | |
| 811 | .green | |
| 812 | case "C": | |
| 813 | .yellow | |
| 814 | case "D", "F": | |
| 815 | .red | |
| 816 | default: | |
| 817 | .secondary | |
| 818 | } | |
| 819 | } | |
| 820 | ||
| 753 | 821 | private func expiryColor(_ days: Int) -> Color { |
| 754 | 822 | if days < 30 { return .red } |
| 755 | 823 | if days < 60 { return .yellow } |
DomainDig/DomainViewModel.swift +47 −2
| @@ -20,6 +20,11 @@ final class DomainViewModel { | ||
| 20 | 20 | |
| 21 | 21 | // HTTP Headers |
| 22 | 22 | var httpHeaders: [HTTPHeader] = [] |
| 23 | var httpSecurityGrade: String? | |
| 24 | var httpStatusCode: Int? | |
| 25 | var httpResponseTimeMs: Int? | |
| 26 | var httpProtocol: String? | |
| 27 | var http3Advertised = false | |
| 23 | 28 | var httpHeadersLoading = false |
| 24 | 29 | var httpHeadersError: String? |
| 25 | 30 | |
| @@ -168,6 +173,11 @@ final class DomainViewModel { | ||
| 168 | 173 | hstsPreloaded = nil |
| 169 | 174 | hstsLoading = false |
| 170 | 175 | httpHeaders = [] |
| 176 | httpSecurityGrade = nil | |
| 177 | httpStatusCode = nil | |
| 178 | httpResponseTimeMs = nil | |
| 179 | httpProtocol = nil | |
| 180 | http3Advertised = false | |
| 171 | 181 | httpHeadersError = nil |
| 172 | 182 | httpHeadersLoading = false |
| 173 | 183 | reachabilityResults = [] |
| @@ -210,6 +220,11 @@ final class DomainViewModel { | ||
| 210 | 220 | hstsPreloaded = nil |
| 211 | 221 | hstsLoading = true |
| 212 | 222 | httpHeaders = [] |
| 223 | httpSecurityGrade = nil | |
| 224 | httpStatusCode = nil | |
| 225 | httpResponseTimeMs = nil | |
| 226 | httpProtocol = nil | |
| 227 | http3Advertised = false | |
| 213 | 228 | httpHeadersError = nil |
| 214 | 229 | httpHeadersLoading = true |
| 215 | 230 | reachabilityResults = [] |
| @@ -300,8 +315,13 @@ final class DomainViewModel { | ||
| 300 | 315 | |
| 301 | 316 | private func runHTTPHeaders(domain: String) async { |
| 302 | 317 | do { |
| 303 | let headers = try await HTTPHeadersService.fetch(domain: domain) | |
| 304 | httpHeaders = headers | |
| 318 | let result = try await HTTPHeadersService.fetch(domain: domain) | |
| 319 | httpHeaders = result.headers | |
| 320 | httpSecurityGrade = HTTPSecurityGrade.grade(for: result.headers).rawValue | |
| 321 | httpStatusCode = result.statusCode | |
| 322 | httpResponseTimeMs = result.responseTimeMs | |
| 323 | httpProtocol = result.httpProtocol | |
| 324 | http3Advertised = result.http3Advertised | |
| 305 | 325 | } catch { |
| 306 | 326 | httpHeadersError = error.localizedDescription |
| 307 | 327 | } |
| @@ -381,6 +401,11 @@ final class DomainViewModel { | ||
| 381 | 401 | sslError: sslError, |
| 382 | 402 | hstsPreloaded: hstsPreloaded, |
| 383 | 403 | httpHeaders: httpHeaders, |
| 404 | httpSecurityGrade: httpSecurityGrade, | |
| 405 | httpStatusCode: httpStatusCode, | |
| 406 | httpResponseTimeMs: httpResponseTimeMs, | |
| 407 | httpProtocol: httpProtocol, | |
| 408 | http3Advertised: http3Advertised, | |
| 384 | 409 | httpHeadersError: httpHeadersError, |
| 385 | 410 | reachabilityResults: reachabilityResults, |
| 386 | 411 | ipGeolocation: ipGeolocation, |
| @@ -400,6 +425,11 @@ final class DomainViewModel { | ||
| 400 | 425 | sslError: String? = nil, |
| 401 | 426 | hstsPreloaded: Bool? = nil, |
| 402 | 427 | httpHeaders: [HTTPHeader], |
| 428 | httpSecurityGrade: String? = nil, | |
| 429 | httpStatusCode: Int? = nil, | |
| 430 | httpResponseTimeMs: Int? = nil, | |
| 431 | httpProtocol: String? = nil, | |
| 432 | http3Advertised: Bool = false, | |
| 403 | 433 | httpHeadersError: String? = nil, |
| 404 | 434 | reachabilityResults: [PortReachability], |
| 405 | 435 | ipGeolocation: IPGeolocation?, |
| @@ -548,6 +578,21 @@ final class DomainViewModel { | ||
| 548 | 578 | for header in httpHeaders { |
| 549 | 579 | lines.append(" \(header.name): \(header.value)") |
| 550 | 580 | } |
| 581 | if let httpSecurityGrade { | |
| 582 | lines.append("Grade: \(httpSecurityGrade)") | |
| 583 | } | |
| 584 | if let httpStatusCode { | |
| 585 | lines.append("Status: \(httpStatusCode)") | |
| 586 | } | |
| 587 | if let httpResponseTimeMs { | |
| 588 | lines.append("Response Time: \(httpResponseTimeMs)ms") | |
| 589 | } | |
| 590 | if let httpProtocol { | |
| 591 | lines.append("Protocol: \(httpProtocol)") | |
| 592 | } | |
| 593 | if http3Advertised { | |
| 594 | lines.append("HTTP/3 Advertised: Yes") | |
| 595 | } | |
| 551 | 596 | } else if let error = httpHeadersError { |
| 552 | 597 | lines.append("") |
| 553 | 598 | lines.append("HTTP Headers") |
DomainDig/HTTPHeadersService.swift +94 −6
| @@ -1,22 +1,110 @@ | ||
| 1 | 1 | import Foundation |
| 2 | 2 | |
| 3 | enum HTTPSecurityGrade: String { | |
| 4 | case a = "A" | |
| 5 | case b = "B" | |
| 6 | case c = "C" | |
| 7 | case d = "D" | |
| 8 | case f = "F" | |
| 9 | ||
| 10 | static func grade(for headers: [HTTPHeader]) -> HTTPSecurityGrade { | |
| 11 | let presentHeaderNames = Set(headers.map { $0.name.lowercased() }) | |
| 12 | let presentCount = HTTPHeader.securityHeaders.intersection(presentHeaderNames).count | |
| 13 | ||
| 14 | switch presentCount { | |
| 15 | case 5: | |
| 16 | return .a | |
| 17 | case 4: | |
| 18 | return .b | |
| 19 | case 3: | |
| 20 | return .c | |
| 21 | case 2: | |
| 22 | return .d | |
| 23 | default: | |
| 24 | return .f | |
| 25 | } | |
| 26 | } | |
| 27 | } | |
| 28 | ||
| 29 | struct HTTPHeadersResult { | |
| 30 | let headers: [HTTPHeader] | |
| 31 | let statusCode: Int? | |
| 32 | let responseTimeMs: Int? | |
| 33 | let httpProtocol: String? | |
| 34 | let http3Advertised: Bool | |
| 35 | } | |
| 36 | ||
| 3 | 37 | struct HTTPHeadersService { |
| 4 | static func fetch(domain: String) async throws -> [HTTPHeader] { | |
| 38 | static func fetch(domain: String) async throws -> HTTPHeadersResult { | |
| 5 | 39 | let url = URL(string: "https://\(domain)")! |
| 6 | 40 | var request = URLRequest(url: url, timeoutInterval: 10) |
| 7 | 41 | request.httpMethod = "HEAD" |
| 42 | let metricsDelegate = TaskMetricsDelegate() | |
| 43 | let startTime = Date() | |
| 8 | 44 | |
| 9 | let (_, response) = try await URLSession.shared.data(for: request) | |
| 45 | let (_, response) = try await URLSession.shared.data(for: request, delegate: metricsDelegate) | |
| 46 | let responseTimeMs = max(0, Int(Date().timeIntervalSince(startTime) * 1000)) | |
| 10 | 47 | |
| 11 | 48 | guard let httpResponse = response as? HTTPURLResponse else { |
| 12 | 49 | throw URLError(.badServerResponse) |
| 13 | 50 | } |
| 14 | 51 | |
| 15 | return httpResponse.allHeaderFields.compactMap { key, value in | |
| 16 | guard let name = key as? String, | |
| 17 | let val = value as? String else { return nil } | |
| 18 | return HTTPHeader(name: name, value: val) | |
| 52 | let headers = httpResponse.allHeaderFields.compactMap { entry -> HTTPHeader? in | |
| 53 | guard let name = entry.key as? String, | |
| 54 | let value = entry.value as? String else { return nil } | |
| 55 | return HTTPHeader(name: name, value: value) | |
| 19 | 56 | } |
| 20 | 57 | .sorted { $0.name.lowercased() < $1.name.lowercased() } |
| 58 | ||
| 59 | let networkProtocolName = metricsDelegate.metrics?.transactionMetrics | |
| 60 | .compactMap { $0.networkProtocolName } | |
| 61 | .last | |
| 62 | let detectedProtocol = protocolLabel(for: networkProtocolName) | |
| 63 | let altSvcValue = headerValue(named: "alt-svc", in: httpResponse) | |
| 64 | let http3Advertised = altSvcValue?.localizedCaseInsensitiveContains("h3") == true | |
| 65 | ||
| 66 | return HTTPHeadersResult( | |
| 67 | headers: headers, | |
| 68 | statusCode: httpResponse.statusCode, | |
| 69 | responseTimeMs: responseTimeMs, | |
| 70 | httpProtocol: detectedProtocol, | |
| 71 | http3Advertised: http3Advertised | |
| 72 | ) | |
| 73 | } | |
| 74 | ||
| 75 | private static func protocolLabel(for networkProtocolName: String?) -> String? { | |
| 76 | guard let networkProtocolName else { return nil } | |
| 77 | ||
| 78 | let normalized = networkProtocolName.lowercased() | |
| 79 | if normalized == "h2" { | |
| 80 | return "HTTP/2" | |
| 81 | } | |
| 82 | if normalized == "h3" || normalized.hasPrefix("quic") { | |
| 83 | return "HTTP/3" | |
| 84 | } | |
| 85 | if normalized.hasPrefix("http/") { | |
| 86 | return normalized.uppercased() | |
| 87 | } | |
| 88 | ||
| 89 | return networkProtocolName.uppercased() | |
| 90 | } | |
| 91 | ||
| 92 | private static func headerValue(named name: String, in response: HTTPURLResponse) -> String? { | |
| 93 | response.allHeaderFields.first { key, _ in | |
| 94 | guard let headerName = key as? String else { return false } | |
| 95 | return headerName.caseInsensitiveCompare(name) == .orderedSame | |
| 96 | }?.value as? String | |
| 97 | } | |
| 98 | } | |
| 99 | ||
| 100 | private final class TaskMetricsDelegate: NSObject, URLSessionTaskDelegate { | |
| 101 | private(set) var metrics: URLSessionTaskMetrics? | |
| 102 | ||
| 103 | func urlSession( | |
| 104 | _ session: URLSession, | |
| 105 | task: URLSessionTask, | |
| 106 | didFinishCollecting metrics: URLSessionTaskMetrics | |
| 107 | ) { | |
| 108 | self.metrics = metrics | |
| 21 | 109 | } |
| 22 | 110 | } |