feat(macos): run bundled gateway via launchd

This commit is contained in:
Peter Steinberger
2025-12-19 19:20:52 +01:00
parent 98bbc73925
commit a4d4a30a6b
8 changed files with 222 additions and 234 deletions

View File

@@ -1,8 +1,9 @@
import Foundation
let launchdLabel = "com.steipete.clawdis"
let gatewayLaunchdLabel = "com.steipete.clawdis.gateway"
let onboardingVersionKey = "clawdis.onboardingVersion"
let currentOnboardingVersion = 6
let currentOnboardingVersion = 7
let pauseDefaultsKey = "clawdis.pauseEnabled"
let iconAnimationsEnabledKey = "clawdis.iconAnimationsEnabled"
let swabbleEnabledKey = "clawdis.swabbleEnabled"

View File

@@ -133,9 +133,6 @@ struct DebugSettings: View {
self.gridLabel("Status")
HStack(spacing: 8) {
Text(self.gatewayManager.status.label)
Text("Restarts: \(self.gatewayManager.restartCount)")
.font(.caption2)
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity, alignment: .leading)
}

View File

@@ -64,6 +64,12 @@ struct GatewayCommandResolution {
enum GatewayEnvironment {
private static let logger = Logger(subsystem: "com.steipete.clawdis", category: "gateway.env")
static func bundledGatewayExecutable() -> String? {
guard let res = Bundle.main.resourceURL else { return nil }
let path = res.appendingPathComponent("Relay/clawdis-gateway").path
return FileManager.default.isExecutableFile(atPath: path) ? path : nil
}
static func gatewayPort() -> Int {
let stored = UserDefaults.standard.integer(forKey: "gatewayPort")
return stored > 0 ? stored : 18789
@@ -90,6 +96,26 @@ enum GatewayEnvironment {
}
}
let expected = self.expectedGatewayVersion()
if let bundled = self.bundledGatewayExecutable() {
let installed = self.readGatewayVersion(binary: bundled)
if let expected, let installed, !installed.compatible(with: expected) {
return GatewayEnvironmentStatus(
kind: .incompatible(found: installed.description, required: expected.description),
nodeVersion: nil,
gatewayVersion: installed.description,
requiredGateway: expected.description,
message: "Bundled gateway \(installed.description) is incompatible with app \(expected.description); rebuild the app bundle.")
}
let gatewayVersionText = installed?.description ?? "unknown"
return GatewayEnvironmentStatus(
kind: .ok,
nodeVersion: nil,
gatewayVersion: gatewayVersionText,
requiredGateway: expected?.description,
message: "Bundled gateway \(gatewayVersionText) (bun)")
}
let projectRoot = CommandResolver.projectRoot()
let projectEntrypoint = CommandResolver.gatewayEntrypoint(in: projectRoot)
@@ -160,6 +186,7 @@ enum GatewayEnvironment {
let projectEntrypoint = CommandResolver.gatewayEntrypoint(in: projectRoot)
let status = self.check()
let gatewayBin = CommandResolver.clawdisExecutable()
let bundled = self.bundledGatewayExecutable()
let runtime = RuntimeLocator.resolve(searchPaths: CommandResolver.preferredPaths())
guard case .ok = status.kind else {
@@ -167,6 +194,10 @@ enum GatewayEnvironment {
}
let port = self.gatewayPort()
if let bundled {
let cmd = [bundled, "--port", "\(port)", "--bind", "loopback"]
return GatewayCommandResolution(status: status, command: cmd)
}
if let gatewayBin {
let cmd = [gatewayBin, "gateway", "--port", "\(port)"]
return GatewayCommandResolution(status: status, command: cmd)

View File

@@ -0,0 +1,114 @@
import Foundation
enum GatewayLaunchAgentManager {
private static var plistURL: URL {
FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent("Library/LaunchAgents/\(gatewayLaunchdLabel).plist")
}
private static func gatewayExecutablePath(bundlePath: String) -> String {
"\(bundlePath)/Contents/Resources/Relay/clawdis-gateway"
}
static func status() async -> Bool {
guard FileManager.default.fileExists(atPath: self.plistURL.path) else { return false }
let result = await self.runLaunchctl(["print", "gui/\(getuid())/\(gatewayLaunchdLabel)"])
return result.status == 0
}
static func set(enabled: Bool, bundlePath: String, port: Int) async -> String? {
if enabled {
let gatewayBin = self.gatewayExecutablePath(bundlePath: bundlePath)
guard FileManager.default.isExecutableFile(atPath: gatewayBin) else {
return "Embedded gateway missing in bundle; rebuild via scripts/package-mac-app.sh"
}
self.writePlist(bundlePath: bundlePath, port: port)
_ = await self.runLaunchctl(["bootout", "gui/\(getuid())/\(gatewayLaunchdLabel)"])
let bootstrap = await self.runLaunchctl(["bootstrap", "gui/\(getuid())", self.plistURL.path])
if bootstrap.status != 0 {
return bootstrap.output.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
? "Failed to bootstrap gateway launchd job"
: bootstrap.output.trimmingCharacters(in: .whitespacesAndNewlines)
}
_ = await self.runLaunchctl(["kickstart", "-k", "gui/\(getuid())/\(gatewayLaunchdLabel)"])
return nil
}
_ = await self.runLaunchctl(["bootout", "gui/\(getuid())/\(gatewayLaunchdLabel)"])
try? FileManager.default.removeItem(at: self.plistURL)
return nil
}
static func kickstart() async {
_ = await self.runLaunchctl(["kickstart", "-k", "gui/\(getuid())/\(gatewayLaunchdLabel)"])
}
private static func writePlist(bundlePath: String, port: Int) {
let gatewayBin = self.gatewayExecutablePath(bundlePath: bundlePath)
let plist = """
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>\(gatewayLaunchdLabel)</string>
<key>ProgramArguments</key>
<array>
<string>\(gatewayBin)</string>
<string>--port</string>
<string>\(port)</string>
<string>--bind</string>
<string>loopback</string>
</array>
<key>WorkingDirectory</key>
<string>\(FileManager.default.homeDirectoryForCurrentUser.path)</string>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
<key>EnvironmentVariables</key>
<dict>
<key>PATH</key>
<string>\(CommandResolver.preferredPaths().joined(separator: ":"))</string>
<key>CLAWDIS_SKIP_BROWSER_CONTROL_SERVER</key>
<string>1</string>
<key>CLAWDIS_IMAGE_BACKEND</key>
<string>sips</string>
</dict>
<key>StandardOutPath</key>
<string>\(LogLocator.launchdGatewayLogPath)</string>
<key>StandardErrorPath</key>
<string>\(LogLocator.launchdGatewayLogPath)</string>
</dict>
</plist>
"""
try? plist.write(to: self.plistURL, atomically: true, encoding: .utf8)
}
private struct LaunchctlResult {
let status: Int32
let output: String
}
@discardableResult
private static func runLaunchctl(_ args: [String]) async -> LaunchctlResult {
await Task.detached(priority: .utility) { () -> LaunchctlResult in
let process = Process()
process.launchPath = "/bin/launchctl"
process.arguments = args
let pipe = Pipe()
process.standardOutput = pipe
process.standardError = pipe
do {
try process.run()
process.waitUntilExit()
let data = pipe.fileHandleForReading.readToEndSafely()
let output = String(data: data, encoding: .utf8) ?? ""
return LaunchctlResult(status: process.terminationStatus, output: output)
} catch {
return LaunchctlResult(status: -1, output: error.localizedDescription)
}
}.value
}
}

View File

@@ -1,16 +1,5 @@
import Foundation
import Network
import Observation
import OSLog
import Subprocess
#if canImport(Darwin)
import Darwin
#endif
#if canImport(System)
import System
#else
import SystemPackage
#endif
@MainActor
@Observable
@@ -20,8 +9,7 @@ final class GatewayProcessManager {
enum Status: Equatable {
case stopped
case starting
case running(pid: Int32)
case restarting
case running(details: String?)
case attachedExisting(details: String?)
case failed(String)
@@ -29,8 +17,9 @@ final class GatewayProcessManager {
switch self {
case .stopped: return "Stopped"
case .starting: return "Starting…"
case let .running(pid): return "Running (pid \(pid))"
case .restarting: return "Restarting…"
case let .running(details):
if let details, !details.isEmpty { return "Running (\(details))" }
return "Running"
case let .attachedExisting(details):
if let details, !details.isEmpty {
return "Using existing gateway (\(details))"
@@ -43,41 +32,15 @@ final class GatewayProcessManager {
private(set) var status: Status = .stopped
private(set) var log: String = ""
private(set) var restartCount: Int = 0
private(set) var environmentStatus: GatewayEnvironmentStatus = .checking
private(set) var existingGatewayDetails: String?
private(set) var lastFailureReason: String?
private(set) var lastExitCode: Int32?
private(set) var lastSubprocessError: String?
private var execution: Execution?
private var lastPid: Int32?
private var lastCommand: [String]?
private var desiredActive = false
private var stopping = false
private var recentCrashes: [Date] = []
private var environmentRefreshTask: Task<Void, Never>?
private var lastEnvironmentRefresh: Date?
private var logRefreshTask: Task<Void, Never>?
private final class GatewayLockHandle {
private let fd: FileDescriptor
private let path: String
init(fd: FileDescriptor, path: String) {
self.fd = fd
self.path = path
}
func cancel() {
try? self.fd.close()
try? FileManager.default.removeItem(atPath: self.path)
}
}
private let logger = Logger(subsystem: "com.steipete.clawdis", category: "gateway")
private let logLimit = 20000 // characters to keep in-memory
private let maxCrashes = 3
private let crashWindow: TimeInterval = 120 // seconds
private let environmentRefreshMinInterval: TimeInterval = 30
func setActive(_ active: Bool) {
@@ -99,20 +62,13 @@ final class GatewayProcessManager {
}
func startIfNeeded() {
guard self.execution == nil, self.desiredActive else { return }
guard self.desiredActive else { return }
// Do not spawn in remote mode (the gateway should run on the remote host).
guard !CommandResolver.connectionModeIsRemote() else {
self.status = .stopped
return
}
if self.shouldGiveUpAfterCrashes() {
self.status = .failed("Too many crashes; giving up")
return
}
if self.status != .restarting {
self.status = .starting
}
self.status = .starting
// First try to latch onto an already-running gateway to avoid spawning a duplicate.
Task { [weak self] in
@@ -128,26 +84,17 @@ final class GatewayProcessManager {
}
return
}
await self.spawnGateway()
await self.enableLaunchdGateway()
}
}
func stop() {
self.desiredActive = false
self.stopping = true
self.existingGatewayDetails = nil
self.lastFailureReason = nil
self.lastExitCode = nil
self.lastSubprocessError = nil
guard let execution else {
self.status = .stopped
return
}
self.status = .stopped
Task {
await execution.teardown(using: [.gracefulShutDown(allowedDurationToNextStep: .seconds(1))])
}
self.execution = nil
let bundlePath = Bundle.main.bundleURL.path
Task { _ = await GatewayLaunchAgentManager.set(enabled: false, bundlePath: bundlePath, port: GatewayEnvironment.gatewayPort()) }
}
func refreshEnvironmentStatus(force: Bool = false) {
@@ -173,6 +120,24 @@ final class GatewayProcessManager {
}
}
func refreshLog() {
guard self.logRefreshTask == nil else { return }
let path = LogLocator.launchdGatewayLogPath
let limit = self.logLimit
self.logRefreshTask = Task { [weak self] in
let log = await Task.detached(priority: .utility) {
Self.readGatewayLog(path: path, limit: limit)
}.value
await MainActor.run {
guard let self else { return }
if !log.isEmpty {
self.log = log
}
self.logRefreshTask = nil
}
}
}
// MARK: - Internals
/// Attempt to connect to an already-running gateway on the configured port.
@@ -204,6 +169,7 @@ final class GatewayProcessManager {
self.existingGatewayDetails = details
self.status = .attachedExisting(details: details)
self.appendLog("[gateway] using existing instance: \(details)\n")
self.refreshLog()
return true
} catch {
// No reachable gateway (or token mismatch) fall through to spawn.
@@ -212,162 +178,47 @@ final class GatewayProcessManager {
}
}
private func spawnGateway() async {
if self.status != .restarting {
self.status = .starting
}
private func enableLaunchdGateway() async {
self.existingGatewayDetails = nil
let resolution = await Task.detached(priority: .utility) {
GatewayEnvironment.resolveGatewayCommand()
}.value
await MainActor.run { self.environmentStatus = resolution.status }
guard let command = resolution.command else {
guard resolution.command != nil else {
await MainActor.run {
self.status = .failed(resolution.status.message)
}
return
}
let cwd = self.defaultProjectRoot().path
self.appendLog("[gateway] starting: \(command.joined(separator: " ")) (cwd: \(cwd))\n")
self.lastCommand = command
do {
// Acquire the same UDS lock the CLI uses to guarantee a single instance.
let lockPath = FileManager.default.temporaryDirectory.appendingPathComponent("clawdis-gateway.lock").path
let listener = try self.acquireGatewayLock(path: lockPath)
let result = try await run(
.name(command.first ?? "clawdis"),
arguments: Arguments(Array(command.dropFirst())),
environment: self.makeEnvironment(),
workingDirectory: FilePath(cwd))
{ execution, stdin, stdout, stderr in
self.didStart(execution)
// Consume stdout/stderr eagerly so the gateway can't block on full pipes.
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
}
// Release the lock after the process exits.
listener.cancel()
await self.handleTermination(status: result.terminationStatus)
} catch {
await self.handleError(error)
}
}
/// Minimal clone of the Node gateway lock: take an exclusive file lock.
private func acquireGatewayLock(path: String) throws -> GatewayLockHandle {
// Remove stale lock if needed (mirrors CLI behavior).
try? FileManager.default.removeItem(atPath: path)
let fd = try FileDescriptor.open(
FilePath(path),
.readWrite,
options: [.create, .exclusiveCreate],
permissions: [.ownerReadWrite])
return GatewayLockHandle(fd: fd, path: path)
}
private func didStart(_ execution: Execution) {
self.execution = execution
self.stopping = false
self.lastFailureReason = nil
self.lastExitCode = nil
self.lastSubprocessError = nil
self.status = .running(pid: execution.processIdentifier.value)
self.lastPid = execution.processIdentifier.value
self.logger.info("gateway started pid \(execution.processIdentifier.value)")
Task {
await PortGuardian.shared.record(
port: GatewayEnvironment.gatewayPort(),
pid: execution.processIdentifier.value,
command: (self.lastCommand ?? []).joined(separator: " "),
mode: AppStateStore.shared.connectionMode)
}
}
private func handleTermination(status: TerminationStatus) async {
let code: Int32 = switch status {
case let .exited(exitCode): exitCode
case let .unhandledException(sig): -Int32(sig)
}
self.execution = nil
if let pid = self.lastPid {
Task { await PortGuardian.shared.removeRecord(pid: pid) }
}
self.lastPid = nil
self.lastCommand = nil
if self.stopping || !self.desiredActive {
self.status = .stopped
self.stopping = false
if let pid = self.lastPid {
Task { await PortGuardian.shared.removeRecord(pid: pid) }
}
let bundlePath = Bundle.main.bundleURL.path
let port = GatewayEnvironment.gatewayPort()
self.appendLog("[gateway] enabling launchd job (\(gatewayLaunchdLabel)) on port \(port)\n")
let err = await GatewayLaunchAgentManager.set(enabled: true, bundlePath: bundlePath, port: port)
if let err {
self.status = .failed(err)
self.lastFailureReason = err
return
}
self.lastExitCode = code
self.lastFailureReason = "Gateway exited (code \(code))."
self.recentCrashes.append(Date())
self.recentCrashes = self.recentCrashes.filter { Date().timeIntervalSince($0) < self.crashWindow }
self.restartCount += 1
self.appendLog("[gateway] exited (\(code)).\n")
if self.shouldGiveUpAfterCrashes() {
self.status = .failed("Too many crashes; last exit code \(code).")
self.logger.error("gateway crash loop detected; giving up")
return
}
self.status = .restarting
self.logger.warning("gateway crashed (code \(code)); restarting")
// Slight backoff to avoid hammering the system in case of immediate crash-on-start.
try? await Task.sleep(nanoseconds: 750_000_000)
self.startIfNeeded()
}
private func handleError(_ error: any Error) async {
self.execution = nil
var message = error.localizedDescription
if let sp = error as? SubprocessError {
message = "SubprocessError \(sp.code.value): \(sp)"
self.lastSubprocessError = message
}
self.lastFailureReason = message
self.appendLog("[gateway] failed: \(message)\n")
self.logger.error("gateway failed: \(message, 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("[gateway \(label)] stream error: \(error.localizedDescription)\n")
// Best-effort: wait for the gateway to accept connections.
let deadline = Date().addingTimeInterval(6)
while Date() < deadline {
if !self.desiredActive { return }
do {
_ = try await GatewayConnection.shared.requestRaw(method: .health, timeoutMs: 1500)
let instance = await PortGuardian.shared.describe(port: port)
let details = instance.map { "pid \($0.pid)" }
self.status = .running(details: details)
self.refreshLog()
return
} catch {
try? await Task.sleep(nanoseconds: 400_000_000)
}
}
self.status = .failed("Gateway did not start in time")
self.lastFailureReason = "launchd start timeout"
}
private func appendLog(_ chunk: String) {
@@ -379,20 +230,7 @@ final class GatewayProcessManager {
func clearLog() {
self.log = ""
}
private func makeEnvironment() -> Environment {
let merged = CommandResolver.preferredPaths().joined(separator: ":")
return .inherit.updating([
"PATH": merged,
"PNPM_HOME": FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent("Library/pnpm").path,
"CLAWDIS_PROJECT_ROOT": CommandResolver.projectRoot().path,
])
}
private func defaultProjectRoot() -> URL {
CommandResolver.projectRoot()
try? FileManager.default.removeItem(atPath: LogLocator.launchdGatewayLogPath)
}
func setProjectRoot(path: String) {
@@ -402,4 +240,12 @@ final class GatewayProcessManager {
func projectRootPath() -> String {
CommandResolver.projectRootPath()
}
private static func readGatewayLog(path: String, limit: Int) -> String {
guard FileManager.default.fileExists(atPath: path) else { return "" }
guard let data = try? Data(contentsOf: URL(fileURLWithPath: path)) else { return "" }
let text = String(data: data, encoding: .utf8) ?? ""
if text.count <= limit { return text }
return String(text.suffix(limit))
}
}

View File

@@ -339,12 +339,6 @@ struct GeneralSettings: View {
.foregroundStyle(.red)
}
if let exitCode = self.gatewayManager.lastExitCode {
Text("Last exit code: \(exitCode)")
.font(.caption2)
.foregroundStyle(.secondary)
}
HStack(spacing: 10) {
Button {
Task { await self.installGateway() }
@@ -352,7 +346,7 @@ struct GeneralSettings: View {
if self.gatewayInstalling {
ProgressView().controlSize(.small)
} else {
Text("Install/Update gateway")
Text("Enable Gateway daemon")
}
}
.buttonStyle(.borderedProminent)
@@ -365,7 +359,7 @@ struct GeneralSettings: View {
Text(self
.gatewayInstallMessage ??
"Installs the global \"clawdis\" package and expects the gateway on port 18789.")
"Enables the bundled Gateway via launchd (\(gatewayLaunchdLabel)). No Node install required.")
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(2)
@@ -407,10 +401,10 @@ struct GeneralSettings: View {
self.gatewayInstalling = true
defer { self.gatewayInstalling = false }
self.gatewayInstallMessage = nil
let expected = GatewayEnvironment.expectedGatewayVersion()
await GatewayEnvironment.installGlobal(version: expected) { message in
Task { @MainActor in self.gatewayInstallMessage = message }
}
let port = GatewayEnvironment.gatewayPort()
let bundlePath = Bundle.main.bundleURL.path
let err = await GatewayLaunchAgentManager.set(enabled: true, bundlePath: bundlePath, port: port)
self.gatewayInstallMessage = err ?? "Gateway enabled and started on port \(port)"
self.refreshGatewayStatus()
}

View File

@@ -3,6 +3,7 @@ import Foundation
enum LogLocator {
private static let logDir = URL(fileURLWithPath: "/tmp/clawdis")
private static let stdoutLog = logDir.appendingPathComponent("clawdis-stdout.log")
private static let gatewayLog = logDir.appendingPathComponent("clawdis-gateway.log")
private static func modificationDate(for url: URL) -> Date {
(try? url.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate) ?? .distantPast
@@ -27,4 +28,9 @@ enum LogLocator {
static var launchdLogPath: String {
stdoutLog.path
}
/// Path to use for the embedded Gateway launchd job stdout/err.
static var launchdGatewayLogPath: String {
gatewayLog.path
}
}

View File

@@ -240,7 +240,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
}
func applicationWillTerminate(_ notification: Notification) {
GatewayProcessManager.shared.stop()
PresenceReporter.shared.stop()
NodePairingApprovalPrompter.shared.stop()
MacNodeModeCoordinator.shared.stop()