Skip to content
Open
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
19 changes: 19 additions & 0 deletions app/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import voiceboxLogo from '@/assets/voicebox-logo.png';
import ShinyText from '@/components/ShinyText';
import { TitleBarDragRegion } from '@/components/TitleBarDragRegion';
import { useAutoUpdater } from '@/hooks/useAutoUpdater';
import { apiClient } from '@/lib/api/client';
import { TOP_SAFE_AREA_PADDING } from '@/lib/constants/ui';
import { cn } from '@/lib/utils/cn';
import { usePlatform } from '@/platform/PlatformContext';
Expand Down Expand Up @@ -122,6 +123,24 @@ function App() {
serverStartingRef.current = false;
// @ts-expect-error - adding property to window
window.__voiceboxServerStartedByApp = false;

// Fall back to polling: the server may already be running externally
// (e.g. started via python/uvicorn/Docker). Poll the health endpoint
// until it responds, then transition to the main UI.
console.log('Falling back to health-check polling...');
const pollInterval = setInterval(async () => {
try {
await apiClient.getHealth();
console.log('External server detected via health check');
clearInterval(pollInterval);
setServerReady(true);
} catch {
Comment on lines +131 to +137
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Validate the health payload before marking the app ready.

apiClient.getHealth() only guarantees response.ok plus JSON parsing in app/src/lib/api/client.ts:58-82. A different service with a generic /health endpoint on that URL will still flip serverReady to true, even though the Tauri startup path may have rejected it. Check for Voicebox-specific fields before accepting the fallback.

Possible fix
         const pollInterval = setInterval(async () => {
           try {
-            await apiClient.getHealth();
+            const health = await apiClient.getHealth();
+            if (
+              health?.status !== 'healthy' ||
+              typeof health.model_loaded !== 'boolean' ||
+              typeof health.gpu_available !== 'boolean'
+            ) {
+              return;
+            }
             console.log('External server detected via health check');
             clearInterval(pollInterval);
             setServerReady(true);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const pollInterval = setInterval(async () => {
try {
await apiClient.getHealth();
console.log('External server detected via health check');
clearInterval(pollInterval);
setServerReady(true);
} catch {
const pollInterval = setInterval(async () => {
try {
const health = await apiClient.getHealth();
if (
health?.status !== 'healthy' ||
typeof health.model_loaded !== 'boolean' ||
typeof health.gpu_available !== 'boolean'
) {
return;
}
console.log('External server detected via health check');
clearInterval(pollInterval);
setServerReady(true);
} catch {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/src/App.tsx` around lines 131 - 137, The poll callback in App.tsx
currently treats any successful apiClient.getHealth() as a ready signal; change
it to validate the parsed health payload for Voicebox-specific fields (e.g., a
version, serviceName, or expected capability flag) before calling
setServerReady(true) and clearInterval(pollInterval). After awaiting
apiClient.getHealth(), inspect the returned JSON (from apiClient.getHealth),
confirm the required keys/values are present and match expected formats, only
then log success and setServerReady; otherwise log the mismatch and keep
polling. Target symbols: the setInterval handler using pollInterval,
apiClient.getHealth(), and setServerReady().

// Server not ready yet, keep polling
}
}, 2000);

// Stop polling after 2 minutes to avoid polling forever
setTimeout(() => clearInterval(pollInterval), 120_000);
});

// Cleanup: stop server on actual unmount (not StrictMode remount)
Expand Down
2 changes: 1 addition & 1 deletion app/src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import ReactDOM from 'react-dom/client';
import App from './App';
import './index.css';

const queryClient = new QueryClient({
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60 * 5, // 5 minutes
Expand Down
22 changes: 20 additions & 2 deletions app/src/stores/serverStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,11 +30,29 @@ interface ServerStore {
setCustomModelsDir: (dir: string | null) => void;
}

/**
* Invalidate all React Query caches and reset UI selection state.
* Called when the server URL changes so stale data from the previous
* server is not shown.
*/
function invalidateAllServerData() {
// Lazy import to avoid circular dependency (main.tsx -> serverStore -> main.tsx)
import('@/main').then(({ queryClient }) => {
queryClient.invalidateQueries();
});
}

export const useServerStore = create<ServerStore>()(
persist(
(set) => ({
(set, get) => ({
serverUrl: 'http://127.0.0.1:17493',
setServerUrl: (url) => set({ serverUrl: url }),
setServerUrl: (url) => {
const prev = get().serverUrl;
set({ serverUrl: url });
if (url !== prev) {
invalidateAllServerData();
}
},

isConnected: false,
setIsConnected: (connected) => set({ isConnected: connected }),
Expand Down
66 changes: 58 additions & 8 deletions tauri/src-tauri/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,35 @@ fn find_voicebox_pid_on_port(port: u16) -> Option<u32> {
None
}

/// Check if a Voicebox server is responding on the given port.
///
/// Sends an HTTP GET to `/health` and returns `true` if the response
/// contains the expected JSON field (`"status"`), confirming it's
/// a Voicebox backend rather than an unrelated service.
#[allow(dead_code)] // Used in platform-specific cfg blocks
fn check_health(port: u16) -> bool {
let url = format!("http://127.0.0.1:{}/health", port);
match reqwest::blocking::Client::builder()
.timeout(std::time::Duration::from_secs(3))
.build()
{
Ok(client) => match client.get(&url).send() {
Ok(resp) => {
if !resp.status().is_success() {
return false;
}
// Verify the body looks like a Voicebox health response
match resp.text() {
Ok(body) => body.contains("status"),
Comment on lines +73 to +75
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Parse the health body instead of searching for "status".

Any HTTP service that returns 200 and happens to include "status" in the body can be reused here. That means a conventional /health response from another app on port 17493 can masquerade as Voicebox and the GUI will attach to the wrong backend. The backend already has a stable HealthResponse shape in backend/models.py:163-175; validate a few real fields/values before treating the port as reusable.

Possible fix
-                match resp.text() {
-                    Ok(body) => body.contains("status"),
-                    Err(_) => false,
-                }
+                match resp.json::<serde_json::Value>() {
+                    Ok(body) => {
+                        body.get("status").and_then(|v| v.as_str()) == Some("healthy")
+                            && body.get("model_loaded").map(|v| v.is_boolean()).unwrap_or(false)
+                            && body.get("gpu_available").map(|v| v.is_boolean()).unwrap_or(false)
+                    }
+                    Err(_) => false,
+                }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Verify the body looks like a Voicebox health response
match resp.text() {
Ok(body) => body.contains("status"),
// Verify the body looks like a Voicebox health response
match resp.json::<serde_json::Value>() {
Ok(body) => {
body.get("status").and_then(|v| v.as_str()) == Some("healthy")
&& body.get("model_loaded").map(|v| v.is_boolean()).unwrap_or(false)
&& body.get("gpu_available").map(|v| v.is_boolean()).unwrap_or(false)
}
Err(_) => false,
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tauri/src-tauri/src/main.rs` around lines 73 - 75, Replace the fragile
substring check on resp.text() with proper JSON deserialization into the
expected health shape: parse resp.text() (or resp.json()) into the
HealthResponse-like struct used by the backend (referencing the HealthResponse
shape in backend/models.py) and validate key fields (e.g., status and any stable
version/id fields) before accepting the port as Voicebox; update the match arm
that currently uses body.contains("status") to attempt deserialization, handle
errors by treating non-conforming responses as invalid, and only return true
when the parsed object matches the expected fields/values.

Err(_) => false,
}
}
Err(_) => false,
},
Err(_) => false,
}
}

struct ServerState {
child: Mutex<Option<tauri_plugin_shell::process::CommandChild>>,
server_pid: Mutex<Option<u32>>,
Expand Down Expand Up @@ -80,7 +109,8 @@ async fn start_server(
return Ok(format!("http://127.0.0.1:{}", SERVER_PORT));
}

// Check if a voicebox server is already running on our port (from previous session with keep_running=true)
// Check if a voicebox server is already running on our port (from previous session with keep_running=true,
// or an externally started server e.g. via `python`, `uvicorn`, Docker, etc.)
#[cfg(unix)]
{
use std::process::Command;
Expand All @@ -101,6 +131,20 @@ async fn start_server(
*state.server_pid.lock().unwrap() = Some(pid);
return Ok(format!("http://127.0.0.1:{}", SERVER_PORT));
}
} else {
// Process name doesn't contain "voicebox" — could be an external
// Python/uvicorn/Docker server. Verify via HTTP health check.
println!("Port {} in use by '{}' (PID: {}), checking if it's a Voicebox server...", SERVER_PORT, command, pid_str);
if check_health(SERVER_PORT) {
println!("Health check passed — reusing external server on port {}", SERVER_PORT);
return Ok(format!("http://127.0.0.1:{}", SERVER_PORT));
}
println!("Health check failed — port is occupied by a non-Voicebox process");
return Err(format!(
"Port {} is already in use by another application ({}). \
Close it or change the Voicebox server port.",
SERVER_PORT, command
));
}
}
}
Expand All @@ -114,18 +158,24 @@ async fn start_server(
&format!("127.0.0.1:{}", SERVER_PORT).parse().unwrap(),
std::time::Duration::from_secs(1),
).is_ok() {
// Port is in use — check if it's a voicebox process
// Port is in use — check if it's a voicebox process by name first
if let Some(pid) = find_voicebox_pid_on_port(SERVER_PORT) {
println!("Found existing voicebox-server on port {} (PID: {}), reusing it", SERVER_PORT, pid);
*state.server_pid.lock().unwrap() = Some(pid);
return Ok(format!("http://127.0.0.1:{}", SERVER_PORT));
} else {
return Err(format!(
"Port {} is already in use by another application. \
Close the other application or change the Voicebox port.",
SERVER_PORT
));
}
// Process name doesn't match — could be an external Python/Docker server.
// Verify via HTTP health check before giving up.
println!("Port {} in use by unknown process, checking if it's a Voicebox server...", SERVER_PORT);
if check_health(SERVER_PORT) {
println!("Health check passed — reusing external server on port {}", SERVER_PORT);
return Ok(format!("http://127.0.0.1:{}", SERVER_PORT));
}
return Err(format!(
"Port {} is already in use by another application. \
Close the other application or change the Voicebox port.",
SERVER_PORT
));
}
}

Expand Down