From ceff6c752991a38b75b836964158453d596b7e61 Mon Sep 17 00:00:00 2001 From: Sander van Dragt Date: Fri, 10 Jul 2026 09:54:23 +0100 Subject: [PATCH] Split proxy core into a shared library so it can build a Linux CLI Splits Package.swift into ProxyLightCore (portable), the existing macOS app target, and a new proxylight-cli target, extracting a plain-Swift ProxyOrchestrator out of AppState so both frontends drive the same start/stop/mapping logic. The CLI is minimal scope: proxy + PAC + CA cert generation only, no system-proxy automation, no CA trust-store install, no login-at-boot, no self-update on Linux. Closes #8. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01Sg1MPNVjkvn53s8bgYdJD9 --- CLAUDE.md | 42 +++-- Package.resolved | 11 +- Package.swift | 24 ++- README.md | 38 +++- Sources/ProxyLight/App/AppState.swift | 113 +++++------- .../ProxyLight/App/MappingEditorView.swift | 1 + .../ProxyLight/App/MappingTransferViews.swift | 1 + Sources/ProxyLight/App/MappingsView.swift | 1 + Sources/ProxyLight/App/MenuContent.swift | 1 + .../{Core => MacOS}/CATrustManager.swift | 0 .../{Core => MacOS}/LoginItemManager.swift | 0 .../{Core => MacOS}/ProxyRestoreStore.swift | 0 .../{Core => MacOS}/SelfUpdater.swift | 0 .../{Core => MacOS}/SystemProxyManager.swift | 0 .../CertificateAuthority.swift | 0 .../ConnectHandler.swift | 0 .../Core => ProxyLightCore}/Mapping.swift | 32 ++-- .../MappingEngine.swift | 0 .../Core => ProxyLightCore}/MappingIO.swift | 24 +-- .../MappingStore.swift | 14 +- .../Core => ProxyLightCore}/MappingsBox.swift | 0 .../PACGenerator.swift | 0 .../PACResponder.swift | 0 .../ProxyGlueHandler.swift | 0 .../ProxyLightCore/ProxyOrchestrator.swift | 95 ++++++++++ .../ProxyServer.swift | 0 .../UpdateChecker.swift | 19 +- Sources/proxylight-cli/ProxyLightCLI.swift | 162 ++++++++++++++++++ .../CertificateAuthorityTests.swift | 25 +-- .../MappingEngineTests.swift | 2 +- .../MappingStoreLinuxDirectoryTests.swift | 15 ++ .../MappingStoreTests.swift | 157 +++++++++++++++++ .../PACGeneratorTests.swift | 2 +- .../PACResponderTests.swift | 2 +- .../ProxyOrchestratorTests.swift | 114 ++++++++++++ .../ProxyServerHTTPTests.swift | 2 +- .../ProxyServerTLSTests.swift | 2 +- .../RequestHeadSizeGuardTests.swift | 2 +- .../UpdateCheckTests.swift | 2 +- .../ProxyLightTests/CATrustManagerTests.swift | 27 +++ ...ts.swift => SystemProxyManagerTests.swift} | 156 +---------------- 41 files changed, 772 insertions(+), 314 deletions(-) rename Sources/ProxyLight/{Core => MacOS}/CATrustManager.swift (100%) rename Sources/ProxyLight/{Core => MacOS}/LoginItemManager.swift (100%) rename Sources/ProxyLight/{Core => MacOS}/ProxyRestoreStore.swift (100%) rename Sources/ProxyLight/{Core => MacOS}/SelfUpdater.swift (100%) rename Sources/ProxyLight/{Core => MacOS}/SystemProxyManager.swift (100%) rename Sources/{ProxyLight/Core => ProxyLightCore}/CertificateAuthority.swift (100%) rename Sources/{ProxyLight/Proxy => ProxyLightCore}/ConnectHandler.swift (100%) rename Sources/{ProxyLight/Core => ProxyLightCore}/Mapping.swift (77%) rename Sources/{ProxyLight/Core => ProxyLightCore}/MappingEngine.swift (100%) rename Sources/{ProxyLight/Core => ProxyLightCore}/MappingIO.swift (82%) rename Sources/{ProxyLight/Core => ProxyLightCore}/MappingStore.swift (59%) rename Sources/{ProxyLight/Core => ProxyLightCore}/MappingsBox.swift (100%) rename Sources/{ProxyLight/Core => ProxyLightCore}/PACGenerator.swift (100%) rename Sources/{ProxyLight/Proxy => ProxyLightCore}/PACResponder.swift (100%) rename Sources/{ProxyLight/Proxy => ProxyLightCore}/ProxyGlueHandler.swift (100%) create mode 100644 Sources/ProxyLightCore/ProxyOrchestrator.swift rename Sources/{ProxyLight/Proxy => ProxyLightCore}/ProxyServer.swift (100%) rename Sources/{ProxyLight/Core => ProxyLightCore}/UpdateChecker.swift (89%) create mode 100644 Sources/proxylight-cli/ProxyLightCLI.swift rename Tests/{ProxyLightTests => ProxyLightCoreTests}/CertificateAuthorityTests.swift (57%) rename Tests/{ProxyLightTests => ProxyLightCoreTests}/MappingEngineTests.swift (99%) create mode 100644 Tests/ProxyLightCoreTests/MappingStoreLinuxDirectoryTests.swift create mode 100644 Tests/ProxyLightCoreTests/MappingStoreTests.swift rename Tests/{ProxyLightTests => ProxyLightCoreTests}/PACGeneratorTests.swift (97%) rename Tests/{ProxyLightTests => ProxyLightCoreTests}/PACResponderTests.swift (99%) create mode 100644 Tests/ProxyLightCoreTests/ProxyOrchestratorTests.swift rename Tests/{ProxyLightTests => ProxyLightCoreTests}/ProxyServerHTTPTests.swift (99%) rename Tests/{ProxyLightTests => ProxyLightCoreTests}/ProxyServerTLSTests.swift (99%) rename Tests/{ProxyLightTests => ProxyLightCoreTests}/RequestHeadSizeGuardTests.swift (99%) rename Tests/{ProxyLightTests => ProxyLightCoreTests}/UpdateCheckTests.swift (98%) create mode 100644 Tests/ProxyLightTests/CATrustManagerTests.swift rename Tests/ProxyLightTests/{MappingStoreTests.swift => SystemProxyManagerTests.swift} (62%) diff --git a/CLAUDE.md b/CLAUDE.md index 896c8b0..bd1284b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,22 +1,39 @@ # ProxyLight -Lightweight macOS menu bar app: a local HTTP/HTTPS proxy that rewrites user-mapped URL patterns to remote origins (e.g. `https://myapp.example.com/tachyon/*` → `https://origin.example.net/tachyon/*`). +A local HTTP/HTTPS proxy that rewrites user-mapped URL patterns to remote origins (e.g. `https://myapp.example.com/tachyon/*` → `https://origin.example.net/tachyon/*`). Ships as a macOS menu bar app and, from the same codebase, a Linux CLI. ## Stack -- Swift 6 Package executable (no Xcode project) — macOS 14+, **Apple Silicon**. `swift build` compiles for the host arch only, so the shipped bundle is arm64-only; a universal build needs `swift build --arch arm64 --arch x86_64`. -- SwiftUI `MenuBarExtra` for UI. `ServiceManagement` (`SMAppService`) for launch-at-login. -- SwiftNIO stack: `apple/swift-nio`, `apple/swift-nio-ssl`, `apple/swift-certificates`. No other dependencies without asking. +- Swift 6 Package (no Xcode project) split into three targets sharing one core: + - `ProxyLightCore` (library, cross-platform): mapping engine, PAC generation, CA/leaf cert generation, the SwiftNIO proxy pipeline, config persistence, and `ProxyOrchestrator` (the plain-Swift start/stop/mapping-mutation API both frontends drive). + - `ProxyLight` (executable, **macOS 14+, Apple Silicon**): the SwiftUI `MenuBarExtra` app. `swift build` compiles for the host arch only, so the shipped bundle is arm64-only; a universal build needs `swift build --arch arm64 --arch x86_64`. Depends on `ProxyLightCore` plus macOS-only wrappers (`SystemProxyManager`, `CATrustManager`, `LoginItemManager`, `SelfUpdater`, `ProxyRestoreStore`) under `Sources/ProxyLight/MacOS/`. `ServiceManagement` (`SMAppService`) drives launch-at-login. + - `proxylight-cli` (executable, Linux + macOS): a thin `swift-argument-parser` CLI over `ProxyOrchestrator` — no system-proxy automation, no CA auto-trust, no login item, no self-update (see Conventions). This is what makes the Linux build viable: it depends only on `ProxyLightCore`, never on `ProxyLight`. +- SwiftNIO stack: `apple/swift-nio`, `apple/swift-nio-ssl`, `apple/swift-certificates`. `swift-argument-parser` for the CLI. No other dependencies without asking. +- `ProxyLightCore` is intentionally the only target required to build and test on Linux; `ProxyLight` (SwiftUI/AppKit/Security/ServiceManagement) will never compile there and isn't meant to. ## Commands -- Build: `swift build` -- Test: `swift test` (integration tests that block on `.wait()` live in a `.serialized` suite; the default parallel runner stalls otherwise) -- Run: `swift run ProxyLight` (note: launch-at-login can't register from `swift run` — it needs a signed bundle; see Conventions) +- Build (macOS app): `swift build` (builds everything — will fail if run from a machine without SwiftUI/AppKit, i.e. non-macOS) +- Build (Linux CLI only): `swift build --product proxylight-cli` — note `--product`, not `--target`; `--target` compiles the module but skips the link step and silently leaves no runnable binary. +- Test (portable core): `swift test --filter ProxyLightCoreTests` builds `ProxyLight` too as a package-wide side effect of `swift test`'s target resolution, so on Linux this still fails — see "Verifying the Linux side" below. +- Test (macOS app + Core together): `swift test` (integration tests that block on `.wait()` live in a `.serialized` suite; the default parallel runner stalls otherwise) +- Run macOS app from source: `swift run ProxyLight` (note: launch-at-login can't register from `swift run` — it needs a signed bundle; see Conventions) +- Run CLI from source: `swift run proxylight-cli start` (or `swift run proxylight-cli --help` for the full subcommand list: `start`, `mapping add/list/remove`, `import`, `export`, `ca-path`) - Package `.app`: `scripts/build-app.sh` → `dist/ProxyLight.app` (bundles the release binary with `packaging/Info.plist` + `ProxyLight.icns`; unsigned) -- Release (signed + notarized): `scripts/release.sh` → `dist/ProxyLight.zip` (build → sign → notarize → staple → zip). Needs a **Developer ID Application** cert (not "Apple Development") + a `ProxyLight-notary` notarytool profile backed by an **App Store Connect API key** (`.p8`) — app-specific passwords proved unreliable when the signing machine has several Apple accounts on different teams. `SKIP_NOTARIZE=1` signs only; `SIGN_IDENTITY=…` overrides identity detection. **Don't move, open, or otherwise touch `dist/ProxyLight.app` while `release.sh` runs** — it operates on that exact path and will fail mid-run. +- Release (signed + notarized): `scripts/release.sh` → `dist/ProxyLight.zip` (build → sign → notarize → staple → zip). Needs a **Developer ID Application** cert (not "Apple Development") + a `ProxyLight-notary` notarytool profile backed by an **App Store Connect API key** (`.p8`) — app-specific passwords proved unreliable when the signing machine has several Apple accounts on different teams. `SKIP_NOTARIZE=1` signs only; `SIGN_IDENTITY=…` overrides identity detection. **Don't move, open, or otherwise touch `dist/ProxyLight.app` while `release.sh` runs** — it operates on that exact path and will fail mid-run. Linux has no equivalent packaging step yet — `swift build --product proxylight-cli` produces a plain debug/release binary only. - Distribute: attach the zip to a GitHub Release — `gh release create vX.Y.Z dist/ProxyLight.zip --target main --latest`. The README download link points at `/releases/latest`, so it always resolves to the newest release. Bump `CFBundleShortVersionString` in `packaging/Info.plist` to match the tag. +### Verifying the Linux side from a macOS or Linux dev machine + +There's no CI job for this yet (see Conventions). To check `ProxyLightCore`/`proxylight-cli` build and test on Linux without a native Linux machine, use the official Docker image — `swift build`/`swift test` against the real `Package.swift` still try to build the macOS-only `ProxyLight`/`ProxyLightTests` targets and fail on `import SwiftUI`, so scope the run to the portable targets: + +``` +docker run --rm -v "$PWD":/src -w /src swift:6.1 swift build --product proxylight-cli +docker run --rm -v "$PWD":/src -w /src swift:6.1 swift build --target ProxyLightCoreTests +``` + +For a real `swift test` run (not just a compile check) you need a package manifest that omits the `ProxyLight`/`ProxyLightTests` targets — copy the repo (excluding `.build`), delete `Sources/ProxyLight` and `Tests/ProxyLightTests`, and swap in a `Package.swift` with only `ProxyLightCore`, `proxylight-cli`, and `ProxyLightCoreTests`, then run `swift test` in that copy. Don't commit such a trimmed manifest — it's a throwaway verification step, not a shipped configuration. + ## Conventions - Design specs live in `docs/superpowers/specs/`; read the current spec before implementing. @@ -24,8 +41,11 @@ Lightweight macOS menu bar app: a local HTTP/HTTPS proxy that rewrites user-mapp - Proxy listener binds `127.0.0.1` only — never `0.0.0.0`. - System proxy config is PAC-based: the listener serves `/proxy.pac` (via `PACResponder`) routing only mapped hostnames through the proxy; everything else goes `DIRECT`. Mapped hosts get no `; DIRECT` failover (fail loudly, never silently hit the real origin). `PACResponder` lives on the outer listener pipeline only — never install it in the rebuilt MITM pipeline. Mapping edits while running must bump the PAC URL's `?v=` (macOS caches the PAC by URL). - HTTP/1.1 only (ALPN offers `http/1.1`); WebSockets on mapped hosts are out of scope for v1. -- CA private key: `~/Library/Application Support/ProxyLight/`, mode `0600`, never logged. +- CA private key: macOS `~/Library/Application Support/ProxyLight/`, Linux `$XDG_CONFIG_HOME/proxylight` or `~/.config/proxylight` (see `MappingStore.defaultDirectory(environment:)`), mode `0600`, never logged. - Menu-bar accessory app (`LSUIElement` + `.accessory`): it never becomes frontmost on its own, so anything that opens a window (e.g. Settings) must call `NSApp.activate(ignoringOtherApps:)` or the window opens behind other apps. - App icon (`packaging/ProxyLight.icns`) is custom-drawn — **never use an SF Symbol as the app icon**; Apple's SF Symbols license forbids it. `build-app.sh` installs the icns; it's declared via `CFBundleIconFile`. -- System-facing side effects go through thin, single-purpose wrappers (`SystemProxyManager`, `CATrustManager`, `LoginItemManager`) so `AppState` orchestration stays clean and the wrappers isolate the untestable I/O. Follow this pattern for new OS integrations. -- In-app updates: `UpdateChecker` polls the GitHub `releases/latest` API over a proxy-bypassed session (defense-in-depth — with PAC routing, unmapped hosts go DIRECT anyway, but update checks must never depend on the proxy's state), and `SelfUpdater` installs the zip only after the new bundle satisfies the running app's designated requirement (same bundle ID + team). Self-update therefore works only in signed release builds; dev/unsigned builds fall back to a browser download. Release assets must stay a single zip containing `ProxyLight.app`. +- System-facing side effects go through thin, single-purpose wrappers (`SystemProxyManager`, `CATrustManager`, `LoginItemManager`) so `AppState` orchestration stays clean and the wrappers isolate the untestable I/O. Follow this pattern for new OS integrations. These wrappers, plus `SelfUpdater` and `ProxyRestoreStore`, live under `Sources/ProxyLight/MacOS/` — they're macOS-only by design (Keychain, `networksetup`, code-signing APIs, `SMAppService` have no Linux equivalent) and must never move into `ProxyLightCore`. +- `ProxyOrchestrator` (`Sources/ProxyLightCore/ProxyOrchestrator.swift`) is the single shared control surface: config load/save, the live mapping set, CA access, and NIO listener start/stop — no SwiftUI/`ObservableObject`, no system-proxy coupling. `AppState` wraps it for the macOS app (adds system-proxy snapshot/restore, CA trust, login item, self-update, update checks); `proxylight-cli` drives it directly. Add new *proxy-behavior* logic here so both frontends get it for free; add new *OS-integration* logic as a macOS-only wrapper instead (see above) or, if it's Linux-specific, as a new thin wrapper under `Sources/proxylight-cli/`. +- The CLI is deliberately minimal scope: no automated system-proxy config, no CA auto-trust install, no login-at-boot, no self-update on Linux. `proxylight start` prints the PAC URL and the CA cert path; the user points their browser/OS at the PAC URL and imports the CA cert manually. Don't add Linux system-proxy or trust-store automation without discussing it first — Linux's trust-store/session-startup landscape is fragmented and often needs root, unlike macOS's Keychain/`networksetup`/`SMAppService`. +- CLI signal handling: `DispatchSource` signal sources for SIGINT/SIGTERM must run on a dedicated `DispatchQueue`, never `.main` — the CLI's `run()` blocks the main thread on a semaphore instead of running a run loop, so `.main` never drains and Ctrl-C/kill hangs forever. +- In-app updates: `UpdateChecker` polls the GitHub `releases/latest` API over a proxy-bypassed session (defense-in-depth — with PAC routing, unmapped hosts go DIRECT anyway, but update checks must never depend on the proxy's state), and `SelfUpdater` installs the zip only after the new bundle satisfies the running app's designated requirement (same bundle ID + team). Self-update therefore works only in signed release builds; dev/unsigned builds fall back to a browser download. Release assets must stay a single zip containing `ProxyLight.app`. `UpdateChecker` itself lives in `ProxyLightCore` (portable, no code-signing dependency) but `proxylight-cli` doesn't wire it up — self-update is out of scope for the CLI. diff --git a/Package.resolved b/Package.resolved index 47db40a..d2b8574 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,6 +1,15 @@ { - "originHash" : "03a7fcf2fd0aff18aaffe4e98fb71f3604ae8f95ff38310e9f40d0f2d1debda1", + "originHash" : "c169e9784da2ae1150abd75423b8b3c57097b6377f6a2ad5de098d64156b2a45", "pins" : [ + { + "identity" : "swift-argument-parser", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-argument-parser.git", + "state" : { + "revision" : "6a52f3251125d74daf04fcbd5e6f08a75d074382", + "version" : "1.8.2" + } + }, { "identity" : "swift-asn1", "kind" : "remoteSourceControl", diff --git a/Package.swift b/Package.swift index 88aa13a..81cafba 100644 --- a/Package.swift +++ b/Package.swift @@ -8,10 +8,11 @@ let package = Package( .package(url: "https://github.com/apple/swift-nio.git", from: "2.101.0"), .package(url: "https://github.com/apple/swift-nio-ssl.git", from: "2.37.0"), .package(url: "https://github.com/apple/swift-certificates.git", from: "1.19.0"), + .package(url: "https://github.com/apple/swift-argument-parser.git", from: "1.5.0"), ], targets: [ - .executableTarget( - name: "ProxyLight", + .target( + name: "ProxyLightCore", dependencies: [ .product(name: "NIOCore", package: "swift-nio"), .product(name: "NIOPosix", package: "swift-nio"), @@ -20,12 +21,27 @@ let package = Package( .product(name: "X509", package: "swift-certificates"), ] ), + .executableTarget( + name: "ProxyLight", + dependencies: ["ProxyLightCore"] + ), + .executableTarget( + name: "proxylight-cli", + dependencies: [ + "ProxyLightCore", + .product(name: "ArgumentParser", package: "swift-argument-parser"), + ] + ), .testTarget( - name: "ProxyLightTests", + name: "ProxyLightCoreTests", dependencies: [ - "ProxyLight", + "ProxyLightCore", .product(name: "NIOEmbedded", package: "swift-nio"), ] ), + .testTarget( + name: "ProxyLightTests", + dependencies: ["ProxyLight", "ProxyLightCore"] + ), ] ) diff --git a/README.md b/README.md index f9bab19..a2287f1 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,16 @@ # ProxyLight -Point a URL at a different origin without touching DNS, hosts files, or your app's config. ProxyLight is a macOS menu bar app that runs a local proxy and rewrites URLs you map to a remote target — including HTTPS, transparently. +Point a URL at a different origin without touching DNS, hosts files, or your app's config. ProxyLight runs a local proxy and rewrites URLs you map to a remote target — including HTTPS, transparently. It ships as a macOS menu bar app, and as a Linux CLI from the same codebase. Example: serve `https://myapp.example.com/assets/*` from `https://origin.example.net/assets/*` while your browser still shows the original address. ## Requirements -- macOS 14 or later, on an Apple Silicon Mac. -- To build from source: Swift 6 toolchain (Xcode 16+ or the Swift toolchain). +- **macOS app**: macOS 14 or later, on an Apple Silicon Mac. +- **Linux CLI**: any Linux distribution with a Swift 6 toolchain; no packaged binary yet, build from source (see [Linux CLI](#linux-cli)). +- To build from source: Swift 6 toolchain (Xcode 16+ on macOS, or the Swift toolchain on Linux). -## Install +## Install (macOS app) 1. Download `ProxyLight.zip` from the [latest release](https://github.com/stuartshields/ProxyLight/releases/latest). 2. Unzip it and drag `ProxyLight.app` to `/Applications`. @@ -46,15 +47,34 @@ Toggle individual mappings on and off from the menu. Use **Import…** / **Expor - The proxy listens on `127.0.0.1` only — it is never exposed to the network. - HTTP/1.1 only. WebSockets on mapped hosts are not supported. -- The default listen port is `9876` (change it in Settings → Proxy). -- The CA private key lives in `~/Library/Application Support/ProxyLight/`. +- The default listen port is `9876` (change it in Settings → Proxy, or with `proxylight start --port` on Linux). +- The CA private key lives in `~/Library/Application Support/ProxyLight/` (macOS) or `$XDG_CONFIG_HOME/proxylight`/`~/.config/proxylight` (Linux). - If ProxyLight quits or crashes, normal browsing is unaffected — macOS falls back to `DIRECT` once the PAC URL stops responding. Only mapped hosts stop resolving until the app restarts or you turn the proxy off to restore your previous settings. +## Linux CLI + +There's no packaged binary yet — build `proxylight-cli` from source with a Swift 6 toolchain: + +``` +swift build --product proxylight-cli +``` + +Unlike the macOS app, the CLI doesn't touch system proxy settings or the OS certificate trust store — Linux has no single equivalent to `networksetup`/Keychain, so those steps are manual: + +1. **Add a mapping**: `swift run proxylight-cli mapping add "https://myapp.example.com/assets/*" "https://origin.example.net/assets/*"` +2. **Start the proxy**: `swift run proxylight-cli start`. This prints the PAC URL (`http://127.0.0.1:/proxy.pac`) and the path to the generated CA certificate. +3. **Point your browser at the PAC URL** (its network/proxy settings — same idea as step 4 of First steps above, just configured manually instead of by the app) and **import the CA certificate** printed above into your browser's trust store, so it accepts the rewritten HTTPS responses. +4. Stop the proxy with Ctrl-C — it shuts down cleanly. + +Other subcommands: `mapping list`, `mapping remove `, `import `, `export `, `ca-path`. Run `proxylight-cli --help` for the full reference. + ## Development -- Build: `swift build` -- Test: `swift test` -- Run from source: `swift run ProxyLight` +- Build (macOS app): `swift build` +- Build (Linux CLI): `swift build --product proxylight-cli` +- Test: `swift test` (macOS; builds and tests the app target too) +- Run macOS app from source: `swift run ProxyLight` +- Run CLI from source: `swift run proxylight-cli start` - Package the app bundle: `scripts/build-app.sh` → `dist/ProxyLight.app` See `CLAUDE.md` for architecture and packaging details. diff --git a/Sources/ProxyLight/App/AppState.swift b/Sources/ProxyLight/App/AppState.swift index 039fc64..771da79 100644 --- a/Sources/ProxyLight/App/AppState.swift +++ b/Sources/ProxyLight/App/AppState.swift @@ -1,6 +1,7 @@ import Foundation import SwiftUI import UniformTypeIdentifiers +import ProxyLightCore // Mappings decoded from an import file, staged until the user picks which to // keep. Identifiable so it can drive a sheet(item:) presentation. @@ -11,7 +12,6 @@ struct PendingImport: Identifiable { @MainActor final class AppState: ObservableObject { - @Published var config: AppConfig @Published var isRunning = false @Published var statusMessage = "Stopped" @Published var caAvailable: Bool @@ -36,21 +36,12 @@ final class AppState: ObservableObject { // builds fall back to a version every release beats. static let appVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "0.0.0-dev" - private let store: MappingStore + private let orchestrator: ProxyOrchestrator private let restoreStore: ProxyRestoreStore private let proxyManager: SystemProxyManager - private var ca: CertificateAuthority? - private var server: ProxyServer? private var savedProxyState: ProxyState? private var activeService: String? - private var runningPort: Int? - // Bumped on every mapping save while running; forces macOS to re-fetch the - // PAC (which it caches by URL) by changing the ?v= query param. - private var pacVersion = 0 private var signalSources: [DispatchSourceSignal] = [] - // Live mapping set read by the proxy's engineProvider closure from NIO - // event-loop threads. Kept in sync with config.mappings on every save(). - private let mappingsBox = MappingsBox([]) private let loginItemManager = LoginItemManager() private var updateCheckTimer: Timer? @@ -59,15 +50,31 @@ final class AppState: ObservableObject { private static let launchUpdateCheckDelay: Duration = .seconds(10) private static let backgroundUpdateCheckInterval: TimeInterval = 24 * 60 * 60 + // Computed (not @Published) because the source of truth lives in + // orchestrator; objectWillChange is sent manually so SwiftUI bindings + // ($state.config.mappings, $state.config.listenPort) still redraw on edit. + var config: AppConfig { + get { orchestrator.config } + set { + objectWillChange.send() + orchestrator.config = newValue + } + } + + // Persists the current config and, if running, re-applies the PAC. + // Views call this explicitly after a binding-driven edit (mapping toggle, + // port field) — see the .onChange/.onSubmit call sites. + func save() { + orchestrator.save() + refreshPACIfRunning() + } + init() { - let dir = MappingStore.defaultDirectory - store = MappingStore(directory: dir) + let dir = ProxyOrchestrator.defaultDirectory + orchestrator = ProxyOrchestrator(directory: dir) restoreStore = ProxyRestoreStore(directory: dir) proxyManager = SystemProxyManager() - config = store.load() - ca = try? CertificateAuthority(directory: dir) - caAvailable = ca != nil - mappingsBox.set(config.mappings) + caAvailable = orchestrator.rootCertificateURL != nil recoverFromUncleanExit() installTerminationHandlers() @@ -123,23 +130,11 @@ final class AppState: ObservableObject { isRunning ? stop() : start() } - private func pacURL(port: Int) -> String { - "http://127.0.0.1:\(port)/proxy.pac?v=\(pacVersion)" - } - private func start() { - let currentConfig = config - // Capture only the thread-safe box, not self/currentConfig, so edits made - // while the proxy is running (toggle/add/delete/edit a mapping) take - // effect immediately without a stop/start cycle. Port changes still need - // a restart to rebind the listener. - let engineProvider: @Sendable () -> MappingEngine = { [mappingsBox] in MappingEngine(mappings: mappingsBox.get()) } - let server = ProxyServer(port: currentConfig.listenPort, engineProvider: engineProvider, ca: ca) do { activeService = nil savedProxyState = nil - let boundPort = try server.start() - self.server = server + let boundPort = try orchestrator.start() let service = try proxyManager.activeNetworkService() activeService = service let snapshot = try proxyManager.snapshot(service: service).discardingLoopbackSelfReference(port: boundPort) @@ -147,9 +142,7 @@ final class AppState: ObservableObject { // Persist the restore point BEFORE applying, so an unclean exit at any // point after this can still be recovered on the next launch. restoreStore.save(RestorePoint(service: service, state: snapshot)) - pacVersion = 1 - try proxyManager.apply(pacURL: pacURL(port: boundPort), service: service) - runningPort = boundPort + try proxyManager.apply(pacURL: orchestrator.pacURL()!, service: service) isRunning = true statusMessage = "Running on 127.0.0.1:\(boundPort) (PAC)" } catch { @@ -157,10 +150,9 @@ final class AppState: ObservableObject { try? proxyManager.restore(saved, service: service) } restoreStore.clear() - try? server.stop() - self.server = nil + try? orchestrator.stop() isRunning = false - statusMessage = "Failed: \(error.localizedDescription). Set the Automatic Proxy Configuration URL to http://127.0.0.1:\(currentConfig.listenPort)/proxy.pac manually." + statusMessage = "Failed: \(error.localizedDescription). Set the Automatic Proxy Configuration URL to http://127.0.0.1:\(orchestrator.config.listenPort)/proxy.pac manually." } } @@ -171,26 +163,21 @@ final class AppState: ObservableObject { // Clean shutdown: drop the restore point so the next launch doesn't think // we exited uncleanly. restoreStore.clear() - try? server?.stop() - server = nil + try? orchestrator.stop() activeService = nil - runningPort = nil savedProxyState = nil isRunning = false statusMessage = "Stopped" } func addMapping(from: String, to: String, mode: MappingMode) { - config.mappings.append(Mapping(from: from, to: to, enabled: true, mode: mode)) - save() + orchestrator.addMapping(from: from, to: to, mode: mode) + refreshPACIfRunning() } func updateMapping(id: Mapping.ID, from: String, to: String, mode: MappingMode) { - guard let index = config.mappings.firstIndex(where: { $0.id == id }) else { return } - config.mappings[index].from = from - config.mappings[index].to = to - config.mappings[index].mode = mode - save() + orchestrator.updateMapping(id: id, from: from, to: to, mode: mode) + refreshPACIfRunning() } func exportMappings(_ selected: [Mapping]) { @@ -199,7 +186,7 @@ final class AppState: ObservableObject { panel.allowedContentTypes = [.json] guard panel.runModal() == .OK, let url = panel.url else { return } do { - try MappingIO.encode(selected).write(to: url, options: .atomic) + try orchestrator.exportData(selected).write(to: url, options: .atomic) transferStatus = "Exported \(selected.count) mapping(s)." } catch { transferStatus = "Export failed: \(error.localizedDescription)" @@ -215,7 +202,7 @@ final class AppState: ObservableObject { panel.allowsMultipleSelection = false guard panel.runModal() == .OK, let url = panel.url else { return } do { - let imported = try MappingIO.decode(Data(contentsOf: url)) + let imported = try orchestrator.decodeImportFile(Data(contentsOf: url)) guard !imported.isEmpty else { transferStatus = "No mappings found in that file." return @@ -228,9 +215,8 @@ final class AppState: ObservableObject { func completeImport(accepted: [Mapping]) { pendingImport = nil - let result = MappingIO.apply(existing: config.mappings, accepted: accepted) - config.mappings = result.mappings - save() + let result = orchestrator.completeImport(accepted: accepted) + refreshPACIfRunning() var parts: [String] = [] if result.added > 0 { parts.append("\(result.added) added") } if result.replaced > 0 { parts.append("\(result.replaced) overwritten") } @@ -239,13 +225,7 @@ final class AppState: ObservableObject { } func deleteMapping(_ id: Mapping.ID) { - config.mappings.removeAll { $0.id == id } - save() - } - - func save() { - mappingsBox.set(config.mappings) - try? store.save(config) + orchestrator.deleteMapping(id) refreshPACIfRunning() } @@ -253,25 +233,24 @@ final class AppState: ObservableObject { // macOS caches the PAC by URL — so bump ?v= and re-apply. A networksetup // hiccup must not roll back the saved mapping; surface it instead. private func refreshPACIfRunning() { - guard isRunning, let service = activeService, let port = runningPort else { return } - pacVersion += 1 + guard isRunning, let service = activeService, let pacURL = orchestrator.pacURL() else { return } do { - try proxyManager.refreshAutoProxyURL(pacURL(port: port), service: service) - statusMessage = "Running on 127.0.0.1:\(port) (PAC)" + try proxyManager.refreshAutoProxyURL(pacURL, service: service) + statusMessage = "Running on 127.0.0.1:\(orchestrator.runningPort!) (PAC)" } catch { statusMessage = "Mapping saved, but the system PAC may be stale — toggle the proxy off and on." } } - var rootCertificatePEM: String { ca?.rootCertificatePEM ?? "Certificate authority unavailable" } + var rootCertificatePEM: String { orchestrator.rootCertificatePEM } func trustCA() { - guard let ca else { + guard let rootCertificateURL = orchestrator.rootCertificateURL else { trustStatus = "Certificate authority unavailable." return } do { - try CATrustManager().trust(certificateURL: ca.rootCertificateURL) + try CATrustManager().trust(certificateURL: rootCertificateURL) trustStatus = "Restart your browser to pick up the change." } catch { trustStatus = "Failed to trust certificate: \(error.localizedDescription)" @@ -280,11 +259,11 @@ final class AppState: ObservableObject { } private func refreshCATrust() { - guard let ca else { + guard let rootCertificateURL = orchestrator.rootCertificateURL else { caTrusted = false return } - caTrusted = CATrustManager().isTrusted(certificateURL: ca.rootCertificateURL) + caTrusted = CATrustManager().isTrusted(certificateURL: rootCertificateURL) } func checkForUpdates() { diff --git a/Sources/ProxyLight/App/MappingEditorView.swift b/Sources/ProxyLight/App/MappingEditorView.swift index d576fcc..67d9588 100644 --- a/Sources/ProxyLight/App/MappingEditorView.swift +++ b/Sources/ProxyLight/App/MappingEditorView.swift @@ -1,4 +1,5 @@ import SwiftUI +import ProxyLightCore // Modal sheet for adding OR editing a URL mapping, styled to macOS conventions: // a grouped Form for the fields and a standard bottom button bar with Cancel on diff --git a/Sources/ProxyLight/App/MappingTransferViews.swift b/Sources/ProxyLight/App/MappingTransferViews.swift index 6ffc0bc..8951dd9 100644 --- a/Sources/ProxyLight/App/MappingTransferViews.swift +++ b/Sources/ProxyLight/App/MappingTransferViews.swift @@ -1,4 +1,5 @@ import SwiftUI +import ProxyLightCore // Sheets for choosing which mappings take part in an export or import. // Both follow the MappingEditorView layout: headline title, grouped Form, diff --git a/Sources/ProxyLight/App/MappingsView.swift b/Sources/ProxyLight/App/MappingsView.swift index 0012f5b..270cd2f 100644 --- a/Sources/ProxyLight/App/MappingsView.swift +++ b/Sources/ProxyLight/App/MappingsView.swift @@ -1,4 +1,5 @@ import SwiftUI +import ProxyLightCore // Standalone "Edit Mappings" window: the mappings list plus add/import/export. // Everything else (startup, proxy port, certificate authority) lives in SettingsView. diff --git a/Sources/ProxyLight/App/MenuContent.swift b/Sources/ProxyLight/App/MenuContent.swift index 320de80..76ef8df 100644 --- a/Sources/ProxyLight/App/MenuContent.swift +++ b/Sources/ProxyLight/App/MenuContent.swift @@ -1,4 +1,5 @@ import SwiftUI +import ProxyLightCore struct MenuContent: View { @ObservedObject var state: AppState diff --git a/Sources/ProxyLight/Core/CATrustManager.swift b/Sources/ProxyLight/MacOS/CATrustManager.swift similarity index 100% rename from Sources/ProxyLight/Core/CATrustManager.swift rename to Sources/ProxyLight/MacOS/CATrustManager.swift diff --git a/Sources/ProxyLight/Core/LoginItemManager.swift b/Sources/ProxyLight/MacOS/LoginItemManager.swift similarity index 100% rename from Sources/ProxyLight/Core/LoginItemManager.swift rename to Sources/ProxyLight/MacOS/LoginItemManager.swift diff --git a/Sources/ProxyLight/Core/ProxyRestoreStore.swift b/Sources/ProxyLight/MacOS/ProxyRestoreStore.swift similarity index 100% rename from Sources/ProxyLight/Core/ProxyRestoreStore.swift rename to Sources/ProxyLight/MacOS/ProxyRestoreStore.swift diff --git a/Sources/ProxyLight/Core/SelfUpdater.swift b/Sources/ProxyLight/MacOS/SelfUpdater.swift similarity index 100% rename from Sources/ProxyLight/Core/SelfUpdater.swift rename to Sources/ProxyLight/MacOS/SelfUpdater.swift diff --git a/Sources/ProxyLight/Core/SystemProxyManager.swift b/Sources/ProxyLight/MacOS/SystemProxyManager.swift similarity index 100% rename from Sources/ProxyLight/Core/SystemProxyManager.swift rename to Sources/ProxyLight/MacOS/SystemProxyManager.swift diff --git a/Sources/ProxyLight/Core/CertificateAuthority.swift b/Sources/ProxyLightCore/CertificateAuthority.swift similarity index 100% rename from Sources/ProxyLight/Core/CertificateAuthority.swift rename to Sources/ProxyLightCore/CertificateAuthority.swift diff --git a/Sources/ProxyLight/Proxy/ConnectHandler.swift b/Sources/ProxyLightCore/ConnectHandler.swift similarity index 100% rename from Sources/ProxyLight/Proxy/ConnectHandler.swift rename to Sources/ProxyLightCore/ConnectHandler.swift diff --git a/Sources/ProxyLight/Core/Mapping.swift b/Sources/ProxyLightCore/Mapping.swift similarity index 77% rename from Sources/ProxyLight/Core/Mapping.swift rename to Sources/ProxyLightCore/Mapping.swift index ce09f22..5745802 100644 --- a/Sources/ProxyLight/Core/Mapping.swift +++ b/Sources/ProxyLightCore/Mapping.swift @@ -4,19 +4,19 @@ import Foundation // - rewrite: always forward to the remote target (the original behavior). // - fallbackOnNotFound: serve the LOCAL origin first; only forward to the remote // target if the local response is 404 (for safe GET/HEAD requests). -enum MappingMode: String, Codable, CaseIterable { +public enum MappingMode: String, Codable, CaseIterable, Sendable { case rewrite case fallbackOnNotFound } -struct Mapping: Codable, Identifiable, Equatable { - var id: UUID - var from: String - var to: String - var enabled: Bool - var mode: MappingMode +public struct Mapping: Codable, Identifiable, Equatable, Sendable { + public var id: UUID + public var from: String + public var to: String + public var enabled: Bool + public var mode: MappingMode - init(id: UUID = UUID(), from: String, to: String, enabled: Bool = true, mode: MappingMode = .rewrite) { + public init(id: UUID = UUID(), from: String, to: String, enabled: Bool = true, mode: MappingMode = .rewrite) { self.id = id self.from = from self.to = to @@ -26,7 +26,7 @@ struct Mapping: Codable, Identifiable, Equatable { // Custom decode so configs written before `mode` existed still load: a // missing `mode` key defaults to .rewrite (the prior behavior). - init(from decoder: Decoder) throws { + public init(from decoder: Decoder) throws { let c = try decoder.container(keyedBy: CodingKeys.self) id = try c.decode(UUID.self, forKey: .id) from = try c.decode(String.self, forKey: .from) @@ -36,23 +36,23 @@ struct Mapping: Codable, Identifiable, Equatable { } } -struct AppConfig: Codable, Equatable { - var listenPort: Int - var mappings: [Mapping] +public struct AppConfig: Codable, Equatable, Sendable { + public var listenPort: Int + public var mappings: [Mapping] - static var defaultConfig: AppConfig { + public static var defaultConfig: AppConfig { AppConfig(listenPort: 9876, mappings: []) } } -enum MappingValidationError: Equatable { +public enum MappingValidationError: Equatable { case fromInvalid(String) case toInvalid(String) case wildcardMismatch // User-facing message shared by every place that surfaces a validation // failure (the mappings list and the add-mapping modal). - var message: String { + public var message: String { switch self { case .fromInvalid(let reason): return "From: \(reason)" case .toInvalid(let reason): return "To: \(reason)" @@ -64,7 +64,7 @@ enum MappingValidationError: Equatable { // Pure validator mirroring MappingEngine's parsing rules, so bad patterns can // be flagged in the UI instead of being silently dropped at engine-build time. // Returns nil when the pair is valid, else the first problem found. -func validateMapping(from: String, to: String) -> MappingValidationError? { +public func validateMapping(from: String, to: String) -> MappingValidationError? { if let reason = urlValidationProblem(from) { return .fromInvalid(reason) } diff --git a/Sources/ProxyLight/Core/MappingEngine.swift b/Sources/ProxyLightCore/MappingEngine.swift similarity index 100% rename from Sources/ProxyLight/Core/MappingEngine.swift rename to Sources/ProxyLightCore/MappingEngine.swift diff --git a/Sources/ProxyLight/Core/MappingIO.swift b/Sources/ProxyLightCore/MappingIO.swift similarity index 82% rename from Sources/ProxyLight/Core/MappingIO.swift rename to Sources/ProxyLightCore/MappingIO.swift index 5867f57..0cd8056 100644 --- a/Sources/ProxyLight/Core/MappingIO.swift +++ b/Sources/ProxyLightCore/MappingIO.swift @@ -8,10 +8,10 @@ struct MappingBundle: Codable, Equatable { // Import/export of mappings so a config can be shared between people. // Pure and I/O-free — the UI layer handles file panels and disk access. -enum MappingIO { - static let currentVersion = 1 +public enum MappingIO { + public static let currentVersion = 1 - static func encode(_ mappings: [Mapping]) throws -> Data { + public static func encode(_ mappings: [Mapping]) throws -> Data { let encoder = JSONEncoder() encoder.outputFormatting = [.prettyPrinted, .sortedKeys] return try encoder.encode(MappingBundle(version: currentVersion, mappings: mappings)) @@ -19,7 +19,7 @@ enum MappingIO { // Accepts either the versioned bundle or a bare array of mappings, so a // hand-written or older file still imports. - static func decode(_ data: Data) throws -> [Mapping] { + public static func decode(_ data: Data) throws -> [Mapping] { let decoder = JSONDecoder() if let bundle = try? decoder.decode(MappingBundle.self, from: data) { return bundle.mappings @@ -31,21 +31,21 @@ enum MappingIO { // - duplicate: identical from/to/mode already present — importing is a no-op. // - conflict: shares a from (local) or to (live site) URL with existing // mappings, so importing it means overwriting those. - enum ImportDisposition: Equatable { + public enum ImportDisposition: Equatable { case new case duplicate case conflict([Mapping]) } // Outcome of a selective import. - struct ImportResult: Equatable { - var mappings: [Mapping] - var added: Int - var replaced: Int - var unchanged: Int + public struct ImportResult: Equatable { + public var mappings: [Mapping] + public var added: Int + public var replaced: Int + public var unchanged: Int } - static func classify(_ imported: Mapping, against existing: [Mapping]) -> ImportDisposition { + public static func classify(_ imported: Mapping, against existing: [Mapping]) -> ImportDisposition { if existing.contains(where: { isSameContent($0, imported) }) { return .duplicate } @@ -57,7 +57,7 @@ enum MappingIO { // duplicates are skipped; a conflicting import overwrites every existing // mapping it collides with (the first keeps its slot and id, extras are // removed); the rest are appended with fresh ids so ids never collide. - static func apply(existing: [Mapping], accepted: [Mapping]) -> ImportResult { + public static func apply(existing: [Mapping], accepted: [Mapping]) -> ImportResult { var result = ImportResult(mappings: existing, added: 0, replaced: 0, unchanged: 0) for imported in accepted { if result.mappings.contains(where: { isSameContent($0, imported) }) { diff --git a/Sources/ProxyLight/Core/MappingStore.swift b/Sources/ProxyLightCore/MappingStore.swift similarity index 59% rename from Sources/ProxyLight/Core/MappingStore.swift rename to Sources/ProxyLightCore/MappingStore.swift index 5a5ccce..57fdb72 100644 --- a/Sources/ProxyLight/Core/MappingStore.swift +++ b/Sources/ProxyLightCore/MappingStore.swift @@ -8,9 +8,21 @@ struct MappingStore { } static var defaultDirectory: URL { - FileManager.default + defaultDirectory(environment: ProcessInfo.processInfo.environment) + } + + static func defaultDirectory(environment: [String: String]) -> URL { + #if os(macOS) + return FileManager.default .urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] .appendingPathComponent("ProxyLight", isDirectory: true) + #else + if let xdgConfigHome = environment["XDG_CONFIG_HOME"], !xdgConfigHome.isEmpty { + return URL(fileURLWithPath: xdgConfigHome).appendingPathComponent("proxylight", isDirectory: true) + } + let home = environment["HOME"] ?? FileManager.default.homeDirectoryForCurrentUser.path + return URL(fileURLWithPath: home).appendingPathComponent(".config/proxylight", isDirectory: true) + #endif } func load() -> AppConfig { diff --git a/Sources/ProxyLight/Core/MappingsBox.swift b/Sources/ProxyLightCore/MappingsBox.swift similarity index 100% rename from Sources/ProxyLight/Core/MappingsBox.swift rename to Sources/ProxyLightCore/MappingsBox.swift diff --git a/Sources/ProxyLight/Core/PACGenerator.swift b/Sources/ProxyLightCore/PACGenerator.swift similarity index 100% rename from Sources/ProxyLight/Core/PACGenerator.swift rename to Sources/ProxyLightCore/PACGenerator.swift diff --git a/Sources/ProxyLight/Proxy/PACResponder.swift b/Sources/ProxyLightCore/PACResponder.swift similarity index 100% rename from Sources/ProxyLight/Proxy/PACResponder.swift rename to Sources/ProxyLightCore/PACResponder.swift diff --git a/Sources/ProxyLight/Proxy/ProxyGlueHandler.swift b/Sources/ProxyLightCore/ProxyGlueHandler.swift similarity index 100% rename from Sources/ProxyLight/Proxy/ProxyGlueHandler.swift rename to Sources/ProxyLightCore/ProxyGlueHandler.swift diff --git a/Sources/ProxyLightCore/ProxyOrchestrator.swift b/Sources/ProxyLightCore/ProxyOrchestrator.swift new file mode 100644 index 0000000..ba12e6e --- /dev/null +++ b/Sources/ProxyLightCore/ProxyOrchestrator.swift @@ -0,0 +1,95 @@ +import Foundation + +// Plain-Swift proxy control surface shared by every frontend (the macOS menu +// bar app and the Linux CLI): owns config persistence, the live mapping set, +// the CA, and the NIO listener. Has no SwiftUI/ObservableObject or +// system-proxy coupling — those are frontend-specific layers built on top. +public final class ProxyOrchestrator { + public var config: AppConfig + public private(set) var isRunning = false + public private(set) var runningPort: Int? + + private let store: MappingStore + private var ca: CertificateAuthority? + private var server: ProxyServer? + // Live mapping set read by the proxy's engineProvider closure from NIO + // event-loop threads. Kept in sync with config.mappings on every save(). + private let mappingsBox = MappingsBox([]) + // Bumped on every mapping save while running, so a frontend that caches + // the PAC by URL (e.g. macOS) can force a re-fetch by changing ?v=. + private var pacVersion = 0 + + public static var defaultDirectory: URL { MappingStore.defaultDirectory } + + public init(directory: URL) { + store = MappingStore(directory: directory) + config = store.load() + ca = try? CertificateAuthority(directory: directory) + mappingsBox.set(config.mappings) + } + + public var rootCertificatePEM: String { ca?.rootCertificatePEM ?? "Certificate authority unavailable" } + public var rootCertificateURL: URL? { ca?.rootCertificateURL } + + public func pacURL(host: String = "127.0.0.1") -> String? { + guard let runningPort else { return nil } + return "http://\(host):\(runningPort)/proxy.pac?v=\(pacVersion)" + } + + public func start() throws -> Int { + let engineProvider: @Sendable () -> MappingEngine = { [mappingsBox] in MappingEngine(mappings: mappingsBox.get()) } + let server = ProxyServer(port: config.listenPort, engineProvider: engineProvider, ca: ca) + let boundPort = try server.start() + self.server = server + pacVersion = 1 + runningPort = boundPort + isRunning = true + return boundPort + } + + public func stop() throws { + try server?.stop() + server = nil + runningPort = nil + isRunning = false + } + + public func addMapping(from: String, to: String, mode: MappingMode) { + config.mappings.append(Mapping(from: from, to: to, enabled: true, mode: mode)) + save() + } + + public func updateMapping(id: Mapping.ID, from: String, to: String, mode: MappingMode) { + guard let index = config.mappings.firstIndex(where: { $0.id == id }) else { return } + config.mappings[index].from = from + config.mappings[index].to = to + config.mappings[index].mode = mode + save() + } + + public func deleteMapping(_ id: Mapping.ID) { + config.mappings.removeAll { $0.id == id } + save() + } + + public func exportData(_ selected: [Mapping]) throws -> Data { + try MappingIO.encode(selected) + } + + public func decodeImportFile(_ data: Data) throws -> [Mapping] { + try MappingIO.decode(data) + } + + public func completeImport(accepted: [Mapping]) -> MappingIO.ImportResult { + let result = MappingIO.apply(existing: config.mappings, accepted: accepted) + config.mappings = result.mappings + save() + return result + } + + public func save() { + mappingsBox.set(config.mappings) + try? store.save(config) + if isRunning { pacVersion += 1 } + } +} diff --git a/Sources/ProxyLight/Proxy/ProxyServer.swift b/Sources/ProxyLightCore/ProxyServer.swift similarity index 100% rename from Sources/ProxyLight/Proxy/ProxyServer.swift rename to Sources/ProxyLightCore/ProxyServer.swift diff --git a/Sources/ProxyLight/Core/UpdateChecker.swift b/Sources/ProxyLightCore/UpdateChecker.swift similarity index 89% rename from Sources/ProxyLight/Core/UpdateChecker.swift rename to Sources/ProxyLightCore/UpdateChecker.swift index 8868e58..56de76c 100644 --- a/Sources/ProxyLight/Core/UpdateChecker.swift +++ b/Sources/ProxyLightCore/UpdateChecker.swift @@ -1,10 +1,13 @@ import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif -enum UpdateError: LocalizedError { +public enum UpdateError: LocalizedError { case badStatus(Int) case responseTooLarge - var errorDescription: String? { + public var errorDescription: String? { switch self { case .badStatus(let code): "GitHub returned HTTP \(code)." case .responseTooLarge: "Release info was unexpectedly large." @@ -14,7 +17,7 @@ enum UpdateError: LocalizedError { // Thin network wrapper (same pattern as SystemProxyManager/CATrustManager): // fetches the latest GitHub release and defers to UpdateCheck for the logic. -struct UpdateChecker { +public struct UpdateChecker { private static let latestReleaseURL = URL(string: "https://api.github.com/repos/stuartshields/ProxyLight/releases/latest")! private static let maxResponseBytes = 1 << 20 @@ -27,7 +30,9 @@ struct UpdateChecker { return URLSession(configuration: config) }() - func checkForUpdate(currentVersion: String) async throws -> AvailableUpdate? { + public init() {} + + public func checkForUpdate(currentVersion: String) async throws -> AvailableUpdate? { var request = URLRequest(url: Self.latestReleaseURL) request.setValue("application/vnd.github+json", forHTTPHeaderField: "Accept") let (data, response) = try await Self.session.data(for: request) @@ -39,9 +44,9 @@ struct UpdateChecker { } } -struct AvailableUpdate: Equatable { - var version: String - var downloadURL: URL +public struct AvailableUpdate: Equatable, Sendable { + public var version: String + public var downloadURL: URL } // Pure release-check logic (no I/O) so it stays unit-testable; the network diff --git a/Sources/proxylight-cli/ProxyLightCLI.swift b/Sources/proxylight-cli/ProxyLightCLI.swift new file mode 100644 index 0000000..a0a6f9f --- /dev/null +++ b/Sources/proxylight-cli/ProxyLightCLI.swift @@ -0,0 +1,162 @@ +import ArgumentParser +import Foundation +import ProxyLightCore + +@main +struct ProxyLightCLI: ParsableCommand { + static let configuration = CommandConfiguration( + commandName: "proxylight", + abstract: "Local HTTP/HTTPS mapping proxy — rewrites mapped URL patterns to remote origins.", + subcommands: [Start.self, Mapping_.self, Import.self, Export.self, CAPath.self], + defaultSubcommand: Start.self + ) +} + +func configDirectory() -> URL { + ProxyOrchestrator.defaultDirectory +} + +struct Start: ParsableCommand { + static let configuration = CommandConfiguration(abstract: "Start the proxy and block until interrupted (Ctrl-C).") + + @Option(name: .long, help: "Override the configured listen port.") + var port: Int? + + func run() throws { + let orchestrator = ProxyOrchestrator(directory: configDirectory()) + if let port { orchestrator.config.listenPort = port } + + let boundPort = try orchestrator.start() + print("Listening on 127.0.0.1:\(boundPort)") + print("PAC URL: \(orchestrator.pacURL() ?? "unavailable")") + print("Point your browser's automatic proxy configuration at the PAC URL above.") + if let certURL = orchestrator.rootCertificateURL { + print("CA certificate: \(certURL.path) — import this into your browser's trust store to intercept HTTPS.") + } else { + print("Warning: certificate authority unavailable — HTTPS mappings will pass through untouched.") + } + + // The main thread blocks on the semaphore below rather than running a + // run loop, so these must fire on a queue GCD services independently — + // `.main` would never drain and Ctrl-C/kill would hang forever. + let signalQueue = DispatchQueue(label: "proxylight.signals") + let semaphore = DispatchSemaphore(value: 0) + var sources: [DispatchSourceSignal] = [] + for sig in [SIGINT, SIGTERM] { + signal(sig, SIG_IGN) + let source = DispatchSource.makeSignalSource(signal: sig, queue: signalQueue) + source.setEventHandler { semaphore.signal() } + source.resume() + sources.append(source) + } + semaphore.wait() + try? orchestrator.stop() + print("Stopped.") + } +} + +struct Mapping_: ParsableCommand { + static let configuration = CommandConfiguration( + commandName: "mapping", + abstract: "Manage URL mappings.", + subcommands: [Add.self, List.self, Remove.self] + ) + + struct Add: ParsableCommand { + static let configuration = CommandConfiguration(abstract: "Add a mapping.") + + @Argument(help: "Pattern to match, e.g. https://local.dev/path/*") + var from: String + + @Argument(help: "Remote target, e.g. https://remote.example/path/*") + var to: String + + @Flag(name: .long, help: "Serve local first, fall back to remote on 404.") + var fallbackOnNotFound = false + + func run() throws { + if let error = validateMapping(from: from, to: to) { + throw ValidationError(error.message) + } + let orchestrator = ProxyOrchestrator(directory: configDirectory()) + orchestrator.addMapping(from: from, to: to, mode: fallbackOnNotFound ? .fallbackOnNotFound : .rewrite) + print("Added: \(from) -> \(to)") + } + } + + struct List: ParsableCommand { + static let configuration = CommandConfiguration(abstract: "List mappings.") + + func run() throws { + let orchestrator = ProxyOrchestrator(directory: configDirectory()) + if orchestrator.config.mappings.isEmpty { + print("No mappings.") + return + } + for mapping in orchestrator.config.mappings { + let flag = mapping.enabled ? "" : " (disabled)" + print("\(mapping.id): \(mapping.from) -> \(mapping.to) [\(mapping.mode)]\(flag)") + } + } + } + + struct Remove: ParsableCommand { + static let configuration = CommandConfiguration(abstract: "Remove a mapping by id.") + + @Argument(help: "Mapping id, as printed by `mapping list`.") + var id: String + + func run() throws { + guard let uuid = UUID(uuidString: id) else { + throw ValidationError("'\(id)' isn't a valid mapping id.") + } + let orchestrator = ProxyOrchestrator(directory: configDirectory()) + orchestrator.deleteMapping(uuid) + print("Removed \(id) (if it existed).") + } + } +} + +struct Import: ParsableCommand { + static let configuration = CommandConfiguration(abstract: "Import mappings from a JSON file, overwriting conflicts.") + + @Argument(help: "Path to a mappings JSON file.") + var file: String + + func run() throws { + let orchestrator = ProxyOrchestrator(directory: configDirectory()) + let data = try Data(contentsOf: URL(fileURLWithPath: file)) + let imported = try orchestrator.decodeImportFile(data) + let result = orchestrator.completeImport(accepted: imported) + print("Imported: \(result.added) added, \(result.replaced) overwritten, \(result.unchanged) already present.") + } +} + +struct Export: ParsableCommand { + static let configuration = CommandConfiguration(abstract: "Export all mappings to a JSON file.") + + @Argument(help: "Path to write the mappings JSON file.") + var file: String + + func run() throws { + let orchestrator = ProxyOrchestrator(directory: configDirectory()) + let data = try orchestrator.exportData(orchestrator.config.mappings) + try data.write(to: URL(fileURLWithPath: file), options: .atomic) + print("Exported \(orchestrator.config.mappings.count) mapping(s) to \(file).") + } +} + +struct CAPath: ParsableCommand { + static let configuration = CommandConfiguration( + commandName: "ca-path", + abstract: "Print the path to the generated CA certificate, for manual import into a browser's trust store." + ) + + func run() throws { + let orchestrator = ProxyOrchestrator(directory: configDirectory()) + guard let certURL = orchestrator.rootCertificateURL else { + throw ValidationError("Certificate authority unavailable.") + } + print(certURL.path) + } +} diff --git a/Tests/ProxyLightTests/CertificateAuthorityTests.swift b/Tests/ProxyLightCoreTests/CertificateAuthorityTests.swift similarity index 57% rename from Tests/ProxyLightTests/CertificateAuthorityTests.swift rename to Tests/ProxyLightCoreTests/CertificateAuthorityTests.swift index ef60726..3a62968 100644 --- a/Tests/ProxyLightTests/CertificateAuthorityTests.swift +++ b/Tests/ProxyLightCoreTests/CertificateAuthorityTests.swift @@ -1,8 +1,6 @@ import Testing import Foundation -import Security -import X509 -@testable import ProxyLight +@testable import ProxyLightCore @Test func generatesAndPersistsRoot() throws { let dir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) @@ -24,27 +22,6 @@ import X509 #expect((attrs[.posixPermissions] as? NSNumber)?.intValue == 0o600) } -@Test func rootPEMDecodesToDERThatSecurityAccepts() throws { - let dir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) - defer { try? FileManager.default.removeItem(at: dir) } - let ca = try CertificateAuthority(directory: dir) - let der = try #require(CATrustManager.derBytes(fromPEM: ca.rootCertificatePEM)) - #expect(SecCertificateCreateWithData(nil, der as CFData) != nil) -} - -@Test func derBytesRejectsNonPEMInput() { - #expect(CATrustManager.derBytes(fromPEM: "not a pem") == nil) -} - -@Test func freshlyGeneratedRootIsNotTrusted() throws { - // A brand-new CA can't have trust settings in the user's keychain yet, so - // the read-only trust query must report false (and must not throw or hang). - let dir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) - defer { try? FileManager.default.removeItem(at: dir) } - let ca = try CertificateAuthority(directory: dir) - #expect(CATrustManager().isTrusted(certificateURL: ca.rootCertificateURL) == false) -} - @Test func mintsLeafServerContextForHost() throws { let dir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) defer { try? FileManager.default.removeItem(at: dir) } diff --git a/Tests/ProxyLightTests/MappingEngineTests.swift b/Tests/ProxyLightCoreTests/MappingEngineTests.swift similarity index 99% rename from Tests/ProxyLightTests/MappingEngineTests.swift rename to Tests/ProxyLightCoreTests/MappingEngineTests.swift index c240204..a0b8807 100644 --- a/Tests/ProxyLightTests/MappingEngineTests.swift +++ b/Tests/ProxyLightCoreTests/MappingEngineTests.swift @@ -1,5 +1,5 @@ import Testing -@testable import ProxyLight +@testable import ProxyLightCore private func engine(_ ms: [(String, String, Bool)]) -> MappingEngine { MappingEngine(mappings: ms.map { Mapping(from: $0.0, to: $0.1, enabled: $0.2) }) diff --git a/Tests/ProxyLightCoreTests/MappingStoreLinuxDirectoryTests.swift b/Tests/ProxyLightCoreTests/MappingStoreLinuxDirectoryTests.swift new file mode 100644 index 0000000..eaff523 --- /dev/null +++ b/Tests/ProxyLightCoreTests/MappingStoreLinuxDirectoryTests.swift @@ -0,0 +1,15 @@ +import Testing +import Foundation +@testable import ProxyLightCore + +#if !os(macOS) +@Test func defaultDirectoryUsesXDGConfigHomeWhenSet() { + let dir = MappingStore.defaultDirectory(environment: ["XDG_CONFIG_HOME": "/tmp/xdg-test"]) + #expect(dir.path == "/tmp/xdg-test/proxylight") +} + +@Test func defaultDirectoryFallsBackToDotConfigWhenXDGUnset() { + let dir = MappingStore.defaultDirectory(environment: ["HOME": "/home/tester"]) + #expect(dir.path == "/home/tester/.config/proxylight") +} +#endif diff --git a/Tests/ProxyLightCoreTests/MappingStoreTests.swift b/Tests/ProxyLightCoreTests/MappingStoreTests.swift new file mode 100644 index 0000000..da46d54 --- /dev/null +++ b/Tests/ProxyLightCoreTests/MappingStoreTests.swift @@ -0,0 +1,157 @@ +import Testing +import Foundation +@testable import ProxyLightCore + +@Test func appConfigRoundTrips() throws { + let config = AppConfig(listenPort: 9876, mappings: [ + Mapping(id: UUID(), from: "https://a.dev/x/*", to: "https://a.org/x/*", enabled: true), + ]) + let data = try JSONEncoder().encode(config) + let decoded = try JSONDecoder().decode(AppConfig.self, from: data) + #expect(decoded == config) +} + +@Test func defaultConfigListensOn9876() { + #expect(AppConfig.defaultConfig.listenPort == 9876) + #expect(AppConfig.defaultConfig.mappings.isEmpty) +} + +@Test func storeSavesAndLoads() throws { + let dir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: dir) } + + let store = MappingStore(directory: dir) + let config = AppConfig(listenPort: 1234, mappings: [ + Mapping(from: "https://a.dev/*", to: "https://a.org/*", enabled: true), + ]) + try store.save(config) + #expect(store.load() == config) +} + +@Test func loadReturnsDefaultWhenMissing() { + let dir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + #expect(MappingStore(directory: dir).load() == AppConfig.defaultConfig) +} + +@Test func loadToleratesMalformedFile() throws { + let dir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: dir) } + try Data("{ not json".utf8).write(to: dir.appendingPathComponent("config.json")) + #expect(MappingStore(directory: dir).load() == AppConfig.defaultConfig) +} + +@Test func mappingIORoundTrips() throws { + let ms = [ + Mapping(from: "https://a.dev/x/*", to: "https://a.org/x/*", enabled: true, mode: .fallbackOnNotFound), + Mapping(from: "https://b.dev/y", to: "https://b.org/y", enabled: false, mode: .rewrite), + ] + let decoded = try MappingIO.decode(MappingIO.encode(ms)) + #expect(decoded.count == 2) + #expect(decoded[0].from == "https://a.dev/x/*") + #expect(decoded[0].mode == .fallbackOnNotFound) + #expect(decoded[1].enabled == false) +} + +@Test func mappingIODecodesBareArrayWithoutMode() throws { + let json = #"[{"id":"637E088C-0DA9-408D-844E-55E94C136211","from":"https://a.dev/x/*","to":"https://a.org/x/*","enabled":true}]"# + let decoded = try MappingIO.decode(Data(json.utf8)) + #expect(decoded.count == 1) + #expect(decoded[0].mode == .rewrite) +} + +// MARK: Import classification & selective apply + +private func m(_ from: String, _ to: String, mode: MappingMode = .rewrite, enabled: Bool = true) -> Mapping { + Mapping(from: from, to: to, enabled: enabled, mode: mode) +} + +@Test func classifyMarksExactContentMatchAsDuplicate() { + let existing = [m("https://a.dev/x/*", "https://a.org/x/*")] + #expect(MappingIO.classify(m("https://a.dev/x/*", "https://a.org/x/*"), against: existing) == .duplicate) +} + +@Test func classifyIgnoresEnabledFlagForDuplicates() { + let existing = [m("https://a.dev/x/*", "https://a.org/x/*", enabled: false)] + #expect(MappingIO.classify(m("https://a.dev/x/*", "https://a.org/x/*"), against: existing) == .duplicate) +} + +@Test func classifyMarksSharedFromAsConflict() { + let existing = [m("https://a.dev/x/*", "https://a.org/x/*")] + let disposition = MappingIO.classify(m("https://a.dev/x/*", "https://other.org/x/*"), against: existing) + #expect(disposition == .conflict(existing)) +} + +@Test func classifyMarksSharedToAsConflict() { + let existing = [m("https://a.dev/x/*", "https://a.org/x/*")] + let disposition = MappingIO.classify(m("https://other.dev/x/*", "https://a.org/x/*"), against: existing) + #expect(disposition == .conflict(existing)) +} + +@Test func classifyMarksModeChangeAsConflict() { + // Same from/to but a different mode isn't an exact duplicate — the user + // must decide whether the imported mode wins. + let existing = [m("https://a.dev/x/*", "https://a.org/x/*", mode: .rewrite)] + let disposition = MappingIO.classify(m("https://a.dev/x/*", "https://a.org/x/*", mode: .fallbackOnNotFound), against: existing) + #expect(disposition == .conflict(existing)) +} + +@Test func classifyMarksUnrelatedAsNew() { + let existing = [m("https://a.dev/x/*", "https://a.org/x/*")] + #expect(MappingIO.classify(m("https://b.dev/*", "https://b.org/*"), against: existing) == .new) +} + +@Test func applyAddsNewSkipsDuplicatesOverwritesConflicts() throws { + let a = m("https://a.dev/x/*", "https://a.org/x/*") + let b = m("https://b.dev/y/*", "https://b.org/y/*") + let accepted = [ + m("https://a.dev/x/*", "https://a.org/x/*"), // duplicate of a + m("https://b.dev/y/*", "https://new.org/y/*", mode: .fallbackOnNotFound), // same from as b + m("https://c.dev/z/*", "https://c.org/z/*"), // new + ] + let result = MappingIO.apply(existing: [a, b], accepted: accepted) + #expect(result.added == 1) + #expect(result.replaced == 1) + #expect(result.unchanged == 1) + try #require(result.mappings.count == 3) + #expect(result.mappings[0] == a) // untouched + #expect(result.mappings[1].id == b.id) // overwritten in place, id stable + #expect(result.mappings[1].to == "https://new.org/y/*") + #expect(result.mappings[1].mode == .fallbackOnNotFound) + let added = result.mappings[2] + #expect(added.from == "https://c.dev/z/*") + #expect(added.id != accepted[2].id) // fresh id, never the imported file's id +} + +@Test func applyOverwriteConsumesEveryConflictingExisting() throws { + // The imported mapping's from matches one existing and its to matches + // another: overwrite replaces both with the single imported mapping. + let a = m("https://a.dev/x/*", "https://a.org/x/*") + let b = m("https://b.dev/y/*", "https://b.org/y/*") + let result = MappingIO.apply(existing: [a, b], accepted: [m("https://a.dev/x/*", "https://b.org/y/*")]) + try #require(result.mappings.count == 1) + #expect(result.mappings[0].id == a.id) + #expect(result.mappings[0].from == "https://a.dev/x/*") + #expect(result.mappings[0].to == "https://b.org/y/*") + #expect(result.replaced == 1) +} + +@Test func applyLeavesExistingUntouchedWhenNothingAccepted() { + let a = m("https://a.dev/x/*", "https://a.org/x/*") + let result = MappingIO.apply(existing: [a], accepted: []) + #expect(result.mappings == [a]) + #expect(result.added == 0 && result.replaced == 0 && result.unchanged == 0) +} + +@Test func mappingWithoutModeFieldDecodesAsRewrite() throws { + // Configs written before `mode` existed must still load (missing key → + // .rewrite), or a user's saved mappings would be wiped on upgrade. + let legacy = """ + {"id":"637E088C-0DA9-408D-844E-55E94C136211","from":"https://a.dev/x/*","to":"https://a.org/x/*","enabled":true} + """ + let mapping = try JSONDecoder().decode(Mapping.self, from: Data(legacy.utf8)) + #expect(mapping.mode == .rewrite) + #expect(mapping.from == "https://a.dev/x/*") + #expect(mapping.enabled) +} diff --git a/Tests/ProxyLightTests/PACGeneratorTests.swift b/Tests/ProxyLightCoreTests/PACGeneratorTests.swift similarity index 97% rename from Tests/ProxyLightTests/PACGeneratorTests.swift rename to Tests/ProxyLightCoreTests/PACGeneratorTests.swift index dd9ab92..9c2c2ec 100644 --- a/Tests/ProxyLightTests/PACGeneratorTests.swift +++ b/Tests/ProxyLightCoreTests/PACGeneratorTests.swift @@ -1,5 +1,5 @@ import Testing -@testable import ProxyLight +@testable import ProxyLightCore @Test func pacRoutesMappedHostsThroughProxy() { let pac = PACGenerator.generate(hostnames: ["a.example.com", "b.example.com"], proxyPort: 9876) diff --git a/Tests/ProxyLightTests/PACResponderTests.swift b/Tests/ProxyLightCoreTests/PACResponderTests.swift similarity index 99% rename from Tests/ProxyLightTests/PACResponderTests.swift rename to Tests/ProxyLightCoreTests/PACResponderTests.swift index 2447f9a..b5cc2a6 100644 --- a/Tests/ProxyLightTests/PACResponderTests.swift +++ b/Tests/ProxyLightCoreTests/PACResponderTests.swift @@ -3,7 +3,7 @@ import Foundation import NIOCore import NIOPosix import NIOHTTP1 -@testable import ProxyLight +@testable import ProxyLightCore // Pure dispatch-rule tests (fast; not in the serialized suite). @Test func originFormURIIsSelfAddressed() { diff --git a/Tests/ProxyLightCoreTests/ProxyOrchestratorTests.swift b/Tests/ProxyLightCoreTests/ProxyOrchestratorTests.swift new file mode 100644 index 0000000..96a9143 --- /dev/null +++ b/Tests/ProxyLightCoreTests/ProxyOrchestratorTests.swift @@ -0,0 +1,114 @@ +import Testing +import Foundation +@testable import ProxyLightCore + +private func makeTempDir() throws -> URL { + let url = FileManager.default.temporaryDirectory.appendingPathComponent("ProxyOrchestratorTests-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) + return url +} + +@Test func loadsDefaultConfigInFreshDirectory() throws { + let dir = try makeTempDir() + defer { try? FileManager.default.removeItem(at: dir) } + let orchestrator = ProxyOrchestrator(directory: dir) + #expect(orchestrator.config == .defaultConfig) +} + +@Test func addMappingPersistsAcrossInstances() throws { + let dir = try makeTempDir() + defer { try? FileManager.default.removeItem(at: dir) } + + let first = ProxyOrchestrator(directory: dir) + first.addMapping(from: "https://a.dev/*", to: "https://a.org/*", mode: .rewrite) + #expect(first.config.mappings.count == 1) + + let second = ProxyOrchestrator(directory: dir) + #expect(second.config.mappings.count == 1) + #expect(second.config.mappings[0].from == "https://a.dev/*") +} + +@Test func updateMappingChangesExistingEntry() throws { + let dir = try makeTempDir() + defer { try? FileManager.default.removeItem(at: dir) } + let orchestrator = ProxyOrchestrator(directory: dir) + orchestrator.addMapping(from: "https://a.dev/*", to: "https://a.org/*", mode: .rewrite) + let id = orchestrator.config.mappings[0].id + + orchestrator.updateMapping(id: id, from: "https://b.dev/*", to: "https://b.org/*", mode: .fallbackOnNotFound) + + #expect(orchestrator.config.mappings[0].from == "https://b.dev/*") + #expect(orchestrator.config.mappings[0].mode == .fallbackOnNotFound) +} + +@Test func deleteMappingRemovesEntry() throws { + let dir = try makeTempDir() + defer { try? FileManager.default.removeItem(at: dir) } + let orchestrator = ProxyOrchestrator(directory: dir) + orchestrator.addMapping(from: "https://a.dev/*", to: "https://a.org/*", mode: .rewrite) + let id = orchestrator.config.mappings[0].id + + orchestrator.deleteMapping(id) + + #expect(orchestrator.config.mappings.isEmpty) +} + +@Test func exportAndDecodeRoundTrip() throws { + let dir = try makeTempDir() + defer { try? FileManager.default.removeItem(at: dir) } + let orchestrator = ProxyOrchestrator(directory: dir) + orchestrator.addMapping(from: "https://a.dev/*", to: "https://a.org/*", mode: .rewrite) + + let data = try orchestrator.exportData(orchestrator.config.mappings) + let decoded = try orchestrator.decodeImportFile(data) + + #expect(decoded.count == 1) + #expect(decoded[0].from == "https://a.dev/*") +} + +@Test func completeImportMergesIntoConfig() throws { + let dir = try makeTempDir() + defer { try? FileManager.default.removeItem(at: dir) } + let orchestrator = ProxyOrchestrator(directory: dir) + let imported = [Mapping(from: "https://c.dev/*", to: "https://c.org/*", mode: .rewrite)] + + let result = orchestrator.completeImport(accepted: imported) + + #expect(result.added == 1) + #expect(orchestrator.config.mappings.count == 1) +} + +@Test func startBindsAPortAndStopReleasesIt() throws { + let dir = try makeTempDir() + defer { try? FileManager.default.removeItem(at: dir) } + let orchestrator = ProxyOrchestrator(directory: dir) + orchestrator.config.listenPort = 0 // let the OS choose a free port + + let port = try orchestrator.start() + #expect(port > 0) + #expect(orchestrator.isRunning) + #expect(orchestrator.runningPort == port) + + try orchestrator.stop() + #expect(!orchestrator.isRunning) + #expect(orchestrator.runningPort == nil) +} + +@Test func pacURLIsNilUntilRunning() throws { + let dir = try makeTempDir() + defer { try? FileManager.default.removeItem(at: dir) } + let orchestrator = ProxyOrchestrator(directory: dir) + #expect(orchestrator.pacURL() == nil) + + orchestrator.config.listenPort = 0 + let port = try orchestrator.start() + defer { try? orchestrator.stop() } + #expect(orchestrator.pacURL() == "http://127.0.0.1:\(port)/proxy.pac?v=1") +} + +@Test func rootCertificatePEMIsAvailable() throws { + let dir = try makeTempDir() + defer { try? FileManager.default.removeItem(at: dir) } + let orchestrator = ProxyOrchestrator(directory: dir) + #expect(orchestrator.rootCertificatePEM.contains("BEGIN CERTIFICATE")) +} diff --git a/Tests/ProxyLightTests/ProxyServerHTTPTests.swift b/Tests/ProxyLightCoreTests/ProxyServerHTTPTests.swift similarity index 99% rename from Tests/ProxyLightTests/ProxyServerHTTPTests.swift rename to Tests/ProxyLightCoreTests/ProxyServerHTTPTests.swift index cc18b7d..746d614 100644 --- a/Tests/ProxyLightTests/ProxyServerHTTPTests.swift +++ b/Tests/ProxyLightCoreTests/ProxyServerHTTPTests.swift @@ -3,7 +3,7 @@ import Foundation import NIOCore import NIOPosix import NIOHTTP1 -@testable import ProxyLight +@testable import ProxyLightCore // Minimal origin server that echoes the request line + Host header it received. private func startEchoOrigin(group: EventLoopGroup) throws -> (channel: Channel, port: Int) { diff --git a/Tests/ProxyLightTests/ProxyServerTLSTests.swift b/Tests/ProxyLightCoreTests/ProxyServerTLSTests.swift similarity index 99% rename from Tests/ProxyLightTests/ProxyServerTLSTests.swift rename to Tests/ProxyLightCoreTests/ProxyServerTLSTests.swift index 2084d64..e93337d 100644 --- a/Tests/ProxyLightTests/ProxyServerTLSTests.swift +++ b/Tests/ProxyLightCoreTests/ProxyServerTLSTests.swift @@ -4,7 +4,7 @@ import NIOCore import NIOPosix import NIOHTTP1 import NIOSSL -@testable import ProxyLight +@testable import ProxyLightCore @Test func connectToUnmappedHostBlindTunnels() throws { let group = MultiThreadedEventLoopGroup(numberOfThreads: 1) diff --git a/Tests/ProxyLightTests/RequestHeadSizeGuardTests.swift b/Tests/ProxyLightCoreTests/RequestHeadSizeGuardTests.swift similarity index 99% rename from Tests/ProxyLightTests/RequestHeadSizeGuardTests.swift rename to Tests/ProxyLightCoreTests/RequestHeadSizeGuardTests.swift index 4dad476..5d83ce4 100644 --- a/Tests/ProxyLightTests/RequestHeadSizeGuardTests.swift +++ b/Tests/ProxyLightCoreTests/RequestHeadSizeGuardTests.swift @@ -1,7 +1,7 @@ import Testing import NIOCore import NIOEmbedded -@testable import ProxyLight +@testable import ProxyLightCore // Exercises `RequestHeadSizeGuard`'s cross-`channelRead` state directly via // `EmbeddedChannel`, which feeds one `ByteBuffer` per `writeInbound` call diff --git a/Tests/ProxyLightTests/UpdateCheckTests.swift b/Tests/ProxyLightCoreTests/UpdateCheckTests.swift similarity index 98% rename from Tests/ProxyLightTests/UpdateCheckTests.swift rename to Tests/ProxyLightCoreTests/UpdateCheckTests.swift index 8f8baa2..d870e9f 100644 --- a/Tests/ProxyLightTests/UpdateCheckTests.swift +++ b/Tests/ProxyLightCoreTests/UpdateCheckTests.swift @@ -1,6 +1,6 @@ import Foundation import Testing -@testable import ProxyLight +@testable import ProxyLightCore private let zipURLString = "https://github.com/stuartshields/ProxyLight/releases/download/v0.1.3/ProxyLight.zip" diff --git a/Tests/ProxyLightTests/CATrustManagerTests.swift b/Tests/ProxyLightTests/CATrustManagerTests.swift new file mode 100644 index 0000000..8d74242 --- /dev/null +++ b/Tests/ProxyLightTests/CATrustManagerTests.swift @@ -0,0 +1,27 @@ +import Testing +import Foundation +import Security +import X509 +@testable import ProxyLightCore +@testable import ProxyLight + +@Test func rootPEMDecodesToDERThatSecurityAccepts() throws { + let dir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: dir) } + let ca = try CertificateAuthority(directory: dir) + let der = try #require(CATrustManager.derBytes(fromPEM: ca.rootCertificatePEM)) + #expect(SecCertificateCreateWithData(nil, der as CFData) != nil) +} + +@Test func derBytesRejectsNonPEMInput() { + #expect(CATrustManager.derBytes(fromPEM: "not a pem") == nil) +} + +@Test func freshlyGeneratedRootIsNotTrusted() throws { + // A brand-new CA can't have trust settings in the user's keychain yet, so + // the read-only trust query must report false (and must not throw or hang). + let dir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: dir) } + let ca = try CertificateAuthority(directory: dir) + #expect(CATrustManager().isTrusted(certificateURL: ca.rootCertificateURL) == false) +} diff --git a/Tests/ProxyLightTests/MappingStoreTests.swift b/Tests/ProxyLightTests/SystemProxyManagerTests.swift similarity index 62% rename from Tests/ProxyLightTests/MappingStoreTests.swift rename to Tests/ProxyLightTests/SystemProxyManagerTests.swift index 3b274c3..bfffc91 100644 --- a/Tests/ProxyLightTests/MappingStoreTests.swift +++ b/Tests/ProxyLightTests/SystemProxyManagerTests.swift @@ -2,46 +2,6 @@ import Testing import Foundation @testable import ProxyLight -@Test func appConfigRoundTrips() throws { - let config = AppConfig(listenPort: 9876, mappings: [ - Mapping(id: UUID(), from: "https://a.dev/x/*", to: "https://a.org/x/*", enabled: true), - ]) - let data = try JSONEncoder().encode(config) - let decoded = try JSONDecoder().decode(AppConfig.self, from: data) - #expect(decoded == config) -} - -@Test func defaultConfigListensOn9876() { - #expect(AppConfig.defaultConfig.listenPort == 9876) - #expect(AppConfig.defaultConfig.mappings.isEmpty) -} - -@Test func storeSavesAndLoads() throws { - let dir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) - try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) - defer { try? FileManager.default.removeItem(at: dir) } - - let store = MappingStore(directory: dir) - let config = AppConfig(listenPort: 1234, mappings: [ - Mapping(from: "https://a.dev/*", to: "https://a.org/*", enabled: true), - ]) - try store.save(config) - #expect(store.load() == config) -} - -@Test func loadReturnsDefaultWhenMissing() { - let dir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) - #expect(MappingStore(directory: dir).load() == AppConfig.defaultConfig) -} - -@Test func loadToleratesMalformedFile() throws { - let dir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) - try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) - defer { try? FileManager.default.removeItem(at: dir) } - try Data("{ not json".utf8).write(to: dir.appendingPathComponent("config.json")) - #expect(MappingStore(directory: dir).load() == AppConfig.defaultConfig) -} - @Test func processRunnerThrowsOnNonZeroExit() { let runner = ProcessRunner() #expect(throws: (any Error).self) { @@ -55,7 +15,7 @@ import Foundation #expect(out.contains("hello")) } -private final class FakeRunner: CommandRunner { +final class FakeRunner: CommandRunner { var responses: [String] var calls: [[String]] = [] var launchPaths: [String] = [] @@ -160,108 +120,6 @@ private final class FakeRunner: CommandRunner { #expect(!runner.calls.contains { $0.first == "-setautoproxyurl" }) } -@Test func mappingIORoundTrips() throws { - let ms = [ - Mapping(from: "https://a.dev/x/*", to: "https://a.org/x/*", enabled: true, mode: .fallbackOnNotFound), - Mapping(from: "https://b.dev/y", to: "https://b.org/y", enabled: false, mode: .rewrite), - ] - let decoded = try MappingIO.decode(MappingIO.encode(ms)) - #expect(decoded.count == 2) - #expect(decoded[0].from == "https://a.dev/x/*") - #expect(decoded[0].mode == .fallbackOnNotFound) - #expect(decoded[1].enabled == false) -} - -@Test func mappingIODecodesBareArrayWithoutMode() throws { - let json = #"[{"id":"637E088C-0DA9-408D-844E-55E94C136211","from":"https://a.dev/x/*","to":"https://a.org/x/*","enabled":true}]"# - let decoded = try MappingIO.decode(Data(json.utf8)) - #expect(decoded.count == 1) - #expect(decoded[0].mode == .rewrite) -} - -// MARK: Import classification & selective apply - -private func m(_ from: String, _ to: String, mode: MappingMode = .rewrite, enabled: Bool = true) -> Mapping { - Mapping(from: from, to: to, enabled: enabled, mode: mode) -} - -@Test func classifyMarksExactContentMatchAsDuplicate() { - let existing = [m("https://a.dev/x/*", "https://a.org/x/*")] - #expect(MappingIO.classify(m("https://a.dev/x/*", "https://a.org/x/*"), against: existing) == .duplicate) -} - -@Test func classifyIgnoresEnabledFlagForDuplicates() { - let existing = [m("https://a.dev/x/*", "https://a.org/x/*", enabled: false)] - #expect(MappingIO.classify(m("https://a.dev/x/*", "https://a.org/x/*"), against: existing) == .duplicate) -} - -@Test func classifyMarksSharedFromAsConflict() { - let existing = [m("https://a.dev/x/*", "https://a.org/x/*")] - let disposition = MappingIO.classify(m("https://a.dev/x/*", "https://other.org/x/*"), against: existing) - #expect(disposition == .conflict(existing)) -} - -@Test func classifyMarksSharedToAsConflict() { - let existing = [m("https://a.dev/x/*", "https://a.org/x/*")] - let disposition = MappingIO.classify(m("https://other.dev/x/*", "https://a.org/x/*"), against: existing) - #expect(disposition == .conflict(existing)) -} - -@Test func classifyMarksModeChangeAsConflict() { - // Same from/to but a different mode isn't an exact duplicate — the user - // must decide whether the imported mode wins. - let existing = [m("https://a.dev/x/*", "https://a.org/x/*", mode: .rewrite)] - let disposition = MappingIO.classify(m("https://a.dev/x/*", "https://a.org/x/*", mode: .fallbackOnNotFound), against: existing) - #expect(disposition == .conflict(existing)) -} - -@Test func classifyMarksUnrelatedAsNew() { - let existing = [m("https://a.dev/x/*", "https://a.org/x/*")] - #expect(MappingIO.classify(m("https://b.dev/*", "https://b.org/*"), against: existing) == .new) -} - -@Test func applyAddsNewSkipsDuplicatesOverwritesConflicts() throws { - let a = m("https://a.dev/x/*", "https://a.org/x/*") - let b = m("https://b.dev/y/*", "https://b.org/y/*") - let accepted = [ - m("https://a.dev/x/*", "https://a.org/x/*"), // duplicate of a - m("https://b.dev/y/*", "https://new.org/y/*", mode: .fallbackOnNotFound), // same from as b - m("https://c.dev/z/*", "https://c.org/z/*"), // new - ] - let result = MappingIO.apply(existing: [a, b], accepted: accepted) - #expect(result.added == 1) - #expect(result.replaced == 1) - #expect(result.unchanged == 1) - try #require(result.mappings.count == 3) - #expect(result.mappings[0] == a) // untouched - #expect(result.mappings[1].id == b.id) // overwritten in place, id stable - #expect(result.mappings[1].to == "https://new.org/y/*") - #expect(result.mappings[1].mode == .fallbackOnNotFound) - let added = result.mappings[2] - #expect(added.from == "https://c.dev/z/*") - #expect(added.id != accepted[2].id) // fresh id, never the imported file's id -} - -@Test func applyOverwriteConsumesEveryConflictingExisting() throws { - // The imported mapping's from matches one existing and its to matches - // another: overwrite replaces both with the single imported mapping. - let a = m("https://a.dev/x/*", "https://a.org/x/*") - let b = m("https://b.dev/y/*", "https://b.org/y/*") - let result = MappingIO.apply(existing: [a, b], accepted: [m("https://a.dev/x/*", "https://b.org/y/*")]) - try #require(result.mappings.count == 1) - #expect(result.mappings[0].id == a.id) - #expect(result.mappings[0].from == "https://a.dev/x/*") - #expect(result.mappings[0].to == "https://b.org/y/*") - #expect(result.replaced == 1) -} - -@Test func applyLeavesExistingUntouchedWhenNothingAccepted() { - let a = m("https://a.dev/x/*", "https://a.org/x/*") - let result = MappingIO.apply(existing: [a], accepted: []) - #expect(result.mappings == [a]) - #expect(result.added == 0 && result.replaced == 0 && result.unchanged == 0) -} - @Test func proxyRestoreStoreRoundTripsAndClears() throws { let dir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) @@ -280,18 +138,6 @@ private func m(_ from: String, _ to: String, mode: MappingMode = .rewrite, enabl #expect(store.load() == nil) } -@Test func mappingWithoutModeFieldDecodesAsRewrite() throws { - // Configs written before `mode` existed must still load (missing key → - // .rewrite), or a user's saved mappings would be wiped on upgrade. - let legacy = """ - {"id":"637E088C-0DA9-408D-844E-55E94C136211","from":"https://a.dev/x/*","to":"https://a.org/x/*","enabled":true} - """ - let mapping = try JSONDecoder().decode(Mapping.self, from: Data(legacy.utf8)) - #expect(mapping.mode == .rewrite) - #expect(mapping.from == "https://a.dev/x/*") - #expect(mapping.enabled) -} - @Test func discardsSelfReferencingProxyStateSoRestoreTurnsItOff() { // A stale ProxyLight setting left by a prior session: system proxy points at // our own loopback listener. Snapshotting this as the restore target would