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 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"

View File

@@ -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)
} }

View File

@@ -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)

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 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))
}
} }

View File

@@ -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()
} }

View File

@@ -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
}
} }

View File

@@ -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()