Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions macos/Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,16 @@ let package = Package(
"ReverseAPIProxy",
.product(name: "GRDB", package: "GRDB.swift"),
.product(name: "MarkdownUI", package: "swift-markdown-ui"),
],
resources: [
// Bundle the brand fonts (Fraunces italic variable) so the
// `*` asterisk + "rae" wordmark render in serif italic
// instead of falling back to SF. SwiftPM compiles this into
// a `ReverseAPI_ReverseAPI.bundle` next to the executable;
// Bundle.module locates it at runtime. scripts/build-app.sh
// copies that .bundle into the .app's Contents/Resources/
// for distribution.
.copy("Resources"),
]
),
.testTarget(
Expand Down
49 changes: 49 additions & 0 deletions macos/Resources/Info.plist
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleDisplayName</key>
<string>rae</string>
<key>CFBundleExecutable</key>
<string>rae</string>
<key>CFBundleIconFile</key>
<string>AppIcon</string>
<key>CFBundleIdentifier</key>
<string>app.rae.reverseapi</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>rae</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>0.1.0</string>
<key>CFBundleVersion</key>
<string>1</string>
<key>LSApplicationCategoryType</key>
<string>public.app-category.developer-tools</string>
<key>LSMinimumSystemVersion</key>
<string>14.0</string>
<key>NSHighResolutionCapable</key>
<true/>
<key>NSSupportsAutomaticGraphicsSwitching</key>
<true/>
<key>NSPrincipalClass</key>
<string>NSApplication</string>
<!-- macOS must not auto-terminate this process when its window is
hidden — the proxy + flow capture happen in the background and
silently dropping them would lose data the user expects to be
recording. -->
<key>NSSupportsAutomaticTermination</key>
<false/>
<key>NSSupportsSuddenTermination</key>
<false/>
<!-- Required for the AppleScript "with administrator privileges"
flow we use to call networksetup when toggling the system
proxy on every active network service. -->
<key>NSAppleEventsUsageDescription</key>
<string>rae uses AppleScript to elevate networksetup when routing macOS traffic through the proxy.</string>
</dict>
</plist>
19 changes: 19 additions & 0 deletions macos/Resources/rae.entitlements
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<!-- The proxy needs full local access (Keychain for the CA private key,
networksetup via AppleScript, listening on a local port for the
WebSocket sidecar). The App Sandbox would break every one of those,
so we ship with sandbox disabled and rely on Hardened Runtime + the
minimal entitlement set below. -->
<key>com.apple.security.app-sandbox</key>
<false/>
<key>com.apple.security.network.client</key>
<true/>
<key>com.apple.security.network.server</key>
<true/>
<key>com.apple.security.automation.apple-events</key>
<true/>
</dict>
</plist>
77 changes: 74 additions & 3 deletions macos/Sources/ReverseAPI/App/AppState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ final class AppState {
let caPath: String

private var proxySnapshot: [ProxyServiceSnapshot]?
private let snapshotFileURL: URL

init(port: Int = 8888) throws {
let appSupport = try FileManager.default.url(
Expand All @@ -78,6 +79,14 @@ final class AppState {
let agentWorkdir = caStore.directory.appendingPathComponent("agent-sessions", isDirectory: true)
try FileManager.default.createDirectory(at: agentWorkdir, withIntermediateDirectories: true)

// Persist the pre-rae proxy snapshot to disk while we hold it in
// memory, so even an abrupt kill (force-quit, kernel panic, power
// loss) leaves enough state on disk for the next launch to
// restore the user's real settings instead of just disabling
// the proxy and losing any pre-existing corporate proxy.
self.snapshotFileURL = caStore.directory
.appendingPathComponent("proxy-snapshot.json")

self.store = store
self.engine = engine
self.installer = CertificateTrustInstaller()
Expand All @@ -90,9 +99,45 @@ final class AppState {
self.caTrustInstalled = installer.isInstalled(derBytes: self.caDER)
self.systemProxyEnabled = (try? systemProxy.isEnabled(host: "127.0.0.1", port: port)) ?? false

// Eagerly recover from a stale snapshot left over from a previous
// process that didn't shut down cleanly. We do this synchronously
// in init so by the time the UI appears the user's network is
// already in a sane state — Safari/Chrome are dead-in-the-water
// as long as the system proxy points at our port and we're not
// accepting connections.
//
// Only load the snapshot when the system proxy is currently
// pointing at our port. Otherwise the file is just leftover from
// a prior run that the user has since changed manually — loading
// it would let us "restore" stale settings on the next exit and
// overwrite the user's real, current proxy configuration.
if self.systemProxyEnabled,
let persisted = try? Self.readSnapshot(from: snapshotFileURL),
!persisted.isEmpty {
self.proxySnapshot = persisted
} else {
// File is either missing, malformed, or no longer relevant —
// drop it so we don't accidentally pick it up later.
Self.deleteSnapshot(at: snapshotFileURL)
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
}

store.subscribe(to: engine.bus)
}

nonisolated private static func readSnapshot(from url: URL) throws -> [ProxyServiceSnapshot] {
let data = try Data(contentsOf: url)
return try JSONDecoder().decode([ProxyServiceSnapshot].self, from: data)
}

nonisolated private static func writeSnapshot(_ snapshot: [ProxyServiceSnapshot], to url: URL) throws {
let data = try JSONEncoder().encode(snapshot)
try data.write(to: url, options: .atomic)
}

nonisolated private static func deleteSnapshot(at url: URL) {
try? FileManager.default.removeItem(at: url)
}

func toggleCapture() async {
if isCapturing {
await stopCapture()
Expand Down Expand Up @@ -254,13 +299,24 @@ final class AppState {
}

func recoverStaleSystemProxyOnLaunch() async {
guard systemProxyEnabled, !isCapturing, proxySnapshot == nil, !isWorking else { return }
guard systemProxyEnabled, !isCapturing, !isWorking else { return }
isWorking = true
defer { isWorking = false }

do {
try await disableCurrentRaeProxy()
lastError = "Recovered stale device proxy from a previous session."
if let snapshot = proxySnapshot, !snapshot.isEmpty {
// Best-case: previous run got far enough to persist the
// user's pre-rae proxy state. Restore it verbatim so any
// corporate proxy / VPN config the user had is preserved.
try await restoreSystemProxy()
lastError = "Restored previous proxy settings from a stale session."
} else {
// No snapshot on disk but the system proxy is still pointing
// at 127.0.0.1:<our port>. Best we can do is turn it off so
// browsers can reach the internet again.
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)"
}
Expand All @@ -275,6 +331,9 @@ final class AppState {
try? systemProxy.disable(host: "127.0.0.1", port: port)
systemProxyEnabled = false
}
// Either way the on-disk sentinel is no longer accurate — drop it
// so the next launch doesn't try to restore stale data.
Self.deleteSnapshot(at: snapshotFileURL)
}

func shutdownForWindowClose() async {
Expand Down Expand Up @@ -304,13 +363,21 @@ final class AppState {
private func applySystemProxy() async throws {
let systemProxy = self.systemProxy
let port = self.port
let snapshotURL = self.snapshotFileURL
let snapshot = try await Task.detached(priority: .userInitiated) {
let snapshot = try systemProxy.snapshot()
// Write the snapshot to disk BEFORE flipping the system proxy.
// If this fails (read-only volume, sandbox refusal, disk full)
// we must NOT flip the proxy — otherwise a subsequent crash
// would leave the user with no way to recover their original
// settings.
try Self.writeSnapshot(snapshot, to: snapshotURL)
do {
try systemProxy.enable(host: "127.0.0.1", port: port)
return snapshot
} catch {
try? systemProxy.restore(snapshot)
Self.deleteSnapshot(at: snapshotURL)
throw error
}
}.value
Expand All @@ -322,12 +389,14 @@ final class AppState {
let systemProxy = self.systemProxy
let port = self.port
let snapshot = proxySnapshot
let snapshotURL = self.snapshotFileURL
try await Task.detached(priority: .userInitiated) {
if let snapshot {
try systemProxy.restore(snapshot)
} else {
try systemProxy.disable(host: "127.0.0.1", port: port)
}
Self.deleteSnapshot(at: snapshotURL)
}.value
proxySnapshot = nil
systemProxyEnabled = false
Expand All @@ -336,8 +405,10 @@ final class AppState {
private func disableCurrentRaeProxy() async throws {
let systemProxy = self.systemProxy
let port = self.port
let snapshotURL = self.snapshotFileURL
try await Task.detached(priority: .userInitiated) {
try systemProxy.disable(host: "127.0.0.1", port: port)
Self.deleteSnapshot(at: snapshotURL)
}.value
proxySnapshot = nil
systemProxyEnabled = false
Expand Down
Loading