fix(macos): show connecting state for remote tunnel
This commit is contained in:
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user