diff --git a/apps/.i18n/native-source.json b/apps/.i18n/native-source.json index b1330acecaf6..815ebb41d1c3 100644 --- a/apps/.i18n/native-source.json +++ b/apps/.i18n/native-source.json @@ -18147,7 +18147,7 @@ }, { "kind": "ui-call", - "line": 103, + "line": 102, "path": "apps/macos/Sources/OpenClaw/MenuBar.swift", "source": "Settings...", "surface": "apple", @@ -18155,7 +18155,7 @@ }, { "kind": "conditional-branch", - "line": 122, + "line": 121, "path": "apps/macos/Sources/OpenClaw/MenuBar.swift", "source": "OpenClaw", "surface": "apple", @@ -18163,7 +18163,7 @@ }, { "kind": "conditional-branch", - "line": 122, + "line": 121, "path": "apps/macos/Sources/OpenClaw/MenuBar.swift", "source": "OpenClaw - Voice Wake live meter active", "surface": "apple", @@ -18171,7 +18171,7 @@ }, { "kind": "conditional-branch", - "line": 317, + "line": 316, "path": "apps/macos/Sources/OpenClaw/MenuBar.swift", "source": "Close Canvas", "surface": "apple", @@ -18179,7 +18179,7 @@ }, { "kind": "conditional-branch", - "line": 317, + "line": 316, "path": "apps/macos/Sources/OpenClaw/MenuBar.swift", "source": "Open Canvas", "surface": "apple", diff --git a/apps/macos/Sources/OpenClaw/GatewayEndpointStore.swift b/apps/macos/Sources/OpenClaw/GatewayEndpointStore.swift index b25ebebdbf7c..9bd8e4ac96fd 100644 --- a/apps/macos/Sources/OpenClaw/GatewayEndpointStore.swift +++ b/apps/macos/Sources/OpenClaw/GatewayEndpointStore.swift @@ -38,6 +38,7 @@ actor GatewayEndpointStore { let localPort: @Sendable () -> Int let localHost: @Sendable () async -> String let remotePortIfRunning: @Sendable () async -> UInt16? + let canStartRemoteTunnel: @Sendable () -> Bool let ensureRemoteTunnel: @Sendable () async throws -> UInt16 static let live = Deps( @@ -75,9 +76,16 @@ actor GatewayEndpointStore { tailscaleIP: tailscaleIP) }, remotePortIfRunning: { await RemoteTunnelManager.shared.controlTunnelPortIfRunning() }, + canStartRemoteTunnel: { GatewayEndpointStore.primaryAppLaunchAdmitted.withValue { $0 } }, ensureRemoteTunnel: { try await RemoteTunnelManager.shared.ensureControlTunnel() }) } + private static let primaryAppLaunchAdmitted = LockIsolated(false) + + static func admitPrimaryAppLaunch() { + self.primaryAppLaunchAdmitted.withValue { $0 = true } + } + private static func resolveGatewayPassword( isRemote: Bool, root: [String: Any], @@ -461,10 +469,15 @@ actor GatewayEndpointStore { self.remoteEnsure = nil } - private func kickRemoteEnsureIfNeeded(detail: String) { + @discardableResult + private func kickRemoteEnsureIfNeeded(detail: String) -> Bool { + guard self.deps.canStartRemoteTunnel() else { + self.setState(.connecting(mode: .remote, detail: detail)) + return false + } if self.remoteEnsure != nil { self.setState(.connecting(mode: .remote, detail: detail)) - return + return true } let deps = self.deps @@ -472,6 +485,7 @@ actor GatewayEndpointStore { let task = Task.detached(priority: .utility) { try await deps.ensureRemoteTunnel() } self.remoteEnsure = (token: token, task: task) self.setState(.connecting(mode: .remote, detail: detail)) + return true } private func ensureRemoteConfig(detail: String) async throws -> GatewayConnection.Config { @@ -485,7 +499,9 @@ actor GatewayEndpointStore { return (url, token, password) } - self.kickRemoteEnsureIfNeeded(detail: detail) + guard self.kickRemoteEnsureIfNeeded(detail: detail) else { + throw CancellationError() + } guard let ensure = self.remoteEnsure else { throw NSError(domain: "GatewayEndpoint", code: 1, userInfo: [NSLocalizedDescriptionKey: "Connecting…"]) } diff --git a/apps/macos/Sources/OpenClaw/MenuBar.swift b/apps/macos/Sources/OpenClaw/MenuBar.swift index 11992df9ac37..1407f2b00d0e 100644 --- a/apps/macos/Sources/OpenClaw/MenuBar.swift +++ b/apps/macos/Sources/OpenClaw/MenuBar.swift @@ -33,7 +33,6 @@ struct OpenClawApp: App { init() { OpenClawLogging.bootstrapIfNeeded() - GatewayConnectivityCoordinator.shared.start() Self.applyAttachOnlyOverrideIfNeeded() _state = State(initialValue: AppStateStore.shared) @@ -379,6 +378,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate { NSApp.terminate(nil) return } + // Remote startup can spawn an SSH child. Admit tunnel work only after the + // singleton check so a short-lived handoff process cannot orphan that child. + GatewayEndpointStore.admitPrimaryAppLaunch() + GatewayConnectivityCoordinator.shared.start() self.state = AppStateStore.shared if let state { MacNodeModeCoordinator.prepareNodeIdentityProfile( diff --git a/apps/macos/Tests/OpenClawIPCTests/GatewayEndpointStoreTests.swift b/apps/macos/Tests/OpenClawIPCTests/GatewayEndpointStoreTests.swift index 2ed6ea0d1aed..4dc8def4fe7d 100644 --- a/apps/macos/Tests/OpenClawIPCTests/GatewayEndpointStoreTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/GatewayEndpointStoreTests.swift @@ -1,8 +1,34 @@ +import ConcurrencyExtras import Foundation import Testing @testable import OpenClaw struct GatewayEndpointStoreTests { + @Test func `remote tunnel waits for primary app launch admission`() async throws { + let admitted = LockIsolated(false) + let tunnelStarts = LockIsolated(0) + let deps = GatewayEndpointStore.Deps( + mode: { .remote }, + token: { nil }, + password: { nil }, + localPort: { 18789 }, + localHost: { "127.0.0.1" }, + remotePortIfRunning: { nil }, + canStartRemoteTunnel: { admitted.withValue { $0 } }, + ensureRemoteTunnel: { + tunnelStarts.withValue { $0 += 1 } + return 18789 + }) + let store = GatewayEndpointStore(deps: deps) + + await store.setMode(.remote) + #expect(tunnelStarts.withValue { $0 } == 0) + + admitted.withValue { $0 = true } + #expect(try await store.ensureRemoteControlTunnel() == 18789) + #expect(tunnelStarts.withValue { $0 } == 1) + } + private func makeLaunchAgentSnapshot( env: [String: String], token: String?,