- Adaptive iPhone/iPad layout with NavigationSplitView sidebar - Auto-detect SSL-pinned domains, fall back to passthrough - Certificate install via local HTTP server (Safari profile flow) - App Group-backed CA, per-domain leaf cert LRU cache - DB-backed config repository, Darwin notification throttling - Rules engine, breakpoint rules, pinned domain tracking - os.Logger instrumentation across tunnel/proxy/mitm/capture/cert/rules/db/ipc/ui - Fix dyld framework embed, race conditions, thread safety
751 lines
28 KiB
Swift
751 lines
28 KiB
Swift
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<BodyDisplayMode?>
|
|
) -> 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<BodyDisplayMode?>) -> 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<Content: View>: 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) {}
|
|
}
|