Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
100 changes: 98 additions & 2 deletions app/src/components/ServerTab/GeneralPage.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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'))),
Expand Down Expand Up @@ -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 (
<div className="space-y-8 max-w-2xl">
<div className="grid grid-cols-2 gap-3">
Expand Down Expand Up @@ -141,6 +185,58 @@ export function GeneralPage() {
</Form>
</SettingRow>

{platform.metadata.isTauri && mode === 'local' && (
<SettingRow
title={t('settings.general.serverControl.title')}
description={t('settings.general.serverControl.description')}
action={
<div className="flex items-center gap-2">
<Button
type="button"
size="sm"
onClick={() => runServerAction('start')}
disabled={serverAction !== null || serverOnline}
>
{serverAction === 'start' ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Play className="h-3.5 w-3.5" />
)}
{t('settings.general.serverControl.start')}
</Button>
<Button
type="button"
size="sm"
variant="outline"
onClick={() => runServerAction('restart')}
disabled={serverAction !== null || !serverOnline}
>
{serverAction === 'restart' ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<RefreshCw className="h-3.5 w-3.5" />
)}
{t('settings.general.serverControl.restart')}
</Button>
<Button
type="button"
size="sm"
variant="destructive"
onClick={() => runServerAction('stop')}
disabled={serverAction !== null || !serverOnline}
>
{serverAction === 'stop' ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Square className="h-3.5 w-3.5" />
)}
{t('settings.general.serverControl.stop')}
</Button>
</div>
}
/>
)}

<SettingRow
title={t('settings.general.keepServerRunning.title')}
description={t('settings.general.keepServerRunning.description')}
Expand Down
14 changes: 13 additions & 1 deletion app/src/i18n/locales/en/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -774,6 +774,17 @@
"updatedTitle": "Server URL updated",
"updatedDescription": "Connected to {{url}}"
},
"serverControl": {
"title": "Local backend",
"description": "Control the bundled Voicebox server running in the background.",
"start": "Start",
"stop": "Stop",
"restart": "Restart",
"startSuccess": "Server started",
"stopSuccess": "Server stopped",
"restartSuccess": "Server restarted",
"failed": "Server action failed"
},
"keepServerRunning": {
"title": "Keep server running when app closes",
"description": "The server will continue running in the background after closing the app.",
Expand Down Expand Up @@ -1124,7 +1135,8 @@
"title": "Switch to CPU backend",
"description": "Disable GPU acceleration. You can re-download the GPU backend later.",
"button": "Switch"
}, "remove": {
},
"remove": {
"title": "Remove CUDA backend",
"description": "Delete the downloaded CUDA binary to free disk space.",
"button": "Remove"
Expand Down
17 changes: 14 additions & 3 deletions backend/build_binary.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,15 @@
python build_binary.py --cuda # Build CUDA-enabled server binary
"""

import PyInstaller.__main__
import argparse
import logging
import os
import platform
import sys
from pathlib import Path

import PyInstaller.__main__

logger = logging.getLogger(__name__)


Expand All @@ -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:
Expand Down Expand Up @@ -665,7 +678,6 @@ def build_server(cuda=False, rocm=False):
check=True,
)


logger.info("Binary built in %s", backend_dir / "dist" / binary_name)


Expand Down Expand Up @@ -781,4 +793,3 @@ def build_shim():
build_shim()
else:
build_server(cuda=cli_args.cuda, rocm=cli_args.rocm)

6 changes: 5 additions & 1 deletion justfile
Original file line number Diff line number Diff line change
Expand Up @@ -228,10 +228,14 @@ build-server: _ensure-venv
$env:PATH = "{{ venv_bin }};$env:PATH"; \
& "{{ python }}" backend/build_binary.py; \
if ($LASTEXITCODE -ne 0) { throw "build_binary.py failed with exit code $LASTEXITCODE" }; \
& "{{ python }}" backend/build_binary.py --shim; \
if ($LASTEXITCODE -ne 0) { throw "build_binary.py --shim failed with exit code $LASTEXITCODE" }; \
$triple = (rustc --print host-tuple); \
New-Item -ItemType Directory -Path "{{ tauri_dir }}/src-tauri/binaries" -Force | Out-Null; \
Copy-Item "backend/dist/voicebox-server.exe" "{{ tauri_dir }}/src-tauri/binaries/voicebox-server-$triple.exe" -Force; \
Write-Host "Copied sidecar: voicebox-server-$triple.exe"
Copy-Item "backend/dist/voicebox-mcp.exe" "{{ tauri_dir }}/src-tauri/binaries/voicebox-mcp-$triple.exe" -Force; \
Write-Host "Copied sidecar: voicebox-server-$triple.exe"; \
Write-Host "Copied sidecar: voicebox-mcp-$triple.exe"

# Build CUDA server binary and place in app data dir for local testing
[windows]
Expand Down
2 changes: 1 addition & 1 deletion tauri/src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ edition = "2021"
tauri-build = { version = "2.0", features = [] }

[dependencies]
tauri = { version = "2.0", features = ["macos-private-api"] }
tauri = { version = "2.0", features = ["macos-private-api", "tray-icon"] }
tauri-plugin-dialog = "2.0"
tauri-plugin-fs = "2.0"
tauri-plugin-shell = "2.0"
Expand Down
71 changes: 71 additions & 0 deletions tauri/src-tauri/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,17 @@ mod synthetic_keys;

use std::sync::Mutex;
use tauri::{command, State, Manager, WindowEvent, Emitter, Listener, RunEvent, WebviewUrl, WebviewWindowBuilder, PhysicalPosition};
#[cfg(desktop)]
use tauri::{
menu::{Menu, MenuItem},
tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent},
};
use tauri_plugin_shell::ShellExt;
use tokio::sync::mpsc;

static TRAY_QUIT_REQUESTED: std::sync::atomic::AtomicBool =
std::sync::atomic::AtomicBool::new(false);

pub const DICTATE_WINDOW_LABEL: &str = "dictate";
const DICTATE_WINDOW_WIDTH: f64 = 420.0;
const DICTATE_WINDOW_HEIGHT: f64 = 64.0;
Expand Down Expand Up @@ -119,6 +127,15 @@ pub fn show_dictate_window(app: &tauri::AppHandle) {
const LEGACY_PORT: u16 = 8000;
pub(crate) const SERVER_PORT: u16 = 17493;

#[cfg(desktop)]
fn show_main_window(app: &tauri::AppHandle) {
if let Some(window) = app.get_webview_window("main") {
let _ = window.unminimize();
let _ = window.show();
let _ = window.set_focus();
}
}

/// Find a voicebox-server process listening on a given port (Windows only).
///
/// Uses PowerShell `Get-NetTCPConnection` to look up the PID owning the port,
Expand Down Expand Up @@ -680,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 {
Expand Down Expand Up @@ -710,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 {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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;
Expand Down
21 changes: 14 additions & 7 deletions tauri/vite.config.ts
Original file line number Diff line number Diff line change
@@ -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'],
},
Expand Down