mac: route remote mode through SSH
This commit is contained in:
48
apps/macos/Sources/Clawdis/ConnectionModeCoordinator.swift
Normal file
48
apps/macos/Sources/Clawdis/ConnectionModeCoordinator.swift
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import Foundation
|
||||||
|
import OSLog
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class ConnectionModeCoordinator {
|
||||||
|
static let shared = ConnectionModeCoordinator()
|
||||||
|
|
||||||
|
private let logger = Logger(subsystem: "com.steipete.clawdis", category: "connection")
|
||||||
|
|
||||||
|
/// Apply the requested connection mode by starting/stopping local gateway,
|
||||||
|
/// managing the control-channel SSH tunnel, and cleaning up WebChat tunnels.
|
||||||
|
func apply(mode: AppState.ConnectionMode, paused: Bool) async {
|
||||||
|
switch mode {
|
||||||
|
case .local:
|
||||||
|
await RemoteTunnelManager.shared.stopAll()
|
||||||
|
WebChatManager.shared.resetTunnels()
|
||||||
|
do {
|
||||||
|
try await ControlChannel.shared.configure(mode: .local)
|
||||||
|
} catch {
|
||||||
|
// Control channel will mark itself degraded; nothing else to do here.
|
||||||
|
self.logger.error("control channel local configure failed: \(error.localizedDescription, privacy: .public)")
|
||||||
|
}
|
||||||
|
if paused {
|
||||||
|
GatewayProcessManager.shared.stop()
|
||||||
|
} else {
|
||||||
|
GatewayProcessManager.shared.setActive(true)
|
||||||
|
}
|
||||||
|
Task.detached { await PortGuardian.shared.sweep(mode: .local) }
|
||||||
|
|
||||||
|
case .remote:
|
||||||
|
// Never run a local gateway in remote mode.
|
||||||
|
GatewayProcessManager.shared.stop()
|
||||||
|
WebChatManager.shared.resetTunnels()
|
||||||
|
|
||||||
|
do {
|
||||||
|
_ = try await RemoteTunnelManager.shared.ensureControlTunnel()
|
||||||
|
let settings = CommandResolver.connectionSettings()
|
||||||
|
try await ControlChannel.shared.configure(mode: .remote(
|
||||||
|
target: settings.target,
|
||||||
|
identity: settings.identity))
|
||||||
|
} catch {
|
||||||
|
self.logger.error("remote tunnel/configure failed: \(error.localizedDescription, privacy: .public)")
|
||||||
|
}
|
||||||
|
|
||||||
|
Task.detached { await PortGuardian.shared.sweep(mode: .remote) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -56,10 +56,8 @@ final class ControlChannel: ObservableObject {
|
|||||||
|
|
||||||
private let logger = Logger(subsystem: "com.steipete.clawdis", category: "control")
|
private let logger = Logger(subsystem: "com.steipete.clawdis", category: "control")
|
||||||
private let gateway = GatewayChannel()
|
private let gateway = GatewayChannel()
|
||||||
private var gatewayURL: URL {
|
private var gatewayPort: Int = GatewayEnvironment.gatewayPort()
|
||||||
let port = GatewayEnvironment.gatewayPort()
|
private var gatewayURL: URL { URL(string: "ws://127.0.0.1:\(self.gatewayPort)")! }
|
||||||
return URL(string: "ws://127.0.0.1:\(port)")!
|
|
||||||
}
|
|
||||||
|
|
||||||
private var gatewayToken: String? {
|
private var gatewayToken: String? {
|
||||||
ProcessInfo.processInfo.environment["CLAWDIS_GATEWAY_TOKEN"]
|
ProcessInfo.processInfo.environment["CLAWDIS_GATEWAY_TOKEN"]
|
||||||
@@ -78,11 +76,19 @@ final class ControlChannel: ObservableObject {
|
|||||||
func configure(mode: Mode = .local) async throws {
|
func configure(mode: Mode = .local) async throws {
|
||||||
switch mode {
|
switch mode {
|
||||||
case .local:
|
case .local:
|
||||||
|
self.gatewayPort = GatewayEnvironment.gatewayPort()
|
||||||
await self.configure()
|
await self.configure()
|
||||||
case let .remote(target, identity):
|
case let .remote(target, identity):
|
||||||
// Remote mode assumed to have an existing tunnel; placeholders retained for future use.
|
// Create/ensure SSH tunnel, then talk to the forwarded local port.
|
||||||
_ = (target, identity)
|
_ = (target, identity)
|
||||||
|
do {
|
||||||
|
let forwarded = try await RemoteTunnelManager.shared.ensureControlTunnel()
|
||||||
|
self.gatewayPort = Int(forwarded)
|
||||||
await self.configure()
|
await self.configure()
|
||||||
|
} catch {
|
||||||
|
self.state = .degraded(error.localizedDescription)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -45,7 +45,14 @@ struct ClawdisApp: App {
|
|||||||
}
|
}
|
||||||
.onChange(of: self.state.isPaused) { _, paused in
|
.onChange(of: self.state.isPaused) { _, paused in
|
||||||
self.applyStatusItemAppearance(paused: paused)
|
self.applyStatusItemAppearance(paused: paused)
|
||||||
|
if self.state.connectionMode == .local {
|
||||||
self.gatewayManager.setActive(!paused)
|
self.gatewayManager.setActive(!paused)
|
||||||
|
} else {
|
||||||
|
self.gatewayManager.stop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onChange(of: self.state.connectionMode) { _, mode in
|
||||||
|
Task { await ConnectionModeCoordinator.shared.apply(mode: mode, paused: self.state.isPaused) }
|
||||||
}
|
}
|
||||||
|
|
||||||
Settings {
|
Settings {
|
||||||
@@ -161,17 +168,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSXPCListenerDelegate
|
|||||||
self.state = AppStateStore.shared
|
self.state = AppStateStore.shared
|
||||||
AppActivationPolicy.apply(showDockIcon: self.state?.showDockIcon ?? false)
|
AppActivationPolicy.apply(showDockIcon: self.state?.showDockIcon ?? false)
|
||||||
if let state {
|
if let state {
|
||||||
GatewayProcessManager.shared.setActive(!state.isPaused)
|
Task { await ConnectionModeCoordinator.shared.apply(mode: state.connectionMode, paused: state.isPaused) }
|
||||||
}
|
|
||||||
Task {
|
|
||||||
await ControlChannel.shared.configure()
|
|
||||||
PresenceReporter.shared.start()
|
|
||||||
}
|
}
|
||||||
|
Task { PresenceReporter.shared.start() }
|
||||||
Task { await HealthStore.shared.refresh(onDemand: true) }
|
Task { await HealthStore.shared.refresh(onDemand: true) }
|
||||||
Task {
|
Task { await PortGuardian.shared.sweep(mode: AppStateStore.shared.connectionMode) }
|
||||||
let mode = AppStateStore.shared.connectionMode
|
|
||||||
await PortGuardian.shared.sweep(mode: mode)
|
|
||||||
}
|
|
||||||
self.startListener()
|
self.startListener()
|
||||||
self.scheduleFirstRunOnboardingIfNeeded()
|
self.scheduleFirstRunOnboardingIfNeeded()
|
||||||
|
|
||||||
@@ -186,6 +187,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSXPCListenerDelegate
|
|||||||
GatewayProcessManager.shared.stop()
|
GatewayProcessManager.shared.stop()
|
||||||
PresenceReporter.shared.stop()
|
PresenceReporter.shared.stop()
|
||||||
WebChatManager.shared.close()
|
WebChatManager.shared.close()
|
||||||
|
WebChatManager.shared.resetTunnels()
|
||||||
|
Task { await RemoteTunnelManager.shared.stopAll() }
|
||||||
Task { await AgentRPC.shared.shutdown() }
|
Task { await AgentRPC.shared.shutdown() }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
35
apps/macos/Sources/Clawdis/RemoteTunnelManager.swift
Normal file
35
apps/macos/Sources/Clawdis/RemoteTunnelManager.swift
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Manages the SSH tunnel that forwards the remote gateway/control port to localhost.
|
||||||
|
actor RemoteTunnelManager {
|
||||||
|
static let shared = RemoteTunnelManager()
|
||||||
|
|
||||||
|
private var controlTunnel: WebChatTunnel?
|
||||||
|
|
||||||
|
/// Ensure an SSH tunnel is running for the gateway control port.
|
||||||
|
/// Returns the local forwarded port (usually 18789).
|
||||||
|
func ensureControlTunnel() async throws -> UInt16 {
|
||||||
|
let settings = CommandResolver.connectionSettings()
|
||||||
|
guard settings.mode == .remote else {
|
||||||
|
throw NSError(domain: "RemoteTunnel", code: 1, userInfo: [NSLocalizedDescriptionKey: "Remote mode is not enabled"])
|
||||||
|
}
|
||||||
|
|
||||||
|
if let tunnel = self.controlTunnel,
|
||||||
|
tunnel.process.isRunning,
|
||||||
|
let local = tunnel.localPort {
|
||||||
|
return local
|
||||||
|
}
|
||||||
|
|
||||||
|
let desiredPort = UInt16(GatewayEnvironment.gatewayPort())
|
||||||
|
let tunnel = try await WebChatTunnel.create(
|
||||||
|
remotePort: GatewayEnvironment.gatewayPort(),
|
||||||
|
preferredLocalPort: desiredPort)
|
||||||
|
self.controlTunnel = tunnel
|
||||||
|
return tunnel.localPort ?? desiredPort
|
||||||
|
}
|
||||||
|
|
||||||
|
func stopAll() {
|
||||||
|
self.controlTunnel?.terminate()
|
||||||
|
self.controlTunnel = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -514,6 +514,18 @@ final class WebChatManager {
|
|||||||
return "+1003"
|
return "+1003"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func resetTunnels() {
|
||||||
|
self.browserTunnel?.terminate()
|
||||||
|
self.browserTunnel = nil
|
||||||
|
self.windowController?.shutdown()
|
||||||
|
self.windowController?.close()
|
||||||
|
self.windowController = nil
|
||||||
|
self.panelController?.shutdown()
|
||||||
|
self.panelController?.close()
|
||||||
|
self.panelController = nil
|
||||||
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
func openInBrowser(sessionKey: String) async {
|
func openInBrowser(sessionKey: String) async {
|
||||||
let port = AppStateStore.webChatPort
|
let port = AppStateStore.webChatPort
|
||||||
|
|||||||
Reference in New Issue
Block a user