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:
@@ -6,13 +6,16 @@ struct ComposeEditorView: View {
|
||||
|
||||
@State private var method = "GET"
|
||||
@State private var url = ""
|
||||
@State private var headersText = ""
|
||||
@State private var queryText = ""
|
||||
@State private var headers: [(key: String, value: String)] = []
|
||||
@State private var queryParams: [(key: String, value: String)] = []
|
||||
@State private var bodyText = ""
|
||||
@State private var selectedTab: EditorTab = .headers
|
||||
@State private var isSending = false
|
||||
@State private var responseStatus: Int?
|
||||
@State private var responseBody: String?
|
||||
@State private var showHeaderEditor = false
|
||||
@State private var showQueryEditor = false
|
||||
@State private var didLoad = false
|
||||
|
||||
private let composeRepo = ComposeRepository()
|
||||
|
||||
@@ -77,47 +80,103 @@ struct ComposeEditorView: View {
|
||||
}
|
||||
|
||||
// Response
|
||||
if let responseBody {
|
||||
if responseStatus != nil || responseBody != nil {
|
||||
Divider()
|
||||
ScrollView {
|
||||
Text(responseBody)
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.textSelection(.enabled)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding()
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
if let status = responseStatus {
|
||||
HStack {
|
||||
Text("Response")
|
||||
.font(.caption.weight(.semibold))
|
||||
StatusBadge(statusCode: status)
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.top, 8)
|
||||
}
|
||||
if let body = responseBody {
|
||||
ScrollView {
|
||||
Text(body)
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.textSelection(.enabled)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.horizontal)
|
||||
}
|
||||
.frame(maxHeight: 200)
|
||||
|
||||
NavigationLink {
|
||||
FullResponseView(statusCode: responseStatus, responseBody: body)
|
||||
} label: {
|
||||
Label("View Full Response", systemImage: "arrow.up.left.and.arrow.down.right")
|
||||
.font(.caption)
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.bottom, 8)
|
||||
}
|
||||
}
|
||||
.frame(maxHeight: 200)
|
||||
.background(Color(.systemGray6))
|
||||
}
|
||||
}
|
||||
.navigationTitle("New Request")
|
||||
.navigationTitle("Edit Request")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button {
|
||||
Task { await sendRequest() }
|
||||
} label: {
|
||||
Text("Send")
|
||||
.font(.headline)
|
||||
if isSending {
|
||||
ProgressView()
|
||||
} else {
|
||||
Text("Send")
|
||||
.font(.headline)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(url.isEmpty || isSending)
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showHeaderEditor) {
|
||||
HeaderEditorSheet(headers: headers) { updated in
|
||||
headers = updated
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showQueryEditor) {
|
||||
QueryEditorSheet(parameters: queryParams) { updated in
|
||||
queryParams = updated
|
||||
}
|
||||
}
|
||||
.task {
|
||||
guard !didLoad else { return }
|
||||
didLoad = true
|
||||
loadFromDB()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Tab Views
|
||||
|
||||
private var headerEditor: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
if headersText.isEmpty {
|
||||
if headers.isEmpty {
|
||||
EmptyStateView(
|
||||
icon: "list.bullet.rectangle",
|
||||
title: "No Headers",
|
||||
subtitle: "Tap 'Edit Headers' to add headers."
|
||||
)
|
||||
} else {
|
||||
ForEach(headers.indices, id: \.self) { index in
|
||||
HStack {
|
||||
Text(headers[index].key)
|
||||
.font(.subheadline.weight(.medium))
|
||||
Spacer()
|
||||
Text(headers[index].value)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
.padding(.vertical, 2)
|
||||
}
|
||||
}
|
||||
|
||||
Button("Edit Headers") {
|
||||
// TODO: Open header editor sheet
|
||||
showHeaderEditor = true
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.buttonStyle(.borderedProminent)
|
||||
@@ -126,16 +185,32 @@ struct ComposeEditorView: View {
|
||||
|
||||
private var queryEditor: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
TextEditor(text: $queryText)
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.frame(minHeight: 100)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.stroke(Color(.systemGray4))
|
||||
if queryParams.isEmpty {
|
||||
EmptyStateView(
|
||||
icon: "questionmark.circle",
|
||||
title: "No Query Parameters",
|
||||
subtitle: "Tap 'Edit Parameters' to add query parameters."
|
||||
)
|
||||
Text("Format: key=value, one per line")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
ForEach(queryParams.indices, id: \.self) { index in
|
||||
HStack {
|
||||
Text(queryParams[index].key)
|
||||
.font(.subheadline.weight(.medium))
|
||||
Spacer()
|
||||
Text(queryParams[index].value)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
.padding(.vertical, 2)
|
||||
}
|
||||
}
|
||||
|
||||
Button("Edit Parameters") {
|
||||
showQueryEditor = true
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.buttonStyle(.borderedProminent)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -151,14 +226,52 @@ struct ComposeEditorView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - DB Load
|
||||
|
||||
private func loadFromDB() {
|
||||
do {
|
||||
if let request = try composeRepo.fetch(id: requestId) {
|
||||
method = request.method
|
||||
url = request.url ?? ""
|
||||
bodyText = request.body ?? ""
|
||||
headers = decodeKeyValues(request.headers)
|
||||
queryParams = decodeKeyValues(request.queryParameters)
|
||||
responseStatus = request.responseStatus
|
||||
if let data = request.responseBody, let str = String(data: data, encoding: .utf8) {
|
||||
responseBody = str
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
print("Failed to load compose request: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Send
|
||||
|
||||
private func sendRequest() async {
|
||||
guard let requestURL = URL(string: url) else { return }
|
||||
// Build URL with query parameters
|
||||
guard var components = URLComponents(string: url) else { return }
|
||||
|
||||
if !queryParams.isEmpty {
|
||||
var items = components.queryItems ?? []
|
||||
for param in queryParams {
|
||||
items.append(URLQueryItem(name: param.key, value: param.value))
|
||||
}
|
||||
components.queryItems = items
|
||||
}
|
||||
|
||||
guard let requestURL = components.url else { return }
|
||||
|
||||
isSending = true
|
||||
defer { isSending = false }
|
||||
|
||||
var request = URLRequest(url: requestURL)
|
||||
request.httpMethod = method
|
||||
|
||||
for header in headers {
|
||||
request.setValue(header.value, forHTTPHeaderField: header.key)
|
||||
}
|
||||
|
||||
if !bodyText.isEmpty {
|
||||
request.httpBody = bodyText.data(using: .utf8)
|
||||
}
|
||||
@@ -175,6 +288,46 @@ struct ComposeEditorView: View {
|
||||
}
|
||||
} catch {
|
||||
responseBody = "Error: \(error.localizedDescription)"
|
||||
responseStatus = nil
|
||||
}
|
||||
|
||||
// Save to DB
|
||||
saveToDB()
|
||||
}
|
||||
|
||||
private func saveToDB() {
|
||||
do {
|
||||
if var request = try composeRepo.fetch(id: requestId) {
|
||||
request.method = method
|
||||
request.url = url
|
||||
request.headers = encodeKeyValues(headers)
|
||||
request.queryParameters = encodeKeyValues(queryParams)
|
||||
request.body = bodyText.isEmpty ? nil : bodyText
|
||||
request.responseStatus = responseStatus
|
||||
request.responseBody = responseBody?.data(using: .utf8)
|
||||
request.lastSentAt = Date().timeIntervalSince1970
|
||||
try composeRepo.update(request)
|
||||
}
|
||||
} catch {
|
||||
print("Failed to save compose request: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private func encodeKeyValues(_ pairs: [(key: String, value: String)]) -> String? {
|
||||
guard !pairs.isEmpty else { return nil }
|
||||
var dict: [String: String] = [:]
|
||||
for pair in pairs { dict[pair.key] = pair.value }
|
||||
guard let data = try? JSONEncoder().encode(dict) else { return nil }
|
||||
return String(data: data, encoding: .utf8)
|
||||
}
|
||||
|
||||
private func decodeKeyValues(_ json: String?) -> [(key: String, value: String)] {
|
||||
guard let json, let data = json.data(using: .utf8),
|
||||
let dict = try? JSONDecoder().decode([String: String].self, from: data) else {
|
||||
return []
|
||||
}
|
||||
return dict.map { (key: $0.key, value: $0.value) }.sorted { $0.key < $1.key }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,6 +36,15 @@ struct ComposeListView: View {
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
if let ts = request.lastSentAt {
|
||||
Text(formatTimestamp(ts))
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
if let status = request.responseStatus {
|
||||
StatusBadge(statusCode: status)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -124,6 +133,13 @@ struct ComposeListView: View {
|
||||
try? composeRepo.insert(&request)
|
||||
}
|
||||
|
||||
private func formatTimestamp(_ ts: Double) -> String {
|
||||
let date = Date(timeIntervalSince1970: ts)
|
||||
let formatter = RelativeDateTimeFormatter()
|
||||
formatter.unitsStyle = .abbreviated
|
||||
return "Sent \(formatter.localizedString(for: date, relativeTo: Date()))"
|
||||
}
|
||||
|
||||
private func encodeHeaders(_ headers: [(key: String, value: String)]) -> String? {
|
||||
var dict: [String: String] = [:]
|
||||
for h in headers { dict[h.key] = h.value }
|
||||
|
||||
36
UI/Compose/FullResponseView.swift
Normal file
36
UI/Compose/FullResponseView.swift
Normal file
@@ -0,0 +1,36 @@
|
||||
import SwiftUI
|
||||
|
||||
struct FullResponseView: View {
|
||||
let statusCode: Int?
|
||||
let responseBody: String
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
if let status = statusCode {
|
||||
HStack {
|
||||
StatusBadge(statusCode: status)
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
|
||||
Text(responseBody)
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.textSelection(.enabled)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
.navigationTitle("Full Response")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button {
|
||||
UIPasteboard.general.string = responseBody
|
||||
} label: {
|
||||
Image(systemName: "doc.on.doc")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
68
UI/Compose/HeaderEditorSheet.swift
Normal file
68
UI/Compose/HeaderEditorSheet.swift
Normal file
@@ -0,0 +1,68 @@
|
||||
import SwiftUI
|
||||
import ProxyCore
|
||||
|
||||
struct HeaderEditorSheet: View {
|
||||
@State var headers: [(key: String, value: String)]
|
||||
let onSave: ([(key: String, value: String)]) -> Void
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
List {
|
||||
ForEach(headers.indices, id: \.self) { index in
|
||||
HStack(spacing: 8) {
|
||||
TextField("Header", text: Binding(
|
||||
get: { headers[index].key },
|
||||
set: { headers[index].key = $0 }
|
||||
))
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
.font(.subheadline)
|
||||
|
||||
TextField("Value", text: Binding(
|
||||
get: { headers[index].value },
|
||||
set: { headers[index].value = $0 }
|
||||
))
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
.font(.subheadline)
|
||||
}
|
||||
}
|
||||
.onDelete { indexSet in
|
||||
headers.remove(atOffsets: indexSet)
|
||||
}
|
||||
|
||||
Menu {
|
||||
ForEach(ProxyConstants.commonHeaders, id: \.self) { header in
|
||||
Button(header) {
|
||||
headers.append((key: header, value: ""))
|
||||
}
|
||||
}
|
||||
Divider()
|
||||
Button("Custom Header") {
|
||||
headers.append((key: "", value: ""))
|
||||
}
|
||||
} label: {
|
||||
Label("Add Header", systemImage: "plus.circle.fill")
|
||||
}
|
||||
}
|
||||
.navigationTitle("Edit Headers")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarLeading) {
|
||||
Button("Cancel") {
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button("Done") {
|
||||
let filtered = headers.filter { !$0.key.isEmpty }
|
||||
onSave(filtered)
|
||||
dismiss()
|
||||
}
|
||||
.fontWeight(.semibold)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
59
UI/Compose/QueryEditorSheet.swift
Normal file
59
UI/Compose/QueryEditorSheet.swift
Normal file
@@ -0,0 +1,59 @@
|
||||
import SwiftUI
|
||||
|
||||
struct QueryEditorSheet: View {
|
||||
@State var parameters: [(key: String, value: String)]
|
||||
let onSave: ([(key: String, value: String)]) -> Void
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
List {
|
||||
ForEach(parameters.indices, id: \.self) { index in
|
||||
HStack(spacing: 8) {
|
||||
TextField("Key", text: Binding(
|
||||
get: { parameters[index].key },
|
||||
set: { parameters[index].key = $0 }
|
||||
))
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
.font(.subheadline)
|
||||
|
||||
TextField("Value", text: Binding(
|
||||
get: { parameters[index].value },
|
||||
set: { parameters[index].value = $0 }
|
||||
))
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
.font(.subheadline)
|
||||
}
|
||||
}
|
||||
.onDelete { indexSet in
|
||||
parameters.remove(atOffsets: indexSet)
|
||||
}
|
||||
|
||||
Button {
|
||||
parameters.append((key: "", value: ""))
|
||||
} label: {
|
||||
Label("Add Parameter", systemImage: "plus.circle.fill")
|
||||
}
|
||||
}
|
||||
.navigationTitle("Edit Query Parameters")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarLeading) {
|
||||
Button("Cancel") {
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button("Done") {
|
||||
let filtered = parameters.filter { !$0.key.isEmpty }
|
||||
onSave(filtered)
|
||||
dismiss()
|
||||
}
|
||||
.fontWeight(.semibold)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {}
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ import ProxyCore
|
||||
|
||||
struct AdvancedSettingsView: View {
|
||||
@State private var hideSystemTraffic = IPCManager.shared.hideSystemTraffic
|
||||
@State private var showImagePreview = true
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
@@ -15,12 +14,6 @@ struct AdvancedSettingsView: View {
|
||||
} footer: {
|
||||
Text("Hide traffic from Apple system services like push notifications, iCloud sync, and analytics.")
|
||||
}
|
||||
|
||||
Section {
|
||||
Toggle("Show Image Preview", isOn: $showImagePreview)
|
||||
} footer: {
|
||||
Text("Display thumbnail previews for image responses in the traffic list.")
|
||||
}
|
||||
}
|
||||
.navigationTitle("Advanced")
|
||||
}
|
||||
|
||||
19
UI/More/AppLockView.swift
Normal file
19
UI/More/AppLockView.swift
Normal file
@@ -0,0 +1,19 @@
|
||||
import SwiftUI
|
||||
import LocalAuthentication
|
||||
|
||||
struct AppLockView: View {
|
||||
@Environment(AppState.self) private var appState
|
||||
|
||||
var body: some View {
|
||||
@Bindable var appState = appState
|
||||
|
||||
List {
|
||||
Section {
|
||||
Toggle("App Lock", isOn: $appState.isAppLockEnabled)
|
||||
} footer: {
|
||||
Text("When enabled, the app will require Face ID, Touch ID, or your device passcode to unlock each time you open it.")
|
||||
}
|
||||
}
|
||||
.navigationTitle("App Lock")
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,19 @@
|
||||
import SwiftUI
|
||||
import ProxyCore
|
||||
|
||||
struct AppSettingsView: View {
|
||||
@State private var analyticsEnabled = false
|
||||
@State private var crashReportingEnabled = true
|
||||
@AppStorage("analyticsEnabled") private var analyticsEnabled = false
|
||||
@AppStorage("crashReportingEnabled") private var crashReportingEnabled = true
|
||||
@State private var showClearCacheConfirmation = false
|
||||
|
||||
private var appVersion: String {
|
||||
Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "Unknown"
|
||||
}
|
||||
|
||||
private var buildNumber: String {
|
||||
Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "Unknown"
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
Section {
|
||||
@@ -23,14 +32,14 @@ struct AppSettingsView: View {
|
||||
}
|
||||
|
||||
Section("About") {
|
||||
LabeledContent("Version", value: "1.0.0")
|
||||
LabeledContent("Build", value: "1")
|
||||
LabeledContent("Version", value: appVersion)
|
||||
LabeledContent("Build", value: buildNumber)
|
||||
}
|
||||
}
|
||||
.navigationTitle("App Settings")
|
||||
.confirmationDialog("Clear Cache", isPresented: $showClearCacheConfirmation) {
|
||||
Button("Clear Cache", role: .destructive) {
|
||||
// TODO: Clear URL cache, image cache, etc.
|
||||
URLCache.shared.removeAllCachedResponses()
|
||||
}
|
||||
} message: {
|
||||
Text("This will clear all cached data.")
|
||||
|
||||
@@ -6,6 +6,9 @@ struct BlockListView: View {
|
||||
@State private var isEnabled = IPCManager.shared.isBlockListEnabled
|
||||
@State private var entries: [BlockListEntry] = []
|
||||
@State private var showAddRule = false
|
||||
@State private var editingEntry: BlockListEntry?
|
||||
@State private var entryToDelete: BlockListEntry?
|
||||
@State private var showDeleteConfirmation = false
|
||||
@State private var observation: AnyDatabaseCancellable?
|
||||
|
||||
private let rulesRepo = RulesRepository()
|
||||
@@ -32,22 +35,26 @@ struct BlockListView: View {
|
||||
.font(.subheadline)
|
||||
} else {
|
||||
ForEach(entries) { entry in
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(entry.name ?? entry.urlPattern)
|
||||
.font(.subheadline.weight(.medium))
|
||||
Text(entry.urlPattern)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
Text(entry.action.displayName)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.tertiary)
|
||||
Button {
|
||||
editingEntry = entry
|
||||
} label: {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(entry.name ?? entry.urlPattern)
|
||||
.font(.subheadline.weight(.medium))
|
||||
Text(entry.urlPattern)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
Text(entry.action.displayName)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
}
|
||||
.tint(.primary)
|
||||
}
|
||||
.onDelete { indexSet in
|
||||
for index in indexSet {
|
||||
if let id = entries[index].id {
|
||||
try? rulesRepo.deleteBlockEntry(id: id)
|
||||
}
|
||||
if let index = indexSet.first {
|
||||
entryToDelete = entries[index]
|
||||
showDeleteConfirmation = true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -67,6 +74,18 @@ struct BlockListView: View {
|
||||
try? rulesRepo.insertBlockEntry(&entry)
|
||||
}
|
||||
}
|
||||
.sheet(item: $editingEntry) { entry in
|
||||
NewBlockRuleView(existingEntry: entry) { updated in
|
||||
try? rulesRepo.updateBlockEntry(updated)
|
||||
}
|
||||
}
|
||||
.confirmationDialog("Delete this rule?", isPresented: $showDeleteConfirmation, presenting: entryToDelete) { entry in
|
||||
Button("Delete", role: .destructive) {
|
||||
if let id = entry.id {
|
||||
try? rulesRepo.deleteBlockEntry(id: id)
|
||||
}
|
||||
}
|
||||
}
|
||||
.task {
|
||||
observation = rulesRepo.observeBlockListEntries()
|
||||
.start(in: DatabaseManager.shared.dbPool) { error in
|
||||
@@ -81,6 +100,7 @@ struct BlockListView: View {
|
||||
// MARK: - New Block Rule
|
||||
|
||||
struct NewBlockRuleView: View {
|
||||
let existingEntry: BlockListEntry?
|
||||
let onSave: (BlockListEntry) -> Void
|
||||
|
||||
@State private var name = ""
|
||||
@@ -90,6 +110,11 @@ struct NewBlockRuleView: View {
|
||||
@State private var blockAction: BlockAction = .blockAndHide
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
init(existingEntry: BlockListEntry? = nil, onSave: @escaping (BlockListEntry) -> Void) {
|
||||
self.existingEntry = existingEntry
|
||||
self.onSave = onSave
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
@@ -118,7 +143,7 @@ struct NewBlockRuleView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("New Block Rule")
|
||||
.navigationTitle(existingEntry == nil ? "New Block Rule" : "Edit Block Rule")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
@@ -127,11 +152,14 @@ struct NewBlockRuleView: View {
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("Save") {
|
||||
let entry = BlockListEntry(
|
||||
id: existingEntry?.id,
|
||||
name: name.isEmpty ? nil : name,
|
||||
urlPattern: urlPattern,
|
||||
method: method,
|
||||
includeSubpaths: includeSubpaths,
|
||||
blockAction: blockAction
|
||||
blockAction: blockAction,
|
||||
isEnabled: existingEntry?.isEnabled ?? true,
|
||||
createdAt: existingEntry?.createdAt ?? Date().timeIntervalSince1970
|
||||
)
|
||||
onSave(entry)
|
||||
dismiss()
|
||||
@@ -139,6 +167,15 @@ struct NewBlockRuleView: View {
|
||||
.disabled(urlPattern.isEmpty)
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
if let entry = existingEntry {
|
||||
name = entry.name ?? ""
|
||||
urlPattern = entry.urlPattern
|
||||
method = entry.method
|
||||
includeSubpaths = entry.includeSubpaths
|
||||
blockAction = entry.action
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,11 @@ struct BreakpointRulesView: View {
|
||||
@State private var isEnabled = IPCManager.shared.isBreakpointEnabled
|
||||
@State private var rules: [BreakpointRule] = []
|
||||
@State private var showAddRule = false
|
||||
@State private var editingRule: BreakpointRule?
|
||||
@State private var ruleToDelete: BreakpointRule?
|
||||
@State private var showDeleteConfirmation = false
|
||||
@State private var observation: AnyDatabaseCancellable?
|
||||
@State private var selectedTab = 0
|
||||
|
||||
private let rulesRepo = RulesRepository()
|
||||
|
||||
@@ -25,42 +29,66 @@ struct BreakpointRulesView: View {
|
||||
}
|
||||
.listRowInsets(EdgeInsets())
|
||||
|
||||
Section("Rules") {
|
||||
if rules.isEmpty {
|
||||
Section {
|
||||
Picker("Tab", selection: $selectedTab) {
|
||||
Text("Rules").tag(0)
|
||||
Text("Waiting").tag(1)
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.listRowInsets(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16))
|
||||
.listRowBackground(Color.clear)
|
||||
}
|
||||
|
||||
if selectedTab == 0 {
|
||||
Section("Rules") {
|
||||
if rules.isEmpty {
|
||||
EmptyStateView(
|
||||
icon: "pause.circle",
|
||||
title: "No Breakpoint Rules",
|
||||
subtitle: "Tap + to create a new breakpoint rule."
|
||||
)
|
||||
} else {
|
||||
ForEach(rules) { rule in
|
||||
Button {
|
||||
editingRule = rule
|
||||
} label: {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(rule.name ?? rule.urlPattern)
|
||||
.font(.subheadline.weight(.medium))
|
||||
Text(rule.urlPattern)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
HStack(spacing: 8) {
|
||||
if rule.interceptRequest {
|
||||
Text("Request")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.blue)
|
||||
}
|
||||
if rule.interceptResponse {
|
||||
Text("Response")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.green)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.tint(.primary)
|
||||
}
|
||||
.onDelete { indexSet in
|
||||
if let index = indexSet.first {
|
||||
ruleToDelete = rules[index]
|
||||
showDeleteConfirmation = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Section("Waiting") {
|
||||
EmptyStateView(
|
||||
icon: "pause.circle",
|
||||
title: "No Breakpoint Rules",
|
||||
subtitle: "Tap + to create a new breakpoint rule."
|
||||
icon: "clock",
|
||||
title: "No Waiting Breakpoints",
|
||||
subtitle: "Breakpoints will appear here when a request is paused."
|
||||
)
|
||||
} else {
|
||||
ForEach(rules) { rule in
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(rule.name ?? rule.urlPattern)
|
||||
.font(.subheadline.weight(.medium))
|
||||
Text(rule.urlPattern)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
HStack(spacing: 8) {
|
||||
if rule.interceptRequest {
|
||||
Text("Request")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.blue)
|
||||
}
|
||||
if rule.interceptResponse {
|
||||
Text("Response")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.green)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.onDelete { indexSet in
|
||||
for index in indexSet {
|
||||
if let id = rules[index].id {
|
||||
try? rulesRepo.deleteBreakpointRule(id: id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -73,17 +101,15 @@ struct BreakpointRulesView: View {
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showAddRule) {
|
||||
NavigationStack {
|
||||
Form {
|
||||
// TODO: Add breakpoint rule creation form
|
||||
Text("Breakpoint rule creation")
|
||||
}
|
||||
.navigationTitle("New Breakpoint Rule")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Cancel") { showAddRule = false }
|
||||
}
|
||||
AddBreakpointRuleSheet(rulesRepo: rulesRepo, isPresented: $showAddRule)
|
||||
}
|
||||
.sheet(item: $editingRule) { rule in
|
||||
AddBreakpointRuleSheet(rulesRepo: rulesRepo, existingRule: rule, isPresented: .constant(true))
|
||||
}
|
||||
.confirmationDialog("Delete this rule?", isPresented: $showDeleteConfirmation, presenting: ruleToDelete) { rule in
|
||||
Button("Delete", role: .destructive) {
|
||||
if let id = rule.id {
|
||||
try? rulesRepo.deleteBreakpointRule(id: id)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -97,3 +123,101 @@ struct BreakpointRulesView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Add Breakpoint Rule Sheet
|
||||
|
||||
private struct AddBreakpointRuleSheet: View {
|
||||
let rulesRepo: RulesRepository
|
||||
let existingRule: BreakpointRule?
|
||||
@Binding var isPresented: Bool
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@State private var name = ""
|
||||
@State private var urlPattern = ""
|
||||
@State private var method = "ANY"
|
||||
@State private var interceptRequest = true
|
||||
@State private var interceptResponse = true
|
||||
|
||||
private var methods: [String] { ["ANY"] + ProxyConstants.httpMethods }
|
||||
|
||||
init(rulesRepo: RulesRepository, existingRule: BreakpointRule? = nil, isPresented: Binding<Bool>) {
|
||||
self.rulesRepo = rulesRepo
|
||||
self.existingRule = existingRule
|
||||
self._isPresented = isPresented
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
Section {
|
||||
TextField("Name (optional)", text: $name)
|
||||
TextField("URL Pattern (e.g. */api/*)", text: $urlPattern)
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
}
|
||||
|
||||
Section {
|
||||
Picker("Method", selection: $method) {
|
||||
ForEach(methods, id: \.self) { m in
|
||||
Text(m).tag(m)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
Toggle("Intercept Request", isOn: $interceptRequest)
|
||||
Toggle("Intercept Response", isOn: $interceptResponse)
|
||||
}
|
||||
|
||||
Section {
|
||||
Button("Save") {
|
||||
if let existing = existingRule {
|
||||
let updated = BreakpointRule(
|
||||
id: existing.id,
|
||||
name: name.isEmpty ? nil : name,
|
||||
urlPattern: urlPattern,
|
||||
method: method,
|
||||
interceptRequest: interceptRequest,
|
||||
interceptResponse: interceptResponse,
|
||||
isEnabled: existing.isEnabled,
|
||||
createdAt: existing.createdAt
|
||||
)
|
||||
try? rulesRepo.updateBreakpointRule(updated)
|
||||
} else {
|
||||
var rule = BreakpointRule(
|
||||
name: name.isEmpty ? nil : name,
|
||||
urlPattern: urlPattern,
|
||||
method: method,
|
||||
interceptRequest: interceptRequest,
|
||||
interceptResponse: interceptResponse
|
||||
)
|
||||
try? rulesRepo.insertBreakpointRule(&rule)
|
||||
}
|
||||
isPresented = false
|
||||
dismiss()
|
||||
}
|
||||
.disabled(urlPattern.isEmpty)
|
||||
}
|
||||
}
|
||||
.navigationTitle(existingRule == nil ? "New Breakpoint Rule" : "Edit Breakpoint Rule")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Cancel") {
|
||||
isPresented = false
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
if let rule = existingRule {
|
||||
name = rule.name ?? ""
|
||||
urlPattern = rule.urlPattern
|
||||
method = rule.method
|
||||
interceptRequest = rule.interceptRequest
|
||||
interceptResponse = rule.interceptResponse
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,21 +3,30 @@ import ProxyCore
|
||||
|
||||
struct CertificateView: View {
|
||||
@Environment(AppState.self) private var appState
|
||||
@State private var showRegenerateConfirmation = false
|
||||
@State private var isInstallingCert = false
|
||||
@State private var certServer: CertificateInstallServer?
|
||||
|
||||
private var dateFormatter: DateFormatter {
|
||||
let f = DateFormatter()
|
||||
f.dateStyle = .medium
|
||||
return f
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
Section {
|
||||
HStack {
|
||||
Image(systemName: appState.isCertificateTrusted ? "checkmark.shield.fill" : "exclamationmark.shield")
|
||||
Image(systemName: CertificateManager.shared.hasCA ? "checkmark.shield.fill" : "exclamationmark.shield")
|
||||
.font(.largeTitle)
|
||||
.foregroundStyle(appState.isCertificateTrusted ? .green : .orange)
|
||||
.foregroundStyle(CertificateManager.shared.hasCA ? .green : .orange)
|
||||
|
||||
VStack(alignment: .leading) {
|
||||
Text(appState.isCertificateTrusted
|
||||
? "Certificate is installed & trusted!"
|
||||
: "Certificate not installed")
|
||||
Text(CertificateManager.shared.hasCA
|
||||
? "Certificate Generated"
|
||||
: "Certificate not generated")
|
||||
.font(.headline)
|
||||
Text("Required for HTTPS decryption")
|
||||
Text("The app owns the shared CA used for HTTPS decryption.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
@@ -27,24 +36,173 @@ struct CertificateView: View {
|
||||
|
||||
Section("Details") {
|
||||
LabeledContent("CA Certificate", value: "Proxy CA (\(UIDevice.current.name))")
|
||||
LabeledContent("Generated", value: "-")
|
||||
LabeledContent("Expires", value: "-")
|
||||
LabeledContent("Generated", value: formattedDate(CertificateManager.shared.caGeneratedDate))
|
||||
LabeledContent("Expires", value: formattedDate(CertificateManager.shared.caExpirationDate))
|
||||
LabeledContent("Fingerprint", value: abbreviatedFingerprint(CertificateManager.shared.caFingerprint))
|
||||
}
|
||||
|
||||
Section("Runtime") {
|
||||
LabeledContent("Extension Loaded Same CA", value: appState.hasSharedCertificate ? "Yes" : "No")
|
||||
LabeledContent("HTTPS Inspection Verified", value: appState.isHTTPSInspectionVerified ? "Yes" : "Not Yet")
|
||||
if let domain = appState.runtimeStatus.lastSuccessfulMITMDomain {
|
||||
LabeledContent("Last Verified Domain", value: domain)
|
||||
}
|
||||
if let lastError = appState.lastRuntimeError {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("Latest Error")
|
||||
.font(.caption.weight(.semibold))
|
||||
Text(lastError)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
Button("Install Certificate") {
|
||||
// TODO: Phase 3 - Export and open cert installation
|
||||
Button {
|
||||
installCertificate()
|
||||
} label: {
|
||||
HStack {
|
||||
Spacer()
|
||||
if isInstallingCert {
|
||||
ProgressView()
|
||||
.padding(.trailing, 8)
|
||||
}
|
||||
Text("Install Certificate to Settings")
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.disabled(isInstallingCert || !CertificateManager.shared.hasCA)
|
||||
} footer: {
|
||||
Text("Downloads the CA certificate in Safari. After downloading, install it from Settings > General > VPN & Device Management, then enable trust in Settings > General > About > Certificate Trust Settings.")
|
||||
}
|
||||
|
||||
Section {
|
||||
Button("Regenerate Certificate", role: .destructive) {
|
||||
// TODO: Phase 3 - Generate new CA
|
||||
showRegenerateConfirmation = true
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
.navigationTitle("Certificate")
|
||||
.confirmationDialog("Regenerate Certificate?", isPresented: $showRegenerateConfirmation) {
|
||||
Button("Regenerate", role: .destructive) {
|
||||
CertificateManager.shared.regenerateCA()
|
||||
appState.isCertificateInstalled = CertificateManager.shared.hasCA
|
||||
}
|
||||
} message: {
|
||||
Text("This will create a new CA certificate. You will need to reinstall and trust it on your device.")
|
||||
}
|
||||
.onAppear {
|
||||
appState.isCertificateInstalled = CertificateManager.shared.hasCA
|
||||
}
|
||||
.onDisappear {
|
||||
certServer?.stop()
|
||||
certServer = nil
|
||||
}
|
||||
}
|
||||
|
||||
private func formattedDate(_ date: Date?) -> String {
|
||||
guard let date else { return "N/A" }
|
||||
return dateFormatter.string(from: date)
|
||||
}
|
||||
|
||||
private func abbreviatedFingerprint(_ fingerprint: String?) -> String {
|
||||
guard let fingerprint else { return "N/A" }
|
||||
if fingerprint.count <= 16 { return fingerprint }
|
||||
return "\(fingerprint.prefix(8))...\(fingerprint.suffix(8))"
|
||||
}
|
||||
|
||||
private func installCertificate() {
|
||||
guard let derBytes = CertificateManager.shared.exportCACertificateDER() else { return }
|
||||
|
||||
isInstallingCert = true
|
||||
|
||||
// Start a local HTTP server that serves the certificate
|
||||
let server = CertificateInstallServer(certDER: Data(derBytes))
|
||||
certServer = server
|
||||
|
||||
server.start { port in
|
||||
Task { @MainActor in
|
||||
// Open Safari to our local server so the certificate can be downloaded and installed.
|
||||
if let url = URL(string: "http://localhost:\(port)/ProxyCA.cer") {
|
||||
UIApplication.shared.open(url)
|
||||
}
|
||||
isInstallingCert = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Local HTTP server for certificate installation
|
||||
|
||||
import Network
|
||||
|
||||
final class CertificateInstallServer: @unchecked Sendable {
|
||||
private let certDER: Data
|
||||
private var listener: NWListener?
|
||||
private let queue = DispatchQueue(label: "cert-install-server")
|
||||
|
||||
init(certDER: Data) {
|
||||
self.certDER = certDER
|
||||
}
|
||||
|
||||
func start(onReady: @escaping @Sendable (UInt16) -> Void) {
|
||||
do {
|
||||
let params = NWParameters.tcp
|
||||
listener = try NWListener(using: params, on: .any)
|
||||
|
||||
listener?.stateUpdateHandler = { state in
|
||||
if case .ready = state, let port = self.listener?.port?.rawValue {
|
||||
DispatchQueue.main.async {
|
||||
onReady(port)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
listener?.newConnectionHandler = { [weak self] connection in
|
||||
self?.handleConnection(connection)
|
||||
}
|
||||
|
||||
listener?.start(queue: queue)
|
||||
} catch {
|
||||
print("[CertInstall] Failed to start server: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
func stop() {
|
||||
listener?.cancel()
|
||||
listener = nil
|
||||
}
|
||||
|
||||
private func handleConnection(_ connection: NWConnection) {
|
||||
connection.start(queue: queue)
|
||||
|
||||
// Read the HTTP request (we don't really need to parse it)
|
||||
connection.receive(minimumIncompleteLength: 1, maximumLength: 4096) { [weak self] data, _, _, _ in
|
||||
guard let self else { return }
|
||||
|
||||
// Respond with the certificate as a mobileconfig-style download
|
||||
let body = self.certDER
|
||||
let response = """
|
||||
HTTP/1.1 200 OK\r
|
||||
Content-Type: application/x-x509-ca-cert\r
|
||||
Content-Disposition: attachment; filename="ProxyCA.cer"\r
|
||||
Content-Length: \(body.count)\r
|
||||
Connection: close\r
|
||||
\r\n
|
||||
"""
|
||||
|
||||
var responseData = Data(response.utf8)
|
||||
responseData.append(body)
|
||||
|
||||
connection.send(content: responseData, completion: .contentProcessed { _ in
|
||||
connection.cancel()
|
||||
// Stop the server after serving — one-shot
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
|
||||
self.stop()
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,9 @@ struct DNSSpoofingView: View {
|
||||
@State private var isEnabled = IPCManager.shared.isDNSSpoofingEnabled
|
||||
@State private var rules: [DNSSpoofRule] = []
|
||||
@State private var showAddRule = false
|
||||
@State private var editingRule: DNSSpoofRule?
|
||||
@State private var ruleToDelete: DNSSpoofRule?
|
||||
@State private var showDeleteConfirmation = false
|
||||
@State private var observation: AnyDatabaseCancellable?
|
||||
|
||||
private let rulesRepo = RulesRepository()
|
||||
@@ -34,24 +37,28 @@ struct DNSSpoofingView: View {
|
||||
)
|
||||
} else {
|
||||
ForEach(rules) { rule in
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack {
|
||||
Text(rule.sourceDomain)
|
||||
.font(.subheadline)
|
||||
Image(systemName: "arrow.right")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
Text(rule.targetDomain)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.blue)
|
||||
Button {
|
||||
editingRule = rule
|
||||
} label: {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack {
|
||||
Text(rule.sourceDomain)
|
||||
.font(.subheadline)
|
||||
Image(systemName: "arrow.right")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
Text(rule.targetDomain)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.blue)
|
||||
}
|
||||
}
|
||||
}
|
||||
.tint(.primary)
|
||||
}
|
||||
.onDelete { indexSet in
|
||||
for index in indexSet {
|
||||
if let id = rules[index].id {
|
||||
try? rulesRepo.deleteDNSSpoofRule(id: id)
|
||||
}
|
||||
if let index = indexSet.first {
|
||||
ruleToDelete = rules[index]
|
||||
showDeleteConfirmation = true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -66,17 +73,15 @@ struct DNSSpoofingView: View {
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showAddRule) {
|
||||
NavigationStack {
|
||||
Form {
|
||||
// TODO: Add DNS spoof rule creation form
|
||||
Text("DNS Spoofing rule creation")
|
||||
}
|
||||
.navigationTitle("New DNS Rule")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Cancel") { showAddRule = false }
|
||||
}
|
||||
AddDNSSpoofRuleSheet(rulesRepo: rulesRepo, isPresented: $showAddRule)
|
||||
}
|
||||
.sheet(item: $editingRule) { rule in
|
||||
AddDNSSpoofRuleSheet(rulesRepo: rulesRepo, existingRule: rule, isPresented: .constant(true))
|
||||
}
|
||||
.confirmationDialog("Delete this rule?", isPresented: $showDeleteConfirmation, presenting: ruleToDelete) { rule in
|
||||
Button("Delete", role: .destructive) {
|
||||
if let id = rule.id {
|
||||
try? rulesRepo.deleteDNSSpoofRule(id: id)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -90,3 +95,76 @@ struct DNSSpoofingView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Add DNS Spoof Rule Sheet
|
||||
|
||||
private struct AddDNSSpoofRuleSheet: View {
|
||||
let rulesRepo: RulesRepository
|
||||
let existingRule: DNSSpoofRule?
|
||||
@Binding var isPresented: Bool
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@State private var sourceDomain = ""
|
||||
@State private var targetDomain = ""
|
||||
|
||||
init(rulesRepo: RulesRepository, existingRule: DNSSpoofRule? = nil, isPresented: Binding<Bool>) {
|
||||
self.rulesRepo = rulesRepo
|
||||
self.existingRule = existingRule
|
||||
self._isPresented = isPresented
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
Section {
|
||||
TextField("Source Domain (e.g. api.example.com)", text: $sourceDomain)
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
TextField("Target Domain (e.g. dev.example.com)", text: $targetDomain)
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
}
|
||||
|
||||
Section {
|
||||
Button("Save") {
|
||||
if let existing = existingRule {
|
||||
let updated = DNSSpoofRule(
|
||||
id: existing.id,
|
||||
sourceDomain: sourceDomain,
|
||||
targetDomain: targetDomain,
|
||||
isEnabled: existing.isEnabled,
|
||||
createdAt: existing.createdAt
|
||||
)
|
||||
try? rulesRepo.updateDNSSpoofRule(updated)
|
||||
} else {
|
||||
var rule = DNSSpoofRule(
|
||||
sourceDomain: sourceDomain,
|
||||
targetDomain: targetDomain
|
||||
)
|
||||
try? rulesRepo.insertDNSSpoofRule(&rule)
|
||||
}
|
||||
isPresented = false
|
||||
dismiss()
|
||||
}
|
||||
.disabled(sourceDomain.isEmpty || targetDomain.isEmpty)
|
||||
}
|
||||
}
|
||||
.navigationTitle(existingRule == nil ? "New DNS Rule" : "Edit DNS Rule")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Cancel") {
|
||||
isPresented = false
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
if let rule = existingRule {
|
||||
sourceDomain = rule.sourceDomain
|
||||
targetDomain = rule.targetDomain
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,9 @@ import GRDB
|
||||
struct MapLocalView: View {
|
||||
@State private var rules: [MapLocalRule] = []
|
||||
@State private var showAddRule = false
|
||||
@State private var editingRule: MapLocalRule?
|
||||
@State private var ruleToDelete: MapLocalRule?
|
||||
@State private var showDeleteConfirmation = false
|
||||
@State private var observation: AnyDatabaseCancellable?
|
||||
|
||||
private let rulesRepo = RulesRepository()
|
||||
@@ -32,22 +35,26 @@ struct MapLocalView: View {
|
||||
)
|
||||
} else {
|
||||
ForEach(rules) { rule in
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(rule.name ?? rule.urlPattern)
|
||||
.font(.subheadline.weight(.medium))
|
||||
Text(rule.urlPattern)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
Text("Status: \(rule.responseStatus)")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.tertiary)
|
||||
Button {
|
||||
editingRule = rule
|
||||
} label: {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(rule.name ?? rule.urlPattern)
|
||||
.font(.subheadline.weight(.medium))
|
||||
Text(rule.urlPattern)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
Text("Status: \(rule.responseStatus)")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
}
|
||||
.tint(.primary)
|
||||
}
|
||||
.onDelete { indexSet in
|
||||
for index in indexSet {
|
||||
if let id = rules[index].id {
|
||||
try? rulesRepo.deleteMapLocalRule(id: id)
|
||||
}
|
||||
if let index = indexSet.first {
|
||||
ruleToDelete = rules[index]
|
||||
showDeleteConfirmation = true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -62,17 +69,15 @@ struct MapLocalView: View {
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showAddRule) {
|
||||
NavigationStack {
|
||||
Form {
|
||||
// TODO: Add map local rule creation form
|
||||
Text("Map Local rule creation")
|
||||
}
|
||||
.navigationTitle("New Map Local Rule")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Cancel") { showAddRule = false }
|
||||
}
|
||||
AddMapLocalRuleSheet(rulesRepo: rulesRepo, isPresented: $showAddRule)
|
||||
}
|
||||
.sheet(item: $editingRule) { rule in
|
||||
AddMapLocalRuleSheet(rulesRepo: rulesRepo, existingRule: rule, isPresented: .constant(true))
|
||||
}
|
||||
.confirmationDialog("Delete this rule?", isPresented: $showDeleteConfirmation, presenting: ruleToDelete) { rule in
|
||||
Button("Delete", role: .destructive) {
|
||||
if let id = rule.id {
|
||||
try? rulesRepo.deleteMapLocalRule(id: id)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -86,3 +91,155 @@ struct MapLocalView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Add Map Local Rule Sheet
|
||||
|
||||
private struct AddMapLocalRuleSheet: View {
|
||||
let rulesRepo: RulesRepository
|
||||
let existingRule: MapLocalRule?
|
||||
@Binding var isPresented: Bool
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@State private var name = ""
|
||||
@State private var urlPattern = ""
|
||||
@State private var method = "ANY"
|
||||
@State private var responseStatus = "200"
|
||||
@State private var responseHeadersJSON = ""
|
||||
@State private var responseBody = ""
|
||||
@State private var contentType = "application/json"
|
||||
|
||||
private var methods: [String] { ["ANY"] + ProxyConstants.httpMethods }
|
||||
|
||||
init(rulesRepo: RulesRepository, existingRule: MapLocalRule? = nil, isPresented: Binding<Bool>) {
|
||||
self.rulesRepo = rulesRepo
|
||||
self.existingRule = existingRule
|
||||
self._isPresented = isPresented
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
Section {
|
||||
TextField("Name (optional)", text: $name)
|
||||
TextField("URL Pattern (e.g. */api/*)", text: $urlPattern)
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
}
|
||||
|
||||
Section {
|
||||
Picker("Method", selection: $method) {
|
||||
ForEach(methods, id: \.self) { m in
|
||||
Text(m).tag(m)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Section("Response") {
|
||||
TextField("Status Code", text: $responseStatus)
|
||||
.keyboardType(.numberPad)
|
||||
TextField("Content Type", text: $contentType)
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
}
|
||||
|
||||
Section {
|
||||
TextEditor(text: $responseHeadersJSON)
|
||||
.font(.system(.body, design: .monospaced))
|
||||
.frame(minHeight: 100)
|
||||
} header: {
|
||||
Text("Response Headers (JSON)")
|
||||
} footer: {
|
||||
Text("Optional JSON object of headers, for example {\"Cache-Control\":\"no-store\",\"X-Mock\":\"1\"}")
|
||||
}
|
||||
|
||||
Section("Response Body") {
|
||||
TextEditor(text: $responseBody)
|
||||
.font(.system(.body, design: .monospaced))
|
||||
.frame(minHeight: 120)
|
||||
}
|
||||
|
||||
Section {
|
||||
Button("Save") {
|
||||
let status = Int(responseStatus) ?? 200
|
||||
if let existing = existingRule {
|
||||
let updated = MapLocalRule(
|
||||
id: existing.id,
|
||||
name: name.isEmpty ? nil : name,
|
||||
urlPattern: urlPattern,
|
||||
method: method,
|
||||
responseStatus: status,
|
||||
responseHeaders: normalizedHeadersJSON,
|
||||
responseBody: responseBody.isEmpty ? nil : responseBody.data(using: .utf8),
|
||||
responseContentType: contentType.isEmpty ? nil : contentType,
|
||||
isEnabled: existing.isEnabled,
|
||||
createdAt: existing.createdAt
|
||||
)
|
||||
try? rulesRepo.updateMapLocalRule(updated)
|
||||
} else {
|
||||
var rule = MapLocalRule(
|
||||
name: name.isEmpty ? nil : name,
|
||||
urlPattern: urlPattern,
|
||||
method: method,
|
||||
responseStatus: status,
|
||||
responseHeaders: normalizedHeadersJSON,
|
||||
responseBody: responseBody.isEmpty ? nil : responseBody.data(using: .utf8),
|
||||
responseContentType: contentType.isEmpty ? nil : contentType
|
||||
)
|
||||
try? rulesRepo.insertMapLocalRule(&rule)
|
||||
}
|
||||
isPresented = false
|
||||
dismiss()
|
||||
}
|
||||
.disabled(urlPattern.isEmpty)
|
||||
}
|
||||
}
|
||||
.navigationTitle(existingRule == nil ? "New Map Local Rule" : "Edit Map Local Rule")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Cancel") {
|
||||
isPresented = false
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
if let rule = existingRule {
|
||||
name = rule.name ?? ""
|
||||
urlPattern = rule.urlPattern
|
||||
method = rule.method
|
||||
responseStatus = String(rule.responseStatus)
|
||||
if let body = rule.responseBody, let str = String(data: body, encoding: .utf8) {
|
||||
responseBody = str
|
||||
}
|
||||
responseHeadersJSON = prettyPrintedHeaders(rule.responseHeaders) ?? ""
|
||||
contentType = rule.responseContentType ?? "application/json"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var normalizedHeadersJSON: String? {
|
||||
let trimmed = responseHeadersJSON.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return nil }
|
||||
guard let data = trimmed.data(using: .utf8),
|
||||
let object = try? JSONSerialization.jsonObject(with: data),
|
||||
let dict = object as? [String: String],
|
||||
let normalized = try? JSONEncoder().encode(dict),
|
||||
let json = String(data: normalized, encoding: .utf8) else {
|
||||
return nil
|
||||
}
|
||||
return json
|
||||
}
|
||||
|
||||
private func prettyPrintedHeaders(_ json: String?) -> String? {
|
||||
guard let json,
|
||||
let data = json.data(using: .utf8),
|
||||
let object = try? JSONSerialization.jsonObject(with: data),
|
||||
let pretty = try? JSONSerialization.data(withJSONObject: object, options: [.prettyPrinted]),
|
||||
let string = String(data: pretty, encoding: .utf8) else {
|
||||
return json
|
||||
}
|
||||
return string
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,13 +13,13 @@ struct MoreView: View {
|
||||
Label {
|
||||
VStack(alignment: .leading) {
|
||||
Text("Setup Guide")
|
||||
Text(appState.isVPNConnected ? "Ready to Intercept" : "Setup Required")
|
||||
Text(setupStatusText)
|
||||
.font(.caption)
|
||||
.foregroundStyle(appState.isVPNConnected ? .green : .orange)
|
||||
.foregroundStyle(setupStatusColor)
|
||||
}
|
||||
} icon: {
|
||||
Image(systemName: "checkmark.shield.fill")
|
||||
.foregroundStyle(appState.isVPNConnected ? .green : .orange)
|
||||
.foregroundStyle(setupStatusColor)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,12 +43,6 @@ struct MoreView: View {
|
||||
Label("Block List", systemImage: "xmark.shield")
|
||||
}
|
||||
|
||||
NavigationLink {
|
||||
BreakpointRulesView()
|
||||
} label: {
|
||||
Label("Breakpoint", systemImage: "pause.circle")
|
||||
}
|
||||
|
||||
NavigationLink {
|
||||
MapLocalView()
|
||||
} label: {
|
||||
@@ -66,6 +60,12 @@ struct MoreView: View {
|
||||
} label: {
|
||||
Label("DNS Spoofing", systemImage: "network")
|
||||
}
|
||||
|
||||
NavigationLink {
|
||||
PinnedDomainsView()
|
||||
} label: {
|
||||
Label("Pinned Domains", systemImage: "pin.slash")
|
||||
}
|
||||
}
|
||||
|
||||
Section("Settings") {
|
||||
@@ -80,8 +80,31 @@ struct MoreView: View {
|
||||
} label: {
|
||||
Label("App Settings", systemImage: "gearshape")
|
||||
}
|
||||
|
||||
NavigationLink {
|
||||
AppLockView()
|
||||
} label: {
|
||||
Label("App Lock", systemImage: "lock.fill")
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("More")
|
||||
}
|
||||
|
||||
private var setupStatusText: String {
|
||||
if appState.isHTTPSInspectionVerified {
|
||||
return "HTTPS Verified"
|
||||
}
|
||||
if appState.isVPNConnected && appState.hasSharedCertificate {
|
||||
return "Ready to Capture"
|
||||
}
|
||||
return "Setup Required"
|
||||
}
|
||||
|
||||
private var setupStatusColor: Color {
|
||||
if appState.isVPNConnected && appState.hasSharedCertificate {
|
||||
return .green
|
||||
}
|
||||
return .orange
|
||||
}
|
||||
}
|
||||
|
||||
80
UI/More/PinnedDomainsView.swift
Normal file
80
UI/More/PinnedDomainsView.swift
Normal file
@@ -0,0 +1,80 @@
|
||||
import SwiftUI
|
||||
import ProxyCore
|
||||
import GRDB
|
||||
|
||||
struct PinnedDomainsView: View {
|
||||
@State private var domains: [PinnedDomain] = []
|
||||
@State private var observation: AnyDatabaseCancellable?
|
||||
@State private var showClearConfirmation = false
|
||||
|
||||
private let repo = PinnedDomainRepository()
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
Section {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("SSL Pinning Detection")
|
||||
.font(.headline)
|
||||
Text("Domains listed here were automatically detected as using SSL pinning. MITM interception is skipped for these domains — they use passthrough instead so the app still works.")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
|
||||
Section("Detected Domains (\(domains.count))") {
|
||||
if domains.isEmpty {
|
||||
Text("No pinned domains detected yet")
|
||||
.foregroundStyle(.secondary)
|
||||
.font(.subheadline)
|
||||
} else {
|
||||
ForEach(domains) { domain in
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(domain.domain)
|
||||
.font(.subheadline.weight(.medium))
|
||||
if let reason = domain.reason {
|
||||
Text(reason)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(2)
|
||||
}
|
||||
Text("Detected \(Date(timeIntervalSince1970: domain.detectedAt).formatted(.relative(presentation: .named)))")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
}
|
||||
.onDelete { offsets in
|
||||
for index in offsets {
|
||||
try? repo.unpin(domain: domains[index].domain)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Pinned Domains")
|
||||
.toolbar {
|
||||
if !domains.isEmpty {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button("Clear All", role: .destructive) {
|
||||
showClearConfirmation = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.confirmationDialog("Clear All Pinned Domains?", isPresented: $showClearConfirmation) {
|
||||
Button("Clear All", role: .destructive) {
|
||||
try? repo.deleteAll()
|
||||
}
|
||||
} message: {
|
||||
Text("This will allow MITM interception to be attempted again for all domains. Pinned domains will be re-detected automatically if they still use SSL pinning.")
|
||||
}
|
||||
.task {
|
||||
observation = repo.observeAll()
|
||||
.start(in: DatabaseManager.shared.dbPool) { error in
|
||||
print("Pinned domains observation error: \(error)")
|
||||
} onChange: { newDomains in
|
||||
withAnimation { domains = newDomains }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,9 @@ struct SSLProxyingListView: View {
|
||||
@State private var entries: [SSLProxyingEntry] = []
|
||||
@State private var showAddInclude = false
|
||||
@State private var showAddExclude = false
|
||||
@State private var editingEntry: SSLProxyingEntry?
|
||||
@State private var entryToDelete: SSLProxyingEntry?
|
||||
@State private var showDeleteConfirmation = false
|
||||
@State private var observation: AnyDatabaseCancellable?
|
||||
|
||||
private let rulesRepo = RulesRepository()
|
||||
@@ -41,13 +44,17 @@ struct SSLProxyingListView: View {
|
||||
.font(.subheadline)
|
||||
} else {
|
||||
ForEach(includeEntries) { entry in
|
||||
Text(entry.domainPattern)
|
||||
Button {
|
||||
editingEntry = entry
|
||||
} label: {
|
||||
Text(entry.domainPattern)
|
||||
.foregroundStyle(.primary)
|
||||
}
|
||||
}
|
||||
.onDelete { indexSet in
|
||||
for index in indexSet {
|
||||
if let id = includeEntries[index].id {
|
||||
try? rulesRepo.deleteSSLEntry(id: id)
|
||||
}
|
||||
if let index = indexSet.first {
|
||||
entryToDelete = includeEntries[index]
|
||||
showDeleteConfirmation = true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -60,13 +67,17 @@ struct SSLProxyingListView: View {
|
||||
.font(.subheadline)
|
||||
} else {
|
||||
ForEach(excludeEntries) { entry in
|
||||
Text(entry.domainPattern)
|
||||
Button {
|
||||
editingEntry = entry
|
||||
} label: {
|
||||
Text(entry.domainPattern)
|
||||
.foregroundStyle(.primary)
|
||||
}
|
||||
}
|
||||
.onDelete { indexSet in
|
||||
for index in indexSet {
|
||||
if let id = excludeEntries[index].id {
|
||||
try? rulesRepo.deleteSSLEntry(id: id)
|
||||
}
|
||||
if let index = indexSet.first {
|
||||
entryToDelete = excludeEntries[index]
|
||||
showDeleteConfirmation = true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -81,6 +92,7 @@ struct SSLProxyingListView: View {
|
||||
Divider()
|
||||
Button("Clear All Rules", role: .destructive) {
|
||||
try? rulesRepo.deleteAllSSLEntries()
|
||||
IPCManager.shared.post(.configurationChanged)
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: "ellipsis.circle")
|
||||
@@ -91,12 +103,37 @@ struct SSLProxyingListView: View {
|
||||
DomainEntrySheet(title: "New Include Entry", isInclude: true) { pattern in
|
||||
var entry = SSLProxyingEntry(domainPattern: pattern, isInclude: true)
|
||||
try? rulesRepo.insertSSLEntry(&entry)
|
||||
IPCManager.shared.isSSLProxyingEnabled = true
|
||||
IPCManager.shared.post(.configurationChanged)
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showAddExclude) {
|
||||
DomainEntrySheet(title: "New Exclude Entry", isInclude: false) { pattern in
|
||||
var entry = SSLProxyingEntry(domainPattern: pattern, isInclude: false)
|
||||
try? rulesRepo.insertSSLEntry(&entry)
|
||||
IPCManager.shared.post(.configurationChanged)
|
||||
}
|
||||
}
|
||||
.sheet(item: $editingEntry) { entry in
|
||||
DomainEntrySheet(
|
||||
title: entry.isInclude ? "Edit Include Entry" : "Edit Exclude Entry",
|
||||
isInclude: entry.isInclude,
|
||||
existingEntry: entry
|
||||
) { pattern in
|
||||
var updated = entry
|
||||
updated.domainPattern = pattern
|
||||
try? rulesRepo.updateSSLEntry(updated)
|
||||
if updated.isInclude {
|
||||
IPCManager.shared.isSSLProxyingEnabled = true
|
||||
}
|
||||
IPCManager.shared.post(.configurationChanged)
|
||||
}
|
||||
}
|
||||
.confirmationDialog("Delete this rule?", isPresented: $showDeleteConfirmation, presenting: entryToDelete) { entry in
|
||||
Button("Delete", role: .destructive) {
|
||||
if let id = entry.id {
|
||||
try? rulesRepo.deleteSSLEntry(id: id)
|
||||
}
|
||||
}
|
||||
}
|
||||
.task {
|
||||
@@ -115,11 +152,19 @@ struct SSLProxyingListView: View {
|
||||
struct DomainEntrySheet: View {
|
||||
let title: String
|
||||
let isInclude: Bool
|
||||
let existingEntry: SSLProxyingEntry?
|
||||
let onSave: (String) -> Void
|
||||
|
||||
@State private var domainPattern = ""
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
init(title: String, isInclude: Bool, existingEntry: SSLProxyingEntry? = nil, onSave: @escaping (String) -> Void) {
|
||||
self.title = title
|
||||
self.isInclude = isInclude
|
||||
self.existingEntry = existingEntry
|
||||
self.onSave = onSave
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
@@ -145,6 +190,11 @@ struct DomainEntrySheet: View {
|
||||
.disabled(domainPattern.isEmpty)
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
if let entry = existingEntry {
|
||||
domainPattern = entry.domainPattern
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ struct SetupGuideView: View {
|
||||
@Environment(AppState.self) private var appState
|
||||
|
||||
var isReady: Bool {
|
||||
appState.isVPNConnected && appState.isCertificateTrusted
|
||||
appState.isVPNConnected && appState.hasSharedCertificate
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
@@ -15,9 +15,9 @@ struct SetupGuideView: View {
|
||||
Image(systemName: isReady ? "checkmark.circle.fill" : "exclamationmark.triangle.fill")
|
||||
.font(.title2)
|
||||
VStack(alignment: .leading) {
|
||||
Text(isReady ? "Ready to Intercept" : "Setup Required")
|
||||
Text(isReady ? "Ready to Capture" : "Setup Required")
|
||||
.font(.headline)
|
||||
Text(isReady ? "All systems are configured correctly" : "Complete the steps below to start")
|
||||
Text(isReady ? "The tunnel and shared certificate are configured" : "Complete the steps below to start")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.white.opacity(0.8))
|
||||
}
|
||||
@@ -44,43 +44,42 @@ struct SetupGuideView: View {
|
||||
)
|
||||
|
||||
// Step 2: Certificate
|
||||
stepRow(
|
||||
title: "Certificate Installed & Trusted",
|
||||
subtitle: appState.isCertificateTrusted
|
||||
? "HTTPS traffic can now be decrypted"
|
||||
: "Install and trust the CA certificate",
|
||||
isComplete: appState.isCertificateTrusted,
|
||||
action: {
|
||||
// TODO: Phase 3 - Open certificate installation flow
|
||||
}
|
||||
)
|
||||
NavigationLink {
|
||||
CertificateView()
|
||||
} label: {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: appState.hasSharedCertificate ? "checkmark.circle.fill" : "circle")
|
||||
.font(.title2)
|
||||
.foregroundStyle(appState.hasSharedCertificate ? .green : .secondary)
|
||||
|
||||
Divider()
|
||||
VStack(alignment: .leading) {
|
||||
Text("Shared Certificate Available")
|
||||
.font(.subheadline.weight(.medium))
|
||||
.foregroundStyle(appState.hasSharedCertificate ? .green : .primary)
|
||||
Text(certificateSubtitle)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
// Help section
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("Need Help?")
|
||||
.font(.headline)
|
||||
|
||||
HStack {
|
||||
Image(systemName: "play.rectangle.fill")
|
||||
.foregroundStyle(.red)
|
||||
Text("Watch Video Tutorial")
|
||||
Spacer()
|
||||
Image(systemName: "arrow.up.forward")
|
||||
}
|
||||
.padding()
|
||||
.background(Color(.secondarySystemGroupedBackground), in: RoundedRectangle(cornerRadius: 12))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
if let lastError = appState.lastRuntimeError {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Label("Latest Runtime Error", systemImage: "exclamationmark.triangle.fill")
|
||||
.font(.subheadline.weight(.medium))
|
||||
.foregroundStyle(.orange)
|
||||
Text(lastError)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
|
||||
HStack {
|
||||
Image(systemName: "book.fill")
|
||||
.foregroundStyle(.blue)
|
||||
Text("Read Documentation")
|
||||
Spacer()
|
||||
Image(systemName: "arrow.up.forward")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding()
|
||||
.background(Color(.secondarySystemGroupedBackground), in: RoundedRectangle(cornerRadius: 12))
|
||||
}
|
||||
|
||||
Spacer()
|
||||
@@ -90,6 +89,19 @@ struct SetupGuideView: View {
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
|
||||
private var certificateSubtitle: String {
|
||||
if !appState.isCertificateInstalled {
|
||||
return "Generate and install the Proxy CA certificate."
|
||||
}
|
||||
if !appState.hasSharedCertificate {
|
||||
return "The app has a CA, but the extension has not loaded the same one yet."
|
||||
}
|
||||
if appState.isHTTPSInspectionVerified {
|
||||
return "HTTPS inspection has been verified on live traffic."
|
||||
}
|
||||
return "Include a domain in SSL Proxying, trust the CA in Settings, then retry the request."
|
||||
}
|
||||
|
||||
private func stepRow(title: String, subtitle: String, isComplete: Bool, action: @escaping () -> Void) -> some View {
|
||||
Button(action: action) {
|
||||
HStack(spacing: 12) {
|
||||
|
||||
@@ -5,6 +5,7 @@ import GRDB
|
||||
struct PinView: View {
|
||||
@State private var pinnedRequests: [CapturedTraffic] = []
|
||||
@State private var observation: AnyDatabaseCancellable?
|
||||
@State private var showClearAllConfirmation = false
|
||||
|
||||
private let trafficRepo = TrafficRepository()
|
||||
|
||||
@@ -19,19 +20,54 @@ struct PinView: View {
|
||||
} else {
|
||||
List {
|
||||
ForEach(pinnedRequests) { request in
|
||||
NavigationLink(value: request.id) {
|
||||
TrafficRowView(traffic: request)
|
||||
if let id = request.id {
|
||||
NavigationLink(value: id) {
|
||||
TrafficRowView(traffic: request)
|
||||
}
|
||||
}
|
||||
}
|
||||
.onDelete { offsets in
|
||||
for index in offsets {
|
||||
let request = pinnedRequests[index]
|
||||
if let id = request.id {
|
||||
try? trafficRepo.togglePin(id: id, isPinned: false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationDestination(for: Int64?.self) { id in
|
||||
if let id {
|
||||
RequestDetailView(trafficId: id)
|
||||
}
|
||||
.navigationDestination(for: Int64.self) { id in
|
||||
RequestDetailView(trafficId: id)
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Pin")
|
||||
.toolbar {
|
||||
if !pinnedRequests.isEmpty {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button(role: .destructive) {
|
||||
showClearAllConfirmation = true
|
||||
} label: {
|
||||
Text("Clear All")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.confirmationDialog(
|
||||
"Clear All Pins",
|
||||
isPresented: $showClearAllConfirmation,
|
||||
titleVisibility: .visible
|
||||
) {
|
||||
Button("Clear All Pins", role: .destructive) {
|
||||
for request in pinnedRequests {
|
||||
if let id = request.id {
|
||||
try? trafficRepo.togglePin(id: id, isPinned: false)
|
||||
}
|
||||
}
|
||||
}
|
||||
Button("Cancel", role: .cancel) {}
|
||||
} message: {
|
||||
Text("This will unpin all \(pinnedRequests.count) pinned requests.")
|
||||
}
|
||||
.task {
|
||||
observation = trafficRepo.observePinnedTraffic()
|
||||
.start(in: DatabaseManager.shared.dbPool) { error in
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import SwiftUI
|
||||
|
||||
struct FilterChip: Identifiable {
|
||||
struct FilterChip: Identifiable, Equatable {
|
||||
let id = UUID()
|
||||
let label: String
|
||||
var isSelected: Bool = false
|
||||
@@ -14,21 +14,37 @@ struct FilterChipsView: View {
|
||||
HStack(spacing: 8) {
|
||||
ForEach($chips) { $chip in
|
||||
Button {
|
||||
chip.isSelected.toggle()
|
||||
withAnimation(.spring(response: 0.24, dampingFraction: 0.9)) {
|
||||
chip.isSelected.toggle()
|
||||
}
|
||||
} label: {
|
||||
Text(chip.label)
|
||||
.font(.caption.weight(.medium))
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
.background(
|
||||
chip.isSelected ? Color.accentColor : Color(.systemGray5),
|
||||
in: Capsule()
|
||||
)
|
||||
.foregroundStyle(chip.isSelected ? .white : .primary)
|
||||
HStack(spacing: 6) {
|
||||
if chip.isSelected {
|
||||
Image(systemName: "checkmark")
|
||||
.font(.caption2.weight(.bold))
|
||||
}
|
||||
Text(chip.label)
|
||||
.font(.caption.weight(.semibold))
|
||||
}
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 9)
|
||||
.background(
|
||||
chip.isSelected ? Color.accentColor.opacity(0.16) : Color(.systemBackground),
|
||||
in: Capsule()
|
||||
)
|
||||
.overlay(
|
||||
Capsule()
|
||||
.stroke(
|
||||
chip.isSelected ? Color.accentColor.opacity(0.35) : Color.primary.opacity(0.06),
|
||||
lineWidth: 1
|
||||
)
|
||||
)
|
||||
.foregroundStyle(chip.isSelected ? Color.accentColor : .primary)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.vertical, 2)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
98
UI/SharedComponents/HexView.swift
Normal file
98
UI/SharedComponents/HexView.swift
Normal file
@@ -0,0 +1,98 @@
|
||||
import SwiftUI
|
||||
|
||||
struct HexView: View {
|
||||
let data: Data
|
||||
|
||||
@State private var showAll = false
|
||||
|
||||
private var displayData: Data {
|
||||
if showAll || data.count <= 1024 {
|
||||
return data
|
||||
}
|
||||
return data.prefix(1024)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
// Header
|
||||
Text("Offset 00 01 02 03 04 05 06 07 08 09 0A 0B 0C 0D 0E 0F ASCII")
|
||||
.font(.system(.caption2, design: .monospaced))
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.bottom, 4)
|
||||
|
||||
// Hex rows
|
||||
let bytes = [UInt8](displayData)
|
||||
let rowCount = (bytes.count + 15) / 16
|
||||
|
||||
ForEach(0..<rowCount, id: \.self) { row in
|
||||
hexRow(bytes: bytes, row: row)
|
||||
}
|
||||
|
||||
// Show more
|
||||
if !showAll && data.count > 1024 {
|
||||
Button {
|
||||
showAll = true
|
||||
} label: {
|
||||
Text("Show All (\(formatBytes(data.count)) total)")
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
}
|
||||
.padding(.top, 8)
|
||||
}
|
||||
}
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
|
||||
private func hexRow(bytes: [UInt8], row: Int) -> some View {
|
||||
let offset = row * 16
|
||||
let end = min(offset + 16, bytes.count)
|
||||
let rowBytes = Array(bytes[offset..<end])
|
||||
|
||||
return HStack(spacing: 0) {
|
||||
// Offset
|
||||
Text(String(format: "%08X ", offset))
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
// Hex bytes
|
||||
Text(hexString(rowBytes))
|
||||
|
||||
// ASCII
|
||||
Text(" ")
|
||||
Text(asciiString(rowBytes))
|
||||
.foregroundStyle(.orange)
|
||||
}
|
||||
.font(.system(.caption2, design: .monospaced))
|
||||
}
|
||||
|
||||
private func hexString(_ bytes: [UInt8]) -> String {
|
||||
var result = ""
|
||||
for i in 0..<16 {
|
||||
if i < bytes.count {
|
||||
result += String(format: "%02X ", bytes[i])
|
||||
} else {
|
||||
result += " "
|
||||
}
|
||||
if i == 7 {
|
||||
result += " "
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private func asciiString(_ bytes: [UInt8]) -> String {
|
||||
var result = ""
|
||||
for byte in bytes {
|
||||
if byte >= 0x20, byte <= 0x7E {
|
||||
result += String(UnicodeScalar(byte))
|
||||
} else {
|
||||
result += "."
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private func formatBytes(_ count: Int) -> String {
|
||||
if count < 1024 { return "\(count) B" }
|
||||
if count < 1_048_576 { return String(format: "%.1f KB", Double(count) / 1024) }
|
||||
return String(format: "%.1f MB", Double(count) / 1_048_576)
|
||||
}
|
||||
}
|
||||
136
UI/SharedComponents/JSONTreeView.swift
Normal file
136
UI/SharedComponents/JSONTreeView.swift
Normal file
@@ -0,0 +1,136 @@
|
||||
import SwiftUI
|
||||
|
||||
struct JSONTreeView: View {
|
||||
let data: Data
|
||||
|
||||
@State private var root: JSONNode?
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if let root {
|
||||
JSONNodeExpandableView(
|
||||
node: root,
|
||||
collapseByDefault: false,
|
||||
childCollapseThreshold: 50
|
||||
)
|
||||
} else {
|
||||
Text("Invalid JSON")
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.task {
|
||||
root = parseJSON(data)
|
||||
}
|
||||
}
|
||||
|
||||
private func parseJSON(_ data: Data) -> JSONNode? {
|
||||
guard let obj = try? JSONSerialization.jsonObject(with: data) else { return nil }
|
||||
return buildNode(key: nil, value: obj)
|
||||
}
|
||||
|
||||
private func buildNode(key: String?, value: Any) -> JSONNode {
|
||||
if let dict = value as? [String: Any] {
|
||||
let children = dict.sorted(by: { $0.key < $1.key }).map { buildNode(key: $0.key, value: $0.value) }
|
||||
return JSONNode(key: key, kind: .object, displayValue: "{\(dict.count)}", children: children)
|
||||
} else if let arr = value as? [Any] {
|
||||
let children = arr.enumerated().map { buildNode(key: "[\($0.offset)]", value: $0.element) }
|
||||
return JSONNode(key: key, kind: .array, displayValue: "[\(arr.count)]", children: children)
|
||||
} else if let str = value as? String {
|
||||
return JSONNode(key: key, kind: .string, displayValue: "\"\(str)\"", children: [])
|
||||
} else if let num = value as? NSNumber {
|
||||
if CFBooleanGetTypeID() == CFGetTypeID(num) {
|
||||
let boolVal = num.boolValue
|
||||
return JSONNode(key: key, kind: .bool, displayValue: boolVal ? "true" : "false", children: [])
|
||||
}
|
||||
return JSONNode(key: key, kind: .number, displayValue: "\(num)", children: [])
|
||||
} else if value is NSNull {
|
||||
return JSONNode(key: key, kind: .null, displayValue: "null", children: [])
|
||||
} else {
|
||||
return JSONNode(key: key, kind: .string, displayValue: "\(value)", children: [])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Model
|
||||
|
||||
private struct JSONNode: Identifiable {
|
||||
let id = UUID()
|
||||
let key: String?
|
||||
let kind: JSONValueKind
|
||||
let displayValue: String
|
||||
let children: [JSONNode]
|
||||
|
||||
var childCount: Int {
|
||||
children.count + children.reduce(0) { $0 + $1.childCount }
|
||||
}
|
||||
}
|
||||
|
||||
private enum JSONValueKind {
|
||||
case object, array, string, number, bool, null
|
||||
}
|
||||
|
||||
// MARK: - Expandable wrapper (allows per-node expand state)
|
||||
|
||||
private struct JSONNodeExpandableView: View {
|
||||
let node: JSONNode
|
||||
let collapseByDefault: Bool
|
||||
let childCollapseThreshold: Int
|
||||
|
||||
@State private var isExpanded: Bool
|
||||
|
||||
init(node: JSONNode, collapseByDefault: Bool, childCollapseThreshold: Int) {
|
||||
self.node = node
|
||||
self.collapseByDefault = collapseByDefault
|
||||
self.childCollapseThreshold = childCollapseThreshold
|
||||
_isExpanded = State(initialValue: !collapseByDefault)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
if node.children.isEmpty {
|
||||
leafRow
|
||||
} else {
|
||||
DisclosureGroup(isExpanded: $isExpanded) {
|
||||
ForEach(node.children) { child in
|
||||
JSONNodeExpandableView(
|
||||
node: child,
|
||||
collapseByDefault: child.childCount > childCollapseThreshold,
|
||||
childCollapseThreshold: childCollapseThreshold
|
||||
)
|
||||
}
|
||||
} label: {
|
||||
labelRow(valueColor: .secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var leafRow: some View {
|
||||
labelRow(valueColor: valueColor)
|
||||
}
|
||||
|
||||
private func labelRow(valueColor: Color) -> some View {
|
||||
HStack(alignment: .firstTextBaseline, spacing: 4) {
|
||||
if let key = node.key {
|
||||
Text(key + ":")
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.bold()
|
||||
.foregroundStyle(.primary)
|
||||
}
|
||||
|
||||
Text(node.displayValue)
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.foregroundStyle(valueColor)
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
}
|
||||
|
||||
private var valueColor: Color {
|
||||
switch node.kind {
|
||||
case .string: return .green
|
||||
case .number: return .blue
|
||||
case .bool: return .orange
|
||||
case .null: return .gray
|
||||
case .object, .array: return .secondary
|
||||
}
|
||||
}
|
||||
}
|
||||
34
UI/SharedComponents/SelectableTextView.swift
Normal file
34
UI/SharedComponents/SelectableTextView.swift
Normal file
@@ -0,0 +1,34 @@
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
struct SelectableTextView: UIViewRepresentable {
|
||||
let text: String
|
||||
|
||||
func makeUIView(context: Context) -> UITextView {
|
||||
let textView = UITextView()
|
||||
textView.isEditable = false
|
||||
textView.isSelectable = true
|
||||
textView.isScrollEnabled = false
|
||||
textView.backgroundColor = .clear
|
||||
textView.textContainerInset = .zero
|
||||
textView.textContainer.lineFragmentPadding = 0
|
||||
textView.adjustsFontForContentSizeCategory = true
|
||||
textView.font = .monospacedSystemFont(ofSize: 12, weight: .regular)
|
||||
textView.textColor = .label
|
||||
textView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
|
||||
return textView
|
||||
}
|
||||
|
||||
func updateUIView(_ uiView: UITextView, context: Context) {
|
||||
if uiView.text != text {
|
||||
uiView.text = text
|
||||
}
|
||||
}
|
||||
|
||||
func sizeThatFits(_ proposal: ProposedViewSize, uiView: UITextView, context: Context) -> CGSize? {
|
||||
let width = proposal.width ?? UIScreen.main.bounds.width
|
||||
let fittingSize = CGSize(width: width, height: .greatestFiniteMagnitude)
|
||||
let size = uiView.sizeThatFits(fittingSize)
|
||||
return CGSize(width: width, height: ceil(size.height))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user