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"