- 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
385 lines
15 KiB
Swift
385 lines
15 KiB
Swift
import SwiftUI
|
|
import ProxyCore
|
|
import GRDB
|
|
|
|
struct DomainDetailView: View {
|
|
let domain: String
|
|
|
|
@State private var requests: [CapturedTraffic] = []
|
|
@State private var searchText = ""
|
|
@State private var filterChips: [FilterChip] = [
|
|
FilterChip(label: "JSON"),
|
|
FilterChip(label: "Form"),
|
|
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"
|
|
|
|
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) ||
|
|
$0.method.localizedCaseInsensitiveContains(searchText) ||
|
|
($0.statusText?.localizedCaseInsensitiveContains(searchText) == true) ||
|
|
($0.searchableResponseBodyText?.localizedCaseInsensitiveContains(searchText) == true)
|
|
}
|
|
}
|
|
|
|
let activeFilters = Set(filterChips.filter(\.isSelected).map(\.label))
|
|
if !activeFilters.isEmpty {
|
|
result = result.filter { request in
|
|
activeFilters.contains { filter in
|
|
switch filter {
|
|
case "JSON":
|
|
return request.responseContentType?.contains("json") == true ||
|
|
request.requestContentType?.contains("json") == true
|
|
case "Form":
|
|
return request.requestContentType?.contains("form") == true
|
|
case "Errors":
|
|
return (request.statusCode ?? 0) >= 400
|
|
case "HTTPS":
|
|
return request.scheme == "https"
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
let matchingRequests = requests.filter { request in
|
|
request.searchableResponseBodyText?.localizedCaseInsensitiveContains(hardcodedDebugNeedle) == true
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
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)
|
|
)
|
|
}
|
|
}
|