From 460d8fc0948d5b3144da402688b4bf8729f428e6 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 6 Dec 2025 22:05:14 +0100 Subject: [PATCH] feat(mac): add child relay process manager --- apps/macos/Package.resolved | 20 +- apps/macos/Package.swift | 2 + apps/macos/Sources/Clawdis/AppMain.swift | 29 ++- .../Sources/Clawdis/RelayProcessManager.swift | 192 ++++++++++++++++++ docs/mac/child-process.md | 68 +++++++ 5 files changed, 308 insertions(+), 3 deletions(-) create mode 100644 apps/macos/Sources/Clawdis/RelayProcessManager.swift create mode 100644 docs/mac/child-process.md diff --git a/apps/macos/Package.resolved b/apps/macos/Package.resolved index aa00e5793..84392aa94 100644 --- a/apps/macos/Package.resolved +++ b/apps/macos/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "31221e39fa9e1e5f162f1fecf985ccf3c6aac31ffc427a3f9f2414be0a39b50f", + "originHash" : "d88e9364f346bbb20f6e4f0bba6328ce6780b32d4645e22c3a9acc8802298c52", "pins" : [ { "identity" : "asyncxpcconnection", @@ -18,6 +18,24 @@ "revision" : "707dff6f55217b3ef5b6be84ced3e83511d4df5c", "version" : "1.2.2" } + }, + { + "identity" : "swift-subprocess", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swiftlang/swift-subprocess.git", + "state" : { + "revision" : "44922dfe46380cd354ca4b0208e717a3e92b13dd", + "version" : "0.2.1" + } + }, + { + "identity" : "swift-system", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-system", + "state" : { + "revision" : "395a77f0aa927f0ff73941d7ac35f2b46d47c9db", + "version" : "1.6.3" + } } ], "version" : 3 diff --git a/apps/macos/Package.swift b/apps/macos/Package.swift index f676cff56..2fda3cbf0 100644 --- a/apps/macos/Package.swift +++ b/apps/macos/Package.swift @@ -16,6 +16,7 @@ let package = Package( dependencies: [ .package(url: "https://github.com/ChimeHQ/AsyncXPCConnection", from: "1.3.0"), .package(url: "https://github.com/orchetect/MenuBarExtraAccess", exact: "1.2.2"), + .package(url: "https://github.com/swiftlang/swift-subprocess.git", from: "0.1.0"), ], targets: [ .target( @@ -31,6 +32,7 @@ let package = Package( "ClawdisIPC", .product(name: "AsyncXPCConnection", package: "AsyncXPCConnection"), .product(name: "MenuBarExtraAccess", package: "MenuBarExtraAccess"), + .product(name: "Subprocess", package: "swift-subprocess"), ], resources: [ .copy("Resources/Clawdis.icns"), diff --git a/apps/macos/Sources/Clawdis/AppMain.swift b/apps/macos/Sources/Clawdis/AppMain.swift index c8dfd93aa..1cf3f5e49 100644 --- a/apps/macos/Sources/Clawdis/AppMain.swift +++ b/apps/macos/Sources/Clawdis/AppMain.swift @@ -571,6 +571,7 @@ struct ClawdisApp: App { @StateObject private var state: AppState @State private var statusItem: NSStatusItem? @State private var isMenuPresented = false + private let relayManager = RelayProcessManager.shared init() { _state = StateObject(wrappedValue: AppStateStore.shared) @@ -585,6 +586,7 @@ struct ClawdisApp: App { } .onChange(of: self.state.isPaused) { _, paused in self.applyStatusItemAppearance(paused: paused) + self.relayManager.setActive(!paused) } Settings { @@ -862,13 +864,15 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSXPCListenerDelegate func applicationDidFinishLaunching(_ notification: Notification) { self.state = AppStateStore.shared AppActivationPolicy.apply(showDockIcon: self.state?.showDockIcon ?? false) - LaunchdManager.startClawdis() + if let state { + RelayProcessManager.shared.setActive(!state.isPaused) + } self.startListener() self.scheduleFirstRunOnboardingIfNeeded() } func applicationWillTerminate(_ notification: Notification) { - LaunchdManager.stopClawdis() + RelayProcessManager.shared.stop() } @MainActor @@ -2683,6 +2687,7 @@ struct DebugSettings: View { @State private var modelsCount: Int? @State private var modelsLoading = false @State private var modelsError: String? + @ObservedObject private var relayManager = RelayProcessManager.shared var body: some View { VStack(alignment: .leading, spacing: 10) { @@ -2691,6 +2696,26 @@ struct DebugSettings: View { Button("Open /tmp/clawdis.log") { NSWorkspace.shared.open(URL(fileURLWithPath: "/tmp/clawdis.log")) } } LabeledContent("Binary path") { Text(Bundle.main.bundlePath).font(.footnote) } + LabeledContent("Relay status") { + VStack(alignment: .leading, spacing: 2) { + Text(self.relayManager.status.label) + Text("Restarts: \(self.relayManager.restartCount)") + .font(.caption2) + .foregroundStyle(.secondary) + } + } + VStack(alignment: .leading, spacing: 4) { + Text("Relay stdout/stderr") + .font(.caption.weight(.semibold)) + ScrollView { + Text(self.relayManager.log.isEmpty ? "—" : self.relayManager.log) + .font(.caption.monospaced()) + .frame(maxWidth: .infinity, alignment: .leading) + .textSelection(.enabled) + } + .frame(height: 180) + .overlay(RoundedRectangle(cornerRadius: 6).stroke(Color.secondary.opacity(0.2))) + } LabeledContent("Model catalog") { VStack(alignment: .leading, spacing: 6) { Text(self.modelCatalogPath) diff --git a/apps/macos/Sources/Clawdis/RelayProcessManager.swift b/apps/macos/Sources/Clawdis/RelayProcessManager.swift new file mode 100644 index 000000000..90f9f589f --- /dev/null +++ b/apps/macos/Sources/Clawdis/RelayProcessManager.swift @@ -0,0 +1,192 @@ +import Foundation +import OSLog +import Subprocess + +@MainActor +final class RelayProcessManager: ObservableObject { + static let shared = RelayProcessManager() + + enum Status: Equatable { + case stopped + case starting + case running(pid: Int32) + case restarting + case failed(String) + + var label: String { + switch self { + case .stopped: "Stopped" + case .starting: "Starting…" + case let .running(pid): "Running (pid \(pid))" + case .restarting: "Restarting…" + case let .failed(reason): "Failed: \(reason)" + } + } + } + + @Published private(set) var status: Status = .stopped + @Published private(set) var log: String = "" + @Published private(set) var restartCount: Int = 0 + + private var execution: Execution? + private var desiredActive = false + private var stopping = false + private var recentCrashes: [Date] = [] + + private let logger = Logger(subsystem: "com.steipete.clawdis", category: "relay") + private let logLimit = 20_000 // characters to keep in-memory + private let maxCrashes = 3 + private let crashWindow: TimeInterval = 120 // seconds + + func setActive(_ active: Bool) { + self.desiredActive = active + if active { + self.startIfNeeded() + } else { + self.stop() + } + } + + func startIfNeeded() { + guard self.execution == nil, self.desiredActive else { return } + if self.shouldGiveUpAfterCrashes() { + self.status = .failed("Too many crashes; giving up") + return + } + self.status = self.status == .restarting ? .restarting : .starting + Task.detached { [weak self] in + guard let self else { return } + await self.spawnRelay() + } + } + + func stop() { + self.desiredActive = false + self.stopping = true + guard let execution else { + self.status = .stopped + return + } + self.status = .stopped + Task { + await execution.teardown(using: [.gracefulShutDown(allowedDurationToNextStep: .seconds(1))]) + } + self.execution = nil + } + + // MARK: - Internals + + private func spawnRelay() async { + let command = self.resolveCommand() + self.appendLog("[relay] starting: \(command.joined(separator: " "))\n") + + do { + let result = try await run( + .name(command.first ?? "clawdis"), + arguments: Arguments(Array(command.dropFirst())), + environment: .inherit, + workingDirectory: nil + ) { execution, stdin, stdout, stderr in + self.didStart(execution) + async let out: Void = self.stream(output: stdout, label: "stdout") + async let err: Void = self.stream(output: stderr, label: "stderr") + try await stdin.finish() + await out + await err + } + + await self.handleTermination(status: result.terminationStatus) + } catch { + await self.handleError(error) + } + } + + private func didStart(_ execution: Execution) { + self.execution = execution + self.stopping = false + self.status = .running(pid: execution.processIdentifier.value) + self.logger.info("relay started pid \(execution.processIdentifier.value)") + } + + private func handleTermination(status: TerminationStatus) async { + let code: Int32 = { + switch status { + case let .exited(exitCode): return exitCode + case let .unhandledException(sig): return -Int32(sig) + } + }() + + self.execution = nil + if self.stopping || !self.desiredActive { + self.status = .stopped + self.stopping = false + return + } + + self.recentCrashes.append(Date()) + self.recentCrashes = self.recentCrashes.filter { Date().timeIntervalSince($0) < self.crashWindow } + self.restartCount += 1 + self.appendLog("[relay] exited (\(code)).\n") + + if self.shouldGiveUpAfterCrashes() { + self.status = .failed("Too many crashes; stopped auto-restart.") + self.logger.error("relay crash loop detected; giving up") + return + } + + self.status = .restarting + self.logger.warning("relay crashed (code \(code)); restarting") + try? await Task.sleep(nanoseconds: 750_000_000) + self.startIfNeeded() + } + + private func handleError(_ error: any Error) async { + self.execution = nil + self.appendLog("[relay] failed: \(error.localizedDescription)\n") + self.logger.error("relay failed: \(error.localizedDescription, privacy: .public)") + if self.desiredActive && !self.shouldGiveUpAfterCrashes() { + self.status = .restarting + self.recentCrashes.append(Date()) + self.startIfNeeded() + } else { + self.status = .failed(error.localizedDescription) + } + } + + private func shouldGiveUpAfterCrashes() -> Bool { + self.recentCrashes = self.recentCrashes.filter { Date().timeIntervalSince($0) < self.crashWindow } + return self.recentCrashes.count >= self.maxCrashes + } + + private func stream(output: AsyncBufferSequence, label: String) async { + do { + for try await line in output.lines() { + await MainActor.run { + self.appendLog(line + "\n") + } + } + } catch { + await MainActor.run { + self.appendLog("[relay \(label)] stream error: \(error.localizedDescription)\n") + } + } + } + + private func appendLog(_ chunk: String) { + self.log.append(chunk) + if self.log.count > self.logLimit { + self.log = String(self.log.suffix(self.logLimit)) + } + } + + private func resolveCommand() -> [String] { + // Keep it simple: rely on system-installed clawdis/warelay. + // Default to `clawdis relay`; users can provide an override via env if needed. + if let override = ProcessInfo.processInfo.environment["CLAWDIS_RELAY_CMD"], + !override.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + { + return override.split(separator: " ").map(String.init) + } + return ["clawdis", "relay"] + } +} diff --git a/docs/mac/child-process.md b/docs/mac/child-process.md new file mode 100644 index 000000000..b0680f436 --- /dev/null +++ b/docs/mac/child-process.md @@ -0,0 +1,68 @@ +# Clawdis relay as a child process of the macOS app + +Date: 2025-12-06 · Status: draft · Owner: steipete + +## Goal +Run the Node-based Clawdis/warelay relay as a direct child of the LSUIElement app (instead of a launchd agent) while keeping all TCC-sensitive work inside the Swift app/XPC and wiring the existing “Clawdis Active” toggle to start/stop the child. + +## When to prefer the child-process mode +- You want relay lifetime strictly coupled to the menu-bar app (dies when the app quits) and controlled by the “Clawdis Active” toggle without touching launchd. +- You’re okay giving up login persistence/auto-restart that launchd provides, or you’ll add your own backoff loop. +- You want simpler log capture and supervision inside the app (no external plist or user-visible LaunchAgent). + +## Tradeoffs vs. launchd +- **Pros:** tighter coupling to UI state; simpler surface (no plist install/bootout); easier to stream stdout/stderr; fewer moving parts for beta users. +- **Cons:** no built-in KeepAlive/login auto-start; app crash kills relay; you must build your own restart/backoff; Activity Monitor will show both processes under the app; still need correct TCC handling (see below). +- **TCC:** behaviorally, child processes often inherit the parent app’s “responsible process” for TCC, but this is *not a contract*. Continue to route all protected actions through the Swift app/XPC so prompts stay tied to the signed app bundle. + +## TCC guardrails (must keep) +- Screen Recording, Accessibility, mic, and speech prompts must originate from the Swift app/XPC. The Node child should never call these APIs directly; use the existing XPC/CLI broker (`clawdis-mac`) for: + - `ensure-permissions` + - `screenshot` / ScreenCaptureKit work + - mic/speech permission checks + - notifications + - shell runs that need `needs-screen-recording` +- Usage strings (`NSMicrophoneUsageDescription`, `NSSpeechRecognitionUsageDescription`, etc.) stay in the app target’s Info.plist; a bare Node binary has none and would fail. +- If you ever embed Node that *must* touch TCC, wrap that call in a tiny signed helper target inside the app bundle and have Node exec that helper instead of calling the API directly. + +## Process manager design (Swift Subprocess) +- Add a small `RelayProcessManager` (Swift) that owns: + - `execution: Execution?` from `Swift Subprocess` to track the child. + - `start(config)` called when “Clawdis Active” flips ON: + - binary: bundled Node or packaged relay CLI under `Clawdis.app/Contents/Resources/Relay/` + - args: current warelay/clawdis entrypoint and flags + - cwd/env: point to `~/.clawdis` as today; inject `PATH` if the embedded Node isn’t on PATH + - output: stream stdout/stderr to `/tmp/clawdis-relay.log` (cap buffer via Subprocess OutputLimits) + - restart: optional linear/backoff restart if exit was non-zero and Active is still true + - `stop()` called when Active flips OFF or app terminates: cancel the execution and `waitUntilExit`. +- Wire SwiftUI toggle: + - ON: `RelayProcessManager.start(...)` + - OFF: `RelayProcessManager.stop()` (no launchctl calls in this mode) +- Keep the existing `LaunchdManager` around so we can switch back if needed; the toggle can choose between launchd or child mode with a flag if we want both. + +## Packaging and signing +- Bundle the relay runtime inside the app: + - Option A: embed a Node binary + JS entrypoint under `Contents/Resources/Relay/`. + - Option B: build a single binary via `pkg`/`nexe` and embed that. +- Codesign the embedded runtime as nested code with the same team ID; notarization fails if nested code is unsigned. +- If we keep using Homebrew Node, do *not* let it touch TCC; only the app/XPC should. + +## Logging and observability +- Stream child stdout/stderr to `/tmp/clawdis-relay.log`; surface the last N lines in the Debug tab. +- Emit a user notification (via existing NotificationManager) on crash/exit while Active is true. +- Add a lightweight heartbeat from Node → app (e.g., ping over stdout) so the app can show status in the menu. + +## Failure/edge cases +- App crash/quit kills the relay. Decide if that is acceptable for the deployment tier; otherwise, stick with launchd for production and keep child-process for dev/experiments. +- If the relay exits repeatedly, back off (e.g., 1s/2s/5s/10s) and give up after N attempts with a menu warning. +- Respect the existing pause semantics: when paused, the XPC should return `ok=false, "clawdis paused"`; the relay should avoid calling privileged routes while paused. + +## Open questions / follow-ups +- Do we need dual-mode (launchd for prod, child for dev)? If yes, gate via a setting or build flag. +- Should we embed Node or keep using the system/Homebrew Node? Embedding improves reproducibility and signing hygiene; Homebrew keeps bundle smaller but risks path/sandbox drift. +- Do we want a tiny signed helper for rare TCC actions that cannot be brokered via XPC? + +## Decision snapshot (current recommendation) +- Keep all TCC surfaces in the Swift app/XPC. +- Implement `RelayProcessManager` with Swift Subprocess to start/stop the relay on the “Clawdis Active” toggle. +- Maintain the launchd path as a fallback for uptime/login persistence until child-mode proves stable.