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)
)
}
}