From 67930ec7fee647abedadd1297e587a354247b6ac Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 19 May 2026 04:43:50 +0000 Subject: [PATCH 01/13] =?UTF-8?q?M3:=20SwiftUI=20app=20=E2=80=94=20Traffic?= =?UTF-8?q?List,=20Inspector,=20SQLite=20store?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ReverseAPI app target wired through Package.swift - AppState (@MainActor @Observable) owns engine, flow store, installer, system proxy, capture toggles, CA install/uninstall - FlowStore: GRDB-backed SQLite persistence with reactive @Observable array of CapturedFlow, subscribes to FlowBus, loads last 500 on boot - TrafficListView: SwiftUI Table with method/host/path/status/size/duration columns, search box, host/method/status/errors filters - InspectorView: Overview / Request / Response tabs with header table and JSON-aware body pretty printer - CaptureToolbar: capture, CA trust, system proxy, clear; status line - ProxyEngine: split stop() (channel close) from terminate() (also shuts down the event-loop group) so capture can be toggled - NIOFoundationCompat added to ReverseAPIProxy deps --- macos/Package.swift | 9 + macos/Sources/ReverseAPI/App/AppState.swift | 142 +++++++++++ .../ReverseAPI/App/ReverseAPIApp.swift | 60 +++++ .../ReverseAPI/Helpers/JSONFormatter.swift | 24 ++ .../ReverseAPI/Storage/FlowStore.swift | 115 +++++++++ .../ReverseAPI/Storage/PersistedFlow.swift | 93 +++++++ .../ReverseAPI/UI/CaptureToolbar.swift | 94 +++++++ macos/Sources/ReverseAPI/UI/ContentView.swift | 20 ++ .../Sources/ReverseAPI/UI/InspectorView.swift | 209 ++++++++++++++++ .../Sources/ReverseAPI/UI/TrafficFilter.swift | 49 ++++ .../ReverseAPI/UI/TrafficListView.swift | 229 ++++++++++++++++++ .../Sources/ReverseAPIProxy/ProxyEngine.swift | 6 +- 12 files changed, 1049 insertions(+), 1 deletion(-) create mode 100644 macos/Sources/ReverseAPI/App/AppState.swift create mode 100644 macos/Sources/ReverseAPI/App/ReverseAPIApp.swift create mode 100644 macos/Sources/ReverseAPI/Helpers/JSONFormatter.swift create mode 100644 macos/Sources/ReverseAPI/Storage/FlowStore.swift create mode 100644 macos/Sources/ReverseAPI/Storage/PersistedFlow.swift create mode 100644 macos/Sources/ReverseAPI/UI/CaptureToolbar.swift create mode 100644 macos/Sources/ReverseAPI/UI/ContentView.swift create mode 100644 macos/Sources/ReverseAPI/UI/InspectorView.swift create mode 100644 macos/Sources/ReverseAPI/UI/TrafficFilter.swift create mode 100644 macos/Sources/ReverseAPI/UI/TrafficListView.swift diff --git a/macos/Package.swift b/macos/Package.swift index 20f8bb3..b7789aa 100644 --- a/macos/Package.swift +++ b/macos/Package.swift @@ -7,12 +7,14 @@ let package = Package( products: [ .library(name: "ReverseAPIProxy", targets: ["ReverseAPIProxy"]), .executable(name: "rae-proxy", targets: ["rae-proxy"]), + .executable(name: "ReverseAPI", targets: ["ReverseAPI"]), ], dependencies: [ .package(url: "https://github.com/apple/swift-nio.git", from: "2.65.0"), .package(url: "https://github.com/apple/swift-nio-ssl.git", from: "2.27.0"), .package(url: "https://github.com/apple/swift-certificates.git", from: "1.5.0"), .package(url: "https://github.com/apple/swift-crypto.git", from: "3.7.0"), + .package(url: "https://github.com/groue/GRDB.swift.git", from: "6.29.0"), ], targets: [ .target( @@ -34,6 +36,13 @@ let package = Package( name: "rae-proxy", dependencies: ["ReverseAPIProxy"] ), + .executableTarget( + name: "ReverseAPI", + dependencies: [ + "ReverseAPIProxy", + .product(name: "GRDB", package: "GRDB.swift"), + ] + ), .testTarget( name: "ReverseAPIProxyTests", dependencies: ["ReverseAPIProxy"] diff --git a/macos/Sources/ReverseAPI/App/AppState.swift b/macos/Sources/ReverseAPI/App/AppState.swift new file mode 100644 index 0000000..b5ddbea --- /dev/null +++ b/macos/Sources/ReverseAPI/App/AppState.swift @@ -0,0 +1,142 @@ +import Foundation +import Observation +import ReverseAPIProxy + +@MainActor +@Observable +final class AppState { + private(set) var isCapturing = false + private(set) var systemProxyEnabled = false + private(set) var caTrustInstalled = false + private(set) var lastError: String? + + var selectedFlowID: UUID? + var filter = TrafficFilter() + + let store: FlowStore + let engine: ProxyEngine + let installer: CertificateTrustInstaller + let systemProxy: SystemProxyController + + let port: Int + let caDER: Data + let caPEM: String + let caPath: String + + init(port: Int = 8888) throws { + let appSupport = try FileManager.default.url( + for: .applicationSupportDirectory, + in: .userDomainMask, + appropriateFor: nil, + create: true + ) + let caStore = try CAStore(applicationSupportURL: appSupport) + let root = try caStore.loadOrCreate() + let engine = try ProxyEngine(root: root, port: port) + + let databaseURL = caStore.directory.appendingPathComponent("flows.sqlite") + let store = try FlowStore(databaseURL: databaseURL) + + self.store = store + self.engine = engine + self.installer = CertificateTrustInstaller() + self.systemProxy = SystemProxyController() + self.port = port + self.caDER = Data(try root.derBytes()) + self.caPEM = try root.pem() + self.caPath = caStore.certificateURL.path + self.caTrustInstalled = installer.isInstalled(derBytes: self.caDER) + self.systemProxyEnabled = (try? systemProxy.isEnabled()) ?? false + + store.subscribe(to: engine.bus) + } + + func toggleCapture() async { + if isCapturing { + await stopCapture() + } else { + await startCapture() + } + } + + func startCapture() async { + guard !isCapturing else { return } + do { + try await engine.start() + isCapturing = true + lastError = nil + } catch { + lastError = "Failed to start proxy: \(error)" + } + } + + func stopCapture() async { + guard isCapturing else { return } + do { + try await engine.stop() + isCapturing = false + } catch { + lastError = "Failed to stop proxy: \(error)" + } + } + + func installCATrust() async { + do { + let installer = self.installer + let der = self.caDER + try await Task.detached(priority: .userInitiated) { + try installer.install(derBytes: der) + }.value + caTrustInstalled = true + } catch { + lastError = "Failed to install CA trust: \(error)" + } + } + + func uninstallCATrust() async { + do { + let installer = self.installer + let der = self.caDER + try await Task.detached(priority: .userInitiated) { + try installer.uninstall(derBytes: der) + }.value + caTrustInstalled = false + } catch { + lastError = "Failed to uninstall CA trust: \(error)" + } + } + + func enableSystemProxy() async { + do { + let systemProxy = self.systemProxy + let port = self.port + try await Task.detached(priority: .userInitiated) { + try systemProxy.enable(host: "127.0.0.1", port: port) + }.value + systemProxyEnabled = true + } catch { + lastError = "Failed to enable system proxy: \(error)" + } + } + + func disableSystemProxy() async { + do { + let systemProxy = self.systemProxy + try await Task.detached(priority: .userInitiated) { + try systemProxy.disable() + }.value + systemProxyEnabled = false + } catch { + lastError = "Failed to disable system proxy: \(error)" + } + } + + func clearFlows() { + do { + try store.clear() + selectedFlowID = nil + } catch { + lastError = "Failed to clear flows: \(error)" + } + } +} diff --git a/macos/Sources/ReverseAPI/App/ReverseAPIApp.swift b/macos/Sources/ReverseAPI/App/ReverseAPIApp.swift new file mode 100644 index 0000000..9ad56e3 --- /dev/null +++ b/macos/Sources/ReverseAPI/App/ReverseAPIApp.swift @@ -0,0 +1,60 @@ +import SwiftUI + +@main +struct ReverseAPIApp: App { + @State private var session = AppSession.live() + + var body: some Scene { + Window("ReverseAPI", id: "main") { + switch session { + case .ready(let state): + ContentView() + .environment(state) + .frame(minWidth: 1100, minHeight: 700) + case .failed(let error): + BootFailureView(error: error) + .frame(minWidth: 500, minHeight: 300) + } + } + .windowStyle(.titleBar) + .windowToolbarStyle(.unifiedCompact) + .commands { + CommandGroup(replacing: .newItem) {} + } + } +} + +enum AppSession { + case ready(AppState) + case failed(Error) + + @MainActor + static func live() -> AppSession { + do { + return .ready(try AppState()) + } catch { + return .failed(error) + } + } +} + +struct BootFailureView: View { + let error: Error + + var body: some View { + VStack(spacing: 16) { + Image(systemName: "exclamationmark.triangle.fill") + .font(.system(size: 48)) + .foregroundStyle(.red) + Text("ReverseAPI failed to start") + .font(.title2) + .bold() + Text("\(error)") + .font(.system(.body, design: .monospaced)) + .foregroundStyle(.secondary) + .textSelection(.enabled) + .multilineTextAlignment(.center) + } + .padding(40) + } +} diff --git a/macos/Sources/ReverseAPI/Helpers/JSONFormatter.swift b/macos/Sources/ReverseAPI/Helpers/JSONFormatter.swift new file mode 100644 index 0000000..f00bf7f --- /dev/null +++ b/macos/Sources/ReverseAPI/Helpers/JSONFormatter.swift @@ -0,0 +1,24 @@ +import Foundation + +enum JSONFormatter { + static func prettyPrintJSON(_ data: Data, contentType: String?) -> String? { + if let contentType, !contentType.lowercased().contains("json") { + if !looksLikeJSON(data) { return nil } + } + guard !data.isEmpty else { return nil } + do { + let object = try JSONSerialization.jsonObject(with: data, options: [.fragmentsAllowed]) + let prettified = try JSONSerialization.data(withJSONObject: object, options: [.prettyPrinted, .sortedKeys]) + return String(data: prettified, encoding: .utf8) + } catch { + return nil + } + } + + private static func looksLikeJSON(_ data: Data) -> Bool { + guard let first = data.first(where: { !($0 == 0x20 || $0 == 0x09 || $0 == 0x0A || $0 == 0x0D) }) else { + return false + } + return first == 0x7B || first == 0x5B + } +} diff --git a/macos/Sources/ReverseAPI/Storage/FlowStore.swift b/macos/Sources/ReverseAPI/Storage/FlowStore.swift new file mode 100644 index 0000000..d343973 --- /dev/null +++ b/macos/Sources/ReverseAPI/Storage/FlowStore.swift @@ -0,0 +1,115 @@ +import Foundation +import Observation +import GRDB +import ReverseAPIProxy + +@MainActor +@Observable +public final class FlowStore { + public private(set) var flows: [CapturedFlow] = [] + public private(set) var isReady = false + + private let database: DatabaseQueue + private var subscription: Task? + + public init(databaseURL: URL) throws { + let parent = databaseURL.deletingLastPathComponent() + try FileManager.default.createDirectory(at: parent, withIntermediateDirectories: true) + var config = Configuration() + config.label = "reverseapi.flows" + self.database = try DatabaseQueue(path: databaseURL.path, configuration: config) + try Self.migrate(database) + try loadInitial() + isReady = true + } + + public func subscribe(to bus: FlowBus) { + subscription?.cancel() + subscription = Task { @MainActor [weak self] in + guard let self else { return } + for await event in await bus.subscribe() { + self.handle(event) + } + } + } + + public func clear() throws { + try database.write { db in + _ = try PersistedFlow.deleteAll(db) + } + flows.removeAll() + } + + public func flow(id: UUID) -> CapturedFlow? { + flows.first(where: { $0.id == id }) + } + + private func handle(_ event: FlowEvent) { + switch event { + case .started(let flow): + insertOrUpdate(flow) + case .updated(let flow): + insertOrUpdate(flow) + case .finished(let flow): + insertOrUpdate(flow) + persist(flow) + } + } + + private func insertOrUpdate(_ flow: CapturedFlow) { + if let index = flows.firstIndex(where: { $0.id == flow.id }) { + flows[index] = flow + } else { + flows.insert(flow, at: 0) + } + } + + private func persist(_ flow: CapturedFlow) { + let record: PersistedFlow + do { + record = try PersistedFlow(from: flow) + } catch { + return + } + Task.detached(priority: .utility) { [database] in + try? await database.write { db in + try record.save(db) + } + } + } + + private func loadInitial() throws { + let records = try database.read { db in + try PersistedFlow + .order(PersistedFlow.Columns.startedAt.desc) + .limit(500) + .fetchAll(db) + } + flows = records.compactMap { try? $0.toCapturedFlow() } + } + + private static func migrate(_ database: DatabaseQueue) throws { + var migrator = DatabaseMigrator() + migrator.registerMigration("v1") { db in + try db.create(table: "flow") { t in + t.column("id", .text).primaryKey() + t.column("scheme", .text).notNull() + t.column("method", .text).notNull() + t.column("host", .text).notNull() + t.column("port", .integer).notNull() + t.column("path", .text).notNull() + t.column("requestHeadersJSON", .blob).notNull() + t.column("requestBody", .blob).notNull() + t.column("responseStatus", .integer) + t.column("responseHeadersJSON", .blob) + t.column("responseBody", .blob).notNull() + t.column("startedAt", .double).notNull() + t.column("finishedAt", .double) + t.column("errorMessage", .text) + } + try db.create(index: "idx_flow_host", on: "flow", columns: ["host"]) + try db.create(index: "idx_flow_startedAt", on: "flow", columns: ["startedAt"]) + } + try migrator.migrate(database) + } +} diff --git a/macos/Sources/ReverseAPI/Storage/PersistedFlow.swift b/macos/Sources/ReverseAPI/Storage/PersistedFlow.swift new file mode 100644 index 0000000..ff1c641 --- /dev/null +++ b/macos/Sources/ReverseAPI/Storage/PersistedFlow.swift @@ -0,0 +1,93 @@ +import Foundation +import GRDB +import ReverseAPIProxy + +struct PersistedFlow: Codable, FetchableRecord, PersistableRecord, Identifiable { + static let databaseTableName = "flow" + + var id: String + var scheme: String + var method: String + var host: String + var port: Int + var path: String + var requestHeadersJSON: Data + var requestBody: Data + var responseStatus: Int? + var responseHeadersJSON: Data? + var responseBody: Data + var startedAt: Double + var finishedAt: Double? + var errorMessage: String? + + enum Columns { + static let id = Column("id") + static let startedAt = Column("startedAt") + } +} + +enum FlowConversionError: Error { + case invalidUUID + case invalidScheme +} + +extension PersistedFlow { + init(from flow: CapturedFlow) throws { + let encoder = JSONEncoder() + let requestPairs = flow.requestHeaders.map { [$0.name, $0.value] } + let responsePairs = flow.responseHeaders.map { [$0.name, $0.value] } + self.init( + id: flow.id.uuidString, + scheme: flow.scheme.rawValue, + method: flow.method, + host: flow.host, + port: flow.port, + path: flow.path, + requestHeadersJSON: try encoder.encode(requestPairs), + requestBody: flow.requestBody, + responseStatus: flow.responseStatus, + responseHeadersJSON: flow.responseHeaders.isEmpty ? nil : try encoder.encode(responsePairs), + responseBody: flow.responseBody, + startedAt: flow.startedAt.timeIntervalSince1970, + finishedAt: flow.finishedAt?.timeIntervalSince1970, + errorMessage: flow.error + ) + } + + func toCapturedFlow() throws -> CapturedFlow { + guard let uuid = UUID(uuidString: id) else { throw FlowConversionError.invalidUUID } + guard let parsedScheme = CapturedFlow.Scheme(rawValue: scheme) else { throw FlowConversionError.invalidScheme } + let decoder = JSONDecoder() + let requestPairs = (try? decoder.decode([[String]].self, from: requestHeadersJSON)) ?? [] + let responsePairs: [[String]] + if let data = responseHeadersJSON, + let decoded = try? decoder.decode([[String]].self, from: data) { + responsePairs = decoded + } else { + responsePairs = [] + } + var flow = CapturedFlow( + id: uuid, + scheme: parsedScheme, + method: method, + host: host, + port: port, + path: path, + requestHeaders: requestPairs.compactMap { pair in + guard pair.count == 2 else { return nil } + return HTTPHeader(pair[0], pair[1]) + }, + startedAt: Date(timeIntervalSince1970: startedAt) + ) + flow.requestBody = requestBody + flow.responseStatus = responseStatus + flow.responseHeaders = responsePairs.compactMap { pair in + guard pair.count == 2 else { return nil } + return HTTPHeader(pair[0], pair[1]) + } + flow.responseBody = responseBody + flow.finishedAt = finishedAt.map { Date(timeIntervalSince1970: $0) } + flow.error = errorMessage + return flow + } +} diff --git a/macos/Sources/ReverseAPI/UI/CaptureToolbar.swift b/macos/Sources/ReverseAPI/UI/CaptureToolbar.swift new file mode 100644 index 0000000..8477ef8 --- /dev/null +++ b/macos/Sources/ReverseAPI/UI/CaptureToolbar.swift @@ -0,0 +1,94 @@ +import SwiftUI + +struct CaptureToolbar: View { + @Environment(AppState.self) private var state + + var body: some View { + HStack(spacing: 12) { + captureButton + Divider().frame(height: 18) + trustButton + systemProxyButton + Spacer() + statusText + Button("Clear", systemImage: "trash") { + state.clearFlows() + } + .buttonStyle(.borderless) + .help("Remove all captured flows") + } + .padding(.horizontal, 16) + .padding(.vertical, 10) + .background(.bar) + } + + private var captureButton: some View { + Button { + Task { await state.toggleCapture() } + } label: { + Label( + state.isCapturing ? "Capturing" : "Start capture", + systemImage: state.isCapturing ? "stop.circle.fill" : "record.circle" + ) + .foregroundStyle(state.isCapturing ? Color.red : Color.primary) + .font(.headline) + } + .buttonStyle(.borderedProminent) + .tint(state.isCapturing ? .red.opacity(0.85) : .accentColor) + } + + private var trustButton: some View { + Button { + Task { + if state.caTrustInstalled { + await state.uninstallCATrust() + } else { + await state.installCATrust() + } + } + } label: { + Label( + state.caTrustInstalled ? "CA trusted" : "Install CA", + systemImage: state.caTrustInstalled ? "checkmark.seal.fill" : "seal" + ) + } + .buttonStyle(.bordered) + .help(state.caTrustInstalled + ? "Remove the ReverseAPI root certificate from the user keychain" + : "Install the ReverseAPI root certificate as trusted for the current user") + } + + private var systemProxyButton: some View { + Button { + Task { + if state.systemProxyEnabled { + await state.disableSystemProxy() + } else { + await state.enableSystemProxy() + } + } + } label: { + Label( + state.systemProxyEnabled ? "System proxy on" : "System proxy off", + systemImage: state.systemProxyEnabled ? "network.badge.shield.half.filled" : "network" + ) + } + .buttonStyle(.bordered) + .help("Toggle macOS HTTP/HTTPS proxy on every active network service") + } + + private var statusText: some View { + Group { + if let error = state.lastError { + Label(error, systemImage: "exclamationmark.triangle.fill") + .foregroundStyle(.red) + .lineLimit(1) + .truncationMode(.middle) + } else { + Text("\(state.store.flows.count) flows · 127.0.0.1:\(state.port)") + .foregroundStyle(.secondary) + } + } + .font(.callout) + } +} diff --git a/macos/Sources/ReverseAPI/UI/ContentView.swift b/macos/Sources/ReverseAPI/UI/ContentView.swift new file mode 100644 index 0000000..c5b83e1 --- /dev/null +++ b/macos/Sources/ReverseAPI/UI/ContentView.swift @@ -0,0 +1,20 @@ +import SwiftUI +import ReverseAPIProxy + +struct ContentView: View { + @Environment(AppState.self) private var state + + var body: some View { + VStack(spacing: 0) { + CaptureToolbar() + Divider() + HSplitView { + TrafficListView() + .frame(minWidth: 520) + InspectorView() + .frame(minWidth: 420) + } + } + .background(.background) + } +} diff --git a/macos/Sources/ReverseAPI/UI/InspectorView.swift b/macos/Sources/ReverseAPI/UI/InspectorView.swift new file mode 100644 index 0000000..bd15f36 --- /dev/null +++ b/macos/Sources/ReverseAPI/UI/InspectorView.swift @@ -0,0 +1,209 @@ +import SwiftUI +import ReverseAPIProxy + +struct InspectorView: View { + @Environment(AppState.self) private var state + + var body: some View { + if let id = state.selectedFlowID, let flow = state.store.flow(id: id) { + FlowInspector(flow: flow) + } else { + ContentUnavailableView { + Label("No flow selected", systemImage: "tray") + } description: { + Text("Pick a request from the list to inspect it.") + } + } + } +} + +private struct FlowInspector: View { + let flow: CapturedFlow + @State private var tab: InspectorTab = .request + + enum InspectorTab: String, CaseIterable, Identifiable { + case overview = "Overview" + case request = "Request" + case response = "Response" + var id: String { rawValue } + } + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + header + Picker("", selection: $tab) { + ForEach(InspectorTab.allCases) { tab in + Text(tab.rawValue).tag(tab) + } + } + .pickerStyle(.segmented) + .padding(.horizontal, 14) + .padding(.vertical, 10) + Divider() + ScrollView { + content + .frame(maxWidth: .infinity, alignment: .leading) + .padding(14) + } + } + } + + private var header: some View { + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 8) { + Text(flow.method) + .font(.system(.callout, design: .monospaced).weight(.semibold)) + if let status = flow.responseStatus { + Text("\(status)") + .font(.system(.callout, design: .monospaced).weight(.semibold)) + .foregroundStyle(.secondary) + } + Spacer() + if let finishedAt = flow.finishedAt { + Text(formatDuration(flow.startedAt, finishedAt)) + .foregroundStyle(.secondary) + .font(.callout) + } + } + Text(flow.url) + .font(.system(.body, design: .monospaced)) + .textSelection(.enabled) + .lineLimit(2) + .truncationMode(.middle) + if let error = flow.error { + Label(error, systemImage: "exclamationmark.octagon.fill") + .foregroundStyle(.red) + .font(.callout) + } + } + .padding(.horizontal, 14) + .padding(.top, 12) + .padding(.bottom, 6) + } + + @ViewBuilder + private var content: some View { + switch tab { + case .overview: + overview + case .request: + HeadersSection(title: "Request headers", headers: flow.requestHeaders) + BodySection(title: "Request body", body: flow.requestBody, headers: flow.requestHeaders) + case .response: + HeadersSection(title: "Response headers", headers: flow.responseHeaders) + BodySection(title: "Response body", body: flow.responseBody, headers: flow.responseHeaders) + } + } + + private var overview: some View { + VStack(alignment: .leading, spacing: 8) { + row("Scheme", flow.scheme.rawValue) + row("Host", "\(flow.host):\(flow.port)") + row("Path", flow.path) + row("Method", flow.method) + row("Status", flow.responseStatus.map(String.init) ?? "—") + row("Started", flow.startedAt.formatted(date: .abbreviated, time: .standard)) + row("Finished", flow.finishedAt?.formatted(date: .abbreviated, time: .standard) ?? "—") + row("Request size", "\(flow.requestBody.count) bytes") + row("Response size", "\(flow.responseBody.count) bytes") + } + } + + private func row(_ key: String, _ value: String) -> some View { + HStack(alignment: .firstTextBaseline, spacing: 12) { + Text(key) + .frame(width: 120, alignment: .leading) + .foregroundStyle(.secondary) + Text(value) + .textSelection(.enabled) + .font(.system(.body, design: .monospaced)) + } + } + + private func formatDuration(_ start: Date, _ end: Date) -> String { + let interval = end.timeIntervalSince(start) + if interval < 1 { return String(format: "%.0f ms", interval * 1000) } + return String(format: "%.2f s", interval) + } +} + +private struct HeadersSection: View { + let title: String + let headers: [HTTPHeader] + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + Text(title) + .font(.headline) + if headers.isEmpty { + Text("No headers") + .foregroundStyle(.tertiary) + .font(.callout) + } else { + ForEach(Array(headers.enumerated()), id: \.offset) { _, header in + HStack(alignment: .firstTextBaseline, spacing: 12) { + Text(header.name) + .font(.system(.callout, design: .monospaced).weight(.semibold)) + .frame(width: 160, alignment: .leading) + Text(header.value) + .font(.system(.callout, design: .monospaced)) + .textSelection(.enabled) + .lineLimit(nil) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + } + } + .padding(.bottom, 16) + } +} + +private struct BodySection: View { + let title: String + let body: Data + let headers: [HTTPHeader] + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + Text(title) + .font(.headline) + if body.isEmpty { + Text("Empty body") + .foregroundStyle(.tertiary) + .font(.callout) + } else if let pretty = JSONFormatter.prettyPrintJSON(body, contentType: contentType) { + CodeBlock(text: pretty) + } else if let text = String(data: body, encoding: .utf8), looksLikeText { + CodeBlock(text: text) + } else { + Text("Binary content · \(body.count) bytes") + .foregroundStyle(.secondary) + .font(.callout) + } + } + } + + private var contentType: String? { + headers.first(where: { $0.name.lowercased() == "content-type" })?.value + } + + private var looksLikeText: Bool { + guard let ct = contentType?.lowercased() else { return false } + return ct.contains("text") || ct.contains("xml") || ct.contains("javascript") || ct.contains("html") + } +} + +private struct CodeBlock: View { + let text: String + + var body: some View { + ScrollView(.horizontal, showsIndicators: true) { + Text(text) + .font(.system(.callout, design: .monospaced)) + .textSelection(.enabled) + .padding(10) + .frame(maxWidth: .infinity, alignment: .leading) + } + .background(.quaternary.opacity(0.5), in: RoundedRectangle(cornerRadius: 6)) + } +} diff --git a/macos/Sources/ReverseAPI/UI/TrafficFilter.swift b/macos/Sources/ReverseAPI/UI/TrafficFilter.swift new file mode 100644 index 0000000..1a94075 --- /dev/null +++ b/macos/Sources/ReverseAPI/UI/TrafficFilter.swift @@ -0,0 +1,49 @@ +import Foundation +import ReverseAPIProxy + +struct TrafficFilter: Equatable { + var search: String = "" + var hosts: Set = [] + var methods: Set = [] + var statusBuckets: Set = [] + var onlyErrors: Bool = false + + enum StatusBucket: String, CaseIterable, Identifiable, Hashable { + case informational = "1xx" + case success = "2xx" + case redirect = "3xx" + case clientError = "4xx" + case serverError = "5xx" + + var id: String { rawValue } + + func contains(_ status: Int) -> Bool { + switch self { + case .informational: return (100..<200).contains(status) + case .success: return (200..<300).contains(status) + case .redirect: return (300..<400).contains(status) + case .clientError: return (400..<500).contains(status) + case .serverError: return (500..<600).contains(status) + } + } + } + + func matches(_ flow: CapturedFlow) -> Bool { + if onlyErrors { + if flow.error == nil, !(flow.responseStatus.map { $0 >= 400 } ?? false) { + return false + } + } + if !search.isEmpty { + let haystack = "\(flow.method) \(flow.url)".lowercased() + if !haystack.contains(search.lowercased()) { return false } + } + if !hosts.isEmpty, !hosts.contains(flow.host) { return false } + if !methods.isEmpty, !methods.contains(flow.method) { return false } + if !statusBuckets.isEmpty { + guard let status = flow.responseStatus else { return false } + if !statusBuckets.contains(where: { $0.contains(status) }) { return false } + } + return true + } +} diff --git a/macos/Sources/ReverseAPI/UI/TrafficListView.swift b/macos/Sources/ReverseAPI/UI/TrafficListView.swift new file mode 100644 index 0000000..3461ad1 --- /dev/null +++ b/macos/Sources/ReverseAPI/UI/TrafficListView.swift @@ -0,0 +1,229 @@ +import SwiftUI +import ReverseAPIProxy + +struct TrafficListView: View { + @Environment(AppState.self) private var state + + var body: some View { + @Bindable var bindable = state + VStack(spacing: 0) { + FilterBar(filter: $bindable.filter, hostOptions: hostOptions, methodOptions: methodOptions) + Divider() + table + } + } + + private var filteredFlows: [CapturedFlow] { + state.store.flows.filter { state.filter.matches($0) } + } + + private var hostOptions: [String] { + Array(Set(state.store.flows.map(\.host))).sorted() + } + + private var methodOptions: [String] { + Array(Set(state.store.flows.map(\.method))).sorted() + } + + private var table: some View { + @Bindable var bindable = state + return Table(filteredFlows, selection: $bindable.selectedFlowID) { + TableColumn("Time") { flow in + Text(flow.startedAt, format: .dateTime.hour().minute().second()) + .font(.system(.callout, design: .monospaced)) + .foregroundStyle(.secondary) + } + .width(min: 70, ideal: 80) + + TableColumn("Method") { flow in + MethodBadge(method: flow.method) + } + .width(min: 60, ideal: 70) + + TableColumn("Host") { flow in + Text(flow.host) + .lineLimit(1) + .truncationMode(.middle) + } + .width(min: 120, ideal: 200) + + TableColumn("Path") { flow in + Text(flow.path) + .lineLimit(1) + .truncationMode(.middle) + .font(.system(.callout, design: .monospaced)) + } + .width(min: 160, ideal: 320) + + TableColumn("Status") { flow in + StatusBadge(status: flow.responseStatus, error: flow.error) + } + .width(min: 60, ideal: 70) + + TableColumn("Size") { flow in + Text(byteString(flow.responseBody.count)) + .font(.system(.callout, design: .monospaced)) + .foregroundStyle(.secondary) + } + .width(min: 60, ideal: 80) + + TableColumn("Duration") { flow in + Text(durationString(flow)) + .font(.system(.callout, design: .monospaced)) + .foregroundStyle(.secondary) + } + .width(min: 70, ideal: 80) + } + } + + private func byteString(_ count: Int) -> String { + let formatter = ByteCountFormatter() + formatter.countStyle = .file + return formatter.string(fromByteCount: Int64(count)) + } + + private func durationString(_ flow: CapturedFlow) -> String { + guard let finished = flow.finishedAt else { return "…" } + let interval = finished.timeIntervalSince(flow.startedAt) + if interval < 1 { + return String(format: "%.0f ms", interval * 1000) + } + return String(format: "%.2f s", interval) + } +} + +private struct FilterBar: View { + @Binding var filter: TrafficFilter + let hostOptions: [String] + let methodOptions: [String] + + var body: some View { + HStack(spacing: 12) { + HStack(spacing: 8) { + Image(systemName: "magnifyingglass") + .foregroundStyle(.secondary) + TextField("Filter by URL or method", text: $filter.search) + .textFieldStyle(.plain) + } + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background(.quaternary, in: RoundedRectangle(cornerRadius: 6)) + .frame(maxWidth: 320) + + Menu { + Toggle("Errors only", isOn: $filter.onlyErrors) + Divider() + Section("Methods") { + ForEach(methodOptions, id: \.self) { method in + toggle(method, in: \.methods) + } + } + Section("Hosts") { + ForEach(hostOptions, id: \.self) { host in + toggle(host, in: \.hosts) + } + } + Section("Status") { + ForEach(TrafficFilter.StatusBucket.allCases) { bucket in + toggleBucket(bucket) + } + } + Divider() + Button("Reset") { filter = TrafficFilter() } + } label: { + Label("Filters", systemImage: "line.3.horizontal.decrease") + } + .menuStyle(.borderlessButton) + .fixedSize() + + Spacer() + } + .padding(.horizontal, 14) + .padding(.vertical, 8) + } + + private func toggle(_ value: String, in keyPath: WritableKeyPath>) -> some View { + Button { + if filter[keyPath: keyPath].contains(value) { + filter[keyPath: keyPath].remove(value) + } else { + filter[keyPath: keyPath].insert(value) + } + } label: { + HStack { + Image(systemName: filter[keyPath: keyPath].contains(value) ? "checkmark.square.fill" : "square") + Text(value) + } + } + } + + private func toggleBucket(_ bucket: TrafficFilter.StatusBucket) -> some View { + Button { + if filter.statusBuckets.contains(bucket) { + filter.statusBuckets.remove(bucket) + } else { + filter.statusBuckets.insert(bucket) + } + } label: { + HStack { + Image(systemName: filter.statusBuckets.contains(bucket) ? "checkmark.square.fill" : "square") + Text(bucket.rawValue) + } + } + } +} + +private struct MethodBadge: View { + let method: String + + var body: some View { + Text(method) + .font(.system(.caption, design: .monospaced).weight(.semibold)) + .foregroundStyle(color) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(color.opacity(0.15), in: RoundedRectangle(cornerRadius: 4)) + } + + private var color: Color { + switch method { + case "GET": return .blue + case "POST": return .green + case "PUT", "PATCH": return .orange + case "DELETE": return .red + case "CONNECT": return .purple + default: return .secondary + } + } +} + +private struct StatusBadge: View { + let status: Int? + let error: String? + + var body: some View { + if let error { + Text("ERR") + .font(.system(.caption, design: .monospaced).weight(.semibold)) + .foregroundStyle(Color.red) + .help(error) + } else if let status { + Text("\(status)") + .font(.system(.callout, design: .monospaced).weight(.medium)) + .foregroundStyle(color(for: status)) + } else { + Text("…") + .foregroundStyle(.tertiary) + } + } + + private func color(for status: Int) -> Color { + switch status { + case 200..<300: return .green + case 300..<400: return .blue + case 400..<500: return .orange + case 500..<600: return .red + default: return .secondary + } + } +} diff --git a/macos/Sources/ReverseAPIProxy/ProxyEngine.swift b/macos/Sources/ReverseAPIProxy/ProxyEngine.swift index 6a2511d..e977700 100644 --- a/macos/Sources/ReverseAPIProxy/ProxyEngine.swift +++ b/macos/Sources/ReverseAPIProxy/ProxyEngine.swift @@ -64,7 +64,11 @@ public final class ProxyEngine: @unchecked Sendable { } catch ChannelError.alreadyClosed { logger.info("proxy channel already closed") } - try await group.shutdownGracefully() logger.info("proxy stopped") } + + public func terminate() async throws { + try await stop() + try await group.shutdownGracefully() + } } From 5b329d69146982ea00a2b4188424dbf538ce3dfd Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 19 May 2026 12:42:57 +0000 Subject: [PATCH 02/13] M3 review fixes + tests Fixes for PR #74 review comments (cubic): - FlowStore: protect against the persist/clear race. Each clear() bumps a generation counter; in-flight persistence tasks check the counter inside the database.write closure and skip the save if the user has cleared since the task was scheduled. Without this, a "Clear" click could be silently undone by a still-pending insert that landed afterwards. - New thread-safe GenerationCounter helper (NSLock-backed). Tests: - TrafficFilterTests: 10 cases covering search, host/method/status bucket filters, errors-only, status bucket boundaries - JSONFormatterTests: empty / valid JSON / non-JSON content types / shape detection / leading whitespace / invalid JSON - Package.swift: ReverseAPITests testTarget --- macos/Package.swift | 4 + .../ReverseAPI/Storage/FlowStore.swift | 31 +++++-- .../ReverseAPITests/JSONFormatterTests.swift | 38 +++++++++ .../ReverseAPITests/TrafficFilterTests.swift | 82 +++++++++++++++++++ 4 files changed, 149 insertions(+), 6 deletions(-) create mode 100644 macos/Tests/ReverseAPITests/JSONFormatterTests.swift create mode 100644 macos/Tests/ReverseAPITests/TrafficFilterTests.swift diff --git a/macos/Package.swift b/macos/Package.swift index b7789aa..7e05037 100644 --- a/macos/Package.swift +++ b/macos/Package.swift @@ -47,5 +47,9 @@ let package = Package( name: "ReverseAPIProxyTests", dependencies: ["ReverseAPIProxy"] ), + .testTarget( + name: "ReverseAPITests", + dependencies: ["ReverseAPI"] + ), ] ) diff --git a/macos/Sources/ReverseAPI/Storage/FlowStore.swift b/macos/Sources/ReverseAPI/Storage/FlowStore.swift index d343973..02d990a 100644 --- a/macos/Sources/ReverseAPI/Storage/FlowStore.swift +++ b/macos/Sources/ReverseAPI/Storage/FlowStore.swift @@ -10,6 +10,7 @@ public final class FlowStore { public private(set) var isReady = false private let database: DatabaseQueue + private let generation = GenerationCounter() private var subscription: Task? public init(databaseURL: URL) throws { @@ -34,6 +35,7 @@ public final class FlowStore { } public func clear() throws { + _ = generation.bump() try database.write { db in _ = try PersistedFlow.deleteAll(db) } @@ -65,14 +67,12 @@ public final class FlowStore { } private func persist(_ flow: CapturedFlow) { - let record: PersistedFlow - do { - record = try PersistedFlow(from: flow) - } catch { - return - } + guard let record = try? PersistedFlow(from: flow) else { return } + let snapshot = generation.value + let counter = generation Task.detached(priority: .utility) { [database] in try? await database.write { db in + guard counter.value == snapshot else { return } try record.save(db) } } @@ -113,3 +113,22 @@ public final class FlowStore { try migrator.migrate(database) } } + +final class GenerationCounter: @unchecked Sendable { + private let lock = NSLock() + private var current: Int = 0 + + var value: Int { + lock.lock() + defer { lock.unlock() } + return current + } + + @discardableResult + func bump() -> Int { + lock.lock() + defer { lock.unlock() } + current += 1 + return current + } +} diff --git a/macos/Tests/ReverseAPITests/JSONFormatterTests.swift b/macos/Tests/ReverseAPITests/JSONFormatterTests.swift new file mode 100644 index 0000000..88a7773 --- /dev/null +++ b/macos/Tests/ReverseAPITests/JSONFormatterTests.swift @@ -0,0 +1,38 @@ +import XCTest +@testable import ReverseAPI + +final class JSONFormatterTests: XCTestCase { + func testReturnsNilForEmptyData() { + XCTAssertNil(JSONFormatter.prettyPrintJSON(Data(), contentType: "application/json")) + } + + func testFormatsValidJSON() { + let data = Data("{\"b\":1,\"a\":2}".utf8) + let pretty = JSONFormatter.prettyPrintJSON(data, contentType: "application/json") + XCTAssertNotNil(pretty) + XCTAssertTrue(pretty?.contains("\"a\" : 2") == true || pretty?.contains("\"a\": 2") == true) + } + + func testReturnsNilForNonJSONContentTypeAndNonJSONShape() { + let data = Data("not json".utf8) + let pretty = JSONFormatter.prettyPrintJSON(data, contentType: "text/plain") + XCTAssertNil(pretty) + } + + func testFallsBackToShapeDetectionWithoutContentType() { + let data = Data("[1,2,3]".utf8) + let pretty = JSONFormatter.prettyPrintJSON(data, contentType: nil) + XCTAssertNotNil(pretty) + } + + func testHandlesLeadingWhitespace() { + let data = Data(" \n {\"x\":1}".utf8) + let pretty = JSONFormatter.prettyPrintJSON(data, contentType: nil) + XCTAssertNotNil(pretty) + } + + func testReturnsNilForInvalidJSON() { + let data = Data("{not valid}".utf8) + XCTAssertNil(JSONFormatter.prettyPrintJSON(data, contentType: "application/json")) + } +} diff --git a/macos/Tests/ReverseAPITests/TrafficFilterTests.swift b/macos/Tests/ReverseAPITests/TrafficFilterTests.swift new file mode 100644 index 0000000..1292a85 --- /dev/null +++ b/macos/Tests/ReverseAPITests/TrafficFilterTests.swift @@ -0,0 +1,82 @@ +import XCTest +import ReverseAPIProxy +@testable import ReverseAPI + +final class TrafficFilterTests: XCTestCase { + private func make(method: String = "GET", host: String = "api.example.com", path: String = "/users", status: Int? = 200, error: String? = nil) -> CapturedFlow { + var flow = CapturedFlow(scheme: .https, method: method, host: host, port: 443, path: path) + flow.responseStatus = status + flow.error = error + return flow + } + + func testEmptyFilterMatchesEverything() { + let filter = TrafficFilter() + XCTAssertTrue(filter.matches(make())) + } + + func testSearchMatchesURLSubstring() { + var filter = TrafficFilter() + filter.search = "users" + XCTAssertTrue(filter.matches(make())) + XCTAssertFalse(filter.matches(make(path: "/posts"))) + } + + func testSearchIsCaseInsensitive() { + var filter = TrafficFilter() + filter.search = "USERS" + XCTAssertTrue(filter.matches(make())) + } + + func testHostFilter() { + var filter = TrafficFilter() + filter.hosts = ["api.example.com"] + XCTAssertTrue(filter.matches(make())) + XCTAssertFalse(filter.matches(make(host: "other.com"))) + } + + func testMethodFilter() { + var filter = TrafficFilter() + filter.methods = ["POST"] + XCTAssertFalse(filter.matches(make())) + XCTAssertTrue(filter.matches(make(method: "POST"))) + } + + func testStatusBucketSuccess() { + var filter = TrafficFilter() + filter.statusBuckets = [.success] + XCTAssertTrue(filter.matches(make(status: 200))) + XCTAssertFalse(filter.matches(make(status: 404))) + } + + func testStatusBucketClientError() { + var filter = TrafficFilter() + filter.statusBuckets = [.clientError] + XCTAssertTrue(filter.matches(make(status: 404))) + XCTAssertFalse(filter.matches(make(status: 200))) + } + + func testOnlyErrorsFiltersBy4xxOr5xxOrError() { + var filter = TrafficFilter() + filter.onlyErrors = true + XCTAssertTrue(filter.matches(make(status: 500))) + XCTAssertTrue(filter.matches(make(status: 400))) + XCTAssertTrue(filter.matches(make(status: nil, error: "boom"))) + XCTAssertFalse(filter.matches(make(status: 200))) + } + + func testStatusBucketRequiresResponseStatus() { + var filter = TrafficFilter() + filter.statusBuckets = [.success] + XCTAssertFalse(filter.matches(make(status: nil))) + } + + func testStatusBucketContainsBoundaries() { + XCTAssertTrue(TrafficFilter.StatusBucket.success.contains(200)) + XCTAssertTrue(TrafficFilter.StatusBucket.success.contains(299)) + XCTAssertFalse(TrafficFilter.StatusBucket.success.contains(300)) + XCTAssertTrue(TrafficFilter.StatusBucket.redirect.contains(301)) + XCTAssertTrue(TrafficFilter.StatusBucket.clientError.contains(404)) + XCTAssertTrue(TrafficFilter.StatusBucket.serverError.contains(503)) + } +} From c27a3ee36ecbd0a47006e92686f08d0d084b9cea Mon Sep 17 00:00:00 2001 From: kalil0321 Date: Tue, 19 May 2026 18:53:03 +0200 Subject: [PATCH 03/13] fix(macos): repair M3 SwiftUI build --- macos/Sources/ReverseAPI/App/ReverseAPIApp.swift | 2 +- macos/Sources/ReverseAPI/UI/InspectorView.swift | 14 +++++++------- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/macos/Sources/ReverseAPI/App/ReverseAPIApp.swift b/macos/Sources/ReverseAPI/App/ReverseAPIApp.swift index 9ad56e3..403f2f1 100644 --- a/macos/Sources/ReverseAPI/App/ReverseAPIApp.swift +++ b/macos/Sources/ReverseAPI/App/ReverseAPIApp.swift @@ -49,7 +49,7 @@ struct BootFailureView: View { Text("ReverseAPI failed to start") .font(.title2) .bold() - Text("\(error)") + Text(String(describing: error)) .font(.system(.body, design: .monospaced)) .foregroundStyle(.secondary) .textSelection(.enabled) diff --git a/macos/Sources/ReverseAPI/UI/InspectorView.swift b/macos/Sources/ReverseAPI/UI/InspectorView.swift index bd15f36..f3903a3 100644 --- a/macos/Sources/ReverseAPI/UI/InspectorView.swift +++ b/macos/Sources/ReverseAPI/UI/InspectorView.swift @@ -88,10 +88,10 @@ private struct FlowInspector: View { overview case .request: HeadersSection(title: "Request headers", headers: flow.requestHeaders) - BodySection(title: "Request body", body: flow.requestBody, headers: flow.requestHeaders) + BodySection(title: "Request body", bodyData: flow.requestBody, headers: flow.requestHeaders) case .response: HeadersSection(title: "Response headers", headers: flow.responseHeaders) - BodySection(title: "Response body", body: flow.responseBody, headers: flow.responseHeaders) + BodySection(title: "Response body", bodyData: flow.responseBody, headers: flow.responseHeaders) } } @@ -160,23 +160,23 @@ private struct HeadersSection: View { private struct BodySection: View { let title: String - let body: Data + let bodyData: Data let headers: [HTTPHeader] var body: some View { VStack(alignment: .leading, spacing: 8) { Text(title) .font(.headline) - if body.isEmpty { + if bodyData.isEmpty { Text("Empty body") .foregroundStyle(.tertiary) .font(.callout) - } else if let pretty = JSONFormatter.prettyPrintJSON(body, contentType: contentType) { + } else if let pretty = JSONFormatter.prettyPrintJSON(bodyData, contentType: contentType) { CodeBlock(text: pretty) - } else if let text = String(data: body, encoding: .utf8), looksLikeText { + } else if let text = String(data: bodyData, encoding: .utf8), looksLikeText { CodeBlock(text: text) } else { - Text("Binary content · \(body.count) bytes") + Text("Binary content · \(bodyData.count) bytes") .foregroundStyle(.secondary) .font(.callout) } From f76a7a3baf684493e552a7e864026c48868d61d5 Mon Sep 17 00:00:00 2001 From: kalil0321 Date: Tue, 19 May 2026 22:50:31 +0200 Subject: [PATCH 04/13] fix(macos): improve M3 capture UX --- macos/Sources/ReverseAPI/App/AppState.swift | 124 ++++++++++-- .../ReverseAPI/App/ReverseAPIApp.swift | 8 +- .../ReverseAPI/UI/CaptureToolbar.swift | 191 ++++++++++++++---- macos/Sources/ReverseAPI/UI/ContentView.swift | 20 +- .../Sources/ReverseAPI/UI/InspectorView.swift | 55 ++++- .../ReverseAPI/UI/TrafficListView.swift | 101 ++++++++- .../System/SystemProxyController.swift | 81 +++----- 7 files changed, 461 insertions(+), 119 deletions(-) diff --git a/macos/Sources/ReverseAPI/App/AppState.swift b/macos/Sources/ReverseAPI/App/AppState.swift index b5ddbea..5e33a37 100644 --- a/macos/Sources/ReverseAPI/App/AppState.swift +++ b/macos/Sources/ReverseAPI/App/AppState.swift @@ -5,13 +5,22 @@ import ReverseAPIProxy @MainActor @Observable final class AppState { + enum CaptureMode: String, CaseIterable, Identifiable { + case device = "Device" + case manual = "Manual" + + var id: String { rawValue } + } + private(set) var isCapturing = false private(set) var systemProxyEnabled = false private(set) var caTrustInstalled = false + private(set) var isWorking = false private(set) var lastError: String? var selectedFlowID: UUID? var filter = TrafficFilter() + var captureMode: CaptureMode = .device let store: FlowStore let engine: ProxyEngine @@ -23,6 +32,8 @@ final class AppState { let caPEM: String let caPath: String + private var proxySnapshot: [ProxyServiceSnapshot]? + init(port: Int = 8888) throws { let appSupport = try FileManager.default.url( for: .applicationSupportDirectory, @@ -46,7 +57,7 @@ final class AppState { self.caPEM = try root.pem() self.caPath = caStore.certificateURL.path self.caTrustInstalled = installer.isInstalled(derBytes: self.caDER) - self.systemProxyEnabled = (try? systemProxy.isEnabled()) ?? false + self.systemProxyEnabled = (try? systemProxy.isEnabled(host: "127.0.0.1", port: port)) ?? false store.subscribe(to: engine.bus) } @@ -55,32 +66,71 @@ final class AppState { if isCapturing { await stopCapture() } else { - await startCapture() + await startCapture(mode: captureMode) } } - func startCapture() async { - guard !isCapturing else { return } + func startCapture(mode: CaptureMode) async { + guard !isCapturing, !isWorking else { return } + isWorking = true + defer { isWorking = false } + do { try await engine.start() + + if mode == .device { + do { + try await applySystemProxy() + } catch { + try? await engine.stop() + throw error + } + } + isCapturing = true lastError = nil } catch { - lastError = "Failed to start proxy: \(error)" + isCapturing = false + lastError = "Could not start capture: \(error)" } } func stopCapture() async { - guard isCapturing else { return } + guard isCapturing, !isWorking else { return } + isWorking = true + defer { isWorking = false } + + var stopError: Error? + if proxySnapshot != nil { + do { + try await restoreSystemProxy() + } catch { + stopError = error + } + } + do { try await engine.stop() isCapturing = false } catch { - lastError = "Failed to stop proxy: \(error)" + stopError = error + } + + if let stopError { + if proxySnapshot != nil { + systemProxyEnabled = (try? systemProxy.isEnabled(host: "127.0.0.1", port: port)) ?? false + } + lastError = "Could not stop capture cleanly: \(stopError)" + } else { + lastError = nil } } func installCATrust() async { + guard !isWorking else { return } + isWorking = true + defer { isWorking = false } + do { let installer = self.installer let der = self.caDER @@ -88,12 +138,17 @@ final class AppState { try installer.install(derBytes: der) }.value caTrustInstalled = true + lastError = nil } catch { lastError = "Failed to install CA trust: \(error)" } } func uninstallCATrust() async { + guard !isWorking else { return } + isWorking = true + defer { isWorking = false } + do { let installer = self.installer let der = self.caDER @@ -101,31 +156,33 @@ final class AppState { try installer.uninstall(derBytes: der) }.value caTrustInstalled = false + lastError = nil } catch { lastError = "Failed to uninstall CA trust: \(error)" } } func enableSystemProxy() async { + guard !isWorking else { return } + isWorking = true + defer { isWorking = false } + do { - let systemProxy = self.systemProxy - let port = self.port - try await Task.detached(priority: .userInitiated) { - try systemProxy.enable(host: "127.0.0.1", port: port) - }.value - systemProxyEnabled = true + try await applySystemProxy() + lastError = nil } catch { lastError = "Failed to enable system proxy: \(error)" } } func disableSystemProxy() async { + guard !isWorking else { return } + isWorking = true + defer { isWorking = false } + do { - let systemProxy = self.systemProxy - try await Task.detached(priority: .userInitiated) { - try systemProxy.disable() - }.value - systemProxyEnabled = false + try await restoreSystemProxy() + lastError = nil } catch { lastError = "Failed to disable system proxy: \(error)" } @@ -139,4 +196,35 @@ final class AppState { lastError = "Failed to clear flows: \(error)" } } + + private func applySystemProxy() async throws { + let systemProxy = self.systemProxy + let port = self.port + let snapshot = try await Task.detached(priority: .userInitiated) { + let snapshot = try systemProxy.snapshot() + do { + try systemProxy.enable(host: "127.0.0.1", port: port) + return snapshot + } catch { + try? systemProxy.restore(snapshot) + throw error + } + }.value + proxySnapshot = snapshot + systemProxyEnabled = true + } + + private func restoreSystemProxy() async throws { + let systemProxy = self.systemProxy + let snapshot = proxySnapshot + try await Task.detached(priority: .userInitiated) { + if let snapshot { + try systemProxy.restore(snapshot) + } else { + try systemProxy.disable() + } + }.value + proxySnapshot = nil + systemProxyEnabled = false + } } diff --git a/macos/Sources/ReverseAPI/App/ReverseAPIApp.swift b/macos/Sources/ReverseAPI/App/ReverseAPIApp.swift index 403f2f1..dc0a5d6 100644 --- a/macos/Sources/ReverseAPI/App/ReverseAPIApp.swift +++ b/macos/Sources/ReverseAPI/App/ReverseAPIApp.swift @@ -10,7 +10,13 @@ struct ReverseAPIApp: App { case .ready(let state): ContentView() .environment(state) - .frame(minWidth: 1100, minHeight: 700) + .frame( + minWidth: 1100, + maxWidth: .infinity, + minHeight: 700, + maxHeight: .infinity, + alignment: .topLeading + ) case .failed(let error): BootFailureView(error: error) .frame(minWidth: 500, minHeight: 300) diff --git a/macos/Sources/ReverseAPI/UI/CaptureToolbar.swift b/macos/Sources/ReverseAPI/UI/CaptureToolbar.swift index 8477ef8..49d7f2a 100644 --- a/macos/Sources/ReverseAPI/UI/CaptureToolbar.swift +++ b/macos/Sources/ReverseAPI/UI/CaptureToolbar.swift @@ -4,21 +4,70 @@ struct CaptureToolbar: View { @Environment(AppState.self) private var state var body: some View { - HStack(spacing: 12) { - captureButton - Divider().frame(height: 18) - trustButton - systemProxyButton - Spacer() - statusText - Button("Clear", systemImage: "trash") { - state.clearFlows() + @Bindable var bindable = state + + return VStack(alignment: .leading, spacing: 12) { + HStack(alignment: .firstTextBaseline, spacing: 16) { + VStack(alignment: .leading, spacing: 3) { + Text("ReverseAPI") + .font(.system(.title2, design: .rounded).weight(.semibold)) + Text(statusLine) + .font(.callout) + .foregroundStyle(.secondary) + .lineLimit(1) + } + + Spacer(minLength: 24) + + MetricPill(title: "Flows", value: "\(state.store.flows.count)") + MetricPill(title: "Proxy", value: ":\(state.port)") + ReadinessPill( + title: state.caTrustInstalled ? "CA trusted" : "CA not trusted", + systemImage: state.caTrustInstalled ? "checkmark.seal.fill" : "seal", + tint: state.caTrustInstalled ? .green : .orange + ) } - .buttonStyle(.borderless) - .help("Remove all captured flows") + + HStack(spacing: 10) { + Picker("Capture mode", selection: $bindable.captureMode) { + ForEach(AppState.CaptureMode.allCases) { mode in + Text(mode.rawValue).tag(mode) + } + } + .labelsHidden() + .pickerStyle(.segmented) + .frame(width: 190) + .disabled(state.isCapturing || state.isWorking) + .help("Device captures traffic from this Mac. Manual only records clients explicitly configured to use 127.0.0.1:\(state.port).") + + captureButton + trustButton + systemProxyButton + + Spacer() + + if let error = state.lastError { + Label(error, systemImage: "exclamationmark.triangle.fill") + .font(.callout) + .foregroundStyle(.red) + .lineLimit(1) + .truncationMode(.middle) + .frame(maxWidth: 420, alignment: .trailing) + } + + Button("Clear", systemImage: "trash") { + state.clearFlows() + } + .buttonStyle(.borderless) + .disabled(state.store.flows.isEmpty || state.isWorking) + .help("Remove captured flows from the list and local database") + } + + CaptureReadinessStrip() } - .padding(.horizontal, 16) - .padding(.vertical, 10) + .padding(.horizontal, 18) + .padding(.top, 16) + .padding(.bottom, 14) .background(.bar) } @@ -26,15 +75,16 @@ struct CaptureToolbar: View { Button { Task { await state.toggleCapture() } } label: { - Label( - state.isCapturing ? "Capturing" : "Start capture", - systemImage: state.isCapturing ? "stop.circle.fill" : "record.circle" - ) - .foregroundStyle(state.isCapturing ? Color.red : Color.primary) - .font(.headline) + Label(captureTitle, systemImage: captureIcon) + .font(.headline) + .frame(minWidth: 176) } .buttonStyle(.borderedProminent) .tint(state.isCapturing ? .red.opacity(0.85) : .accentColor) + .disabled(state.isWorking) + .help(state.captureMode == .device + ? "Start proxy capture and route macOS HTTP/HTTPS traffic through it" + : "Start proxy capture without changing macOS network settings") } private var trustButton: some View { @@ -48,14 +98,15 @@ struct CaptureToolbar: View { } } label: { Label( - state.caTrustInstalled ? "CA trusted" : "Install CA", + state.caTrustInstalled ? "Trusted" : "Trust CA", systemImage: state.caTrustInstalled ? "checkmark.seal.fill" : "seal" ) } .buttonStyle(.bordered) + .disabled(state.isWorking) .help(state.caTrustInstalled - ? "Remove the ReverseAPI root certificate from the user keychain" - : "Install the ReverseAPI root certificate as trusted for the current user") + ? "Remove the ReverseAPI root certificate from the current user's trust store" + : "Trust the ReverseAPI root certificate so HTTPS requests can be inspected") } private var systemProxyButton: some View { @@ -69,26 +120,94 @@ struct CaptureToolbar: View { } } label: { Label( - state.systemProxyEnabled ? "System proxy on" : "System proxy off", + state.systemProxyEnabled ? "Device routed" : "Route device", systemImage: state.systemProxyEnabled ? "network.badge.shield.half.filled" : "network" ) } .buttonStyle(.bordered) - .help("Toggle macOS HTTP/HTTPS proxy on every active network service") + .disabled(state.isWorking || (state.isCapturing && state.captureMode == .device)) + .help("Toggle macOS HTTP/HTTPS proxy for active network services") } - private var statusText: some View { - Group { - if let error = state.lastError { - Label(error, systemImage: "exclamationmark.triangle.fill") - .foregroundStyle(.red) - .lineLimit(1) - .truncationMode(.middle) - } else { - Text("\(state.store.flows.count) flows · 127.0.0.1:\(state.port)") - .foregroundStyle(.secondary) - } + private var captureTitle: String { + if state.isWorking { return "Working" } + if state.isCapturing { return "Stop capture" } + return state.captureMode == .device ? "Start device capture" : "Start manual capture" + } + + private var captureIcon: String { + if state.isWorking { return "hourglass" } + return state.isCapturing ? "stop.circle.fill" : "record.circle" + } + + private var statusLine: String { + if state.isCapturing, state.systemProxyEnabled { + return "Recording traffic from this Mac. Proxy settings restore when capture stops." + } + if state.isCapturing { + return "Manual proxy is listening at 127.0.0.1:\(state.port). Device traffic is not routed." } - .font(.callout) + return "Device mode starts the proxy and routes this Mac in one step." + } +} + +private struct CaptureReadinessStrip: View { + @Environment(AppState.self) private var state + + var body: some View { + HStack(spacing: 8) { + ReadinessPill( + title: state.isCapturing ? "Proxy running" : "Proxy stopped", + systemImage: state.isCapturing ? "record.circle.fill" : "record.circle", + tint: state.isCapturing ? .green : .secondary + ) + ReadinessPill( + title: state.systemProxyEnabled ? "Device traffic routed" : "Device traffic not routed", + systemImage: state.systemProxyEnabled ? "arrow.triangle.branch" : "point.topleft.down.curvedto.point.bottomright.up", + tint: state.systemProxyEnabled ? .green : .orange + ) + ReadinessPill( + title: state.caTrustInstalled ? "HTTPS inspectable" : "HTTPS needs CA trust", + systemImage: state.caTrustInstalled ? "lock.open.fill" : "lock.fill", + tint: state.caTrustInstalled ? .green : .orange + ) + Spacer() + Text(state.captureMode == .manual ? "Manual clients must use 127.0.0.1:\(state.port)." : "Recommended for normal testing.") + .font(.caption) + .foregroundStyle(.secondary) + } + } +} + +private struct MetricPill: View { + let title: String + let value: String + + var body: some View { + VStack(alignment: .trailing, spacing: 1) { + Text(title) + .font(.caption2) + .foregroundStyle(.secondary) + Text(value) + .font(.system(.callout, design: .monospaced).weight(.semibold)) + } + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background(.thinMaterial, in: RoundedRectangle(cornerRadius: 7)) + } +} + +private struct ReadinessPill: View { + let title: String + let systemImage: String + let tint: Color + + var body: some View { + Label(title, systemImage: systemImage) + .font(.caption.weight(.medium)) + .foregroundStyle(tint) + .padding(.horizontal, 9) + .padding(.vertical, 5) + .background(tint.opacity(0.12), in: RoundedRectangle(cornerRadius: 7)) } } diff --git a/macos/Sources/ReverseAPI/UI/ContentView.swift b/macos/Sources/ReverseAPI/UI/ContentView.swift index c5b83e1..15af2af 100644 --- a/macos/Sources/ReverseAPI/UI/ContentView.swift +++ b/macos/Sources/ReverseAPI/UI/ContentView.swift @@ -7,14 +7,26 @@ struct ContentView: View { var body: some View { VStack(spacing: 0) { CaptureToolbar() - Divider() HSplitView { TrafficListView() - .frame(minWidth: 520) + .frame(minWidth: 600, maxHeight: .infinity) InspectorView() - .frame(minWidth: 420) + .frame(minWidth: 460, maxHeight: .infinity) } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding(.horizontal, 12) + .padding(.bottom, 12) } - .background(.background) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .background( + LinearGradient( + colors: [ + Color(nsColor: .windowBackgroundColor), + Color(nsColor: .underPageBackgroundColor).opacity(0.75), + ], + startPoint: .top, + endPoint: .bottom + ) + ) } } diff --git a/macos/Sources/ReverseAPI/UI/InspectorView.swift b/macos/Sources/ReverseAPI/UI/InspectorView.swift index f3903a3..29cb274 100644 --- a/macos/Sources/ReverseAPI/UI/InspectorView.swift +++ b/macos/Sources/ReverseAPI/UI/InspectorView.swift @@ -8,11 +8,20 @@ struct InspectorView: View { if let id = state.selectedFlowID, let flow = state.store.flow(id: id) { FlowInspector(flow: flow) } else { - ContentUnavailableView { - Label("No flow selected", systemImage: "tray") - } description: { - Text("Pick a request from the list to inspect it.") + VStack(spacing: 12) { + Image(systemName: "sidebar.right") + .font(.system(size: 36)) + .foregroundStyle(.secondary) + Text("Select a flow") + .font(.title3.weight(.semibold)) + Text("Request headers, response data, timing, and body previews appear here.") + .font(.callout) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .frame(maxWidth: 340) } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 8)) } } } @@ -43,9 +52,10 @@ private struct FlowInspector: View { ScrollView { content .frame(maxWidth: .infinity, alignment: .leading) - .padding(14) + .padding(16) } } + .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 8)) } private var header: some View { @@ -53,10 +63,14 @@ private struct FlowInspector: View { HStack(spacing: 8) { Text(flow.method) .font(.system(.callout, design: .monospaced).weight(.semibold)) + .foregroundStyle(methodColor) + .padding(.horizontal, 7) + .padding(.vertical, 3) + .background(methodColor.opacity(0.14), in: RoundedRectangle(cornerRadius: 5)) if let status = flow.responseStatus { Text("\(status)") .font(.system(.callout, design: .monospaced).weight(.semibold)) - .foregroundStyle(.secondary) + .foregroundStyle(statusColor(status)) } Spacer() if let finishedAt = flow.finishedAt { @@ -78,7 +92,7 @@ private struct FlowInspector: View { } .padding(.horizontal, 14) .padding(.top, 12) - .padding(.bottom, 6) + .padding(.bottom, 10) } @ViewBuilder @@ -125,6 +139,26 @@ private struct FlowInspector: View { if interval < 1 { return String(format: "%.0f ms", interval * 1000) } return String(format: "%.2f s", interval) } + + private var methodColor: Color { + switch flow.method { + case "GET": return .blue + case "POST": return .green + case "PUT", "PATCH": return .orange + case "DELETE": return .red + default: return .secondary + } + } + + private func statusColor(_ status: Int) -> Color { + switch status { + case 200..<300: return .green + case 300..<400: return .blue + case 400..<500: return .orange + case 500..<600: return .red + default: return .secondary + } + } } private struct HeadersSection: View { @@ -144,6 +178,7 @@ private struct HeadersSection: View { HStack(alignment: .firstTextBaseline, spacing: 12) { Text(header.name) .font(.system(.callout, design: .monospaced).weight(.semibold)) + .foregroundStyle(.secondary) .frame(width: 160, alignment: .leading) Text(header.value) .font(.system(.callout, design: .monospaced)) @@ -204,6 +239,10 @@ private struct CodeBlock: View { .padding(10) .frame(maxWidth: .infinity, alignment: .leading) } - .background(.quaternary.opacity(0.5), in: RoundedRectangle(cornerRadius: 6)) + .background(Color(nsColor: .textBackgroundColor).opacity(0.62), in: RoundedRectangle(cornerRadius: 6)) + .overlay { + RoundedRectangle(cornerRadius: 6) + .stroke(.separator.opacity(0.45), lineWidth: 1) + } } } diff --git a/macos/Sources/ReverseAPI/UI/TrafficListView.swift b/macos/Sources/ReverseAPI/UI/TrafficListView.swift index 3461ad1..844763f 100644 --- a/macos/Sources/ReverseAPI/UI/TrafficListView.swift +++ b/macos/Sources/ReverseAPI/UI/TrafficListView.swift @@ -9,8 +9,16 @@ struct TrafficListView: View { VStack(spacing: 0) { FilterBar(filter: $bindable.filter, hostOptions: hostOptions, methodOptions: methodOptions) Divider() - table + ZStack { + table + if state.store.flows.isEmpty { + EmptyTrafficState() + } else if filteredFlows.isEmpty { + EmptyFilterState() + } + } } + .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 8)) } private var filteredFlows: [CapturedFlow] { @@ -99,6 +107,9 @@ private struct FilterBar: View { var body: some View { HStack(spacing: 12) { + Text("Traffic") + .font(.headline) + HStack(spacing: 8) { Image(systemName: "magnifyingglass") .foregroundStyle(.secondary) @@ -136,12 +147,30 @@ private struct FilterBar: View { .menuStyle(.borderlessButton) .fixedSize() + if activeFilterCount > 0 { + Button("Reset \(activeFilterCount)", systemImage: "xmark.circle.fill") { + filter = TrafficFilter() + } + .buttonStyle(.borderless) + .foregroundStyle(.secondary) + } + Spacer() } .padding(.horizontal, 14) .padding(.vertical, 8) } + private var activeFilterCount: Int { + var count = 0 + if !filter.search.isEmpty { count += 1 } + if filter.onlyErrors { count += 1 } + count += filter.hosts.count + count += filter.methods.count + count += filter.statusBuckets.count + return count + } + private func toggle(_ value: String, in keyPath: WritableKeyPath>) -> some View { Button { if filter[keyPath: keyPath].contains(value) { @@ -197,6 +226,76 @@ private struct MethodBadge: View { } } +private struct EmptyTrafficState: View { + @Environment(AppState.self) private var state + + var body: some View { + VStack(spacing: 14) { + Image(systemName: state.isCapturing ? "dot.radiowaves.left.and.right" : "waveform.path.ecg.rectangle") + .font(.system(size: 38)) + .foregroundStyle(state.isCapturing ? Color.green : Color.secondary) + + VStack(spacing: 5) { + Text(title) + .font(.title3.weight(.semibold)) + Text(message) + .font(.callout) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .frame(maxWidth: 430) + } + + HStack(spacing: 8) { + Label(state.systemProxyEnabled ? "Device routed" : "Device not routed", systemImage: "network") + Label(state.caTrustInstalled ? "CA trusted" : "CA not trusted", systemImage: "seal") + Label("127.0.0.1:\(state.port)", systemImage: "number") + } + .font(.caption) + .foregroundStyle(.secondary) + } + .padding(28) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(.regularMaterial) + } + + private var title: String { + if state.isCapturing, !state.systemProxyEnabled { return "Manual capture is running" } + if state.isCapturing { return "Waiting for traffic" } + return "No traffic captured" + } + + private var message: String { + if state.isCapturing, !state.systemProxyEnabled { + return "Only clients configured to use the proxy will appear here. Switch to Device mode to route this Mac automatically." + } + if state.isCapturing, !state.caTrustInstalled { + return "HTTP traffic should appear immediately. Trust the CA to inspect HTTPS traffic without certificate errors." + } + if state.isCapturing { + return "Open an app or browser and make a request. New flows will appear as they start." + } + return "Start device capture to run the proxy and route this Mac through it." + } +} + +private struct EmptyFilterState: View { + var body: some View { + VStack(spacing: 10) { + Image(systemName: "line.3.horizontal.decrease.circle") + .font(.system(size: 32)) + .foregroundStyle(.secondary) + Text("No matching traffic") + .font(.headline) + Text("Clear or loosen the current filters.") + .font(.callout) + .foregroundStyle(.secondary) + } + .padding(28) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(.regularMaterial) + } +} + private struct StatusBadge: View { let status: Int? let error: String? diff --git a/macos/Sources/ReverseAPIProxy/System/SystemProxyController.swift b/macos/Sources/ReverseAPIProxy/System/SystemProxyController.swift index bf7bf65..5cfa8dd 100644 --- a/macos/Sources/ReverseAPIProxy/System/SystemProxyController.swift +++ b/macos/Sources/ReverseAPIProxy/System/SystemProxyController.swift @@ -1,7 +1,6 @@ import Foundation public enum SystemProxyError: Error { - case scriptFailed(String) case networksetupFailed(Int32, String) case noNetworkServices case invalidHost(String) @@ -27,51 +26,30 @@ public final class SystemProxyController: @unchecked Sendable { try validate(host: host, port: port) let services = try listNetworkServices() guard !services.isEmpty else { throw SystemProxyError.noNetworkServices } - let quotedHost = shellQuote(host) - let commands = services.flatMap { service -> [String] in - let quotedService = shellQuote(service) - return [ - "\(networksetup) -setwebproxy \(quotedService) \(quotedHost) \(port)", - "\(networksetup) -setsecurewebproxy \(quotedService) \(quotedHost) \(port)", - "\(networksetup) -setwebproxystate \(quotedService) on", - "\(networksetup) -setsecurewebproxystate \(quotedService) on", - ] + for service in services { + try shell(networksetup, "-setwebproxy", service, host, String(port)) + try shell(networksetup, "-setsecurewebproxy", service, host, String(port)) + try shell(networksetup, "-setwebproxystate", service, "on") + try shell(networksetup, "-setsecurewebproxystate", service, "on") } - try runWithAdminPrivileges(commands.joined(separator: " && ")) } public func disable() throws { let services = try listNetworkServices() guard !services.isEmpty else { return } - let commands = services.flatMap { service -> [String] in - let q = shellQuote(service) - return [ - "\(networksetup) -setwebproxystate \(q) off", - "\(networksetup) -setsecurewebproxystate \(q) off", - ] + for service in services { + try shell(networksetup, "-setwebproxystate", service, "off") + try shell(networksetup, "-setsecurewebproxystate", service, "off") } - try runWithAdminPrivileges(commands.joined(separator: " && ")) } public func restore(_ snapshots: [ProxyServiceSnapshot]) throws { - var commands: [String] = [] for snapshot in snapshots { - let q = shellQuote(snapshot.service) - commands.append( - "\(networksetup) -setwebproxy \(q) \(shellQuote(snapshot.httpHost)) \(snapshot.httpPort)" - ) - commands.append( - "\(networksetup) -setsecurewebproxy \(q) \(shellQuote(snapshot.httpsHost)) \(snapshot.httpsPort)" - ) - commands.append( - "\(networksetup) -setwebproxystate \(q) \(snapshot.httpEnabled ? "on" : "off")" - ) - commands.append( - "\(networksetup) -setsecurewebproxystate \(q) \(snapshot.httpsEnabled ? "on" : "off")" - ) + try shell(networksetup, "-setwebproxy", snapshot.service, snapshot.httpHost, String(snapshot.httpPort)) + try shell(networksetup, "-setsecurewebproxy", snapshot.service, snapshot.httpsHost, String(snapshot.httpsPort)) + try shell(networksetup, "-setwebproxystate", snapshot.service, snapshot.httpEnabled ? "on" : "off") + try shell(networksetup, "-setsecurewebproxystate", snapshot.service, snapshot.httpsEnabled ? "on" : "off") } - guard !commands.isEmpty else { return } - try runWithAdminPrivileges(commands.joined(separator: " && ")) } public func snapshot() throws -> [ProxyServiceSnapshot] { @@ -102,6 +80,24 @@ public final class SystemProxyController: @unchecked Sendable { return false } + public func isEnabled(host: String, port: Int) throws -> Bool { + try validate(host: host, port: port) + let services = try listNetworkServices() + for service in services { + let http = try parseGetWebProxy(service: service, command: "-getwebproxy") + let https = try parseGetWebProxy(service: service, command: "-getsecurewebproxy") + if http.enabled, + https.enabled, + http.host == host, + https.host == host, + http.port == port, + https.port == port { + return true + } + } + return false + } + public func listNetworkServices() throws -> [String] { let output = try shell(networksetup, "-listallnetworkservices") return output @@ -172,23 +168,6 @@ public final class SystemProxyController: @unchecked Sendable { return stdout } - private func runWithAdminPrivileges(_ command: String) throws { - let escaped = command - .replacingOccurrences(of: "\\", with: "\\\\") - .replacingOccurrences(of: "\"", with: "\\\"") - let script = "do shell script \"\(escaped)\" with administrator privileges" - var error: NSDictionary? - if let apple = NSAppleScript(source: script) { - apple.executeAndReturnError(&error) - } else { - throw SystemProxyError.scriptFailed("failed to construct script") - } - if let error { - let message = error[NSAppleScript.errorMessage] as? String ?? "\(error)" - throw SystemProxyError.scriptFailed(message) - } - } - internal func shellQuote(_ value: String) -> String { "'" + value.replacingOccurrences(of: "'", with: "'\\''") + "'" } From b57734417f995d709fbf683a6af7cca4dbc8dc63 Mon Sep 17 00:00:00 2001 From: kalil0321 Date: Tue, 19 May 2026 22:53:28 +0200 Subject: [PATCH 05/13] fix(macos): rename app to rae --- macos/Package.swift | 2 +- macos/Sources/ReverseAPI/App/ReverseAPIApp.swift | 4 ++-- macos/Sources/ReverseAPI/UI/CaptureToolbar.swift | 6 +++--- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/macos/Package.swift b/macos/Package.swift index 7e05037..c207d70 100644 --- a/macos/Package.swift +++ b/macos/Package.swift @@ -7,7 +7,7 @@ let package = Package( products: [ .library(name: "ReverseAPIProxy", targets: ["ReverseAPIProxy"]), .executable(name: "rae-proxy", targets: ["rae-proxy"]), - .executable(name: "ReverseAPI", targets: ["ReverseAPI"]), + .executable(name: "rae", targets: ["ReverseAPI"]), ], dependencies: [ .package(url: "https://github.com/apple/swift-nio.git", from: "2.65.0"), diff --git a/macos/Sources/ReverseAPI/App/ReverseAPIApp.swift b/macos/Sources/ReverseAPI/App/ReverseAPIApp.swift index dc0a5d6..3843e29 100644 --- a/macos/Sources/ReverseAPI/App/ReverseAPIApp.swift +++ b/macos/Sources/ReverseAPI/App/ReverseAPIApp.swift @@ -5,7 +5,7 @@ struct ReverseAPIApp: App { @State private var session = AppSession.live() var body: some Scene { - Window("ReverseAPI", id: "main") { + Window("rae", id: "main") { switch session { case .ready(let state): ContentView() @@ -52,7 +52,7 @@ struct BootFailureView: View { Image(systemName: "exclamationmark.triangle.fill") .font(.system(size: 48)) .foregroundStyle(.red) - Text("ReverseAPI failed to start") + Text("rae failed to start") .font(.title2) .bold() Text(String(describing: error)) diff --git a/macos/Sources/ReverseAPI/UI/CaptureToolbar.swift b/macos/Sources/ReverseAPI/UI/CaptureToolbar.swift index 49d7f2a..8439eb6 100644 --- a/macos/Sources/ReverseAPI/UI/CaptureToolbar.swift +++ b/macos/Sources/ReverseAPI/UI/CaptureToolbar.swift @@ -9,7 +9,7 @@ struct CaptureToolbar: View { return VStack(alignment: .leading, spacing: 12) { HStack(alignment: .firstTextBaseline, spacing: 16) { VStack(alignment: .leading, spacing: 3) { - Text("ReverseAPI") + Text("rae") .font(.system(.title2, design: .rounded).weight(.semibold)) Text(statusLine) .font(.callout) @@ -105,8 +105,8 @@ struct CaptureToolbar: View { .buttonStyle(.bordered) .disabled(state.isWorking) .help(state.caTrustInstalled - ? "Remove the ReverseAPI root certificate from the current user's trust store" - : "Trust the ReverseAPI root certificate so HTTPS requests can be inspected") + ? "Remove the rae root certificate from the current user's trust store" + : "Trust the rae root certificate so HTTPS requests can be inspected") } private var systemProxyButton: some View { From 19e143cdcaaaea2fe737af0e9d448f102f6ccfe9 Mon Sep 17 00:00:00 2001 From: kalil0321 Date: Tue, 19 May 2026 22:57:42 +0200 Subject: [PATCH 06/13] fix(macos): recover stale app proxy --- macos/Sources/ReverseAPI/App/AppState.swift | 37 ++++++++++++++++++- macos/Sources/ReverseAPI/UI/ContentView.swift | 7 ++++ .../System/SystemProxyController.swift | 16 ++++++++ 3 files changed, 59 insertions(+), 1 deletion(-) diff --git a/macos/Sources/ReverseAPI/App/AppState.swift b/macos/Sources/ReverseAPI/App/AppState.swift index 5e33a37..ca331d4 100644 --- a/macos/Sources/ReverseAPI/App/AppState.swift +++ b/macos/Sources/ReverseAPI/App/AppState.swift @@ -197,6 +197,30 @@ final class AppState { } } + func recoverStaleSystemProxyOnLaunch() async { + guard systemProxyEnabled, !isCapturing, proxySnapshot == nil, !isWorking else { return } + isWorking = true + defer { isWorking = false } + + do { + try await disableCurrentRaeProxy() + lastError = "Recovered stale device proxy from a previous session." + } catch { + lastError = "Device proxy points at rae, but could not be repaired automatically: \(error)" + } + } + + func restoreProxyBeforeExit() { + if let snapshot = proxySnapshot { + try? systemProxy.restore(snapshot) + proxySnapshot = nil + systemProxyEnabled = false + } else if systemProxyEnabled { + try? systemProxy.disable(host: "127.0.0.1", port: port) + systemProxyEnabled = false + } + } + private func applySystemProxy() async throws { let systemProxy = self.systemProxy let port = self.port @@ -216,15 +240,26 @@ final class AppState { private func restoreSystemProxy() async throws { let systemProxy = self.systemProxy + let port = self.port let snapshot = proxySnapshot try await Task.detached(priority: .userInitiated) { if let snapshot { try systemProxy.restore(snapshot) } else { - try systemProxy.disable() + try systemProxy.disable(host: "127.0.0.1", port: port) } }.value proxySnapshot = nil systemProxyEnabled = false } + + private func disableCurrentRaeProxy() async throws { + let systemProxy = self.systemProxy + let port = self.port + try await Task.detached(priority: .userInitiated) { + try systemProxy.disable(host: "127.0.0.1", port: port) + }.value + proxySnapshot = nil + systemProxyEnabled = false + } } diff --git a/macos/Sources/ReverseAPI/UI/ContentView.swift b/macos/Sources/ReverseAPI/UI/ContentView.swift index 15af2af..772cfc6 100644 --- a/macos/Sources/ReverseAPI/UI/ContentView.swift +++ b/macos/Sources/ReverseAPI/UI/ContentView.swift @@ -1,5 +1,6 @@ import SwiftUI import ReverseAPIProxy +import AppKit struct ContentView: View { @Environment(AppState.self) private var state @@ -28,5 +29,11 @@ struct ContentView: View { endPoint: .bottom ) ) + .task { + await state.recoverStaleSystemProxyOnLaunch() + } + .onReceive(NotificationCenter.default.publisher(for: NSApplication.willTerminateNotification)) { _ in + state.restoreProxyBeforeExit() + } } } diff --git a/macos/Sources/ReverseAPIProxy/System/SystemProxyController.swift b/macos/Sources/ReverseAPIProxy/System/SystemProxyController.swift index 5cfa8dd..4df8668 100644 --- a/macos/Sources/ReverseAPIProxy/System/SystemProxyController.swift +++ b/macos/Sources/ReverseAPIProxy/System/SystemProxyController.swift @@ -43,6 +43,22 @@ public final class SystemProxyController: @unchecked Sendable { } } + public func disable(host: String, port: Int) throws { + try validate(host: host, port: port) + let services = try listNetworkServices() + guard !services.isEmpty else { return } + for service in services { + let http = try parseGetWebProxy(service: service, command: "-getwebproxy") + let https = try parseGetWebProxy(service: service, command: "-getsecurewebproxy") + if http.enabled, http.host == host, http.port == port { + try shell(networksetup, "-setwebproxystate", service, "off") + } + if https.enabled, https.host == host, https.port == port { + try shell(networksetup, "-setsecurewebproxystate", service, "off") + } + } + } + public func restore(_ snapshots: [ProxyServiceSnapshot]) throws { for snapshot in snapshots { try shell(networksetup, "-setwebproxy", snapshot.service, snapshot.httpHost, String(snapshot.httpPort)) From ef4b58bf046d24eb1366cc6f7e406a42085d1758 Mon Sep 17 00:00:00 2001 From: kalil0321 Date: Tue, 19 May 2026 23:36:42 +0200 Subject: [PATCH 07/13] fix(macos): restore proxy on app close --- macos/Sources/ReverseAPI/App/AppState.swift | 9 ++++++ .../ReverseAPI/App/ReverseAPIApp.swift | 31 +++++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/macos/Sources/ReverseAPI/App/AppState.swift b/macos/Sources/ReverseAPI/App/AppState.swift index ca331d4..a2bea33 100644 --- a/macos/Sources/ReverseAPI/App/AppState.swift +++ b/macos/Sources/ReverseAPI/App/AppState.swift @@ -221,6 +221,15 @@ final class AppState { } } + func shutdownForWindowClose() async { + restoreProxyBeforeExit() + if isCapturing { + try? await engine.stop() + isCapturing = false + } + isWorking = false + } + private func applySystemProxy() async throws { let systemProxy = self.systemProxy let port = self.port diff --git a/macos/Sources/ReverseAPI/App/ReverseAPIApp.swift b/macos/Sources/ReverseAPI/App/ReverseAPIApp.swift index 3843e29..d439872 100644 --- a/macos/Sources/ReverseAPI/App/ReverseAPIApp.swift +++ b/macos/Sources/ReverseAPI/App/ReverseAPIApp.swift @@ -1,7 +1,9 @@ +import AppKit import SwiftUI @main struct ReverseAPIApp: App { + @NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate @State private var session = AppSession.live() var body: some Scene { @@ -10,6 +12,12 @@ struct ReverseAPIApp: App { case .ready(let state): ContentView() .environment(state) + .onAppear { + AppLifecycle.shared.state = state + } + .onDisappear { + Task { await state.shutdownForWindowClose() } + } .frame( minWidth: 1100, maxWidth: .infinity, @@ -30,6 +38,29 @@ struct ReverseAPIApp: App { } } +@MainActor +final class AppLifecycle { + static let shared = AppLifecycle() + weak var state: AppState? + + private init() {} + + func restoreProxyBeforeExit() { + state?.restoreProxyBeforeExit() + } +} + +final class AppDelegate: NSObject, NSApplicationDelegate { + func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { + true + } + + @MainActor + func applicationWillTerminate(_ notification: Notification) { + AppLifecycle.shared.restoreProxyBeforeExit() + } +} + enum AppSession { case ready(AppState) case failed(Error) From ae2466ec6faec018fde356f2cd7003ccf2581b6c Mon Sep 17 00:00:00 2001 From: kalil0321 Date: Tue, 19 May 2026 23:41:33 +0200 Subject: [PATCH 08/13] feat(macos): add resource type traffic filters --- .../Sources/ReverseAPI/UI/TrafficFilter.swift | 73 +++++++ .../ReverseAPI/UI/TrafficListView.swift | 185 ++++++++++++++---- .../ReverseAPITests/TrafficFilterTests.swift | 59 +++++- 3 files changed, 273 insertions(+), 44 deletions(-) diff --git a/macos/Sources/ReverseAPI/UI/TrafficFilter.swift b/macos/Sources/ReverseAPI/UI/TrafficFilter.swift index 1a94075..1a8eac8 100644 --- a/macos/Sources/ReverseAPI/UI/TrafficFilter.swift +++ b/macos/Sources/ReverseAPI/UI/TrafficFilter.swift @@ -6,8 +6,23 @@ struct TrafficFilter: Equatable { var hosts: Set = [] var methods: Set = [] var statusBuckets: Set = [] + var resourceKinds: Set = [] var onlyErrors: Bool = false + enum ResourceKind: String, CaseIterable, Identifiable, Hashable { + case document = "Doc" + case fetch = "Fetch/XHR" + case script = "JS" + case stylesheet = "CSS" + case image = "Img" + case media = "Media" + case font = "Font" + case websocket = "WS" + case other = "Other" + + var id: String { rawValue } + } + enum StatusBucket: String, CaseIterable, Identifiable, Hashable { case informational = "1xx" case success = "2xx" @@ -40,10 +55,68 @@ struct TrafficFilter: Equatable { } if !hosts.isEmpty, !hosts.contains(flow.host) { return false } if !methods.isEmpty, !methods.contains(flow.method) { return false } + if !resourceKinds.isEmpty, !resourceKinds.contains(Self.resourceKind(for: flow)) { return false } if !statusBuckets.isEmpty { guard let status = flow.responseStatus else { return false } if !statusBuckets.contains(where: { $0.contains(status) }) { return false } } return true } + + static func resourceKind(for flow: CapturedFlow) -> ResourceKind { + if isWebSocket(flow) { return .websocket } + + let contentType = headerValue("content-type", in: flow.responseHeaders)?.lowercased() ?? "" + let accept = headerValue("accept", in: flow.requestHeaders)?.lowercased() ?? "" + let path = flow.path.lowercased() + let ext = path + .split(separator: "?") + .first? + .split(separator: "/") + .last? + .split(separator: ".") + .last + .map { String($0).lowercased() } ?? "" + + if contentType.contains("text/html") || accept.contains("text/html") || ["html", "htm"].contains(ext) { + return .document + } + if contentType.contains("text/css") || ext == "css" { + return .stylesheet + } + if contentType.contains("javascript") || + contentType.contains("ecmascript") || + ["js", "mjs", "cjs"].contains(ext) { + return .script + } + if contentType.hasPrefix("image/") || ["png", "jpg", "jpeg", "gif", "webp", "avif", "svg", "ico"].contains(ext) { + return .image + } + if contentType.hasPrefix("video/") || + contentType.hasPrefix("audio/") || + ["mp4", "webm", "mov", "m4v", "mp3", "wav", "ogg", "m3u8"].contains(ext) { + return .media + } + if contentType.contains("font") || ["woff", "woff2", "ttf", "otf", "eot"].contains(ext) { + return .font + } + if contentType.contains("json") || + contentType.contains("xml") || + accept.contains("application/json") || + path.contains("/api/") || + flow.method != "GET" { + return .fetch + } + return .other + } + + private static func isWebSocket(_ flow: CapturedFlow) -> Bool { + let upgrade = headerValue("upgrade", in: flow.requestHeaders)?.lowercased() + ?? headerValue("upgrade", in: flow.responseHeaders)?.lowercased() + return upgrade == "websocket" || flow.responseStatus == 101 + } + + private static func headerValue(_ name: String, in headers: [HTTPHeader]) -> String? { + headers.first { $0.name.caseInsensitiveCompare(name) == .orderedSame }?.value + } } diff --git a/macos/Sources/ReverseAPI/UI/TrafficListView.swift b/macos/Sources/ReverseAPI/UI/TrafficListView.swift index 844763f..3d75e91 100644 --- a/macos/Sources/ReverseAPI/UI/TrafficListView.swift +++ b/macos/Sources/ReverseAPI/UI/TrafficListView.swift @@ -48,6 +48,11 @@ struct TrafficListView: View { } .width(min: 60, ideal: 70) + TableColumn("Type") { flow in + ResourceKindBadge(kind: TrafficFilter.resourceKind(for: flow)) + } + .width(min: 74, ideal: 86) + TableColumn("Host") { flow in Text(flow.host) .lineLimit(1) @@ -106,56 +111,65 @@ private struct FilterBar: View { let methodOptions: [String] var body: some View { - HStack(spacing: 12) { - Text("Traffic") - .font(.headline) - - HStack(spacing: 8) { - Image(systemName: "magnifyingglass") - .foregroundStyle(.secondary) - TextField("Filter by URL or method", text: $filter.search) - .textFieldStyle(.plain) - } - .padding(.horizontal, 10) - .padding(.vertical, 6) - .background(.quaternary, in: RoundedRectangle(cornerRadius: 6)) - .frame(maxWidth: 320) - - Menu { - Toggle("Errors only", isOn: $filter.onlyErrors) - Divider() - Section("Methods") { - ForEach(methodOptions, id: \.self) { method in - toggle(method, in: \.methods) - } + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 12) { + Text("Traffic") + .font(.headline) + + HStack(spacing: 8) { + Image(systemName: "magnifyingglass") + .foregroundStyle(.secondary) + TextField("Filter by URL or method", text: $filter.search) + .textFieldStyle(.plain) } - Section("Hosts") { - ForEach(hostOptions, id: \.self) { host in - toggle(host, in: \.hosts) + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background(.quaternary, in: RoundedRectangle(cornerRadius: 6)) + .frame(maxWidth: 320) + + Menu { + Toggle("Errors only", isOn: $filter.onlyErrors) + Divider() + Section("Types") { + ForEach(TrafficFilter.ResourceKind.allCases) { kind in + toggleResource(kind) + } } - } - Section("Status") { - ForEach(TrafficFilter.StatusBucket.allCases) { bucket in - toggleBucket(bucket) + Section("Methods") { + ForEach(methodOptions, id: \.self) { method in + toggle(method, in: \.methods) + } + } + Section("Hosts") { + ForEach(hostOptions, id: \.self) { host in + toggle(host, in: \.hosts) + } + } + Section("Status") { + ForEach(TrafficFilter.StatusBucket.allCases) { bucket in + toggleBucket(bucket) + } } + Divider() + Button("Reset") { filter = TrafficFilter() } + } label: { + Label("Filters", systemImage: "line.3.horizontal.decrease") } - Divider() - Button("Reset") { filter = TrafficFilter() } - } label: { - Label("Filters", systemImage: "line.3.horizontal.decrease") - } - .menuStyle(.borderlessButton) - .fixedSize() + .menuStyle(.borderlessButton) + .fixedSize() - if activeFilterCount > 0 { - Button("Reset \(activeFilterCount)", systemImage: "xmark.circle.fill") { - filter = TrafficFilter() + if activeFilterCount > 0 { + Button("Reset \(activeFilterCount)", systemImage: "xmark.circle.fill") { + filter = TrafficFilter() + } + .buttonStyle(.borderless) + .foregroundStyle(.secondary) } - .buttonStyle(.borderless) - .foregroundStyle(.secondary) + + Spacer() } - Spacer() + ResourceKindBar(selectedKinds: $filter.resourceKinds) } .padding(.horizontal, 14) .padding(.vertical, 8) @@ -168,6 +182,7 @@ private struct FilterBar: View { count += filter.hosts.count count += filter.methods.count count += filter.statusBuckets.count + count += filter.resourceKinds.count return count } @@ -200,6 +215,65 @@ private struct FilterBar: View { } } } + + private func toggleResource(_ kind: TrafficFilter.ResourceKind) -> some View { + Button { + if filter.resourceKinds.contains(kind) { + filter.resourceKinds.remove(kind) + } else { + filter.resourceKinds.insert(kind) + } + } label: { + HStack { + Image(systemName: filter.resourceKinds.contains(kind) ? "checkmark.square.fill" : "square") + Text(kind.rawValue) + } + } + } +} + +private struct ResourceKindBar: View { + @Binding var selectedKinds: Set + + var body: some View { + HStack(spacing: 6) { + ResourceKindFilterButton(title: "All", isSelected: selectedKinds.isEmpty) { + selectedKinds.removeAll() + } + + ForEach(TrafficFilter.ResourceKind.allCases) { kind in + ResourceKindFilterButton(title: kind.rawValue, isSelected: selectedKinds.contains(kind)) { + if selectedKinds.contains(kind) { + selectedKinds.remove(kind) + } else { + selectedKinds.insert(kind) + } + } + } + } + .buttonStyle(.plain) + } +} + +private struct ResourceKindFilterButton: View { + let title: String + let isSelected: Bool + let action: () -> Void + + var body: some View { + Button(action: action) { + Text(title) + .font(.caption.weight(.medium)) + .foregroundStyle(isSelected ? Color.accentColor : Color.secondary) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background( + isSelected ? Color.accentColor.opacity(0.14) : Color.secondary.opacity(0.08), + in: RoundedRectangle(cornerRadius: 6) + ) + } + .help(title == "All" ? "Show all resource types" : "Toggle \(title) resources") + } } private struct MethodBadge: View { @@ -226,6 +300,33 @@ private struct MethodBadge: View { } } +private struct ResourceKindBadge: View { + let kind: TrafficFilter.ResourceKind + + var body: some View { + Text(kind.rawValue) + .font(.system(.caption, design: .monospaced).weight(.semibold)) + .foregroundStyle(color) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(color.opacity(0.13), in: RoundedRectangle(cornerRadius: 4)) + } + + private var color: Color { + switch kind { + case .document: return .primary + case .fetch: return .cyan + case .script: return .yellow + case .stylesheet: return .blue + case .image: return .green + case .media: return .pink + case .font: return .indigo + case .websocket: return .purple + case .other: return .secondary + } + } +} + private struct EmptyTrafficState: View { @Environment(AppState.self) private var state diff --git a/macos/Tests/ReverseAPITests/TrafficFilterTests.swift b/macos/Tests/ReverseAPITests/TrafficFilterTests.swift index 1292a85..3f8558b 100644 --- a/macos/Tests/ReverseAPITests/TrafficFilterTests.swift +++ b/macos/Tests/ReverseAPITests/TrafficFilterTests.swift @@ -3,10 +3,26 @@ import ReverseAPIProxy @testable import ReverseAPI final class TrafficFilterTests: XCTestCase { - private func make(method: String = "GET", host: String = "api.example.com", path: String = "/users", status: Int? = 200, error: String? = nil) -> CapturedFlow { - var flow = CapturedFlow(scheme: .https, method: method, host: host, port: 443, path: path) + private func make( + method: String = "GET", + host: String = "api.example.com", + path: String = "/users", + status: Int? = 200, + error: String? = nil, + requestHeaders: [HTTPHeader] = [], + responseHeaders: [HTTPHeader] = [] + ) -> CapturedFlow { + var flow = CapturedFlow( + scheme: .https, + method: method, + host: host, + port: 443, + path: path, + requestHeaders: requestHeaders + ) flow.responseStatus = status flow.error = error + flow.responseHeaders = responseHeaders return flow } @@ -79,4 +95,43 @@ final class TrafficFilterTests: XCTestCase { XCTAssertTrue(TrafficFilter.StatusBucket.clientError.contains(404)) XCTAssertTrue(TrafficFilter.StatusBucket.serverError.contains(503)) } + + func testResourceKindFilterMatchesImage() { + var filter = TrafficFilter() + filter.resourceKinds = [.image] + XCTAssertTrue(filter.matches(make(path: "/logo.png"))) + XCTAssertTrue(filter.matches(make(responseHeaders: [HTTPHeader("Content-Type", "image/webp")]))) + XCTAssertFalse(filter.matches(make(responseHeaders: [HTTPHeader("Content-Type", "text/css")]))) + } + + func testResourceKindClassifiesStylesheetsAndScripts() { + XCTAssertEqual(TrafficFilter.resourceKind(for: make(path: "/app.css")), .stylesheet) + XCTAssertEqual(TrafficFilter.resourceKind(for: make(path: "/app.js")), .script) + XCTAssertEqual( + TrafficFilter.resourceKind(for: make(responseHeaders: [HTTPHeader("Content-Type", "text/javascript")])), + .script + ) + } + + func testResourceKindClassifiesFetch() { + XCTAssertEqual( + TrafficFilter.resourceKind(for: make(responseHeaders: [HTTPHeader("Content-Type", "application/json")])), + .fetch + ) + XCTAssertEqual(TrafficFilter.resourceKind(for: make(method: "POST")), .fetch) + XCTAssertEqual(TrafficFilter.resourceKind(for: make(path: "/api/users")), .fetch) + } + + func testResourceKindClassifiesDocumentFontMediaAndWebSocket() { + XCTAssertEqual( + TrafficFilter.resourceKind(for: make(responseHeaders: [HTTPHeader("Content-Type", "text/html")])), + .document + ) + XCTAssertEqual(TrafficFilter.resourceKind(for: make(path: "/font.woff2")), .font) + XCTAssertEqual(TrafficFilter.resourceKind(for: make(path: "/clip.mp4")), .media) + XCTAssertEqual( + TrafficFilter.resourceKind(for: make(status: 101, requestHeaders: [HTTPHeader("Upgrade", "websocket")])), + .websocket + ) + } } From 1fb116971db180d507509c7ca0536a363660525b Mon Sep 17 00:00:00 2001 From: kalil0321 Date: Tue, 19 May 2026 23:49:34 +0200 Subject: [PATCH 09/13] style(macos): refine rae traffic UI --- .../ReverseAPI/UI/CaptureToolbar.swift | 245 ++++++++++-------- macos/Sources/ReverseAPI/UI/ContentView.swift | 18 +- .../Sources/ReverseAPI/UI/InspectorView.swift | 177 ++++++++++--- .../ReverseAPI/UI/TrafficListView.swift | 222 ++++++++++------ 4 files changed, 424 insertions(+), 238 deletions(-) diff --git a/macos/Sources/ReverseAPI/UI/CaptureToolbar.swift b/macos/Sources/ReverseAPI/UI/CaptureToolbar.swift index 8439eb6..e0098b4 100644 --- a/macos/Sources/ReverseAPI/UI/CaptureToolbar.swift +++ b/macos/Sources/ReverseAPI/UI/CaptureToolbar.swift @@ -6,29 +6,11 @@ struct CaptureToolbar: View { var body: some View { @Bindable var bindable = state - return VStack(alignment: .leading, spacing: 12) { - HStack(alignment: .firstTextBaseline, spacing: 16) { - VStack(alignment: .leading, spacing: 3) { - Text("rae") - .font(.system(.title2, design: .rounded).weight(.semibold)) - Text(statusLine) - .font(.callout) - .foregroundStyle(.secondary) - .lineLimit(1) - } - - Spacer(minLength: 24) - - MetricPill(title: "Flows", value: "\(state.store.flows.count)") - MetricPill(title: "Proxy", value: ":\(state.port)") - ReadinessPill( - title: state.caTrustInstalled ? "CA trusted" : "CA not trusted", - systemImage: state.caTrustInstalled ? "checkmark.seal.fill" : "seal", - tint: state.caTrustInstalled ? .green : .orange - ) - } + return VStack(alignment: .leading, spacing: 18) { + brandHeader - HStack(spacing: 10) { + VStack(spacing: 10) { + captureButton Picker("Capture mode", selection: $bindable.captureMode) { ForEach(AppState.CaptureMode.allCases) { mode in Text(mode.rawValue).tag(mode) @@ -36,39 +18,99 @@ struct CaptureToolbar: View { } .labelsHidden() .pickerStyle(.segmented) - .frame(width: 190) .disabled(state.isCapturing || state.isWorking) - .help("Device captures traffic from this Mac. Manual only records clients explicitly configured to use 127.0.0.1:\(state.port).") + } - captureButton + VStack(alignment: .leading, spacing: 8) { + SidebarStatusRow( + title: state.isCapturing ? "Proxy running" : "Proxy stopped", + detail: "127.0.0.1:\(state.port)", + systemImage: "record.circle", + tint: state.isCapturing ? .green : .secondary + ) + SidebarStatusRow( + title: state.systemProxyEnabled ? "Device routed" : "Device not routed", + detail: state.captureMode == .device ? "Automatic capture" : "Manual clients only", + systemImage: "network", + tint: state.systemProxyEnabled ? .green : .orange + ) + SidebarStatusRow( + title: state.caTrustInstalled ? "CA trusted" : "CA not trusted", + detail: state.caTrustInstalled ? "HTTPS ready" : "HTTPS may fail", + systemImage: "seal", + tint: state.caTrustInstalled ? .green : .orange + ) + } + + VStack(alignment: .leading, spacing: 8) { + Text("Actions") + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) trustButton systemProxyButton + clearButton + } + + if let error = state.lastError { + Label(error, systemImage: "exclamationmark.triangle.fill") + .font(.caption) + .foregroundStyle(.red) + .lineLimit(4) + .padding(10) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color.red.opacity(0.12), in: RoundedRectangle(cornerRadius: 8)) + } - Spacer() + Spacer() + + VStack(alignment: .leading, spacing: 6) { + Text("\(state.store.flows.count)") + .font(.system(.title, design: .rounded).weight(.semibold)) + .contentTransition(.numericText()) + Text("captured flows") + .font(.caption) + .foregroundStyle(.secondary) + } + .padding(14) + .frame(maxWidth: .infinity, alignment: .leading) + .background(.thinMaterial, in: RoundedRectangle(cornerRadius: 10)) + } + .padding(18) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .background( + LinearGradient( + colors: [ + Color(nsColor: .controlBackgroundColor), + Color(nsColor: .underPageBackgroundColor).opacity(0.82), + ], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) + } - if let error = state.lastError { - Label(error, systemImage: "exclamationmark.triangle.fill") - .font(.callout) - .foregroundStyle(.red) - .lineLimit(1) - .truncationMode(.middle) - .frame(maxWidth: 420, alignment: .trailing) + private var brandHeader: some View { + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 10) { + ZStack { + RoundedRectangle(cornerRadius: 9) + .fill(Color.accentColor.opacity(0.16)) + Image(systemName: "waveform.path.ecg") + .foregroundStyle(Color.accentColor) + .font(.system(size: 17, weight: .semibold)) } + .frame(width: 34, height: 34) - Button("Clear", systemImage: "trash") { - state.clearFlows() + VStack(alignment: .leading, spacing: 1) { + Text("rae") + .font(.system(.title3, design: .rounded).weight(.semibold)) + Text(statusLine) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(2) } - .buttonStyle(.borderless) - .disabled(state.store.flows.isEmpty || state.isWorking) - .help("Remove captured flows from the list and local database") } - - CaptureReadinessStrip() } - .padding(.horizontal, 18) - .padding(.top, 16) - .padding(.bottom, 14) - .background(.bar) } private var captureButton: some View { @@ -77,10 +119,12 @@ struct CaptureToolbar: View { } label: { Label(captureTitle, systemImage: captureIcon) .font(.headline) - .frame(minWidth: 176) + .frame(maxWidth: .infinity) + .padding(.vertical, 5) } .buttonStyle(.borderedProminent) - .tint(state.isCapturing ? .red.opacity(0.85) : .accentColor) + .controlSize(.large) + .tint(state.isCapturing ? .red.opacity(0.86) : .accentColor) .disabled(state.isWorking) .help(state.captureMode == .device ? "Start proxy capture and route macOS HTTP/HTTPS traffic through it" @@ -97,12 +141,12 @@ struct CaptureToolbar: View { } } } label: { - Label( - state.caTrustInstalled ? "Trusted" : "Trust CA", + SidebarActionLabel( + title: state.caTrustInstalled ? "Remove CA trust" : "Trust CA", systemImage: state.caTrustInstalled ? "checkmark.seal.fill" : "seal" ) } - .buttonStyle(.bordered) + .buttonStyle(.plain) .disabled(state.isWorking) .help(state.caTrustInstalled ? "Remove the rae root certificate from the current user's trust store" @@ -119,16 +163,27 @@ struct CaptureToolbar: View { } } } label: { - Label( - state.systemProxyEnabled ? "Device routed" : "Route device", + SidebarActionLabel( + title: state.systemProxyEnabled ? "Unroute device" : "Route device", systemImage: state.systemProxyEnabled ? "network.badge.shield.half.filled" : "network" ) } - .buttonStyle(.bordered) + .buttonStyle(.plain) .disabled(state.isWorking || (state.isCapturing && state.captureMode == .device)) .help("Toggle macOS HTTP/HTTPS proxy for active network services") } + private var clearButton: some View { + Button { + state.clearFlows() + } label: { + SidebarActionLabel(title: "Clear traffic", systemImage: "trash") + } + .buttonStyle(.plain) + .disabled(state.store.flows.isEmpty || state.isWorking) + .help("Remove captured flows from the list and local database") + } + private var captureTitle: String { if state.isWorking { return "Working" } if state.isCapturing { return "Stop capture" } @@ -141,73 +196,55 @@ struct CaptureToolbar: View { } private var statusLine: String { - if state.isCapturing, state.systemProxyEnabled { - return "Recording traffic from this Mac. Proxy settings restore when capture stops." - } - if state.isCapturing { - return "Manual proxy is listening at 127.0.0.1:\(state.port). Device traffic is not routed." - } - return "Device mode starts the proxy and routes this Mac in one step." + if state.isCapturing, state.systemProxyEnabled { return "Recording this Mac" } + if state.isCapturing { return "Manual proxy active" } + return "Ready to capture" } } -private struct CaptureReadinessStrip: View { - @Environment(AppState.self) private var state +private struct SidebarStatusRow: View { + let title: String + let detail: String + let systemImage: String + let tint: Color var body: some View { - HStack(spacing: 8) { - ReadinessPill( - title: state.isCapturing ? "Proxy running" : "Proxy stopped", - systemImage: state.isCapturing ? "record.circle.fill" : "record.circle", - tint: state.isCapturing ? .green : .secondary - ) - ReadinessPill( - title: state.systemProxyEnabled ? "Device traffic routed" : "Device traffic not routed", - systemImage: state.systemProxyEnabled ? "arrow.triangle.branch" : "point.topleft.down.curvedto.point.bottomright.up", - tint: state.systemProxyEnabled ? .green : .orange - ) - ReadinessPill( - title: state.caTrustInstalled ? "HTTPS inspectable" : "HTTPS needs CA trust", - systemImage: state.caTrustInstalled ? "lock.open.fill" : "lock.fill", - tint: state.caTrustInstalled ? .green : .orange - ) + HStack(spacing: 10) { + Image(systemName: systemImage) + .font(.system(size: 14, weight: .medium)) + .foregroundStyle(tint) + .frame(width: 22) + VStack(alignment: .leading, spacing: 1) { + Text(title) + .font(.callout.weight(.medium)) + .lineLimit(1) + Text(detail) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + } Spacer() - Text(state.captureMode == .manual ? "Manual clients must use 127.0.0.1:\(state.port)." : "Recommended for normal testing.") - .font(.caption) - .foregroundStyle(.secondary) } + .padding(10) + .background(Color.primary.opacity(0.045), in: RoundedRectangle(cornerRadius: 9)) } } -private struct MetricPill: View { +private struct SidebarActionLabel: View { let title: String - let value: String + let systemImage: String var body: some View { - VStack(alignment: .trailing, spacing: 1) { - Text(title) - .font(.caption2) + HStack(spacing: 10) { + Image(systemName: systemImage) + .frame(width: 22) .foregroundStyle(.secondary) - Text(value) - .font(.system(.callout, design: .monospaced).weight(.semibold)) + Text(title) + .font(.callout) + Spacer() } .padding(.horizontal, 10) - .padding(.vertical, 6) - .background(.thinMaterial, in: RoundedRectangle(cornerRadius: 7)) - } -} - -private struct ReadinessPill: View { - let title: String - let systemImage: String - let tint: Color - - var body: some View { - Label(title, systemImage: systemImage) - .font(.caption.weight(.medium)) - .foregroundStyle(tint) - .padding(.horizontal, 9) - .padding(.vertical, 5) - .background(tint.opacity(0.12), in: RoundedRectangle(cornerRadius: 7)) + .padding(.vertical, 8) + .background(Color.primary.opacity(0.035), in: RoundedRectangle(cornerRadius: 8)) } } diff --git a/macos/Sources/ReverseAPI/UI/ContentView.swift b/macos/Sources/ReverseAPI/UI/ContentView.swift index 772cfc6..8203949 100644 --- a/macos/Sources/ReverseAPI/UI/ContentView.swift +++ b/macos/Sources/ReverseAPI/UI/ContentView.swift @@ -6,8 +6,10 @@ struct ContentView: View { @Environment(AppState.self) private var state var body: some View { - VStack(spacing: 0) { + HStack(spacing: 0) { CaptureToolbar() + .frame(width: 296) + Divider() HSplitView { TrafficListView() .frame(minWidth: 600, maxHeight: .infinity) @@ -15,20 +17,10 @@ struct ContentView: View { .frame(minWidth: 460, maxHeight: .infinity) } .frame(maxWidth: .infinity, maxHeight: .infinity) - .padding(.horizontal, 12) - .padding(.bottom, 12) + .padding(14) } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) - .background( - LinearGradient( - colors: [ - Color(nsColor: .windowBackgroundColor), - Color(nsColor: .underPageBackgroundColor).opacity(0.75), - ], - startPoint: .top, - endPoint: .bottom - ) - ) + .background(Color(nsColor: .windowBackgroundColor)) .task { await state.recoverStaleSystemProxyOnLaunch() } diff --git a/macos/Sources/ReverseAPI/UI/InspectorView.swift b/macos/Sources/ReverseAPI/UI/InspectorView.swift index 29cb274..f43e00e 100644 --- a/macos/Sources/ReverseAPI/UI/InspectorView.swift +++ b/macos/Sources/ReverseAPI/UI/InspectorView.swift @@ -8,20 +8,32 @@ struct InspectorView: View { if let id = state.selectedFlowID, let flow = state.store.flow(id: id) { FlowInspector(flow: flow) } else { - VStack(spacing: 12) { - Image(systemName: "sidebar.right") - .font(.system(size: 36)) - .foregroundStyle(.secondary) - Text("Select a flow") - .font(.title3.weight(.semibold)) - Text("Request headers, response data, timing, and body previews appear here.") - .font(.callout) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) - .frame(maxWidth: 340) + VStack(spacing: 16) { + ZStack { + RoundedRectangle(cornerRadius: 18) + .fill(Color.primary.opacity(0.055)) + .frame(width: 70, height: 70) + Image(systemName: "doc.text.magnifyingglass") + .font(.system(size: 30, weight: .medium)) + .foregroundStyle(.secondary) + } + + VStack(spacing: 6) { + Text("No request selected") + .font(.title3.weight(.semibold)) + Text("Pick a row from traffic to inspect headers, timing, and body data.") + .font(.callout) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .frame(maxWidth: 330) + } } .frame(maxWidth: .infinity, maxHeight: .infinity) - .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 8)) + .background(Color(nsColor: .controlBackgroundColor), in: RoundedRectangle(cornerRadius: 10)) + .overlay { + RoundedRectangle(cornerRadius: 10) + .stroke(.separator.opacity(0.5), lineWidth: 1) + } } } } @@ -52,14 +64,19 @@ private struct FlowInspector: View { ScrollView { content .frame(maxWidth: .infinity, alignment: .leading) - .padding(16) + .padding(14) } } - .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 8)) + .background(Color(nsColor: .controlBackgroundColor), in: RoundedRectangle(cornerRadius: 10)) + .overlay { + RoundedRectangle(cornerRadius: 10) + .stroke(.separator.opacity(0.5), lineWidth: 1) + } + .clipShape(RoundedRectangle(cornerRadius: 10)) } private var header: some View { - VStack(alignment: .leading, spacing: 4) { + VStack(alignment: .leading, spacing: 8) { HStack(spacing: 8) { Text(flow.method) .font(.system(.callout, design: .monospaced).weight(.semibold)) @@ -72,6 +89,12 @@ private struct FlowInspector: View { .font(.system(.callout, design: .monospaced).weight(.semibold)) .foregroundStyle(statusColor(status)) } + Text(TrafficFilter.resourceKind(for: flow).rawValue) + .font(.system(.caption, design: .monospaced).weight(.semibold)) + .foregroundStyle(.secondary) + .padding(.horizontal, 7) + .padding(.vertical, 3) + .background(Color.primary.opacity(0.055), in: RoundedRectangle(cornerRadius: 5)) Spacer() if let finishedAt = flow.finishedAt { Text(formatDuration(flow.startedAt, finishedAt)) @@ -79,11 +102,18 @@ private struct FlowInspector: View { .font(.callout) } } - Text(flow.url) - .font(.system(.body, design: .monospaced)) - .textSelection(.enabled) - .lineLimit(2) - .truncationMode(.middle) + VStack(alignment: .leading, spacing: 2) { + Text(flow.host) + .font(.headline) + .lineLimit(1) + .truncationMode(.middle) + Text(flow.url) + .font(.system(.callout, design: .monospaced)) + .foregroundStyle(.secondary) + .textSelection(.enabled) + .lineLimit(2) + .truncationMode(.middle) + } if let error = flow.error { Label(error, systemImage: "exclamationmark.octagon.fill") .foregroundStyle(.red) @@ -101,25 +131,38 @@ private struct FlowInspector: View { case .overview: overview case .request: - HeadersSection(title: "Request headers", headers: flow.requestHeaders) - BodySection(title: "Request body", bodyData: flow.requestBody, headers: flow.requestHeaders) + VStack(alignment: .leading, spacing: 12) { + HeadersSection(title: "Request headers", headers: flow.requestHeaders) + BodySection(title: "Request body", bodyData: flow.requestBody, headers: flow.requestHeaders) + } case .response: - HeadersSection(title: "Response headers", headers: flow.responseHeaders) - BodySection(title: "Response body", bodyData: flow.responseBody, headers: flow.responseHeaders) + VStack(alignment: .leading, spacing: 12) { + HeadersSection(title: "Response headers", headers: flow.responseHeaders) + BodySection(title: "Response body", bodyData: flow.responseBody, headers: flow.responseHeaders) + } } } private var overview: some View { - VStack(alignment: .leading, spacing: 8) { - row("Scheme", flow.scheme.rawValue) - row("Host", "\(flow.host):\(flow.port)") - row("Path", flow.path) - row("Method", flow.method) - row("Status", flow.responseStatus.map(String.init) ?? "—") - row("Started", flow.startedAt.formatted(date: .abbreviated, time: .standard)) - row("Finished", flow.finishedAt?.formatted(date: .abbreviated, time: .standard) ?? "—") - row("Request size", "\(flow.requestBody.count) bytes") - row("Response size", "\(flow.responseBody.count) bytes") + VStack(alignment: .leading, spacing: 12) { + LazyVGrid(columns: [GridItem(.adaptive(minimum: 130), spacing: 10)], spacing: 10) { + MetricCard(title: "Status", value: flow.responseStatus.map(String.init) ?? "Pending") + MetricCard(title: "Duration", value: durationValue) + MetricCard(title: "Request", value: byteString(flow.requestBody.count)) + MetricCard(title: "Response", value: byteString(flow.responseBody.count)) + } + + DetailPanel(title: "Request") { + row("Scheme", flow.scheme.rawValue) + row("Host", "\(flow.host):\(flow.port)") + row("Path", flow.path) + row("Method", flow.method) + } + + DetailPanel(title: "Timing") { + row("Started", flow.startedAt.formatted(date: .abbreviated, time: .standard)) + row("Finished", flow.finishedAt?.formatted(date: .abbreviated, time: .standard) ?? "Pending") + } } } @@ -130,7 +173,8 @@ private struct FlowInspector: View { .foregroundStyle(.secondary) Text(value) .textSelection(.enabled) - .font(.system(.body, design: .monospaced)) + .font(.system(.callout, design: .monospaced)) + .frame(maxWidth: .infinity, alignment: .leading) } } @@ -159,16 +203,68 @@ private struct FlowInspector: View { default: return .secondary } } + + private var durationValue: String { + guard let finishedAt = flow.finishedAt else { return "Pending" } + return formatDuration(flow.startedAt, finishedAt) + } + + private func byteString(_ count: Int) -> String { + let formatter = ByteCountFormatter() + formatter.countStyle = .file + return formatter.string(fromByteCount: Int64(count)) + } +} + +private struct MetricCard: View { + let title: String + let value: String + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + Text(title) + .font(.caption) + .foregroundStyle(.secondary) + Text(value) + .font(.system(.callout, design: .monospaced).weight(.semibold)) + .lineLimit(1) + .minimumScaleFactor(0.8) + } + .padding(12) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color.primary.opacity(0.045), in: RoundedRectangle(cornerRadius: 8)) + } } -private struct HeadersSection: View { +private struct DetailPanel: View { let title: String - let headers: [HTTPHeader] + let content: Content + + init(title: String, @ViewBuilder content: () -> Content) { + self.title = title + self.content = content() + } var body: some View { - VStack(alignment: .leading, spacing: 8) { + VStack(alignment: .leading, spacing: 10) { Text(title) .font(.headline) + VStack(alignment: .leading, spacing: 8) { + content + } + .padding(12) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color.primary.opacity(0.035), in: RoundedRectangle(cornerRadius: 8)) + } + } +} + +private struct HeadersSection: View { + let title: String + let headers: [HTTPHeader] + + var body: some View { + DetailPanel(title: title) { if headers.isEmpty { Text("No headers") .foregroundStyle(.tertiary) @@ -189,7 +285,6 @@ private struct HeadersSection: View { } } } - .padding(.bottom, 16) } } @@ -199,9 +294,7 @@ private struct BodySection: View { let headers: [HTTPHeader] var body: some View { - VStack(alignment: .leading, spacing: 8) { - Text(title) - .font(.headline) + DetailPanel(title: title) { if bodyData.isEmpty { Text("Empty body") .foregroundStyle(.tertiary) diff --git a/macos/Sources/ReverseAPI/UI/TrafficListView.swift b/macos/Sources/ReverseAPI/UI/TrafficListView.swift index 3d75e91..33e87be 100644 --- a/macos/Sources/ReverseAPI/UI/TrafficListView.swift +++ b/macos/Sources/ReverseAPI/UI/TrafficListView.swift @@ -7,7 +7,13 @@ struct TrafficListView: View { var body: some View { @Bindable var bindable = state VStack(spacing: 0) { - FilterBar(filter: $bindable.filter, hostOptions: hostOptions, methodOptions: methodOptions) + FilterBar( + filter: $bindable.filter, + hostOptions: hostOptions, + methodOptions: methodOptions, + totalCount: state.store.flows.count, + visibleCount: filteredFlows.count + ) Divider() ZStack { table @@ -18,7 +24,12 @@ struct TrafficListView: View { } } } - .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 8)) + .background(Color(nsColor: .controlBackgroundColor), in: RoundedRectangle(cornerRadius: 10)) + .overlay { + RoundedRectangle(cornerRadius: 10) + .stroke(.separator.opacity(0.5), lineWidth: 1) + } + .clipShape(RoundedRectangle(cornerRadius: 10)) } private var filteredFlows: [CapturedFlow] { @@ -109,64 +120,37 @@ private struct FilterBar: View { @Binding var filter: TrafficFilter let hostOptions: [String] let methodOptions: [String] + let totalCount: Int + let visibleCount: Int var body: some View { - VStack(alignment: .leading, spacing: 8) { - HStack(spacing: 12) { - Text("Traffic") - .font(.headline) - - HStack(spacing: 8) { - Image(systemName: "magnifyingglass") + VStack(alignment: .leading, spacing: 10) { + HStack(alignment: .firstTextBaseline, spacing: 12) { + VStack(alignment: .leading, spacing: 2) { + Text("Traffic") + .font(.title3.weight(.semibold)) + Text(countLabel) + .font(.caption) .foregroundStyle(.secondary) - TextField("Filter by URL or method", text: $filter.search) - .textFieldStyle(.plain) + .monospacedDigit() } - .padding(.horizontal, 10) - .padding(.vertical, 6) - .background(.quaternary, in: RoundedRectangle(cornerRadius: 6)) - .frame(maxWidth: 320) - - Menu { - Toggle("Errors only", isOn: $filter.onlyErrors) - Divider() - Section("Types") { - ForEach(TrafficFilter.ResourceKind.allCases) { kind in - toggleResource(kind) - } - } - Section("Methods") { - ForEach(methodOptions, id: \.self) { method in - toggle(method, in: \.methods) - } - } - Section("Hosts") { - ForEach(hostOptions, id: \.self) { host in - toggle(host, in: \.hosts) - } - } - Section("Status") { - ForEach(TrafficFilter.StatusBucket.allCases) { bucket in - toggleBucket(bucket) - } - } - Divider() - Button("Reset") { filter = TrafficFilter() } - } label: { - Label("Filters", systemImage: "line.3.horizontal.decrease") - } - .menuStyle(.borderlessButton) - .fixedSize() + Spacer() + + searchField + .frame(width: 300) + + filterMenu if activeFilterCount > 0 { - Button("Reset \(activeFilterCount)", systemImage: "xmark.circle.fill") { + Button { filter = TrafficFilter() + } label: { + Label("Reset", systemImage: "xmark.circle.fill") } .buttonStyle(.borderless) .foregroundStyle(.secondary) + .help("Clear \(activeFilterCount) active filters") } - - Spacer() } ResourceKindBar(selectedKinds: $filter.resourceKinds) @@ -175,6 +159,58 @@ private struct FilterBar: View { .padding(.vertical, 8) } + private var searchField: some View { + HStack(spacing: 8) { + Image(systemName: "magnifyingglass") + .foregroundStyle(.secondary) + TextField("Search URL, host, or method", text: $filter.search) + .textFieldStyle(.plain) + } + .padding(.horizontal, 10) + .padding(.vertical, 7) + .background(Color.primary.opacity(0.055), in: RoundedRectangle(cornerRadius: 7)) + } + + private var filterMenu: some View { + Menu { + Toggle("Errors only", isOn: $filter.onlyErrors) + Divider() + Section("Types") { + ForEach(TrafficFilter.ResourceKind.allCases) { kind in + toggleResource(kind) + } + } + Section("Methods") { + ForEach(methodOptions, id: \.self) { method in + toggle(method, in: \.methods) + } + } + Section("Hosts") { + ForEach(hostOptions, id: \.self) { host in + toggle(host, in: \.hosts) + } + } + Section("Status") { + ForEach(TrafficFilter.StatusBucket.allCases) { bucket in + toggleBucket(bucket) + } + } + Divider() + Button("Reset") { filter = TrafficFilter() } + } label: { + Label("Filters", systemImage: "line.3.horizontal.decrease") + } + .menuStyle(.borderlessButton) + .fixedSize() + } + + private var countLabel: String { + if activeFilterCount > 0 { + return "\(visibleCount) of \(totalCount) shown" + } + return "\(totalCount) captured" + } + private var activeFilterCount: Int { var count = 0 if !filter.search.isEmpty { count += 1 } @@ -236,17 +272,19 @@ private struct ResourceKindBar: View { @Binding var selectedKinds: Set var body: some View { - HStack(spacing: 6) { - ResourceKindFilterButton(title: "All", isSelected: selectedKinds.isEmpty) { - selectedKinds.removeAll() - } + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 6) { + ResourceKindFilterButton(title: "All", isSelected: selectedKinds.isEmpty) { + selectedKinds.removeAll() + } - ForEach(TrafficFilter.ResourceKind.allCases) { kind in - ResourceKindFilterButton(title: kind.rawValue, isSelected: selectedKinds.contains(kind)) { - if selectedKinds.contains(kind) { - selectedKinds.remove(kind) - } else { - selectedKinds.insert(kind) + ForEach(TrafficFilter.ResourceKind.allCases) { kind in + ResourceKindFilterButton(title: kind.rawValue, isSelected: selectedKinds.contains(kind)) { + if selectedKinds.contains(kind) { + selectedKinds.remove(kind) + } else { + selectedKinds.insert(kind) + } } } } @@ -264,13 +302,17 @@ private struct ResourceKindFilterButton: View { Button(action: action) { Text(title) .font(.caption.weight(.medium)) - .foregroundStyle(isSelected ? Color.accentColor : Color.secondary) - .padding(.horizontal, 8) - .padding(.vertical, 4) + .foregroundStyle(isSelected ? Color.primary : Color.secondary) + .padding(.horizontal, 9) + .padding(.vertical, 5) .background( - isSelected ? Color.accentColor.opacity(0.14) : Color.secondary.opacity(0.08), + isSelected ? Color.accentColor.opacity(0.18) : Color.primary.opacity(0.045), in: RoundedRectangle(cornerRadius: 6) ) + .overlay { + RoundedRectangle(cornerRadius: 6) + .stroke(isSelected ? Color.accentColor.opacity(0.55) : .clear, lineWidth: 1) + } } .help(title == "All" ? "Show all resource types" : "Toggle \(title) resources") } @@ -315,11 +357,11 @@ private struct ResourceKindBadge: View { private var color: Color { switch kind { case .document: return .primary - case .fetch: return .cyan - case .script: return .yellow + case .fetch: return .blue + case .script: return .orange case .stylesheet: return .blue case .image: return .green - case .media: return .pink + case .media: return .red case .font: return .indigo case .websocket: return .purple case .other: return .secondary @@ -332,9 +374,14 @@ private struct EmptyTrafficState: View { var body: some View { VStack(spacing: 14) { - Image(systemName: state.isCapturing ? "dot.radiowaves.left.and.right" : "waveform.path.ecg.rectangle") - .font(.system(size: 38)) - .foregroundStyle(state.isCapturing ? Color.green : Color.secondary) + ZStack { + RoundedRectangle(cornerRadius: 16) + .fill(Color.primary.opacity(0.055)) + .frame(width: 62, height: 62) + Image(systemName: state.isCapturing ? "dot.radiowaves.left.and.right" : "waveform.path.ecg.rectangle") + .font(.system(size: 28, weight: .medium)) + .foregroundStyle(state.isCapturing ? Color.green : Color.secondary) + } VStack(spacing: 5) { Text(title) @@ -347,16 +394,14 @@ private struct EmptyTrafficState: View { } HStack(spacing: 8) { - Label(state.systemProxyEnabled ? "Device routed" : "Device not routed", systemImage: "network") - Label(state.caTrustInstalled ? "CA trusted" : "CA not trusted", systemImage: "seal") - Label("127.0.0.1:\(state.port)", systemImage: "number") + EmptyStatePill(title: state.systemProxyEnabled ? "Device routed" : "Device not routed", systemImage: "network") + EmptyStatePill(title: state.caTrustInstalled ? "CA trusted" : "CA not trusted", systemImage: "seal") + EmptyStatePill(title: "127.0.0.1:\(state.port)", systemImage: "number") } - .font(.caption) - .foregroundStyle(.secondary) } .padding(28) .frame(maxWidth: .infinity, maxHeight: .infinity) - .background(.regularMaterial) + .background(Color(nsColor: .controlBackgroundColor)) } private var title: String { @@ -382,9 +427,14 @@ private struct EmptyTrafficState: View { private struct EmptyFilterState: View { var body: some View { VStack(spacing: 10) { - Image(systemName: "line.3.horizontal.decrease.circle") - .font(.system(size: 32)) - .foregroundStyle(.secondary) + ZStack { + RoundedRectangle(cornerRadius: 14) + .fill(Color.primary.opacity(0.055)) + .frame(width: 56, height: 56) + Image(systemName: "line.3.horizontal.decrease.circle") + .font(.system(size: 26, weight: .medium)) + .foregroundStyle(.secondary) + } Text("No matching traffic") .font(.headline) Text("Clear or loosen the current filters.") @@ -393,7 +443,21 @@ private struct EmptyFilterState: View { } .padding(28) .frame(maxWidth: .infinity, maxHeight: .infinity) - .background(.regularMaterial) + .background(Color(nsColor: .controlBackgroundColor)) + } +} + +private struct EmptyStatePill: View { + let title: String + let systemImage: String + + var body: some View { + Label(title, systemImage: systemImage) + .font(.caption) + .foregroundStyle(.secondary) + .padding(.horizontal, 8) + .padding(.vertical, 5) + .background(Color.primary.opacity(0.055), in: RoundedRectangle(cornerRadius: 6)) } } From f649db4aa26b91dccd557de11df9345e601a442c Mon Sep 17 00:00:00 2001 From: kalil0321 Date: Tue, 19 May 2026 23:57:29 +0200 Subject: [PATCH 10/13] fix(macos): stream captured responses --- .../Sources/ReverseAPI/UI/InspectorView.swift | 74 ++++++++++++ .../ReverseAPIProxy/Proxy/ProxyHandler.swift | 28 +---- .../ReverseAPIProxy/Proxy/UpstreamPump.swift | 107 ++++++++++++------ 3 files changed, 151 insertions(+), 58 deletions(-) diff --git a/macos/Sources/ReverseAPI/UI/InspectorView.swift b/macos/Sources/ReverseAPI/UI/InspectorView.swift index f43e00e..b520a0d 100644 --- a/macos/Sources/ReverseAPI/UI/InspectorView.swift +++ b/macos/Sources/ReverseAPI/UI/InspectorView.swift @@ -1,3 +1,4 @@ +import AppKit import SwiftUI import ReverseAPIProxy @@ -101,6 +102,7 @@ private struct FlowInspector: View { .foregroundStyle(.secondary) .font(.callout) } + copyMenu } VStack(alignment: .leading, spacing: 2) { Text(flow.host) @@ -125,6 +127,26 @@ private struct FlowInspector: View { .padding(.bottom, 10) } + private var copyMenu: some View { + Menu { + Button("Copy request", systemImage: "arrow.up.doc") { + copyToPasteboard(requestCopyText) + } + Button("Copy response", systemImage: "arrow.down.doc") { + copyToPasteboard(responseCopyText) + } + Divider() + Button("Copy URL", systemImage: "link") { + copyToPasteboard(flow.url) + } + } label: { + Label("Copy", systemImage: "doc.on.doc") + } + .menuStyle(.borderlessButton) + .fixedSize() + .help("Copy this request or response") + } + @ViewBuilder private var content: some View { switch tab { @@ -214,6 +236,58 @@ private struct FlowInspector: View { formatter.countStyle = .file return formatter.string(fromByteCount: Int64(count)) } + + private var requestCopyText: String { + var lines = ["\(flow.method) \(flow.path) HTTP/1.1"] + lines.append(contentsOf: headerLines(flow.requestHeaders)) + return copyText(headLines: lines, body: flow.requestBody, headers: flow.requestHeaders) + } + + private var responseCopyText: String { + var lines = ["HTTP/1.1 \(flow.responseStatus.map(String.init) ?? "pending")"] + lines.append(contentsOf: headerLines(flow.responseHeaders)) + return copyText(headLines: lines, body: flow.responseBody, headers: flow.responseHeaders) + } + + private func headerLines(_ headers: [HTTPHeader]) -> [String] { + headers.map { "\($0.name): \($0.value)" } + } + + private func copyText(headLines: [String], body: Data, headers: [HTTPHeader]) -> String { + guard !body.isEmpty else { + return headLines.joined(separator: "\n") + "\n\n" + } + let bodyText = copyableBody(body, headers: headers) + return headLines.joined(separator: "\n") + "\n\n" + bodyText + } + + private func copyableBody(_ data: Data, headers: [HTTPHeader]) -> String { + if let pretty = JSONFormatter.prettyPrintJSON(data, contentType: contentType(in: headers)) { + return pretty + } + if let text = String(data: data, encoding: .utf8), looksLikeText(headers) { + return text + } + return """ + Binary body: \(data.count) bytes + Base64: + \(data.base64EncodedString()) + """ + } + + private func contentType(in headers: [HTTPHeader]) -> String? { + headers.first(where: { $0.name.lowercased() == "content-type" })?.value + } + + private func looksLikeText(_ headers: [HTTPHeader]) -> Bool { + guard let ct = contentType(in: headers)?.lowercased() else { return false } + return ct.contains("text") || ct.contains("xml") || ct.contains("javascript") || ct.contains("html") + } + + private func copyToPasteboard(_ text: String) { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(text, forType: .string) + } } private struct MetricCard: View { diff --git a/macos/Sources/ReverseAPIProxy/Proxy/ProxyHandler.swift b/macos/Sources/ReverseAPIProxy/Proxy/ProxyHandler.swift index 18bd6cd..17656be 100644 --- a/macos/Sources/ReverseAPIProxy/Proxy/ProxyHandler.swift +++ b/macos/Sources/ReverseAPIProxy/Proxy/ProxyHandler.swift @@ -102,36 +102,19 @@ final class ProxyHandler: ChannelInboundHandler, RemovableChannelHandler, @unche Task { await proxyContext.bus.emit(.started(flow)) do { - let response = try await proxyContext.upstream.send( + let captured = try await proxyContext.upstream.forward( scheme: inflight.scheme, host: inflight.host, port: inflight.port, method: inflight.head.method, uri: inflight.path, headers: headersForUpstream, - body: inflight.body + body: inflight.body, + downstream: channel, + initialFlow: flow, + maxCaptureBodyBytes: maxBodyBytes ) - var captured = flow - captured.responseStatus = Int(response.status.code) - captured.responseHeaders = response.headers.map { HTTPHeader($0.name, $0.value) } - captured.responseBody = Data(buffer: response.body) - captured.finishedAt = Date() await proxyContext.bus.emit(.finished(captured)) - - eventLoop.execute { - var head = HTTPResponseHead(version: response.version, status: response.status, headers: response.headers) - head.headers.replaceOrAdd(name: "Connection", value: "close") - head.headers.remove(name: "Transfer-Encoding") - head.headers.replaceOrAdd(name: "Content-Length", value: String(response.body.readableBytes)) - - channel.write(HTTPServerResponsePart.head(head), promise: nil) - if response.body.readableBytes > 0 { - channel.write(HTTPServerResponsePart.body(.byteBuffer(response.body)), promise: nil) - } - channel.writeAndFlush(HTTPServerResponsePart.end(nil)).whenComplete { _ in - channel.close(promise: nil) - } - } } catch { var failed = flow failed.error = "\(error)" @@ -201,6 +184,7 @@ final class ProxyHandler: ChannelInboundHandler, RemovableChannelHandler, @unche headers.remove(name: "Proxy-Connection") headers.remove(name: "Proxy-Authorization") headers.replaceOrAdd(name: "Connection", value: "close") + headers.replaceOrAdd(name: "Accept-Encoding", value: "identity") } private func makeFlow(from inflight: InflightRequest) -> CapturedFlow { diff --git a/macos/Sources/ReverseAPIProxy/Proxy/UpstreamPump.swift b/macos/Sources/ReverseAPIProxy/Proxy/UpstreamPump.swift index d54fa1f..1c66c41 100644 --- a/macos/Sources/ReverseAPIProxy/Proxy/UpstreamPump.swift +++ b/macos/Sources/ReverseAPIProxy/Proxy/UpstreamPump.swift @@ -5,17 +5,8 @@ import NIOPosix import NIOHTTP1 import NIOSSL -struct UpstreamResponse: Sendable { - let status: HTTPResponseStatus - let version: HTTPVersion - let headers: HTTPHeaders - let body: ByteBuffer -} - enum UpstreamError: Error { case connectionClosed - case missingResponse - case unexpected(String) } actor UpstreamPump { @@ -27,17 +18,24 @@ actor UpstreamPump { self.logger = logger } - func send( + func forward( scheme: CapturedFlow.Scheme, host: String, port: Int, method: HTTPMethod, uri: String, headers: HTTPHeaders, - body: ByteBuffer - ) async throws -> UpstreamResponse { - let collector = ResponseCollector() - let resultTask = Task { try await collector.awaitResponse() } + body: ByteBuffer, + downstream: Channel, + initialFlow: CapturedFlow, + maxCaptureBodyBytes: Int + ) async throws -> CapturedFlow { + let forwarder = StreamingResponseForwarder( + downstream: downstream, + initialFlow: initialFlow, + maxCaptureBodyBytes: maxCaptureBodyBytes + ) + let resultTask = Task { try await forwarder.awaitResponse() } let bootstrap = ClientBootstrap(group: group) .channelOption(ChannelOptions.socketOption(.so_reuseaddr), value: 1) @@ -46,7 +44,7 @@ actor UpstreamPump { do { channel = try await bootstrap.connect(host: host, port: port).get() } catch { - collector.cancel(with: error) + forwarder.cancel(with: error) resultTask.cancel() throw error } @@ -63,7 +61,7 @@ actor UpstreamPump { } try await channel.pipeline.addHTTPClientHandlers().get() - try await channel.pipeline.addHandler(collector).get() + try await channel.pipeline.addHandler(forwarder).get() var requestHeaders = headers requestHeaders.replaceOrAdd(name: "Host", value: hostHeaderValue(host: host, port: port, scheme: scheme)) @@ -83,21 +81,20 @@ actor UpstreamPump { } try await channel.writeAndFlush(HTTPClientRequestPart.end(nil)).get() } catch { - collector.cancel(with: error) + forwarder.cancel(with: error) try? await channel.close().get() throw error } do { - let response = try await resultTask.value + let flow = try await resultTask.value try? await channel.close().get() - return response + return flow } catch { try? await channel.close().get() throw error } } - private func hostHeaderValue(host: String, port: Int, scheme: CapturedFlow.Scheme) -> String { let bracketed = host.contains(":") && !host.hasPrefix("[") ? "[\(host)]" : host switch (scheme, port) { @@ -107,22 +104,31 @@ actor UpstreamPump { } } -private final class ResponseCollector: ChannelInboundHandler, @unchecked Sendable { +private final class StreamingResponseForwarder: ChannelInboundHandler, @unchecked Sendable { typealias InboundIn = HTTPClientResponsePart private struct State { - var head: HTTPResponseHead? + var flow: CapturedFlow var body: ByteBuffer = ByteBufferAllocator().buffer(capacity: 0) - var continuation: CheckedContinuation? - var result: Result? + var continuation: CheckedContinuation? + var result: Result? var settled = false + var capturedBytes = 0 } - private let lock = NIOLockedValueBox(State()) + private let downstream: Channel + private let maxCaptureBodyBytes: Int + private let lock: NIOLockedValueBox + + init(downstream: Channel, initialFlow: CapturedFlow, maxCaptureBodyBytes: Int) { + self.downstream = downstream + self.maxCaptureBodyBytes = max(1024, maxCaptureBodyBytes) + self.lock = NIOLockedValueBox(State(flow: initialFlow)) + } - func awaitResponse() async throws -> UpstreamResponse { - try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in - let pending: Result? = lock.withLockedValue { state in + func awaitResponse() async throws -> CapturedFlow { + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + let pending: Result? = lock.withLockedValue { state in if state.settled, let result = state.result { return result } @@ -146,24 +152,53 @@ private final class ResponseCollector: ChannelInboundHandler, @unchecked Sendabl switch unwrapInboundIn(data) { case .head(let head): lock.withLockedValue { state in - state.head = head + state.flow.responseStatus = Int(head.status.code) + state.flow.responseHeaders = head.headers.map { HTTPHeader($0.name, $0.value) } + } + + var responseHead = HTTPResponseHead(version: head.version, status: head.status, headers: head.headers) + responseHead.headers.replaceOrAdd(name: "Connection", value: "close") + downstream.eventLoop.execute { + self.downstream.write(HTTPServerResponsePart.head(responseHead), promise: nil) } + case .body(let buffer): lock.withLockedValue { state in + let remaining = maxCaptureBodyBytes - state.capturedBytes + guard remaining > 0 else { return } var copy = buffer - state.body.writeBuffer(©) + let captureLength = min(copy.readableBytes, remaining) + if var slice = copy.readSlice(length: captureLength) { + state.body.writeBuffer(&slice) + state.capturedBytes += captureLength + } + } + + downstream.eventLoop.execute { + self.downstream.write(HTTPServerResponsePart.body(.byteBuffer(buffer)), promise: nil) } + case .end: - let response = lock.withLockedValue { state -> UpstreamResponse in - let head = state.head ?? HTTPResponseHead(version: .http1_1, status: .internalServerError) - return UpstreamResponse(status: head.status, version: head.version, headers: head.headers, body: state.body) + let flow = lock.withLockedValue { state -> CapturedFlow in + var finished = state.flow + finished.responseBody = Data(buffer: state.body) + finished.finishedAt = Date() + return finished } - finish(.success(response)) + downstream.eventLoop.execute { + self.downstream.writeAndFlush(HTTPServerResponsePart.end(nil)).whenComplete { _ in + self.downstream.close(promise: nil) + } + } + finish(.success(flow)) } } func errorCaught(context: ChannelHandlerContext, error: Error) { finish(.failure(error)) + downstream.eventLoop.execute { + self.downstream.close(promise: nil) + } context.close(promise: nil) } @@ -171,8 +206,8 @@ private final class ResponseCollector: ChannelInboundHandler, @unchecked Sendabl finish(.failure(UpstreamError.connectionClosed)) } - private func finish(_ result: Result) { - let pending: CheckedContinuation? = lock.withLockedValue { state in + private func finish(_ result: Result) { + let pending: CheckedContinuation? = lock.withLockedValue { state in if state.settled { return nil } state.settled = true state.result = result From 91f017fb6a33e77ee1e0d7ab6d6640f565e17859 Mon Sep 17 00:00:00 2001 From: kalil0321 Date: Wed, 20 May 2026 00:02:56 +0200 Subject: [PATCH 11/13] fix(macos): polish capture workspace --- .../ReverseAPI/App/ReverseAPIApp.swift | 9 +- .../ReverseAPI/UI/CaptureToolbar.swift | 215 ++++++++++++++---- macos/Sources/ReverseAPI/UI/ContentView.swift | 87 ++++++- .../Sources/ReverseAPI/UI/TrafficFilter.swift | 29 ++- .../Sources/ReverseAPIProxy/CA/CAStore.swift | 90 ++------ .../ReverseAPITests/TrafficFilterTests.swift | 16 ++ 6 files changed, 326 insertions(+), 120 deletions(-) diff --git a/macos/Sources/ReverseAPI/App/ReverseAPIApp.swift b/macos/Sources/ReverseAPI/App/ReverseAPIApp.swift index d439872..339f193 100644 --- a/macos/Sources/ReverseAPI/App/ReverseAPIApp.swift +++ b/macos/Sources/ReverseAPI/App/ReverseAPIApp.swift @@ -5,12 +5,13 @@ import SwiftUI struct ReverseAPIApp: App { @NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate @State private var session = AppSession.live() + @AppStorage("rae.sidebar.visible") private var isSidebarVisible = true var body: some Scene { Window("rae", id: "main") { switch session { case .ready(let state): - ContentView() + ContentView(isSidebarVisible: $isSidebarVisible) .environment(state) .onAppear { AppLifecycle.shared.state = state @@ -34,6 +35,12 @@ struct ReverseAPIApp: App { .windowToolbarStyle(.unifiedCompact) .commands { CommandGroup(replacing: .newItem) {} + CommandGroup(after: .toolbar) { + Button(isSidebarVisible ? "Hide Sidebar" : "Show Sidebar") { + isSidebarVisible.toggle() + } + .keyboardShortcut("b", modifiers: .command) + } } } } diff --git a/macos/Sources/ReverseAPI/UI/CaptureToolbar.swift b/macos/Sources/ReverseAPI/UI/CaptureToolbar.swift index e0098b4..9ee76c9 100644 --- a/macos/Sources/ReverseAPI/UI/CaptureToolbar.swift +++ b/macos/Sources/ReverseAPI/UI/CaptureToolbar.swift @@ -2,26 +2,23 @@ import SwiftUI struct CaptureToolbar: View { @Environment(AppState.self) private var state + @Binding var isSidebarVisible: Bool var body: some View { @Bindable var bindable = state - return VStack(alignment: .leading, spacing: 18) { + return VStack(alignment: .leading, spacing: 20) { brandHeader - VStack(spacing: 10) { + VStack(alignment: .leading, spacing: 10) { + SidebarSectionLabel("Capture") captureButton - Picker("Capture mode", selection: $bindable.captureMode) { - ForEach(AppState.CaptureMode.allCases) { mode in - Text(mode.rawValue).tag(mode) - } - } - .labelsHidden() - .pickerStyle(.segmented) - .disabled(state.isCapturing || state.isWorking) + CaptureModePicker(selection: $bindable.captureMode) + .disabled(state.isCapturing || state.isWorking) } VStack(alignment: .leading, spacing: 8) { + SidebarSectionLabel("Readiness") SidebarStatusRow( title: state.isCapturing ? "Proxy running" : "Proxy stopped", detail: "127.0.0.1:\(state.port)", @@ -30,7 +27,7 @@ struct CaptureToolbar: View { ) SidebarStatusRow( title: state.systemProxyEnabled ? "Device routed" : "Device not routed", - detail: state.captureMode == .device ? "Automatic capture" : "Manual clients only", + detail: state.captureMode == .device ? "This Mac is automatic" : "Manual clients only", systemImage: "network", tint: state.systemProxyEnabled ? .green : .orange ) @@ -43,9 +40,7 @@ struct CaptureToolbar: View { } VStack(alignment: .leading, spacing: 8) { - Text("Actions") - .font(.caption.weight(.semibold)) - .foregroundStyle(.secondary) + SidebarSectionLabel("Actions") trustButton systemProxyButton clearButton @@ -90,26 +85,37 @@ struct CaptureToolbar: View { } private var brandHeader: some View { - VStack(alignment: .leading, spacing: 8) { - HStack(spacing: 10) { - ZStack { - RoundedRectangle(cornerRadius: 9) - .fill(Color.accentColor.opacity(0.16)) - Image(systemName: "waveform.path.ecg") - .foregroundStyle(Color.accentColor) - .font(.system(size: 17, weight: .semibold)) - } - .frame(width: 34, height: 34) + HStack(spacing: 10) { + ZStack { + RoundedRectangle(cornerRadius: 10) + .fill(Color.accentColor.opacity(0.16)) + Image(systemName: "waveform.path.ecg") + .foregroundStyle(Color.accentColor) + .font(.system(size: 17, weight: .semibold)) + } + .frame(width: 36, height: 36) - VStack(alignment: .leading, spacing: 1) { - Text("rae") - .font(.system(.title3, design: .rounded).weight(.semibold)) - Text(statusLine) - .font(.caption) - .foregroundStyle(.secondary) - .lineLimit(2) - } + VStack(alignment: .leading, spacing: 1) { + Text("rae") + .font(.system(.title3, design: .rounded).weight(.semibold)) + Text(statusLine) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(2) } + + Spacer() + + Button { + isSidebarVisible = false + } label: { + Image(systemName: "sidebar.left") + .font(.system(size: 15, weight: .medium)) + .frame(width: 28, height: 28) + } + .buttonStyle(.plain) + .foregroundStyle(.secondary) + .help("Hide sidebar") } } @@ -117,14 +123,40 @@ struct CaptureToolbar: View { Button { Task { await state.toggleCapture() } } label: { - Label(captureTitle, systemImage: captureIcon) - .font(.headline) - .frame(maxWidth: .infinity) - .padding(.vertical, 5) + HStack(spacing: 12) { + ZStack { + Circle() + .fill(captureButtonForeground.opacity(0.16)) + Image(systemName: captureIcon) + .font(.system(size: 18, weight: .semibold)) + .foregroundStyle(captureButtonForeground) + } + .frame(width: 42, height: 42) + + VStack(alignment: .leading, spacing: 2) { + Text(captureTitle) + .font(.headline.weight(.semibold)) + Text(captureSubtitle) + .font(.caption) + .foregroundStyle(captureButtonForeground.opacity(0.78)) + .lineLimit(2) + } + + Spacer() + + Image(systemName: state.isCapturing ? "stop.fill" : "arrow.right") + .font(.system(size: 13, weight: .bold)) + .foregroundStyle(captureButtonForeground.opacity(0.85)) + } + .padding(14) + .frame(maxWidth: .infinity, minHeight: 82) + .background(captureButtonBackground, in: RoundedRectangle(cornerRadius: 12)) + .overlay { + RoundedRectangle(cornerRadius: 12) + .stroke(captureButtonForeground.opacity(0.26), lineWidth: 1) + } } - .buttonStyle(.borderedProminent) - .controlSize(.large) - .tint(state.isCapturing ? .red.opacity(0.86) : .accentColor) + .buttonStyle(.plain) .disabled(state.isWorking) .help(state.captureMode == .device ? "Start proxy capture and route macOS HTTP/HTTPS traffic through it" @@ -187,7 +219,7 @@ struct CaptureToolbar: View { private var captureTitle: String { if state.isWorking { return "Working" } if state.isCapturing { return "Stop capture" } - return state.captureMode == .device ? "Start device capture" : "Start manual capture" + return "Start capture" } private var captureIcon: String { @@ -195,6 +227,31 @@ struct CaptureToolbar: View { return state.isCapturing ? "stop.circle.fill" : "record.circle" } + private var captureSubtitle: String { + if state.isWorking { return "Applying changes" } + if state.isCapturing, state.systemProxyEnabled { return "Capturing traffic from this Mac" } + if state.isCapturing { return "Listening on 127.0.0.1:\(state.port)" } + return state.captureMode == .device + ? "Routes this Mac through rae automatically" + : "Only records apps configured to use the proxy" + } + + private var captureButtonForeground: Color { + if state.isCapturing { return .red } + return .accentColor + } + + private var captureButtonBackground: LinearGradient { + LinearGradient( + colors: [ + captureButtonForeground.opacity(0.18), + captureButtonForeground.opacity(0.08), + ], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + } + private var statusLine: String { if state.isCapturing, state.systemProxyEnabled { return "Recording this Mac" } if state.isCapturing { return "Manual proxy active" } @@ -202,6 +259,84 @@ struct CaptureToolbar: View { } } +private struct SidebarSectionLabel: View { + let title: String + + init(_ title: String) { + self.title = title + } + + var body: some View { + Text(title) + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + } +} + +private struct CaptureModePicker: View { + @Binding var selection: AppState.CaptureMode + + var body: some View { + HStack(spacing: 8) { + CaptureModeButton( + title: "This Mac", + detail: "Route device traffic", + systemImage: "desktopcomputer", + isSelected: selection == .device + ) { + selection = .device + } + CaptureModeButton( + title: "Manual", + detail: "Use proxy address", + systemImage: "point.topleft.down.curvedto.point.bottomright.up", + isSelected: selection == .manual + ) { + selection = .manual + } + } + } +} + +private struct CaptureModeButton: View { + let title: String + let detail: String + let systemImage: String + let isSelected: Bool + let action: () -> Void + + var body: some View { + Button(action: action) { + VStack(alignment: .leading, spacing: 8) { + HStack { + Image(systemName: systemImage) + .font(.system(size: 14, weight: .semibold)) + Spacer() + if isSelected { + Image(systemName: "checkmark.circle.fill") + .font(.system(size: 13, weight: .semibold)) + } + } + Text(title) + .font(.callout.weight(.semibold)) + Text(detail) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(2) + } + .padding(10) + .frame(maxWidth: .infinity, minHeight: 94, alignment: .topLeading) + .foregroundStyle(isSelected ? Color.primary : Color.secondary) + .background(isSelected ? Color.accentColor.opacity(0.14) : Color.primary.opacity(0.035), in: RoundedRectangle(cornerRadius: 10)) + .overlay { + RoundedRectangle(cornerRadius: 10) + .stroke(isSelected ? Color.accentColor.opacity(0.55) : Color.primary.opacity(0.05), lineWidth: 1) + } + } + .buttonStyle(.plain) + } +} + private struct SidebarStatusRow: View { let title: String let detail: String diff --git a/macos/Sources/ReverseAPI/UI/ContentView.swift b/macos/Sources/ReverseAPI/UI/ContentView.swift index 8203949..73e5672 100644 --- a/macos/Sources/ReverseAPI/UI/ContentView.swift +++ b/macos/Sources/ReverseAPI/UI/ContentView.swift @@ -4,12 +4,19 @@ import AppKit struct ContentView: View { @Environment(AppState.self) private var state + @Binding var isSidebarVisible: Bool var body: some View { HStack(spacing: 0) { - CaptureToolbar() - .frame(width: 296) - Divider() + if isSidebarVisible { + CaptureToolbar(isSidebarVisible: $isSidebarVisible) + .frame(width: 312) + Divider() + } else { + CollapsedSidebarRail(isSidebarVisible: $isSidebarVisible) + Divider() + } + HSplitView { TrafficListView() .frame(minWidth: 600, maxHeight: .infinity) @@ -17,10 +24,10 @@ struct ContentView: View { .frame(minWidth: 460, maxHeight: .infinity) } .frame(maxWidth: .infinity, maxHeight: .infinity) - .padding(14) } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) .background(Color(nsColor: .windowBackgroundColor)) + .background(SidebarShortcutHandler(isSidebarVisible: $isSidebarVisible)) .task { await state.recoverStaleSystemProxyOnLaunch() } @@ -29,3 +36,75 @@ struct ContentView: View { } } } + +private struct CollapsedSidebarRail: View { + @Binding var isSidebarVisible: Bool + + var body: some View { + VStack(spacing: 14) { + Button { + isSidebarVisible = true + } label: { + Image(systemName: "sidebar.left") + .font(.system(size: 16, weight: .medium)) + .frame(width: 30, height: 30) + } + .buttonStyle(.plain) + .help("Show sidebar") + + Image(systemName: "waveform.path.ecg") + .font(.system(size: 18, weight: .semibold)) + .foregroundStyle(Color.accentColor) + + Spacer() + } + .padding(.top, 14) + .frame(minWidth: 48, idealWidth: 48, maxWidth: 48, maxHeight: .infinity) + .background(Color(nsColor: .controlBackgroundColor)) + } +} + +private struct SidebarShortcutHandler: NSViewRepresentable { + @Binding var isSidebarVisible: Bool + + func makeCoordinator() -> Coordinator { + Coordinator(isSidebarVisible: $isSidebarVisible) + } + + func makeNSView(context: Context) -> NSView { + context.coordinator.installMonitor() + return NSView(frame: .zero) + } + + func updateNSView(_ nsView: NSView, context: Context) { + context.coordinator.isSidebarVisible = $isSidebarVisible + } + + final class Coordinator { + var isSidebarVisible: Binding + private var monitor: Any? + + init(isSidebarVisible: Binding) { + self.isSidebarVisible = isSidebarVisible + } + + deinit { + if let monitor { + NSEvent.removeMonitor(monitor) + } + } + + func installMonitor() { + guard monitor == nil else { return } + monitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in + guard let self else { return event } + let modifiers = event.modifierFlags.intersection(.deviceIndependentFlagsMask) + let togglesSidebar = event.charactersIgnoringModifiers?.lowercased() == "b" && + (modifiers.contains(.command) || modifiers.contains(.control)) + guard togglesSidebar else { return event } + isSidebarVisible.wrappedValue.toggle() + return nil + } + } + } +} diff --git a/macos/Sources/ReverseAPI/UI/TrafficFilter.swift b/macos/Sources/ReverseAPI/UI/TrafficFilter.swift index 1a8eac8..5df4540 100644 --- a/macos/Sources/ReverseAPI/UI/TrafficFilter.swift +++ b/macos/Sources/ReverseAPI/UI/TrafficFilter.swift @@ -49,9 +49,13 @@ struct TrafficFilter: Equatable { return false } } - if !search.isEmpty { - let haystack = "\(flow.method) \(flow.url)".lowercased() - if !haystack.contains(search.lowercased()) { return false } + let terms = search + .lowercased() + .split(whereSeparator: \.isWhitespace) + .map(String.init) + if !terms.isEmpty { + let haystack = searchHaystack(for: flow) + if !terms.allSatisfy({ haystack.contains($0) }) { return false } } if !hosts.isEmpty, !hosts.contains(flow.host) { return false } if !methods.isEmpty, !methods.contains(flow.method) { return false } @@ -63,6 +67,25 @@ struct TrafficFilter: Equatable { return true } + private func searchHaystack(for flow: CapturedFlow) -> String { + var parts = [ + flow.method, + flow.url, + flow.host, + flow.path, + Self.resourceKind(for: flow).rawValue, + ] + if let status = flow.responseStatus { + parts.append(String(status)) + } + if let error = flow.error { + parts.append(error) + } + parts.append(contentsOf: flow.requestHeaders.flatMap { [$0.name, $0.value] }) + parts.append(contentsOf: flow.responseHeaders.flatMap { [$0.name, $0.value] }) + return parts.joined(separator: " ").lowercased() + } + static func resourceKind(for flow: CapturedFlow) -> ResourceKind { if isWebSocket(flow) { return .websocket } diff --git a/macos/Sources/ReverseAPIProxy/CA/CAStore.swift b/macos/Sources/ReverseAPIProxy/CA/CAStore.swift index 3d98fb5..427f7e3 100644 --- a/macos/Sources/ReverseAPIProxy/CA/CAStore.swift +++ b/macos/Sources/ReverseAPIProxy/CA/CAStore.swift @@ -2,30 +2,27 @@ import Foundation import Crypto import X509 import SwiftASN1 -import Security public enum CAStoreError: Error { case missingCertificateOnDisk - case missingPrivateKeyInKeychain - case keychainWriteFailed(OSStatus) - case keychainReadFailed(OSStatus) - case keychainDeleteFailed(OSStatus) + case missingPrivateKeyOnDisk case invalidStoredPrivateKey case certificateDeleteFailed(any Error) + case privateKeyDeleteFailed(any Error) } public final class CAStore: @unchecked Sendable { public let directory: URL public let certificateURL: URL - private let keychainService = "app.reverseapi" - private let keychainAccount = "ca.root-private-key" + private let privateKeyURL: URL public init(applicationSupportURL: URL) throws { let root = applicationSupportURL.appendingPathComponent("ReverseAPI", isDirectory: true) try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) self.directory = root self.certificateURL = root.appendingPathComponent("root.cer") + self.privateKeyURL = root.appendingPathComponent("root-key.pem") } public func loadOrCreate() throws -> RootCertificate { @@ -41,7 +38,10 @@ public final class CAStore: @unchecked Sendable { } let derBytes = try Data(contentsOf: certificateURL) let certificate = try Certificate(derEncoded: Array(derBytes)) - let pemData = try loadPrivateKeyPEM() + guard FileManager.default.fileExists(atPath: privateKeyURL.path) else { + throw CAStoreError.missingPrivateKeyOnDisk + } + let pemData = try Data(contentsOf: privateKeyURL) guard let pemString = String(data: pemData, encoding: .utf8) else { throw CAStoreError.invalidStoredPrivateKey } @@ -53,7 +53,8 @@ public final class CAStore: @unchecked Sendable { let root = try CertificateAuthority.generateRoot() try Data(try root.derBytes()).write(to: certificateURL, options: .atomic) let pem = try root.privateKey.serializeAsPEM().pemString - try storePrivateKeyPEM(Data(pem.utf8)) + try Data(pem.utf8).write(to: privateKeyURL, options: .atomic) + try FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: privateKeyURL.path) return root } @@ -66,72 +67,17 @@ public final class CAStore: @unchecked Sendable { throw CAStoreError.certificateDeleteFailed(error) } } - try deletePrivateKey() + if manager.fileExists(atPath: privateKeyURL.path) { + do { + try manager.removeItem(at: privateKeyURL) + } catch { + throw CAStoreError.privateKeyDeleteFailed(error) + } + } } public func exists() -> Bool { guard FileManager.default.fileExists(atPath: certificateURL.path) else { return false } - return privateKeyExists() - } - - private func storePrivateKeyPEM(_ data: Data) throws { - let query: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: keychainService, - kSecAttrAccount as String: keychainAccount, - ] - let deleteStatus = SecItemDelete(query as CFDictionary) - if deleteStatus != errSecSuccess && deleteStatus != errSecItemNotFound { - throw CAStoreError.keychainDeleteFailed(deleteStatus) - } - - var addQuery = query - addQuery[kSecValueData as String] = data - addQuery[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly - let status = SecItemAdd(addQuery as CFDictionary, nil) - guard status == errSecSuccess else { throw CAStoreError.keychainWriteFailed(status) } - } - - private func loadPrivateKeyPEM() throws -> Data { - let query: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: keychainService, - kSecAttrAccount as String: keychainAccount, - kSecReturnData as String: true, - kSecMatchLimit as String: kSecMatchLimitOne, - ] - var result: CFTypeRef? - let status = SecItemCopyMatching(query as CFDictionary, &result) - if status == errSecItemNotFound { - throw CAStoreError.missingPrivateKeyInKeychain - } - guard status == errSecSuccess, let data = result as? Data else { - throw CAStoreError.keychainReadFailed(status) - } - return data - } - - private func privateKeyExists() -> Bool { - let query: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: keychainService, - kSecAttrAccount as String: keychainAccount, - kSecReturnData as String: false, - kSecMatchLimit as String: kSecMatchLimitOne, - ] - let status = SecItemCopyMatching(query as CFDictionary, nil) - return status == errSecSuccess - } - - private func deletePrivateKey() throws { - let query: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: keychainService, - kSecAttrAccount as String: keychainAccount, - ] - let status = SecItemDelete(query as CFDictionary) - if status != errSecSuccess && status != errSecItemNotFound { - throw CAStoreError.keychainDeleteFailed(status) - } + return FileManager.default.fileExists(atPath: privateKeyURL.path) } } diff --git a/macos/Tests/ReverseAPITests/TrafficFilterTests.swift b/macos/Tests/ReverseAPITests/TrafficFilterTests.swift index 3f8558b..9572074 100644 --- a/macos/Tests/ReverseAPITests/TrafficFilterTests.swift +++ b/macos/Tests/ReverseAPITests/TrafficFilterTests.swift @@ -44,6 +44,22 @@ final class TrafficFilterTests: XCTestCase { XCTAssertTrue(filter.matches(make())) } + func testSearchMatchesStatusHostHeadersAndResourceKind() { + let flow = make( + host: "assets.example.com", + path: "/styles/app.css", + status: 304, + responseHeaders: [HTTPHeader("Cache-Control", "max-age=3600")] + ) + + var filter = TrafficFilter() + filter.search = "assets 304 cache css" + XCTAssertTrue(filter.matches(flow)) + + filter.search = "missing" + XCTAssertFalse(filter.matches(flow)) + } + func testHostFilter() { var filter = TrafficFilter() filter.hosts = ["api.example.com"] From 2242393daedcd173a28ab11b95f5252d7e76f101 Mon Sep 17 00:00:00 2001 From: kalil0321 Date: Wed, 20 May 2026 00:09:56 +0200 Subject: [PATCH 12/13] fix(macos): refine traffic workspace controls --- .../ReverseAPI/App/ReverseAPIApp.swift | 21 ++++ macos/Sources/ReverseAPI/UI/ContentView.swift | 49 +------- .../Sources/ReverseAPI/UI/InspectorView.swift | 113 +++++++++++++++--- .../ReverseAPI/UI/TrafficListView.swift | 62 +++++++--- 4 files changed, 164 insertions(+), 81 deletions(-) diff --git a/macos/Sources/ReverseAPI/App/ReverseAPIApp.swift b/macos/Sources/ReverseAPI/App/ReverseAPIApp.swift index 339f193..c7cd7da 100644 --- a/macos/Sources/ReverseAPI/App/ReverseAPIApp.swift +++ b/macos/Sources/ReverseAPI/App/ReverseAPIApp.swift @@ -58,16 +58,37 @@ final class AppLifecycle { } final class AppDelegate: NSObject, NSApplicationDelegate { + private var keyMonitor: Any? + + func applicationDidFinishLaunching(_ notification: Notification) { + keyMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { event in + let modifiers = event.modifierFlags.intersection(.deviceIndependentFlagsMask) + let togglesSidebar = event.charactersIgnoringModifiers?.lowercased() == "b" && + (modifiers.contains(.command) || modifiers.contains(.control)) + guard togglesSidebar else { return event } + NotificationCenter.default.post(name: .toggleRaeSidebar, object: nil) + return nil + } + } + func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { true } @MainActor func applicationWillTerminate(_ notification: Notification) { + if let keyMonitor { + NSEvent.removeMonitor(keyMonitor) + self.keyMonitor = nil + } AppLifecycle.shared.restoreProxyBeforeExit() } } +extension Notification.Name { + static let toggleRaeSidebar = Notification.Name("rae.toggleSidebar") +} + enum AppSession { case ready(AppState) case failed(Error) diff --git a/macos/Sources/ReverseAPI/UI/ContentView.swift b/macos/Sources/ReverseAPI/UI/ContentView.swift index 73e5672..713e06c 100644 --- a/macos/Sources/ReverseAPI/UI/ContentView.swift +++ b/macos/Sources/ReverseAPI/UI/ContentView.swift @@ -27,10 +27,12 @@ struct ContentView: View { } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) .background(Color(nsColor: .windowBackgroundColor)) - .background(SidebarShortcutHandler(isSidebarVisible: $isSidebarVisible)) .task { await state.recoverStaleSystemProxyOnLaunch() } + .onReceive(NotificationCenter.default.publisher(for: .toggleRaeSidebar)) { _ in + isSidebarVisible.toggle() + } .onReceive(NotificationCenter.default.publisher(for: NSApplication.willTerminateNotification)) { _ in state.restoreProxyBeforeExit() } @@ -63,48 +65,3 @@ private struct CollapsedSidebarRail: View { .background(Color(nsColor: .controlBackgroundColor)) } } - -private struct SidebarShortcutHandler: NSViewRepresentable { - @Binding var isSidebarVisible: Bool - - func makeCoordinator() -> Coordinator { - Coordinator(isSidebarVisible: $isSidebarVisible) - } - - func makeNSView(context: Context) -> NSView { - context.coordinator.installMonitor() - return NSView(frame: .zero) - } - - func updateNSView(_ nsView: NSView, context: Context) { - context.coordinator.isSidebarVisible = $isSidebarVisible - } - - final class Coordinator { - var isSidebarVisible: Binding - private var monitor: Any? - - init(isSidebarVisible: Binding) { - self.isSidebarVisible = isSidebarVisible - } - - deinit { - if let monitor { - NSEvent.removeMonitor(monitor) - } - } - - func installMonitor() { - guard monitor == nil else { return } - monitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in - guard let self else { return event } - let modifiers = event.modifierFlags.intersection(.deviceIndependentFlagsMask) - let togglesSidebar = event.charactersIgnoringModifiers?.lowercased() == "b" && - (modifiers.contains(.command) || modifiers.contains(.control)) - guard togglesSidebar else { return event } - isSidebarVisible.wrappedValue.toggle() - return nil - } - } - } -} diff --git a/macos/Sources/ReverseAPI/UI/InspectorView.swift b/macos/Sources/ReverseAPI/UI/InspectorView.swift index b520a0d..0282bde 100644 --- a/macos/Sources/ReverseAPI/UI/InspectorView.swift +++ b/macos/Sources/ReverseAPI/UI/InspectorView.swift @@ -30,11 +30,7 @@ struct InspectorView: View { } } .frame(maxWidth: .infinity, maxHeight: .infinity) - .background(Color(nsColor: .controlBackgroundColor), in: RoundedRectangle(cornerRadius: 10)) - .overlay { - RoundedRectangle(cornerRadius: 10) - .stroke(.separator.opacity(0.5), lineWidth: 1) - } + .background(Color(nsColor: .controlBackgroundColor)) } } } @@ -68,12 +64,7 @@ private struct FlowInspector: View { .padding(14) } } - .background(Color(nsColor: .controlBackgroundColor), in: RoundedRectangle(cornerRadius: 10)) - .overlay { - RoundedRectangle(cornerRadius: 10) - .stroke(.separator.opacity(0.5), lineWidth: 1) - } - .clipShape(RoundedRectangle(cornerRadius: 10)) + .background(Color(nsColor: .controlBackgroundColor)) } private var header: some View { @@ -185,6 +176,16 @@ private struct FlowInspector: View { row("Started", flow.startedAt.formatted(date: .abbreviated, time: .standard)) row("Finished", flow.finishedAt?.formatted(date: .abbreviated, time: .standard) ?? "Pending") } + + DetailPanel(title: "Response") { + row("Status", flow.responseStatus.map(String.init) ?? "Pending") + row("Type", TrafficFilter.resourceKind(for: flow).rawValue) + row("Content-Type", headerValue("content-type", in: flow.responseHeaders) ?? "None") + row("Content-Encoding", headerValue("content-encoding", in: flow.responseHeaders) ?? "None") + row("Body", byteString(flow.responseBody.count)) + } + + BodySection(title: "Response body", bodyData: flow.responseBody, headers: flow.responseHeaders) } } @@ -276,7 +277,11 @@ private struct FlowInspector: View { } private func contentType(in headers: [HTTPHeader]) -> String? { - headers.first(where: { $0.name.lowercased() == "content-type" })?.value + headerValue("content-type", in: headers) + } + + private func headerValue(_ name: String, in headers: [HTTPHeader]) -> String? { + headers.first(where: { $0.name.caseInsensitiveCompare(name) == .orderedSame })?.value } private func looksLikeText(_ headers: [HTTPHeader]) -> Bool { @@ -377,10 +382,14 @@ private struct BodySection: View { CodeBlock(text: pretty) } else if let text = String(data: bodyData, encoding: .utf8), looksLikeText { CodeBlock(text: text) + } else if let text = String(data: bodyData, encoding: .utf8), isMostlyPrintable(text) { + CodeBlock(text: text) } else { - Text("Binary content · \(bodyData.count) bytes") - .foregroundStyle(.secondary) - .font(.callout) + BinaryBodyNotice( + byteCount: bodyData.count, + contentType: contentType, + contentEncoding: contentEncoding + ) } } } @@ -389,9 +398,81 @@ private struct BodySection: View { headers.first(where: { $0.name.lowercased() == "content-type" })?.value } + private var contentEncoding: String? { + headers.first(where: { $0.name.lowercased() == "content-encoding" })?.value + } + private var looksLikeText: Bool { guard let ct = contentType?.lowercased() else { return false } - return ct.contains("text") || ct.contains("xml") || ct.contains("javascript") || ct.contains("html") + return ct.contains("text") || + ct.contains("json") || + ct.contains("xml") || + ct.contains("javascript") || + ct.contains("html") || + ct.contains("event-stream") || + ct.contains("x-www-form-urlencoded") || + ct.contains("graphql") || + ct.contains("csv") + } + + private func isMostlyPrintable(_ text: String) -> Bool { + guard !text.isEmpty else { return false } + let scalars = text.unicodeScalars + let printable = scalars.filter { scalar in + scalar.value == 10 || scalar.value == 13 || scalar.value == 9 || scalar.value >= 32 + } + return Double(printable.count) / Double(scalars.count) > 0.92 + } +} + +private struct BinaryBodyNotice: View { + let byteCount: Int + let contentType: String? + let contentEncoding: String? + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + Label("Binary body · \(byteCount) bytes", systemImage: "cube.transparent") + .font(.callout.weight(.medium)) + Text(reason) + .font(.callout) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + if let contentType { + Text("Content-Type: \(contentType)") + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(.tertiary) + } + if let contentEncoding { + Text("Content-Encoding: \(contentEncoding)") + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(.tertiary) + } + } + .padding(12) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color.primary.opacity(0.035), in: RoundedRectangle(cornerRadius: 8)) + } + + private var reason: String { + if let contentEncoding, !contentEncoding.localizedCaseInsensitiveContains("identity") { + return "The server returned an encoded body. rae asks for identity encoding, but some servers still send compressed payloads." + } + if let contentType, isKnownBinary(contentType) { + return "This response is a binary asset or protocol payload, so there is no text preview to show." + } + return "rae could not decode this body as JSON or readable UTF-8 text." + } + + private func isKnownBinary(_ contentType: String) -> Bool { + let lower = contentType.lowercased() + return lower.hasPrefix("image/") || + lower.hasPrefix("audio/") || + lower.hasPrefix("video/") || + lower.contains("font") || + lower.contains("octet-stream") || + lower.contains("protobuf") || + lower.contains("msgpack") } } diff --git a/macos/Sources/ReverseAPI/UI/TrafficListView.swift b/macos/Sources/ReverseAPI/UI/TrafficListView.swift index 33e87be..784efa8 100644 --- a/macos/Sources/ReverseAPI/UI/TrafficListView.swift +++ b/macos/Sources/ReverseAPI/UI/TrafficListView.swift @@ -1,3 +1,4 @@ +import AppKit import SwiftUI import ReverseAPIProxy @@ -24,12 +25,7 @@ struct TrafficListView: View { } } } - .background(Color(nsColor: .controlBackgroundColor), in: RoundedRectangle(cornerRadius: 10)) - .overlay { - RoundedRectangle(cornerRadius: 10) - .stroke(.separator.opacity(0.5), lineWidth: 1) - } - .clipShape(RoundedRectangle(cornerRadius: 10)) + .background(Color(nsColor: .controlBackgroundColor)) } private var filteredFlows: [CapturedFlow] { @@ -137,7 +133,7 @@ private struct FilterBar: View { Spacer() - searchField + SearchField(text: $filter.search, placeholder: "Search requests") .frame(width: 300) filterMenu @@ -159,18 +155,6 @@ private struct FilterBar: View { .padding(.vertical, 8) } - private var searchField: some View { - HStack(spacing: 8) { - Image(systemName: "magnifyingglass") - .foregroundStyle(.secondary) - TextField("Search URL, host, or method", text: $filter.search) - .textFieldStyle(.plain) - } - .padding(.horizontal, 10) - .padding(.vertical, 7) - .background(Color.primary.opacity(0.055), in: RoundedRectangle(cornerRadius: 7)) - } - private var filterMenu: some View { Menu { Toggle("Errors only", isOn: $filter.onlyErrors) @@ -268,6 +252,46 @@ private struct FilterBar: View { } } +private struct SearchField: NSViewRepresentable { + @Binding var text: String + let placeholder: String + + func makeCoordinator() -> Coordinator { + Coordinator(text: $text) + } + + func makeNSView(context: Context) -> NSSearchField { + let field = NSSearchField() + field.placeholderString = placeholder + field.sendsSearchStringImmediately = true + field.delegate = context.coordinator + field.font = .systemFont(ofSize: NSFont.systemFontSize) + field.controlSize = .regular + return field + } + + func updateNSView(_ field: NSSearchField, context: Context) { + context.coordinator.text = $text + if field.stringValue != text { + field.stringValue = text + } + field.placeholderString = placeholder + } + + final class Coordinator: NSObject, NSSearchFieldDelegate { + var text: Binding + + init(text: Binding) { + self.text = text + } + + func controlTextDidChange(_ notification: Notification) { + guard let field = notification.object as? NSSearchField else { return } + text.wrappedValue = field.stringValue + } + } +} + private struct ResourceKindBar: View { @Binding var selectedKinds: Set From 773e59ff32578aae781bb944f2ac533b9eb54d4f Mon Sep 17 00:00:00 2001 From: kalil0321 Date: Wed, 20 May 2026 01:28:24 +0200 Subject: [PATCH 13/13] fix(macos): address m3 review feedback --- macos/Sources/ReverseAPI/App/AppState.swift | 35 ++++- .../ReverseAPI/App/ReverseAPIApp.swift | 20 ++- .../ReverseAPI/Storage/FlowStore.swift | 122 +++++++++++++++--- .../Sources/ReverseAPI/UI/InspectorView.swift | 10 +- .../Sources/ReverseAPI/UI/TrafficFilter.swift | 21 +-- .../ReverseAPI/UI/TrafficListView.swift | 20 ++- .../Sources/ReverseAPIProxy/CA/CAStore.swift | 95 ++++++++++++-- .../ReverseAPIProxy/Proxy/UpstreamPump.swift | 2 +- .../ReverseAPITests/TrafficFilterTests.swift | 5 + 9 files changed, 269 insertions(+), 61 deletions(-) diff --git a/macos/Sources/ReverseAPI/App/AppState.swift b/macos/Sources/ReverseAPI/App/AppState.swift index a2bea33..cfbcc08 100644 --- a/macos/Sources/ReverseAPI/App/AppState.swift +++ b/macos/Sources/ReverseAPI/App/AppState.swift @@ -189,11 +189,17 @@ final class AppState { } func clearFlows() { - do { - try store.clear() - selectedFlowID = nil - } catch { - lastError = "Failed to clear flows: \(error)" + guard !isWorking else { return } + isWorking = true + Task { + defer { isWorking = false } + do { + try await store.clear() + selectedFlowID = nil + lastError = nil + } catch { + lastError = "Failed to clear flows: \(error)" + } } } @@ -224,12 +230,27 @@ final class AppState { func shutdownForWindowClose() async { restoreProxyBeforeExit() if isCapturing { - try? await engine.stop() - isCapturing = false + do { + try await engine.stop() + isCapturing = false + } catch { + lastError = "Could not stop capture cleanly: \(error)" + } } isWorking = false } + func terminate() async { + restoreProxyBeforeExit() + do { + try await engine.terminate() + isCapturing = false + isWorking = false + } catch { + lastError = "Could not terminate proxy cleanly: \(error)" + } + } + private func applySystemProxy() async throws { let systemProxy = self.systemProxy let port = self.port diff --git a/macos/Sources/ReverseAPI/App/ReverseAPIApp.swift b/macos/Sources/ReverseAPI/App/ReverseAPIApp.swift index c7cd7da..ce596c8 100644 --- a/macos/Sources/ReverseAPI/App/ReverseAPIApp.swift +++ b/macos/Sources/ReverseAPI/App/ReverseAPIApp.swift @@ -59,12 +59,13 @@ final class AppLifecycle { final class AppDelegate: NSObject, NSApplicationDelegate { private var keyMonitor: Any? + private var isTerminating = false func applicationDidFinishLaunching(_ notification: Notification) { keyMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { event in let modifiers = event.modifierFlags.intersection(.deviceIndependentFlagsMask) let togglesSidebar = event.charactersIgnoringModifiers?.lowercased() == "b" && - (modifiers.contains(.command) || modifiers.contains(.control)) + modifiers == .command guard togglesSidebar else { return event } NotificationCenter.default.post(name: .toggleRaeSidebar, object: nil) return nil @@ -75,13 +76,28 @@ final class AppDelegate: NSObject, NSApplicationDelegate { true } + @MainActor + func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply { + guard !isTerminating else { return .terminateNow } + guard let state = AppLifecycle.shared.state else { return .terminateNow } + + isTerminating = true + Task { + await state.terminate() + sender.reply(toApplicationShouldTerminate: true) + } + return .terminateLater + } + @MainActor func applicationWillTerminate(_ notification: Notification) { if let keyMonitor { NSEvent.removeMonitor(keyMonitor) self.keyMonitor = nil } - AppLifecycle.shared.restoreProxyBeforeExit() + if !isTerminating { + AppLifecycle.shared.restoreProxyBeforeExit() + } } } diff --git a/macos/Sources/ReverseAPI/Storage/FlowStore.swift b/macos/Sources/ReverseAPI/Storage/FlowStore.swift index 02d990a..5fe579e 100644 --- a/macos/Sources/ReverseAPI/Storage/FlowStore.swift +++ b/macos/Sources/ReverseAPI/Storage/FlowStore.swift @@ -7,11 +7,16 @@ import ReverseAPIProxy @Observable public final class FlowStore { public private(set) var flows: [CapturedFlow] = [] + public private(set) var hostOptions: [String] = [] + public private(set) var methodOptions: [String] = [] public private(set) var isReady = false private let database: DatabaseQueue private let generation = GenerationCounter() + private let logger = AppLogger("app.flowstore") private var subscription: Task? + private var hostCounts: [String: Int] = [:] + private var methodCounts: [String: Int] = [:] public init(databaseURL: URL) throws { let parent = databaseURL.deletingLastPathComponent() @@ -20,8 +25,7 @@ public final class FlowStore { config.label = "reverseapi.flows" self.database = try DatabaseQueue(path: databaseURL.path, configuration: config) try Self.migrate(database) - try loadInitial() - isReady = true + Task { await loadInitial() } } public func subscribe(to bus: FlowBus) { @@ -34,12 +38,17 @@ public final class FlowStore { } } - public func clear() throws { - _ = generation.bump() - try database.write { db in - _ = try PersistedFlow.deleteAll(db) - } + public func clear() async throws { + let snapshot = generation.bump() + let database = self.database + try await Task.detached(priority: .userInitiated) { + try await database.write { db in + _ = try PersistedFlow.deleteAll(db) + } + }.value + guard generation.value == snapshot else { return } flows.removeAll() + resetFilterOptions() } public func flow(id: UUID) -> CapturedFlow? { @@ -60,32 +69,109 @@ public final class FlowStore { private func insertOrUpdate(_ flow: CapturedFlow) { if let index = flows.firstIndex(where: { $0.id == flow.id }) { + updateFilterOptions(removing: flows[index]) flows[index] = flow } else { flows.insert(flow, at: 0) } + updateFilterOptions(adding: flow) } private func persist(_ flow: CapturedFlow) { - guard let record = try? PersistedFlow(from: flow) else { return } + let record: PersistedFlow + do { + record = try PersistedFlow(from: flow) + } catch { + logger.error("failed to encode flow \(flow.id) for persistence: \(error)") + return + } let snapshot = generation.value let counter = generation + let logger = self.logger Task.detached(priority: .utility) { [database] in - try? await database.write { db in - guard counter.value == snapshot else { return } - try record.save(db) + do { + try await database.write { db in + guard counter.value == snapshot else { return } + try record.save(db) + } + } catch { + logger.error("failed to persist flow \(record.id): \(error)") + } + } + } + + private func loadInitial() async { + do { + let database = self.database + let records = try await Task.detached(priority: .utility) { + try database.read { db in + try PersistedFlow + .order(PersistedFlow.Columns.startedAt.desc) + .limit(500) + .fetchAll(db) + } + }.value + + var loaded: [CapturedFlow] = [] + for record in records { + do { + loaded.append(try record.toCapturedFlow()) + } catch { + logger.error("failed to decode persisted flow \(record.id): \(error)") + } } + flows = loaded + rebuildFilterOptions() + } catch { + logger.error("failed to load persisted flows: \(error)") + } + isReady = true + } + + private func updateFilterOptions(adding flow: CapturedFlow) { + increment(flow.host, in: &hostCounts) + increment(flow.method, in: &methodCounts) + publishFilterOptions() + } + + private func updateFilterOptions(removing flow: CapturedFlow) { + decrement(flow.host, in: &hostCounts) + decrement(flow.method, in: &methodCounts) + publishFilterOptions() + } + + private func rebuildFilterOptions() { + hostCounts = [:] + methodCounts = [:] + for flow in flows { + increment(flow.host, in: &hostCounts) + increment(flow.method, in: &methodCounts) } + publishFilterOptions() + } + + private func resetFilterOptions() { + hostCounts = [:] + methodCounts = [:] + publishFilterOptions() + } + + private func publishFilterOptions() { + hostOptions = hostCounts.keys.sorted() + methodOptions = methodCounts.keys.sorted() } - private func loadInitial() throws { - let records = try database.read { db in - try PersistedFlow - .order(PersistedFlow.Columns.startedAt.desc) - .limit(500) - .fetchAll(db) + private func increment(_ value: String, in counts: inout [String: Int]) { + counts[value, default: 0] += 1 + } + + private func decrement(_ value: String, in counts: inout [String: Int]) { + guard let count = counts[value] else { return } + if count <= 1 { + counts.removeValue(forKey: value) + } else { + counts[value] = count - 1 } - flows = records.compactMap { try? $0.toCapturedFlow() } } private static func migrate(_ database: DatabaseQueue) throws { diff --git a/macos/Sources/ReverseAPI/UI/InspectorView.swift b/macos/Sources/ReverseAPI/UI/InspectorView.swift index 0282bde..4b24f1a 100644 --- a/macos/Sources/ReverseAPI/UI/InspectorView.swift +++ b/macos/Sources/ReverseAPI/UI/InspectorView.swift @@ -286,7 +286,15 @@ private struct FlowInspector: View { private func looksLikeText(_ headers: [HTTPHeader]) -> Bool { guard let ct = contentType(in: headers)?.lowercased() else { return false } - return ct.contains("text") || ct.contains("xml") || ct.contains("javascript") || ct.contains("html") + return ct.contains("text") || + ct.contains("json") || + ct.contains("xml") || + ct.contains("javascript") || + ct.contains("html") || + ct.contains("event-stream") || + ct.contains("x-www-form-urlencoded") || + ct.contains("graphql") || + ct.contains("csv") } private func copyToPasteboard(_ text: String) { diff --git a/macos/Sources/ReverseAPI/UI/TrafficFilter.swift b/macos/Sources/ReverseAPI/UI/TrafficFilter.swift index 5df4540..b3c57bb 100644 --- a/macos/Sources/ReverseAPI/UI/TrafficFilter.swift +++ b/macos/Sources/ReverseAPI/UI/TrafficFilter.swift @@ -92,14 +92,19 @@ struct TrafficFilter: Equatable { let contentType = headerValue("content-type", in: flow.responseHeaders)?.lowercased() ?? "" let accept = headerValue("accept", in: flow.requestHeaders)?.lowercased() ?? "" let path = flow.path.lowercased() - let ext = path - .split(separator: "?") - .first? - .split(separator: "/") - .last? - .split(separator: ".") - .last - .map { String($0).lowercased() } ?? "" + let ext: String = { + let lastComponent = path + .split(separator: "?") + .first? + .split(separator: "/") + .last + .map(String.init) ?? "" + guard lastComponent.contains("."), + let suffix = lastComponent.split(separator: ".").last else { + return "" + } + return String(suffix).lowercased() + }() if contentType.contains("text/html") || accept.contains("text/html") || ["html", "htm"].contains(ext) { return .document diff --git a/macos/Sources/ReverseAPI/UI/TrafficListView.swift b/macos/Sources/ReverseAPI/UI/TrafficListView.swift index 784efa8..202931e 100644 --- a/macos/Sources/ReverseAPI/UI/TrafficListView.swift +++ b/macos/Sources/ReverseAPI/UI/TrafficListView.swift @@ -10,8 +10,8 @@ struct TrafficListView: View { VStack(spacing: 0) { FilterBar( filter: $bindable.filter, - hostOptions: hostOptions, - methodOptions: methodOptions, + hostOptions: state.store.hostOptions, + methodOptions: state.store.methodOptions, totalCount: state.store.flows.count, visibleCount: filteredFlows.count ) @@ -32,14 +32,6 @@ struct TrafficListView: View { state.store.flows.filter { state.filter.matches($0) } } - private var hostOptions: [String] { - Array(Set(state.store.flows.map(\.host))).sorted() - } - - private var methodOptions: [String] { - Array(Set(state.store.flows.map(\.method))).sorted() - } - private var table: some View { @Bindable var bindable = state return Table(filteredFlows, selection: $bindable.selectedFlowID) { @@ -97,10 +89,14 @@ struct TrafficListView: View { } private func byteString(_ count: Int) -> String { + Self.byteCountFormatter.string(fromByteCount: Int64(count)) + } + + private static let byteCountFormatter: ByteCountFormatter = { let formatter = ByteCountFormatter() formatter.countStyle = .file - return formatter.string(fromByteCount: Int64(count)) - } + return formatter + }() private func durationString(_ flow: CapturedFlow) -> String { guard let finished = flow.finishedAt else { return "…" } diff --git a/macos/Sources/ReverseAPIProxy/CA/CAStore.swift b/macos/Sources/ReverseAPIProxy/CA/CAStore.swift index 427f7e3..81eb7cf 100644 --- a/macos/Sources/ReverseAPIProxy/CA/CAStore.swift +++ b/macos/Sources/ReverseAPIProxy/CA/CAStore.swift @@ -2,10 +2,14 @@ import Foundation import Crypto import X509 import SwiftASN1 +import Security public enum CAStoreError: Error { case missingCertificateOnDisk - case missingPrivateKeyOnDisk + case missingPrivateKeyInKeychain + case keychainWriteFailed(OSStatus) + case keychainReadFailed(OSStatus) + case keychainDeleteFailed(OSStatus) case invalidStoredPrivateKey case certificateDeleteFailed(any Error) case privateKeyDeleteFailed(any Error) @@ -15,14 +19,16 @@ public final class CAStore: @unchecked Sendable { public let directory: URL public let certificateURL: URL - private let privateKeyURL: URL + private let keychainService = "app.reverseapi" + private let keychainAccount = "ca.root-private-key" + private let legacyPrivateKeyURL: URL public init(applicationSupportURL: URL) throws { let root = applicationSupportURL.appendingPathComponent("ReverseAPI", isDirectory: true) try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) self.directory = root self.certificateURL = root.appendingPathComponent("root.cer") - self.privateKeyURL = root.appendingPathComponent("root-key.pem") + self.legacyPrivateKeyURL = root.appendingPathComponent("root-key.pem") } public func loadOrCreate() throws -> RootCertificate { @@ -38,10 +44,7 @@ public final class CAStore: @unchecked Sendable { } let derBytes = try Data(contentsOf: certificateURL) let certificate = try Certificate(derEncoded: Array(derBytes)) - guard FileManager.default.fileExists(atPath: privateKeyURL.path) else { - throw CAStoreError.missingPrivateKeyOnDisk - } - let pemData = try Data(contentsOf: privateKeyURL) + let pemData = try loadPrivateKeyPEM() guard let pemString = String(data: pemData, encoding: .utf8) else { throw CAStoreError.invalidStoredPrivateKey } @@ -53,8 +56,7 @@ public final class CAStore: @unchecked Sendable { let root = try CertificateAuthority.generateRoot() try Data(try root.derBytes()).write(to: certificateURL, options: .atomic) let pem = try root.privateKey.serializeAsPEM().pemString - try Data(pem.utf8).write(to: privateKeyURL, options: .atomic) - try FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: privateKeyURL.path) + try storePrivateKeyPEM(Data(pem.utf8)) return root } @@ -67,17 +69,86 @@ public final class CAStore: @unchecked Sendable { throw CAStoreError.certificateDeleteFailed(error) } } - if manager.fileExists(atPath: privateKeyURL.path) { + if manager.fileExists(atPath: legacyPrivateKeyURL.path) { do { - try manager.removeItem(at: privateKeyURL) + try manager.removeItem(at: legacyPrivateKeyURL) } catch { throw CAStoreError.privateKeyDeleteFailed(error) } } + try deletePrivateKey() } public func exists() -> Bool { guard FileManager.default.fileExists(atPath: certificateURL.path) else { return false } - return FileManager.default.fileExists(atPath: privateKeyURL.path) + return privateKeyExists() + } + + private func storePrivateKeyPEM(_ data: Data) throws { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: keychainService, + kSecAttrAccount as String: keychainAccount, + ] + + var addQuery = query + addQuery[kSecValueData as String] = data + addQuery[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly + let status = SecItemAdd(addQuery as CFDictionary, nil) + if status == errSecDuplicateItem { + let updateStatus = SecItemUpdate(query as CFDictionary, [kSecValueData as String: data] as CFDictionary) + guard updateStatus == errSecSuccess else { throw CAStoreError.keychainWriteFailed(updateStatus) } + return + } + guard status == errSecSuccess else { throw CAStoreError.keychainWriteFailed(status) } + } + + private func loadPrivateKeyPEM() throws -> Data { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: keychainService, + kSecAttrAccount as String: keychainAccount, + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne, + ] + var result: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &result) + if status == errSecItemNotFound, FileManager.default.fileExists(atPath: legacyPrivateKeyURL.path) { + let data = try Data(contentsOf: legacyPrivateKeyURL) + try storePrivateKeyPEM(data) + try? FileManager.default.removeItem(at: legacyPrivateKeyURL) + return data + } + if status == errSecItemNotFound { + throw CAStoreError.missingPrivateKeyInKeychain + } + guard status == errSecSuccess, let data = result as? Data else { + throw CAStoreError.keychainReadFailed(status) + } + return data + } + + private func privateKeyExists() -> Bool { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: keychainService, + kSecAttrAccount as String: keychainAccount, + kSecReturnData as String: false, + kSecMatchLimit as String: kSecMatchLimitOne, + ] + let status = SecItemCopyMatching(query as CFDictionary, nil) + return status == errSecSuccess || FileManager.default.fileExists(atPath: legacyPrivateKeyURL.path) + } + + private func deletePrivateKey() throws { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: keychainService, + kSecAttrAccount as String: keychainAccount, + ] + let status = SecItemDelete(query as CFDictionary) + if status != errSecSuccess && status != errSecItemNotFound { + throw CAStoreError.keychainDeleteFailed(status) + } } } diff --git a/macos/Sources/ReverseAPIProxy/Proxy/UpstreamPump.swift b/macos/Sources/ReverseAPIProxy/Proxy/UpstreamPump.swift index 1c66c41..a47dc94 100644 --- a/macos/Sources/ReverseAPIProxy/Proxy/UpstreamPump.swift +++ b/macos/Sources/ReverseAPIProxy/Proxy/UpstreamPump.swift @@ -175,7 +175,7 @@ private final class StreamingResponseForwarder: ChannelInboundHandler, @unchecke } downstream.eventLoop.execute { - self.downstream.write(HTTPServerResponsePart.body(.byteBuffer(buffer)), promise: nil) + self.downstream.writeAndFlush(HTTPServerResponsePart.body(.byteBuffer(buffer)), promise: nil) } case .end: diff --git a/macos/Tests/ReverseAPITests/TrafficFilterTests.swift b/macos/Tests/ReverseAPITests/TrafficFilterTests.swift index 9572074..9212327 100644 --- a/macos/Tests/ReverseAPITests/TrafficFilterTests.swift +++ b/macos/Tests/ReverseAPITests/TrafficFilterTests.swift @@ -150,4 +150,9 @@ final class TrafficFilterTests: XCTestCase { .websocket ) } + + func testResourceKindDoesNotTreatExtensionlessPathAsExtension() { + XCTAssertEqual(TrafficFilter.resourceKind(for: make(path: "/js")), .other) + XCTAssertEqual(TrafficFilter.resourceKind(for: make(path: "/css")), .other) + } }