feat(mac): add child relay process manager
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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)
|
||||
|
||||
192
apps/macos/Sources/Clawdis/RelayProcessManager.swift
Normal file
192
apps/macos/Sources/Clawdis/RelayProcessManager.swift
Normal file
@@ -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"]
|
||||
}
|
||||
}
|
||||
68
docs/mac/child-process.md
Normal file
68
docs/mac/child-process.md
Normal file
@@ -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.
|
||||
Reference in New Issue
Block a user