diff --git a/apps/macos/Sources/Clawdbot/AppState.swift b/apps/macos/Sources/Clawdbot/AppState.swift index fdb702f17c..b70b0afe09 100644 --- a/apps/macos/Sources/Clawdbot/AppState.swift +++ b/apps/macos/Sources/Clawdbot/AppState.swift @@ -24,6 +24,11 @@ final class AppState { case remote } + enum RemoteTransport: String { + case ssh + case direct + } + var isPaused: Bool { didSet { self.ifNotPreview { UserDefaults.standard.set(self.isPaused, forKey: pauseDefaultsKey) } } } @@ -166,6 +171,10 @@ final class AppState { } } + var remoteTransport: RemoteTransport { + didSet { self.syncGatewayConfigIfNeeded() } + } + var canvasEnabled: Bool { didSet { self.ifNotPreview { UserDefaults.standard.set(self.canvasEnabled, forKey: canvasEnabledKey) } } } @@ -200,6 +209,10 @@ final class AppState { } } + var remoteUrl: String { + didSet { self.syncGatewayConfigIfNeeded() } + } + var remoteIdentity: String { didSet { self.ifNotPreview { UserDefaults.standard.set(self.remoteIdentity, forKey: remoteIdentityKey) } } } @@ -265,11 +278,14 @@ final class AppState { let configRoot = ClawdbotConfigFile.loadDict() let configGateway = configRoot["gateway"] as? [String: Any] let configRemoteUrl = (configGateway?["remote"] as? [String: Any])?["url"] as? String + let configRemoteTransport = AppState.remoteTransport(from: configRoot) let resolvedConnectionMode = ConnectionModeResolver.resolve(root: configRoot).mode + self.remoteTransport = configRemoteTransport self.connectionMode = resolvedConnectionMode let storedRemoteTarget = UserDefaults.standard.string(forKey: remoteTargetKey) ?? "" if resolvedConnectionMode == .remote, + configRemoteTransport != .direct, storedRemoteTarget.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, let host = AppState.remoteHost(from: configRemoteUrl) { @@ -277,6 +293,7 @@ final class AppState { } else { self.remoteTarget = storedRemoteTarget } + self.remoteUrl = configRemoteUrl?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" self.remoteIdentity = UserDefaults.standard.string(forKey: remoteIdentityKey) ?? "" self.remoteProjectRoot = UserDefaults.standard.string(forKey: remoteProjectRootKey) ?? "" self.remoteCliPath = UserDefaults.standard.string(forKey: remoteCliPathKey) ?? "" @@ -358,6 +375,7 @@ final class AppState { let hasRemoteUrl = !(remoteUrl? .trimmingCharacters(in: .whitespacesAndNewlines) .isEmpty ?? true) + let remoteTransport = AppState.remoteTransport(from: root) let desiredMode: ConnectionMode? = switch modeRaw { case "local": @@ -378,8 +396,17 @@ final class AppState { self.connectionMode = .remote } + if remoteTransport != self.remoteTransport { + self.remoteTransport = remoteTransport + } + let remoteUrlText = remoteUrl?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if remoteUrlText != self.remoteUrl { + self.remoteUrl = remoteUrlText + } + let targetMode = desiredMode ?? self.connectionMode if targetMode == .remote, + remoteTransport != .direct, let host = AppState.remoteHost(from: remoteUrl) { self.updateRemoteTarget(host: host) @@ -402,6 +429,8 @@ final class AppState { let connectionMode = self.connectionMode let remoteTarget = self.remoteTarget let remoteIdentity = self.remoteIdentity + let remoteTransport = self.remoteTransport + let remoteUrl = self.remoteUrl let desiredMode: String? = switch connectionMode { case .local: "local" @@ -435,39 +464,60 @@ final class AppState { var remote = gateway["remote"] as? [String: Any] ?? [:] var remoteChanged = false - if let host = remoteHost { - let existingUrl = (remote["url"] as? String)? - .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - let parsedExisting = existingUrl.isEmpty ? nil : URL(string: existingUrl) - let scheme = parsedExisting?.scheme?.isEmpty == false ? parsedExisting?.scheme : "ws" - let port = parsedExisting?.port ?? 18789 - let desiredUrl = "\(scheme ?? "ws")://\(host):\(port)" - if existingUrl != desiredUrl { - remote["url"] = desiredUrl + if remoteTransport == .direct { + let trimmedUrl = remoteUrl.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmedUrl.isEmpty { + if remote["url"] != nil { + remote.removeValue(forKey: "url") + remoteChanged = true + } + } else if (remote["url"] as? String) != trimmedUrl { + remote["url"] = trimmedUrl remoteChanged = true } - } + if (remote["transport"] as? String) != RemoteTransport.direct.rawValue { + remote["transport"] = RemoteTransport.direct.rawValue + remoteChanged = true + } + } else { + if remote["transport"] != nil { + remote.removeValue(forKey: "transport") + remoteChanged = true + } + if let host = remoteHost { + let existingUrl = (remote["url"] as? String)? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let parsedExisting = existingUrl.isEmpty ? nil : URL(string: existingUrl) + let scheme = parsedExisting?.scheme?.isEmpty == false ? parsedExisting?.scheme : "ws" + let port = parsedExisting?.port ?? 18789 + let desiredUrl = "\(scheme ?? "ws")://\(host):\(port)" + if existingUrl != desiredUrl { + remote["url"] = desiredUrl + remoteChanged = true + } + } - let sanitizedTarget = Self.sanitizeSSHTarget(remoteTarget) - if !sanitizedTarget.isEmpty { - if (remote["sshTarget"] as? String) != sanitizedTarget { - remote["sshTarget"] = sanitizedTarget + let sanitizedTarget = Self.sanitizeSSHTarget(remoteTarget) + if !sanitizedTarget.isEmpty { + if (remote["sshTarget"] as? String) != sanitizedTarget { + remote["sshTarget"] = sanitizedTarget + remoteChanged = true + } + } else if remote["sshTarget"] != nil { + remote.removeValue(forKey: "sshTarget") remoteChanged = true } - } else if remote["sshTarget"] != nil { - remote.removeValue(forKey: "sshTarget") - remoteChanged = true - } - let trimmedIdentity = remoteIdentity.trimmingCharacters(in: .whitespacesAndNewlines) - if !trimmedIdentity.isEmpty { - if (remote["sshIdentity"] as? String) != trimmedIdentity { - remote["sshIdentity"] = trimmedIdentity + let trimmedIdentity = remoteIdentity.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmedIdentity.isEmpty { + if (remote["sshIdentity"] as? String) != trimmedIdentity { + remote["sshIdentity"] = trimmedIdentity + remoteChanged = true + } + } else if remote["sshIdentity"] != nil { + remote.removeValue(forKey: "sshIdentity") remoteChanged = true } - } else if remote["sshIdentity"] != nil { - remote.removeValue(forKey: "sshIdentity") - remoteChanged = true } if remoteChanged { @@ -486,6 +536,17 @@ final class AppState { } } + private static func remoteTransport(from root: [String: Any]) -> RemoteTransport { + guard let gateway = root["gateway"] as? [String: Any], + let remote = gateway["remote"] as? [String: Any], + let raw = remote["transport"] as? String + else { + return .ssh + } + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + return trimmed == RemoteTransport.direct.rawValue ? .direct : .ssh + } + func triggerVoiceEars(ttl: TimeInterval? = 5) { self.earBoostTask?.cancel() self.earBoostActive = true @@ -621,8 +682,10 @@ extension AppState { state.iconOverride = .system state.heartbeatsEnabled = true state.connectionMode = .local + state.remoteTransport = .ssh state.canvasEnabled = true state.remoteTarget = "user@example.com" + state.remoteUrl = "wss://gateway.example.ts.net" state.remoteIdentity = "~/.ssh/id_ed25519" state.remoteProjectRoot = "~/Projects/clawdbot" state.remoteCliPath = "" diff --git a/apps/macos/Sources/Clawdbot/GatewayDiscoveryHelpers.swift b/apps/macos/Sources/Clawdbot/GatewayDiscoveryHelpers.swift new file mode 100644 index 0000000000..2bb0aaf155 --- /dev/null +++ b/apps/macos/Sources/Clawdbot/GatewayDiscoveryHelpers.swift @@ -0,0 +1,47 @@ +import ClawdbotDiscovery +import Foundation + +enum GatewayDiscoveryHelpers { + static func sshTarget(for gateway: GatewayDiscoveryModel.DiscoveredGateway) -> String? { + let host = self.sanitizedTailnetHost(gateway.tailnetDns) ?? gateway.lanHost + guard let host = self.trimmed(host), !host.isEmpty else { return nil } + let user = NSUserName() + var target = "\(user)@\(host)" + if gateway.sshPort != 22 { + target += ":\(gateway.sshPort)" + } + return target + } + + static func directUrl(for gateway: GatewayDiscoveryModel.DiscoveredGateway) -> String? { + self.directGatewayUrl( + tailnetDns: gateway.tailnetDns, + lanHost: gateway.lanHost, + gatewayPort: gateway.gatewayPort) + } + + static func directGatewayUrl( + tailnetDns: String?, + lanHost: String?, + gatewayPort: Int?) -> String? + { + if let tailnetDns = self.sanitizedTailnetHost(tailnetDns) { + return "wss://\(tailnetDns)" + } + guard let lanHost = self.trimmed(lanHost), !lanHost.isEmpty else { return nil } + let port = gatewayPort ?? 18789 + return "ws://\(lanHost):\(port)" + } + + static func sanitizedTailnetHost(_ host: String?) -> String? { + guard let host = self.trimmed(host), !host.isEmpty else { return nil } + if host.hasSuffix(".internal.") || host.hasSuffix(".internal") { + return nil + } + return host + } + + private static func trimmed(_ value: String?) -> String? { + value?.trimmingCharacters(in: .whitespacesAndNewlines) + } +} diff --git a/apps/macos/Sources/Clawdbot/GatewayDiscoveryMenu.swift b/apps/macos/Sources/Clawdbot/GatewayDiscoveryMenu.swift index a064b788a3..2a4c571ebc 100644 --- a/apps/macos/Sources/Clawdbot/GatewayDiscoveryMenu.swift +++ b/apps/macos/Sources/Clawdbot/GatewayDiscoveryMenu.swift @@ -4,6 +4,8 @@ import SwiftUI struct GatewayDiscoveryInlineList: View { var discovery: GatewayDiscoveryModel var currentTarget: String? + var currentUrl: String? + var transport: AppState.RemoteTransport var onSelect: (GatewayDiscoveryModel.DiscoveredGateway) -> Void @State private var hoveredGatewayID: GatewayDiscoveryModel.DiscoveredGateway.ID? @@ -25,9 +27,8 @@ struct GatewayDiscoveryInlineList: View { } else { VStack(alignment: .leading, spacing: 6) { ForEach(self.discovery.gateways.prefix(6)) { gateway in - let target = self.suggestedSSHTarget(gateway) - let selected = (target != nil && self.currentTarget? - .trimmingCharacters(in: .whitespacesAndNewlines) == target) + let display = self.displayInfo(for: gateway) + let selected = display.selected Button { withAnimation(.spring(response: 0.25, dampingFraction: 0.9)) { @@ -40,7 +41,7 @@ struct GatewayDiscoveryInlineList: View { .font(.callout.weight(.semibold)) .lineLimit(1) .truncationMode(.tail) - Text(target ?? "Gateway pairing only") + Text(display.label) .font(.caption.monospaced()) .foregroundStyle(.secondary) .lineLimit(1) @@ -83,27 +84,26 @@ struct GatewayDiscoveryInlineList: View { .fill(Color(NSColor.controlBackgroundColor))) } } - .help("Click a discovered gateway to fill the SSH target.") + .help(self.transport == .direct + ? "Click a discovered gateway to fill the gateway URL." + : "Click a discovered gateway to fill the SSH target.") } - private func suggestedSSHTarget(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) -> String? { - let host = self.sanitizedTailnetHost(gateway.tailnetDns) ?? gateway.lanHost - guard let host else { return nil } - let user = NSUserName() - return GatewayDiscoveryModel.buildSSHTarget( - user: user, - host: host, - port: gateway.sshPort) - } - - private func sanitizedTailnetHost(_ host: String?) -> String? { - guard let host else { return nil } - let trimmed = host.trimmingCharacters(in: .whitespacesAndNewlines) - if trimmed.isEmpty { return nil } - if trimmed.hasSuffix(".internal.") || trimmed.hasSuffix(".internal") { - return nil + private func displayInfo( + for gateway: GatewayDiscoveryModel.DiscoveredGateway) -> (label: String, selected: Bool) + { + switch self.transport { + case .direct: + let url = GatewayDiscoveryHelpers.directUrl(for: gateway) + let label = url ?? "Gateway pairing only" + let selected = url != nil && self.trimmed(self.currentUrl) == url + return (label, selected) + case .ssh: + let target = GatewayDiscoveryHelpers.sshTarget(for: gateway) + let label = target ?? "Gateway pairing only" + let selected = target != nil && self.trimmed(self.currentTarget) == target + return (label, selected) } - return trimmed } private func rowBackground(selected: Bool, hovered: Bool) -> Color { @@ -111,6 +111,10 @@ struct GatewayDiscoveryInlineList: View { if hovered { return Color.secondary.opacity(0.08) } return Color.clear } + + private func trimmed(_ value: String?) -> String { + value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + } } struct GatewayDiscoveryMenu: View { diff --git a/apps/macos/Sources/Clawdbot/GatewayEndpointStore.swift b/apps/macos/Sources/Clawdbot/GatewayEndpointStore.swift index 633b7d872f..043e4f5ae1 100644 --- a/apps/macos/Sources/Clawdbot/GatewayEndpointStore.swift +++ b/apps/macos/Sources/Clawdbot/GatewayEndpointStore.swift @@ -311,6 +311,19 @@ actor GatewayEndpointStore { token: token, password: password)) case .remote: + let root = ClawdbotConfigFile.loadDict() + if GatewayEndpointStore.resolveRemoteTransport(root: root) == "direct" { + guard let url = GatewayEndpointStore.resolveRemoteGatewayUrl(root: root) else { + self.cancelRemoteEnsure() + self.setState(.unavailable( + mode: .remote, + reason: "gateway.remote.url missing or invalid for direct transport")) + return + } + self.cancelRemoteEnsure() + self.setState(.ready(mode: .remote, url: url, token: token, password: password)) + return + } let port = await self.deps.remotePortIfRunning() guard let port else { self.setState(.connecting(mode: .remote, detail: Self.remoteConnectingDetail)) @@ -341,6 +354,24 @@ actor GatewayEndpointStore { code: 1, userInfo: [NSLocalizedDescriptionKey: "Remote mode is not enabled"]) } + let root = ClawdbotConfigFile.loadDict() + if GatewayEndpointStore.resolveRemoteTransport(root: root) == "direct" { + guard let url = GatewayEndpointStore.resolveRemoteGatewayUrl(root: root) else { + throw NSError( + domain: "GatewayEndpoint", + code: 1, + userInfo: [NSLocalizedDescriptionKey: "gateway.remote.url missing or invalid"]) + } + let port = url.port ?? (url.scheme?.lowercased() == "wss" ? 443 : 80) + guard let portInt = UInt16(exactly: port) else { + throw NSError( + domain: "GatewayEndpoint", + code: 1, + userInfo: [NSLocalizedDescriptionKey: "Invalid gateway.remote.url port"]) + } + self.logger.info("remote transport direct; skipping SSH tunnel") + return portInt + } let config = try await self.ensureRemoteConfig(detail: Self.remoteConnectingDetail) guard let portInt = config.0.port, let port = UInt16(exactly: portInt) else { throw NSError( @@ -401,6 +432,21 @@ actor GatewayEndpointStore { userInfo: [NSLocalizedDescriptionKey: "Remote mode is not enabled"]) } + let root = ClawdbotConfigFile.loadDict() + if GatewayEndpointStore.resolveRemoteTransport(root: root) == "direct" { + guard let url = GatewayEndpointStore.resolveRemoteGatewayUrl(root: root) else { + throw NSError( + domain: "GatewayEndpoint", + code: 1, + userInfo: [NSLocalizedDescriptionKey: "gateway.remote.url missing or invalid"]) + } + let token = self.deps.token() + let password = self.deps.password() + self.cancelRemoteEnsure() + self.setState(.ready(mode: .remote, url: url, token: token, password: password)) + return (url, token, password) + } + self.kickRemoteEnsureIfNeeded(detail: detail) guard let ensure = self.remoteEnsure else { throw NSError(domain: "GatewayEndpoint", code: 1, userInfo: [NSLocalizedDescriptionKey: "Connecting…"]) @@ -535,6 +581,31 @@ actor GatewayEndpointStore { return nil } + private static func resolveRemoteTransport(root: [String: Any]) -> String { + guard let gateway = root["gateway"] as? [String: Any], + let remote = gateway["remote"] as? [String: Any], + let transportRaw = remote["transport"] as? String + else { + return "ssh" + } + let trimmed = transportRaw.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + return trimmed == "direct" ? "direct" : "ssh" + } + + private static func resolveRemoteGatewayUrl(root: [String: Any]) -> URL? { + guard let gateway = root["gateway"] as? [String: Any], + let remote = gateway["remote"] as? [String: Any], + let urlRaw = remote["url"] as? String + else { + return nil + } + let trimmed = urlRaw.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty, let url = URL(string: trimmed) else { return nil } + let scheme = url.scheme?.lowercased() ?? "" + guard scheme == "ws" || scheme == "wss" else { return nil } + return url + } + private static func resolveGatewayScheme( root: [String: Any], env: [String: String]) -> String diff --git a/apps/macos/Sources/Clawdbot/GeneralSettings.swift b/apps/macos/Sources/Clawdbot/GeneralSettings.swift index bffd75b3cd..18dd423a20 100644 --- a/apps/macos/Sources/Clawdbot/GeneralSettings.swift +++ b/apps/macos/Sources/Clawdbot/GeneralSettings.swift @@ -17,6 +17,7 @@ struct GeneralSettings: View { @State private var showRemoteAdvanced = false private let isPreview = ProcessInfo.processInfo.isPreview private var isNixMode: Bool { ProcessInfo.processInfo.isNixMode } + private var remoteLabelWidth: CGFloat { 88 } var body: some View { ScrollView(.vertical) { @@ -104,7 +105,7 @@ struct GeneralSettings: View { Picker("Mode", selection: self.$state.connectionMode) { Text("Not configured").tag(AppState.ConnectionMode.unconfigured) Text("Local (this Mac)").tag(AppState.ConnectionMode.local) - Text("Remote over SSH").tag(AppState.ConnectionMode.remote) + Text("Remote (another host)").tag(AppState.ConnectionMode.remote) } .pickerStyle(.menu) .labelsHidden() @@ -136,60 +137,51 @@ struct GeneralSettings: View { private var remoteCard: some View { VStack(alignment: .leading, spacing: 10) { - HStack(alignment: .center, spacing: 10) { - Text("SSH") - .font(.callout.weight(.semibold)) - .frame(width: 48, alignment: .leading) - TextField("user@host[:22]", text: self.$state.remoteTarget) - .textFieldStyle(.roundedBorder) - .frame(maxWidth: .infinity) - Button { - Task { await self.testRemote() } - } label: { - if self.remoteStatus == .checking { - ProgressView().controlSize(.small) - } else { - Text("Test remote") - } - } - .buttonStyle(.borderedProminent) - .disabled(self.remoteStatus == .checking || self.state.remoteTarget - .trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + self.remoteTransportRow + + if self.state.remoteTransport == .ssh { + self.remoteSshRow + } else { + self.remoteDirectRow } GatewayDiscoveryInlineList( discovery: self.gatewayDiscovery, - currentTarget: self.state.remoteTarget) + currentTarget: self.state.remoteTarget, + currentUrl: self.state.remoteUrl, + transport: self.state.remoteTransport) { gateway in self.applyDiscoveredGateway(gateway) } - .padding(.leading, 58) + .padding(.leading, self.remoteLabelWidth + 10) self.remoteStatusView - .padding(.leading, 58) + .padding(.leading, self.remoteLabelWidth + 10) - DisclosureGroup(isExpanded: self.$showRemoteAdvanced) { - VStack(alignment: .leading, spacing: 8) { - LabeledContent("Identity file") { - TextField("/Users/you/.ssh/id_ed25519", text: self.$state.remoteIdentity) - .textFieldStyle(.roundedBorder) - .frame(width: 280) - } - LabeledContent("Project root") { - TextField("/home/you/Projects/clawdbot", text: self.$state.remoteProjectRoot) - .textFieldStyle(.roundedBorder) - .frame(width: 280) - } - LabeledContent("CLI path") { - TextField("/Applications/Clawdbot.app/.../clawdbot", text: self.$state.remoteCliPath) - .textFieldStyle(.roundedBorder) - .frame(width: 280) + if self.state.remoteTransport == .ssh { + DisclosureGroup(isExpanded: self.$showRemoteAdvanced) { + VStack(alignment: .leading, spacing: 8) { + LabeledContent("Identity file") { + TextField("/Users/you/.ssh/id_ed25519", text: self.$state.remoteIdentity) + .textFieldStyle(.roundedBorder) + .frame(width: 280) + } + LabeledContent("Project root") { + TextField("/home/you/Projects/clawdbot", text: self.$state.remoteProjectRoot) + .textFieldStyle(.roundedBorder) + .frame(width: 280) + } + LabeledContent("CLI path") { + TextField("/Applications/Clawdbot.app/.../clawdbot", text: self.$state.remoteCliPath) + .textFieldStyle(.roundedBorder) + .frame(width: 280) + } } + .padding(.top, 4) + } label: { + Text("Advanced") + .font(.callout.weight(.semibold)) } - .padding(.top, 4) - } label: { - Text("Advanced") - .font(.callout.weight(.semibold)) } // Diagnostics @@ -219,16 +211,89 @@ struct GeneralSettings: View { } } - Text("Tip: enable Tailscale for stable remote access.") - .font(.footnote) - .foregroundStyle(.secondary) - .lineLimit(1) + if self.state.remoteTransport == .ssh { + Text("Tip: enable Tailscale for stable remote access.") + .font(.footnote) + .foregroundStyle(.secondary) + .lineLimit(1) + } else { + Text("Tip: use Tailscale Serve so the gateway has a valid HTTPS cert.") + .font(.footnote) + .foregroundStyle(.secondary) + .lineLimit(2) + } } .transition(.opacity) .onAppear { self.gatewayDiscovery.start() } .onDisappear { self.gatewayDiscovery.stop() } } + private var remoteTransportRow: some View { + HStack(alignment: .center, spacing: 10) { + Text("Transport") + .font(.callout.weight(.semibold)) + .frame(width: self.remoteLabelWidth, alignment: .leading) + Picker("Transport", selection: self.$state.remoteTransport) { + Text("SSH tunnel").tag(AppState.RemoteTransport.ssh) + Text("Direct (ws/wss)").tag(AppState.RemoteTransport.direct) + } + .pickerStyle(.segmented) + .frame(maxWidth: 320) + } + } + + private var remoteSshRow: some View { + HStack(alignment: .center, spacing: 10) { + Text("SSH target") + .font(.callout.weight(.semibold)) + .frame(width: self.remoteLabelWidth, alignment: .leading) + TextField("user@host[:22]", text: self.$state.remoteTarget) + .textFieldStyle(.roundedBorder) + .frame(maxWidth: .infinity) + Button { + Task { await self.testRemote() } + } label: { + if self.remoteStatus == .checking { + ProgressView().controlSize(.small) + } else { + Text("Test remote") + } + } + .buttonStyle(.borderedProminent) + .disabled(self.remoteStatus == .checking || self.state.remoteTarget + .trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + } + } + + private var remoteDirectRow: some View { + VStack(alignment: .leading, spacing: 6) { + HStack(alignment: .center, spacing: 10) { + Text("Gateway") + .font(.callout.weight(.semibold)) + .frame(width: self.remoteLabelWidth, alignment: .leading) + TextField("wss://gateway.example.ts.net", text: self.$state.remoteUrl) + .textFieldStyle(.roundedBorder) + .frame(maxWidth: .infinity) + Button { + Task { await self.testRemote() } + } label: { + if self.remoteStatus == .checking { + ProgressView().controlSize(.small) + } else { + Text("Test remote") + } + } + .buttonStyle(.borderedProminent) + .disabled(self.remoteStatus == .checking || self.state.remoteUrl + .trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + } + Text("Direct mode requires a ws:// or wss:// URL (Tailscale Serve uses wss://).") + .font(.caption) + .foregroundStyle(.secondary) + .padding(.leading, self.remoteLabelWidth + 10) + } + } + private var controlStatusLine: String { switch ControlChannel.shared.state { case .connected: "Connected" @@ -458,24 +523,36 @@ extension GeneralSettings { func testRemote() async { self.remoteStatus = .checking let settings = CommandResolver.connectionSettings() - guard !settings.target.isEmpty else { - self.remoteStatus = .failed("Set an SSH target first") - return + if self.state.remoteTransport == .direct { + let trimmedUrl = self.state.remoteUrl.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedUrl.isEmpty else { + self.remoteStatus = .failed("Set a gateway URL first") + return + } + guard Self.isValidWsUrl(trimmedUrl) else { + self.remoteStatus = .failed("Gateway URL must start with ws:// or wss://") + return + } + } else { + guard !settings.target.isEmpty else { + self.remoteStatus = .failed("Set an SSH target first") + return + } + + // Step 1: basic SSH reachability check + let sshResult = await ShellExecutor.run( + command: Self.sshCheckCommand(target: settings.target, identity: settings.identity), + cwd: nil, + env: nil, + timeout: 8) + + guard sshResult.ok else { + self.remoteStatus = .failed(self.formatSSHFailure(sshResult, target: settings.target)) + return + } } - // Step 1: basic SSH reachability check - let sshResult = await ShellExecutor.run( - command: Self.sshCheckCommand(target: settings.target, identity: settings.identity), - cwd: nil, - env: nil, - timeout: 8) - - guard sshResult.ok else { - self.remoteStatus = .failed(self.formatSSHFailure(sshResult, target: settings.target)) - return - } - - // Step 2: control channel health over tunnel + // Step 2: control channel health check let originalMode = AppStateStore.shared.connectionMode do { try await ControlChannel.shared.configure(mode: .remote( @@ -502,6 +579,14 @@ extension GeneralSettings { } } + private static func isValidWsUrl(_ raw: String) -> Bool { + guard let url = URL(string: raw.trimmingCharacters(in: .whitespacesAndNewlines)) else { return false } + let scheme = url.scheme?.lowercased() ?? "" + guard scheme == "ws" || scheme == "wss" else { return false } + let host = url.host?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return !host.isEmpty + } + private static func sshCheckCommand(target: String, identity: String) -> [String] { var args: [String] = [ "/usr/bin/ssh", @@ -570,12 +655,18 @@ extension GeneralSettings { let host = gateway.tailnetDns ?? gateway.lanHost guard let host else { return } let user = NSUserName() - self.state.remoteTarget = GatewayDiscoveryModel.buildSSHTarget( - user: user, - host: host, - port: gateway.sshPort) - self.state.remoteCliPath = gateway.cliPath ?? "" - ClawdbotConfigFile.setRemoteGatewayUrl(host: host, port: gateway.gatewayPort) + if self.state.remoteTransport == .direct { + if let url = GatewayDiscoveryHelpers.directUrl(for: gateway) { + self.state.remoteUrl = url + } + } else { + self.state.remoteTarget = GatewayDiscoveryModel.buildSSHTarget( + user: user, + host: host, + port: gateway.sshPort) + self.state.remoteCliPath = gateway.cliPath ?? "" + ClawdbotConfigFile.setRemoteGatewayUrl(host: host, port: gateway.gatewayPort) + } } } @@ -598,7 +689,9 @@ extension GeneralSettings { static func exerciseForTesting() { let state = AppState(preview: true) state.connectionMode = .remote + state.remoteTransport = .ssh state.remoteTarget = "user@host:2222" + state.remoteUrl = "wss://gateway.example.ts.net" state.remoteIdentity = "/tmp/id_ed25519" state.remoteProjectRoot = "/tmp/clawdbot" state.remoteCliPath = "/tmp/clawdbot" diff --git a/apps/macos/Sources/Clawdbot/MenuSessionsInjector.swift b/apps/macos/Sources/Clawdbot/MenuSessionsInjector.swift index 4b8854cdab..7ab9a64ca6 100644 --- a/apps/macos/Sources/Clawdbot/MenuSessionsInjector.swift +++ b/apps/macos/Sources/Clawdbot/MenuSessionsInjector.swift @@ -1,4 +1,5 @@ import AppKit +import Foundation import Observation import SwiftUI @@ -517,11 +518,25 @@ extension MenuSessionsInjector { switch mode { case .remote: platform = "remote" - let target = AppStateStore.shared.remoteTarget - if let parsed = CommandResolver.parseSSHTarget(target) { - host = parsed.port == 22 ? parsed.host : "\(parsed.host):\(parsed.port)" + if AppStateStore.shared.remoteTransport == .direct { + let trimmedUrl = AppStateStore.shared.remoteUrl + .trimmingCharacters(in: .whitespacesAndNewlines) + if let url = URL(string: trimmedUrl), let urlHost = url.host, !urlHost.isEmpty { + if let port = url.port { + host = "\(urlHost):\(port)" + } else { + host = urlHost + } + } else { + host = trimmedUrl.nonEmpty + } } else { - host = target.nonEmpty + let target = AppStateStore.shared.remoteTarget + if let parsed = CommandResolver.parseSSHTarget(target) { + host = parsed.port == 22 ? parsed.host : "\(parsed.host):\(parsed.port)" + } else { + host = target.nonEmpty + } } case .local: platform = "local" diff --git a/apps/macos/Sources/Clawdbot/OnboardingView+Actions.swift b/apps/macos/Sources/Clawdbot/OnboardingView+Actions.swift index 5942be7604..874ae28a20 100644 --- a/apps/macos/Sources/Clawdbot/OnboardingView+Actions.swift +++ b/apps/macos/Sources/Clawdbot/OnboardingView+Actions.swift @@ -25,7 +25,11 @@ extension OnboardingView { self.preferredGatewayID = gateway.stableID GatewayDiscoveryPreferences.setPreferredStableID(gateway.stableID) - if let host = gateway.tailnetDns ?? gateway.lanHost { + if self.state.remoteTransport == .direct { + if let url = GatewayDiscoveryHelpers.directUrl(for: gateway) { + self.state.remoteUrl = url + } + } else if let host = GatewayDiscoveryHelpers.sanitizedTailnetHost(gateway.tailnetDns) ?? gateway.lanHost { let user = NSUserName() self.state.remoteTarget = GatewayDiscoveryModel.buildSSHTarget( user: user, diff --git a/apps/macos/Sources/Clawdbot/OnboardingView+Pages.swift b/apps/macos/Sources/Clawdbot/OnboardingView+Pages.swift index e32252e81b..5c5eead345 100644 --- a/apps/macos/Sources/Clawdbot/OnboardingView+Pages.swift +++ b/apps/macos/Sources/Clawdbot/OnboardingView+Pages.swift @@ -177,42 +177,67 @@ extension OnboardingView { VStack(alignment: .leading, spacing: 10) { Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 8) { GridRow { - Text("SSH target") + Text("Transport") .font(.callout.weight(.semibold)) .frame(width: labelWidth, alignment: .leading) - TextField("user@host[:port]", text: self.$state.remoteTarget) - .textFieldStyle(.roundedBorder) - .frame(width: fieldWidth) + Picker("Transport", selection: self.$state.remoteTransport) { + Text("SSH tunnel").tag(AppState.RemoteTransport.ssh) + Text("Direct (ws/wss)").tag(AppState.RemoteTransport.direct) + } + .pickerStyle(.segmented) + .frame(width: fieldWidth) } - GridRow { - Text("Identity file") - .font(.callout.weight(.semibold)) - .frame(width: labelWidth, alignment: .leading) - TextField("/Users/you/.ssh/id_ed25519", text: self.$state.remoteIdentity) - .textFieldStyle(.roundedBorder) - .frame(width: fieldWidth) + if self.state.remoteTransport == .direct { + GridRow { + Text("Gateway URL") + .font(.callout.weight(.semibold)) + .frame(width: labelWidth, alignment: .leading) + TextField("wss://gateway.example.ts.net", text: self.$state.remoteUrl) + .textFieldStyle(.roundedBorder) + .frame(width: fieldWidth) + } } - GridRow { - Text("Project root") - .font(.callout.weight(.semibold)) - .frame(width: labelWidth, alignment: .leading) - TextField("/home/you/Projects/clawdbot", text: self.$state.remoteProjectRoot) - .textFieldStyle(.roundedBorder) - .frame(width: fieldWidth) - } - GridRow { - Text("CLI path") - .font(.callout.weight(.semibold)) - .frame(width: labelWidth, alignment: .leading) - TextField( - "/Applications/Clawdbot.app/.../clawdbot", - text: self.$state.remoteCliPath) - .textFieldStyle(.roundedBorder) - .frame(width: fieldWidth) + if self.state.remoteTransport == .ssh { + GridRow { + Text("SSH target") + .font(.callout.weight(.semibold)) + .frame(width: labelWidth, alignment: .leading) + TextField("user@host[:port]", text: self.$state.remoteTarget) + .textFieldStyle(.roundedBorder) + .frame(width: fieldWidth) + } + GridRow { + Text("Identity file") + .font(.callout.weight(.semibold)) + .frame(width: labelWidth, alignment: .leading) + TextField("/Users/you/.ssh/id_ed25519", text: self.$state.remoteIdentity) + .textFieldStyle(.roundedBorder) + .frame(width: fieldWidth) + } + GridRow { + Text("Project root") + .font(.callout.weight(.semibold)) + .frame(width: labelWidth, alignment: .leading) + TextField("/home/you/Projects/clawdbot", text: self.$state.remoteProjectRoot) + .textFieldStyle(.roundedBorder) + .frame(width: fieldWidth) + } + GridRow { + Text("CLI path") + .font(.callout.weight(.semibold)) + .frame(width: labelWidth, alignment: .leading) + TextField( + "/Applications/Clawdbot.app/.../clawdbot", + text: self.$state.remoteCliPath) + .textFieldStyle(.roundedBorder) + .frame(width: fieldWidth) + } } } - Text("Tip: keep Tailscale enabled so your gateway stays reachable.") + Text(self.state.remoteTransport == .direct + ? "Tip: use Tailscale Serve so the gateway has a valid HTTPS cert." + : "Tip: keep Tailscale enabled so your gateway stays reachable.") .font(.footnote) .foregroundStyle(.secondary) .lineLimit(1) @@ -225,7 +250,10 @@ extension OnboardingView { } func gatewaySubtitle(for gateway: GatewayDiscoveryModel.DiscoveredGateway) -> String? { - if let host = gateway.tailnetDns ?? gateway.lanHost { + if self.state.remoteTransport == .direct { + return GatewayDiscoveryHelpers.directUrl(for: gateway) ?? "Gateway pairing only" + } + if let host = GatewayDiscoveryHelpers.sanitizedTailnetHost(gateway.tailnetDns) ?? gateway.lanHost { let portSuffix = gateway.sshPort != 22 ? " · ssh \(gateway.sshPort)" : "" return "\(host)\(portSuffix)" } diff --git a/src/config/config.gateway-remote-transport.test.ts b/src/config/config.gateway-remote-transport.test.ts new file mode 100644 index 0000000000..f1ed6f62d7 --- /dev/null +++ b/src/config/config.gateway-remote-transport.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it, vi } from "vitest"; + +describe("gateway.remote.transport", () => { + it("accepts direct transport", async () => { + vi.resetModules(); + const { validateConfigObject } = await import("./config.js"); + const res = validateConfigObject({ + gateway: { + remote: { + transport: "direct", + url: "wss://gateway.example.ts.net", + }, + }, + }); + expect(res.ok).toBe(true); + }); + + it("rejects unknown transport", async () => { + vi.resetModules(); + const { validateConfigObject } = await import("./config.js"); + const res = validateConfigObject({ + gateway: { + remote: { + transport: "udp", + }, + }, + }); + expect(res.ok).toBe(false); + if (!res.ok) { + expect(res.issues[0]?.path).toBe("gateway.remote.transport"); + } + }); +}); diff --git a/src/config/types.gateway.ts b/src/config/types.gateway.ts index bcf1a23aad..9f5d787c28 100644 --- a/src/config/types.gateway.ts +++ b/src/config/types.gateway.ts @@ -80,6 +80,8 @@ export type GatewayTailscaleConfig = { export type GatewayRemoteConfig = { /** Remote Gateway WebSocket URL (ws:// or wss://). */ url?: string; + /** Transport for macOS remote connections (ssh tunnel or direct WS). */ + transport?: "ssh" | "direct"; /** Token for remote auth (when the gateway requires token auth). */ token?: string; /** Password for remote auth (when the gateway requires password auth). */ diff --git a/src/config/zod-schema.ts b/src/config/zod-schema.ts index 2b16a7f02b..1a4f9c5d77 100644 --- a/src/config/zod-schema.ts +++ b/src/config/zod-schema.ts @@ -334,6 +334,7 @@ export const ClawdbotSchema = z remote: z .object({ url: z.string().optional(), + transport: z.union([z.literal("ssh"), z.literal("direct")]).optional(), token: z.string().optional(), password: z.string().optional(), tlsFingerprint: z.string().optional(),