From 08195fb92f6e21e931306e9ac2fe3f621dcd5182 Mon Sep 17 00:00:00 2001 From: Qwen User Date: Sun, 19 Jul 2026 01:32:46 +0200 Subject: [PATCH 1/4] Improve Windows desktop integration --- app/src/components/ServerTab/GeneralPage.tsx | 99 +++++++++++++++++++- app/src/i18n/locales/en/translation.json | 14 ++- justfile | 6 +- tauri/src-tauri/Cargo.toml | 2 +- tauri/src-tauri/src/main.rs | 55 +++++++++++ tauri/vite.config.ts | 21 +++-- 6 files changed, 185 insertions(+), 12 deletions(-) diff --git a/app/src/components/ServerTab/GeneralPage.tsx b/app/src/components/ServerTab/GeneralPage.tsx index 0d8d7af7e..3480197cf 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,10 @@ 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 resolver = useMemo( () => zodResolver(makeConnectionSchema(t('settings.general.serverUrl.invalidUrl'))), @@ -70,6 +81,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 +184,58 @@ export function GeneralPage() { + {platform.metadata.isTauri && mode === 'local' && ( + + + + +
+ } + /> + )} + )?; + 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 +1575,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'], }, From 7ca5ade6df704ebebad85d0ff9bab7e840d5b7fe Mon Sep 17 00:00:00 2001 From: Qwen User Date: Sun, 19 Jul 2026 01:34:12 +0200 Subject: [PATCH 2/4] Handle stale server health after stop --- app/src/components/ServerTab/GeneralPage.tsx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/app/src/components/ServerTab/GeneralPage.tsx b/app/src/components/ServerTab/GeneralPage.tsx index 3480197cf..1e117203e 100644 --- a/app/src/components/ServerTab/GeneralPage.tsx +++ b/app/src/components/ServerTab/GeneralPage.tsx @@ -49,6 +49,7 @@ export function GeneralPage() { const { toast } = useToast(); 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'))), @@ -194,7 +195,7 @@ export function GeneralPage() { type="button" size="sm" onClick={() => runServerAction('start')} - disabled={serverAction !== null || Boolean(health)} + disabled={serverAction !== null || serverOnline} > {serverAction === 'start' ? ( @@ -208,7 +209,7 @@ export function GeneralPage() { size="sm" variant="outline" onClick={() => runServerAction('restart')} - disabled={serverAction !== null || !health} + disabled={serverAction !== null || !serverOnline} > {serverAction === 'restart' ? ( @@ -222,7 +223,7 @@ export function GeneralPage() { size="sm" variant="destructive" onClick={() => runServerAction('stop')} - disabled={serverAction !== null || !health} + disabled={serverAction !== null || !serverOnline} > {serverAction === 'stop' ? ( From 1285cb683715893799583bb9923691b178713c09 Mon Sep 17 00:00:00 2001 From: Qwen User Date: Sun, 19 Jul 2026 01:42:37 +0200 Subject: [PATCH 3/4] Detect Windows sidecar readiness via health check --- tauri/src-tauri/src/main.rs | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/tauri/src-tauri/src/main.rs b/tauri/src-tauri/src/main.rs index 8b2ad7fbc..a88cc31ba 100644 --- a/tauri/src-tauri/src/main.rs +++ b/tauri/src-tauri/src/main.rs @@ -697,6 +697,7 @@ async fn start_server( // PyInstaller bundles can be slow on first import, especially torch/transformers let timeout = tokio::time::Duration::from_secs(120); let start_time = tokio::time::Instant::now(); + let mut last_health_check = tokio::time::Instant::now() - tokio::time::Duration::from_secs(1); let mut error_output = Vec::new(); loop { @@ -727,6 +728,21 @@ async fn start_server( return Err("Server startup timeout - check Console.app for detailed logs".to_string()); } + // Windows sidecars are built with PyInstaller's --noconsole flag, so + // stdout/stderr may be redirected to NUL and never produce the Uvicorn + // readiness lines handled below. Poll the Voicebox-specific health + // endpoint as a platform-independent readiness signal as well. + if last_health_check.elapsed() >= 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 { From 6908cde873d5fcb8c086904d98fdb8322a2d5e3c Mon Sep 17 00:00:00 2001 From: Qwen User Date: Sun, 19 Jul 2026 02:39:02 +0200 Subject: [PATCH 4/4] Prefer system VC runtime when freezing Windows server --- backend/build_binary.py | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/backend/build_binary.py b/backend/build_binary.py index 90829c73d..48735da37 100644 --- a/backend/build_binary.py +++ b/backend/build_binary.py @@ -6,7 +6,6 @@ python build_binary.py --cuda # Build CUDA-enabled server binary """ -import PyInstaller.__main__ import argparse import logging import os @@ -14,6 +13,8 @@ import sys from pathlib import Path +import PyInstaller.__main__ + logger = logging.getLogger(__name__) @@ -34,6 +35,18 @@ def build_server(cuda=False, rocm=False): if cuda and rocm: raise ValueError("Cannot build with both CUDA and ROCm support") + # PyInstaller resolves transitive DLLs using PATH. On Windows, third-party + # toolchains such as LLVM can put an older MSVCP140.dll ahead of the current + # system VC++ runtime, producing a binary that crashes during extraction. + # Prefer the Windows runtime directory while collecting dependencies. + if platform.system() == "Windows": + system_root = Path(os.environ.get("SYSTEMROOT", r"C:\Windows")) + system32 = str(system_root / "System32") + path_entries = os.environ.get("PATH", "").split(os.pathsep) + os.environ["PATH"] = os.pathsep.join( + [system32, *(entry for entry in path_entries if entry.lower() != system32.lower())] + ) + backend_dir = Path(__file__).parent if rocm: @@ -665,7 +678,6 @@ def build_server(cuda=False, rocm=False): check=True, ) - logger.info("Binary built in %s", backend_dir / "dist" / binary_name) @@ -781,4 +793,3 @@ def build_shim(): build_shim() else: build_server(cuda=cli_args.cuda, rocm=cli_args.rocm) -