feat(macos): run bundled gateway via launchd
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
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 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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user