diff --git a/app/src/App.tsx b/app/src/App.tsx index 52c6a18b..ee0820d0 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -7,7 +7,7 @@ import { ModelManagement } from '@/components/ServerSettings/ModelManagement'; import { Toaster } from '@/components/ui/toaster'; import { ProfileList } from '@/components/VoiceProfiles/ProfileList'; import { Sidebar } from '@/components/Sidebar'; -import { isTauri, startServer, stopServer } from '@/lib/tauri'; +import { isTauri, startServer, setupWindowCloseHandler } from '@/lib/tauri'; // Track if server is starting to prevent duplicate starts let serverStarting = false; @@ -16,9 +16,19 @@ function App() { const [activeTab, setActiveTab] = useState('profiles'); const [serverReady, setServerReady] = useState(false); - // Auto-start server when running in Tauri + // Setup window close handler and auto-start server when running in Tauri useEffect(() => { - if (!isTauri() || serverStarting) { + if (!isTauri()) { + return; + } + + // Setup window close handler to check setting and stop server if needed + setupWindowCloseHandler().catch((error) => { + console.error('Failed to setup window close handler:', error); + }); + + // Auto-start server + if (serverStarting) { return; } @@ -36,13 +46,13 @@ function App() { }); // Cleanup: stop server on actual unmount (not StrictMode remount) + // Note: Window close is handled separately in Tauri Rust code return () => { - // In production builds, we want to stop the server on unmount // In dev mode, React StrictMode causes remounts, so we skip cleanup + // In production, window close event handles server shutdown based on setting if (import.meta.env?.PROD) { - stopServer().catch((error) => { - console.error('Failed to stop server on cleanup:', error); - }); + // Only stop if setting says to stop (handled by window close event) + // This cleanup is mainly for React remounts in dev mode serverStarting = false; } }; diff --git a/app/src/components/ServerSettings/ConnectionForm.tsx b/app/src/components/ServerSettings/ConnectionForm.tsx index 7de0c23a..75cfff3e 100644 --- a/app/src/components/ServerSettings/ConnectionForm.tsx +++ b/app/src/components/ServerSettings/ConnectionForm.tsx @@ -13,6 +13,7 @@ import { FormMessage, } from '@/components/ui/form'; import { Input } from '@/components/ui/input'; +import { Checkbox } from '@/components/ui/checkbox'; import { useToast } from '@/components/ui/use-toast'; import { useServerStore } from '@/stores/serverStore'; @@ -25,6 +26,8 @@ type ConnectionFormValues = z.infer; export function ConnectionForm() { const serverUrl = useServerStore((state) => state.serverUrl); const setServerUrl = useServerStore((state) => state.setServerUrl); + const keepServerRunningOnClose = useServerStore((state) => state.keepServerRunningOnClose); + const setKeepServerRunningOnClose = useServerStore((state) => state.setKeepServerRunningOnClose); const { toast } = useToast(); const form = useForm({ @@ -68,6 +71,36 @@ export function ConnectionForm() { + +
+
+ { + setKeepServerRunningOnClose(checked); + toast({ + title: 'Setting updated', + description: checked + ? 'Server will continue running when app closes' + : 'Server will stop when app closes', + }); + }} + /> +
+ +

+ When enabled, the server will continue running in the background after closing the app. + Disabled by default. +

+
+
+
); diff --git a/app/src/components/ui/checkbox.tsx b/app/src/components/ui/checkbox.tsx new file mode 100644 index 00000000..6ae7d75b --- /dev/null +++ b/app/src/components/ui/checkbox.tsx @@ -0,0 +1,36 @@ +import * as React from 'react'; +import { cn } from '@/lib/utils/cn'; + +export interface CheckboxProps extends React.InputHTMLAttributes { + onCheckedChange?: (checked: boolean) => void; +} + +const Checkbox = React.forwardRef( + ({ className, onCheckedChange, ...props }, ref) => { + const handleChange = (e: React.ChangeEvent) => { + if (onCheckedChange) { + onCheckedChange(e.target.checked); + } + // Call original onChange if provided + if (props.onChange) { + props.onChange(e); + } + }; + + return ( + + ); + }, +); +Checkbox.displayName = 'Checkbox'; + +export { Checkbox }; diff --git a/app/src/lib/tauri.ts b/app/src/lib/tauri.ts index 89c596d9..39cf7fc7 100644 --- a/app/src/lib/tauri.ts +++ b/app/src/lib/tauri.ts @@ -3,6 +3,7 @@ */ import { invoke } from '@tauri-apps/api/core'; +import { listen, emit } from '@tauri-apps/api/event'; /** * Check if running in Tauri environment @@ -45,3 +46,35 @@ export async function stopServer(): Promise { throw error; } } + +/** + * Setup window close handler to check setting and stop server if needed + */ +export async function setupWindowCloseHandler(): Promise { + if (!isTauri()) { + return; + } + + try { + // Listen for window close request from Rust + await listen('window-close-requested', async () => { + // Import store here to avoid circular dependency + const { useServerStore } = await import('@/stores/serverStore'); + const keepRunning = useServerStore.getState().keepServerRunningOnClose; + + if (!keepRunning) { + // Stop server before closing + try { + await stopServer(); + } catch (error) { + console.error('Failed to stop server on close:', error); + } + } + + // Emit event back to Rust to allow close + await emit('window-close-allowed'); + }); + } catch (error) { + console.error('Failed to setup window close handler:', error); + } +} diff --git a/app/src/stores/serverStore.ts b/app/src/stores/serverStore.ts index 8adb29b1..f92a8870 100644 --- a/app/src/stores/serverStore.ts +++ b/app/src/stores/serverStore.ts @@ -10,6 +10,9 @@ interface ServerStore { mode: 'local' | 'remote'; setMode: (mode: 'local' | 'remote') => void; + + keepServerRunningOnClose: boolean; + setKeepServerRunningOnClose: (keepRunning: boolean) => void; } export const useServerStore = create()( @@ -23,6 +26,10 @@ export const useServerStore = create()( mode: 'local', setMode: (mode) => set({ mode }), + + keepServerRunningOnClose: false, + setKeepServerRunningOnClose: (keepRunning) => + set({ keepServerRunningOnClose: keepRunning }), }), { name: 'voicebox-server', diff --git a/tauri/src-tauri/src/main.rs b/tauri/src-tauri/src/main.rs index 33427c7f..afb1a56f 100644 --- a/tauri/src-tauri/src/main.rs +++ b/tauri/src-tauri/src/main.rs @@ -2,8 +2,9 @@ #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] use std::sync::Mutex; -use tauri::{command, State, Manager}; +use tauri::{command, State, Manager, WindowEvent, Emitter, Listener}; use tauri_plugin_shell::ShellExt; +use tokio::sync::mpsc; struct ServerState { child: Mutex>, @@ -132,6 +133,50 @@ pub fn run() { child: Mutex::new(None), }) .invoke_handler(tauri::generate_handler![start_server, stop_server]) + .on_window_event(|window, event| { + if let WindowEvent::CloseRequested { api, .. } = event { + // Prevent automatic close + api.prevent_close(); + + // Emit event to frontend to check setting and stop server if needed + let app_handle = window.app_handle(); + + if let Err(e) = app_handle.emit("window-close-requested", ()) { + eprintln!("Failed to emit window-close-requested event: {}", e); + // If event emission fails, allow close anyway + window.close().ok(); + return; + } + + // Set up listener for frontend response + let window_for_close = window.clone(); + let (tx, mut rx) = mpsc::unbounded_channel::<()>(); + + // Listen for response from frontend using window's listen method + let listener_id = window.listen("window-close-allowed", move |_| { + // Frontend has checked setting and stopped server if needed + // Signal that we can close + let _ = tx.send(()); + }); + + // Wait for frontend response or timeout + tokio::spawn(async move { + tokio::select! { + _ = rx.recv() => { + // Frontend responded, close window + window_for_close.close().ok(); + } + _ = tokio::time::sleep(tokio::time::Duration::from_secs(5)) => { + // Timeout - close anyway + eprintln!("Window close timeout, closing anyway"); + window_for_close.close().ok(); + } + } + // Clean up listener + window_for_close.unlisten(listener_id); + }); + } + }) .setup(|_app| { #[cfg(debug_assertions)] {