From 42b7bdce0b4293e4080be3020ec0ee7017f4f3bf Mon Sep 17 00:00:00 2001 From: Shimul Debnath <270455167+velixio@users.noreply.github.com> Date: Thu, 15 Oct 2026 21:22:30 +0530 Subject: [PATCH 1/3] fix(profiles): keep local saves running without internet --- electron/README.md | 4 + .../src/hooks/use-backend-status.test.ts | 95 ++++++++++++++++++- .../renderer/src/hooks/use-backend-status.ts | 8 +- 3 files changed, 105 insertions(+), 2 deletions(-) diff --git a/electron/README.md b/electron/README.md index e24a5f8eb7..3fa42595c4 100644 --- a/electron/README.md +++ b/electron/README.md @@ -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 diff --git a/electron/src/renderer/src/hooks/use-backend-status.test.ts b/electron/src/renderer/src/hooks/use-backend-status.test.ts index 992f03ae64..884e7513c7 100644 --- a/electron/src/renderer/src/hooks/use-backend-status.test.ts +++ b/electron/src/renderer/src/hooks/use-backend-status.test.ts @@ -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(); @@ -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(); + } +}); diff --git a/electron/src/renderer/src/hooks/use-backend-status.ts b/electron/src/renderer/src/hooks/use-backend-status.ts index b894d91588..380ad78120 100644 --- a/electron/src/renderer/src/hooks/use-backend-status.ts +++ b/electron/src/renderer/src/hooks/use-backend-status.ts @@ -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); + onlineManager.setOnline(false); +} let revision = 0; const listeners = new Set<() => void>(); let bridgeUnsubscribe: (() => void) | null = null; From 0bd29fe4f6807454f2ce3548cc84b2440cd7bf57 Mon Sep 17 00:00:00 2001 From: Shimul Debnath <270455167+velixio@users.noreply.github.com> Date: Thu, 15 Oct 2026 21:22:56 +0530 Subject: [PATCH 2/3] docs: note offline voice-profile save fix --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8ab41f333e..bba0aacf53 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,7 @@ metadata and the backend fallback mirror it. ### Fixed +- Desktop voice-profile saves stay usable without internet when the local backend is ready (#2901) — thanks @velixio! - Snap packages follow Canonical’s strict-confinement approach to launch without manual Chromium sandbox setup (#2893) — thanks @velixio! - Snap launches explain missing sandbox permissions instead of crashing before startup (#2889) - Snap packages expand the Electron desktop runtime so they launch after installation (#2858) From b6e29b010ec4ff3750154b0a108d194d2b96eedd Mon Sep 17 00:00:00 2001 From: Shimul Debnath <270455167+velixio@users.noreply.github.com> Date: Thu, 15 Oct 2026 22:34:49 +0530 Subject: [PATCH 3/3] fix(connectivity): keep disconnected remote saves queued --- electron/README.md | 3 +- .../src/hooks/use-backend-status.test.ts | 49 ++++++++++++++++++- .../renderer/src/hooks/use-backend-status.ts | 35 ++++++++++--- 3 files changed, 76 insertions(+), 11 deletions(-) diff --git a/electron/README.md b/electron/README.md index 3fa42595c4..44da70ea70 100644 --- a/electron/README.md +++ b/electron/README.md @@ -9,7 +9,8 @@ and remote-backend connections require configuration; local generation stays on 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. +pause during startup or a backend failure. Remote backend requests also wait +for network connectivity before resuming queued operations. ## Stack diff --git a/electron/src/renderer/src/hooks/use-backend-status.test.ts b/electron/src/renderer/src/hooks/use-backend-status.test.ts index 884e7513c7..7c6f128499 100644 --- a/electron/src/renderer/src/hooks/use-backend-status.test.ts +++ b/electron/src/renderer/src/hooks/use-backend-status.test.ts @@ -153,8 +153,8 @@ async function nativeConnectivity() { client.mount(); return { client, - push: (stage: 'ready' | 'unresponsive' | 'crashed') => - push({ ...status.FALLBACK_BACKEND_STATUS, stage }), + push: (stage: 'ready' | 'unresponsive' | 'crashed', remote = false) => + push({ ...status.FALLBACK_BACKEND_STATUS, stage, remote }), stop: () => { onlineManager.setOnline(true); client.unmount(); @@ -217,3 +217,48 @@ it('keeps browser connectivity handling without a native supervisor', async () = client.clear(); } }); + +it.each(['ready', 'unresponsive'] as const)( + 'queues disconnected remote saves while the supervisor reports %s', + async (stage) => { + const native = await nativeConnectivity(); + try { + native.push(stage, true); + window.dispatchEvent(new Event('offline')); + const save = vi.fn().mockResolvedValue({ id: 'remote-profile' }); + const mutation = native.client.getMutationCache().build(native.client, { + mutationFn: save, + gcTime: 0, + }); + const pending = mutation.execute({ name: 'Remote voice' }); + await waitFor(() => expect(mutation.state.isPaused).toBe(true)); + expect(save).not.toHaveBeenCalled(); + native.push('ready', true); + expect(onlineManager.isOnline()).toBe(false); + native.push('crashed', true); + window.dispatchEvent(new Event('online')); + expect(onlineManager.isOnline()).toBe(false); + expect(save).not.toHaveBeenCalled(); + native.push('ready', true); + await expect(pending).resolves.toEqual({ id: 'remote-profile' }); + expect(save).toHaveBeenCalledOnce(); + } finally { + native.stop(); + } + }, +); + +it('keeps remote startup offline but allows switching to a local backend', async () => { + vi.stubGlobal('navigator', { onLine: false }); + const native = await nativeConnectivity(); + try { + native.push('ready', true); + expect(onlineManager.isOnline()).toBe(false); + native.push('ready'); + expect(onlineManager.isOnline()).toBe(true); + native.push('ready', true); + expect(onlineManager.isOnline()).toBe(false); + } finally { + native.stop(); + } +}); diff --git a/electron/src/renderer/src/hooks/use-backend-status.ts b/electron/src/renderer/src/hooks/use-backend-status.ts index 380ad78120..8ab6171c77 100644 --- a/electron/src/renderer/src/hooks/use-backend-status.ts +++ b/electron/src/renderer/src/hooks/use-backend-status.ts @@ -39,14 +39,33 @@ const nativeBackend = bridge(); let current: BackendStatus = nativeBackend ? { ...FALLBACK_BACKEND_STATUS, stage: 'attaching' } : FALLBACK_BACKEND_STATUS; -// 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. +let browserOnline = typeof navigator === 'undefined' || navigator.onLine !== false; + +/** Local requests need a live backend; remote requests also need a connection. */ +function backendRequestsAvailable(): boolean { + return isBackendReachable(current.stage) && (!current.remote || browserOnline); +} + +// Hold desktop requests until the supervisor is ready. Internet events must +// never pause local saves or bypass startup/crash handling; remote connections +// retain browser connectivity gating as well as supervisor readiness. 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); + onlineManager.setEventListener((setOnline) => { + const online = () => { + browserOnline = true; + setOnline(backendRequestsAvailable()); + }; + const offline = () => { + browserOnline = false; + setOnline(backendRequestsAvailable()); + }; + window.addEventListener('online', online); + window.addEventListener('offline', offline); + return () => { + window.removeEventListener('online', online); + window.removeEventListener('offline', offline); + }; + }); onlineManager.setOnline(false); } let revision = 0; @@ -57,7 +76,7 @@ function publish(status: BackendStatus): void { revision++; current = status; noteBackendStage(status.stage); - if (nativeBackend) onlineManager.setOnline(isBackendReachable(status.stage)); + if (nativeBackend) onlineManager.setOnline(backendRequestsAvailable()); for (const listener of listeners) listener(); }