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'],
},