diff --git a/macos/Package.swift b/macos/Package.swift index 20f8bb3..c207d70 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: "rae", 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,9 +36,20 @@ let package = Package( name: "rae-proxy", dependencies: ["ReverseAPIProxy"] ), + .executableTarget( + name: "ReverseAPI", + dependencies: [ + "ReverseAPIProxy", + .product(name: "GRDB", package: "GRDB.swift"), + ] + ), .testTarget( name: "ReverseAPIProxyTests", dependencies: ["ReverseAPIProxy"] ), + .testTarget( + name: "ReverseAPITests", + dependencies: ["ReverseAPI"] + ), ] ) diff --git a/macos/Sources/ReverseAPI/App/AppState.swift b/macos/Sources/ReverseAPI/App/AppState.swift new file mode 100644 index 0000000..cfbcc08 --- /dev/null +++ b/macos/Sources/ReverseAPI/App/AppState.swift @@ -0,0 +1,295 @@ +import Foundation +import Observation +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 + let installer: CertificateTrustInstaller + let systemProxy: SystemProxyController + + let port: Int + let caDER: Data + let caPEM: String + let caPath: String + + private var proxySnapshot: [ProxyServiceSnapshot]? + + 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(host: "127.0.0.1", port: port)) ?? false + + store.subscribe(to: engine.bus) + } + + func toggleCapture() async { + if isCapturing { + await stopCapture() + } else { + await startCapture(mode: captureMode) + } + } + + 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 { + isCapturing = false + lastError = "Could not start capture: \(error)" + } + } + + func stopCapture() async { + 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 { + 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 + try await Task.detached(priority: .userInitiated) { + 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 + try await Task.detached(priority: .userInitiated) { + 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 { + 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 { + try await restoreSystemProxy() + lastError = nil + } catch { + lastError = "Failed to disable system proxy: \(error)" + } + } + + func clearFlows() { + 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)" + } + } + } + + 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 + } + } + + func shutdownForWindowClose() async { + restoreProxyBeforeExit() + if isCapturing { + 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 + 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 port = self.port + let snapshot = proxySnapshot + try await Task.detached(priority: .userInitiated) { + if let snapshot { + try systemProxy.restore(snapshot) + } else { + 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/App/ReverseAPIApp.swift b/macos/Sources/ReverseAPI/App/ReverseAPIApp.swift new file mode 100644 index 0000000..ce596c8 --- /dev/null +++ b/macos/Sources/ReverseAPI/App/ReverseAPIApp.swift @@ -0,0 +1,141 @@ +import AppKit +import SwiftUI + +@main +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(isSidebarVisible: $isSidebarVisible) + .environment(state) + .onAppear { + AppLifecycle.shared.state = state + } + .onDisappear { + Task { await state.shutdownForWindowClose() } + } + .frame( + minWidth: 1100, + maxWidth: .infinity, + minHeight: 700, + maxHeight: .infinity, + alignment: .topLeading + ) + case .failed(let error): + BootFailureView(error: error) + .frame(minWidth: 500, minHeight: 300) + } + } + .windowStyle(.titleBar) + .windowToolbarStyle(.unifiedCompact) + .commands { + CommandGroup(replacing: .newItem) {} + CommandGroup(after: .toolbar) { + Button(isSidebarVisible ? "Hide Sidebar" : "Show Sidebar") { + isSidebarVisible.toggle() + } + .keyboardShortcut("b", modifiers: .command) + } + } + } +} + +@MainActor +final class AppLifecycle { + static let shared = AppLifecycle() + weak var state: AppState? + + private init() {} + + func restoreProxyBeforeExit() { + state?.restoreProxyBeforeExit() + } +} + +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 == .command + guard togglesSidebar else { return event } + NotificationCenter.default.post(name: .toggleRaeSidebar, object: nil) + return nil + } + } + + func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { + 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 + } + if !isTerminating { + AppLifecycle.shared.restoreProxyBeforeExit() + } + } +} + +extension Notification.Name { + static let toggleRaeSidebar = Notification.Name("rae.toggleSidebar") +} + +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("rae failed to start") + .font(.title2) + .bold() + Text(String(describing: 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..5fe579e --- /dev/null +++ b/macos/Sources/ReverseAPI/Storage/FlowStore.swift @@ -0,0 +1,220 @@ +import Foundation +import Observation +import GRDB +import ReverseAPIProxy + +@MainActor +@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() + 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) + Task { await loadInitial() } + } + + 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() 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? { + 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 }) { + updateFilterOptions(removing: flows[index]) + flows[index] = flow + } else { + flows.insert(flow, at: 0) + } + updateFilterOptions(adding: flow) + } + + private func persist(_ flow: CapturedFlow) { + 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 + 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 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 + } + } + + 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) + } +} + +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/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..9ee76c9 --- /dev/null +++ b/macos/Sources/ReverseAPI/UI/CaptureToolbar.swift @@ -0,0 +1,385 @@ +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: 20) { + brandHeader + + VStack(alignment: .leading, spacing: 10) { + SidebarSectionLabel("Capture") + captureButton + 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)", + systemImage: "record.circle", + tint: state.isCapturing ? .green : .secondary + ) + SidebarStatusRow( + title: state.systemProxyEnabled ? "Device routed" : "Device not routed", + detail: state.captureMode == .device ? "This Mac is automatic" : "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) { + SidebarSectionLabel("Actions") + 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() + + 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 + ) + ) + } + + private var brandHeader: some View { + 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) + } + + 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") + } + } + + private var captureButton: some View { + Button { + Task { await state.toggleCapture() } + } label: { + 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(.plain) + .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 { + Button { + Task { + if state.caTrustInstalled { + await state.uninstallCATrust() + } else { + await state.installCATrust() + } + } + } label: { + SidebarActionLabel( + title: state.caTrustInstalled ? "Remove CA trust" : "Trust CA", + systemImage: state.caTrustInstalled ? "checkmark.seal.fill" : "seal" + ) + } + .buttonStyle(.plain) + .disabled(state.isWorking) + .help(state.caTrustInstalled + ? "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 { + Button { + Task { + if state.systemProxyEnabled { + await state.disableSystemProxy() + } else { + await state.enableSystemProxy() + } + } + } label: { + SidebarActionLabel( + title: state.systemProxyEnabled ? "Unroute device" : "Route device", + systemImage: state.systemProxyEnabled ? "network.badge.shield.half.filled" : "network" + ) + } + .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" } + return "Start capture" + } + + private var captureIcon: String { + if state.isWorking { return "hourglass" } + 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" } + return "Ready to capture" + } +} + +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 + let systemImage: String + let tint: Color + + var body: some View { + 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() + } + .padding(10) + .background(Color.primary.opacity(0.045), in: RoundedRectangle(cornerRadius: 9)) + } +} + +private struct SidebarActionLabel: View { + let title: String + let systemImage: String + + var body: some View { + HStack(spacing: 10) { + Image(systemName: systemImage) + .frame(width: 22) + .foregroundStyle(.secondary) + Text(title) + .font(.callout) + Spacer() + } + .padding(.horizontal, 10) + .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 new file mode 100644 index 0000000..713e06c --- /dev/null +++ b/macos/Sources/ReverseAPI/UI/ContentView.swift @@ -0,0 +1,67 @@ +import SwiftUI +import ReverseAPIProxy +import AppKit + +struct ContentView: View { + @Environment(AppState.self) private var state + @Binding var isSidebarVisible: Bool + + var body: some View { + HStack(spacing: 0) { + if isSidebarVisible { + CaptureToolbar(isSidebarVisible: $isSidebarVisible) + .frame(width: 312) + Divider() + } else { + CollapsedSidebarRail(isSidebarVisible: $isSidebarVisible) + Divider() + } + + HSplitView { + TrafficListView() + .frame(minWidth: 600, maxHeight: .infinity) + InspectorView() + .frame(minWidth: 460, maxHeight: .infinity) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .background(Color(nsColor: .windowBackgroundColor)) + .task { + await state.recoverStaleSystemProxyOnLaunch() + } + .onReceive(NotificationCenter.default.publisher(for: .toggleRaeSidebar)) { _ in + isSidebarVisible.toggle() + } + .onReceive(NotificationCenter.default.publisher(for: NSApplication.willTerminateNotification)) { _ in + state.restoreProxyBeforeExit() + } + } +} + +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)) + } +} diff --git a/macos/Sources/ReverseAPI/UI/InspectorView.swift b/macos/Sources/ReverseAPI/UI/InspectorView.swift new file mode 100644 index 0000000..4b24f1a --- /dev/null +++ b/macos/Sources/ReverseAPI/UI/InspectorView.swift @@ -0,0 +1,504 @@ +import AppKit +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 { + 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(Color(nsColor: .controlBackgroundColor)) + } + } +} + +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) + } + } + .background(Color(nsColor: .controlBackgroundColor)) + } + + private var header: some View { + VStack(alignment: .leading, spacing: 8) { + 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(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)) + .foregroundStyle(.secondary) + .font(.callout) + } + copyMenu + } + 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) + .font(.callout) + } + } + .padding(.horizontal, 14) + .padding(.top, 12) + .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 { + case .overview: + overview + case .request: + VStack(alignment: .leading, spacing: 12) { + HeadersSection(title: "Request headers", headers: flow.requestHeaders) + BodySection(title: "Request body", bodyData: flow.requestBody, headers: flow.requestHeaders) + } + case .response: + 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: 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") + } + + 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) + } + } + + 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(.callout, design: .monospaced)) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + + 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 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 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 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? { + 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 { + guard let ct = contentType(in: headers)?.lowercased() else { return false } + 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) { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(text, forType: .string) + } +} + +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 DetailPanel: View { + let title: String + let content: Content + + init(title: String, @ViewBuilder content: () -> Content) { + self.title = title + self.content = content() + } + + var body: some View { + 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) + .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)) + .foregroundStyle(.secondary) + .frame(width: 160, alignment: .leading) + Text(header.value) + .font(.system(.callout, design: .monospaced)) + .textSelection(.enabled) + .lineLimit(nil) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + } + } + } +} + +private struct BodySection: View { + let title: String + let bodyData: Data + let headers: [HTTPHeader] + + var body: some View { + DetailPanel(title: title) { + if bodyData.isEmpty { + Text("Empty body") + .foregroundStyle(.tertiary) + .font(.callout) + } else if let pretty = JSONFormatter.prettyPrintJSON(bodyData, contentType: contentType) { + 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 { + BinaryBodyNotice( + byteCount: bodyData.count, + contentType: contentType, + contentEncoding: contentEncoding + ) + } + } + } + + private var contentType: String? { + 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("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") + } +} + +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(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/TrafficFilter.swift b/macos/Sources/ReverseAPI/UI/TrafficFilter.swift new file mode 100644 index 0000000..b3c57bb --- /dev/null +++ b/macos/Sources/ReverseAPI/UI/TrafficFilter.swift @@ -0,0 +1,150 @@ +import Foundation +import ReverseAPIProxy + +struct TrafficFilter: Equatable { + var search: String = "" + 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" + 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 + } + } + 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 } + 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 + } + + 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 } + + let contentType = headerValue("content-type", in: flow.responseHeaders)?.lowercased() ?? "" + let accept = headerValue("accept", in: flow.requestHeaders)?.lowercased() ?? "" + let path = flow.path.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 + } + 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 new file mode 100644 index 0000000..202931e --- /dev/null +++ b/macos/Sources/ReverseAPI/UI/TrafficListView.swift @@ -0,0 +1,513 @@ +import AppKit +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: state.store.hostOptions, + methodOptions: state.store.methodOptions, + totalCount: state.store.flows.count, + visibleCount: filteredFlows.count + ) + Divider() + ZStack { + table + if state.store.flows.isEmpty { + EmptyTrafficState() + } else if filteredFlows.isEmpty { + EmptyFilterState() + } + } + } + .background(Color(nsColor: .controlBackgroundColor)) + } + + private var filteredFlows: [CapturedFlow] { + state.store.flows.filter { state.filter.matches($0) } + } + + 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("Type") { flow in + ResourceKindBadge(kind: TrafficFilter.resourceKind(for: flow)) + } + .width(min: 74, ideal: 86) + + 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 { + Self.byteCountFormatter.string(fromByteCount: Int64(count)) + } + + private static let byteCountFormatter: ByteCountFormatter = { + let formatter = ByteCountFormatter() + formatter.countStyle = .file + return formatter + }() + + 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] + let totalCount: Int + let visibleCount: Int + + var body: some View { + 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) + .monospacedDigit() + } + + Spacer() + + SearchField(text: $filter.search, placeholder: "Search requests") + .frame(width: 300) + + filterMenu + if activeFilterCount > 0 { + Button { + filter = TrafficFilter() + } label: { + Label("Reset", systemImage: "xmark.circle.fill") + } + .buttonStyle(.borderless) + .foregroundStyle(.secondary) + .help("Clear \(activeFilterCount) active filters") + } + } + + ResourceKindBar(selectedKinds: $filter.resourceKinds) + } + .padding(.horizontal, 14) + .padding(.vertical, 8) + } + + 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 } + if filter.onlyErrors { count += 1 } + count += filter.hosts.count + count += filter.methods.count + count += filter.statusBuckets.count + count += filter.resourceKinds.count + return count + } + + 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 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 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 + + var body: some View { + 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) + } + } + } + } + } + .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.primary : Color.secondary) + .padding(.horizontal, 9) + .padding(.vertical, 5) + .background( + 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") + } +} + +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 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 .blue + case .script: return .orange + case .stylesheet: return .blue + case .image: return .green + case .media: return .red + case .font: return .indigo + case .websocket: return .purple + case .other: return .secondary + } + } +} + +private struct EmptyTrafficState: View { + @Environment(AppState.self) private var state + + var body: some View { + VStack(spacing: 14) { + 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) + .font(.title3.weight(.semibold)) + Text(message) + .font(.callout) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .frame(maxWidth: 430) + } + + HStack(spacing: 8) { + 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") + } + } + .padding(28) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color(nsColor: .controlBackgroundColor)) + } + + 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) { + 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.") + .font(.callout) + .foregroundStyle(.secondary) + } + .padding(28) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .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)) + } +} + +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/CA/CAStore.swift b/macos/Sources/ReverseAPIProxy/CA/CAStore.swift index 3d98fb5..81eb7cf 100644 --- a/macos/Sources/ReverseAPIProxy/CA/CAStore.swift +++ b/macos/Sources/ReverseAPIProxy/CA/CAStore.swift @@ -12,6 +12,7 @@ public enum CAStoreError: Error { case keychainDeleteFailed(OSStatus) case invalidStoredPrivateKey case certificateDeleteFailed(any Error) + case privateKeyDeleteFailed(any Error) } public final class CAStore: @unchecked Sendable { @@ -20,12 +21,14 @@ public final class CAStore: @unchecked Sendable { 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.legacyPrivateKeyURL = root.appendingPathComponent("root-key.pem") } public func loadOrCreate() throws -> RootCertificate { @@ -66,6 +69,13 @@ public final class CAStore: @unchecked Sendable { throw CAStoreError.certificateDeleteFailed(error) } } + if manager.fileExists(atPath: legacyPrivateKeyURL.path) { + do { + try manager.removeItem(at: legacyPrivateKeyURL) + } catch { + throw CAStoreError.privateKeyDeleteFailed(error) + } + } try deletePrivateKey() } @@ -80,15 +90,16 @@ public final class CAStore: @unchecked Sendable { 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) + 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) } } @@ -102,6 +113,12 @@ public final class CAStore: @unchecked Sendable { ] 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 } @@ -120,7 +137,7 @@ public final class CAStore: @unchecked Sendable { kSecMatchLimit as String: kSecMatchLimitOne, ] let status = SecItemCopyMatching(query as CFDictionary, nil) - return status == errSecSuccess + return status == errSecSuccess || FileManager.default.fileExists(atPath: legacyPrivateKeyURL.path) } private func deletePrivateKey() throws { 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..a47dc94 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.writeAndFlush(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 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() + } } diff --git a/macos/Sources/ReverseAPIProxy/System/SystemProxyController.swift b/macos/Sources/ReverseAPIProxy/System/SystemProxyController.swift index bf7bf65..4df8668 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,46 @@ 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") + } + } + + 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") + } } - 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 +96,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 +184,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: "'\\''") + "'" } 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..9212327 --- /dev/null +++ b/macos/Tests/ReverseAPITests/TrafficFilterTests.swift @@ -0,0 +1,158 @@ +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, + 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 + } + + 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 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"] + 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)) + } + + 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 + ) + } + + func testResourceKindDoesNotTreatExtensionlessPathAsExtension() { + XCTAssertEqual(TrafficFilter.resourceKind(for: make(path: "/js")), .other) + XCTAssertEqual(TrafficFilter.resourceKind(for: make(path: "/css")), .other) + } +}