Skip to content
4 changes: 4 additions & 0 deletions electron/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ existing installations, see [migration notes](../docs/electron-migration.md).

The runtime supervisor manages the local FastAPI backend. Network integrations
and remote-backend connections require configuration; local generation stays on your machine.
Once setup and the required models are installed, saving cloned voice profiles
works without internet access. Desktop API requests follow the supervisor's
backend readiness rather than the browser's internet connectivity; they still
pause during startup or a backend failure.

## Stack

Expand Down
95 changes: 94 additions & 1 deletion electron/src/renderer/src/hooks/use-backend-status.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,17 @@
import { afterEach, expect, it, vi } from 'vitest';
import { onlineManager } from '@tanstack/react-query';
import { waitFor } from '@testing-library/react';
import { QueryClient, onlineManager } from '@tanstack/react-query';
afterEach(() => {
onlineManager.setEventListener((setOnline) => {
const online = () => setOnline(true);
const offline = () => setOnline(false);
window.addEventListener('online', online);
window.addEventListener('offline', offline);
return () => {
window.removeEventListener('online', online);
window.removeEventListener('offline', offline);
};
});
onlineManager.setOnline(true);
vi.unstubAllGlobals();
vi.resetModules();
Expand Down Expand Up @@ -124,3 +135,85 @@ it('lengthens background status polls while the backend is busy', async () => {
expect(polling.modelStatusPollMs(1, 'ready')).toBe(polling.ACTIVE_STATUS_POLL_MS);
stop();
});

async function nativeConnectivity() {
let push!: (status: unknown) => void;
vi.stubGlobal('voicestudio', {
backend: {
onStatus: (callback: typeof push) => {
push = callback;
return () => {};
},
getStatus: () => new Promise(() => {}),
},
});
const status = await import('./use-backend-status');
const stop = status.subscribeBackendStatus(() => {});
const client = new QueryClient();
client.mount();
return {
client,
push: (stage: 'ready' | 'unresponsive' | 'crashed') =>
push({ ...status.FALLBACK_BACKEND_STATUS, stage }),
stop: () => {
onlineManager.setOnline(true);
client.unmount();
client.clear();
stop();
},
};
}

// Exercise actual mutation execution after browser connectivity changes.
it.each(['ready', 'unresponsive'] as const)(
'runs local saves without internet while the native backend is %s',
async (stage) => {
const native = await nativeConnectivity();
try {
native.push(stage);
window.dispatchEvent(new Event('offline'));
const save = vi.fn().mockResolvedValue({ id: 'offline-profile' });
const mutation = native.client.getMutationCache().build(native.client, {
mutationFn: save,
gcTime: 0,
});
const pending = mutation.execute({ name: 'Offline voice' });
await waitFor(() => expect(save).toHaveBeenCalledOnce());
await expect(pending).resolves.toEqual({ id: 'offline-profile' });
expect(mutation.state.isPaused).toBe(false);
} finally {
native.stop();
}
},
);

it('internet reconnect cannot resume requests before native readiness or after a crash', async () => {
const native = await nativeConnectivity();
try {
window.dispatchEvent(new Event('online'));
expect(onlineManager.isOnline()).toBe(false);
native.push('ready');
expect(onlineManager.isOnline()).toBe(true);
native.push('crashed');
window.dispatchEvent(new Event('online'));
expect(onlineManager.isOnline()).toBe(false);
} finally {
native.stop();
}
});

it('keeps browser connectivity handling without a native supervisor', async () => {
vi.stubGlobal('voicestudio', undefined);
await import('./use-backend-status');
const client = new QueryClient();
client.mount();
try {
window.dispatchEvent(new Event('offline'));
expect(onlineManager.isOnline()).toBe(false);
window.dispatchEvent(new Event('online'));
expect(onlineManager.isOnline()).toBe(true);
} finally {
client.unmount();
client.clear();
}
});
8 changes: 7 additions & 1 deletion electron/src/renderer/src/hooks/use-backend-status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,13 @@ let current: BackendStatus = nativeBackend
// Every renderer query targets the local backend. Holding React Query offline
// until the native supervisor is ready prevents a startup/crash fan-out of
// doomed requests, then resumes active queries automatically on readiness.
if (nativeBackend) onlineManager.setOnline(false);
if (nativeBackend) {
// Browser online/offline events describe internet connectivity, not whether
// our backend can answer. Let only supervisor status drive native requests;
// otherwise losing Wi-Fi pauses local saves and reconnecting bypasses startup.
onlineManager.setEventListener(() => undefined);
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Outdated
onlineManager.setOnline(false);
}
let revision = 0;
const listeners = new Set<() => void>();
let bridgeUnsubscribe: (() => void) | null = null;
Expand Down
Loading