diff --git a/macos/Package.swift b/macos/Package.swift
index 0fb7384..afcd939 100644
--- a/macos/Package.swift
+++ b/macos/Package.swift
@@ -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(
diff --git a/macos/Resources/Info.plist b/macos/Resources/Info.plist
new file mode 100644
index 0000000..03e3136
--- /dev/null
+++ b/macos/Resources/Info.plist
@@ -0,0 +1,49 @@
+
+
+
+
+ CFBundleDevelopmentRegion
+ en
+ CFBundleDisplayName
+ rae
+ CFBundleExecutable
+ rae
+ CFBundleIconFile
+ AppIcon
+ CFBundleIdentifier
+ app.rae.reverseapi
+ CFBundleInfoDictionaryVersion
+ 6.0
+ CFBundleName
+ rae
+ CFBundlePackageType
+ APPL
+ CFBundleShortVersionString
+ 0.1.0
+ CFBundleVersion
+ 1
+ LSApplicationCategoryType
+ public.app-category.developer-tools
+ LSMinimumSystemVersion
+ 14.0
+ NSHighResolutionCapable
+
+ NSSupportsAutomaticGraphicsSwitching
+
+ NSPrincipalClass
+ NSApplication
+
+ NSSupportsAutomaticTermination
+
+ NSSupportsSuddenTermination
+
+
+ NSAppleEventsUsageDescription
+ rae uses AppleScript to elevate networksetup when routing macOS traffic through the proxy.
+
+
diff --git a/macos/Resources/rae.entitlements b/macos/Resources/rae.entitlements
new file mode 100644
index 0000000..c0e5296
--- /dev/null
+++ b/macos/Resources/rae.entitlements
@@ -0,0 +1,19 @@
+
+
+
+
+
+ com.apple.security.app-sandbox
+
+ com.apple.security.network.client
+
+ com.apple.security.network.server
+
+ com.apple.security.automation.apple-events
+
+
+
diff --git a/macos/Sources/ReverseAPI/App/AppState.swift b/macos/Sources/ReverseAPI/App/AppState.swift
index 526de81..7218c98 100644
--- a/macos/Sources/ReverseAPI/App/AppState.swift
+++ b/macos/Sources/ReverseAPI/App/AppState.swift
@@ -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(
@@ -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()
@@ -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)
+ }
+
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()
@@ -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:. 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)"
}
@@ -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 {
@@ -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
@@ -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
@@ -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
diff --git a/macos/Sources/ReverseAPI/App/ReverseAPIApp.swift b/macos/Sources/ReverseAPI/App/ReverseAPIApp.swift
index 3e742aa..db0478f 100644
--- a/macos/Sources/ReverseAPI/App/ReverseAPIApp.swift
+++ b/macos/Sources/ReverseAPI/App/ReverseAPIApp.swift
@@ -5,41 +5,37 @@ import SwiftUI
struct ReverseAPIApp: App {
@NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
@State private var session = AppSession.live()
+ /// Splash gate — visible for ~1.6s on launch so the wordmark gets a
+ /// brand moment instead of the window flashing through an empty
+ /// frame. Flips to false after the timer fires.
+ @State private var isShowingSplash = true
+
+ init() {
+ // Register the bundled Fraunces font *here*, not in
+ // applicationDidFinishLaunching — `App.init()` runs before any
+ // View body is computed, while the delegate's launch callback
+ // fires after the run loop starts. Doing it late means the
+ // SplashView's first paint resolves `Font.fraunces(...)` to a
+ // fallback (SF Italic) and only flips to Fraunces on a later
+ // layout pass, which reads as the wordmark "morphing" mid-anim.
+ BrandFont.bootstrap()
+ }
var body: some Scene {
Window("rae", id: "main") {
- switch session {
- case .ready(let state):
- ContentView()
- .environment(state)
- .onAppear {
- AppLifecycle.shared.state = state
- }
- .onDisappear {
- Task { await state.shutdownForWindowClose() }
- }
- .background(WindowAccessor { window in
- window.title = ""
- window.titleVisibility = .hidden
- window.titlebarAppearsTransparent = true
- window.isOpaque = true
- window.backgroundColor = NSColor(Theme.appBackground)
- })
- .frame(
- // Bumped so the traffic card can always fit
- // table+inspector side by side (its inner HSplitView
- // needs ~700pt) without compressing past its
- // rounded border into a glitchy state. Old 980pt
- // window minimum was below that threshold.
- minWidth: 1100,
- maxWidth: .infinity,
- minHeight: 640,
- maxHeight: .infinity,
- alignment: .topLeading
- )
- case .failed(let error):
- BootFailureView(error: error)
- .frame(minWidth: 500, minHeight: 300)
+ ZStack {
+ mainContent
+ .opacity(isShowingSplash ? 0 : 1)
+ if isShowingSplash {
+ SplashView()
+ .transition(.opacity)
+ }
+ }
+ .task {
+ try? await Task.sleep(for: .milliseconds(1600))
+ withAnimation(.easeOut(duration: 0.45)) {
+ isShowingSplash = false
+ }
}
}
.windowStyle(.titleBar)
@@ -48,6 +44,45 @@ struct ReverseAPIApp: App {
CommandGroup(replacing: .newItem) {}
}
}
+
+ @ViewBuilder
+ private var mainContent: some View {
+ switch session {
+ case .ready(let state):
+ ContentView()
+ .environment(state)
+ .onAppear {
+ AppLifecycle.shared.state = state
+ }
+ .onDisappear {
+ Task { await state.shutdownForWindowClose() }
+ }
+ .background(WindowAccessor { window in
+ window.title = ""
+ window.titleVisibility = .hidden
+ window.titlebarAppearsTransparent = true
+ window.isOpaque = true
+ // Dynamic NSColor under the hood — flips automatically
+ // when the system appearance changes.
+ window.backgroundColor = NSColor(Theme.appBackground)
+ })
+ .frame(
+ // Bumped so the traffic card can always fit
+ // table+inspector side by side (its inner HSplitView
+ // needs ~700pt) without compressing past its
+ // rounded border into a glitchy state. Old 980pt
+ // window minimum was below that threshold.
+ minWidth: 1100,
+ maxWidth: .infinity,
+ minHeight: 640,
+ maxHeight: .infinity,
+ alignment: .topLeading
+ )
+ case .failed(let error):
+ BootFailureView(error: error)
+ .frame(minWidth: 500, minHeight: 300)
+ }
+ }
}
@MainActor
@@ -64,8 +99,12 @@ final class AppLifecycle {
final class AppDelegate: NSObject, NSApplicationDelegate {
private var isTerminating = false
+ private var signalSources: [DispatchSourceSignal] = []
func applicationDidFinishLaunching(_ notification: Notification) {
+ // Font bootstrap happens in `ReverseAPIApp.init()` so it lands
+ // before any view body is computed — see the comment there.
+
// `swift run` launches a bare SwiftPM executable with no .app bundle
// and no Info.plist, so macOS doesn't treat it as a regular GUI app —
// the window never reliably becomes key and AppKit text fields can't
@@ -76,7 +115,50 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
// launched from a real .app bundle.
NSApp.setActivationPolicy(.regular)
NSApp.activate(ignoringOtherApps: true)
- NSApp.appearance = NSAppearance(named: .darkAqua)
+ // No longer force `.darkAqua` — the Theme tokens are dynamic NSColors
+ // and ContentView no longer pins `.preferredColorScheme(.dark)`,
+ // so light/dark now follows the system setting.
+ installSignalHandlers()
+ }
+
+ /// Restore the system proxy before exiting on SIGINT / SIGTERM / SIGHUP.
+ /// Activity Monitor's "Quit" / "Force Quit", `kill `, terminal Ctrl-C
+ /// from `swift run` and shutdown all hit this path. AppKit's normal
+ /// applicationShouldTerminate doesn't fire for these, so without this
+ /// the system proxy stays pointing at 127.0.0.1: with nothing
+ /// listening — exactly the "Safari can't connect" symptom users see.
+ private func installSignalHandlers() {
+ for sig in [SIGINT, SIGTERM, SIGHUP] {
+ // Ignore the default signal action FIRST so the dispatch source
+ // gets a chance to run before the process gets killed by the
+ // kernel's default handler.
+ signal(sig, SIG_IGN)
+ let source = DispatchSource.makeSignalSource(signal: sig, queue: .main)
+ source.setEventHandler { [weak self] in
+ guard let self else { exit(0) }
+ // Queue is .main — we're already on the main actor, just
+ // assert it so we can call @MainActor methods without
+ // hopping through an async Task.
+ MainActor.assumeIsolated { self.handleSignal(sig) }
+ }
+ source.resume()
+ signalSources.append(source)
+ }
+ }
+
+ @MainActor
+ private func handleSignal(_ sig: Int32) {
+ guard !isTerminating else { return }
+ isTerminating = true
+ // Synchronous restore — we have no async budget once the OS has
+ // decided to kill us. The proxy state is the priority; the engine
+ // + agent sidecar would normally clean up too but they'd race the
+ // process exit anyway.
+ AppLifecycle.shared.restoreProxyBeforeExit()
+ // Re-raise the signal with the default handler so the process
+ // actually exits with the right termination status.
+ signal(sig, SIG_DFL)
+ raise(sig)
}
func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {
diff --git a/macos/Sources/ReverseAPI/Resources/Fraunces-Italic-VariableFont.ttf b/macos/Sources/ReverseAPI/Resources/Fraunces-Italic-VariableFont.ttf
new file mode 100644
index 0000000..3ae1a9a
Binary files /dev/null and b/macos/Sources/ReverseAPI/Resources/Fraunces-Italic-VariableFont.ttf differ
diff --git a/macos/Sources/ReverseAPI/UI/AgentPanel.swift b/macos/Sources/ReverseAPI/UI/AgentPanel.swift
index c684391..b7f38d7 100644
--- a/macos/Sources/ReverseAPI/UI/AgentPanel.swift
+++ b/macos/Sources/ReverseAPI/UI/AgentPanel.swift
@@ -555,10 +555,16 @@ private struct AgentComposer: View {
Button(action: { if canSend { onSend() } }) {
Image(systemName: "arrow.up")
.font(.system(size: 12, weight: .bold))
- .foregroundStyle(canSend ? Theme.appBackground : Theme.textTertiary)
+ // Cream/dark icon on pink — high contrast, brand-led.
+ .foregroundStyle(canSend ? Color.white : Theme.textTertiary)
.frame(width: 28, height: 28)
.background(
- canSend ? Theme.textPrimary : Theme.elevated,
+ // Brand pink while the user has typed something to
+ // send — the primary action of the entire panel
+ // earns the brand color. Falls back to neutral
+ // `Theme.elevated` when disabled so we don't bait
+ // a click on an empty composer.
+ canSend ? Theme.brandPink : Theme.elevated,
in: Circle()
)
}
@@ -568,6 +574,13 @@ private struct AgentComposer: View {
}
.padding(10)
.background(Theme.input, in: RoundedRectangle(cornerRadius: 12))
+ .overlay(
+ // 1pt outline so the composer reads as a focused input even
+ // before the background lift kicks in — works in both
+ // appearances since `Theme.border` is dynamic.
+ RoundedRectangle(cornerRadius: 12)
+ .stroke(Theme.border, lineWidth: 1)
+ )
.padding(12)
.background(Theme.surface)
}
diff --git a/macos/Sources/ReverseAPI/UI/ContentView.swift b/macos/Sources/ReverseAPI/UI/ContentView.swift
index 6a14982..b4e7c55 100644
--- a/macos/Sources/ReverseAPI/UI/ContentView.swift
+++ b/macos/Sources/ReverseAPI/UI/ContentView.swift
@@ -8,6 +8,11 @@ struct ContentView: View {
@State private var isPaletteVisible: Bool = false
@State private var trafficWidth: CGFloat = 720
@State private var dragStartWidth: CGFloat?
+ // First-launch onboarding gate. The sheet is shown until the user
+ // explicitly acknowledges it via "Get started" / "Skip for now" —
+ // both buttons flip this flag, NOT the sheet dismiss path, so closing
+ // the window before acknowledging doesn't silently mark it complete.
+ @AppStorage("hasCompletedOnboarding") private var hasCompletedOnboarding = false
var body: some View {
ZStack {
@@ -102,9 +107,14 @@ struct ContentView: View {
set: { if !$0 { state.viewingFile = nil } }
))
}
+ .sheet(isPresented: Binding(
+ get: { !hasCompletedOnboarding },
+ set: { if !$0 { hasCompletedOnboarding = true } }
+ )) {
+ OnboardingView(hasCompletedOnboarding: $hasCompletedOnboarding)
+ }
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.background(Theme.appBackground)
- .preferredColorScheme(.dark)
.toolbar { toolbarContent }
.task {
await state.recoverStaleSystemProxyOnLaunch()
@@ -374,12 +384,18 @@ private struct ErrorBanner: View {
private struct CaptureStateChip: View {
@Environment(AppState.self) private var state
+ /// Drives the soft pulse animation on the recording dot — bound to a
+ /// boolean toggle that flips every 1.1s via `repeatForever`.
+ @State private var pulse = false
var body: some View {
HStack(spacing: 6) {
Circle()
.fill(dotColor)
.frame(width: 6, height: 6)
+ .scaleEffect(state.isCapturing && pulse ? 1.4 : 1.0)
+ .opacity(state.isCapturing && pulse ? 0.55 : 1.0)
+ .shadow(color: state.isCapturing ? Theme.brandPink.opacity(0.6) : .clear, radius: 4)
Text(label)
.font(.caption)
.foregroundStyle(Theme.textSecondary)
@@ -387,13 +403,21 @@ private struct CaptureStateChip: View {
}
.padding(.horizontal, 9)
.padding(.vertical, 4)
- .background(Theme.input, in: Capsule())
+ .background(Theme.elevated, in: Capsule())
.help("Capture state · 127.0.0.1:\(state.port)")
+ .onAppear {
+ withAnimation(.easeInOut(duration: 1.1).repeatForever(autoreverses: true)) {
+ pulse = true
+ }
+ }
}
+ /// Recording uses brand pink so the live state pulls the eye — this
+ /// is the most action-driven indicator in the toolbar. Idle stays
+ /// neutral so a quiet app doesn't shout.
private var dotColor: Color {
- if state.isWorking { return .yellow }
- if state.isCapturing { return Theme.success }
+ if state.isWorking { return Theme.warn }
+ if state.isCapturing { return Theme.brandPink }
return Theme.textTertiary
}
@@ -418,7 +442,7 @@ private struct CATrustChip: View {
}
.padding(.horizontal, 9)
.padding(.vertical, 4)
- .background(Theme.input, in: Capsule())
+ .background(Theme.elevated, in: Capsule())
.help(state.caTrustInstalled
? "Root CA installed — HTTPS can be inspected"
: "Root CA not trusted — HTTPS will fail")
@@ -435,10 +459,23 @@ private struct SearchButton: View {
Button(action: action) {
Image(systemName: "magnifyingglass")
.font(.system(size: 12, weight: .medium))
- .foregroundStyle(Theme.textSecondary)
+ // Brand pink on hover so the eye picks up the only
+ // outbound action in the bar; subtle when at rest.
+ .foregroundStyle(isHovering ? Theme.brandPink : Theme.textSecondary)
.padding(.horizontal, 10)
.padding(.vertical, 6)
- .background(isHovering ? PillStyle.activeBackground : PillStyle.hoverBackground, in: Capsule())
+ .background(
+ isHovering
+ ? Theme.brandPink.opacity(0.12)
+ : Theme.elevated,
+ in: Capsule()
+ )
+ .overlay(
+ Capsule().stroke(
+ isHovering ? Theme.brandPink.opacity(0.35) : .clear,
+ lineWidth: 1
+ )
+ )
}
.buttonStyle(.plain)
.onHover { isHovering = $0 }
diff --git a/macos/Sources/ReverseAPI/UI/Fonts.swift b/macos/Sources/ReverseAPI/UI/Fonts.swift
new file mode 100644
index 0000000..0bd445d
--- /dev/null
+++ b/macos/Sources/ReverseAPI/UI/Fonts.swift
@@ -0,0 +1,91 @@
+import SwiftUI
+import AppKit
+import CoreText
+import Foundation
+
+/// Bundled brand fonts — currently just Fraunces (italic variable) for the
+/// `*` brand asterisk + "rae" wordmark + section headlines.
+///
+/// Body text + monospaced labels stay on SF Pro / SF Mono — shipping
+/// Inter + JetBrains Mono for marginal visual gain would add ~2 MB to
+/// the .app for little payoff on macOS where SF reads native.
+enum BrandFont {
+ /// Register the bundled font files with Core Text so SwiftUI's
+ /// `.font(.custom("Fraunces", size: ...))` and the `NSFont`-based
+ /// helpers below can find them. Idempotent — Core Text coalesces
+ /// duplicate process-scope registrations.
+ ///
+ /// Call once at app launch (`AppDelegate.applicationDidFinishLaunching`)
+ /// before any window appears, so the wordmark renders correctly on
+ /// first paint.
+ static func bootstrap() {
+ let fontFiles = ["Fraunces-Italic-VariableFont"]
+ for name in fontFiles {
+ // SwiftPM's resource bundle uses `Resources/` (not the
+ // macOS-standard `Contents/Resources/`) when populated via
+ // `.copy("Resources")`. Try the explicit subdirectory first,
+ // fall back to a root lookup so a future Package.swift tweak
+ // that puts the file at the bundle root keeps working.
+ let url = Bundle.module.url(forResource: name, withExtension: "ttf", subdirectory: "Resources")
+ ?? Bundle.module.url(forResource: name, withExtension: "ttf")
+ guard let url else {
+ print("[BrandFont] \(name).ttf not found in bundle — falling back to system font")
+ continue
+ }
+ var error: Unmanaged?
+ if !CTFontManagerRegisterFontsForURL(url as CFURL, .process, &error) {
+ let message = (error?.takeRetainedValue()).map { String(describing: $0) } ?? "unknown"
+ print("[BrandFont] failed to register \(name): \(message)")
+ }
+ }
+ }
+}
+
+extension Font {
+ /// Fraunces Italic, our display serif. Use sparingly — wordmark,
+ /// brand asterisk, section headlines ("Traffic", "Sessions").
+ ///
+ /// `soft` and `wonk` control the variable-font axes that give the
+ /// brand asterisk its characteristic rounded-petal shape:
+ /// - `soft = 100` rounds the terminals
+ /// - `wonk = true` enables the alternate, more playful glyph forms
+ /// - `opsz = 144` is the website's chosen optical size
+ ///
+ /// Defaults match the website's marquee usage. Falls back to SF
+ /// Italic if the bundled font failed to register.
+ static func fraunces(
+ size: CGFloat,
+ weight: CGFloat = 600,
+ soft: CGFloat = 100,
+ wonk: Bool = true,
+ opsz: CGFloat = 144
+ ) -> Font {
+ let variations: [UInt32: Any] = [
+ fourCC("wght"): weight,
+ fourCC("SOFT"): soft,
+ fourCC("WONK"): wonk ? 1 : 0,
+ fourCC("opsz"): opsz,
+ ]
+ // Build a descriptor with the family + italic trait + variable
+ // axes, then materialise it as NSFont so SwiftUI can wrap it.
+ let descriptor = NSFontDescriptor(fontAttributes: [
+ .family: "Fraunces",
+ NSFontDescriptor.AttributeName(rawValue: kCTFontVariationAttribute as String): variations,
+ ]).withSymbolicTraits(.italic)
+ if let nsFont = NSFont(descriptor: descriptor, size: size) {
+ return Font(nsFont)
+ }
+ return .system(size: size, weight: .semibold).italic()
+ }
+}
+
+/// Four-character code → UInt32 in the byte order Core Text expects for
+/// `kCTFontVariationAxisIdentifierKey`. "wght" → 0x77676874, etc.
+@inlinable
+func fourCC(_ tag: String) -> UInt32 {
+ var result: UInt32 = 0
+ for scalar in tag.unicodeScalars {
+ result = (result << 8) | (scalar.value & 0xff)
+ }
+ return result
+}
diff --git a/macos/Sources/ReverseAPI/UI/OnboardingView.swift b/macos/Sources/ReverseAPI/UI/OnboardingView.swift
new file mode 100644
index 0000000..c685cab
--- /dev/null
+++ b/macos/Sources/ReverseAPI/UI/OnboardingView.swift
@@ -0,0 +1,197 @@
+import SwiftUI
+
+/// First-launch welcome sheet that walks the user through the three setup
+/// steps the app needs to actually capture traffic: trusting the local CA,
+/// routing the device through the proxy, and starting the capture loop.
+///
+/// Gated by `@AppStorage("hasCompletedOnboarding")` in ContentView. The
+/// flag is flipped from the explicit "Get started" / "Skip for now"
+/// buttons, NOT from `.onDismiss`, so closing the window before
+/// acknowledging doesn't silently mark onboarding done.
+struct OnboardingView: View {
+ @Environment(AppState.self) private var state
+ @Binding var hasCompletedOnboarding: Bool
+ @Environment(\.dismiss) private var dismiss
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 32) {
+ header
+ steps
+ Spacer(minLength: 0)
+ footer
+ }
+ .padding(.horizontal, 40)
+ .padding(.vertical, 36)
+ .frame(width: 520, height: 560)
+ .background(Theme.surface)
+ .preferredColorScheme(.dark)
+ }
+
+ // MARK: - Header
+
+ private var header: some View {
+ VStack(alignment: .leading, spacing: 12) {
+ HStack(alignment: .firstTextBaseline, spacing: 4) {
+ // Brand asterisk in pink — mirrors the `*` mark used in the
+ // website header / app icon / hero. Drawn as a glyph (not an
+ // SF Symbol) so the Fraunces shape carries through.
+ Text("*")
+ .font(.fraunces(size: 38, weight: 600))
+ .foregroundStyle(Theme.brandPink)
+ .baselineOffset(-4)
+ Text("rae")
+ .font(.fraunces(size: 30, weight: 600))
+ .foregroundStyle(Theme.textPrimary)
+ }
+ Text("Three quick steps to start intercepting and reverse-engineering API traffic on this Mac.")
+ .font(.callout)
+ .foregroundStyle(Theme.textSecondary)
+ .fixedSize(horizontal: false, vertical: true)
+ }
+ }
+
+ // MARK: - Steps
+
+ private var steps: some View {
+ VStack(alignment: .leading, spacing: 22) {
+ OnboardingStep(
+ number: 1,
+ title: "Trust the local root certificate",
+ description: "Lets rae decrypt HTTPS traffic from apps that trust the user keychain.",
+ isComplete: state.caTrustInstalled,
+ isWorking: state.isWorking && !state.caTrustInstalled,
+ completedLabel: "Trusted",
+ actionLabel: "Trust CA",
+ action: { Task { await state.installCATrust() } }
+ )
+ OnboardingStep(
+ number: 2,
+ title: "Route this Mac through the proxy",
+ description: "Toggles macOS HTTP and HTTPS proxies on every active network service.",
+ isComplete: state.systemProxyEnabled,
+ isWorking: state.isWorking && state.caTrustInstalled && !state.systemProxyEnabled,
+ completedLabel: "Routed",
+ actionLabel: "Enable proxy",
+ action: { Task { await state.enableSystemProxy() } }
+ )
+ OnboardingStep(
+ number: 3,
+ title: "Start capturing traffic",
+ description: "Open the apps you want to inspect — captured flows land in the table on the left.",
+ isComplete: state.isCapturing,
+ isWorking: state.isWorking && state.systemProxyEnabled && !state.isCapturing,
+ completedLabel: "Capturing",
+ actionLabel: "Start capture",
+ action: { Task { await state.toggleCapture() } }
+ )
+ }
+ }
+
+ // MARK: - Footer
+
+ private var footer: some View {
+ HStack {
+ Button {
+ hasCompletedOnboarding = true
+ dismiss()
+ } label: {
+ Text("Skip for now")
+ .font(.callout)
+ .foregroundStyle(Theme.textTertiary)
+ }
+ .buttonStyle(.plain)
+
+ Spacer()
+
+ Button {
+ hasCompletedOnboarding = true
+ dismiss()
+ } label: {
+ Text("Get started")
+ .font(.callout.weight(.semibold))
+ .foregroundStyle(Theme.appBackground)
+ .padding(.horizontal, 18)
+ .padding(.vertical, 9)
+ .background(Theme.textPrimary, in: Capsule())
+ }
+ .buttonStyle(.plain)
+ }
+ }
+}
+
+// MARK: - Step row
+
+private struct OnboardingStep: View {
+ let number: Int
+ let title: String
+ let description: String
+ let isComplete: Bool
+ let isWorking: Bool
+ let completedLabel: String
+ let actionLabel: String
+ let action: () -> Void
+
+ /// Completion color. Pulls from `Theme.mint` (the cream/ink palette's
+ /// dark mint variant) so the three "Trusted / Routed / Capturing"
+ /// pills read as soft status indicators rather than vivid success
+ /// stamps. Centralised on `Theme.mint` so any future palette tweak
+ /// lives in one place.
+ private static let completedGreen = Theme.mint
+
+ var body: some View {
+ HStack(alignment: .top, spacing: 14) {
+ indicator
+ .padding(.top, 2)
+ VStack(alignment: .leading, spacing: 4) {
+ Text(title)
+ .font(.callout.weight(.semibold))
+ .foregroundStyle(Theme.textPrimary)
+ Text(description)
+ .font(.caption)
+ .foregroundStyle(Theme.textSecondary)
+ .fixedSize(horizontal: false, vertical: true)
+ .lineSpacing(2)
+ }
+ Spacer(minLength: 8)
+ actionAffordance
+ }
+ }
+
+ @ViewBuilder
+ private var indicator: some View {
+ if isComplete {
+ Image(systemName: "checkmark")
+ .font(.system(size: 11, weight: .bold))
+ .foregroundStyle(Self.completedGreen)
+ .frame(width: 20, height: 20)
+ } else {
+ Text("\(number)")
+ .font(.system(size: 12, weight: .semibold).monospacedDigit())
+ .foregroundStyle(Theme.textTertiary)
+ .frame(width: 20, height: 20)
+ }
+ }
+
+ @ViewBuilder
+ private var actionAffordance: some View {
+ if isComplete {
+ Text(completedLabel)
+ .font(.caption.weight(.medium))
+ .foregroundStyle(Self.completedGreen)
+ } else if isWorking {
+ ProgressView()
+ .controlSize(.small)
+ .tint(Theme.textTertiary)
+ } else {
+ Button(action: action) {
+ Text(actionLabel)
+ .font(.caption.weight(.semibold))
+ .foregroundStyle(Theme.appBackground)
+ .padding(.horizontal, 12)
+ .padding(.vertical, 6)
+ .background(Theme.textPrimary, in: Capsule())
+ }
+ .buttonStyle(.plain)
+ }
+ }
+}
diff --git a/macos/Sources/ReverseAPI/UI/SessionsListView.swift b/macos/Sources/ReverseAPI/UI/SessionsListView.swift
index f2d16d1..447de07 100644
--- a/macos/Sources/ReverseAPI/UI/SessionsListView.swift
+++ b/macos/Sources/ReverseAPI/UI/SessionsListView.swift
@@ -20,7 +20,7 @@ struct SessionsListView: View {
private var header: some View {
HStack(spacing: 10) {
Text("Sessions")
- .font(.system(size: 13, weight: .semibold))
+ .font(.fraunces(size: 18, weight: 600))
.foregroundStyle(Theme.textPrimary)
Spacer()
Button {
diff --git a/macos/Sources/ReverseAPI/UI/SplashView.swift b/macos/Sources/ReverseAPI/UI/SplashView.swift
new file mode 100644
index 0000000..d7b8920
--- /dev/null
+++ b/macos/Sources/ReverseAPI/UI/SplashView.swift
@@ -0,0 +1,86 @@
+import SwiftUI
+
+/// First-frame launch surface — shows the brand wordmark with a small
+/// asterisk animation while the rest of the app finishes wiring up
+/// (CA load, DB open, ProxyEngine init, font registration).
+///
+/// Visible for ~1.6s, then fades into the main `ContentView`. Plays the
+/// same role as iOS's LaunchScreen.storyboard — a brand moment that
+/// hides the brief "blank window" frame.
+struct SplashView: View {
+ /// Scale-in pop of the asterisk on first appear.
+ @State private var asteriskScale: CGFloat = 0.55
+ /// Continuous gentle wobble — the visible "movement" the user asked for.
+ @State private var asteriskRotation: Double = -10
+ /// Wordmark fades in slightly after the asterisk lands.
+ @State private var wordmarkOpacity: Double = 0
+ /// Wordmark slides in from the asterisk's right.
+ @State private var wordmarkOffset: CGFloat = -18
+
+ init() {
+ // SwiftUI Previews bypass App.init() where BrandFont.bootstrap()
+ // normally runs, so the wordmark would fall back to SF Italic in
+ // Xcode's canvas. Register here too — registration is idempotent
+ // at the Core Text level and the runtime cost is one dictionary
+ // lookup on subsequent calls.
+ BrandFont.bootstrap()
+ }
+
+ var body: some View {
+ ZStack {
+ Theme.appBackground.ignoresSafeArea()
+
+ VStack(spacing: 14) {
+ Spacer()
+
+ HStack(alignment: .firstTextBaseline, spacing: 6) {
+ Text("*")
+ .font(.fraunces(size: 96, weight: 600))
+ .foregroundStyle(Theme.brandPink)
+ .baselineOffset(-10)
+ .scaleEffect(asteriskScale, anchor: .center)
+ .rotationEffect(.degrees(asteriskRotation))
+ Text("rae")
+ .font(.fraunces(size: 76, weight: 600))
+ .foregroundStyle(Theme.textPrimary)
+ .opacity(wordmarkOpacity)
+ .offset(x: wordmarkOffset)
+ }
+
+ Spacer()
+
+ Text("warming up")
+ .font(.system(size: 11, weight: .medium, design: .monospaced))
+ .foregroundStyle(Theme.textTertiary)
+ .tracking(2.0)
+ .textCase(.uppercase)
+ .padding(.bottom, 40)
+ }
+ }
+ .onAppear {
+ // Pop the asterisk in with a small overshoot, then start the
+ // continuous wobble.
+ withAnimation(.spring(response: 0.55, dampingFraction: 0.55)) {
+ asteriskScale = 1.0
+ asteriskRotation = 0
+ }
+ withAnimation(.easeOut(duration: 0.45).delay(0.25)) {
+ wordmarkOpacity = 1
+ wordmarkOffset = 0
+ }
+ // Continuous gentle wobble — never settles fully, so the eye
+ // always reads it as alive.
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.7) {
+ withAnimation(.easeInOut(duration: 1.6).repeatForever(autoreverses: true)) {
+ asteriskRotation = 6
+ }
+ }
+ }
+ }
+}
+
+#Preview("Splash") {
+ SplashView()
+ .frame(width: 720, height: 480)
+}
+
diff --git a/macos/Sources/ReverseAPI/UI/Theme.swift b/macos/Sources/ReverseAPI/UI/Theme.swift
index a51e961..931479e 100644
--- a/macos/Sources/ReverseAPI/UI/Theme.swift
+++ b/macos/Sources/ReverseAPI/UI/Theme.swift
@@ -1,35 +1,165 @@
import SwiftUI
+import AppKit
+/// Visual tokens for the rae macOS app.
+///
+/// Mirrors `reverse-api-website`'s design system (see `SYSTEM_DESIGN.md`
+/// at the repo root) — warm cream/ink palette, pink magenta brand
+/// accent. Every token now resolves dynamically against the system
+/// appearance, so the app honours macOS's dark/light setting instead of
+/// being locked to dark.
+///
+/// Hex equivalents trail each declaration in comments — they're the
+/// authoritative reference. RGB literals are SwiftUI plumbing.
enum Theme {
- // Backgrounds (darkest → lightest) — exploration: bumped roughly +6%
- // luminance across the dark stack so the canvas reads as warm dark grey
- // instead of near-black. Each surface keeps the same relative gap
- // (~5% between tiers) so contrast on hover/selected states is preserved.
- static let appBackground = Color(red: 0.071, green: 0.075, blue: 0.086) // #12131A
- static let surface = Color(red: 0.106, green: 0.110, blue: 0.122) // #1B1C1F
- static let elevated = Color(red: 0.157, green: 0.161, blue: 0.180) // #28292E
- static let input = Color(red: 0.122, green: 0.125, blue: 0.141) // #1F2024
+ // MARK: Backgrounds — warm cream / cream-dark stack
+
+ /// Root canvas. `--color-cream` light / darker than spec in dark
+ /// (user feedback: original #14110e read too "milky", deepened to
+ /// #0a0806).
+ static let appBackground = Color.dynamic(
+ light: hex(0xfff7f0),
+ dark: hex(0x0a0806)
+ )
+ /// Cards, panels. `--color-cream-soft`.
+ static let surface = Color.dynamic(
+ light: hex(0xfef8ee),
+ dark: hex(0x14110e)
+ )
+ /// Hover backgrounds, badges, raised one tier above `surface`.
+ static let elevated = Color.dynamic(
+ light: hex(0xf5f2ee),
+ dark: hex(0x1c1814)
+ )
+ /// Input fields. Intentionally distinct from `surface` so a composer /
+ /// search field sits as its own focused element rather than blending
+ /// into the panel. Aggressively lifted in dark (`#2a2418`, ~3 tiers
+ /// brighter than surface so the contrast is unmistakable) and pure
+ /// white on light. Pair with a 1pt `Theme.border` outline for extra
+ /// definition.
+ static let input = Color.dynamic(
+ light: hex(0xffffff),
+ dark: hex(0x2a2418)
+ )
+ /// Scrim behind modal palettes — slightly lighter than pitch black
+ /// so light-mode users still see the underlying surface bleed
+ /// through.
static let overlay = Color.black.opacity(0.55)
- // Borders & dividers
- static let border = Color.white.opacity(0.07)
- static let borderStrong = Color.white.opacity(0.14)
-
- // Text
- static let textPrimary = Color(red: 0.929, green: 0.929, blue: 0.937) // #EDEDEF
- static let textSecondary = Color(red: 0.549, green: 0.557, blue: 0.580) // #8C8E94
- static let textTertiary = Color(red: 0.373, green: 0.380, blue: 0.404) // #5F6167
-
- // Accent colors (matched against the dark canvas)
- static let accent = Color(red: 0.231, green: 0.510, blue: 0.965) // #3B82F6
- static let warn = Color(red: 0.941, green: 0.549, blue: 0.227) // #F08C3A
- static let success = Color(red: 0.298, green: 0.792, blue: 0.518) // #4CCA84
- static let danger = Color(red: 0.949, green: 0.357, blue: 0.357) // #F25B5B
-
- // Method palette (HTTP)
- static let methodGet = Color(red: 0.392, green: 0.643, blue: 1.000) // #64A4FF
- static let methodPost = Color(red: 0.388, green: 0.851, blue: 0.541) // #63D98A
- static let methodPut = Color(red: 0.953, green: 0.722, blue: 0.282) // #F3B848
- static let methodDelete = Color(red: 0.949, green: 0.439, blue: 0.439) // #F27070
- static let methodConnect = Color(red: 0.682, green: 0.482, blue: 0.965) // #AE7BF6
+ // MARK: Borders & dividers — ink/cream at very low alpha
+
+ /// Default 1pt strokes. `--color-fd-border` = ink @ 10% light / cream @ 8% dark.
+ static let border = Color.dynamic(
+ light: Color.black.opacity(0.10),
+ dark: Color.white.opacity(0.08)
+ )
+ /// Stronger structural separators.
+ static let borderStrong = Color.dynamic(
+ light: Color.black.opacity(0.18),
+ dark: Color.white.opacity(0.14)
+ )
+
+ // MARK: Text — ink / cream
+
+ /// Primary text.
+ static let textPrimary = Color.dynamic(
+ light: hex(0x1f1f1f),
+ dark: hex(0xfff7f0)
+ )
+ /// Secondary text. `--color-ink-soft`.
+ static let textSecondary = Color.dynamic(
+ light: hex(0x1f1f1f).opacity(0.78),
+ dark: hex(0xfff7f0).opacity(0.73)
+ )
+ /// Tertiary text (timestamps, labels, captions).
+ static let textTertiary = Color.dynamic(
+ light: hex(0x1f1f1f).opacity(0.55),
+ dark: hex(0xfff7f0).opacity(0.55)
+ )
+
+ // MARK: Semantic accents
+
+ /// Primary brand accent. Pink magenta. `--color-fd-primary`.
+ /// Drives focus rings, primary CTAs, status dots, brand asterisk.
+ static let accent = Color.dynamic(
+ light: hex(0xe50d75),
+ dark: hex(0xff3d8b)
+ )
+ /// Alias of `accent` for sites that read as "brand mark" (the
+ /// `*` asterisk + "rae" wordmark) rather than generic CTA.
+ static let brandPink = accent
+
+ /// Completed / success states. Deeper mint on light bg for
+ /// readability, soft mint on dark bg so it doesn't shout.
+ static let success = Color.dynamic(
+ light: hex(0x2d8059),
+ dark: hex(0xa4d4b8)
+ )
+ /// Alias of `success` for explicit "completion" semantics.
+ static let mint = success
+
+ /// Warning / attention. Warm orange.
+ static let warn = Color.dynamic(
+ light: hex(0xb06b3e),
+ dark: hex(0xd4946e)
+ )
+
+ /// Errors / destructive actions. Single value — works on both
+ /// palettes with adequate contrast.
+ static let danger = hex(0xe04848)
+
+ // MARK: HTTP method palette
+
+ /// HTTP method colors — desaturated slightly vs the original dark-only
+ /// palette so they read on the cream light background without
+ /// vibrating, while keeping enough difference between them for the
+ /// dense traffic table.
+ static let methodGet = Color.dynamic(light: hex(0x1f6fd1), dark: hex(0x64a4ff))
+ static let methodPost = Color.dynamic(light: hex(0x1f8757), dark: hex(0x63d98a))
+ static let methodPut = Color.dynamic(light: hex(0xb88128), dark: hex(0xf3b848))
+ static let methodDelete = Color.dynamic(light: hex(0xc43d3d), dark: hex(0xf27070))
+ static let methodConnect = Color.dynamic(light: hex(0x6e3fbd), dark: hex(0xae7bf6))
+
+ // MARK: Shape radii
+
+ /// Outer container cards (the 3-column shell).
+ static let radiusCard: CGFloat = 14
+ /// Inner sub-cards inside a Card.
+ static let radiusInner: CGFloat = 10
+ /// Inputs + small pill buttons.
+ static let radiusInput: CGFloat = 8
+}
+
+// MARK: - Color helpers
+
+extension Color {
+ /// Build a SwiftUI Color that resolves to `light` under any aqua-family
+ /// appearance and `dark` under any dark-aqua-family appearance. Backed
+ /// by AppKit's dynamic NSColor so it also works when handed to APIs
+ /// outside SwiftUI (e.g. `NSWindow.backgroundColor`).
+ static func dynamic(light: Color, dark: Color) -> Color {
+ Color(nsColor: NSColor(name: nil, dynamicProvider: { appearance in
+ switch appearance.name {
+ case .darkAqua,
+ .vibrantDark,
+ .accessibilityHighContrastDarkAqua,
+ .accessibilityHighContrastVibrantDark:
+ return NSColor(dark)
+ default:
+ return NSColor(light)
+ }
+ }))
+ }
+}
+
+/// Hex literal helper — `hex(0xff3d8b)` reads more naturally than the
+/// RGB-as-fractions form. Alpha is always 1.0; use `.opacity(...)` for
+/// translucent variants.
+@inlinable
+func hex(_ value: UInt32) -> Color {
+ Color(
+ red: Double((value >> 16) & 0xff) / 255.0,
+ green: Double((value >> 8) & 0xff) / 255.0,
+ blue: Double(value & 0xff) / 255.0
+ )
}
diff --git a/macos/Sources/ReverseAPI/UI/TrafficListView.swift b/macos/Sources/ReverseAPI/UI/TrafficListView.swift
index ff1dfad..29f899f 100644
--- a/macos/Sources/ReverseAPI/UI/TrafficListView.swift
+++ b/macos/Sources/ReverseAPI/UI/TrafficListView.swift
@@ -44,7 +44,7 @@ private struct TrafficListHeader: View {
HStack(spacing: 10) {
SelectAllCheckbox(visibleIDs: visibleIDs)
Text("Traffic")
- .font(.system(size: 13, weight: .semibold))
+ .font(.fraunces(size: 18, weight: 600))
.foregroundStyle(Theme.textPrimary)
Text("\(visibleCount)")
.font(.caption.monospacedDigit())
diff --git a/macos/Sources/ReverseAPIProxy/System/SystemProxyController.swift b/macos/Sources/ReverseAPIProxy/System/SystemProxyController.swift
index 4df8668..917c85b 100644
--- a/macos/Sources/ReverseAPIProxy/System/SystemProxyController.swift
+++ b/macos/Sources/ReverseAPIProxy/System/SystemProxyController.swift
@@ -7,7 +7,7 @@ public enum SystemProxyError: Error {
case invalidPort(Int)
}
-public struct ProxyServiceSnapshot: Sendable, Equatable {
+public struct ProxyServiceSnapshot: Sendable, Equatable, Codable {
public let service: String
public let httpEnabled: Bool
public let httpHost: String
@@ -15,6 +15,24 @@ public struct ProxyServiceSnapshot: Sendable, Equatable {
public let httpsEnabled: Bool
public let httpsHost: String
public let httpsPort: Int
+
+ public init(
+ service: String,
+ httpEnabled: Bool,
+ httpHost: String,
+ httpPort: Int,
+ httpsEnabled: Bool,
+ httpsHost: String,
+ httpsPort: Int
+ ) {
+ self.service = service
+ self.httpEnabled = httpEnabled
+ self.httpHost = httpHost
+ self.httpPort = httpPort
+ self.httpsEnabled = httpsEnabled
+ self.httpsHost = httpsHost
+ self.httpsPort = httpsPort
+ }
}
public final class SystemProxyController: @unchecked Sendable {
diff --git a/macos/scripts/build-app.sh b/macos/scripts/build-app.sh
index 1686e25..45821e2 100755
--- a/macos/scripts/build-app.sh
+++ b/macos/scripts/build-app.sh
@@ -1,61 +1,113 @@
#!/usr/bin/env bash
-# Build a portable rae.app bundle with a self-contained Python runtime.
-#
-# Output: /macos/dist/rae.app — drop into /Applications and it just runs.
-# No user-side Python or pip required.
+# Build a portable, universal (arm64 + x86_64) rae.app bundle with a
+# self-contained Python runtime embedded under Contents/Resources/agent-runtime.
+# Drop the result into /Applications and the app just runs — no user-side
+# Python or pip needed.
#
# Requires:
-# - swift (Xcode command line tools)
-# - uv (https://docs.astral.sh/uv) — used to fetch a standalone Python build
-# and create a relocatable venv inside the .app
+# - swift (Xcode command line tools)
+# - uv (https://docs.astral.sh/uv) — used to fetch a standalone Python
+# build and create a relocatable venv inside the .app
#
# Usage:
-# ./macos/scripts/build-app.sh # builds release
-# PYTHON_VERSION=3.12 ./macos/scripts/build-app.sh # pin the Python version
+# ./macos/scripts/build-app.sh
+# PYTHON_VERSION=3.12 ./macos/scripts/build-app.sh
+# CONFIG=debug ./macos/scripts/build-app.sh # faster iteration
+# ARCH_FLAGS="--arch arm64" ./macos/scripts/build-app.sh # single-arch
+#
+# Output: macos/build/rae.app
set -euo pipefail
-PYTHON_VERSION="${PYTHON_VERSION:-3.12}"
+APP_NAME=${APP_NAME:-rae}
+CONFIG=${CONFIG:-release}
+ARCH_FLAGS=${ARCH_FLAGS:-"--arch arm64 --arch x86_64"}
+PYTHON_VERSION=${PYTHON_VERSION:-3.12}
+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
MACOS_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
REPO_ROOT="$(cd "$MACOS_DIR/.." && pwd)"
-DIST_DIR="$MACOS_DIR/dist"
-APP_DIR="$DIST_DIR/rae.app"
-CONTENTS="$APP_DIR/Contents"
+OUT_DIR="$MACOS_DIR/build"
+APP_BUNDLE="$OUT_DIR/$APP_NAME.app"
+CONTENTS="$APP_BUNDLE/Contents"
MACOS_BIN="$CONTENTS/MacOS"
RESOURCES="$CONTENTS/Resources"
AGENT_RUNTIME="$RESOURCES/agent-runtime"
require() {
- command -v "$1" >/dev/null 2>&1 || { echo "error: '$1' not found in PATH" >&2; exit 1; }
+ command -v "$1" >/dev/null 2>&1 || {
+ echo "error: '$1' not found in PATH" >&2
+ exit 1
+ }
}
require swift
require uv
# ---------------------------------------------------------------------------
-# 1. Clean & scaffold the .app bundle layout
+# 1. Scaffold the .app bundle layout
# ---------------------------------------------------------------------------
-echo "→ cleaning $APP_DIR"
-rm -rf "$APP_DIR"
+echo "→ cleaning $APP_BUNDLE"
+rm -rf "$APP_BUNDLE"
mkdir -p "$MACOS_BIN" "$RESOURCES"
# ---------------------------------------------------------------------------
-# 2. Build the Swift release binary
+# 2. Universal Swift build
+#
+# Two invocations: the first actually compiles, the second is `--show-bin-path`
+# to recover the products dir (cheap, no rebuild — SwiftPM just prints the
+# resolved path).
# ---------------------------------------------------------------------------
-echo "→ swift build (release)"
cd "$MACOS_DIR"
-swift build -c release --product rae
-cp "$MACOS_DIR/.build/release/rae" "$MACOS_BIN/rae"
-chmod +x "$MACOS_BIN/rae"
+echo "→ swift build ($CONFIG, $ARCH_FLAGS)"
+# shellcheck disable=SC2086
+swift build -c "$CONFIG" --product "$APP_NAME" $ARCH_FLAGS
+
+# shellcheck disable=SC2086
+BIN_PATH=$(swift build -c "$CONFIG" --product "$APP_NAME" $ARCH_FLAGS --show-bin-path)
+BUILT_BIN="$BIN_PATH/$APP_NAME"
+
+if [ ! -f "$BUILT_BIN" ]; then
+ echo "error: binary not found at $BUILT_BIN" >&2
+ exit 1
+fi
+
+cp "$BUILT_BIN" "$MACOS_BIN/$APP_NAME"
+chmod +x "$MACOS_BIN/$APP_NAME"
+
+# ---------------------------------------------------------------------------
+# 3. Static Info.plist + (optional) AppIcon + bundled SwiftPM resources
+# ---------------------------------------------------------------------------
+cp "$MACOS_DIR/Resources/Info.plist" "$CONTENTS/Info.plist"
+
+if [ -f "$MACOS_DIR/Resources/AppIcon.icns" ]; then
+ cp "$MACOS_DIR/Resources/AppIcon.icns" "$RESOURCES/AppIcon.icns"
+fi
+
+# SwiftPM compiles a resource bundle for any target that declares `resources:`
+# — we ship Fraunces italic for the brand wordmark this way. The bundle
+# lives next to the executable in the build products dir; copy it into the
+# .app so Bundle.module finds it at runtime.
+SPM_RESOURCE_BUNDLE="$BIN_PATH/ReverseAPI_ReverseAPI.bundle"
+if [ -d "$SPM_RESOURCE_BUNDLE" ]; then
+ cp -R "$SPM_RESOURCE_BUNDLE" "$RESOURCES/"
+fi
# ---------------------------------------------------------------------------
-# 3. Embed a relocatable Python runtime with rae-agent installed
+# 4. Embed a relocatable Python runtime with rae-agent installed
#
-# uv venv --relocatable rewrites the activator + shebangs so the venv can
-# be moved (or shipped) to a different absolute path. uv pip install with
-# --python targets that specific interpreter so all deps land
-# inside the venv's site-packages, not the host's.
+# `uv venv --relocatable` rewrites shebangs + activator so the venv can be
+# moved to its final path. `uv pip install --python ` targets the
+# embedded interpreter so deps land inside the bundled venv's site-packages.
+#
+# Drop any inherited HTTP proxy env vars before `uv` reaches out to PyPI.
+# uv reads HTTP_PROXY / HTTPS_PROXY / ALL_PROXY (case both) from the
+# environment — if rae was previously set as the system proxy and the
+# shell still has them exported pointing at 127.0.0.1:, fetches
+# fail with "Connection refused (os error 61)" the moment rae isn't
+# running. PyPI itself is reachable directly; routing the build through
+# a local MITM never made sense anyway.
+unset HTTP_PROXY HTTPS_PROXY ALL_PROXY http_proxy https_proxy all_proxy NO_PROXY no_proxy
# ---------------------------------------------------------------------------
echo "→ creating embedded Python $PYTHON_VERSION runtime"
uv venv --relocatable --python "$PYTHON_VERSION" "$AGENT_RUNTIME"
@@ -66,51 +118,16 @@ uv pip install \
--quiet \
"$REPO_ROOT/backend"
-# Strip __pycache__ + tests to shave a few MB
+# Strip __pycache__ + bundled tests to shave a few MB from the DMG
find "$AGENT_RUNTIME" -type d -name "__pycache__" -prune -exec rm -rf {} +
find "$AGENT_RUNTIME" -type d -name "tests" -prune -exec rm -rf {} +
# ---------------------------------------------------------------------------
-# 4. Info.plist — minimal so macOS treats the binary as a regular GUI app
-# (Dock icon, menu bar, keyboard focus, the works).
-# ---------------------------------------------------------------------------
-cat > "$CONTENTS/Info.plist" <<'PLIST'
-
-
-
-
- CFBundleIdentifier
- app.rae.reverseapi
- CFBundleName
- rae
- CFBundleDisplayName
- rae
- CFBundleExecutable
- rae
- CFBundleVersion
- 1
- CFBundleShortVersionString
- 0.1.0
- CFBundlePackageType
- APPL
- LSMinimumSystemVersion
- 14.0
- NSPrincipalClass
- NSApplication
- NSHighResolutionCapable
-
- NSSupportsAutomaticGraphicsSwitching
-
-
-
-PLIST
-
-# ---------------------------------------------------------------------------
-# 5. Smoke check — make sure rae_agent imports inside the embedded runtime
+# 5. Smoke check — confirm rae_agent imports inside the embedded runtime
# ---------------------------------------------------------------------------
echo "→ smoke-testing embedded runtime"
"$AGENT_RUNTIME/bin/python3" -c "import rae_agent.server; print('rae_agent ok')"
echo ""
-echo "✓ built $APP_DIR"
-du -sh "$APP_DIR" 2>/dev/null | awk '{print " size: " $1}'
+echo "✓ built $APP_BUNDLE"
+du -sh "$APP_BUNDLE" 2>/dev/null | awk '{print " size: " $1}'
diff --git a/macos/scripts/make-dmg.sh b/macos/scripts/make-dmg.sh
new file mode 100755
index 0000000..7f28766
--- /dev/null
+++ b/macos/scripts/make-dmg.sh
@@ -0,0 +1,48 @@
+#!/usr/bin/env bash
+# Wrap the signed rae.app into a distributable DMG with a /Applications
+# symlink so the install gesture is the familiar "drag the app into
+# Applications" pattern.
+#
+# Usage:
+# ./macos/scripts/make-dmg.sh # unsigned DMG
+# SIGNING_IDENTITY="…" ./macos/scripts/make-dmg.sh # also signs the DMG
+#
+# Prerequisite: scripts/build-app.sh has produced build/rae.app, and
+# optionally scripts/sign-and-notarize.sh has signed it.
+
+set -euo pipefail
+
+HERE=$(cd "$(dirname "$0")/.." && pwd)
+cd "$HERE"
+
+APP_NAME=${APP_NAME:-rae}
+APP_BUNDLE="build/$APP_NAME.app"
+DMG_PATH="build/$APP_NAME.dmg"
+
+if [ ! -d "$APP_BUNDLE" ]; then
+ echo "error: $APP_BUNDLE not found — run scripts/build-app.sh first" >&2
+ exit 1
+fi
+
+STAGING=$(mktemp -d)
+# EXIT trap so an hdiutil failure (or anything else) doesn't leak the
+# staging dir into /var/folders.
+trap 'rm -rf "$STAGING"' EXIT
+
+cp -R "$APP_BUNDLE" "$STAGING/"
+ln -s /Applications "$STAGING/Applications"
+
+rm -f "$DMG_PATH"
+hdiutil create \
+ -volname "$APP_NAME" \
+ -srcfolder "$STAGING" \
+ -ov -format UDZO \
+ "$DMG_PATH"
+
+if [ -n "${SIGNING_IDENTITY:-}" ]; then
+ echo "→ codesign DMG with $SIGNING_IDENTITY"
+ codesign --force --sign "$SIGNING_IDENTITY" --timestamp "$DMG_PATH"
+fi
+
+echo "✓ $DMG_PATH"
+du -h "$DMG_PATH" 2>/dev/null | awk '{print " size: " $1}'
diff --git a/macos/scripts/sign-and-notarize.sh b/macos/scripts/sign-and-notarize.sh
new file mode 100755
index 0000000..2e2fa8b
--- /dev/null
+++ b/macos/scripts/sign-and-notarize.sh
@@ -0,0 +1,90 @@
+#!/usr/bin/env bash
+# Codesign + (optionally) notarize the rae.app bundle.
+#
+# Usage:
+# SIGNING_IDENTITY="Developer ID Application: …" \
+# NOTARY_PROFILE="rae-notary" \ # optional — skip notarization if unset
+# ./macos/scripts/sign-and-notarize.sh
+#
+# Prerequisites: scripts/build-app.sh has already produced build/rae.app.
+#
+# Apple deprecated `codesign --deep` for production signing because it can
+# silently skip nested components or stamp the wrong entitlements on
+# bundled helpers. We sign just the outer .app here; once the Python
+# sidecar lands inside Contents/Resources/agent-runtime, we'll need to
+# walk it explicitly first.
+
+set -euo pipefail
+
+HERE=$(cd "$(dirname "$0")/.." && pwd)
+cd "$HERE"
+
+APP_NAME=${APP_NAME:-rae}
+APP_BUNDLE="build/$APP_NAME.app"
+ENTITLEMENTS="Resources/$APP_NAME.entitlements"
+
+if [ ! -d "$APP_BUNDLE" ]; then
+ echo "error: $APP_BUNDLE not found — run scripts/build-app.sh first" >&2
+ exit 1
+fi
+
+if [ ! -f "$ENTITLEMENTS" ]; then
+ echo "error: entitlements file not found at $ENTITLEMENTS" >&2
+ exit 1
+fi
+
+if [ -z "${SIGNING_IDENTITY:-}" ]; then
+ echo "error: SIGNING_IDENTITY is required (Developer ID Application: …)" >&2
+ exit 1
+fi
+
+# If the bundle embeds a Python runtime, sign every nested executable +
+# dylib + framework first. We don't ship those yet in this branch, but
+# leaving the loop here means the script is correct the day they land
+# without us having to remember to come back and update it.
+EMBEDDED_RUNTIME="$APP_BUNDLE/Contents/Resources/agent-runtime"
+if [ -d "$EMBEDDED_RUNTIME" ]; then
+ echo "→ signing embedded agent runtime"
+ while IFS= read -r -d '' bin; do
+ codesign --force --options runtime --timestamp \
+ --sign "$SIGNING_IDENTITY" \
+ "$bin"
+ done < <(find "$EMBEDDED_RUNTIME" \( -name "*.dylib" -o -name "*.so" \) -print0)
+ if [ -x "$EMBEDDED_RUNTIME/bin/python3" ]; then
+ codesign --force --options runtime --timestamp \
+ --sign "$SIGNING_IDENTITY" \
+ "$EMBEDDED_RUNTIME/bin/python3"
+ fi
+fi
+
+echo "→ codesign $APP_BUNDLE with $SIGNING_IDENTITY"
+codesign --force --options runtime --timestamp \
+ --entitlements "$ENTITLEMENTS" \
+ --sign "$SIGNING_IDENTITY" \
+ "$APP_BUNDLE"
+
+codesign --verify --deep --strict --verbose=2 "$APP_BUNDLE"
+
+if [ -z "${NOTARY_PROFILE:-}" ]; then
+ echo "✓ signed; skipping notarization (set NOTARY_PROFILE to enable)"
+ exit 0
+fi
+
+ZIP_PATH="build/$APP_NAME.zip"
+# Clean the artifact on any exit path so the CI/local build dir doesn't
+# accumulate stale ZIPs alongside the DMG.
+trap 'rm -f "$ZIP_PATH"' EXIT
+
+rm -f "$ZIP_PATH"
+ditto -c -k --sequesterRsrc --keepParent "$APP_BUNDLE" "$ZIP_PATH"
+
+echo "→ notarytool submit ($NOTARY_PROFILE)"
+xcrun notarytool submit "$ZIP_PATH" \
+ --keychain-profile "$NOTARY_PROFILE" \
+ --wait
+
+echo "→ stapler staple"
+xcrun stapler staple "$APP_BUNDLE"
+xcrun stapler validate "$APP_BUNDLE"
+
+echo "✓ signed + notarized $APP_BUNDLE"