From de0a488985abf85e899c671b3ef22fe334d89b28 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 21 Jan 2026 05:01:22 +0000 Subject: [PATCH] refactor: unify gateway connectivity state --- .../Sources/Clawdbot/ControlChannel.swift | 45 ++++++++----- .../GatewayConnectivityCoordinator.swift | 63 +++++++++++++++++++ .../Clawdbot/GatewayEndpointStore.swift | 2 + apps/macos/Sources/Clawdbot/HealthStore.swift | 4 +- apps/macos/Sources/Clawdbot/MenuBar.swift | 1 + .../Clawdbot/MenuSessionsInjector.swift | 2 +- .../Sources/Clawdbot/TailscaleService.swift | 9 +++ 7 files changed, 108 insertions(+), 18 deletions(-) create mode 100644 apps/macos/Sources/Clawdbot/GatewayConnectivityCoordinator.swift diff --git a/apps/macos/Sources/Clawdbot/ControlChannel.swift b/apps/macos/Sources/Clawdbot/ControlChannel.swift index 5c0f45597..e72ccbbde 100644 --- a/apps/macos/Sources/Clawdbot/ControlChannel.swift +++ b/apps/macos/Sources/Clawdbot/ControlChannel.swift @@ -87,15 +87,7 @@ final class ControlChannel { func configure() async { self.logger.info("control channel configure mode=local") - self.state = .connecting - do { - try await GatewayConnection.shared.refresh() - self.state = .connected - PresenceReporter.shared.sendImmediate(reason: "connect") - } catch { - let message = self.friendlyGatewayMessage(error) - self.state = .degraded(message) - } + await self.refreshEndpoint(reason: "configure") } func configure(mode: Mode = .local) async throws { @@ -111,7 +103,7 @@ final class ControlChannel { "target=\(target, privacy: .public) identitySet=\(idSet, privacy: .public)") self.state = .connecting _ = try await GatewayEndpointStore.shared.ensureRemoteControlTunnel() - await self.configure() + await self.refreshEndpoint(reason: "configure") } catch { self.state = .degraded(error.localizedDescription) throw error @@ -119,6 +111,19 @@ final class ControlChannel { } } + func refreshEndpoint(reason: String) async { + self.logger.info("control channel refresh endpoint reason=\(reason, privacy: .public)") + self.state = .connecting + do { + try await self.establishGatewayConnection() + self.state = .connected + PresenceReporter.shared.sendImmediate(reason: "connect") + } catch { + let message = self.friendlyGatewayMessage(error) + self.state = .degraded(message) + } + } + func disconnect() async { await GatewayConnection.shared.shutdown() self.state = .disconnected @@ -275,18 +280,28 @@ final class ControlChannel { } } - do { - try await GatewayConnection.shared.refresh() + await self.refreshEndpoint(reason: "recovery:\(reasonText)") + if case .connected = self.state { self.logger.info("control channel recovery finished") - } catch { - self.logger.error( - "control channel recovery failed \(error.localizedDescription, privacy: .public)") + } else if case let .degraded(message) = self.state { + self.logger.error("control channel recovery failed \(message, privacy: .public)") } self.recoveryTask = nil } } + private func establishGatewayConnection(timeoutMs: Int = 5000) async throws { + try await GatewayConnection.shared.refresh() + let ok = try await GatewayConnection.shared.healthOK(timeoutMs: timeoutMs) + if ok == false { + throw NSError( + domain: "Gateway", + code: 0, + userInfo: [NSLocalizedDescriptionKey: "gateway health not ok"]) + } + } + func sendSystemEvent(_ text: String, params: [String: AnyHashable] = [:]) async throws { var merged = params merged["text"] = AnyHashable(text) diff --git a/apps/macos/Sources/Clawdbot/GatewayConnectivityCoordinator.swift b/apps/macos/Sources/Clawdbot/GatewayConnectivityCoordinator.swift new file mode 100644 index 000000000..ac65ec0ac --- /dev/null +++ b/apps/macos/Sources/Clawdbot/GatewayConnectivityCoordinator.swift @@ -0,0 +1,63 @@ +import Foundation +import Observation +import OSLog + +@MainActor +@Observable +final class GatewayConnectivityCoordinator { + static let shared = GatewayConnectivityCoordinator() + + private let logger = Logger(subsystem: "com.clawdbot", category: "gateway.connectivity") + private var endpointTask: Task? + private var lastResolvedURL: URL? + + private(set) var endpointState: GatewayEndpointState? + private(set) var resolvedURL: URL? + private(set) var resolvedMode: AppState.ConnectionMode? + private(set) var resolvedHostLabel: String? + + private init() { + self.start() + } + + func start() { + guard self.endpointTask == nil else { return } + self.endpointTask = Task { [weak self] in + guard let self else { return } + let stream = await GatewayEndpointStore.shared.subscribe() + for await state in stream { + await MainActor.run { self.handleEndpointState(state) } + } + } + } + + var localEndpointHostLabel: String? { + guard self.resolvedMode == .local, let url = self.resolvedURL else { return nil } + return Self.hostLabel(for: url) + } + + private func handleEndpointState(_ state: GatewayEndpointState) { + self.endpointState = state + switch state { + case let .ready(mode, url, _, _): + self.resolvedMode = mode + self.resolvedURL = url + self.resolvedHostLabel = Self.hostLabel(for: url) + let urlChanged = self.lastResolvedURL?.absoluteString != url.absoluteString + if urlChanged { + self.lastResolvedURL = url + Task { await ControlChannel.shared.refreshEndpoint(reason: "endpoint changed") } + } + case let .connecting(mode, _): + self.resolvedMode = mode + case let .unavailable(mode, _): + self.resolvedMode = mode + } + } + + private static func hostLabel(for url: URL) -> String { + let host = url.host ?? url.absoluteString + if let port = url.port { return "\(host):\(port)" } + return host + } +} diff --git a/apps/macos/Sources/Clawdbot/GatewayEndpointStore.swift b/apps/macos/Sources/Clawdbot/GatewayEndpointStore.swift index 9335146ed..6a5f02ca0 100644 --- a/apps/macos/Sources/Clawdbot/GatewayEndpointStore.swift +++ b/apps/macos/Sources/Clawdbot/GatewayEndpointStore.swift @@ -68,6 +68,7 @@ actor GatewayEndpointStore { env: ProcessInfo.processInfo.environment) let customBindHost = GatewayEndpointStore.resolveGatewayCustomBindHost(root: root) let tailscaleIP = await MainActor.run { TailscaleService.shared.tailscaleIP } + ?? TailscaleService.fallbackTailnetIPv4() return GatewayEndpointStore.resolveLocalGatewayHost( bindMode: bind, customBindHost: customBindHost, @@ -487,6 +488,7 @@ actor GatewayEndpointStore { guard currentHost == "127.0.0.1" || currentHost == "localhost" else { return nil } let tailscaleIP = await MainActor.run { TailscaleService.shared.tailscaleIP } + ?? TailscaleService.fallbackTailnetIPv4() guard let tailscaleIP, !tailscaleIP.isEmpty else { return nil } let scheme = GatewayEndpointStore.resolveGatewayScheme( diff --git a/apps/macos/Sources/Clawdbot/HealthStore.swift b/apps/macos/Sources/Clawdbot/HealthStore.swift index 668056e00..cc3d5e136 100644 --- a/apps/macos/Sources/Clawdbot/HealthStore.swift +++ b/apps/macos/Sources/Clawdbot/HealthStore.swift @@ -235,8 +235,8 @@ final class HealthStore { let lower = error.lowercased() if lower.contains("connection refused") { let port = GatewayEnvironment.gatewayPort() - return "The gateway control port (127.0.0.1:\(port)) isn’t listening — " + - "restart Clawdbot to bring it back." + let host = GatewayConnectivityCoordinator.shared.localEndpointHostLabel ?? "127.0.0.1:\(port)" + return "The gateway control port (\(host)) isn’t listening — restart Clawdbot to bring it back." } if lower.contains("timeout") { return "Timed out waiting for the control server; the gateway may be crashed or still starting." diff --git a/apps/macos/Sources/Clawdbot/MenuBar.swift b/apps/macos/Sources/Clawdbot/MenuBar.swift index df58c04c2..9738c310b 100644 --- a/apps/macos/Sources/Clawdbot/MenuBar.swift +++ b/apps/macos/Sources/Clawdbot/MenuBar.swift @@ -13,6 +13,7 @@ struct ClawdbotApp: App { private let gatewayManager = GatewayProcessManager.shared private let controlChannel = ControlChannel.shared private let activityStore = WorkActivityStore.shared + private let connectivityCoordinator = GatewayConnectivityCoordinator.shared @State private var statusItem: NSStatusItem? @State private var isMenuPresented = false @State private var isPanelVisible = false diff --git a/apps/macos/Sources/Clawdbot/MenuSessionsInjector.swift b/apps/macos/Sources/Clawdbot/MenuSessionsInjector.swift index 26a8a2d38..7bbe907ba 100644 --- a/apps/macos/Sources/Clawdbot/MenuSessionsInjector.swift +++ b/apps/macos/Sources/Clawdbot/MenuSessionsInjector.swift @@ -469,7 +469,7 @@ extension MenuSessionsInjector { } case .local: platform = "local" - host = "127.0.0.1:\(port)" + host = GatewayConnectivityCoordinator.shared.localEndpointHostLabel ?? "127.0.0.1:\(port)" case .unconfigured: platform = nil host = nil diff --git a/apps/macos/Sources/Clawdbot/TailscaleService.swift b/apps/macos/Sources/Clawdbot/TailscaleService.swift index e3dc696b7..1d886aec3 100644 --- a/apps/macos/Sources/Clawdbot/TailscaleService.swift +++ b/apps/macos/Sources/Clawdbot/TailscaleService.swift @@ -103,6 +103,7 @@ final class TailscaleService { } func checkTailscaleStatus() async { + let previousIP = self.tailscaleIP self.isInstalled = self.checkAppInstallation() if !self.isInstalled { self.isRunning = false @@ -147,6 +148,10 @@ final class TailscaleService { self.statusError = nil self.logger.info("Tailscale interface IP detected (fallback) ip=\(fallback, privacy: .public)") } + + if previousIP != self.tailscaleIP { + await GatewayEndpointStore.shared.refresh() + } } func openTailscaleApp() { @@ -214,4 +219,8 @@ final class TailscaleService { return nil } + + nonisolated static func fallbackTailnetIPv4() -> String? { + Self.detectTailnetIPv4() + } }