fix(macos): show connecting state for remote tunnel

This commit is contained in:
Peter Steinberger
2026-01-11 04:42:56 +01:00
parent 30348e41c6
commit 5fa682d8f0
3 changed files with 116 additions and 20 deletions

View File

@@ -108,6 +108,7 @@ final class ControlChannel {
self.logger.info( self.logger.info(
"control channel configure mode=remote " + "control channel configure mode=remote " +
"target=\(target, privacy: .public) identitySet=\(idSet, privacy: .public)") "target=\(target, privacy: .public) identitySet=\(idSet, privacy: .public)")
self.state = .connecting
_ = try await GatewayEndpointStore.shared.ensureRemoteControlTunnel() _ = try await GatewayEndpointStore.shared.ensureRemoteControlTunnel()
await self.configure() await self.configure()
} catch { } catch {

View File

@@ -3,6 +3,7 @@ import OSLog
enum GatewayEndpointState: Sendable, Equatable { enum GatewayEndpointState: Sendable, Equatable {
case ready(mode: AppState.ConnectionMode, url: URL, token: String?, password: String?) 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) case unavailable(mode: AppState.ConnectionMode, reason: String)
} }
@@ -14,6 +15,7 @@ enum GatewayEndpointState: Sendable, Equatable {
actor GatewayEndpointStore { actor GatewayEndpointStore {
static let shared = GatewayEndpointStore() static let shared = GatewayEndpointStore()
private static let supportedBindModes: Set<String> = ["loopback", "tailnet", "lan", "auto"] private static let supportedBindModes: Set<String> = ["loopback", "tailnet", "lan", "auto"]
private static let remoteConnectingDetail = "Connecting to remote gateway…"
struct Deps: Sendable { struct Deps: Sendable {
let mode: @Sendable () async -> AppState.ConnectionMode let mode: @Sendable () async -> AppState.ConnectionMode
@@ -128,6 +130,7 @@ actor GatewayEndpointStore {
private var state: GatewayEndpointState private var state: GatewayEndpointState
private var subscribers: [UUID: AsyncStream<GatewayEndpointState>.Continuation] = [:] private var subscribers: [UUID: AsyncStream<GatewayEndpointState>.Continuation] = [:]
private var remoteEnsure: (token: UUID, task: Task<UInt16, Error>)?
init(deps: Deps = .live) { init(deps: Deps = .live) {
self.deps = deps self.deps = deps
@@ -155,7 +158,8 @@ actor GatewayEndpointStore {
token: token, token: token,
password: password) password: password)
case .remote: case .remote:
self.state = .unavailable(mode: .remote, reason: "Remote mode enabled but no active control tunnel") self.state = .connecting(mode: .remote, detail: Self.remoteConnectingDetail)
Task { await self.setMode(.remote) }
case .unconfigured: case .unconfigured:
self.state = .unavailable(mode: .unconfigured, reason: "Gateway not configured") self.state = .unavailable(mode: .unconfigured, reason: "Gateway not configured")
} }
@@ -184,6 +188,7 @@ actor GatewayEndpointStore {
let password = self.deps.password() let password = self.deps.password()
switch mode { switch mode {
case .local: case .local:
self.cancelRemoteEnsure()
let port = self.deps.localPort() let port = self.deps.localPort()
let host = await self.deps.localHost() let host = await self.deps.localHost()
self.setState(.ready( self.setState(.ready(
@@ -194,15 +199,18 @@ actor GatewayEndpointStore {
case .remote: case .remote:
let port = await self.deps.remotePortIfRunning() let port = await self.deps.remotePortIfRunning()
guard let port else { guard let port else {
self.setState(.unavailable(mode: .remote, reason: "Remote mode enabled but no active control tunnel")) self.setState(.connecting(mode: .remote, detail: Self.remoteConnectingDetail))
self.kickRemoteEnsureIfNeeded(detail: Self.remoteConnectingDetail)
return return
} }
self.cancelRemoteEnsure()
self.setState(.ready( self.setState(.ready(
mode: .remote, mode: .remote,
url: URL(string: "ws://127.0.0.1:\(Int(port))")!, url: URL(string: "ws://127.0.0.1:\(Int(port))")!,
token: token, token: token,
password: password)) password: password))
case .unconfigured: case .unconfigured:
self.cancelRemoteEnsure()
self.setState(.unavailable(mode: .unconfigured, reason: "Gateway not configured")) self.setState(.unavailable(mode: .unconfigured, reason: "Gateway not configured"))
} }
} }
@@ -216,8 +224,10 @@ actor GatewayEndpointStore {
code: 1, code: 1,
userInfo: [NSLocalizedDescriptionKey: "Remote mode is not enabled"]) userInfo: [NSLocalizedDescriptionKey: "Remote mode is not enabled"])
} }
let port = try await self.deps.ensureRemoteTunnel() let config = try await self.ensureRemoteConfig(detail: Self.remoteConnectingDetail)
await self.setMode(.remote) 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 return port
} }
@@ -226,6 +236,11 @@ actor GatewayEndpointStore {
switch self.state { switch self.state {
case let .ready(_, url, token, password): case let .ready(_, url, token, password):
return (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): case let .unavailable(mode, reason):
guard mode == .remote else { guard mode == .remote else {
throw NSError(domain: "GatewayEndpoint", code: 1, userInfo: [NSLocalizedDescriptionKey: reason]) throw NSError(domain: "GatewayEndpoint", code: 1, userInfo: [NSLocalizedDescriptionKey: reason])
@@ -233,21 +248,73 @@ actor GatewayEndpointStore {
// Auto-recover for remote mode: if the SSH control tunnel died (or hasn't been created yet), // 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. // recreate it on demand so callers can recover without a manual reconnect.
do { self.logger.info(
self.logger.info( "endpoint unavailable; ensuring remote control tunnel reason=\(reason, privacy: .public)")
"endpoint unavailable; ensuring remote control tunnel reason=\(reason, privacy: .public)") return try await self.ensureRemoteConfig(detail: Self.remoteConnectingDetail)
let forwarded = try await self.deps.ensureRemoteTunnel() }
let token = self.deps.token() }
let password = self.deps.password()
let url = URL(string: "ws://127.0.0.1:\(Int(forwarded))")! private func cancelRemoteEnsure() {
self.setState(.ready(mode: .remote, url: url, token: token, password: password)) self.remoteEnsure?.task.cancel()
return (url, token, password) self.remoteEnsure = nil
} catch { }
let msg = "\(reason) (\(error.localizedDescription))"
self.setState(.unavailable(mode: .remote, reason: msg)) private func kickRemoteEnsureIfNeeded(detail: String) {
self.logger.error("remote control tunnel ensure failed \(msg, privacy: .public)") if self.remoteEnsure != nil {
throw NSError(domain: "GatewayEndpoint", code: 1, userInfo: [NSLocalizedDescriptionKey: msg]) 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])
} }
} }
@@ -268,6 +335,11 @@ actor GatewayEndpointStore {
self.logger self.logger
.debug( .debug(
"resolved endpoint mode=\(modeDesc, privacy: .public) url=\(urlDesc, privacy: .public)") "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): case let .unavailable(mode, reason):
let modeDesc = String(describing: mode) let modeDesc = String(describing: mode)
self.logger self.logger

View File

@@ -111,6 +111,7 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
guard let insertIndex = self.findInsertIndex(in: menu) else { return } guard let insertIndex = self.findInsertIndex(in: menu) else { return }
let width = self.initialWidth(for: menu) let width = self.initialWidth(for: menu)
let isConnected = self.isControlChannelConnected let isConnected = self.isControlChannelConnected
let channelState = ControlChannel.shared.state
var cursor = insertIndex var cursor = insertIndex
var headerView: NSView? var headerView: NSView?
@@ -133,7 +134,7 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
let hosted = self.makeHostedView( let hosted = self.makeHostedView(
rootView: AnyView(MenuSessionsHeaderView( rootView: AnyView(MenuSessionsHeaderView(
count: rows.count, count: rows.count,
statusText: isConnected ? nil : "Gateway disconnected")), statusText: isConnected ? nil : self.controlChannelStatusText(for: channelState))),
width: width, width: width,
highlighted: false) highlighted: false)
headerItem.view = hosted headerItem.view = hosted
@@ -166,7 +167,7 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
headerItem.isEnabled = false headerItem.isEnabled = false
let statusText = isConnected let statusText = isConnected
? (self.cachedErrorText ?? "Loading sessions…") ? (self.cachedErrorText ?? "Loading sessions…")
: "Gateway disconnected" : self.controlChannelStatusText(for: channelState)
let hosted = self.makeHostedView( let hosted = self.makeHostedView(
rootView: AnyView(MenuSessionsHeaderView( rootView: AnyView(MenuSessionsHeaderView(
count: 0, count: 0,
@@ -218,6 +219,14 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
cursor += 1 cursor += 1
} }
if case .connecting = ControlChannel.shared.state {
menu.insertItem(
self.makeMessageItem(text: "Connecting…", symbolName: "circle.dashed", width: width),
at: cursor)
cursor += 1
return
}
guard self.isControlChannelConnected else { return } guard self.isControlChannelConnected else { return }
if let error = self.nodesStore.lastError?.nonEmpty { if let error = self.nodesStore.lastError?.nonEmpty {
@@ -383,6 +392,19 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
return false return false
} }
private func controlChannelStatusText(for state: ControlChannel.ConnectionState) -> String {
switch state {
case .connected:
return "Loading sessions…"
case .connecting:
return "Connecting…"
case let .degraded(message):
return message.nonEmpty ?? "Gateway disconnected"
case .disconnected:
return "Gateway disconnected"
}
}
private func gatewayEntry() -> NodeInfo? { private func gatewayEntry() -> NodeInfo? {
let mode = AppStateStore.shared.connectionMode let mode = AppStateStore.shared.connectionMode
let isConnected = self.isControlChannelConnected let isConnected = self.isControlChannelConnected
@@ -471,6 +493,7 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
.truncationMode(.tail) .truncationMode(.tail)
.fixedSize(horizontal: false, vertical: true) .fixedSize(horizontal: false, vertical: true)
.layoutPriority(1) .layoutPriority(1)
.frame(maxWidth: .infinity, alignment: .leading)
Spacer(minLength: 0) Spacer(minLength: 0)
} }