feat(macos): add direct gateway transport
This commit is contained in:
committed by
Peter Steinberger
parent
2c5141d7df
commit
5330595a5a
@@ -24,6 +24,11 @@ final class AppState {
|
|||||||
case remote
|
case remote
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum RemoteTransport: String {
|
||||||
|
case ssh
|
||||||
|
case direct
|
||||||
|
}
|
||||||
|
|
||||||
var isPaused: Bool {
|
var isPaused: Bool {
|
||||||
didSet { self.ifNotPreview { UserDefaults.standard.set(self.isPaused, forKey: pauseDefaultsKey) } }
|
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 {
|
var canvasEnabled: Bool {
|
||||||
didSet { self.ifNotPreview { UserDefaults.standard.set(self.canvasEnabled, forKey: canvasEnabledKey) } }
|
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 {
|
var remoteIdentity: String {
|
||||||
didSet { self.ifNotPreview { UserDefaults.standard.set(self.remoteIdentity, forKey: remoteIdentityKey) } }
|
didSet { self.ifNotPreview { UserDefaults.standard.set(self.remoteIdentity, forKey: remoteIdentityKey) } }
|
||||||
}
|
}
|
||||||
@@ -265,11 +278,14 @@ final class AppState {
|
|||||||
let configRoot = ClawdbotConfigFile.loadDict()
|
let configRoot = ClawdbotConfigFile.loadDict()
|
||||||
let configGateway = configRoot["gateway"] as? [String: Any]
|
let configGateway = configRoot["gateway"] as? [String: Any]
|
||||||
let configRemoteUrl = (configGateway?["remote"] as? [String: Any])?["url"] as? String
|
let configRemoteUrl = (configGateway?["remote"] as? [String: Any])?["url"] as? String
|
||||||
|
let configRemoteTransport = AppState.remoteTransport(from: configRoot)
|
||||||
let resolvedConnectionMode = ConnectionModeResolver.resolve(root: configRoot).mode
|
let resolvedConnectionMode = ConnectionModeResolver.resolve(root: configRoot).mode
|
||||||
|
self.remoteTransport = configRemoteTransport
|
||||||
self.connectionMode = resolvedConnectionMode
|
self.connectionMode = resolvedConnectionMode
|
||||||
|
|
||||||
let storedRemoteTarget = UserDefaults.standard.string(forKey: remoteTargetKey) ?? ""
|
let storedRemoteTarget = UserDefaults.standard.string(forKey: remoteTargetKey) ?? ""
|
||||||
if resolvedConnectionMode == .remote,
|
if resolvedConnectionMode == .remote,
|
||||||
|
configRemoteTransport != .direct,
|
||||||
storedRemoteTarget.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
|
storedRemoteTarget.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
|
||||||
let host = AppState.remoteHost(from: configRemoteUrl)
|
let host = AppState.remoteHost(from: configRemoteUrl)
|
||||||
{
|
{
|
||||||
@@ -277,6 +293,7 @@ final class AppState {
|
|||||||
} else {
|
} else {
|
||||||
self.remoteTarget = storedRemoteTarget
|
self.remoteTarget = storedRemoteTarget
|
||||||
}
|
}
|
||||||
|
self.remoteUrl = configRemoteUrl?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
self.remoteIdentity = UserDefaults.standard.string(forKey: remoteIdentityKey) ?? ""
|
self.remoteIdentity = UserDefaults.standard.string(forKey: remoteIdentityKey) ?? ""
|
||||||
self.remoteProjectRoot = UserDefaults.standard.string(forKey: remoteProjectRootKey) ?? ""
|
self.remoteProjectRoot = UserDefaults.standard.string(forKey: remoteProjectRootKey) ?? ""
|
||||||
self.remoteCliPath = UserDefaults.standard.string(forKey: remoteCliPathKey) ?? ""
|
self.remoteCliPath = UserDefaults.standard.string(forKey: remoteCliPathKey) ?? ""
|
||||||
@@ -358,6 +375,7 @@ final class AppState {
|
|||||||
let hasRemoteUrl = !(remoteUrl?
|
let hasRemoteUrl = !(remoteUrl?
|
||||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
.isEmpty ?? true)
|
.isEmpty ?? true)
|
||||||
|
let remoteTransport = AppState.remoteTransport(from: root)
|
||||||
|
|
||||||
let desiredMode: ConnectionMode? = switch modeRaw {
|
let desiredMode: ConnectionMode? = switch modeRaw {
|
||||||
case "local":
|
case "local":
|
||||||
@@ -378,8 +396,17 @@ final class AppState {
|
|||||||
self.connectionMode = .remote
|
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
|
let targetMode = desiredMode ?? self.connectionMode
|
||||||
if targetMode == .remote,
|
if targetMode == .remote,
|
||||||
|
remoteTransport != .direct,
|
||||||
let host = AppState.remoteHost(from: remoteUrl)
|
let host = AppState.remoteHost(from: remoteUrl)
|
||||||
{
|
{
|
||||||
self.updateRemoteTarget(host: host)
|
self.updateRemoteTarget(host: host)
|
||||||
@@ -402,6 +429,8 @@ final class AppState {
|
|||||||
let connectionMode = self.connectionMode
|
let connectionMode = self.connectionMode
|
||||||
let remoteTarget = self.remoteTarget
|
let remoteTarget = self.remoteTarget
|
||||||
let remoteIdentity = self.remoteIdentity
|
let remoteIdentity = self.remoteIdentity
|
||||||
|
let remoteTransport = self.remoteTransport
|
||||||
|
let remoteUrl = self.remoteUrl
|
||||||
let desiredMode: String? = switch connectionMode {
|
let desiredMode: String? = switch connectionMode {
|
||||||
case .local:
|
case .local:
|
||||||
"local"
|
"local"
|
||||||
@@ -435,39 +464,60 @@ final class AppState {
|
|||||||
var remote = gateway["remote"] as? [String: Any] ?? [:]
|
var remote = gateway["remote"] as? [String: Any] ?? [:]
|
||||||
var remoteChanged = false
|
var remoteChanged = false
|
||||||
|
|
||||||
if let host = remoteHost {
|
if remoteTransport == .direct {
|
||||||
let existingUrl = (remote["url"] as? String)?
|
let trimmedUrl = remoteUrl.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
if trimmedUrl.isEmpty {
|
||||||
let parsedExisting = existingUrl.isEmpty ? nil : URL(string: existingUrl)
|
if remote["url"] != nil {
|
||||||
let scheme = parsedExisting?.scheme?.isEmpty == false ? parsedExisting?.scheme : "ws"
|
remote.removeValue(forKey: "url")
|
||||||
let port = parsedExisting?.port ?? 18789
|
remoteChanged = true
|
||||||
let desiredUrl = "\(scheme ?? "ws")://\(host):\(port)"
|
}
|
||||||
if existingUrl != desiredUrl {
|
} else if (remote["url"] as? String) != trimmedUrl {
|
||||||
remote["url"] = desiredUrl
|
remote["url"] = trimmedUrl
|
||||||
remoteChanged = true
|
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)
|
let sanitizedTarget = Self.sanitizeSSHTarget(remoteTarget)
|
||||||
if !sanitizedTarget.isEmpty {
|
if !sanitizedTarget.isEmpty {
|
||||||
if (remote["sshTarget"] as? String) != sanitizedTarget {
|
if (remote["sshTarget"] as? String) != sanitizedTarget {
|
||||||
remote["sshTarget"] = sanitizedTarget
|
remote["sshTarget"] = sanitizedTarget
|
||||||
|
remoteChanged = true
|
||||||
|
}
|
||||||
|
} else if remote["sshTarget"] != nil {
|
||||||
|
remote.removeValue(forKey: "sshTarget")
|
||||||
remoteChanged = true
|
remoteChanged = true
|
||||||
}
|
}
|
||||||
} else if remote["sshTarget"] != nil {
|
|
||||||
remote.removeValue(forKey: "sshTarget")
|
|
||||||
remoteChanged = true
|
|
||||||
}
|
|
||||||
|
|
||||||
let trimmedIdentity = remoteIdentity.trimmingCharacters(in: .whitespacesAndNewlines)
|
let trimmedIdentity = remoteIdentity.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
if !trimmedIdentity.isEmpty {
|
if !trimmedIdentity.isEmpty {
|
||||||
if (remote["sshIdentity"] as? String) != trimmedIdentity {
|
if (remote["sshIdentity"] as? String) != trimmedIdentity {
|
||||||
remote["sshIdentity"] = trimmedIdentity
|
remote["sshIdentity"] = trimmedIdentity
|
||||||
|
remoteChanged = true
|
||||||
|
}
|
||||||
|
} else if remote["sshIdentity"] != nil {
|
||||||
|
remote.removeValue(forKey: "sshIdentity")
|
||||||
remoteChanged = true
|
remoteChanged = true
|
||||||
}
|
}
|
||||||
} else if remote["sshIdentity"] != nil {
|
|
||||||
remote.removeValue(forKey: "sshIdentity")
|
|
||||||
remoteChanged = true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if remoteChanged {
|
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) {
|
func triggerVoiceEars(ttl: TimeInterval? = 5) {
|
||||||
self.earBoostTask?.cancel()
|
self.earBoostTask?.cancel()
|
||||||
self.earBoostActive = true
|
self.earBoostActive = true
|
||||||
@@ -621,8 +682,10 @@ extension AppState {
|
|||||||
state.iconOverride = .system
|
state.iconOverride = .system
|
||||||
state.heartbeatsEnabled = true
|
state.heartbeatsEnabled = true
|
||||||
state.connectionMode = .local
|
state.connectionMode = .local
|
||||||
|
state.remoteTransport = .ssh
|
||||||
state.canvasEnabled = true
|
state.canvasEnabled = true
|
||||||
state.remoteTarget = "user@example.com"
|
state.remoteTarget = "user@example.com"
|
||||||
|
state.remoteUrl = "wss://gateway.example.ts.net"
|
||||||
state.remoteIdentity = "~/.ssh/id_ed25519"
|
state.remoteIdentity = "~/.ssh/id_ed25519"
|
||||||
state.remoteProjectRoot = "~/Projects/clawdbot"
|
state.remoteProjectRoot = "~/Projects/clawdbot"
|
||||||
state.remoteCliPath = ""
|
state.remoteCliPath = ""
|
||||||
|
|||||||
47
apps/macos/Sources/Clawdbot/GatewayDiscoveryHelpers.swift
Normal file
47
apps/macos/Sources/Clawdbot/GatewayDiscoveryHelpers.swift
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,8 @@ import SwiftUI
|
|||||||
struct GatewayDiscoveryInlineList: View {
|
struct GatewayDiscoveryInlineList: View {
|
||||||
var discovery: GatewayDiscoveryModel
|
var discovery: GatewayDiscoveryModel
|
||||||
var currentTarget: String?
|
var currentTarget: String?
|
||||||
|
var currentUrl: String?
|
||||||
|
var transport: AppState.RemoteTransport
|
||||||
var onSelect: (GatewayDiscoveryModel.DiscoveredGateway) -> Void
|
var onSelect: (GatewayDiscoveryModel.DiscoveredGateway) -> Void
|
||||||
@State private var hoveredGatewayID: GatewayDiscoveryModel.DiscoveredGateway.ID?
|
@State private var hoveredGatewayID: GatewayDiscoveryModel.DiscoveredGateway.ID?
|
||||||
|
|
||||||
@@ -25,9 +27,8 @@ struct GatewayDiscoveryInlineList: View {
|
|||||||
} else {
|
} else {
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
ForEach(self.discovery.gateways.prefix(6)) { gateway in
|
ForEach(self.discovery.gateways.prefix(6)) { gateway in
|
||||||
let target = self.suggestedSSHTarget(gateway)
|
let display = self.displayInfo(for: gateway)
|
||||||
let selected = (target != nil && self.currentTarget?
|
let selected = display.selected
|
||||||
.trimmingCharacters(in: .whitespacesAndNewlines) == target)
|
|
||||||
|
|
||||||
Button {
|
Button {
|
||||||
withAnimation(.spring(response: 0.25, dampingFraction: 0.9)) {
|
withAnimation(.spring(response: 0.25, dampingFraction: 0.9)) {
|
||||||
@@ -40,7 +41,7 @@ struct GatewayDiscoveryInlineList: View {
|
|||||||
.font(.callout.weight(.semibold))
|
.font(.callout.weight(.semibold))
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
.truncationMode(.tail)
|
.truncationMode(.tail)
|
||||||
Text(target ?? "Gateway pairing only")
|
Text(display.label)
|
||||||
.font(.caption.monospaced())
|
.font(.caption.monospaced())
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
@@ -83,27 +84,26 @@ struct GatewayDiscoveryInlineList: View {
|
|||||||
.fill(Color(NSColor.controlBackgroundColor)))
|
.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? {
|
private func displayInfo(
|
||||||
let host = self.sanitizedTailnetHost(gateway.tailnetDns) ?? gateway.lanHost
|
for gateway: GatewayDiscoveryModel.DiscoveredGateway) -> (label: String, selected: Bool)
|
||||||
guard let host else { return nil }
|
{
|
||||||
let user = NSUserName()
|
switch self.transport {
|
||||||
return GatewayDiscoveryModel.buildSSHTarget(
|
case .direct:
|
||||||
user: user,
|
let url = GatewayDiscoveryHelpers.directUrl(for: gateway)
|
||||||
host: host,
|
let label = url ?? "Gateway pairing only"
|
||||||
port: gateway.sshPort)
|
let selected = url != nil && self.trimmed(self.currentUrl) == url
|
||||||
}
|
return (label, selected)
|
||||||
|
case .ssh:
|
||||||
private func sanitizedTailnetHost(_ host: String?) -> String? {
|
let target = GatewayDiscoveryHelpers.sshTarget(for: gateway)
|
||||||
guard let host else { return nil }
|
let label = target ?? "Gateway pairing only"
|
||||||
let trimmed = host.trimmingCharacters(in: .whitespacesAndNewlines)
|
let selected = target != nil && self.trimmed(self.currentTarget) == target
|
||||||
if trimmed.isEmpty { return nil }
|
return (label, selected)
|
||||||
if trimmed.hasSuffix(".internal.") || trimmed.hasSuffix(".internal") {
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
return trimmed
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func rowBackground(selected: Bool, hovered: Bool) -> Color {
|
private func rowBackground(selected: Bool, hovered: Bool) -> Color {
|
||||||
@@ -111,6 +111,10 @@ struct GatewayDiscoveryInlineList: View {
|
|||||||
if hovered { return Color.secondary.opacity(0.08) }
|
if hovered { return Color.secondary.opacity(0.08) }
|
||||||
return Color.clear
|
return Color.clear
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func trimmed(_ value: String?) -> String {
|
||||||
|
value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct GatewayDiscoveryMenu: View {
|
struct GatewayDiscoveryMenu: View {
|
||||||
|
|||||||
@@ -311,6 +311,19 @@ actor GatewayEndpointStore {
|
|||||||
token: token,
|
token: token,
|
||||||
password: password))
|
password: password))
|
||||||
case .remote:
|
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()
|
let port = await self.deps.remotePortIfRunning()
|
||||||
guard let port else {
|
guard let port else {
|
||||||
self.setState(.connecting(mode: .remote, detail: Self.remoteConnectingDetail))
|
self.setState(.connecting(mode: .remote, detail: Self.remoteConnectingDetail))
|
||||||
@@ -341,6 +354,24 @@ actor GatewayEndpointStore {
|
|||||||
code: 1,
|
code: 1,
|
||||||
userInfo: [NSLocalizedDescriptionKey: "Remote mode is not enabled"])
|
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)
|
let config = try await self.ensureRemoteConfig(detail: Self.remoteConnectingDetail)
|
||||||
guard let portInt = config.0.port, let port = UInt16(exactly: portInt) else {
|
guard let portInt = config.0.port, let port = UInt16(exactly: portInt) else {
|
||||||
throw NSError(
|
throw NSError(
|
||||||
@@ -401,6 +432,21 @@ actor GatewayEndpointStore {
|
|||||||
userInfo: [NSLocalizedDescriptionKey: "Remote mode is not enabled"])
|
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)
|
self.kickRemoteEnsureIfNeeded(detail: detail)
|
||||||
guard let ensure = self.remoteEnsure else {
|
guard let ensure = self.remoteEnsure else {
|
||||||
throw NSError(domain: "GatewayEndpoint", code: 1, userInfo: [NSLocalizedDescriptionKey: "Connecting…"])
|
throw NSError(domain: "GatewayEndpoint", code: 1, userInfo: [NSLocalizedDescriptionKey: "Connecting…"])
|
||||||
@@ -535,6 +581,31 @@ actor GatewayEndpointStore {
|
|||||||
return nil
|
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(
|
private static func resolveGatewayScheme(
|
||||||
root: [String: Any],
|
root: [String: Any],
|
||||||
env: [String: String]) -> String
|
env: [String: String]) -> String
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ struct GeneralSettings: View {
|
|||||||
@State private var showRemoteAdvanced = false
|
@State private var showRemoteAdvanced = false
|
||||||
private let isPreview = ProcessInfo.processInfo.isPreview
|
private let isPreview = ProcessInfo.processInfo.isPreview
|
||||||
private var isNixMode: Bool { ProcessInfo.processInfo.isNixMode }
|
private var isNixMode: Bool { ProcessInfo.processInfo.isNixMode }
|
||||||
|
private var remoteLabelWidth: CGFloat { 88 }
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ScrollView(.vertical) {
|
ScrollView(.vertical) {
|
||||||
@@ -104,7 +105,7 @@ struct GeneralSettings: View {
|
|||||||
Picker("Mode", selection: self.$state.connectionMode) {
|
Picker("Mode", selection: self.$state.connectionMode) {
|
||||||
Text("Not configured").tag(AppState.ConnectionMode.unconfigured)
|
Text("Not configured").tag(AppState.ConnectionMode.unconfigured)
|
||||||
Text("Local (this Mac)").tag(AppState.ConnectionMode.local)
|
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)
|
.pickerStyle(.menu)
|
||||||
.labelsHidden()
|
.labelsHidden()
|
||||||
@@ -136,60 +137,51 @@ struct GeneralSettings: View {
|
|||||||
|
|
||||||
private var remoteCard: some View {
|
private var remoteCard: some View {
|
||||||
VStack(alignment: .leading, spacing: 10) {
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
HStack(alignment: .center, spacing: 10) {
|
self.remoteTransportRow
|
||||||
Text("SSH")
|
|
||||||
.font(.callout.weight(.semibold))
|
if self.state.remoteTransport == .ssh {
|
||||||
.frame(width: 48, alignment: .leading)
|
self.remoteSshRow
|
||||||
TextField("user@host[:22]", text: self.$state.remoteTarget)
|
} else {
|
||||||
.textFieldStyle(.roundedBorder)
|
self.remoteDirectRow
|
||||||
.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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
GatewayDiscoveryInlineList(
|
GatewayDiscoveryInlineList(
|
||||||
discovery: self.gatewayDiscovery,
|
discovery: self.gatewayDiscovery,
|
||||||
currentTarget: self.state.remoteTarget)
|
currentTarget: self.state.remoteTarget,
|
||||||
|
currentUrl: self.state.remoteUrl,
|
||||||
|
transport: self.state.remoteTransport)
|
||||||
{ gateway in
|
{ gateway in
|
||||||
self.applyDiscoveredGateway(gateway)
|
self.applyDiscoveredGateway(gateway)
|
||||||
}
|
}
|
||||||
.padding(.leading, 58)
|
.padding(.leading, self.remoteLabelWidth + 10)
|
||||||
|
|
||||||
self.remoteStatusView
|
self.remoteStatusView
|
||||||
.padding(.leading, 58)
|
.padding(.leading, self.remoteLabelWidth + 10)
|
||||||
|
|
||||||
DisclosureGroup(isExpanded: self.$showRemoteAdvanced) {
|
if self.state.remoteTransport == .ssh {
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
DisclosureGroup(isExpanded: self.$showRemoteAdvanced) {
|
||||||
LabeledContent("Identity file") {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
TextField("/Users/you/.ssh/id_ed25519", text: self.$state.remoteIdentity)
|
LabeledContent("Identity file") {
|
||||||
.textFieldStyle(.roundedBorder)
|
TextField("/Users/you/.ssh/id_ed25519", text: self.$state.remoteIdentity)
|
||||||
.frame(width: 280)
|
.textFieldStyle(.roundedBorder)
|
||||||
}
|
.frame(width: 280)
|
||||||
LabeledContent("Project root") {
|
}
|
||||||
TextField("/home/you/Projects/clawdbot", text: self.$state.remoteProjectRoot)
|
LabeledContent("Project root") {
|
||||||
.textFieldStyle(.roundedBorder)
|
TextField("/home/you/Projects/clawdbot", text: self.$state.remoteProjectRoot)
|
||||||
.frame(width: 280)
|
.textFieldStyle(.roundedBorder)
|
||||||
}
|
.frame(width: 280)
|
||||||
LabeledContent("CLI path") {
|
}
|
||||||
TextField("/Applications/Clawdbot.app/.../clawdbot", text: self.$state.remoteCliPath)
|
LabeledContent("CLI path") {
|
||||||
.textFieldStyle(.roundedBorder)
|
TextField("/Applications/Clawdbot.app/.../clawdbot", text: self.$state.remoteCliPath)
|
||||||
.frame(width: 280)
|
.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
|
// Diagnostics
|
||||||
@@ -219,16 +211,89 @@ struct GeneralSettings: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Text("Tip: enable Tailscale for stable remote access.")
|
if self.state.remoteTransport == .ssh {
|
||||||
.font(.footnote)
|
Text("Tip: enable Tailscale for stable remote access.")
|
||||||
.foregroundStyle(.secondary)
|
.font(.footnote)
|
||||||
.lineLimit(1)
|
.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)
|
.transition(.opacity)
|
||||||
.onAppear { self.gatewayDiscovery.start() }
|
.onAppear { self.gatewayDiscovery.start() }
|
||||||
.onDisappear { self.gatewayDiscovery.stop() }
|
.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://<magicdns>).")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.padding(.leading, self.remoteLabelWidth + 10)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private var controlStatusLine: String {
|
private var controlStatusLine: String {
|
||||||
switch ControlChannel.shared.state {
|
switch ControlChannel.shared.state {
|
||||||
case .connected: "Connected"
|
case .connected: "Connected"
|
||||||
@@ -458,24 +523,36 @@ extension GeneralSettings {
|
|||||||
func testRemote() async {
|
func testRemote() async {
|
||||||
self.remoteStatus = .checking
|
self.remoteStatus = .checking
|
||||||
let settings = CommandResolver.connectionSettings()
|
let settings = CommandResolver.connectionSettings()
|
||||||
guard !settings.target.isEmpty else {
|
if self.state.remoteTransport == .direct {
|
||||||
self.remoteStatus = .failed("Set an SSH target first")
|
let trimmedUrl = self.state.remoteUrl.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
return
|
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
|
// Step 2: control channel health 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
|
|
||||||
let originalMode = AppStateStore.shared.connectionMode
|
let originalMode = AppStateStore.shared.connectionMode
|
||||||
do {
|
do {
|
||||||
try await ControlChannel.shared.configure(mode: .remote(
|
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] {
|
private static func sshCheckCommand(target: String, identity: String) -> [String] {
|
||||||
var args: [String] = [
|
var args: [String] = [
|
||||||
"/usr/bin/ssh",
|
"/usr/bin/ssh",
|
||||||
@@ -570,12 +655,18 @@ extension GeneralSettings {
|
|||||||
let host = gateway.tailnetDns ?? gateway.lanHost
|
let host = gateway.tailnetDns ?? gateway.lanHost
|
||||||
guard let host else { return }
|
guard let host else { return }
|
||||||
let user = NSUserName()
|
let user = NSUserName()
|
||||||
self.state.remoteTarget = GatewayDiscoveryModel.buildSSHTarget(
|
if self.state.remoteTransport == .direct {
|
||||||
user: user,
|
if let url = GatewayDiscoveryHelpers.directUrl(for: gateway) {
|
||||||
host: host,
|
self.state.remoteUrl = url
|
||||||
port: gateway.sshPort)
|
}
|
||||||
self.state.remoteCliPath = gateway.cliPath ?? ""
|
} else {
|
||||||
ClawdbotConfigFile.setRemoteGatewayUrl(host: host, port: gateway.gatewayPort)
|
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() {
|
static func exerciseForTesting() {
|
||||||
let state = AppState(preview: true)
|
let state = AppState(preview: true)
|
||||||
state.connectionMode = .remote
|
state.connectionMode = .remote
|
||||||
|
state.remoteTransport = .ssh
|
||||||
state.remoteTarget = "user@host:2222"
|
state.remoteTarget = "user@host:2222"
|
||||||
|
state.remoteUrl = "wss://gateway.example.ts.net"
|
||||||
state.remoteIdentity = "/tmp/id_ed25519"
|
state.remoteIdentity = "/tmp/id_ed25519"
|
||||||
state.remoteProjectRoot = "/tmp/clawdbot"
|
state.remoteProjectRoot = "/tmp/clawdbot"
|
||||||
state.remoteCliPath = "/tmp/clawdbot"
|
state.remoteCliPath = "/tmp/clawdbot"
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import AppKit
|
import AppKit
|
||||||
|
import Foundation
|
||||||
import Observation
|
import Observation
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
@@ -517,11 +518,25 @@ extension MenuSessionsInjector {
|
|||||||
switch mode {
|
switch mode {
|
||||||
case .remote:
|
case .remote:
|
||||||
platform = "remote"
|
platform = "remote"
|
||||||
let target = AppStateStore.shared.remoteTarget
|
if AppStateStore.shared.remoteTransport == .direct {
|
||||||
if let parsed = CommandResolver.parseSSHTarget(target) {
|
let trimmedUrl = AppStateStore.shared.remoteUrl
|
||||||
host = parsed.port == 22 ? parsed.host : "\(parsed.host):\(parsed.port)"
|
.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 {
|
} 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:
|
case .local:
|
||||||
platform = "local"
|
platform = "local"
|
||||||
|
|||||||
@@ -25,7 +25,11 @@ extension OnboardingView {
|
|||||||
self.preferredGatewayID = gateway.stableID
|
self.preferredGatewayID = gateway.stableID
|
||||||
GatewayDiscoveryPreferences.setPreferredStableID(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()
|
let user = NSUserName()
|
||||||
self.state.remoteTarget = GatewayDiscoveryModel.buildSSHTarget(
|
self.state.remoteTarget = GatewayDiscoveryModel.buildSSHTarget(
|
||||||
user: user,
|
user: user,
|
||||||
|
|||||||
@@ -177,42 +177,67 @@ extension OnboardingView {
|
|||||||
VStack(alignment: .leading, spacing: 10) {
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 8) {
|
Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 8) {
|
||||||
GridRow {
|
GridRow {
|
||||||
Text("SSH target")
|
Text("Transport")
|
||||||
.font(.callout.weight(.semibold))
|
.font(.callout.weight(.semibold))
|
||||||
.frame(width: labelWidth, alignment: .leading)
|
.frame(width: labelWidth, alignment: .leading)
|
||||||
TextField("user@host[:port]", text: self.$state.remoteTarget)
|
Picker("Transport", selection: self.$state.remoteTransport) {
|
||||||
.textFieldStyle(.roundedBorder)
|
Text("SSH tunnel").tag(AppState.RemoteTransport.ssh)
|
||||||
.frame(width: fieldWidth)
|
Text("Direct (ws/wss)").tag(AppState.RemoteTransport.direct)
|
||||||
|
}
|
||||||
|
.pickerStyle(.segmented)
|
||||||
|
.frame(width: fieldWidth)
|
||||||
}
|
}
|
||||||
GridRow {
|
if self.state.remoteTransport == .direct {
|
||||||
Text("Identity file")
|
GridRow {
|
||||||
.font(.callout.weight(.semibold))
|
Text("Gateway URL")
|
||||||
.frame(width: labelWidth, alignment: .leading)
|
.font(.callout.weight(.semibold))
|
||||||
TextField("/Users/you/.ssh/id_ed25519", text: self.$state.remoteIdentity)
|
.frame(width: labelWidth, alignment: .leading)
|
||||||
.textFieldStyle(.roundedBorder)
|
TextField("wss://gateway.example.ts.net", text: self.$state.remoteUrl)
|
||||||
.frame(width: fieldWidth)
|
.textFieldStyle(.roundedBorder)
|
||||||
|
.frame(width: fieldWidth)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
GridRow {
|
if self.state.remoteTransport == .ssh {
|
||||||
Text("Project root")
|
GridRow {
|
||||||
.font(.callout.weight(.semibold))
|
Text("SSH target")
|
||||||
.frame(width: labelWidth, alignment: .leading)
|
.font(.callout.weight(.semibold))
|
||||||
TextField("/home/you/Projects/clawdbot", text: self.$state.remoteProjectRoot)
|
.frame(width: labelWidth, alignment: .leading)
|
||||||
.textFieldStyle(.roundedBorder)
|
TextField("user@host[:port]", text: self.$state.remoteTarget)
|
||||||
.frame(width: fieldWidth)
|
.textFieldStyle(.roundedBorder)
|
||||||
}
|
.frame(width: fieldWidth)
|
||||||
GridRow {
|
}
|
||||||
Text("CLI path")
|
GridRow {
|
||||||
.font(.callout.weight(.semibold))
|
Text("Identity file")
|
||||||
.frame(width: labelWidth, alignment: .leading)
|
.font(.callout.weight(.semibold))
|
||||||
TextField(
|
.frame(width: labelWidth, alignment: .leading)
|
||||||
"/Applications/Clawdbot.app/.../clawdbot",
|
TextField("/Users/you/.ssh/id_ed25519", text: self.$state.remoteIdentity)
|
||||||
text: self.$state.remoteCliPath)
|
.textFieldStyle(.roundedBorder)
|
||||||
.textFieldStyle(.roundedBorder)
|
.frame(width: fieldWidth)
|
||||||
.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)
|
.font(.footnote)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
@@ -225,7 +250,10 @@ extension OnboardingView {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func gatewaySubtitle(for gateway: GatewayDiscoveryModel.DiscoveredGateway) -> String? {
|
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)" : ""
|
let portSuffix = gateway.sshPort != 22 ? " · ssh \(gateway.sshPort)" : ""
|
||||||
return "\(host)\(portSuffix)"
|
return "\(host)\(portSuffix)"
|
||||||
}
|
}
|
||||||
|
|||||||
33
src/config/config.gateway-remote-transport.test.ts
Normal file
33
src/config/config.gateway-remote-transport.test.ts
Normal file
@@ -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");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -80,6 +80,8 @@ export type GatewayTailscaleConfig = {
|
|||||||
export type GatewayRemoteConfig = {
|
export type GatewayRemoteConfig = {
|
||||||
/** Remote Gateway WebSocket URL (ws:// or wss://). */
|
/** Remote Gateway WebSocket URL (ws:// or wss://). */
|
||||||
url?: string;
|
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 for remote auth (when the gateway requires token auth). */
|
||||||
token?: string;
|
token?: string;
|
||||||
/** Password for remote auth (when the gateway requires password auth). */
|
/** Password for remote auth (when the gateway requires password auth). */
|
||||||
|
|||||||
@@ -334,6 +334,7 @@ export const ClawdbotSchema = z
|
|||||||
remote: z
|
remote: z
|
||||||
.object({
|
.object({
|
||||||
url: z.string().optional(),
|
url: z.string().optional(),
|
||||||
|
transport: z.union([z.literal("ssh"), z.literal("direct")]).optional(),
|
||||||
token: z.string().optional(),
|
token: z.string().optional(),
|
||||||
password: z.string().optional(),
|
password: z.string().optional(),
|
||||||
tlsFingerprint: z.string().optional(),
|
tlsFingerprint: z.string().optional(),
|
||||||
|
|||||||
Reference in New Issue
Block a user