diff --git a/crates/goose-server/src/commands/agent.rs b/crates/goose-server/src/commands/agent.rs index 5a091ba65d..e9d01a52bc 100644 --- a/crates/goose-server/src/commands/agent.rs +++ b/crates/goose-server/src/commands/agent.rs @@ -61,8 +61,6 @@ pub async fn run() -> Result<()> { let tunnel_manager = app_state.tunnel_manager.clone(); tokio::spawn(async move { - // Give time for GUI to render before doing autostart - tokio::time::sleep(tokio::time::Duration::from_secs(3)).await; tunnel_manager.check_auto_start().await; }); diff --git a/crates/goose-server/src/routes/tunnel.rs b/crates/goose-server/src/routes/tunnel.rs index 57996494db..91e9caecd9 100644 --- a/crates/goose-server/src/routes/tunnel.rs +++ b/crates/goose-server/src/routes/tunnel.rs @@ -43,8 +43,6 @@ pub async fn start_tunnel(State(state): State>) -> Response { } /// Stop the tunnel -/// -/// Stops the currently running tunnel and optionally clears the auto-start setting. #[utoipa::path( post, path = "/api/tunnel/stop", @@ -59,8 +57,6 @@ pub async fn stop_tunnel(State(state): State>) -> Response { } /// Get tunnel info -/// -/// Returns the current tunnel state and connection information if running. #[utoipa::path( get, path = "/api/tunnel/status", diff --git a/crates/goose-server/src/tunnel/config.rs b/crates/goose-server/src/tunnel/config.rs deleted file mode 100644 index b1cf2579ae..0000000000 --- a/crates/goose-server/src/tunnel/config.rs +++ /dev/null @@ -1,49 +0,0 @@ -use super::TunnelConfig; -use goose::config::Config; -use tracing::info; - -const CONFIG_KEY_AUTO_START: &str = "tunnel_auto_start"; -const SECRET_KEY_SECRET: &str = "tunnel_secret"; -const SECRET_KEY_AGENT_ID: &str = "tunnel_agent_id"; - -/// Load tunnel configuration from Config system -/// Non-secrets from config.yaml, secrets from keyring/secrets.yaml -pub async fn load_config() -> TunnelConfig { - let cfg = Config::global(); - - let auto_start = cfg.get_param(CONFIG_KEY_AUTO_START).unwrap_or(false); - - let secret = cfg.get_secret(SECRET_KEY_SECRET).ok(); - let agent_id = cfg.get_secret(SECRET_KEY_AGENT_ID).ok(); - - info!( - "Loaded tunnel config from goose Config (auto_start: {})", - auto_start - ); - - TunnelConfig { - auto_start, - secret, - agent_id, - } -} - -/// Save tunnel configuration to Config system -/// Non-secrets to config.yaml, secrets to keyring/secrets.yaml -pub async fn save_config(config: &TunnelConfig) -> Result<(), Box> { - let cfg = Config::global(); - - // Save non-secret config to config.yaml - cfg.set_param(CONFIG_KEY_AUTO_START, config.auto_start)?; - - // Save secrets to keyring or secrets.yaml - if let Some(secret) = &config.secret { - cfg.set_secret(SECRET_KEY_SECRET, secret)?; - } - if let Some(agent_id) = &config.agent_id { - cfg.set_secret(SECRET_KEY_AGENT_ID, agent_id)?; - } - - info!("Saved tunnel config to goose Config"); - Ok(()) -} diff --git a/crates/goose-server/src/tunnel/mod.rs b/crates/goose-server/src/tunnel/mod.rs index 35e8b80ae9..7024574249 100644 --- a/crates/goose-server/src/tunnel/mod.rs +++ b/crates/goose-server/src/tunnel/mod.rs @@ -1,4 +1,3 @@ -pub mod config; pub mod ed25519; pub mod lapstone; @@ -6,6 +5,7 @@ pub mod lapstone; mod lapstone_test; use crate::configuration::Settings; +use goose::config::Config; use serde::{Deserialize, Serialize}; use std::sync::Arc; use tokio::sync::{mpsc, RwLock}; @@ -75,8 +75,16 @@ impl TunnelManager { } async fn load_config(&self) { - let loaded = config::load_config().await; - *self.config.write().await = loaded; + let cfg = Config::global(); + let auto_start = cfg.get_param("tunnel_auto_start").unwrap_or(false); + let secret = cfg.get_secret("tunnel_secret").ok(); + let agent_id = cfg.get_secret("tunnel_agent_id").ok(); + + *self.config.write().await = TunnelConfig { + auto_start, + secret, + agent_id, + }; } pub async fn check_auto_start(&self) { @@ -122,19 +130,26 @@ impl TunnelManager { { let mut cfg = self.config.write().await; f(&mut cfg); - if let Err(e) = config::save_config(&cfg).await { + + let global_cfg = Config::global(); + if let Err(e) = global_cfg.set_param("tunnel_auto_start", cfg.auto_start) { tracing::error!("Failed to save tunnel config: {}", e); + return; + } + + if let Some(secret) = &cfg.secret { + if let Err(e) = global_cfg.set_secret("tunnel_secret", secret) { + tracing::error!("Failed to save tunnel secret: {}", e); + } + } + if let Some(agent_id) = &cfg.agent_id { + if let Err(e) = global_cfg.set_secret("tunnel_agent_id", agent_id) { + tracing::error!("Failed to save tunnel agent_id: {}", e); + } } } - pub async fn start(&self) -> anyhow::Result { - let mut state = self.state.write().await; - if *state != TunnelState::Idle { - anyhow::bail!("Tunnel is already running or starting"); - } - *state = TunnelState::Starting; - drop(state); - + async fn start_tunnel_internal(&self) -> anyhow::Result<(TunnelInfo, mpsc::Receiver<()>)> { let config = self.config.read().await.clone(); let server_port = get_server_port()?; @@ -149,7 +164,7 @@ impl TunnelManager { }) .await; - let (restart_tx, mut restart_rx) = mpsc::channel::<()>(1); + let (restart_tx, restart_rx) = mpsc::channel::<()>(1); *self.restart_tx.write().await = Some(restart_tx.clone()); let result = lapstone::start( @@ -163,7 +178,21 @@ impl TunnelManager { .await; match result { - Ok(info) => { + Ok(info) => Ok((info, restart_rx)), + Err(e) => Err(e), + } + } + + pub async fn start(&self) -> anyhow::Result { + let mut state = self.state.write().await; + if *state != TunnelState::Idle { + anyhow::bail!("Tunnel is already running or starting"); + } + *state = TunnelState::Starting; + drop(state); + + match self.start_tunnel_internal().await { + Ok((info, mut restart_rx)) => { *self.state.write().await = TunnelState::Running; *self.info.write().await = Some(info.clone()); self.update_config(|c| c.auto_start = true).await; @@ -171,55 +200,35 @@ impl TunnelManager { let state = self.state.clone(); let config = self.config.clone(); let lapstone_handle = self.lapstone_handle.clone(); - let restart_tx_clone = self.restart_tx.clone(); let watchdog_handle_arc = self.watchdog_handle.clone(); + let manager = Arc::new(self.clone_for_watchdog()); let watchdog = tokio::spawn(async move { while restart_rx.recv().await.is_some() { let auto_start = config.read().await.auto_start; - if auto_start { - tracing::warn!("Tunnel connection lost, initiating restart..."); - - lapstone::stop(lapstone_handle.clone()).await; - *state.write().await = TunnelState::Idle; - - tokio::time::sleep(std::time::Duration::from_millis(100)).await; - - *state.write().await = TunnelState::Starting; - let cfg = config.read().await.clone(); - let tunnel_secret = cfg.secret.clone().unwrap_or_else(generate_secret); - let server_secret = std::env::var("GOOSE_SERVER__SECRET_KEY") - .unwrap_or_else(|_| "test".to_string()); - let agent_id = cfg.agent_id.clone().unwrap_or_else(generate_agent_id); - - let (new_restart_tx, new_restart_rx) = mpsc::channel::<()>(1); - *restart_tx_clone.write().await = Some(new_restart_tx.clone()); - - match lapstone::start( - server_port, - tunnel_secret, - server_secret, - agent_id, - lapstone_handle.clone(), - new_restart_tx, - ) - .await - { - Ok(_) => { - *state.write().await = TunnelState::Running; - tracing::info!("Tunnel restarted successfully"); - restart_rx = new_restart_rx; - } - Err(e) => { - tracing::error!("Failed to restart tunnel: {}", e); - *state.write().await = TunnelState::Error; - break; - } - } - } else { + if !auto_start { tracing::info!("Tunnel connection lost but auto_start is disabled"); break; } + + tracing::warn!("Tunnel connection lost, initiating restart..."); + lapstone::stop(lapstone_handle.clone()).await; + *state.write().await = TunnelState::Idle; + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + *state.write().await = TunnelState::Starting; + + match manager.start_tunnel_internal().await { + Ok((_, new_restart_rx)) => { + *state.write().await = TunnelState::Running; + tracing::info!("Tunnel restarted successfully"); + restart_rx = new_restart_rx; + } + Err(e) => { + tracing::error!("Failed to restart tunnel: {}", e); + *state.write().await = TunnelState::Error; + break; + } + } } }); @@ -234,6 +243,17 @@ impl TunnelManager { } } + fn clone_for_watchdog(&self) -> Self { + TunnelManager { + state: self.state.clone(), + info: self.info.clone(), + config: self.config.clone(), + lapstone_handle: self.lapstone_handle.clone(), + restart_tx: self.restart_tx.clone(), + watchdog_handle: self.watchdog_handle.clone(), + } + } + pub async fn stop(&self, clear_auto_start: bool) { if let Some(handle) = self.watchdog_handle.write().await.take() { handle.abort(); diff --git a/ui/desktop/openapi.json b/ui/desktop/openapi.json index 1ee3b519d2..55dd0a536a 100644 --- a/ui/desktop/openapi.json +++ b/ui/desktop/openapi.json @@ -386,7 +386,6 @@ "super::routes::tunnel" ], "summary": "Get tunnel info", - "description": "Returns the current tunnel state and connection information if running.", "operationId": "get_tunnel_status", "responses": { "200": { @@ -408,7 +407,6 @@ "super::routes::tunnel" ], "summary": "Stop the tunnel", - "description": "Stops the currently running tunnel and optionally clears the auto-start setting.", "operationId": "stop_tunnel", "responses": { "200": { diff --git a/ui/desktop/src/api/sdk.gen.ts b/ui/desktop/src/api/sdk.gen.ts index 923cab56ac..bffbd387b7 100644 --- a/ui/desktop/src/api/sdk.gen.ts +++ b/ui/desktop/src/api/sdk.gen.ts @@ -114,8 +114,6 @@ export const startTunnel = (options?: Opti /** * Get tunnel info - * - * Returns the current tunnel state and connection information if running. */ export const getTunnelStatus = (options?: Options) => { return (options?.client ?? client).get({ @@ -126,8 +124,6 @@ export const getTunnelStatus = (options?: /** * Stop the tunnel - * - * Stops the currently running tunnel and optionally clears the auto-start setting. */ export const stopTunnel = (options?: Options) => { return (options?.client ?? client).post({ diff --git a/ui/desktop/src/components/settings/tunnel/TunnelSection.tsx b/ui/desktop/src/components/settings/tunnel/TunnelSection.tsx index 663e5341b3..7b5dd68438 100644 --- a/ui/desktop/src/components/settings/tunnel/TunnelSection.tsx +++ b/ui/desktop/src/components/settings/tunnel/TunnelSection.tsx @@ -5,7 +5,15 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../.. import { QRCodeSVG } from 'qrcode.react'; import { Loader2, Copy, Check, ChevronDown, ChevronUp } from 'lucide-react'; import { errorMessage } from '../../../utils/conversionUtils'; -import type { TunnelInfo } from '../../../utils/tunnel'; +import { startTunnel, stopTunnel, getTunnelStatus } from '../../../api/sdk.gen'; +import type { TunnelInfo } from '../../../api/types.gen'; + +const STATUS_MESSAGES = { + idle: 'Tunnel is not running', + starting: 'Starting tunnel...', + running: 'Tunnel is active', + error: 'Tunnel encountered an error', +} as const; export default function TunnelSection() { const [tunnelInfo, setTunnelInfo] = useState({ @@ -20,54 +28,51 @@ export default function TunnelSection() { useEffect(() => { const loadTunnelInfo = async () => { try { - const baseUrl = await window.electron.getGoosedHostPort(); - if (!baseUrl) throw new Error('goose server not available'); - const info = await window.electron.getTunnelStatus(baseUrl); - setTunnelInfo(info); + const { data } = await getTunnelStatus(); + if (data) { + setTunnelInfo(data); + } } catch (err) { const errorMsg = errorMessage(err, 'Failed to load tunnel status'); setError(errorMsg); - setTunnelInfo({ state: 'error', error: errorMsg }); + setTunnelInfo({ state: 'error' }); } }; loadTunnelInfo(); }, []); - const handleStartTunnel = async () => { - setError(null); - setTunnelInfo({ state: 'starting' }); - - try { - const baseUrl = await window.electron.getGoosedHostPort(); - if (!baseUrl) throw new Error('goose server not available'); - const info = await window.electron.startTunnel(baseUrl); - setTunnelInfo(info); - setShowQRModal(true); - } catch (err) { - const errorMsg = errorMessage(err, 'Failed to start tunnel'); - setError(errorMsg); - setTunnelInfo({ state: 'error', error: errorMsg }); - } - }; - - const handleStopTunnel = async () => { - try { - const baseUrl = await window.electron.getGoosedHostPort(); - if (!baseUrl) throw new Error('goose server not available'); - await window.electron.stopTunnel(baseUrl); - setTunnelInfo({ state: 'idle' }); - setShowQRModal(false); - } catch (err) { - setError(errorMessage(err, 'Failed to stop tunnel')); + const handleToggleTunnel = async () => { + if (tunnelInfo.state === 'running') { try { - const baseUrl = await window.electron.getGoosedHostPort(); - if (!baseUrl) throw new Error('goose server not available'); - const info = await window.electron.getTunnelStatus(baseUrl); - setTunnelInfo(info); - } catch (statusErr) { - console.error('Failed to fetch tunnel status after stop error:', statusErr); - setTunnelInfo({ state: 'error', error: 'Failed to stop tunnel and get status' }); + await stopTunnel(); + setTunnelInfo({ state: 'idle' }); + setShowQRModal(false); + } catch (err) { + setError(errorMessage(err, 'Failed to stop tunnel')); + try { + const { data } = await getTunnelStatus(); + if (data) { + setTunnelInfo(data); + } + } catch (statusErr) { + console.error('Failed to fetch tunnel status after stop error:', statusErr); + } + } + } else { + setError(null); + setTunnelInfo({ state: 'starting' }); + + try { + const { data } = await startTunnel(); + if (data) { + setTunnelInfo(data); + setShowQRModal(true); + } + } catch (err) { + const errorMsg = errorMessage(err, 'Failed to start tunnel'); + setError(errorMsg); + setTunnelInfo({ state: 'error' }); } } }; @@ -118,37 +123,27 @@ export default function TunnelSection() {

Tunnel Status

- {tunnelInfo.state === 'idle' && 'Tunnel is not running'} - {tunnelInfo.state === 'starting' && 'Starting tunnel...'} - {tunnelInfo.state === 'running' && 'Tunnel is active'} - {tunnelInfo.state === 'error' && 'Tunnel encountered an error'} + {STATUS_MESSAGES[tunnelInfo.state]}

- {tunnelInfo.state === 'idle' && ( - - )} - {tunnelInfo.state === 'starting' && ( + {tunnelInfo.state === 'starting' ? ( - )} - {tunnelInfo.state === 'running' && ( + ) : tunnelInfo.state === 'running' ? ( <> - - )} - {tunnelInfo.state === 'error' && ( - )}
@@ -208,7 +203,7 @@ export default function TunnelSection() { size="sm" variant="ghost" className="flex-shrink-0" - onClick={() => copyToClipboard(tunnelInfo.url, 'url')} + onClick={() => tunnelInfo.url && copyToClipboard(tunnelInfo.url, 'url')} > {copiedUrl ? : } @@ -225,7 +220,9 @@ export default function TunnelSection() { size="sm" variant="ghost" className="flex-shrink-0" - onClick={() => copyToClipboard(tunnelInfo.secret, 'secret')} + onClick={() => + tunnelInfo.secret && copyToClipboard(tunnelInfo.secret, 'secret') + } > {copiedSecret ? ( @@ -245,7 +242,7 @@ export default function TunnelSection() { - diff --git a/ui/desktop/src/main.ts b/ui/desktop/src/main.ts index b462ed239d..d7058ab0e4 100644 --- a/ui/desktop/src/main.ts +++ b/ui/desktop/src/main.ts @@ -1310,37 +1310,6 @@ ipcMain.handle('get-wakelock-state', () => { } }); -ipcMain.handle('start-tunnel', async (_event, baseUrl: string) => { - try { - const { startTunnel } = await import('./utils/tunnel'); - const tunnelInfo = await startTunnel(baseUrl, SERVER_SECRET); - return tunnelInfo; - } catch (error) { - console.error('Error starting tunnel:', error); - throw error; - } -}); - -ipcMain.handle('stop-tunnel', async (_event, baseUrl: string) => { - try { - const { stopTunnel } = await import('./utils/tunnel'); - await stopTunnel(baseUrl, SERVER_SECRET); - } catch (error) { - console.error('Error stopping tunnel:', error); - throw error; - } -}); - -ipcMain.handle('get-tunnel-status', async (_event, baseUrl: string) => { - try { - const { syncTunnelStatus } = await import('./utils/tunnel'); - return await syncTunnelStatus(baseUrl, SERVER_SECRET); - } catch (error) { - console.error('Error getting tunnel status:', error); - return { state: 'error', error: String(error) }; - } -}); - // Add file/directory selection handler ipcMain.handle('select-file-or-directory', async (_event, defaultPath?: string) => { const dialogOptions: OpenDialogOptions = { diff --git a/ui/desktop/src/preload.ts b/ui/desktop/src/preload.ts index 418de7d0a4..070e8a0afe 100644 --- a/ui/desktop/src/preload.ts +++ b/ui/desktop/src/preload.ts @@ -1,6 +1,5 @@ import Electron, { contextBridge, ipcRenderer, webUtils } from 'electron'; import { Recipe } from './recipe'; -import type { TunnelInfo } from './utils/tunnel'; interface NotificationData { title: string; @@ -120,9 +119,6 @@ type ElectronAPI = { hasAcceptedRecipeBefore: (recipe: Recipe) => Promise; recordRecipeHash: (recipe: Recipe) => Promise; openDirectoryInExplorer: (directoryPath: string) => Promise; - startTunnel: (baseUrl: string) => Promise; - stopTunnel: (baseUrl: string) => Promise; - getTunnelStatus: (baseUrl: string) => Promise; }; type AppConfigAPI = { @@ -258,9 +254,6 @@ const electronAPI: ElectronAPI = { recordRecipeHash: (recipe: Recipe) => ipcRenderer.invoke('record-recipe-hash', recipe), openDirectoryInExplorer: (directoryPath: string) => ipcRenderer.invoke('open-directory-in-explorer', directoryPath), - startTunnel: (baseUrl: string) => ipcRenderer.invoke('start-tunnel', baseUrl), - stopTunnel: (baseUrl: string) => ipcRenderer.invoke('stop-tunnel', baseUrl), - getTunnelStatus: (baseUrl: string) => ipcRenderer.invoke('get-tunnel-status', baseUrl), }; const appConfigAPI: AppConfigAPI = { diff --git a/ui/desktop/src/utils/tunnel.ts b/ui/desktop/src/utils/tunnel.ts deleted file mode 100644 index 9711c25f67..0000000000 --- a/ui/desktop/src/utils/tunnel.ts +++ /dev/null @@ -1,75 +0,0 @@ -import log from './logger'; - -export type TunnelInfo = - | { state: 'idle' } - | { state: 'starting' } - | { state: 'running'; url: string; hostname: string; secret: string } - | { state: 'error'; error: string }; - -/** - * Start the tunnel via Rust API and remember it - */ -export async function startTunnel(baseUrl: string, serverSecret: string): Promise { - log.info(`Starting tunnel via Rust API at ${baseUrl}`); - - const response = await fetch(`${baseUrl}/api/tunnel/start`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-Secret-Key': serverSecret, - }, - }); - - if (!response.ok) { - const errorText = await response.text(); - throw new Error(`Failed to start tunnel: ${response.statusText} - ${errorText}`); - } - - const info: TunnelInfo = await response.json(); - - log.info('Tunnel started successfully:', info); - return info; -} - -/** - * Stop the tunnel via Rust API, and remember it - */ -export async function stopTunnel(baseUrl: string, secret: string): Promise { - log.info('Stopping tunnel via Rust API'); - - try { - const response = await fetch(`${baseUrl}/api/tunnel/stop`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-Secret-Key': secret, - }, - }); - - if (!response.ok) { - log.error(`Failed to stop tunnel: ${response.statusText}`); - } else { - log.info('Tunnel stopped successfully'); - } - } catch (error) { - log.error('Error stopping tunnel:', error); - } -} - -/** - * Get tunnel info from Rust API - * Note: Rust backend is the source of truth - */ -export async function syncTunnelStatus(baseUrl: string, secret: string): Promise { - const response = await fetch(`${baseUrl}/api/tunnel/status`, { - headers: { - 'X-Secret-Key': secret, - }, - }); - - if (!response.ok) { - throw new Error(`Failed to get tunnel status: ${response.statusText}`); - } - - return await response.json(); -}