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 gateway = GatewayChannel()
|
||||
private var gatewayURL: URL {
|
||||
let port = GatewayEnvironment.gatewayPort()
|
||||
return URL(string: "ws://127.0.0.1:\(port)")!
|
||||
}
|
||||
private var gatewayPort: Int = GatewayEnvironment.gatewayPort()
|
||||
private var gatewayURL: URL { URL(string: "ws://127.0.0.1:\(self.gatewayPort)")! }
|
||||
|
||||
private var gatewayToken: String? {
|
||||
ProcessInfo.processInfo.environment["CLAWDIS_GATEWAY_TOKEN"]
|
||||
@@ -78,11 +76,19 @@ final class ControlChannel: ObservableObject {
|
||||
func configure(mode: Mode = .local) async throws {
|
||||
switch mode {
|
||||
case .local:
|
||||
self.gatewayPort = GatewayEnvironment.gatewayPort()
|
||||
await self.configure()
|
||||
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)
|
||||
await self.configure()
|
||||
do {
|
||||
let forwarded = try await RemoteTunnelManager.shared.ensureControlTunnel()
|
||||
self.gatewayPort = Int(forwarded)
|
||||
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
|
||||
self.applyStatusItemAppearance(paused: paused)
|
||||
self.gatewayManager.setActive(!paused)
|
||||
if self.state.connectionMode == .local {
|
||||
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 {
|
||||
@@ -161,17 +168,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSXPCListenerDelegate
|
||||
self.state = AppStateStore.shared
|
||||
AppActivationPolicy.apply(showDockIcon: self.state?.showDockIcon ?? false)
|
||||
if let state {
|
||||
GatewayProcessManager.shared.setActive(!state.isPaused)
|
||||
}
|
||||
Task {
|
||||
await ControlChannel.shared.configure()
|
||||
PresenceReporter.shared.start()
|
||||
Task { await ConnectionModeCoordinator.shared.apply(mode: state.connectionMode, paused: state.isPaused) }
|
||||
}
|
||||
Task { PresenceReporter.shared.start() }
|
||||
Task { await HealthStore.shared.refresh(onDemand: true) }
|
||||
Task {
|
||||
let mode = AppStateStore.shared.connectionMode
|
||||
await PortGuardian.shared.sweep(mode: mode)
|
||||
}
|
||||
Task { await PortGuardian.shared.sweep(mode: AppStateStore.shared.connectionMode) }
|
||||
self.startListener()
|
||||
self.scheduleFirstRunOnboardingIfNeeded()
|
||||
|
||||
@@ -186,6 +187,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSXPCListenerDelegate
|
||||
GatewayProcessManager.shared.stop()
|
||||
PresenceReporter.shared.stop()
|
||||
WebChatManager.shared.close()
|
||||
WebChatManager.shared.resetTunnels()
|
||||
Task { await RemoteTunnelManager.shared.stopAll() }
|
||||
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"
|
||||
}
|
||||
|
||||
@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
|
||||
func openInBrowser(sessionKey: String) async {
|
||||
let port = AppStateStore.webChatPort
|
||||
|
||||
Reference in New Issue
Block a user