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:
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user