From a4d4a30a6b7dd684b04d5289a04e48e7d6caf718 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 19 Dec 2025 19:20:52 +0100 Subject: [PATCH] feat(macos): run bundled gateway via launchd --- apps/macos/Sources/Clawdis/Constants.swift | 3 +- .../macos/Sources/Clawdis/DebugSettings.swift | 3 - .../Sources/Clawdis/GatewayEnvironment.swift | 31 ++ .../Clawdis/GatewayLaunchAgentManager.swift | 114 +++++++ .../Clawdis/GatewayProcessManager.swift | 280 ++++-------------- .../Sources/Clawdis/GeneralSettings.swift | 18 +- apps/macos/Sources/Clawdis/LogLocator.swift | 6 + apps/macos/Sources/Clawdis/MenuBar.swift | 1 - 8 files changed, 222 insertions(+), 234 deletions(-) create mode 100644 apps/macos/Sources/Clawdis/GatewayLaunchAgentManager.swift diff --git a/apps/macos/Sources/Clawdis/Constants.swift b/apps/macos/Sources/Clawdis/Constants.swift index e29a98ee3..37ee7480a 100644 --- a/apps/macos/Sources/Clawdis/Constants.swift +++ b/apps/macos/Sources/Clawdis/Constants.swift @@ -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" diff --git a/apps/macos/Sources/Clawdis/DebugSettings.swift b/apps/macos/Sources/Clawdis/DebugSettings.swift index c886c6cee..efa209aad 100644 --- a/apps/macos/Sources/Clawdis/DebugSettings.swift +++ b/apps/macos/Sources/Clawdis/DebugSettings.swift @@ -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) } diff --git a/apps/macos/Sources/Clawdis/GatewayEnvironment.swift b/apps/macos/Sources/Clawdis/GatewayEnvironment.swift index 1306a625d..ef6cdf0cd 100644 --- a/apps/macos/Sources/Clawdis/GatewayEnvironment.swift +++ b/apps/macos/Sources/Clawdis/GatewayEnvironment.swift @@ -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) diff --git a/apps/macos/Sources/Clawdis/GatewayLaunchAgentManager.swift b/apps/macos/Sources/Clawdis/GatewayLaunchAgentManager.swift new file mode 100644 index 000000000..160404b8f --- /dev/null +++ b/apps/macos/Sources/Clawdis/GatewayLaunchAgentManager.swift @@ -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 = """ + + + + + Label + \(gatewayLaunchdLabel) + ProgramArguments + + \(gatewayBin) + --port + \(port) + --bind + loopback + + WorkingDirectory + \(FileManager.default.homeDirectoryForCurrentUser.path) + RunAtLoad + + KeepAlive + + EnvironmentVariables + + PATH + \(CommandResolver.preferredPaths().joined(separator: ":")) + CLAWDIS_SKIP_BROWSER_CONTROL_SERVER + 1 + CLAWDIS_IMAGE_BACKEND + sips + + StandardOutPath + \(LogLocator.launchdGatewayLogPath) + StandardErrorPath + \(LogLocator.launchdGatewayLogPath) + + + """ + 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 + } +} + diff --git a/apps/macos/Sources/Clawdis/GatewayProcessManager.swift b/apps/macos/Sources/Clawdis/GatewayProcessManager.swift index e5856664d..41b6bc96e 100644 --- a/apps/macos/Sources/Clawdis/GatewayProcessManager.swift +++ b/apps/macos/Sources/Clawdis/GatewayProcessManager.swift @@ -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? private var lastEnvironmentRefresh: Date? + private var logRefreshTask: Task? - 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)) + } } diff --git a/apps/macos/Sources/Clawdis/GeneralSettings.swift b/apps/macos/Sources/Clawdis/GeneralSettings.swift index 391d62e49..9b0a5aee0 100644 --- a/apps/macos/Sources/Clawdis/GeneralSettings.swift +++ b/apps/macos/Sources/Clawdis/GeneralSettings.swift @@ -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() } diff --git a/apps/macos/Sources/Clawdis/LogLocator.swift b/apps/macos/Sources/Clawdis/LogLocator.swift index 75ebb6e76..35306f969 100644 --- a/apps/macos/Sources/Clawdis/LogLocator.swift +++ b/apps/macos/Sources/Clawdis/LogLocator.swift @@ -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 + } } diff --git a/apps/macos/Sources/Clawdis/MenuBar.swift b/apps/macos/Sources/Clawdis/MenuBar.swift index 14b2eca4c..0ccb1ce34 100644 --- a/apps/macos/Sources/Clawdis/MenuBar.swift +++ b/apps/macos/Sources/Clawdis/MenuBar.swift @@ -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()