import SwiftUI import ProxyCore struct RequestDetailView: View { @Environment(AppState.self) private var appState let trafficId: Int64 @State private var traffic: CapturedTraffic? @State private var selectedSegment: Segment = .request @State private var requestBodyMode: BodyDisplayMode? @State private var responseBodyMode: BodyDisplayMode? @State private var showShareSheet = false private let trafficRepo = TrafficRepository() private let rulesRepo = RulesRepository() enum Segment: String, CaseIterable { case request = "Request" case response = "Response" } enum BodyDisplayMode: String, CaseIterable { case body = "Body" case tree = "Tree" case hex = "Hex" } var body: some View { Group { if let traffic { ScrollView { LazyVStack(alignment: .leading, spacing: 16) { heroCard(for: traffic) segmentSwitcher switch selectedSegment { case .request: requestContent(traffic) case .response: responseContent(traffic) } } .padding(.horizontal, 16) .padding(.vertical, 14) .padding(.bottom, 28) } .scrollIndicators(.hidden) .background(screenBackground) } else { ProgressView() .frame(maxWidth: .infinity, maxHeight: .infinity) .background(screenBackground) } } .navigationTitle(traffic?.domain ?? "Request") .navigationBarTitleDisplayMode(.inline) .toolbar { if let traffic { ToolbarItem(placement: .topBarTrailing) { Button { ProxyLogger.ui.info("RequestDetailView[\(trafficId)]: share tapped") showShareSheet = true } label: { Image(systemName: "square.and.arrow.up") } } ToolbarItem(placement: .topBarTrailing) { Button { ProxyLogger.ui.info("RequestDetailView[\(trafficId)]: pin toggled to \(!traffic.isPinned)") try? trafficRepo.togglePin(id: trafficId, isPinned: !traffic.isPinned) self.traffic?.isPinned.toggle() } label: { Image(systemName: traffic.isPinned ? "pin.fill" : "pin") } } } } .sheet(isPresented: $showShareSheet) { if let traffic { ActivitySheet(activityItems: [buildCURL(from: traffic)]) } } .onAppear { ProxyLogger.ui.info("RequestDetailView[\(trafficId)]: onAppear") } .onDisappear { ProxyLogger.ui.info("RequestDetailView[\(trafficId)]: onDisappear") } .onChange(of: selectedSegment) { _, newValue in ProxyLogger.ui.info("RequestDetailView[\(trafficId)]: segment changed to \(newValue.rawValue)") } .task { ProxyLogger.ui.info("RequestDetailView[\(trafficId)]: loading traffic record") do { let loadedTraffic = try trafficRepo.traffic(byId: trafficId) traffic = loadedTraffic if let loadedTraffic { ProxyLogger.ui.info( "RequestDetailView[\(trafficId)]: loaded method=\(loadedTraffic.method) domain=\(loadedTraffic.domain) status=\(loadedTraffic.statusCode ?? -1)" ) } else { ProxyLogger.ui.error("RequestDetailView[\(trafficId)]: no traffic record found") } } catch { ProxyLogger.ui.error("RequestDetailView[\(trafficId)]: load failed \(error.localizedDescription)") } } } private func heroCard(for traffic: CapturedTraffic) -> some View { DetailScreenCard { VStack(alignment: .leading, spacing: 16) { VStack(alignment: .leading, spacing: 6) { Text(primaryTitle(for: traffic)) .font(.title3.weight(.semibold)) .foregroundStyle(.primary) Text(traffic.url) .font(.subheadline) .foregroundStyle(.secondary) .textSelection(.enabled) } ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 8) { MethodBadge(method: traffic.method) StatusBadge(statusCode: traffic.statusCode) detailTag(traffic.scheme.uppercased(), systemImage: "network") inspectionTag(for: traffic) if traffic.isPinned { detailTag("Pinned", systemImage: "pin.fill", tint: .secondary) } } .padding(.vertical, 2) } LazyVGrid(columns: overviewColumns, spacing: 10) { metricTile("Started", value: traffic.startDate.formatted(.dateTime.hour().minute().second()), systemImage: "clock") metricTile("Duration", value: traffic.formattedDuration, systemImage: "timer") metricTile("Request", value: formatBytes(traffic.requestBodySize), systemImage: "arrow.up.right") metricTile("Response", value: responseSizeText(for: traffic), systemImage: "arrow.down.right") } } } } private var segmentSwitcher: some View { DetailScreenCard(padding: 8) { Picker("Segment", selection: $selectedSegment) { ForEach(Segment.allCases, id: \.self) { segment in Text(segment.rawValue).tag(segment) } } .pickerStyle(.segmented) } } // MARK: - Request Content @ViewBuilder private func requestContent(_ traffic: CapturedTraffic) -> some View { if let tlsHint = tlsHint(for: traffic) { warningCard( title: "HTTPS Passthrough", message: tlsHint ) } DetailScreenCard { VStack(alignment: .leading, spacing: 14) { cardHeader( title: "Request Overview", subtitle: "Core metadata and payload info" ) detailField("Host", value: traffic.domain) detailField("Path", value: primaryTitle(for: traffic)) LazyVGrid(columns: overviewColumns, spacing: 12) { detailField("Method", value: traffic.method.uppercased()) detailField("Scheme", value: traffic.scheme.uppercased()) if let contentType = traffic.requestContentType { detailField("Content-Type", value: contentType) } detailField("Body Size", value: formatBytes(traffic.requestBodySize)) if !traffic.decodedQueryParameters.isEmpty { detailField("Query Items", value: "\(traffic.decodedQueryParameters.count)") } if traffic.isWebsocket { detailField("Upgrade", value: "WebSocket") } } } } let queryParams = traffic.decodedQueryParameters if !queryParams.isEmpty { detailKeyValueCard( title: "Query Parameters", subtitle: "\(queryParams.count) items", actionTitle: nil, action: nil, pairs: queryParams.sorted(by: { $0.key < $1.key }) ) } let requestHeaders = traffic.decodedRequestHeaders if !requestHeaders.isEmpty { detailKeyValueCard( title: "Headers", subtitle: "\(requestHeaders.count) items", actionTitle: "Copy", action: { copyHeaders(requestHeaders) }, pairs: requestHeaders.sorted(by: { $0.key < $1.key }) ) } if let body = traffic.requestBody, !body.isEmpty { bodyCard( title: "Request Body", subtitle: formatBytes(body.count), data: body, contentType: traffic.requestContentType, mode: $requestBodyMode ) } } // MARK: - Response Content @ViewBuilder private func responseContent(_ traffic: CapturedTraffic) -> some View { if traffic.statusCode == nil { DetailScreenCard { EmptyStateView( icon: "clock", title: "Waiting for Response", subtitle: "The response has not been received yet." ) } } else { let responseHeaders = traffic.decodedResponseHeaders let displayedResponseBody = traffic.decodedResponseBodyData ?? traffic.responseBody DetailScreenCard { VStack(alignment: .leading, spacing: 14) { HStack(alignment: .top, spacing: 12) { VStack(alignment: .leading, spacing: 4) { Text("Response Overview") .font(.headline) Text(traffic.statusText ?? "Response received") .font(.subheadline) .foregroundStyle(.secondary) } Spacer() StatusBadge(statusCode: traffic.statusCode) } LazyVGrid(columns: overviewColumns, spacing: 12) { if let statusCode = traffic.statusCode { detailField("Status", value: "\(statusCode)") } detailField("Body Size", value: formatBytes(traffic.responseBodySize)) if let responseType = traffic.responseContentType { detailField("Content-Type", value: responseType) } if let completedAt = traffic.completedAt { detailField( "Finished", value: Date(timeIntervalSince1970: completedAt) .formatted(.dateTime.hour().minute().second()) ) } if let server = traffic.responseHeaderValue(named: "Server") { detailField("Server", value: server) } detailField("Headers", value: "\(responseHeaders.count)") } } } if !responseHeaders.isEmpty { detailKeyValueCard( title: "Headers", subtitle: "\(responseHeaders.count) items", actionTitle: "Copy", action: { copyHeaders(responseHeaders) }, pairs: responseHeaders.sorted(by: { $0.key < $1.key }) ) } if let body = displayedResponseBody, !body.isEmpty { bodyCard( title: "Response Body", subtitle: responseBodySubtitle(for: traffic, displayedBody: body), data: body, contentType: traffic.responseContentType, mode: $responseBodyMode ) } } } // MARK: - Shared Content Builders @ViewBuilder private func warningCard(title: String, message: String) -> some View { DetailScreenCard { HStack(alignment: .top, spacing: 12) { Image(systemName: "lock.fill") .font(.headline) .foregroundStyle(.orange) .frame(width: 28, height: 28) .background(Color.orange.opacity(0.12), in: Circle()) VStack(alignment: .leading, spacing: 6) { Text(title) .font(.headline) Text(message) .font(.caption) .foregroundStyle(.secondary) } } } } @ViewBuilder private func detailKeyValueCard( title: String, subtitle: String?, actionTitle: String?, action: (() -> Void)?, pairs: [(key: String, value: String)] ) -> some View { DetailScreenCard { VStack(alignment: .leading, spacing: 14) { HStack(alignment: .top) { VStack(alignment: .leading, spacing: 4) { Text(title) .font(.headline) if let subtitle { Text(subtitle) .font(.caption) .foregroundStyle(.secondary) } } Spacer() if let actionTitle, let action { Button(actionTitle, action: action) .font(.caption.weight(.semibold)) .buttonStyle(.bordered) .controlSize(.small) } } VStack(spacing: 0) { ForEach(Array(pairs.enumerated()), id: \.offset) { index, item in VStack(alignment: .leading, spacing: 4) { Text(item.key) .font(.caption.weight(.semibold)) .foregroundStyle(.secondary) Text(item.value) .font(.subheadline) .foregroundStyle(.primary) .textSelection(.enabled) .frame(maxWidth: .infinity, alignment: .leading) } .frame(maxWidth: .infinity, alignment: .leading) .padding(.vertical, 12) if index < pairs.count - 1 { Divider() } } } } } } @ViewBuilder private func bodyCard( title: String, subtitle: String, data: Data, contentType: String?, mode: Binding ) -> some View { DetailScreenCard { VStack(alignment: .leading, spacing: 14) { HStack(alignment: .top) { VStack(alignment: .leading, spacing: 4) { Text(title) .font(.headline) Text(subtitle) .font(.caption) .foregroundStyle(.secondary) } Spacer() Button("Copy") { copyBody(data, contentType: contentType) } .font(.caption.weight(.semibold)) .buttonStyle(.bordered) .controlSize(.small) } bodyView(data: data, contentType: contentType, mode: mode) } } } // MARK: - Body View private func defaultMode(for data: Data, contentType: String?) -> BodyDisplayMode { if let contentType, contentType.contains("json"), (try? JSONSerialization.jsonObject(with: data)) != nil { return .tree } else if String(data: data, encoding: .utf8) != nil { return .body } else { return .hex } } @ViewBuilder private func bodyView(data: Data, contentType: String?, mode: Binding) -> some View { let resolvedMode = mode.wrappedValue ?? defaultMode(for: data, contentType: contentType) VStack(alignment: .leading, spacing: 12) { Picker("Display", selection: Binding( get: { resolvedMode }, set: { mode.wrappedValue = $0 } )) { ForEach(BodyDisplayMode.allCases, id: \.self) { displayMode in Text(displayMode.rawValue).tag(displayMode) } } .pickerStyle(.segmented) Group { switch resolvedMode { case .body: bodyTextView(data: data, contentType: contentType) case .tree: JSONTreeView(data: data) case .hex: ScrollView(.horizontal) { HexView(data: data) } } } .frame(maxWidth: .infinity, alignment: .leading) .padding(14) .background(Color(.systemBackground), in: RoundedRectangle(cornerRadius: 18, style: .continuous)) .overlay( RoundedRectangle(cornerRadius: 18, style: .continuous) .strokeBorder(Color.primary.opacity(0.05), lineWidth: 1) ) } } @ViewBuilder private func bodyTextView(data: Data, contentType: String?) -> some View { if let bodyString = formattedBodyString(data: data, contentType: contentType) { SelectableTextView(text: bodyString) .frame(maxWidth: .infinity, minHeight: 140, alignment: .topLeading) } else { Text("\(data.count) bytes (binary)") .font(.caption) .foregroundStyle(.secondary) } } // MARK: - Helpers private var overviewColumns: [GridItem] { [GridItem(.flexible()), GridItem(.flexible())] } private func primaryTitle(for traffic: CapturedTraffic) -> String { guard let components = URLComponents(string: traffic.url) else { return traffic.url } var value = components.path if let query = components.percentEncodedQuery, !query.isEmpty { value += "?\(query)" } if value.isEmpty { if let host = components.host { let portSuffix = components.port.map { ":\($0)" } ?? "" return "\(host)\(portSuffix)" } return traffic.url } return value } private func responseSizeText(for traffic: CapturedTraffic) -> String { if traffic.responseBodySize > 0 { return formatBytes(traffic.responseBodySize) } if traffic.statusCode == nil { return "Pending" } return "0 B" } private func responseBodySubtitle(for traffic: CapturedTraffic, displayedBody: Data) -> String { let rawSize = formatBytes(traffic.responseBodySize) let hint = traffic.responseBodyDecodingHint guard hint != "identity", hint != "empty" else { return rawSize } return "\(formatBytes(displayedBody.count)) shown from \(rawSize) raw (\(hint))" } private func formattedBodyString(data: Data, contentType: String?) -> String? { if let contentType, contentType.localizedCaseInsensitiveContains("json"), let json = try? JSONSerialization.jsonObject(with: data), let pretty = try? JSONSerialization.data(withJSONObject: json, options: [.prettyPrinted, .sortedKeys]), let string = String(data: pretty, encoding: .utf8) { return string } if let string = String(data: data, encoding: .utf8), !string.isEmpty { return string } return nil } private func cardHeader(title: String, subtitle: String) -> some View { VStack(alignment: .leading, spacing: 4) { Text(title) .font(.headline) Text(subtitle) .font(.caption) .foregroundStyle(.secondary) } } @ViewBuilder private func metricTile(_ title: String, value: String, systemImage: String) -> some View { VStack(alignment: .leading, spacing: 8) { Label(title, systemImage: systemImage) .font(.caption.weight(.semibold)) .foregroundStyle(.secondary) Text(value) .font(.headline.weight(.semibold)) .foregroundStyle(.primary) .lineLimit(1) .minimumScaleFactor(0.8) } .frame(maxWidth: .infinity, alignment: .leading) .padding(14) .background(Color(.systemBackground).opacity(0.75), in: RoundedRectangle(cornerRadius: 18, style: .continuous)) } @ViewBuilder private func detailField(_ title: String, value: String) -> some View { VStack(alignment: .leading, spacing: 4) { Text(title) .font(.caption.weight(.semibold)) .foregroundStyle(.secondary) Text(value) .font(.subheadline) .foregroundStyle(.primary) .textSelection(.enabled) .frame(maxWidth: .infinity, alignment: .leading) } } @ViewBuilder private func detailTag(_ text: String, systemImage: String? = nil, tint: Color = .secondary) -> some View { Group { if let systemImage { Label(text, systemImage: systemImage) } else { Text(text) } } .font(.caption.weight(.semibold)) .foregroundStyle(tint) .padding(.horizontal, 12) .padding(.vertical, 8) .background(tint.opacity(0.10), in: Capsule()) } @ViewBuilder private func inspectionTag(for traffic: CapturedTraffic) -> some View { if traffic.scheme == "http" { detailTag("Plain HTTP", systemImage: "bolt.horizontal", tint: .blue) } else if traffic.isSslDecrypted { detailTag("Decrypted", systemImage: "lock.open.fill", tint: .green) } else { detailTag("Encrypted", systemImage: "lock.fill", tint: .orange) } } private var screenBackground: some View { LinearGradient( colors: [ Color(.systemGroupedBackground), Color(.secondarySystemGroupedBackground) ], startPoint: .top, endPoint: .bottom ) .ignoresSafeArea() } private func formatBytes(_ bytes: Int) -> String { if bytes < 1024 { return "\(bytes) B" } if bytes < 1_048_576 { return String(format: "%.1f KB", Double(bytes) / 1024) } return String(format: "%.1f MB", Double(bytes) / 1_048_576) } private func tlsHint(for traffic: CapturedTraffic) -> String? { guard !traffic.isSslDecrypted, traffic.method == "CONNECT" else { return nil } if !IPCManager.shared.isSSLProxyingEnabled { return "SSL Proxying is turned off. Enable it in More > SSL Proxying and retry the request." } let state = sslProxyState(for: traffic.domain) if state.isExcluded { return "This domain matches an SSL Proxying exclude rule, so the connection was intentionally passed through without decryption." } if !state.isIncluded { return "This domain is not in the SSL Proxying include list. Add it in More > SSL Proxying to capture HTTPS headers and bodies." } if !appState.hasSharedCertificate { return "This domain is included, but the extension has not loaded the same shared CA as the app yet. Reopen the tunnel or regenerate and reinstall the certificate." } if let lastMITMError = appState.runtimeStatus.lastMITMError { return "This domain is included, but HTTPS interception failed. Latest MITM error: \(lastMITMError)" } if !appState.isHTTPSInspectionVerified { return "This domain is included and the shared CA is available. If it still stays encrypted, install and trust the Proxy CA in Settings, then retry." } return "This request stayed encrypted even though SSL Proxying is enabled. The most likely causes are certificate pinning or an upstream TLS handshake failure." } private func sslProxyState(for domain: String) -> (isIncluded: Bool, isExcluded: Bool) { do { let entries = try rulesRepo.fetchAllSSLEntries() let isExcluded = entries .filter { !$0.isInclude } .contains { WildcardMatcher.matches(domain, pattern: $0.domainPattern) } let isIncluded = entries .filter(\.isInclude) .contains { WildcardMatcher.matches(domain, pattern: $0.domainPattern) } return (isIncluded, isExcluded) } catch { return (false, false) } } // MARK: - cURL Builder private func buildCURL(from traffic: CapturedTraffic) -> String { var parts = ["curl -X \(traffic.method) '\(traffic.url)'"] for (key, value) in traffic.decodedRequestHeaders.sorted(by: { $0.key < $1.key }) { parts.append("-H '\(key): \(value)'") } if let body = traffic.requestBody, let bodyString = String(data: body, encoding: .utf8), !bodyString.isEmpty { let escaped = bodyString.replacingOccurrences(of: "'", with: "'\\''") parts.append("-d '\(escaped)'") } return parts.joined(separator: " \\\n ") } // MARK: - Copy Helpers private func copyHeaders(_ headers: [String: String]) { let text = headers.sorted(by: { $0.key < $1.key }) .map { "\($0.key): \($0.value)" } .joined(separator: "\n") UIPasteboard.general.string = text } private func copyBody(_ data: Data, contentType: String?) { if let contentType, contentType.contains("json"), let json = try? JSONSerialization.jsonObject(with: data), let pretty = try? JSONSerialization.data(withJSONObject: json, options: .prettyPrinted), let string = String(data: pretty, encoding: .utf8) { UIPasteboard.general.string = string } else if let string = String(data: data, encoding: .utf8) { UIPasteboard.general.string = string } } } private struct DetailScreenCard: View { let padding: CGFloat let content: Content init(padding: CGFloat = 18, @ViewBuilder content: () -> Content) { self.padding = padding self.content = content() } var body: some View { ZStack { RoundedRectangle(cornerRadius: 26, style: .continuous) .fill(Color(.secondarySystemGroupedBackground)) RoundedRectangle(cornerRadius: 26, style: .continuous) .fill( LinearGradient( colors: [ Color.accentColor.opacity(0.08), .clear, Color.blue.opacity(0.04) ], startPoint: .topLeading, endPoint: .bottomTrailing ) ) content .padding(padding) .frame(maxWidth: .infinity, alignment: .leading) } .overlay( RoundedRectangle(cornerRadius: 26, style: .continuous) .strokeBorder(Color.primary.opacity(0.05), lineWidth: 1) ) } } // MARK: - Share Sheet private struct ActivitySheet: UIViewControllerRepresentable { let activityItems: [Any] func makeUIViewController(context: Context) -> UIActivityViewController { UIActivityViewController(activityItems: activityItems, applicationActivities: nil) } func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) {} }