feat(macos): run bundled gateway via launchd
This commit is contained in:
@@ -1,8 +1,9 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
let launchdLabel = "com.steipete.clawdis"
|
let launchdLabel = "com.steipete.clawdis"
|
||||||
|
let gatewayLaunchdLabel = "com.steipete.clawdis.gateway"
|
||||||
let onboardingVersionKey = "clawdis.onboardingVersion"
|
let onboardingVersionKey = "clawdis.onboardingVersion"
|
||||||
let currentOnboardingVersion = 6
|
let currentOnboardingVersion = 7
|
||||||
let pauseDefaultsKey = "clawdis.pauseEnabled"
|
let pauseDefaultsKey = "clawdis.pauseEnabled"
|
||||||
let iconAnimationsEnabledKey = "clawdis.iconAnimationsEnabled"
|
let iconAnimationsEnabledKey = "clawdis.iconAnimationsEnabled"
|
||||||
let swabbleEnabledKey = "clawdis.swabbleEnabled"
|
let swabbleEnabledKey = "clawdis.swabbleEnabled"
|
||||||
|
|||||||
@@ -133,9 +133,6 @@ struct DebugSettings: View {
|
|||||||
self.gridLabel("Status")
|
self.gridLabel("Status")
|
||||||
HStack(spacing: 8) {
|
HStack(spacing: 8) {
|
||||||
Text(self.gatewayManager.status.label)
|
Text(self.gatewayManager.status.label)
|
||||||
Text("Restarts: \(self.gatewayManager.restartCount)")
|
|
||||||
.font(.caption2)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,6 +64,12 @@ struct GatewayCommandResolution {
|
|||||||
enum GatewayEnvironment {
|
enum GatewayEnvironment {
|
||||||
private static let logger = Logger(subsystem: "com.steipete.clawdis", category: "gateway.env")
|
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 {
|
static func gatewayPort() -> Int {
|
||||||
let stored = UserDefaults.standard.integer(forKey: "gatewayPort")
|
let stored = UserDefaults.standard.integer(forKey: "gatewayPort")
|
||||||
return stored > 0 ? stored : 18789
|
return stored > 0 ? stored : 18789
|
||||||
@@ -90,6 +96,26 @@ enum GatewayEnvironment {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
let expected = self.expectedGatewayVersion()
|
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 projectRoot = CommandResolver.projectRoot()
|
||||||
let projectEntrypoint = CommandResolver.gatewayEntrypoint(in: projectRoot)
|
let projectEntrypoint = CommandResolver.gatewayEntrypoint(in: projectRoot)
|
||||||
|
|
||||||
@@ -160,6 +186,7 @@ enum GatewayEnvironment {
|
|||||||
let projectEntrypoint = CommandResolver.gatewayEntrypoint(in: projectRoot)
|
let projectEntrypoint = CommandResolver.gatewayEntrypoint(in: projectRoot)
|
||||||
let status = self.check()
|
let status = self.check()
|
||||||
let gatewayBin = CommandResolver.clawdisExecutable()
|
let gatewayBin = CommandResolver.clawdisExecutable()
|
||||||
|
let bundled = self.bundledGatewayExecutable()
|
||||||
let runtime = RuntimeLocator.resolve(searchPaths: CommandResolver.preferredPaths())
|
let runtime = RuntimeLocator.resolve(searchPaths: CommandResolver.preferredPaths())
|
||||||
|
|
||||||
guard case .ok = status.kind else {
|
guard case .ok = status.kind else {
|
||||||
@@ -167,6 +194,10 @@ enum GatewayEnvironment {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let port = self.gatewayPort()
|
let port = self.gatewayPort()
|
||||||
|
if let bundled {
|
||||||
|
let cmd = [bundled, "--port", "\(port)", "--bind", "loopback"]
|
||||||
|
return GatewayCommandResolution(status: status, command: cmd)
|
||||||
|
}
|
||||||
if let gatewayBin {
|
if let gatewayBin {
|
||||||
let cmd = [gatewayBin, "gateway", "--port", "\(port)"]
|
let cmd = [gatewayBin, "gateway", "--port", "\(port)"]
|
||||||
return GatewayCommandResolution(status: status, command: cmd)
|
return GatewayCommandResolution(status: status, command: cmd)
|
||||||
|
|||||||
114
apps/macos/Sources/Clawdis/GatewayLaunchAgentManager.swift
Normal file
114
apps/macos/Sources/Clawdis/GatewayLaunchAgentManager.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,16 +1,5 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import Network
|
|
||||||
import Observation
|
import Observation
|
||||||
import OSLog
|
|
||||||
import Subprocess
|
|
||||||
#if canImport(Darwin)
|
|
||||||
import Darwin
|
|
||||||
#endif
|
|
||||||
#if canImport(System)
|
|
||||||
import System
|
|
||||||
#else
|
|
||||||
import SystemPackage
|
|
||||||
#endif
|
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
@Observable
|
@Observable
|
||||||
@@ -20,8 +9,7 @@ final class GatewayProcessManager {
|
|||||||
enum Status: Equatable {
|
enum Status: Equatable {
|
||||||
case stopped
|
case stopped
|
||||||
case starting
|
case starting
|
||||||
case running(pid: Int32)
|
case running(details: String?)
|
||||||
case restarting
|
|
||||||
case attachedExisting(details: String?)
|
case attachedExisting(details: String?)
|
||||||
case failed(String)
|
case failed(String)
|
||||||
|
|
||||||
@@ -29,8 +17,9 @@ final class GatewayProcessManager {
|
|||||||
switch self {
|
switch self {
|
||||||
case .stopped: return "Stopped"
|
case .stopped: return "Stopped"
|
||||||
case .starting: return "Starting…"
|
case .starting: return "Starting…"
|
||||||
case let .running(pid): return "Running (pid \(pid))"
|
case let .running(details):
|
||||||
case .restarting: return "Restarting…"
|
if let details, !details.isEmpty { return "Running (\(details))" }
|
||||||
|
return "Running"
|
||||||
case let .attachedExisting(details):
|
case let .attachedExisting(details):
|
||||||
if let details, !details.isEmpty {
|
if let details, !details.isEmpty {
|
||||||
return "Using existing gateway (\(details))"
|
return "Using existing gateway (\(details))"
|
||||||
@@ -43,41 +32,15 @@ final class GatewayProcessManager {
|
|||||||
|
|
||||||
private(set) var status: Status = .stopped
|
private(set) var status: Status = .stopped
|
||||||
private(set) var log: String = ""
|
private(set) var log: String = ""
|
||||||
private(set) var restartCount: Int = 0
|
|
||||||
private(set) var environmentStatus: GatewayEnvironmentStatus = .checking
|
private(set) var environmentStatus: GatewayEnvironmentStatus = .checking
|
||||||
private(set) var existingGatewayDetails: String?
|
private(set) var existingGatewayDetails: String?
|
||||||
private(set) var lastFailureReason: 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 desiredActive = false
|
||||||
private var stopping = false
|
|
||||||
private var recentCrashes: [Date] = []
|
|
||||||
private var environmentRefreshTask: Task<Void, Never>?
|
private var environmentRefreshTask: Task<Void, Never>?
|
||||||
private var lastEnvironmentRefresh: Date?
|
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 logLimit = 20000 // characters to keep in-memory
|
||||||
private let maxCrashes = 3
|
|
||||||
private let crashWindow: TimeInterval = 120 // seconds
|
|
||||||
private let environmentRefreshMinInterval: TimeInterval = 30
|
private let environmentRefreshMinInterval: TimeInterval = 30
|
||||||
|
|
||||||
func setActive(_ active: Bool) {
|
func setActive(_ active: Bool) {
|
||||||
@@ -99,20 +62,13 @@ final class GatewayProcessManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func startIfNeeded() {
|
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).
|
// Do not spawn in remote mode (the gateway should run on the remote host).
|
||||||
guard !CommandResolver.connectionModeIsRemote() else {
|
guard !CommandResolver.connectionModeIsRemote() else {
|
||||||
self.status = .stopped
|
self.status = .stopped
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if self.shouldGiveUpAfterCrashes() {
|
self.status = .starting
|
||||||
self.status = .failed("Too many crashes; giving up")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if self.status != .restarting {
|
|
||||||
self.status = .starting
|
|
||||||
}
|
|
||||||
|
|
||||||
// First try to latch onto an already-running gateway to avoid spawning a duplicate.
|
// First try to latch onto an already-running gateway to avoid spawning a duplicate.
|
||||||
Task { [weak self] in
|
Task { [weak self] in
|
||||||
@@ -128,26 +84,17 @@ final class GatewayProcessManager {
|
|||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
await self.spawnGateway()
|
await self.enableLaunchdGateway()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func stop() {
|
func stop() {
|
||||||
self.desiredActive = false
|
self.desiredActive = false
|
||||||
self.stopping = true
|
|
||||||
self.existingGatewayDetails = nil
|
self.existingGatewayDetails = nil
|
||||||
self.lastFailureReason = nil
|
self.lastFailureReason = nil
|
||||||
self.lastExitCode = nil
|
|
||||||
self.lastSubprocessError = nil
|
|
||||||
guard let execution else {
|
|
||||||
self.status = .stopped
|
|
||||||
return
|
|
||||||
}
|
|
||||||
self.status = .stopped
|
self.status = .stopped
|
||||||
Task {
|
let bundlePath = Bundle.main.bundleURL.path
|
||||||
await execution.teardown(using: [.gracefulShutDown(allowedDurationToNextStep: .seconds(1))])
|
Task { _ = await GatewayLaunchAgentManager.set(enabled: false, bundlePath: bundlePath, port: GatewayEnvironment.gatewayPort()) }
|
||||||
}
|
|
||||||
self.execution = nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func refreshEnvironmentStatus(force: Bool = false) {
|
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
|
// MARK: - Internals
|
||||||
|
|
||||||
/// Attempt to connect to an already-running gateway on the configured port.
|
/// Attempt to connect to an already-running gateway on the configured port.
|
||||||
@@ -204,6 +169,7 @@ final class GatewayProcessManager {
|
|||||||
self.existingGatewayDetails = details
|
self.existingGatewayDetails = details
|
||||||
self.status = .attachedExisting(details: details)
|
self.status = .attachedExisting(details: details)
|
||||||
self.appendLog("[gateway] using existing instance: \(details)\n")
|
self.appendLog("[gateway] using existing instance: \(details)\n")
|
||||||
|
self.refreshLog()
|
||||||
return true
|
return true
|
||||||
} catch {
|
} catch {
|
||||||
// No reachable gateway (or token mismatch) — fall through to spawn.
|
// No reachable gateway (or token mismatch) — fall through to spawn.
|
||||||
@@ -212,162 +178,47 @@ final class GatewayProcessManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func spawnGateway() async {
|
private func enableLaunchdGateway() async {
|
||||||
if self.status != .restarting {
|
|
||||||
self.status = .starting
|
|
||||||
}
|
|
||||||
self.existingGatewayDetails = nil
|
self.existingGatewayDetails = nil
|
||||||
let resolution = await Task.detached(priority: .utility) {
|
let resolution = await Task.detached(priority: .utility) {
|
||||||
GatewayEnvironment.resolveGatewayCommand()
|
GatewayEnvironment.resolveGatewayCommand()
|
||||||
}.value
|
}.value
|
||||||
await MainActor.run { self.environmentStatus = resolution.status }
|
await MainActor.run { self.environmentStatus = resolution.status }
|
||||||
guard let command = resolution.command else {
|
guard resolution.command != nil else {
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
self.status = .failed(resolution.status.message)
|
self.status = .failed(resolution.status.message)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
let cwd = self.defaultProjectRoot().path
|
let bundlePath = Bundle.main.bundleURL.path
|
||||||
self.appendLog("[gateway] starting: \(command.joined(separator: " ")) (cwd: \(cwd))\n")
|
let port = GatewayEnvironment.gatewayPort()
|
||||||
self.lastCommand = command
|
self.appendLog("[gateway] enabling launchd job (\(gatewayLaunchdLabel)) on port \(port)\n")
|
||||||
|
let err = await GatewayLaunchAgentManager.set(enabled: true, bundlePath: bundlePath, port: port)
|
||||||
do {
|
if let err {
|
||||||
// Acquire the same UDS lock the CLI uses to guarantee a single instance.
|
self.status = .failed(err)
|
||||||
let lockPath = FileManager.default.temporaryDirectory.appendingPathComponent("clawdis-gateway.lock").path
|
self.lastFailureReason = err
|
||||||
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) }
|
|
||||||
}
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
self.lastExitCode = code
|
// Best-effort: wait for the gateway to accept connections.
|
||||||
self.lastFailureReason = "Gateway exited (code \(code))."
|
let deadline = Date().addingTimeInterval(6)
|
||||||
self.recentCrashes.append(Date())
|
while Date() < deadline {
|
||||||
self.recentCrashes = self.recentCrashes.filter { Date().timeIntervalSince($0) < self.crashWindow }
|
if !self.desiredActive { return }
|
||||||
self.restartCount += 1
|
do {
|
||||||
self.appendLog("[gateway] exited (\(code)).\n")
|
_ = try await GatewayConnection.shared.requestRaw(method: .health, timeoutMs: 1500)
|
||||||
|
let instance = await PortGuardian.shared.describe(port: port)
|
||||||
if self.shouldGiveUpAfterCrashes() {
|
let details = instance.map { "pid \($0.pid)" }
|
||||||
self.status = .failed("Too many crashes; last exit code \(code).")
|
self.status = .running(details: details)
|
||||||
self.logger.error("gateway crash loop detected; giving up")
|
self.refreshLog()
|
||||||
return
|
return
|
||||||
}
|
} catch {
|
||||||
|
try? await Task.sleep(nanoseconds: 400_000_000)
|
||||||
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")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
self.status = .failed("Gateway did not start in time")
|
||||||
|
self.lastFailureReason = "launchd start timeout"
|
||||||
}
|
}
|
||||||
|
|
||||||
private func appendLog(_ chunk: String) {
|
private func appendLog(_ chunk: String) {
|
||||||
@@ -379,20 +230,7 @@ final class GatewayProcessManager {
|
|||||||
|
|
||||||
func clearLog() {
|
func clearLog() {
|
||||||
self.log = ""
|
self.log = ""
|
||||||
}
|
try? FileManager.default.removeItem(atPath: LogLocator.launchdGatewayLogPath)
|
||||||
|
|
||||||
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()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func setProjectRoot(path: String) {
|
func setProjectRoot(path: String) {
|
||||||
@@ -402,4 +240,12 @@ final class GatewayProcessManager {
|
|||||||
func projectRootPath() -> String {
|
func projectRootPath() -> String {
|
||||||
CommandResolver.projectRootPath()
|
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))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -339,12 +339,6 @@ struct GeneralSettings: View {
|
|||||||
.foregroundStyle(.red)
|
.foregroundStyle(.red)
|
||||||
}
|
}
|
||||||
|
|
||||||
if let exitCode = self.gatewayManager.lastExitCode {
|
|
||||||
Text("Last exit code: \(exitCode)")
|
|
||||||
.font(.caption2)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
}
|
|
||||||
|
|
||||||
HStack(spacing: 10) {
|
HStack(spacing: 10) {
|
||||||
Button {
|
Button {
|
||||||
Task { await self.installGateway() }
|
Task { await self.installGateway() }
|
||||||
@@ -352,7 +346,7 @@ struct GeneralSettings: View {
|
|||||||
if self.gatewayInstalling {
|
if self.gatewayInstalling {
|
||||||
ProgressView().controlSize(.small)
|
ProgressView().controlSize(.small)
|
||||||
} else {
|
} else {
|
||||||
Text("Install/Update gateway")
|
Text("Enable Gateway daemon")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.buttonStyle(.borderedProminent)
|
.buttonStyle(.borderedProminent)
|
||||||
@@ -365,7 +359,7 @@ struct GeneralSettings: View {
|
|||||||
|
|
||||||
Text(self
|
Text(self
|
||||||
.gatewayInstallMessage ??
|
.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)
|
.font(.caption)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
.lineLimit(2)
|
.lineLimit(2)
|
||||||
@@ -407,10 +401,10 @@ struct GeneralSettings: View {
|
|||||||
self.gatewayInstalling = true
|
self.gatewayInstalling = true
|
||||||
defer { self.gatewayInstalling = false }
|
defer { self.gatewayInstalling = false }
|
||||||
self.gatewayInstallMessage = nil
|
self.gatewayInstallMessage = nil
|
||||||
let expected = GatewayEnvironment.expectedGatewayVersion()
|
let port = GatewayEnvironment.gatewayPort()
|
||||||
await GatewayEnvironment.installGlobal(version: expected) { message in
|
let bundlePath = Bundle.main.bundleURL.path
|
||||||
Task { @MainActor in self.gatewayInstallMessage = message }
|
let err = await GatewayLaunchAgentManager.set(enabled: true, bundlePath: bundlePath, port: port)
|
||||||
}
|
self.gatewayInstallMessage = err ?? "Gateway enabled and started on port \(port)"
|
||||||
self.refreshGatewayStatus()
|
self.refreshGatewayStatus()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import Foundation
|
|||||||
enum LogLocator {
|
enum LogLocator {
|
||||||
private static let logDir = URL(fileURLWithPath: "/tmp/clawdis")
|
private static let logDir = URL(fileURLWithPath: "/tmp/clawdis")
|
||||||
private static let stdoutLog = logDir.appendingPathComponent("clawdis-stdout.log")
|
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 {
|
private static func modificationDate(for url: URL) -> Date {
|
||||||
(try? url.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate) ?? .distantPast
|
(try? url.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate) ?? .distantPast
|
||||||
@@ -27,4 +28,9 @@ enum LogLocator {
|
|||||||
static var launchdLogPath: String {
|
static var launchdLogPath: String {
|
||||||
stdoutLog.path
|
stdoutLog.path
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Path to use for the embedded Gateway launchd job stdout/err.
|
||||||
|
static var launchdGatewayLogPath: String {
|
||||||
|
gatewayLog.path
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -240,7 +240,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func applicationWillTerminate(_ notification: Notification) {
|
func applicationWillTerminate(_ notification: Notification) {
|
||||||
GatewayProcessManager.shared.stop()
|
|
||||||
PresenceReporter.shared.stop()
|
PresenceReporter.shared.stop()
|
||||||
NodePairingApprovalPrompter.shared.stop()
|
NodePairingApprovalPrompter.shared.stop()
|
||||||
MacNodeModeCoordinator.shared.stop()
|
MacNodeModeCoordinator.shared.stop()
|
||||||
|
|||||||
Reference in New Issue
Block a user