diff --git a/app/src/components/ServerTab/GeneralPage.tsx b/app/src/components/ServerTab/GeneralPage.tsx index 0d8d7af7e..1e117203e 100644 --- a/app/src/components/ServerTab/GeneralPage.tsx +++ b/app/src/components/ServerTab/GeneralPage.tsx @@ -1,5 +1,14 @@ import { zodResolver } from '@hookform/resolvers/zod'; -import { AlertCircle, ArrowUpRight, Book, Download, Loader2, RefreshCw } from 'lucide-react'; +import { + AlertCircle, + ArrowUpRight, + Book, + Download, + Loader2, + Play, + RefreshCw, + Square, +} from 'lucide-react'; import { useEffect, useMemo, useState } from 'react'; import { useForm } from 'react-hook-form'; import { Trans, useTranslation } from 'react-i18next'; @@ -36,8 +45,11 @@ export function GeneralPage() { const setKeepServerRunningOnClose = useServerStore((state) => state.setKeepServerRunningOnClose); const mode = useServerStore((state) => state.mode); const setMode = useServerStore((state) => state.setMode); + const customModelsDir = useServerStore((state) => state.customModelsDir); const { toast } = useToast(); - const { data: health, isLoading, error: healthError } = useServerHealth(); + const { data: health, isLoading, error: healthError, refetch: refetchHealth } = useServerHealth(); + const [serverAction, setServerAction] = useState<'start' | 'stop' | 'restart' | null>(null); + const serverOnline = Boolean(health) && !healthError; const resolver = useMemo( () => zodResolver(makeConnectionSchema(t('settings.general.serverUrl.invalidUrl'))), @@ -70,6 +82,38 @@ export function GeneralPage() { }); } + async function runServerAction(action: 'start' | 'stop' | 'restart') { + setServerAction(action); + try { + if (action === 'start') { + const url = await platform.lifecycle.startServer(false, customModelsDir); + setServerUrl(url); + window.__voiceboxServerStartedByApp = true; + } else if (action === 'stop') { + await platform.lifecycle.stopServer(); + window.__voiceboxServerStartedByApp = false; + } else { + const url = await platform.lifecycle.restartServer(customModelsDir); + setServerUrl(url); + window.__voiceboxServerStartedByApp = true; + } + + await refetchHealth(); + toast({ + title: t(`settings.general.serverControl.${action}Success`), + }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + toast({ + title: t('settings.general.serverControl.failed'), + description: message, + variant: 'destructive', + }); + } finally { + setServerAction(null); + } + } + return (
@@ -141,6 +185,58 @@ export function GeneralPage() { + {platform.metadata.isTauri && mode === 'local' && ( + + + + +
+ } + /> + )} + = tokio::time::Duration::from_secs(1) { + last_health_check = tokio::time::Instant::now(); + let healthy = tokio::task::spawn_blocking(|| check_health(SERVER_PORT)) + .await + .unwrap_or(false); + if healthy { + println!("Server is ready (health check passed)!"); + break; + } + } + match tokio::time::timeout(tokio::time::Duration::from_millis(100), rx.recv()).await { Ok(Some(event)) => { match event { @@ -1396,6 +1429,35 @@ pub fn run() { app.handle().plugin(tauri_plugin_updater::Builder::new().build())?; app.handle().plugin(tauri_plugin_process::init())?; + let open_item = MenuItem::with_id(app, "open", "Open Voicebox", true, None::<&str>)?; + let quit_item = MenuItem::with_id(app, "quit", "Quit Voicebox", true, None::<&str>)?; + let tray_menu = Menu::with_items(app, &[&open_item, &quit_item])?; + + TrayIconBuilder::with_id("voicebox-tray") + .icon(app.default_window_icon().expect("Voicebox app icon missing").clone()) + .tooltip("Voicebox") + .menu(&tray_menu) + .show_menu_on_left_click(false) + .on_menu_event(|app, event| match event.id.as_ref() { + "open" => show_main_window(app), + "quit" => { + TRAY_QUIT_REQUESTED.store(true, std::sync::atomic::Ordering::SeqCst); + app.exit(0); + } + _ => {} + }) + .on_tray_icon_event(|tray, event| { + if let TrayIconEvent::Click { + button: MouseButton::Left, + button_state: MouseButtonState::Up, + .. + } = event + { + show_main_window(tray.app_handle()); + } + }) + .build(app)?; + // Resolve the active keyboard layout's V keycode now, on // the main thread, and register an observer for layout // changes. The synthetic-paste hot path then only reads an @@ -1529,6 +1591,15 @@ pub fn run() { let closing = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); move |window, event| { if let WindowEvent::CloseRequested { api, .. } = event { + #[cfg(desktop)] + if window.label() == "main" + && !TRAY_QUIT_REQUESTED.load(std::sync::atomic::Ordering::SeqCst) + { + api.prevent_close(); + window.hide().ok(); + return; + } + // If we're already in the close flow, let it proceed if closing.load(std::sync::atomic::Ordering::SeqCst) { return; diff --git a/tauri/vite.config.ts b/tauri/vite.config.ts index a51605f40..4681e9705 100644 --- a/tauri/vite.config.ts +++ b/tauri/vite.config.ts @@ -1,22 +1,29 @@ +import fs from 'node:fs'; import path from 'node:path'; import tailwindcss from '@tailwindcss/vite'; import react from '@vitejs/plugin-react'; import { defineConfig } from 'vite'; import { changelogPlugin } from '../app/plugins/changelog'; +function resolveWorkspaceDependency(name: string) { + const isolatedPath = path.resolve(__dirname, '../app/node_modules', name); + return fs.existsSync(isolatedPath) + ? isolatedPath + : path.resolve(__dirname, '../node_modules', name); +} + export default defineConfig({ plugins: [react(), tailwindcss(), changelogPlugin(path.resolve(__dirname, '..'))], resolve: { alias: { '@': path.resolve(__dirname, '../app/src'), - react: path.resolve(__dirname, '../app/node_modules/react'), - 'react-dom': path.resolve(__dirname, '../app/node_modules/react-dom'), - '@tanstack/react-query': path.resolve(__dirname, '../app/node_modules/@tanstack/react-query'), - '@tanstack/react-query-devtools': path.resolve( - __dirname, - '../app/node_modules/@tanstack/react-query-devtools', + react: resolveWorkspaceDependency('react'), + 'react-dom': resolveWorkspaceDependency('react-dom'), + '@tanstack/react-query': resolveWorkspaceDependency('@tanstack/react-query'), + '@tanstack/react-query-devtools': resolveWorkspaceDependency( + '@tanstack/react-query-devtools', ), - zustand: path.resolve(__dirname, '../app/node_modules/zustand'), + zustand: resolveWorkspaceDependency('zustand'), }, dedupe: ['react', 'react-dom', '@tanstack/react-query', 'zustand'], },