Add iPad support, auto-pinning, and comprehensive logging

- 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
This commit is contained in:
Trey t
2026-04-11 12:52:18 -05:00
parent c77e506db5
commit 148bc3887c
77 changed files with 6710 additions and 847 deletions

View File

@@ -10,73 +10,375 @@ struct DomainDetailView: View {
@State private var filterChips: [FilterChip] = [
FilterChip(label: "JSON"),
FilterChip(label: "Form"),
FilterChip(label: "HTTP"),
FilterChip(label: "HTTPS"),
FilterChip(label: "Errors"),
FilterChip(label: "HTTPS")
]
@State private var observation: AnyDatabaseCancellable?
@State private var didStartObservation = false
private let trafficRepo = TrafficRepository()
private let hardcodedDebugDomain = "okcupid"
private let hardcodedDebugNeedle = "jill"
var filteredRequests: [CapturedTraffic] {
private var filteredRequests: [CapturedTraffic] {
filteredResults(for: requests)
}
private var httpsCount: Int {
requests.filter { $0.scheme == "https" }.count
}
private var errorCount: Int {
requests.filter { ($0.statusCode ?? 0) >= 400 }.count
}
private var jsonCount: Int {
requests.filter {
$0.responseContentType?.contains("json") == true ||
$0.requestContentType?.contains("json") == true
}.count
}
private var lastSeenText: String {
guard let date = requests.first?.startDate else { return "Waiting" }
return date.formatted(.relative(presentation: .named))
}
private var activeFilterLabels: String {
let labels = filterChips.filter(\.isSelected).map(\.label)
return labels.isEmpty ? "none" : labels.joined(separator: ",")
}
var body: some View {
ScrollView {
LazyVStack(alignment: .leading, spacing: 16) {
summaryCard
filtersCard
if filteredRequests.isEmpty {
emptyStateCard
} else {
LazyVStack(spacing: 12) {
ForEach(filteredRequests) { request in
if let id = request.id {
NavigationLink(value: id) {
TrafficRowView(traffic: request)
}
.buttonStyle(.plain)
}
}
}
}
}
.padding(.horizontal, 16)
.padding(.vertical, 14)
.padding(.bottom, 28)
}
.background(screenBackground)
.scrollIndicators(.hidden)
.searchable(text: $searchText, prompt: "Search path, method, status, or response body")
.navigationTitle(domain)
.navigationBarTitleDisplayMode(.inline)
.navigationDestination(for: Int64.self) { id in
RequestDetailView(trafficId: id)
}
.onAppear {
ProxyLogger.ui.info("DomainDetailView[\(domain)]: onAppear requests=\(requests.count)")
}
.onDisappear {
ProxyLogger.ui.info("DomainDetailView[\(domain)]: onDisappear requests=\(requests.count)")
observation?.cancel()
observation = nil
didStartObservation = false
}
.onChange(of: searchText) { _, newValue in
ProxyLogger.ui.info("DomainDetailView[\(domain)]: search changed text=\(newValue)")
if newValue.localizedCaseInsensitiveContains(hardcodedDebugNeedle) {
logHardcodedSearchDebug(requests: requests, source: "searchChanged")
}
}
.onChange(of: filterChips) { _, _ in
ProxyLogger.ui.info("DomainDetailView[\(domain)]: filters changed active=\(activeFilterLabels)")
if searchText.localizedCaseInsensitiveContains(hardcodedDebugNeedle) {
logHardcodedSearchDebug(requests: requests, source: "filtersChanged")
}
}
.task {
guard !didStartObservation else {
ProxyLogger.ui.info("DomainDetailView[\(domain)]: task rerun ignored; observation already active")
return
}
didStartObservation = true
ProxyLogger.ui.info("DomainDetailView[\(domain)]: starting observation")
observation = trafficRepo.observeTraffic(forDomain: domain)
.start(in: DatabaseManager.shared.dbPool) { error in
ProxyLogger.ui.error("DomainDetailView[\(domain)]: observation error \(error.localizedDescription)")
} onChange: { newRequests in
let filteredCount = filteredResults(for: newRequests).count
let preview = newRequests.prefix(3).compactMap { request -> String? in
guard let id = request.id else { return request.method }
return "#\(id):\(request.method)"
}.joined(separator: ", ")
ProxyLogger.ui.info(
"DomainDetailView[\(domain)]: requests updated count=\(newRequests.count) filtered=\(filteredCount) preview=\(preview)"
)
logHardcodedSearchDebug(requests: newRequests, source: "observation")
withAnimation(.snappy) {
requests = newRequests
}
}
}
}
private var summaryCard: some View {
DomainSurfaceCard {
VStack(alignment: .leading, spacing: 16) {
VStack(alignment: .leading, spacing: 6) {
Text(domain)
.font(.title3.weight(.semibold))
.foregroundStyle(.primary)
Text("Scan recent calls, errors, payload types, and request timing for this host.")
.font(.subheadline)
.foregroundStyle(.secondary)
}
LazyVGrid(columns: summaryColumns, spacing: 10) {
summaryMetric("Requests", value: "\(requests.count)", systemImage: "point.3.connected.trianglepath.dotted")
summaryMetric("HTTPS", value: "\(httpsCount)", systemImage: "lock.fill")
summaryMetric("Errors", value: "\(errorCount)", systemImage: "exclamationmark.triangle.fill")
summaryMetric("JSON", value: "\(jsonCount)", systemImage: "curlybraces")
}
HStack(spacing: 8) {
domainTag("Last seen \(lastSeenText)", systemImage: "clock")
if !searchText.isEmpty {
domainTag("Searching", systemImage: "magnifyingglass", tint: .accentColor)
}
}
}
}
}
private var filtersCard: some View {
DomainSurfaceCard {
VStack(alignment: .leading, spacing: 14) {
HStack(alignment: .firstTextBaseline) {
VStack(alignment: .leading, spacing: 4) {
Text("Refine Requests")
.font(.headline)
Text("\(filteredRequests.count) of \(requests.count) shown")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
if filterChips.contains(where: \.isSelected) {
Button("Clear") {
withAnimation(.snappy) {
for index in filterChips.indices {
filterChips[index].isSelected = false
}
}
}
.font(.caption.weight(.semibold))
}
}
FilterChipsView(chips: $filterChips)
}
}
}
private var emptyStateCard: some View {
DomainSurfaceCard {
EmptyStateView(
icon: "line.3.horizontal.decrease.circle",
title: requests.isEmpty ? "No Requests Yet" : "No Matching Requests",
subtitle: requests.isEmpty
? "Traffic for this domain will appear here as soon as the app captures it."
: "Try a different search or clear one of the active filters."
)
}
}
private var summaryColumns: [GridItem] {
[GridItem(.flexible()), GridItem(.flexible())]
}
@ViewBuilder
private func summaryMetric(_ 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(.title3.weight(.semibold))
.foregroundStyle(.primary)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(14)
.background(Color(.systemBackground).opacity(0.75), in: RoundedRectangle(cornerRadius: 18, style: .continuous))
}
@ViewBuilder
private func domainTag(_ text: String, systemImage: String, tint: Color = .secondary) -> some View {
Label(text, systemImage: systemImage)
.font(.caption.weight(.semibold))
.foregroundStyle(tint)
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(tint.opacity(0.10), in: Capsule())
}
private var screenBackground: some View {
LinearGradient(
colors: [
Color(.systemGroupedBackground),
Color(.secondarySystemGroupedBackground)
],
startPoint: .top,
endPoint: .bottom
)
.ignoresSafeArea()
}
private func filteredResults(for requests: [CapturedTraffic]) -> [CapturedTraffic] {
var result = requests
if !searchText.isEmpty {
result = result.filter { $0.url.localizedCaseInsensitiveContains(searchText) }
result = result.filter {
$0.url.localizedCaseInsensitiveContains(searchText) ||
$0.method.localizedCaseInsensitiveContains(searchText) ||
($0.statusText?.localizedCaseInsensitiveContains(searchText) == true) ||
($0.searchableResponseBodyText?.localizedCaseInsensitiveContains(searchText) == true)
}
}
let activeFilters = filterChips.filter(\.isSelected).map(\.label)
let activeFilters = Set(filterChips.filter(\.isSelected).map(\.label))
if !activeFilters.isEmpty {
result = result.filter { request in
for filter in activeFilters {
activeFilters.contains { filter in
switch filter {
case "JSON":
if request.responseContentType?.contains("json") == true { return true }
return request.responseContentType?.contains("json") == true ||
request.requestContentType?.contains("json") == true
case "Form":
if request.requestContentType?.contains("form") == true { return true }
case "HTTP":
if request.scheme == "http" { return true }
return request.requestContentType?.contains("form") == true
case "Errors":
return (request.statusCode ?? 0) >= 400
case "HTTPS":
if request.scheme == "https" { return true }
default: break
return request.scheme == "https"
default:
return false
}
}
return false
}
}
return result
}
var body: some View {
VStack(spacing: 0) {
FilterChipsView(chips: $filterChips)
.padding(.vertical, 8)
private func logHardcodedSearchDebug(requests: [CapturedTraffic], source: String) {
guard domain.localizedCaseInsensitiveContains(hardcodedDebugDomain) ||
requests.contains(where: {
$0.domain.localizedCaseInsensitiveContains(hardcodedDebugDomain) ||
$0.url.localizedCaseInsensitiveContains(hardcodedDebugDomain)
}) ||
searchText.localizedCaseInsensitiveContains(hardcodedDebugNeedle) else {
return
}
List {
ForEach(filteredRequests) { request in
NavigationLink(value: request.id) {
TrafficRowView(traffic: request)
}
}
}
let matchingRequests = requests.filter { request in
request.searchableResponseBodyText?.localizedCaseInsensitiveContains(hardcodedDebugNeedle) == true
}
.searchable(text: $searchText)
.navigationTitle(domain)
.navigationBarTitleDisplayMode(.inline)
.navigationDestination(for: Int64?.self) { id in
if let id {
RequestDetailView(trafficId: id)
}
if matchingRequests.isEmpty {
ProxyLogger.ui.info(
"HARDCODED DEBUG search source=\(source) domain=\(domain) needle=\(hardcodedDebugNeedle) found=0 total=\(requests.count) searchText=\(searchText)"
)
logHardcodedRequestDiagnostics(requests: requests, source: source)
return
}
.task {
observation = trafficRepo.observeTraffic(forDomain: domain)
.start(in: DatabaseManager.shared.dbPool) { error in
print("Observation error: \(error)")
} onChange: { newRequests in
withAnimation {
requests = newRequests
}
}
let filtered = filteredResults(for: requests)
for request in matchingRequests {
let isVisibleInFilteredResults = filtered.contains { $0.id == request.id }
let bodyPreview = String((request.searchableResponseBodyText ?? "").prefix(180))
.replacingOccurrences(of: "\n", with: " ")
ProxyLogger.ui.info(
"""
HARDCODED DEBUG search source=\(source) domain=\(domain) needle=\(hardcodedDebugNeedle) \
requestId=\(request.id ?? -1) visible=\(isVisibleInFilteredResults) url=\(request.url) \
status=\(request.statusCode ?? -1) preview=\(bodyPreview)
"""
)
}
}
private func logHardcodedRequestDiagnostics(requests: [CapturedTraffic], source: String) {
for request in requests.prefix(20) {
let contentEncoding = request.responseHeaderValue(named: "Content-Encoding") ?? "nil"
let contentType = request.responseContentType ?? "nil"
let bodySize = request.responseBody?.count ?? 0
let decodedBodySize = request.decodedResponseBodyData?.count ?? 0
let decodingHint = request.responseBodyDecodingHint
let gzipMagic = hasGzipMagic(request.responseBody)
let preview = String((request.searchableResponseBodyText ?? "<no searchable text>").prefix(140))
.replacingOccurrences(of: "\n", with: " ")
ProxyLogger.ui.info(
"""
HARDCODED DEBUG body source=\(source) domain=\(domain) requestId=\(request.id ?? -1) \
status=\(request.statusCode ?? -1) contentType=\(contentType) contentEncoding=\(contentEncoding) \
bodyBytes=\(bodySize) decodedBytes=\(decodedBodySize) decoding=\(decodingHint) \
gzipMagic=\(gzipMagic) preview=\(preview)
"""
)
}
}
private func hasGzipMagic(_ data: Data?) -> Bool {
guard let data, data.count >= 2 else { return false }
return data[data.startIndex] == 0x1f && data[data.index(after: data.startIndex)] == 0x8b
}
}
private struct DomainSurfaceCard<Content: View>: View {
let content: Content
init(@ViewBuilder content: () -> Content) {
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.10),
.clear,
Color.blue.opacity(0.05)
],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
content
.padding(18)
.frame(maxWidth: .infinity, alignment: .leading)
}
.overlay(
RoundedRectangle(cornerRadius: 26, style: .continuous)
.strokeBorder(Color.primary.opacity(0.05), lineWidth: 1)
)
}
}

View File

@@ -8,6 +8,8 @@ struct HomeView: View {
@State private var searchText = ""
@State private var showClearConfirmation = false
@State private var observation: AnyDatabaseCancellable?
@State private var didInitializeObservers = false
@State private var refreshSequence = 0
private let trafficRepo = TrafficRepository()
@@ -52,6 +54,23 @@ struct HomeView: View {
.foregroundStyle(.tertiary)
}
}
.contextMenu {
Button {
addToSSLProxyingList(domain: group.domain)
} label: {
Label("Add to SSL Proxying", systemImage: "lock.shield")
}
Button {
addToBlockList(domain: group.domain)
} label: {
Label("Add to Block List", systemImage: "xmark.shield")
}
Button(role: .destructive) {
try? trafficRepo.deleteForDomain(group.domain)
} label: {
Label("Delete Domain", systemImage: "trash")
}
}
}
}
}
@@ -86,19 +105,70 @@ struct HomeView: View {
} message: {
Text("This will permanently delete all captured traffic.")
}
.onAppear {
ProxyLogger.ui.info("HomeView: onAppear vpnConnected=\(appState.isVPNConnected) domains=\(domains.count)")
}
.onDisappear {
ProxyLogger.ui.info("HomeView: onDisappear domains=\(domains.count)")
}
.task {
startObservation()
guard !didInitializeObservers else {
ProxyLogger.ui.info("HomeView: task rerun ignored; observers already initialized")
return
}
didInitializeObservers = true
ProxyLogger.ui.info("HomeView: initial task setup")
startObservation(source: "initial")
observeNewTraffic()
}
}
private func startObservation() {
private let rulesRepo = RulesRepository()
private func addToSSLProxyingList(domain: String) {
var entry = SSLProxyingEntry(domainPattern: domain, isInclude: true)
try? rulesRepo.insertSSLEntry(&entry)
// Auto-enable SSL proxying if not already
if !IPCManager.shared.isSSLProxyingEnabled {
IPCManager.shared.isSSLProxyingEnabled = true
}
IPCManager.shared.post(.configurationChanged)
}
private func addToBlockList(domain: String) {
var entry = BlockListEntry(urlPattern: "*\(domain)*")
try? rulesRepo.insertBlockEntry(&entry)
if !IPCManager.shared.isBlockListEnabled {
IPCManager.shared.isBlockListEnabled = true
}
IPCManager.shared.post(.configurationChanged)
}
private func startObservation(source: String) {
refreshSequence += 1
let sequence = refreshSequence
ProxyLogger.ui.info("HomeView: starting GRDB observation source=\(source) seq=\(sequence)")
observation?.cancel()
observation = trafficRepo.observeDomainGroups()
.start(in: DatabaseManager.shared.dbPool) { error in
print("[HomeView] Observation error: \(error)")
ProxyLogger.ui.error("HomeView: observation error source=\(source) seq=\(sequence) error=\(error.localizedDescription)")
} onChange: { newDomains in
let preview = newDomains.prefix(3).map(\.domain).joined(separator: ", ")
ProxyLogger.ui.info("HomeView: domains updated source=\(source) seq=\(sequence) count=\(newDomains.count) preview=\(preview)")
withAnimation {
domains = newDomains
}
}
}
private func observeNewTraffic() {
ProxyLogger.ui.info("HomeView: registering Darwin notification observer")
IPCManager.shared.observe(.newTrafficCaptured) {
ProxyLogger.ui.info("HomeView: Darwin notification received")
DispatchQueue.main.async {
startObservation(source: "darwin")
}
}
}
}

View File

@@ -2,44 +2,54 @@ 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 {
VStack(spacing: 0) {
Picker("Segment", selection: $selectedSegment) {
ForEach(Segment.allCases, id: \.self) { segment in
Text(segment.rawValue).tag(segment)
}
}
.pickerStyle(.segmented)
.padding()
ScrollView {
LazyVStack(alignment: .leading, spacing: 16) {
heroCard(for: traffic)
segmentSwitcher
ScrollView {
VStack(alignment: .leading, spacing: 16) {
switch selectedSegment {
case .request:
requestContent(traffic)
case .response:
responseContent(traffic)
}
switch selectedSegment {
case .request:
requestContent(traffic)
case .response:
responseContent(traffic)
}
.padding()
}
.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")
@@ -48,6 +58,15 @@ struct RequestDetailView: View {
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: {
@@ -56,8 +75,83 @@ struct RequestDetailView: View {
}
}
}
.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 {
traffic = try? trafficRepo.traffic(byId: trafficId)
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)
}
}
@@ -65,55 +159,70 @@ struct RequestDetailView: View {
@ViewBuilder
private func requestContent(_ traffic: CapturedTraffic) -> some View {
// General
DisclosureGroup("General") {
VStack(alignment: .leading, spacing: 12) {
KeyValueRow(key: "URL", value: traffic.url)
KeyValueRow(key: "Method", value: traffic.method)
KeyValueRow(key: "Scheme", value: traffic.scheme)
KeyValueRow(key: "Time", value: traffic.startDate.formatted(.dateTime))
if let duration = traffic.durationMs {
KeyValueRow(key: "Duration", value: "\(duration) ms")
}
if let status = traffic.statusCode {
KeyValueRow(key: "Status", value: "\(status) \(traffic.statusText ?? "")")
}
}
.padding(.vertical, 8)
if let tlsHint = tlsHint(for: traffic) {
warningCard(
title: "HTTPS Passthrough",
message: tlsHint
)
}
// Headers
let requestHeaders = traffic.decodedRequestHeaders
if !requestHeaders.isEmpty {
DisclosureGroup("Headers (\(requestHeaders.count))") {
VStack(alignment: .leading, spacing: 12) {
ForEach(requestHeaders.sorted(by: { $0.key < $1.key }), id: \.key) { key, value in
KeyValueRow(key: key, value: value)
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")
}
}
.padding(.vertical, 8)
}
}
// Query Parameters
let queryParams = traffic.decodedQueryParameters
if !queryParams.isEmpty {
DisclosureGroup("Query (\(queryParams.count))") {
VStack(alignment: .leading, spacing: 12) {
ForEach(queryParams.sorted(by: { $0.key < $1.key }), id: \.key) { key, value in
KeyValueRow(key: key, value: value)
}
}
.padding(.vertical, 8)
}
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 })
)
}
// Body
if let body = traffic.requestBody, !body.isEmpty {
DisclosureGroup("Body (\(formatBytes(body.count)))") {
bodyView(data: body, contentType: traffic.requestContentType)
.padding(.vertical, 8)
}
bodyCard(
title: "Request Body",
subtitle: formatBytes(body.count),
data: body,
contentType: traffic.requestContentType,
mode: $requestBodyMode
)
}
}
@@ -121,63 +230,246 @@ struct RequestDetailView: View {
@ViewBuilder
private func responseContent(_ traffic: CapturedTraffic) -> some View {
if let status = traffic.statusCode {
// Status
HStack {
StatusBadge(statusCode: status)
Text(traffic.statusText ?? "")
.font(.subheadline)
if traffic.statusCode == nil {
DetailScreenCard {
EmptyStateView(
icon: "clock",
title: "Waiting for Response",
subtitle: "The response has not been received yet."
)
}
.padding(.vertical, 4)
}
} else {
let responseHeaders = traffic.decodedResponseHeaders
let displayedResponseBody = traffic.decodedResponseBodyData ?? traffic.responseBody
// Headers
let responseHeaders = traffic.decodedResponseHeaders
if !responseHeaders.isEmpty {
DisclosureGroup("Headers (\(responseHeaders.count))") {
VStack(alignment: .leading, spacing: 12) {
ForEach(responseHeaders.sorted(by: { $0.key < $1.key }), id: \.key) { key, value in
KeyValueRow(key: key, value: value)
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)")
}
}
.padding(.vertical, 8)
}
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
)
}
}
}
// Body
if let body = traffic.responseBody, !body.isEmpty {
DisclosureGroup("Body (\(formatBytes(body.count)))") {
bodyView(data: body, contentType: traffic.responseContentType)
.padding(.vertical, 8)
// 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)
}
}
}
}
if traffic.statusCode == nil {
EmptyStateView(
icon: "clock",
title: "Waiting for Response",
subtitle: "The response has not been received yet."
)
@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
@ViewBuilder
private func bodyView(data: Data, contentType: String?) -> some View {
private func defaultMode(for data: Data, contentType: String?) -> BodyDisplayMode {
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) {
ScrollView(.horizontal) {
Text(string)
.font(.system(.caption, design: .monospaced))
.textSelection(.enabled)
(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)
}
}
} else if let string = String(data: data, encoding: .utf8) {
Text(string)
.font(.system(.caption, design: .monospaced))
.textSelection(.enabled)
.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)
@@ -185,9 +477,274 @@ struct RequestDetailView: View {
}
}
// 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) {}
}

View File

@@ -5,41 +5,79 @@ struct TrafficRowView: View {
let traffic: CapturedTraffic
var body: some View {
VStack(alignment: .leading, spacing: 6) {
HStack(spacing: 6) {
MethodBadge(method: traffic.method)
StatusBadge(statusCode: traffic.statusCode)
VStack(alignment: .leading, spacing: 14) {
HStack(alignment: .top, spacing: 10) {
HStack(spacing: 6) {
MethodBadge(method: traffic.method)
StatusBadge(statusCode: traffic.statusCode)
}
Spacer()
Spacer(minLength: 12)
Text(traffic.startDate, format: .dateTime.hour().minute().second().secondFraction(.fractional(3)))
.font(.caption2)
.foregroundStyle(.secondary)
VStack(alignment: .trailing, spacing: 4) {
Text(traffic.startDate, format: .dateTime.hour().minute().second().secondFraction(.fractional(3)))
.font(.caption.weight(.medium))
.foregroundStyle(.secondary)
Text(traffic.formattedDuration)
.font(.caption2)
.foregroundStyle(.secondary)
Text(traffic.formattedDuration)
.font(.caption2.weight(.semibold))
.foregroundStyle(.secondary)
}
}
Text(traffic.url)
.font(.caption)
.lineLimit(3)
.foregroundStyle(.primary)
VStack(alignment: .leading, spacing: 4) {
Text(primaryLine)
.font(.subheadline.weight(.semibold))
.foregroundStyle(.primary)
.lineLimit(2)
HStack(spacing: 12) {
if traffic.requestBodySize > 0 {
Label(formatBytes(traffic.requestBodySize), systemImage: "arrow.up.circle.fill")
.font(.caption2)
.foregroundStyle(.green)
Text(secondaryLine)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(2)
}
HStack(spacing: 8) {
TransferPill(
systemImage: "arrow.up.circle.fill",
text: formatBytes(traffic.requestBodySize),
tint: .green
)
TransferPill(
systemImage: "arrow.down.circle.fill",
text: responseSizeText,
tint: .blue
)
if let responseContentType = traffic.responseContentType {
MetaPill(text: shortContentType(responseContentType))
} else if let requestContentType = traffic.requestContentType {
MetaPill(text: shortContentType(requestContentType))
}
if traffic.responseBodySize > 0 {
Label(formatBytes(traffic.responseBodySize), systemImage: "arrow.down.circle.fill")
.font(.caption2)
.foregroundStyle(.blue)
if traffic.scheme == "https" && !traffic.isSslDecrypted {
MetaPill(text: "Encrypted", tint: .orange)
}
Spacer(minLength: 0)
if traffic.isPinned {
Image(systemName: "pin.fill")
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
.padding(.vertical, 4)
.padding(16)
.frame(maxWidth: .infinity, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: 22, style: .continuous)
.fill(Color(.secondarySystemGroupedBackground))
)
.overlay(
RoundedRectangle(cornerRadius: 22, style: .continuous)
.strokeBorder(Color.primary.opacity(0.05), lineWidth: 1)
)
}
private func formatBytes(_ bytes: Int) -> String {
@@ -47,4 +85,64 @@ struct TrafficRowView: View {
if bytes < 1_048_576 { return String(format: "%.1f KB", Double(bytes) / 1024) }
return String(format: "%.1f MB", Double(bytes) / 1_048_576)
}
private var primaryLine: String {
let components = URLComponents(string: traffic.url)
let path = components?.path ?? traffic.url
let query = components?.percentEncodedQuery.map { "?\($0)" } ?? ""
let route = path.isEmpty ? traffic.domain : path + query
return route.isEmpty ? traffic.url : route
}
private var secondaryLine: String {
if let statusText = traffic.statusText, let statusCode = traffic.statusCode {
return "\(traffic.domain)\(statusCode) \(statusText)"
}
return traffic.domain
}
private var responseSizeText: String {
if traffic.responseBodySize > 0 {
return formatBytes(traffic.responseBodySize)
}
if traffic.statusCode == nil {
return "Pending"
}
return "0 B"
}
private func shortContentType(_ contentType: String) -> String {
let base = contentType.split(separator: ";").first.map(String.init) ?? contentType
return base.replacingOccurrences(of: "application/", with: "")
.replacingOccurrences(of: "text/", with: "")
}
}
private struct TransferPill: View {
let systemImage: String
let text: String
let tint: Color
var body: some View {
Label(text, systemImage: systemImage)
.font(.caption2.weight(.semibold))
.foregroundStyle(tint)
.padding(.horizontal, 10)
.padding(.vertical, 6)
.background(tint.opacity(0.12), in: Capsule())
}
}
private struct MetaPill: View {
let text: String
var tint: Color = .secondary
var body: some View {
Text(text)
.font(.caption2.weight(.semibold))
.foregroundStyle(tint)
.padding(.horizontal, 10)
.padding(.vertical, 6)
.background(tint.opacity(0.10), in: Capsule())
}
}