From 903a1ad931084d61e9f6d3ec8b1f803a129c85f7 Mon Sep 17 00:00:00 2001 From: happydog-intj Date: Fri, 13 Mar 2026 14:49:10 +0800 Subject: [PATCH] chore(macos): apply swiftformat to upstream-added Swift files --- apps/macos/Sources/OpenClaw/AppState.swift | 43 ++- .../Sources/OpenClaw/GeneralSettings.swift | 132 ++------ .../NodeMode/MacNodeBrowserProxy.swift | 4 +- .../OpenClaw/OnboardingView+Pages.swift | 284 ++++++++++++++++-- .../Sources/OpenClaw/RemoteGatewayProbe.swift | 237 +++++++++++++++ .../OpenClaw/TalkModeGatewayConfig.swift | 8 +- 6 files changed, 550 insertions(+), 158 deletions(-) create mode 100644 apps/macos/Sources/OpenClaw/RemoteGatewayProbe.swift diff --git a/apps/macos/Sources/OpenClaw/AppState.swift b/apps/macos/Sources/OpenClaw/AppState.swift index 5e8238ebe92..77402d7796f 100644 --- a/apps/macos/Sources/OpenClaw/AppState.swift +++ b/apps/macos/Sources/OpenClaw/AppState.swift @@ -600,30 +600,29 @@ final class AppState { private func syncGatewayConfigIfNeeded() { guard !self.isPreview, !self.isInitializing else { return } - let connectionMode = self.connectionMode - let remoteTarget = self.remoteTarget - let remoteIdentity = self.remoteIdentity - let remoteTransport = self.remoteTransport - let remoteUrl = self.remoteUrl - let remoteToken = self.remoteToken - let remoteTokenDirty = self.remoteTokenDirty - Task { @MainActor in - // Keep app-only connection settings local to avoid overwriting remote gateway config. - let synced = Self.syncedGatewayRoot( - currentRoot: OpenClawConfigFile.loadDict(), - connectionMode: connectionMode, - remoteTransport: remoteTransport, - remoteTarget: remoteTarget, - remoteIdentity: remoteIdentity, - remoteUrl: remoteUrl, - remoteToken: remoteToken, - remoteTokenDirty: remoteTokenDirty) - guard synced.changed else { return } - OpenClawConfigFile.saveDict(synced.root) + self.syncGatewayConfigNow() } } + @MainActor + func syncGatewayConfigNow() { + guard !self.isPreview, !self.isInitializing else { return } + + // Keep app-only connection settings local to avoid overwriting remote gateway config. + let synced = Self.syncedGatewayRoot( + currentRoot: OpenClawConfigFile.loadDict(), + connectionMode: self.connectionMode, + remoteTransport: self.remoteTransport, + remoteTarget: self.remoteTarget, + remoteIdentity: self.remoteIdentity, + remoteUrl: self.remoteUrl, + remoteToken: self.remoteToken, + remoteTokenDirty: self.remoteTokenDirty) + guard synced.changed else { return } + OpenClawConfigFile.saveDict(synced.root) + } + func triggerVoiceEars(ttl: TimeInterval? = 5) { self.earBoostTask?.cancel() self.earBoostActive = true @@ -784,7 +783,7 @@ extension AppState { remoteToken: String, remoteTokenDirty: Bool) -> [String: Any] { - Self.updatedRemoteGatewayConfig( + self.updatedRemoteGatewayConfig( current: current, transport: transport, remoteUrl: remoteUrl, @@ -805,7 +804,7 @@ extension AppState { remoteToken: String, remoteTokenDirty: Bool) -> [String: Any] { - Self.syncedGatewayRoot( + self.syncedGatewayRoot( currentRoot: currentRoot, connectionMode: connectionMode, remoteTransport: remoteTransport, diff --git a/apps/macos/Sources/OpenClaw/GeneralSettings.swift b/apps/macos/Sources/OpenClaw/GeneralSettings.swift index b55ed439489..633879367ea 100644 --- a/apps/macos/Sources/OpenClaw/GeneralSettings.swift +++ b/apps/macos/Sources/OpenClaw/GeneralSettings.swift @@ -348,10 +348,18 @@ struct GeneralSettings: View { Text("Testing…") .font(.caption) .foregroundStyle(.secondary) - case .ok: - Label("Ready", systemImage: "checkmark.circle.fill") - .font(.caption) - .foregroundStyle(.green) + case let .ok(success): + VStack(alignment: .leading, spacing: 2) { + Label(success.title, systemImage: "checkmark.circle.fill") + .font(.caption) + .foregroundStyle(.green) + if let detail = success.detail { + Text(detail) + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + } case let .failed(message): Text(message) .font(.caption) @@ -518,7 +526,7 @@ struct GeneralSettings: View { private enum RemoteStatus: Equatable { case idle case checking - case ok + case ok(RemoteGatewayProbeSuccess) case failed(String) } @@ -558,114 +566,14 @@ extension GeneralSettings { @MainActor func testRemote() async { self.remoteStatus = .checking - let settings = CommandResolver.connectionSettings() - 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 use wss:// for remote hosts (ws:// only for localhost)") - return - } - } else { - guard !settings.target.isEmpty else { - self.remoteStatus = .failed("Set an SSH target first") - return - } - - // Step 1: basic SSH reachability check - guard let sshCommand = Self.sshCheckCommand( - target: settings.target, - identity: settings.identity) - else { - self.remoteStatus = .failed("SSH target is invalid") - return - } - let sshResult = await ShellExecutor.run( - command: sshCommand, - cwd: nil, - env: nil, - timeout: 8) - - guard sshResult.ok else { - self.remoteStatus = .failed(self.formatSSHFailure(sshResult, target: settings.target)) - return - } + switch await RemoteGatewayProbe.run() { + case let .ready(success): + self.remoteStatus = .ok(success) + case let .authIssue(issue): + self.remoteStatus = .failed(issue.statusMessage) + case let .failed(message): + self.remoteStatus = .failed(message) } - - // Step 2: control channel health check - let originalMode = AppStateStore.shared.connectionMode - do { - try await ControlChannel.shared.configure(mode: .remote( - target: settings.target, - identity: settings.identity)) - let data = try await ControlChannel.shared.health(timeout: 10) - if decodeHealthSnapshot(from: data) != nil { - self.remoteStatus = .ok - } else { - self.remoteStatus = .failed("Control channel returned invalid health JSON") - } - } catch { - self.remoteStatus = .failed(error.localizedDescription) - } - - // Restore original mode if we temporarily switched - switch originalMode { - case .remote: - break - case .local: - try? await ControlChannel.shared.configure(mode: .local) - case .unconfigured: - await ControlChannel.shared.disconnect() - } - } - - private static func isValidWsUrl(_ raw: String) -> Bool { - GatewayRemoteConfig.normalizeGatewayUrl(raw) != nil - } - - private static func sshCheckCommand(target: String, identity: String) -> [String]? { - guard let parsed = CommandResolver.parseSSHTarget(target) else { return nil } - let options = [ - "-o", "BatchMode=yes", - "-o", "ConnectTimeout=5", - "-o", "StrictHostKeyChecking=accept-new", - "-o", "UpdateHostKeys=yes", - ] - let args = CommandResolver.sshArguments( - target: parsed, - identity: identity, - options: options, - remoteCommand: ["echo", "ok"]) - return ["/usr/bin/ssh"] + args - } - - private func formatSSHFailure(_ response: Response, target: String) -> String { - let payload = response.payload.flatMap { String(data: $0, encoding: .utf8) } - let trimmed = payload? - .trimmingCharacters(in: .whitespacesAndNewlines) - .split(whereSeparator: \.isNewline) - .joined(separator: " ") - if let trimmed, - trimmed.localizedCaseInsensitiveContains("host key verification failed") - { - let host = CommandResolver.parseSSHTarget(target)?.host ?? target - return "SSH check failed: Host key verification failed. Remove the old key with " + - "`ssh-keygen -R \(host)` and try again." - } - if let trimmed, !trimmed.isEmpty { - if let message = response.message, message.hasPrefix("exit ") { - return "SSH check failed: \(trimmed) (\(message))" - } - return "SSH check failed: \(trimmed)" - } - if let message = response.message { - return "SSH check failed (\(message))" - } - return "SSH check failed" } private func revealLogs() { diff --git a/apps/macos/Sources/OpenClaw/NodeMode/MacNodeBrowserProxy.swift b/apps/macos/Sources/OpenClaw/NodeMode/MacNodeBrowserProxy.swift index 367907f9fb7..b43141a3139 100644 --- a/apps/macos/Sources/OpenClaw/NodeMode/MacNodeBrowserProxy.swift +++ b/apps/macos/Sources/OpenClaw/NodeMode/MacNodeBrowserProxy.swift @@ -147,7 +147,9 @@ actor MacNodeBrowserProxy { } if method != "GET", let body = params.body { - request.httpBody = try JSONSerialization.data(withJSONObject: body.foundationValue, options: [.fragmentsAllowed]) + request.httpBody = try JSONSerialization.data( + withJSONObject: body.foundationValue, + options: [.fragmentsAllowed]) request.setValue("application/json", forHTTPHeaderField: "Content-Type") } diff --git a/apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift b/apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift index 8f4d16420bc..d1831d104fc 100644 --- a/apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift +++ b/apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift @@ -2,6 +2,7 @@ import AppKit import OpenClawChatUI import OpenClawDiscovery import OpenClawIPC +import OpenClawKit import SwiftUI extension OnboardingView { @@ -97,6 +98,11 @@ extension OnboardingView { self.gatewayDiscoverySection() + if self.shouldShowRemoteConnectionSection { + Divider().padding(.vertical, 4) + self.remoteConnectionSection() + } + self.connectionChoiceButton( title: "Configure later", subtitle: "Don’t start the Gateway yet.", @@ -109,6 +115,22 @@ extension OnboardingView { } } } + .onChange(of: self.state.connectionMode) { _, newValue in + guard Self.shouldResetRemoteProbeFeedback( + for: newValue, + suppressReset: self.suppressRemoteProbeReset) + else { return } + self.resetRemoteProbeFeedback() + } + .onChange(of: self.state.remoteTransport) { _, _ in + self.resetRemoteProbeFeedback() + } + .onChange(of: self.state.remoteTarget) { _, _ in + self.resetRemoteProbeFeedback() + } + .onChange(of: self.state.remoteUrl) { _, _ in + self.resetRemoteProbeFeedback() + } } private var localGatewaySubtitle: String { @@ -199,25 +221,6 @@ extension OnboardingView { .pickerStyle(.segmented) .frame(width: fieldWidth) } - GridRow { - Text("Gateway token") - .font(.callout.weight(.semibold)) - .frame(width: labelWidth, alignment: .leading) - SecureField("remote gateway auth token (gateway.remote.token)", text: self.$state.remoteToken) - .textFieldStyle(.roundedBorder) - .frame(width: fieldWidth) - } - if self.state.remoteTokenUnsupported { - GridRow { - Text("") - .frame(width: labelWidth, alignment: .leading) - Text( - "The current gateway.remote.token value is not plain text. OpenClaw for macOS cannot use it directly; enter a plaintext token here to replace it.") - .font(.caption) - .foregroundStyle(.orange) - .frame(width: fieldWidth, alignment: .leading) - } - } if self.state.remoteTransport == .direct { GridRow { Text("Gateway URL") @@ -289,6 +292,249 @@ extension OnboardingView { } } + private var shouldShowRemoteConnectionSection: Bool { + self.state.connectionMode == .remote || + self.showAdvancedConnection || + self.remoteProbeState != .idle || + self.remoteAuthIssue != nil || + Self.shouldShowRemoteTokenField( + showAdvancedConnection: self.showAdvancedConnection, + remoteToken: self.state.remoteToken, + remoteTokenUnsupported: self.state.remoteTokenUnsupported, + authIssue: self.remoteAuthIssue) + } + + private var shouldShowRemoteTokenField: Bool { + guard self.shouldShowRemoteConnectionSection else { return false } + return Self.shouldShowRemoteTokenField( + showAdvancedConnection: self.showAdvancedConnection, + remoteToken: self.state.remoteToken, + remoteTokenUnsupported: self.state.remoteTokenUnsupported, + authIssue: self.remoteAuthIssue) + } + + private var remoteProbePreflightMessage: String? { + switch self.state.remoteTransport { + case .direct: + let trimmedUrl = self.state.remoteUrl.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmedUrl.isEmpty { + return "Select a nearby gateway or open Advanced to enter a gateway URL." + } + if GatewayRemoteConfig.normalizeGatewayUrl(trimmedUrl) == nil { + return "Gateway URL must use wss:// for remote hosts (ws:// only for localhost)." + } + return nil + case .ssh: + let trimmedTarget = self.state.remoteTarget.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmedTarget.isEmpty { + return "Select a nearby gateway or open Advanced to enter an SSH target." + } + return CommandResolver.sshTargetValidationMessage(trimmedTarget) + } + } + + private var canProbeRemoteConnection: Bool { + self.remoteProbePreflightMessage == nil && self.remoteProbeState != .checking + } + + private func remoteConnectionSection() -> some View { + VStack(alignment: .leading, spacing: 10) { + HStack(alignment: .top, spacing: 12) { + VStack(alignment: .leading, spacing: 2) { + Text("Remote connection") + .font(.callout.weight(.semibold)) + Text("Checks the real remote websocket and auth handshake.") + .font(.caption) + .foregroundStyle(.secondary) + } + Spacer(minLength: 0) + Button { + Task { await self.probeRemoteConnection() } + } label: { + if self.remoteProbeState == .checking { + ProgressView() + .controlSize(.small) + .frame(minWidth: 120) + } else { + Text("Check connection") + .frame(minWidth: 120) + } + } + .buttonStyle(.borderedProminent) + .disabled(!self.canProbeRemoteConnection) + } + + if self.shouldShowRemoteTokenField { + self.remoteTokenField() + } + + if let message = self.remoteProbePreflightMessage, self.remoteProbeState != .checking { + Text(message) + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + + self.remoteProbeStatusView() + + if let issue = self.remoteAuthIssue { + self.remoteAuthPromptView(issue: issue) + } + } + } + + private func remoteTokenField() -> some View { + VStack(alignment: .leading, spacing: 6) { + HStack(alignment: .center, spacing: 12) { + Text("Gateway token") + .font(.callout.weight(.semibold)) + .frame(width: 110, alignment: .leading) + SecureField("remote gateway auth token (gateway.remote.token)", text: self.$state.remoteToken) + .textFieldStyle(.roundedBorder) + .frame(maxWidth: 320) + } + Text("Used when the remote gateway requires token auth.") + .font(.caption) + .foregroundStyle(.secondary) + if self.state.remoteTokenUnsupported { + Text( + "The current gateway.remote.token value is not plain text. OpenClaw for macOS cannot use it directly; enter a plaintext token here to replace it.") + .font(.caption) + .foregroundStyle(.orange) + .fixedSize(horizontal: false, vertical: true) + } + } + } + + @ViewBuilder + private func remoteProbeStatusView() -> some View { + switch self.remoteProbeState { + case .idle: + EmptyView() + case .checking: + Text("Checking remote gateway…") + .font(.caption) + .foregroundStyle(.secondary) + case let .ok(success): + VStack(alignment: .leading, spacing: 2) { + Label(success.title, systemImage: "checkmark.circle.fill") + .font(.caption) + .foregroundStyle(.green) + if let detail = success.detail { + Text(detail) + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + } + case let .failed(message): + if self.remoteAuthIssue == nil { + Text(message) + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + } + } + + private func remoteAuthPromptView(issue: RemoteGatewayAuthIssue) -> some View { + let promptStyle = Self.remoteAuthPromptStyle(for: issue) + return HStack(alignment: .top, spacing: 10) { + Image(systemName: promptStyle.systemImage) + .font(.caption.weight(.semibold)) + .foregroundStyle(promptStyle.tint) + .frame(width: 16, alignment: .center) + .padding(.top, 1) + VStack(alignment: .leading, spacing: 4) { + Text(issue.title) + .font(.caption.weight(.semibold)) + Text(.init(issue.body)) + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + if let footnote = issue.footnote { + Text(.init(footnote)) + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + } + } + } + + @MainActor + private func probeRemoteConnection() async { + let originalMode = self.state.connectionMode + let shouldRestoreMode = originalMode != .remote + if shouldRestoreMode { + // Reuse the shared remote endpoint stack for probing without committing the user's mode choice. + self.state.connectionMode = .remote + } + self.remoteProbeState = .checking + self.remoteAuthIssue = nil + defer { + if shouldRestoreMode { + self.suppressRemoteProbeReset = true + self.state.connectionMode = originalMode + self.suppressRemoteProbeReset = false + } + } + + switch await RemoteGatewayProbe.run() { + case let .ready(success): + self.remoteProbeState = .ok(success) + case let .authIssue(issue): + self.remoteAuthIssue = issue + self.remoteProbeState = .failed(issue.statusMessage) + case let .failed(message): + self.remoteProbeState = .failed(message) + } + } + + private func resetRemoteProbeFeedback() { + self.remoteProbeState = .idle + self.remoteAuthIssue = nil + } + + static func remoteAuthPromptStyle( + for issue: RemoteGatewayAuthIssue) + -> (systemImage: String, tint: Color) + { + switch issue { + case .tokenRequired: + ("key.fill", .orange) + case .tokenMismatch: + ("exclamationmark.triangle.fill", .orange) + case .gatewayTokenNotConfigured: + ("wrench.and.screwdriver.fill", .orange) + case .setupCodeExpired: + ("qrcode.viewfinder", .orange) + case .passwordRequired: + ("lock.slash.fill", .orange) + case .pairingRequired: + ("link.badge.plus", .orange) + } + } + + static func shouldShowRemoteTokenField( + showAdvancedConnection: Bool, + remoteToken: String, + remoteTokenUnsupported: Bool, + authIssue: RemoteGatewayAuthIssue?) -> Bool + { + showAdvancedConnection || + remoteTokenUnsupported || + !remoteToken.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || + authIssue?.showsTokenField == true + } + + static func shouldResetRemoteProbeFeedback( + for connectionMode: AppState.ConnectionMode, + suppressReset: Bool) -> Bool + { + !suppressReset && connectionMode != .remote + } + func gatewaySubtitle(for gateway: GatewayDiscoveryModel.DiscoveredGateway) -> String? { if self.state.remoteTransport == .direct { return GatewayDiscoveryHelpers.directUrl(for: gateway) ?? "Gateway pairing only" diff --git a/apps/macos/Sources/OpenClaw/RemoteGatewayProbe.swift b/apps/macos/Sources/OpenClaw/RemoteGatewayProbe.swift new file mode 100644 index 00000000000..bde65c03495 --- /dev/null +++ b/apps/macos/Sources/OpenClaw/RemoteGatewayProbe.swift @@ -0,0 +1,237 @@ +import Foundation +import OpenClawIPC +import OpenClawKit + +enum RemoteGatewayAuthIssue: Equatable { + case tokenRequired + case tokenMismatch + case gatewayTokenNotConfigured + case setupCodeExpired + case passwordRequired + case pairingRequired + + init?(error: Error) { + guard let authError = error as? GatewayConnectAuthError else { + return nil + } + switch authError.detail { + case .authTokenMissing: + self = .tokenRequired + case .authTokenMismatch: + self = .tokenMismatch + case .authTokenNotConfigured: + self = .gatewayTokenNotConfigured + case .authBootstrapTokenInvalid: + self = .setupCodeExpired + case .authPasswordMissing, .authPasswordMismatch, .authPasswordNotConfigured: + self = .passwordRequired + case .pairingRequired: + self = .pairingRequired + default: + return nil + } + } + + var showsTokenField: Bool { + switch self { + case .tokenRequired, .tokenMismatch: + true + case .gatewayTokenNotConfigured, .setupCodeExpired, .passwordRequired, .pairingRequired: + false + } + } + + var title: String { + switch self { + case .tokenRequired: + "This gateway requires an auth token" + case .tokenMismatch: + "That token did not match the gateway" + case .gatewayTokenNotConfigured: + "This gateway host needs token setup" + case .setupCodeExpired: + "This setup code is no longer valid" + case .passwordRequired: + "This gateway is using unsupported auth" + case .pairingRequired: + "This device needs pairing approval" + } + } + + var body: String { + switch self { + case .tokenRequired: + "Paste the token configured on the gateway host. On the gateway host, run `openclaw config get gateway.auth.token`. If the gateway uses an environment variable instead, use `OPENCLAW_GATEWAY_TOKEN`." + case .tokenMismatch: + "Check `gateway.auth.token` or `OPENCLAW_GATEWAY_TOKEN` on the gateway host and try again." + case .gatewayTokenNotConfigured: + "This gateway is set to token auth, but no `gateway.auth.token` is configured on the gateway host. If the gateway uses an environment variable instead, set `OPENCLAW_GATEWAY_TOKEN` before starting the gateway." + case .setupCodeExpired: + "Scan or paste a fresh setup code from an already-paired OpenClaw client, then try again." + case .passwordRequired: + "This onboarding flow does not support password auth yet. Reconfigure the gateway to use token auth, then retry." + case .pairingRequired: + "Approve this device from an already-paired OpenClaw client. In your OpenClaw chat, run `/pair approve`, then click **Check connection** again." + } + } + + var footnote: String? { + switch self { + case .tokenRequired, .gatewayTokenNotConfigured: + "No token yet? Generate one on the gateway host with `openclaw doctor --generate-gateway-token`, then set it as `gateway.auth.token`." + case .setupCodeExpired: + nil + case .pairingRequired: + "If you do not have another paired OpenClaw client yet, approve the pending request on the gateway host with `openclaw devices approve`." + case .tokenMismatch, .passwordRequired: + nil + } + } + + var statusMessage: String { + switch self { + case .tokenRequired: + "This gateway requires an auth token from the gateway host." + case .tokenMismatch: + "Gateway token mismatch. Check gateway.auth.token or OPENCLAW_GATEWAY_TOKEN on the gateway host." + case .gatewayTokenNotConfigured: + "This gateway has token auth enabled, but no gateway.auth.token is configured on the host." + case .setupCodeExpired: + "Setup code expired or already used. Scan a fresh setup code, then try again." + case .passwordRequired: + "This gateway uses password auth. Remote onboarding on macOS cannot collect gateway passwords yet." + case .pairingRequired: + "Pairing required. In an already-paired OpenClaw client, run /pair approve, then check the connection again." + } + } +} + +enum RemoteGatewayProbeResult: Equatable { + case ready(RemoteGatewayProbeSuccess) + case authIssue(RemoteGatewayAuthIssue) + case failed(String) +} + +struct RemoteGatewayProbeSuccess: Equatable { + let authSource: GatewayAuthSource? + + var title: String { + switch self.authSource { + case .some(.deviceToken): + "Connected via paired device" + case .some(.bootstrapToken): + "Connected with setup code" + case .some(.sharedToken): + "Connected with gateway token" + case .some(.password): + "Connected with password" + case .some(GatewayAuthSource.none), nil: + "Remote gateway ready" + } + } + + var detail: String? { + switch self.authSource { + case .some(.deviceToken): + "This Mac used a stored device token. New or unpaired devices may still need the gateway token." + case .some(.bootstrapToken): + "This Mac is still using the temporary setup code. Approve pairing to finish provisioning device-scoped auth." + case .some(.sharedToken), .some(.password), .some(GatewayAuthSource.none), nil: + nil + } + } +} + +enum RemoteGatewayProbe { + @MainActor + static func run() async -> RemoteGatewayProbeResult { + AppStateStore.shared.syncGatewayConfigNow() + let settings = CommandResolver.connectionSettings() + let transport = AppStateStore.shared.remoteTransport + + if transport == .direct { + let trimmedUrl = AppStateStore.shared.remoteUrl.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedUrl.isEmpty else { + return .failed("Set a gateway URL first") + } + guard self.isValidWsUrl(trimmedUrl) else { + return .failed("Gateway URL must use wss:// for remote hosts (ws:// only for localhost)") + } + } else { + let trimmedTarget = settings.target.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedTarget.isEmpty else { + return .failed("Set an SSH target first") + } + if let validationMessage = CommandResolver.sshTargetValidationMessage(trimmedTarget) { + return .failed(validationMessage) + } + guard let sshCommand = self.sshCheckCommand(target: settings.target, identity: settings.identity) else { + return .failed("SSH target is invalid") + } + + let sshResult = await ShellExecutor.run( + command: sshCommand, + cwd: nil, + env: nil, + timeout: 8) + guard sshResult.ok else { + return .failed(self.formatSSHFailure(sshResult, target: settings.target)) + } + } + + do { + _ = try await GatewayConnection.shared.healthSnapshot(timeoutMs: 10000) + let authSource = await GatewayConnection.shared.authSource() + return .ready(RemoteGatewayProbeSuccess(authSource: authSource)) + } catch { + if let authIssue = RemoteGatewayAuthIssue(error: error) { + return .authIssue(authIssue) + } + return .failed(error.localizedDescription) + } + } + + private static func isValidWsUrl(_ raw: String) -> Bool { + GatewayRemoteConfig.normalizeGatewayUrl(raw) != nil + } + + private static func sshCheckCommand(target: String, identity: String) -> [String]? { + guard let parsed = CommandResolver.parseSSHTarget(target) else { return nil } + let options = [ + "-o", "BatchMode=yes", + "-o", "ConnectTimeout=5", + "-o", "StrictHostKeyChecking=accept-new", + "-o", "UpdateHostKeys=yes", + ] + let args = CommandResolver.sshArguments( + target: parsed, + identity: identity, + options: options, + remoteCommand: ["echo", "ok"]) + return ["/usr/bin/ssh"] + args + } + + private static func formatSSHFailure(_ response: Response, target: String) -> String { + let payload = response.payload.flatMap { String(data: $0, encoding: .utf8) } + let trimmed = payload? + .trimmingCharacters(in: .whitespacesAndNewlines) + .split(whereSeparator: \.isNewline) + .joined(separator: " ") + if let trimmed, + trimmed.localizedCaseInsensitiveContains("host key verification failed") + { + let host = CommandResolver.parseSSHTarget(target)?.host ?? target + return "SSH check failed: Host key verification failed. Remove the old key with ssh-keygen -R \(host) and try again." + } + if let trimmed, !trimmed.isEmpty { + if let message = response.message, message.hasPrefix("exit ") { + return "SSH check failed: \(trimmed) (\(message))" + } + return "SSH check failed: \(trimmed)" + } + if let message = response.message { + return "SSH check failed (\(message))" + } + return "SSH check failed" + } +} diff --git a/apps/macos/Sources/OpenClaw/TalkModeGatewayConfig.swift b/apps/macos/Sources/OpenClaw/TalkModeGatewayConfig.swift index 15600b5ea0e..0f6026cd40e 100644 --- a/apps/macos/Sources/OpenClaw/TalkModeGatewayConfig.swift +++ b/apps/macos/Sources/OpenClaw/TalkModeGatewayConfig.swift @@ -23,8 +23,8 @@ enum TalkModeGatewayConfigParser { defaultSilenceTimeoutMs: Int, envVoice: String?, sagVoice: String?, - envApiKey: String? - ) -> TalkModeGatewayConfigState { + envApiKey: String?) -> TalkModeGatewayConfigState + { let talk = snapshot.config?["talk"]?.dictionaryValue let selection = TalkConfigParsing.selectProviderConfig(talk, defaultProvider: defaultProvider) let activeProvider = selection?.provider ?? defaultProvider @@ -81,8 +81,8 @@ enum TalkModeGatewayConfigParser { defaultSilenceTimeoutMs: Int, envVoice: String?, sagVoice: String?, - envApiKey: String? - ) -> TalkModeGatewayConfigState { + envApiKey: String?) -> TalkModeGatewayConfigState + { let resolvedVoice = (envVoice?.isEmpty == false ? envVoice : nil) ?? (sagVoice?.isEmpty == false ? sagVoice : nil)