diff --git a/__tests__/gateway-protocol.test.ts b/__tests__/gateway-protocol.test.ts index 988b23f..c63d671 100644 --- a/__tests__/gateway-protocol.test.ts +++ b/__tests__/gateway-protocol.test.ts @@ -50,7 +50,10 @@ describe('makeConnectRequest', () => { it('creates a connect request with password', () => { const req = makeConnectRequest('secret123') expect(req.method).toBe('connect') - expect((req.params as Record).auth).toEqual({ password: 'secret123' }) + expect((req.params as Record).auth).toEqual({ + password: 'secret123', + token: 'secret123', + }) }) it('includes stored token when provided', () => { diff --git a/components/agent-panel.tsx b/components/agent-panel.tsx index f4a27a6..f6bf0dd 100644 --- a/components/agent-panel.tsx +++ b/components/agent-panel.tsx @@ -26,7 +26,7 @@ import { DiffViewer } from '@/components/diff-viewer' import { parseEditProposals, type EditProposal } from '@/lib/edit-parser' import { diffEngine } from '@/lib/streaming-diff' import { handleChatEvent, type ChatMessage, type StreamState } from '@/lib/chat-stream' -import { isTauri } from '@/lib/tauri' +import { isTauri, tauriInvoke } from '@/lib/tauri' import { buildEditorPatchSnippet, generateCommitMessageWithGateway, @@ -65,19 +65,270 @@ import { // ChatMessage type imported from @/lib/chat-stream +type SshTunnelConfig = { + destination: string + sshPort?: number + localPort?: number + remotePort?: number + identityPath?: string | null +} + +type SshTunnelStatus = { + active: boolean + pid: number | null + localUrl: string | null + config: SshTunnelConfig | null +} + +type SshTunnelForm = { + destination: string + sshPort: string + localPort: string + remotePort: string + identityPath: string +} + +type TunnelHealthState = { + state: 'idle' | 'checking' | 'ok' | 'error' + message: string +} + +const SSH_TUNNEL_STORAGE_KEY = 'code-editor:ssh-tunnel-config' +const DEFAULT_SSH_TUNNEL_FORM: SshTunnelForm = { + destination: '', + sshPort: '22', + localPort: '28789', + remotePort: '18789', + identityPath: '', +} + +function loadSshTunnelForm(): SshTunnelForm { + try { + const raw = localStorage.getItem(SSH_TUNNEL_STORAGE_KEY) + if (!raw) return DEFAULT_SSH_TUNNEL_FORM + const parsed = JSON.parse(raw) as Partial + return { + destination: parsed.destination ?? DEFAULT_SSH_TUNNEL_FORM.destination, + sshPort: parsed.sshPort ?? DEFAULT_SSH_TUNNEL_FORM.sshPort, + localPort: parsed.localPort ?? DEFAULT_SSH_TUNNEL_FORM.localPort, + remotePort: parsed.remotePort ?? DEFAULT_SSH_TUNNEL_FORM.remotePort, + identityPath: parsed.identityPath ?? DEFAULT_SSH_TUNNEL_FORM.identityPath, + } + } catch { + return DEFAULT_SSH_TUNNEL_FORM + } +} + +function getUnknownErrorMessage(error: unknown, fallback: string): string { + if (error instanceof Error && error.message) return error.message + if (typeof error === 'string' && error.trim()) return error + if (error && typeof error === 'object' && 'message' in error) { + const message = (error as { message?: unknown }).message + if (typeof message === 'string' && message.trim()) return message + } + return fallback +} + +function getHealthUrlFromWs(url: string): string | null { + try { + const parsed = new URL(url) + if (parsed.protocol === 'ws:') parsed.protocol = 'http:' + if (parsed.protocol === 'wss:') parsed.protocol = 'https:' + parsed.pathname = '/health' + parsed.search = '' + parsed.hash = '' + return parsed.toString() + } catch { + return null + } +} + function AgentConnectPrompt() { const { status, error, connect } = useGateway() const isMobileDevice = typeof window !== 'undefined' && window.innerWidth <= 768 + const isDesktopTauri = typeof window !== 'undefined' && isTauri() && !isMobileDevice const [url, setUrl] = useState(isMobileDevice ? '' : 'ws://localhost:18789') const [password, setPassword] = useState('') + const [sshMode, setSshMode] = useState(false) + const [sshForm, setSshForm] = useState(() => + typeof window === 'undefined' ? DEFAULT_SSH_TUNNEL_FORM : loadSshTunnelForm(), + ) + const [sshStatus, setSshStatus] = useState(null) + const [sshBusy, setSshBusy] = useState(false) + const [sshError, setSshError] = useState(null) + const [tunnelHealth, setTunnelHealth] = useState({ + state: 'idle', + message: '', + }) const isConnecting = status === 'connecting' || status === 'authenticating' + const gatewayError = error?.toLowerCase() ?? '' + const pairingRequired = gatewayError.includes('pairing required') + const gatewayTokenMissing = + gatewayError.includes('gateway token missing') || gatewayError.includes('unauthorized') const handleConnect = () => { if (!url.trim()) return connect(url.trim(), password) } + const refreshSshStatus = useCallback(async () => { + if (!isDesktopTauri) return null + try { + const nextStatus = await tauriInvoke('ssh_tunnel_status', {}) + if (!nextStatus) return null + setSshStatus(nextStatus) + if (nextStatus.active && nextStatus.localUrl) { + setSshMode(true) + setUrl(nextStatus.localUrl) + setSshForm((prev) => ({ + ...prev, + localPort: nextStatus.config?.localPort + ? String(nextStatus.config.localPort) + : prev.localPort, + })) + } + return nextStatus + } catch { + return null + } + }, [isDesktopTauri]) + + useEffect(() => { + refreshSshStatus() + }, [refreshSshStatus]) + + useEffect(() => { + if (!isDesktopTauri) return + try { + localStorage.setItem(SSH_TUNNEL_STORAGE_KEY, JSON.stringify(sshForm)) + } catch {} + }, [isDesktopTauri, sshForm]) + + const updateSshForm = useCallback((key: keyof SshTunnelForm, value: string) => { + setSshForm((prev) => ({ ...prev, [key]: value })) + }, []) + + const handleStartSshTunnel = useCallback(async () => { + if (!isDesktopTauri) return + + const destination = sshForm.destination.trim() + const sshPort = Number.parseInt(sshForm.sshPort, 10) + const localPort = Number.parseInt(sshForm.localPort, 10) + const remotePort = Number.parseInt(sshForm.remotePort, 10) + + if (!destination) { + setSshError('SSH destination is required.') + return + } + if ([sshPort, localPort, remotePort].some((value) => Number.isNaN(value) || value <= 0)) { + setSshError('SSH, local, and remote ports must be positive numbers.') + return + } + + setSshBusy(true) + setSshError(null) + try { + const nextStatus = await tauriInvoke('ssh_tunnel_start', { + config: { + destination, + sshPort, + localPort, + remotePort, + identityPath: sshForm.identityPath.trim() || null, + }, + }) + if (!nextStatus?.localUrl) { + throw new Error('SSH tunnel started without a forwarded local URL.') + } + setSshStatus(nextStatus) + setSshMode(true) + setUrl(nextStatus.localUrl) + setSshForm((prev) => ({ + ...prev, + localPort: nextStatus.config?.localPort + ? String(nextStatus.config.localPort) + : prev.localPort, + })) + } catch (err) { + setSshError(getUnknownErrorMessage(err, 'Failed to start SSH tunnel.')) + } finally { + setSshBusy(false) + } + }, [isDesktopTauri, sshForm]) + + const handleStopSshTunnel = useCallback(async () => { + if (!isDesktopTauri) return + setSshBusy(true) + setSshError(null) + try { + const nextStatus = await tauriInvoke('ssh_tunnel_stop', {}) + setSshStatus(nextStatus) + if (url === sshStatus?.localUrl) { + setUrl('ws://localhost:18789') + } + setTunnelHealth({ state: 'idle', message: '' }) + } catch (err) { + setSshError(getUnknownErrorMessage(err, 'Failed to stop SSH tunnel.')) + } finally { + setSshBusy(false) + } + }, [isDesktopTauri, sshStatus?.localUrl, url]) + + useEffect(() => { + if (!sshStatus?.active || !sshStatus.localUrl) { + setTunnelHealth({ state: 'idle', message: '' }) + return + } + + const healthUrl = getHealthUrlFromWs(sshStatus.localUrl) + if (!healthUrl) { + setTunnelHealth({ + state: 'error', + message: 'Tunnel started, but the forwarded gateway URL could not be verified.', + }) + return + } + + const controller = new AbortController() + setTunnelHealth({ + state: 'checking', + message: 'Checking the forwarded gateway health endpoint…', + }) + + fetch(healthUrl, { signal: controller.signal }) + .then(async (response) => { + if (!response.ok) throw new Error(`Health check returned ${response.status}`) + const payload = (await response.json().catch(() => null)) as { + ok?: boolean + status?: string + } | null + if (payload?.ok) { + setTunnelHealth({ + state: 'ok', + message: 'Tunnel OK. Enter the gateway auth token and connect.', + }) + return + } + setTunnelHealth({ + state: 'ok', + message: 'Tunnel is forwarding traffic. Enter the gateway auth token and connect.', + }) + }) + .catch((err) => { + if (controller.signal.aborted) return + setTunnelHealth({ + state: 'error', + message: + err instanceof Error + ? `Tunnel is up, but /health failed: ${err.message}` + : 'Tunnel is up, but the forwarded gateway did not respond to /health.', + }) + }) + + return () => controller.abort() + }, [sshStatus?.active, sshStatus?.localUrl]) + return (
{/* Animated connection icon */} @@ -108,62 +359,263 @@ function AgentConnectPrompt() { ) : ( <>

- Connect to Gateway + {sshMode ? 'Connect to Remote Gateway' : 'Connect to Gateway'}

- {isMobileDevice - ? 'Enter your gateway address to start chatting.' - : 'Make sure OpenClaw is running on this machine.'} + {sshMode + ? 'Start the SSH tunnel first, then enter the OpenClaw gateway token for the remote host.' + : isMobileDevice + ? 'Enter your gateway address to start chatting.' + : 'Make sure OpenClaw is running on this machine.'}

- {/* URL input */} -
- - setUrl(e.target.value)} - onKeyDown={(e) => { - if (e.key === 'Enter') handleConnect() - }} - placeholder={isMobileDevice ? 'wss://your-gateway.ts.net' : 'ws://localhost:18789'} - className="w-full pl-10 pr-3 py-3.5 rounded-xl bg-[var(--bg)] border border-[var(--border)] text-[14px] font-mono text-[var(--text-primary)] placeholder:text-[var(--text-disabled)] outline-none focus:border-[var(--brand)] focus:ring-1 focus:ring-[color-mix(in_srgb,var(--brand)_30%,transparent)] transition-all" - autoCapitalize="off" - autoCorrect="off" - spellCheck={false} - /> + {isDesktopTauri && ( + + )} + + {isDesktopTauri && sshMode && ( +
+
+ {[ + { step: '1', title: 'Start tunnel', active: !sshStatus?.active }, + { + step: '2', + title: 'Enter token', + active: sshStatus?.active && !pairingRequired, + }, + { step: '3', title: 'Approve device', active: pairingRequired }, + ].map((item) => ( +
+

Step {item.step}

+

{item.title}

+
+ ))} +
+ +
+
+ + updateSshForm('destination', e.target.value)} + placeholder="SSH destination (user@host or alias)" + className="w-full pl-9 pr-3 py-2.5 rounded-lg bg-[var(--bg)] border border-[var(--border)] text-[12px] font-mono text-[var(--text-primary)] placeholder:text-[var(--text-disabled)] outline-none focus:border-[var(--brand)]" + autoCapitalize="off" + autoCorrect="off" + spellCheck={false} + /> +
+
+ updateSshForm('sshPort', e.target.value)} + placeholder="SSH port" + className="w-full px-3 py-2.5 rounded-lg bg-[var(--bg)] border border-[var(--border)] text-[12px] font-mono text-[var(--text-primary)] placeholder:text-[var(--text-disabled)] outline-none focus:border-[var(--brand)]" + /> + updateSshForm('localPort', e.target.value)} + placeholder="Local port" + className="w-full px-3 py-2.5 rounded-lg bg-[var(--bg)] border border-[var(--border)] text-[12px] font-mono text-[var(--text-primary)] placeholder:text-[var(--text-disabled)] outline-none focus:border-[var(--brand)]" + /> + updateSshForm('remotePort', e.target.value)} + placeholder="Gateway port" + className="w-full px-3 py-2.5 rounded-lg bg-[var(--bg)] border border-[var(--border)] text-[12px] font-mono text-[var(--text-primary)] placeholder:text-[var(--text-disabled)] outline-none focus:border-[var(--brand)]" + /> +
+ updateSshForm('identityPath', e.target.value)} + placeholder="Identity file" + className="w-full px-3 py-2.5 rounded-lg bg-[var(--bg)] border border-[var(--border)] text-[12px] font-mono text-[var(--text-primary)] placeholder:text-[var(--text-disabled)] outline-none focus:border-[var(--brand)]" + autoCapitalize="off" + autoCorrect="off" + spellCheck={false} + /> +

+ Required unless this host already works through your local SSH agent or + `~/.ssh/config`. +

+
+ + How to create an identity file + +
+

Create a key on your Mac:

+
+                        ssh-keygen -t ed25519 -C "you@example.com"
+                      
+

This usually creates `~/.ssh/id_ed25519` and `~/.ssh/id_ed25519.pub`.

+

Copy the public key to the remote host:

+
+                        ssh-copy-id -i ~/.ssh/id_ed25519.pub user@host
+                      
+

+ Then enter the private key path here, for example ` + /Users/you/.ssh/id_ed25519`. +

+
+
+
+ +
+ + +
+ + {sshStatus?.active && sshStatus.localUrl && ( +
+

Tunnel active

+

{sshStatus.localUrl}

+
+ +

+ {tunnelHealth.message || + 'SSH auth uses your local SSH config and agent. Interactive password prompts are not supported in-app.'} +

+
+

+ SSH auth uses your local SSH config and agent. Interactive password prompts + are not supported in-app. +

+
+ )} +
+ )} + +
+ +
+ + setUrl(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') handleConnect() + }} + placeholder={ + isMobileDevice ? 'wss://your-gateway.ts.net' : 'ws://localhost:18789' + } + className="w-full pl-10 pr-3 py-3.5 rounded-xl bg-[var(--bg)] border border-[var(--border)] text-[14px] font-mono text-[var(--text-primary)] placeholder:text-[var(--text-disabled)] outline-none focus:border-[var(--brand)] focus:ring-1 focus:ring-[color-mix(in_srgb,var(--brand)_30%,transparent)] transition-all" + autoCapitalize="off" + autoCorrect="off" + spellCheck={false} + readOnly={sshMode && Boolean(sshStatus?.active)} + /> +
- {/* Password input */} -
- - setPassword(e.target.value)} - onKeyDown={(e) => { - if (e.key === 'Enter') handleConnect() - }} - placeholder="Password (optional)" - className="w-full pl-10 pr-3 py-3.5 rounded-xl bg-[var(--bg)] border border-[var(--border)] text-[14px] font-mono text-[var(--text-primary)] placeholder:text-[var(--text-disabled)] outline-none focus:border-[var(--brand)] focus:ring-1 focus:ring-[color-mix(in_srgb,var(--brand)_30%,transparent)] transition-all" - /> +
+ +
+ + setPassword(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') handleConnect() + }} + placeholder="OpenClaw gateway token or password" + className="w-full pl-10 pr-3 py-3.5 rounded-xl bg-[var(--bg)] border border-[var(--border)] text-[14px] font-mono text-[var(--text-primary)] placeholder:text-[var(--text-disabled)] outline-none focus:border-[var(--brand)] focus:ring-1 focus:ring-[color-mix(in_srgb,var(--brand)_30%,transparent)] transition-all" + /> +
+

+ This is the OpenClaw gateway secret from the remote host, not your SSH password. +

{/* Connect button */}
)} + {sshError && ( +
+ + {sshError} +
+ )} + {pairingRequired && ( +
+

Device approval required

+

+ On the remote host, approve this Mac once, then click Connect again: +

+ + openclaw devices list + + + openclaw devices approve <request-id> + +
+ )} + {gatewayTokenMissing && !pairingRequired && ( +
+

Gateway token required

+

+ Enter the OpenClaw gateway secret from the remote host config, then connect again. +

+
+ )} {/* Tips */} {isMobileDevice && ( @@ -205,13 +690,21 @@ function AgentConnectPrompt() {
)} {!isMobileDevice && ( -

- Run{' '} - - openclaw gateway start - {' '} - to start -

+
+

+ Run{' '} + + openclaw gateway start + {' '} + for local use. +

+ {isDesktopTauri && ( +

+ For remote hosts, start an SSH tunnel here and connect through the forwarded + localhost port. +

+ )} +
)} )} diff --git a/components/settings-panel.tsx b/components/settings-panel.tsx index 927170d..fa5bd9e 100644 --- a/components/settings-panel.tsx +++ b/components/settings-panel.tsx @@ -1,6 +1,6 @@ 'use client' -import { useMemo, useRef, useState, useCallback } from 'react' +import { useMemo, useRef, useState, useCallback, useEffect } from 'react' import { motion } from 'framer-motion' import { Icon } from '@iconify/react' import { MobileConnect } from './mobile-connect' @@ -9,6 +9,7 @@ import { CaffeinateToggle } from './caffeinate-toggle' import { KnotLogo } from './knot-logo' import { useGateway } from '@/context/gateway-context' import { useGitHubAuth } from '@/context/github-auth-context' +import { isTauri, tauriInvoke } from '@/lib/tauri' import { fetchAuthenticatedUser, startDeviceFlow, @@ -66,12 +67,28 @@ function groupThemes() { })) } +type SshTunnelConfig = { + destination: string + sshPort?: number + localPort?: number + remotePort?: number + identityPath?: string | null +} + +type SshTunnelStatus = { + active: boolean + pid: number | null + localUrl: string | null + config: SshTunnelConfig | null +} + /** * Settings Panel — single scrollable view for the left sidepanel. * All sections inline, Connect at the bottom. */ export function SettingsPanel({ onBack }: { onBack: () => void }) { - const { status, gatewayUrl } = useGateway() + const { status, gatewayUrl, disconnect } = useGateway() + const isDesktopTauri = typeof window !== 'undefined' && isTauri() const { themeId, setThemeId, @@ -98,6 +115,9 @@ export function SettingsPanel({ onBack }: { onBack: () => void }) { const [loadingRepos, setLoadingRepos] = useState(false) const [repoSearch, setRepoSearch] = useState('') const [showGatewayUrl, setShowGatewayUrl] = useState(false) + const [sshTunnelStatus, setSshTunnelStatus] = useState(null) + const [sshTunnelBusy, setSshTunnelBusy] = useState(false) + const [sshTunnelError, setSshTunnelError] = useState(null) const [approvalTier, setApprovalTierState] = useState(() => { try { return getAgentConfig()?.approvalTier ?? 'ask-all' @@ -119,6 +139,41 @@ export function SettingsPanel({ onBack }: { onBack: () => void }) { } catch {} }, []) + const refreshSshTunnelStatus = useCallback(async () => { + if (!isDesktopTauri) return + try { + const nextStatus = await tauriInvoke('ssh_tunnel_status', {}) + setSshTunnelStatus(nextStatus) + setSshTunnelError(null) + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to load SSH tunnel status.' + setSshTunnelError(message) + } + }, [isDesktopTauri]) + + useEffect(() => { + refreshSshTunnelStatus() + }, [refreshSshTunnelStatus, status]) + + const handleStopSshTunnel = useCallback(async () => { + if (!isDesktopTauri) return + + setSshTunnelBusy(true) + setSshTunnelError(null) + try { + if (status === 'connected' || status === 'connecting' || status === 'authenticating') { + disconnect() + } + const nextStatus = await tauriInvoke('ssh_tunnel_stop', {}) + setSshTunnelStatus(nextStatus) + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to stop SSH tunnel.' + setSshTunnelError(message) + } finally { + setSshTunnelBusy(false) + } + }, [disconnect, isDesktopTauri, status]) + const ghTokenRef = useRef(ghToken) if (ghTokenRef.current !== ghToken) { ghTokenRef.current = ghToken @@ -737,6 +792,55 @@ export function SettingsPanel({ onBack }: { onBack: () => void }) {
)} + + {(status === 'connected' || status === 'connecting' || status === 'authenticating') && ( +
+ Session + +
+ )} + + {sshTunnelStatus?.active && ( + <> +
+ SSH tunnel +
+
+ {sshTunnelStatus.localUrl ?? 'Forwarded localhost'} +
+ {sshTunnelStatus.config?.destination && ( +

+ {sshTunnelStatus.config.destination} +

+ )} +
+
+ +
+ Port forward + +
+ + )} + + {sshTunnelError && ( +
+ {sshTunnelError} +
+ )}
diff --git a/context/gateway-context.tsx b/context/gateway-context.tsx index 251d754..13192c9 100644 --- a/context/gateway-context.tsx +++ b/context/gateway-context.tsx @@ -161,6 +161,7 @@ export function GatewayProvider({ children }: { children: React.ReactNode }) { const scopes = ['operator.read', 'operator.write', 'operator.admin'] const signedAt = Date.now() const existingToken = loadDeviceToken(identity.deviceId, role) + const authTokenForSignature = existingToken ?? (password.trim() || null) const authPayload = buildDeviceAuthPayload({ deviceId: identity.deviceId, @@ -169,7 +170,7 @@ export function GatewayProvider({ children }: { children: React.ReactNode }) { role, scopes, signedAtMs: signedAt, - token: existingToken, + token: authTokenForSignature, nonce, }) const signature = await signPayload(identity.privateKey, authPayload) diff --git a/lib/gateway-protocol.ts b/lib/gateway-protocol.ts index 5b813e5..784094c 100644 --- a/lib/gateway-protocol.ts +++ b/lib/gateway-protocol.ts @@ -509,6 +509,7 @@ export function makeConnectRequest( device?: DeviceBlock, storedToken?: string ): GatewayRequest { + const authSecret = password.trim(); return { type: "req", id: makeId(), @@ -526,7 +527,10 @@ export function makeConnectRequest( role: "operator", scopes: ["operator.read", "operator.write", "operator.admin"], caps: [], - auth: { password, ...(storedToken ? { token: storedToken } : {}) }, + auth: { + ...(authSecret ? { password: authSecret, token: authSecret } : {}), + ...(storedToken ? { token: storedToken } : {}), + }, ...(device ? { device } : {}), }, }; diff --git a/output/screenshots/knotcode-settings-connect-card.png b/output/screenshots/knotcode-settings-connect-card.png new file mode 100644 index 0000000..b53a44d Binary files /dev/null and b/output/screenshots/knotcode-settings-connect-card.png differ diff --git a/output/screenshots/knotcode-settings-connect-full.png b/output/screenshots/knotcode-settings-connect-full.png new file mode 100644 index 0000000..c1d13cf Binary files /dev/null and b/output/screenshots/knotcode-settings-connect-full.png differ diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 29f8e37..cbba266 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -27,6 +27,9 @@ "allow-engine-stop", "allow-engine-restart", "allow-engine-gateway-config", + "allow-ssh-tunnel-start", + "allow-ssh-tunnel-stop", + "allow-ssh-tunnel-status", "allow-local-read-tree", "allow-local-read-file", "allow-local-read-file-base64", diff --git a/src-tauri/permissions/default.toml b/src-tauri/permissions/default.toml index 5ca92cb..4a29599 100644 --- a/src-tauri/permissions/default.toml +++ b/src-tauri/permissions/default.toml @@ -45,6 +45,21 @@ identifier = "allow-engine-gateway-config" description = "Allows reading the gateway configuration" commands.allow = ["engine_gateway_config"] +[[permission]] +identifier = "allow-ssh-tunnel-start" +description = "Allows starting an SSH tunnel for remote gateway forwarding" +commands.allow = ["ssh_tunnel_start"] + +[[permission]] +identifier = "allow-ssh-tunnel-stop" +description = "Allows stopping an active SSH tunnel" +commands.allow = ["ssh_tunnel_stop"] + +[[permission]] +identifier = "allow-ssh-tunnel-status" +description = "Allows reading SSH tunnel status" +commands.allow = ["ssh_tunnel_status"] + [[permission]] identifier = "allow-local-read-tree" description = "Allows reading the file tree of a local directory" diff --git a/src-tauri/src/engine.rs b/src-tauri/src/engine.rs index c74ca43..eba9a95 100644 --- a/src-tauri/src/engine.rs +++ b/src-tauri/src/engine.rs @@ -1,6 +1,16 @@ -use serde::Serialize; +use serde::{Deserialize, Serialize}; use std::process::Command; +#[cfg(not(target_os = "ios"))] +use std::{ + io::Read, + net::TcpListener, + process::{Child, Stdio}, + sync::Mutex, + thread, + time::Duration, +}; + #[derive(Clone, Serialize)] pub struct EngineStatus { pub installed: bool, @@ -120,6 +130,298 @@ pub struct GatewayConfig { pub password: String, } +#[cfg(not(target_os = "ios"))] +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SshTunnelConfig { + pub destination: String, + pub ssh_port: Option, + pub local_port: Option, + pub remote_port: Option, + pub identity_path: Option, +} + +#[cfg(not(target_os = "ios"))] +#[derive(Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SshTunnelStatus { + pub active: bool, + pub pid: Option, + pub local_url: Option, + pub config: Option, +} + +#[cfg(not(target_os = "ios"))] +struct ActiveSshTunnel { + child: Child, + config: SshTunnelConfig, + local_url: String, +} + +#[cfg(not(target_os = "ios"))] +#[derive(Default)] +pub struct SshTunnelManager { + tunnel: Mutex>, +} + +#[cfg(not(target_os = "ios"))] +impl Drop for SshTunnelManager { + fn drop(&mut self) { + if let Ok(mut tunnel) = self.tunnel.lock() { + if let Some(active) = tunnel.as_mut() { + let _ = stop_child(&mut active.child); + } + *tunnel = None; + } + } +} + +#[cfg(not(target_os = "ios"))] +fn stop_child(child: &mut Child) -> Result<(), String> { + match child.try_wait() { + Ok(Some(_)) => Ok(()), + Ok(None) => { + child + .kill() + .map_err(|e| format!("Failed to stop SSH tunnel: {}", e))?; + let _ = child.wait(); + Ok(()) + } + Err(e) => Err(format!("Failed to inspect SSH tunnel state: {}", e)), + } +} + +#[cfg(not(target_os = "ios"))] +fn refresh_tunnel_slot(slot: &mut Option) { + let should_clear = match slot.as_mut() { + Some(active) => active + .child + .try_wait() + .map(|status| status.is_some()) + .unwrap_or(true), + None => false, + }; + if should_clear { + *slot = None; + } +} + +#[cfg(not(target_os = "ios"))] +fn tunnel_status_from_slot(slot: &Option) -> SshTunnelStatus { + match slot.as_ref() { + Some(active) => SshTunnelStatus { + active: true, + pid: Some(active.child.id()), + local_url: Some(active.local_url.clone()), + config: Some(active.config.clone()), + }, + None => SshTunnelStatus { + active: false, + pid: None, + local_url: None, + config: None, + }, + } +} + +#[cfg(not(target_os = "ios"))] +fn normalize_tunnel_config(config: SshTunnelConfig) -> Result { + let destination = config.destination.trim(); + if destination.is_empty() { + return Err("SSH destination is required".to_string()); + } + + let identity_path = config + .identity_path + .as_ref() + .map(|path| path.trim()) + .filter(|path| !path.is_empty()) + .map(|path| path.to_string()); + + Ok(SshTunnelConfig { + destination: destination.to_string(), + ssh_port: Some(config.ssh_port.unwrap_or(22)), + local_port: Some(config.local_port.unwrap_or(28789)), + remote_port: Some(config.remote_port.unwrap_or(18789)), + identity_path, + }) +} + +#[cfg(not(target_os = "ios"))] +fn build_local_url(local_port: u16) -> String { + format!("ws://127.0.0.1:{}", local_port) +} + +#[cfg(not(target_os = "ios"))] +fn explain_ssh_tunnel_error(details: &str, config: &SshTunnelConfig) -> String { + let normalized = details.to_lowercase(); + + if normalized.contains("host key verification failed") { + return format!( + "SSH host verification failed. Connect to {} once in Terminal to trust the host key, then try again. {}", + config.destination, details + ); + } + + if normalized.contains("permission denied (publickey,password)") + || normalized.contains("permission denied (publickey)") + || normalized.contains("permission denied") + { + if config.identity_path.is_none() { + return format!( + "SSH authentication failed. Add an Identity file, or make sure this host already works through your local ssh-agent or ~/.ssh/config. {}", + details + ); + } + + return format!( + "SSH authentication failed with the selected Identity file. Verify that the key path is correct and that the public key is authorized on the remote host. {}", + details + ); + } + + if normalized.contains("could not resolve hostname") { + return format!( + "SSH could not resolve the host. Check the destination or your ~/.ssh/config alias. {}", + details + ); + } + + if normalized.contains("connection refused") || normalized.contains("operation timed out") { + return format!( + "SSH could not reach the remote host. Check the SSH port, host, and network access. {}", + details + ); + } + + details.to_string() +} + +#[cfg(not(target_os = "ios"))] +fn pick_available_local_port(preferred_port: u16) -> Result { + const MAX_PORT_SEARCH: u16 = 24; + + for offset in 0..=MAX_PORT_SEARCH { + let candidate = preferred_port.saturating_add(offset); + if candidate == 0 { + continue; + } + + if let Ok(listener) = TcpListener::bind(("127.0.0.1", candidate)) { + drop(listener); + return Ok(candidate); + } + } + + Err(format!( + "No available local tunnel ports found near {}", + preferred_port + )) +} + +#[cfg(not(target_os = "ios"))] +#[tauri::command] +pub fn ssh_tunnel_status(state: tauri::State) -> Result { + let mut tunnel = state + .tunnel + .lock() + .map_err(|_| "Failed to access SSH tunnel state".to_string())?; + refresh_tunnel_slot(&mut tunnel); + Ok(tunnel_status_from_slot(&tunnel)) +} + +#[cfg(not(target_os = "ios"))] +#[tauri::command] +pub fn ssh_tunnel_stop(state: tauri::State) -> Result { + let mut tunnel = state + .tunnel + .lock() + .map_err(|_| "Failed to access SSH tunnel state".to_string())?; + if let Some(active) = tunnel.as_mut() { + stop_child(&mut active.child)?; + } + *tunnel = None; + Ok(tunnel_status_from_slot(&tunnel)) +} + +#[cfg(not(target_os = "ios"))] +#[tauri::command] +pub fn ssh_tunnel_start( + config: SshTunnelConfig, + state: tauri::State, +) -> Result { + let mut config = normalize_tunnel_config(config)?; + let ssh_port = config.ssh_port.unwrap_or(22); + let requested_local_port = config.local_port.unwrap_or(28789); + let local_port = pick_available_local_port(requested_local_port)?; + let remote_port = config.remote_port.unwrap_or(18789); + let local_url = build_local_url(local_port); + config.local_port = Some(local_port); + + let mut tunnel = state + .tunnel + .lock() + .map_err(|_| "Failed to access SSH tunnel state".to_string())?; + + if let Some(active) = tunnel.as_mut() { + stop_child(&mut active.child)?; + } + *tunnel = None; + + let forward_spec = format!("127.0.0.1:{}:127.0.0.1:{}", local_port, remote_port); + let mut command = Command::new("ssh"); + command + .args(["-NT", "-L", &forward_spec]) + .args(["-o", "ExitOnForwardFailure=yes"]) + .args(["-o", "ServerAliveInterval=30"]) + .args(["-o", "ServerAliveCountMax=3"]) + .args(["-o", "BatchMode=yes"]) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::piped()); + + if ssh_port != 22 { + command.args(["-p", &ssh_port.to_string()]); + } + + if let Some(identity_path) = config.identity_path.as_deref() { + command.args(["-i", identity_path]); + } + + command.arg(&config.destination); + + let mut child = command + .spawn() + .map_err(|e| format!("Failed to start SSH tunnel: {}", e))?; + + thread::sleep(Duration::from_millis(500)); + if let Some(status) = child + .try_wait() + .map_err(|e| format!("Failed to inspect SSH tunnel: {}", e))? + { + let mut stderr = String::new(); + if let Some(mut reader) = child.stderr.take() { + let _ = reader.read_to_string(&mut stderr); + } + let details = stderr.trim(); + if !details.is_empty() { + return Err(explain_ssh_tunnel_error(details, &config)); + } + return Err(format!( + "SSH tunnel exited immediately with status {}", + status + )); + } + + *tunnel = Some(ActiveSshTunnel { + child, + config, + local_url, + }); + + Ok(tunnel_status_from_slot(&tunnel)) +} + #[tauri::command] pub fn engine_gateway_config() -> Result { // Read ~/.openclaw/openclaw.json for gateway port and password diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 975aba8..b099d0c 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -62,8 +62,7 @@ fn create_editor_window(app: &tauri::AppHandle) -> tauri::Result<()> { // is the fallback. Either prevents raw transparency from tauri.conf.json. if window_vibrancy::apply_mica(&window, None).is_err() { log::warn!("Mica not available, falling back to Acrylic"); - window_vibrancy::apply_acrylic(&window, Some((18, 18, 18, 200))) - .ok(); + window_vibrancy::apply_acrylic(&window, Some((18, 18, 18, 200))).ok(); } } @@ -88,7 +87,8 @@ pub fn run() { let builder = builder .plugin(tauri_plugin_shell::init()) .plugin(tauri_plugin_window_state::Builder::new().build()) - .manage(TerminalState::new()); + .manage(TerminalState::new()) + .manage(engine::SshTunnelManager::default()); let builder = builder.setup(|app| { #[cfg(not(target_os = "ios"))] @@ -131,10 +131,7 @@ pub fn run() { setExtendedLayoutIncludesOpaqueBars: true ]; // preferredStatusBarStyle = .lightContent (1) - let _: () = objc2::msg_send![ - vc_ptr, - setNeedsStatusBarAppearanceUpdate - ]; + let _: () = objc2::msg_send![vc_ptr, setNeedsStatusBarAppearanceUpdate]; } } }); @@ -158,6 +155,9 @@ pub fn run() { engine::engine_stop, engine::engine_restart, engine::engine_gateway_config, + engine::ssh_tunnel_start, + engine::ssh_tunnel_stop, + engine::ssh_tunnel_status, local_fs::local_read_tree, local_fs::local_read_file, local_fs::local_read_file_base64, @@ -208,7 +208,11 @@ fn setup_desktop_menu(app: &tauri::App) -> Result<(), Box use tauri::{Emitter, Manager}; let app_menu = SubmenuBuilder::new(app, "Knot Code") - .item(&PredefinedMenuItem::about(app, Some("About Knot Code"), None)?) + .item(&PredefinedMenuItem::about( + app, + Some("About Knot Code"), + None, + )?) .separator() .item(&PredefinedMenuItem::services(app, None)?) .separator() @@ -320,9 +324,7 @@ fn setup_desktop_menu(app: &tauri::App) -> Result<(), Box let docs_item = MenuItemBuilder::new("Documentation") .id("help_docs") .build(app)?; - let help_menu = SubmenuBuilder::new(app, "Help") - .item(&docs_item) - .build()?; + let help_menu = SubmenuBuilder::new(app, "Help").item(&docs_item).build()?; let menu = MenuBuilder::new(app) .item(&app_menu) @@ -366,8 +368,13 @@ fn setup_desktop_menu(app: &tauri::App) -> Result<(), Box { use window_vibrancy::apply_vibrancy; let window = app.get_webview_window("main").unwrap(); - apply_vibrancy(&window, window_vibrancy::NSVisualEffectMaterial::Sidebar, None, None) - .ok(); + apply_vibrancy( + &window, + window_vibrancy::NSVisualEffectMaterial::Sidebar, + None, + None, + ) + .ok(); } #[cfg(target_os = "windows")] @@ -375,8 +382,7 @@ fn setup_desktop_menu(app: &tauri::App) -> Result<(), Box let window = app.get_webview_window("main").unwrap(); if window_vibrancy::apply_mica(&window, None).is_err() { log::warn!("Mica not available, falling back to Acrylic"); - window_vibrancy::apply_acrylic(&window, Some((18, 18, 18, 200))) - .ok(); + window_vibrancy::apply_acrylic(&window, Some((18, 18, 18, 200))).ok(); } }