import Foundation import Observation @MainActor @Observable final class GatewayProcessManager { static let shared = GatewayProcessManager() enum Status: Equatable { case stopped case starting case running(details: String?) case attachedExisting(details: String?) case failed(String) var label: String { switch self { case .stopped: return "Stopped" case .starting: return "Starting…" 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))" } return "Using existing gateway" case let .failed(reason): return "Failed: \(reason)" } } } private(set) var status: Status = .stopped private(set) var log: String = "" private(set) var environmentStatus: GatewayEnvironmentStatus = .checking private(set) var existingGatewayDetails: String? private(set) var lastFailureReason: String? private var desiredActive = false private var environmentRefreshTask: Task? private var lastEnvironmentRefresh: Date? private var logRefreshTask: Task? private let logLimit = 20000 // characters to keep in-memory private let environmentRefreshMinInterval: TimeInterval = 30 func setActive(_ active: Bool) { // Remote mode should never spawn a local gateway; treat as stopped. if CommandResolver.connectionModeIsRemote() { self.desiredActive = false self.stop() self.status = .stopped self.appendLog("[gateway] remote mode active; skipping local gateway\n") return } self.desiredActive = active self.refreshEnvironmentStatus() if active { self.startIfNeeded() } else { self.stop() } } func startIfNeeded() { 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 } self.status = .starting // First try to latch onto an already-running gateway to avoid spawning a duplicate. Task { [weak self] in guard let self else { return } if await self.attachExistingGatewayIfAvailable() { return } // Respect debug toggle: only attach, never spawn, when enabled. if AppStateStore.attachExistingGatewayOnly { await MainActor.run { self.status = .failed("Attach-only enabled; no gateway to attach") self.appendLog("[gateway] attach-only enabled; not spawning local gateway\n") } return } await self.enableLaunchdGateway() } } func stop() { self.desiredActive = false self.existingGatewayDetails = nil self.lastFailureReason = nil self.status = .stopped let bundlePath = Bundle.main.bundleURL.path Task { _ = await GatewayLaunchAgentManager.set( enabled: false, bundlePath: bundlePath, port: GatewayEnvironment.gatewayPort()) } } func refreshEnvironmentStatus(force: Bool = false) { let now = Date() if !force { if self.environmentRefreshTask != nil { return } if let last = self.lastEnvironmentRefresh, now.timeIntervalSince(last) < self.environmentRefreshMinInterval { return } } self.lastEnvironmentRefresh = now self.environmentRefreshTask = Task { [weak self] in let status = await Task.detached(priority: .utility) { GatewayEnvironment.check() }.value await MainActor.run { guard let self else { return } self.environmentStatus = status self.environmentRefreshTask = nil } } } 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. /// If successful, mark status as attached and skip spawning a new process. private func attachExistingGatewayIfAvailable() async -> Bool { let port = GatewayEnvironment.gatewayPort() do { let data = try await GatewayConnection.shared.requestRaw(method: .health, timeoutMs: 2000) let snap = decodeHealthSnapshot(from: data) let instance = await PortGuardian.shared.describe(port: port) let instanceText: String if let instance { let path = instance.executablePath ?? "path unknown" instanceText = "pid \(instance.pid) \(instance.command) @ \(path)" } else { instanceText = "pid unknown" } let details: String if let snap { let linked = snap.web.linked ? "linked" : "not linked" let authAge = snap.web.authAgeMs.flatMap(msToAge) ?? "unknown age" details = "port \(port), \(linked), auth \(authAge), \(instanceText)" } else { details = "port \(port), health probe succeeded, \(instanceText)" } 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. self.existingGatewayDetails = nil return false } } 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 resolution.command != nil else { await MainActor.run { self.status = .failed(resolution.status.message) } return } 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 } // 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) { self.log.append(chunk) if self.log.count > self.logLimit { self.log = String(self.log.suffix(self.logLimit)) } } func clearLog() { self.log = "" try? FileManager.default.removeItem(atPath: LogLocator.launchdGatewayLogPath) } func setProjectRoot(path: String) { CommandResolver.setProjectRoot(path) } func projectRootPath() -> String { CommandResolver.projectRootPath() } private nonisolated 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)) } }