import Foundation import OSLog enum GatewayEndpointState: Sendable, Equatable { case ready(mode: AppState.ConnectionMode, url: URL, token: String?, password: String?) case connecting(mode: AppState.ConnectionMode, detail: String) case unavailable(mode: AppState.ConnectionMode, reason: String) } /// Single place to resolve (and publish) the effective gateway control endpoint. /// /// This is intentionally separate from `GatewayConnection`: /// - `GatewayConnection` consumes the resolved endpoint (no tunnel side-effects). /// - The endpoint store owns observation + explicit "ensure tunnel" actions. actor GatewayEndpointStore { static let shared = GatewayEndpointStore() private static let supportedBindModes: Set = ["loopback", "tailnet", "lan", "auto"] private static let remoteConnectingDetail = "Connecting to remote gateway…" struct Deps: Sendable { let mode: @Sendable () async -> AppState.ConnectionMode let token: @Sendable () -> String? let password: @Sendable () -> String? let localPort: @Sendable () -> Int let localHost: @Sendable () async -> String let remotePortIfRunning: @Sendable () async -> UInt16? let ensureRemoteTunnel: @Sendable () async throws -> UInt16 static let live = Deps( mode: { await MainActor.run { AppStateStore.shared.connectionMode } }, token: { let root = ClawdbotConfigFile.loadDict() return GatewayEndpointStore.resolveGatewayToken( isRemote: CommandResolver.connectionModeIsRemote(), root: root, env: ProcessInfo.processInfo.environment) }, password: { let root = ClawdbotConfigFile.loadDict() return GatewayEndpointStore.resolveGatewayPassword( isRemote: CommandResolver.connectionModeIsRemote(), root: root, env: ProcessInfo.processInfo.environment) }, localPort: { GatewayEnvironment.gatewayPort() }, localHost: { let root = ClawdbotConfigFile.loadDict() let bind = GatewayEndpointStore.resolveGatewayBindMode( root: root, env: ProcessInfo.processInfo.environment) let tailscaleIP = await MainActor.run { TailscaleService.shared.tailscaleIP } return GatewayEndpointStore.resolveLocalGatewayHost( bindMode: bind, tailscaleIP: tailscaleIP) }, remotePortIfRunning: { await RemoteTunnelManager.shared.controlTunnelPortIfRunning() }, ensureRemoteTunnel: { try await RemoteTunnelManager.shared.ensureControlTunnel() }) } private static func resolveGatewayPassword( isRemote: Bool, root: [String: Any], env: [String: String]) -> String? { let raw = env["CLAWDBOT_GATEWAY_PASSWORD"] ?? "" let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) if !trimmed.isEmpty { return trimmed } if isRemote { if let gateway = root["gateway"] as? [String: Any], let remote = gateway["remote"] as? [String: Any], let password = remote["password"] as? String { let pw = password.trimmingCharacters(in: .whitespacesAndNewlines) if !pw.isEmpty { return pw } } return nil } if let gateway = root["gateway"] as? [String: Any], let auth = gateway["auth"] as? [String: Any], let password = auth["password"] as? String { let pw = password.trimmingCharacters(in: .whitespacesAndNewlines) if !pw.isEmpty { return pw } } return nil } private static func resolveGatewayToken( isRemote: Bool, root: [String: Any], env: [String: String]) -> String? { let raw = env["CLAWDBOT_GATEWAY_TOKEN"] ?? "" let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) if !trimmed.isEmpty { return trimmed } if isRemote { if let gateway = root["gateway"] as? [String: Any], let remote = gateway["remote"] as? [String: Any], let token = remote["token"] as? String { let value = token.trimmingCharacters(in: .whitespacesAndNewlines) if !value.isEmpty { return value } } return nil } if let gateway = root["gateway"] as? [String: Any], let auth = gateway["auth"] as? [String: Any], let token = auth["token"] as? String { let value = token.trimmingCharacters(in: .whitespacesAndNewlines) if !value.isEmpty { return value } } return nil } private let deps: Deps private let logger = Logger(subsystem: "com.clawdbot", category: "gateway-endpoint") private var state: GatewayEndpointState private var subscribers: [UUID: AsyncStream.Continuation] = [:] private var remoteEnsure: (token: UUID, task: Task)? init(deps: Deps = .live) { self.deps = deps let modeRaw = UserDefaults.standard.string(forKey: connectionModeKey) let initialMode: AppState.ConnectionMode if let modeRaw { initialMode = AppState.ConnectionMode(rawValue: modeRaw) ?? .local } else { let seen = UserDefaults.standard.bool(forKey: "clawdbot.onboardingSeen") initialMode = seen ? .local : .unconfigured } let port = deps.localPort() let bind = GatewayEndpointStore.resolveGatewayBindMode( root: ClawdbotConfigFile.loadDict(), env: ProcessInfo.processInfo.environment) let host = GatewayEndpointStore.resolveLocalGatewayHost(bindMode: bind, tailscaleIP: nil) let token = deps.token() let password = deps.password() switch initialMode { case .local: self.state = .ready( mode: .local, url: URL(string: "ws://\(host):\(port)")!, token: token, password: password) case .remote: self.state = .connecting(mode: .remote, detail: Self.remoteConnectingDetail) Task { await self.setMode(.remote) } case .unconfigured: self.state = .unavailable(mode: .unconfigured, reason: "Gateway not configured") } } func subscribe(bufferingNewest: Int = 1) -> AsyncStream { let id = UUID() let initial = self.state let store = self return AsyncStream(bufferingPolicy: .bufferingNewest(bufferingNewest)) { continuation in continuation.yield(initial) self.subscribers[id] = continuation continuation.onTermination = { @Sendable _ in Task { await store.removeSubscriber(id) } } } } func refresh() async { let mode = await self.deps.mode() await self.setMode(mode) } func setMode(_ mode: AppState.ConnectionMode) async { let token = self.deps.token() let password = self.deps.password() switch mode { case .local: self.cancelRemoteEnsure() let port = self.deps.localPort() let host = await self.deps.localHost() self.setState(.ready( mode: .local, url: URL(string: "ws://\(host):\(port)")!, token: token, password: password)) case .remote: let port = await self.deps.remotePortIfRunning() guard let port else { self.setState(.connecting(mode: .remote, detail: Self.remoteConnectingDetail)) self.kickRemoteEnsureIfNeeded(detail: Self.remoteConnectingDetail) return } self.cancelRemoteEnsure() self.setState(.ready( mode: .remote, url: URL(string: "ws://127.0.0.1:\(Int(port))")!, token: token, password: password)) case .unconfigured: self.cancelRemoteEnsure() self.setState(.unavailable(mode: .unconfigured, reason: "Gateway not configured")) } } /// Explicit action: ensure the remote control tunnel is established and publish the resolved endpoint. func ensureRemoteControlTunnel() async throws -> UInt16 { let mode = await self.deps.mode() guard mode == .remote else { throw NSError( domain: "RemoteTunnel", code: 1, userInfo: [NSLocalizedDescriptionKey: "Remote mode is not enabled"]) } let config = try await self.ensureRemoteConfig(detail: Self.remoteConnectingDetail) guard let portInt = config.0.port, let port = UInt16(exactly: portInt) else { throw NSError( domain: "GatewayEndpoint", code: 1, userInfo: [NSLocalizedDescriptionKey: "Missing tunnel port"]) } return port } func requireConfig() async throws -> GatewayConnection.Config { await self.refresh() switch self.state { case let .ready(_, url, token, password): return (url, token, password) case let .connecting(mode, _): guard mode == .remote else { throw NSError(domain: "GatewayEndpoint", code: 1, userInfo: [NSLocalizedDescriptionKey: "Connecting…"]) } return try await self.ensureRemoteConfig(detail: Self.remoteConnectingDetail) case let .unavailable(mode, reason): guard mode == .remote else { throw NSError(domain: "GatewayEndpoint", code: 1, userInfo: [NSLocalizedDescriptionKey: reason]) } // Auto-recover for remote mode: if the SSH control tunnel died (or hasn't been created yet), // recreate it on demand so callers can recover without a manual reconnect. self.logger.info( "endpoint unavailable; ensuring remote control tunnel reason=\(reason, privacy: .public)") return try await self.ensureRemoteConfig(detail: Self.remoteConnectingDetail) } } private func cancelRemoteEnsure() { self.remoteEnsure?.task.cancel() self.remoteEnsure = nil } private func kickRemoteEnsureIfNeeded(detail: String) { if self.remoteEnsure != nil { self.setState(.connecting(mode: .remote, detail: detail)) return } let deps = self.deps let token = UUID() let task = Task.detached(priority: .utility) { try await deps.ensureRemoteTunnel() } self.remoteEnsure = (token: token, task: task) self.setState(.connecting(mode: .remote, detail: detail)) } private func ensureRemoteConfig(detail: String) async throws -> GatewayConnection.Config { let mode = await self.deps.mode() guard mode == .remote else { throw NSError( domain: "RemoteTunnel", code: 1, userInfo: [NSLocalizedDescriptionKey: "Remote mode is not enabled"]) } self.kickRemoteEnsureIfNeeded(detail: detail) guard let ensure = self.remoteEnsure else { throw NSError(domain: "GatewayEndpoint", code: 1, userInfo: [NSLocalizedDescriptionKey: "Connecting…"]) } do { let forwarded = try await ensure.task.value let stillRemote = await self.deps.mode() == .remote guard stillRemote else { throw NSError( domain: "RemoteTunnel", code: 1, userInfo: [NSLocalizedDescriptionKey: "Remote mode is not enabled"]) } if self.remoteEnsure?.token == ensure.token { self.remoteEnsure = nil } let token = self.deps.token() let password = self.deps.password() let url = URL(string: "ws://127.0.0.1:\(Int(forwarded))")! self.setState(.ready(mode: .remote, url: url, token: token, password: password)) return (url, token, password) } catch let err as CancellationError { if self.remoteEnsure?.token == ensure.token { self.remoteEnsure = nil } throw err } catch { if self.remoteEnsure?.token == ensure.token { self.remoteEnsure = nil } let msg = "Remote control tunnel failed (\(error.localizedDescription))" self.setState(.unavailable(mode: .remote, reason: msg)) self.logger.error("remote control tunnel ensure failed \(msg, privacy: .public)") throw NSError(domain: "GatewayEndpoint", code: 1, userInfo: [NSLocalizedDescriptionKey: msg]) } } private func removeSubscriber(_ id: UUID) { self.subscribers[id] = nil } private func setState(_ next: GatewayEndpointState) { guard next != self.state else { return } self.state = next for (_, continuation) in self.subscribers { continuation.yield(next) } switch next { case let .ready(mode, url, _, _): let modeDesc = String(describing: mode) let urlDesc = url.absoluteString self.logger .debug( "resolved endpoint mode=\(modeDesc, privacy: .public) url=\(urlDesc, privacy: .public)") case let .connecting(mode, detail): let modeDesc = String(describing: mode) self.logger .debug( "endpoint connecting mode=\(modeDesc, privacy: .public) detail=\(detail, privacy: .public)") case let .unavailable(mode, reason): let modeDesc = String(describing: mode) self.logger .debug( "endpoint unavailable mode=\(modeDesc, privacy: .public) reason=\(reason, privacy: .public)") } } private static func resolveGatewayBindMode( root: [String: Any], env: [String: String]) -> String? { if let envBind = env["CLAWDBOT_GATEWAY_BIND"] { let trimmed = envBind.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() if self.supportedBindModes.contains(trimmed) { return trimmed } } if let gateway = root["gateway"] as? [String: Any], let bind = gateway["bind"] as? String { let trimmed = bind.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() if self.supportedBindModes.contains(trimmed) { return trimmed } } return nil } private static func resolveLocalGatewayHost( bindMode: String?, tailscaleIP: String?) -> String { switch bindMode { case "tailnet", "auto": tailscaleIP ?? "127.0.0.1" default: "127.0.0.1" } } } #if DEBUG extension GatewayEndpointStore { static func _testResolveGatewayPassword( isRemote: Bool, root: [String: Any], env: [String: String]) -> String? { self.resolveGatewayPassword(isRemote: isRemote, root: root, env: env) } static func _testResolveGatewayBindMode( root: [String: Any], env: [String: String]) -> String? { self.resolveGatewayBindMode(root: root, env: env) } static func _testResolveLocalGatewayHost( bindMode: String?, tailscaleIP: String?) -> String { self.resolveLocalGatewayHost(bindMode: bindMode, tailscaleIP: tailscaleIP) } } #endif