diff --git a/CHANGELOG.md b/CHANGELOG.md
index 9a3aacc6..341560ed 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -54,6 +54,7 @@ the frozen-backend fallback mirror it for their toolchains.
### Fixed
- Leaving a screen while its waveform is still loading no longer opens a bug-report prompt for a normal cancelled request. (#1498)
+- An unreachable remote backend now opens a retryable recovery screen instead of sending the app into local model setup, with clear TLS, CORS, network, HTTP, and wrong-port guidance — thanks @debpalash! (#1501)
- Linux production test launches now stop their own extracted AppImage before resetting SQLite and logs. (#1494)
- Restored the pre-release version to 0.4.2 while the next release remains in preparation. (#1488)
- Large multi-language dubbing batches now use compact searchable language and track managers instead of overflowing the editor. (#1492)
diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx
index c586b9c7..dfa8b747 100644
--- a/frontend/src/App.jsx
+++ b/frontend/src/App.jsx
@@ -46,6 +46,7 @@ import FloatingPill from './components/FloatingPill';
import GlobalAudioPlayer from './components/GlobalAudioPlayer';
import BackendCrashNotice from './components/BackendCrashNotice';
import BackendStartFailureNotice from './components/BackendStartFailureNotice';
+import RemoteBackendRecovery from './components/RemoteBackendRecovery';
import AnalyticsConsentBanner from './components/AnalyticsConsentBanner';
import LanguageSwitchPrompt from './components/LanguageSwitchPrompt';
import { initAnalyticsFromConsent } from './utils/analytics';
@@ -72,6 +73,7 @@ import { toastErrorWithReport } from './utils/errorToast';
import { listenDictationNotice, showDictationNotice } from './utils/dictationNotice';
import { addBreadcrumb } from './utils/breadcrumbs';
import { appShellClasses } from './utils/appShellClasses';
+import { configuredRemoteBackend, probeRemoteBackend } from './utils/remoteBackendProbe';
import { applyUiScale } from './utils/uiScaleEngine';
import { recordValueMoment } from './utils/donationMoments';
import {
@@ -567,6 +569,7 @@ function App() {
const setActiveProject = useAppStore((s) => s.setActiveProject);
const sidebarTab = useAppStore((s) => s.sidebarTab);
const setSidebarTab = useAppStore((s) => s.setSidebarTab);
+ const openSettingsTab = useAppStore((s) => s.openSettingsTab);
// Snap sidebar to a valid tab when view changes
useEffect(() => {
@@ -591,6 +594,17 @@ function App() {
// the studio in front of a user who actually needs the wizard.
const [setupNeeded, setSetupNeeded] = useState(false);
const [setupChecked, setSetupChecked] = useState(false);
+ const [remoteFailure, setRemoteFailure] = useState(null);
+ const [remoteProbeAttempt, setRemoteProbeAttempt] = useState(0);
+ const retryRemoteBackend = useCallback(() => {
+ setRemoteFailure(null);
+ setSetupChecked(false);
+ setRemoteProbeAttempt((attempt) => attempt + 1);
+ }, []);
+ const openRemoteBackendSettings = useCallback(() => {
+ setRemoteFailure(null);
+ openSettingsTab('sharing');
+ }, [openSettingsTab]);
useEffect(() => {
// Gate the probe on the bootstrap being 'ready' — before that there is
// no backend to answer. Probing from mount burned the 30-attempt ceiling
@@ -601,6 +615,15 @@ function App() {
if (bootstrapStage !== 'ready') return undefined;
let cancelled = false;
(async () => {
+ const remote = configuredRemoteBackend();
+ if (remote) {
+ const result = await probeRemoteBackend(remote.url, remote.key);
+ if (cancelled) return;
+ setRemoteFailure(result.ok ? null : result);
+ setSetupNeeded(false);
+ setSetupChecked(true);
+ return;
+ }
const { setupStatus } = await import('./api/setup');
// ~30 attempts × ~1s ≈ 30s ceiling; enough for a cold sidecar on slow disks.
for (let attempt = 0; attempt < 30 && !cancelled; attempt++) {
@@ -619,7 +642,7 @@ function App() {
return () => {
cancelled = true;
};
- }, [bootstrapStage]);
+ }, [bootstrapStage, remoteProbeAttempt]);
// ── First sound ──
// Onboarding should end with the product doing the thing: the moment the
@@ -1194,6 +1217,17 @@ function App() {
);
}
+ if (remoteFailure) {
+ return (
+
+
+
+ );
+ }
if (setupNeeded && bootstrapStage === 'ready') {
// Render outside the `app-container` grid so the wizard spans the full
// viewport instead of getting squeezed into whatever grid cell the
diff --git a/frontend/src/components/RemoteBackendRecovery.jsx b/frontend/src/components/RemoteBackendRecovery.jsx
new file mode 100644
index 00000000..7001af4b
--- /dev/null
+++ b/frontend/src/components/RemoteBackendRecovery.jsx
@@ -0,0 +1,47 @@
+import { ServerOff } from 'lucide-react';
+import { useTranslation } from 'react-i18next';
+import { Button } from '../ui';
+import { disableRemoteBackend } from '../utils/remoteBackendProbe';
+
+const DETAIL_KEYS = {
+ tls: 'settings.remote_backend_error_tls',
+ cors: 'settings.remote_backend_error_cors',
+ network: 'settings.remote_backend_error_network',
+ timeout: 'settings.remote_backend_error_timeout',
+ http: 'settings.remote_backend_error_http',
+ wrong_port: 'settings.remote_backend_error_wrong_port',
+};
+
+export default function RemoteBackendRecovery({
+ failure,
+ onRetry,
+ onOpenSettings,
+ reload = () => location.reload(),
+}) {
+ const { t } = useTranslation();
+ const detail = t(DETAIL_KEYS[failure.kind], { status: failure.status });
+ return (
+
+
+
+
{t('settings.remote_backend_recovery_title')}
+
{detail}
+ {failure.target}
+
+
+
+
+
+
+ {t('settings.remote_backend_recovery_hint')}
+
+
+
+ );
+}
diff --git a/frontend/src/components/RemoteBackendRecovery.test.jsx b/frontend/src/components/RemoteBackendRecovery.test.jsx
new file mode 100644
index 00000000..c10a2aa1
--- /dev/null
+++ b/frontend/src/components/RemoteBackendRecovery.test.jsx
@@ -0,0 +1,45 @@
+import React from 'react';
+import { describe, expect, it, vi } from 'vitest';
+import { fireEvent, render, screen } from '@testing-library/react';
+import RemoteBackendRecovery from './RemoteBackendRecovery';
+
+describe('RemoteBackendRecovery', () => {
+ it('explains the classified failure and supports retry', () => {
+ const onRetry = vi.fn();
+ const onOpenSettings = vi.fn();
+ render(
+ ,
+ );
+ expect(screen.getByRole('alert')).toHaveTextContent('7443');
+ fireEvent.click(screen.getByRole('button', { name: 'Retry' }));
+ expect(onRetry).toHaveBeenCalledOnce();
+ fireEvent.click(screen.getByRole('button', { name: 'Settings' }));
+ expect(onOpenSettings).toHaveBeenCalledOnce();
+ });
+
+ it('clears the remote URL and API key before reloading locally', () => {
+ localStorage.setItem('ov_backend_url', 'https://gpu-box:3900');
+ localStorage.setItem('ov_api_key', 'secret');
+ const reload = vi.fn();
+ render(
+ ,
+ );
+ fireEvent.click(screen.getByRole('button', { name: 'Use local backend' }));
+ expect(localStorage.getItem('ov_backend_url')).toBeNull();
+ expect(localStorage.getItem('ov_api_key')).toBeNull();
+ expect(reload).toHaveBeenCalledOnce();
+ });
+});
diff --git a/frontend/src/components/settings/RemoteBackendPanel.jsx b/frontend/src/components/settings/RemoteBackendPanel.jsx
index 64d9ce50..3ce889d2 100644
--- a/frontend/src/components/settings/RemoteBackendPanel.jsx
+++ b/frontend/src/components/settings/RemoteBackendPanel.jsx
@@ -20,6 +20,7 @@ import toast from 'react-hot-toast';
import { Trans, useTranslation } from 'react-i18next';
import { LS_BACKEND_URL, LS_API_KEY, API } from '../../api/client';
import { askConfirm } from '../../utils/dialog';
+import { disableRemoteBackend, probeRemoteBackend } from '../../utils/remoteBackendProbe';
import { SettingsSection, SettingRow, InfoHint, SettingsInput } from './primitives';
import { Button, Badge } from '../../ui';
@@ -30,7 +31,13 @@ export function isValidBackendUrl(value) {
if (!value) return false;
try {
const u = new URL(value);
- return u.protocol === 'http:' || u.protocol === 'https:';
+ return (
+ (u.protocol === 'http:' || u.protocol === 'https:') &&
+ !u.username &&
+ !u.password &&
+ !u.search &&
+ !u.hash
+ );
} catch {
return false;
}
@@ -42,6 +49,7 @@ export default function RemoteBackendPanel({ reload = () => window.location.relo
const [key, setKey] = useState(() => localStorage.getItem(LS_API_KEY) || '');
const [probe, setProbe] = useState(null); // {ok, detail, target}
const [testing, setTesting] = useState(false);
+ const hasSavedRemote = Boolean(localStorage.getItem(LS_BACKEND_URL));
const normalized = url.trim().replace(/\/+$/, '');
@@ -50,23 +58,7 @@ export default function RemoteBackendPanel({ reload = () => window.location.relo
setProbe(null);
const target = normalized || API;
try {
- const res = await fetch(`${target}/health`, {
- headers: key.trim() ? { Authorization: `Bearer ${key.trim()}` } : {},
- });
- const body = await res.json().catch(() => ({}));
- if (!res.ok) throw new Error(body?.detail || `HTTP ${res.status}`);
- setProbe({
- ok: true,
- detail: `${body.version || '?'} on ${body.device || '?'}`,
- target,
- });
- } catch (e) {
- setProbe({
- ok: false,
- detail:
- e?.message || t('settings.remote_backend_unreachable', { defaultValue: 'unreachable' }),
- target,
- });
+ setProbe(await probeRemoteBackend(target, key.trim()));
} finally {
setTesting(false);
}
@@ -172,6 +164,16 @@ export default function RemoteBackendPanel({ reload = () => window.location.relo
+ {hasSavedRemote && (
+
+ )}
{probe && (
{probe.ok
@@ -180,7 +182,9 @@ export default function RemoteBackendPanel({ reload = () => window.location.relo
defaultValue: 'OK — {{detail}}',
})
: t('settings.remote_backend_probe_fail', {
- detail: probe.detail,
+ detail: t(`settings.remote_backend_error_${probe.kind}`, {
+ status: probe.status,
+ }),
defaultValue: 'Failed — {{detail}}',
})}
diff --git a/frontend/src/components/settings/RemoteBackendPanel.test.jsx b/frontend/src/components/settings/RemoteBackendPanel.test.jsx
index 471c9930..e7fe7b52 100644
--- a/frontend/src/components/settings/RemoteBackendPanel.test.jsx
+++ b/frontend/src/components/settings/RemoteBackendPanel.test.jsx
@@ -19,6 +19,9 @@ vi.mock('../../utils/dialog', () => ({ askConfirm }));
import toast from 'react-hot-toast';
import RemoteBackendPanel, { isValidBackendUrl } from './RemoteBackendPanel';
+const healthResponse = (version) =>
+ new Response(JSON.stringify({ status: 'ok', version, device: 'cuda' }), { status: 200 });
+
describe('isValidBackendUrl', () => {
it('accepts absolute http(s) URLs only', () => {
expect(isValidBackendUrl('http://gpu-box:3900')).toBe(true);
@@ -28,6 +31,8 @@ describe('isValidBackendUrl', () => {
expect(isValidBackendUrl('gpu-box:3900')).toBe(false);
expect(isValidBackendUrl('not a url')).toBe(false);
expect(isValidBackendUrl('ftp://gpu-box')).toBe(false);
+ expect(isValidBackendUrl('https://user:secret@gpu-box:3900')).toBe(false);
+ expect(isValidBackendUrl('https://gpu-box:3900?key=secret')).toBe(false);
expect(isValidBackendUrl('')).toBe(false);
});
});
@@ -79,10 +84,7 @@ describe('RemoteBackendPanel', () => {
});
it('skips the confirmation when the exact URL passed a connection test', async () => {
- global.fetch = vi.fn().mockResolvedValue({
- ok: true,
- json: async () => ({ version: '0.3.15', device: 'cuda' }),
- });
+ global.fetch = vi.fn().mockResolvedValue(healthResponse('0.3.15'));
render();
setUrl('http://gpu-box:3900');
@@ -95,6 +97,32 @@ describe('RemoteBackendPanel', () => {
expect(localStorage.getItem('ov_backend_url')).toBe('http://gpu-box:3900');
});
+ it('classifies a wrong 7443 service and succeeds when retried with the HTTP API', async () => {
+ global.fetch = vi
+ .fn()
+ .mockRejectedValueOnce(new TypeError('Failed to fetch'))
+ .mockResolvedValueOnce(healthResponse('0.4.2'));
+ render();
+ setUrl('https://gpu-box:7443');
+ fireEvent.click(screen.getByTestId('remote-backend-test'));
+ await screen.findByText(/7443/);
+
+ setUrl('http://gpu-box:3900');
+ fireEvent.click(screen.getByTestId('remote-backend-test'));
+ await screen.findByText('OK — 0.4.2 on cuda');
+ expect(global.fetch).toHaveBeenCalledTimes(2);
+ });
+
+ it('offers an explicit disable action that clears the remote URL and key', () => {
+ localStorage.setItem('ov_backend_url', 'http://old-box:3900');
+ localStorage.setItem('ov_api_key', 'secret');
+ render();
+ fireEvent.click(screen.getByTestId('remote-backend-disable'));
+ expect(localStorage.getItem('ov_backend_url')).toBeNull();
+ expect(localStorage.getItem('ov_api_key')).toBeNull();
+ expect(reload).toHaveBeenCalledOnce();
+ });
+
it('clears both settings and reloads without confirmation when the URL is emptied', async () => {
localStorage.setItem('ov_backend_url', 'http://old-box:3900');
localStorage.setItem('ov_api_key', 'k');
diff --git a/frontend/src/i18n/locales/ar.json b/frontend/src/i18n/locales/ar.json
index 6348e778..8d2c4e36 100644
--- a/frontend/src/i18n/locales/ar.json
+++ b/frontend/src/i18n/locales/ar.json
@@ -25,6 +25,15 @@
"workspaces": "مساحات العمل"
},
"settings": {
+ "remote_backend_recovery_title": "تعذّر الوصول إلى الواجهة الخلفية البعيدة",
+ "remote_backend_recovery_hint": "يمكنك تغيير الواجهة الخلفية البعيدة لاحقًا من الإعدادات ← المشاركة.",
+ "remote_backend_use_local": "استخدام الواجهة الخلفية المحلية",
+ "remote_backend_error_tls": "تعذّر التحقق من الاتصال الآمن. تحقّق من الشهادة واستخدم منفذ HTTP API الخاص بـ VoiceStudio.",
+ "remote_backend_error_cors": "حظرت الواجهة الخلفية البعيدة هذا التطبيق. اسمح بمصدر التطبيق في إعدادات CORS.",
+ "remote_backend_error_network": "تعذّر الوصول إلى الواجهة الخلفية البعيدة. تحقّق من العنوان والشبكة وجدار الحماية.",
+ "remote_backend_error_timeout": "لم تستجب الواجهة الخلفية البعيدة في الوقت المحدد.",
+ "remote_backend_error_http": "أعادت الواجهة الخلفية البعيدة حالة HTTP {{status}}.",
+ "remote_backend_error_wrong_port": "لا تبدو هذه واجهة VoiceStudio HTTP API. المنفذ 7443 مخصص عادةً للتحكم بالعامل البعيد؛ جرّب المنفذ 3900.",
"openapi": "واجهة برمجة تطبيقات VoiceStudio",
"general": "عام",
"theme": "المظهر",
diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json
index 4f98d9da..4e81de79 100644
--- a/frontend/src/i18n/locales/de.json
+++ b/frontend/src/i18n/locales/de.json
@@ -25,6 +25,15 @@
"workspaces": "Arbeitsbereiche"
},
"settings": {
+ "remote_backend_recovery_title": "Remote-Backend nicht erreichbar",
+ "remote_backend_recovery_hint": "Du kannst das Remote-Backend später unter Einstellungen → Freigabe ändern.",
+ "remote_backend_use_local": "Lokales Backend verwenden",
+ "remote_backend_error_tls": "Die sichere Verbindung konnte nicht überprüft werden. Prüfe das Zertifikat und verwende den HTTP-API-Port von VoiceStudio.",
+ "remote_backend_error_cors": "Das Remote-Backend hat diese App blockiert. Erlaube den Ursprung der App in den CORS-Einstellungen.",
+ "remote_backend_error_network": "Das Remote-Backend ist nicht erreichbar. Prüfe Adresse, Netzwerk und Firewall.",
+ "remote_backend_error_timeout": "Das Remote-Backend hat nicht rechtzeitig geantwortet.",
+ "remote_backend_error_http": "Das Remote-Backend hat HTTP {{status}} zurückgegeben.",
+ "remote_backend_error_wrong_port": "Dies scheint nicht die VoiceStudio-HTTP-API zu sein. Port 7443 ist normalerweise der Steuerport des Remote-Workers; versuche Port 3900.",
"openapi": "VoiceStudio-API",
"general": "Allgemein",
"theme": "Theme",
diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json
index ff7de77e..670b513e 100644
--- a/frontend/src/i18n/locales/en.json
+++ b/frontend/src/i18n/locales/en.json
@@ -466,6 +466,15 @@
"remote_backend_invalid_url": "Enter a valid URL starting with http:// or https:// (e.g. http://gpu-box:3900).",
"remote_backend_confirm_unverified": "This backend URL hasn't passed a connection test. Save it and reload anyway? If it's wrong, the app can't reach any backend until you change it back here.",
"remote_backend_confirm_title": "Use unverified backend?",
+ "remote_backend_recovery_title": "Can't reach the remote backend",
+ "remote_backend_recovery_hint": "You can change the remote backend later in Settings → Sharing.",
+ "remote_backend_use_local": "Use local backend",
+ "remote_backend_error_tls": "The secure connection could not be verified. Check the certificate and use the VoiceStudio HTTP API port.",
+ "remote_backend_error_cors": "The remote backend blocked this app. Allow this app's origin in the backend CORS settings.",
+ "remote_backend_error_network": "The remote backend could not be reached. Check its address, network, and firewall.",
+ "remote_backend_error_timeout": "The remote backend did not respond in time.",
+ "remote_backend_error_http": "The remote backend returned HTTP {{status}}.",
+ "remote_backend_error_wrong_port": "This does not look like the VoiceStudio HTTP API. Port 7443 is usually the remote-worker control port; try port 3900.",
"ffmpeg": "FFmpeg",
"ffmpeg_found": "Found",
"ffmpeg_missing": "Not found",
diff --git a/frontend/src/i18n/locales/es.json b/frontend/src/i18n/locales/es.json
index 1ac9eba8..fd0c906d 100644
--- a/frontend/src/i18n/locales/es.json
+++ b/frontend/src/i18n/locales/es.json
@@ -25,6 +25,15 @@
"workspaces": "Espacios de trabajo"
},
"settings": {
+ "remote_backend_recovery_title": "No se puede acceder al backend remoto",
+ "remote_backend_recovery_hint": "Puedes cambiar el backend remoto más tarde en Ajustes → Compartir.",
+ "remote_backend_use_local": "Usar backend local",
+ "remote_backend_error_tls": "No se pudo verificar la conexión segura. Revisa el certificado y usa el puerto de la API HTTP de VoiceStudio.",
+ "remote_backend_error_cors": "El backend remoto bloqueó esta aplicación. Permite el origen de la aplicación en la configuración CORS.",
+ "remote_backend_error_network": "No se pudo acceder al backend remoto. Revisa la dirección, la red y el cortafuegos.",
+ "remote_backend_error_timeout": "El backend remoto no respondió a tiempo.",
+ "remote_backend_error_http": "El backend remoto devolvió HTTP {{status}}.",
+ "remote_backend_error_wrong_port": "Esto no parece la API HTTP de VoiceStudio. El puerto 7443 suele ser el puerto de control del trabajador remoto; prueba el 3900.",
"openapi": "API de VoiceStudio",
"general": "General",
"theme": "Tema",
diff --git a/frontend/src/i18n/locales/fr.json b/frontend/src/i18n/locales/fr.json
index 8b4e84ad..51a10445 100644
--- a/frontend/src/i18n/locales/fr.json
+++ b/frontend/src/i18n/locales/fr.json
@@ -25,6 +25,15 @@
"workspaces": "Espaces de travail"
},
"settings": {
+ "remote_backend_recovery_title": "Impossible de joindre le backend distant",
+ "remote_backend_recovery_hint": "Vous pourrez modifier le backend distant plus tard dans Réglages → Partage.",
+ "remote_backend_use_local": "Utiliser le backend local",
+ "remote_backend_error_tls": "La connexion sécurisée n’a pas pu être vérifiée. Vérifiez le certificat et utilisez le port de l’API HTTP VoiceStudio.",
+ "remote_backend_error_cors": "Le backend distant a bloqué cette application. Autorisez l’origine de l’application dans les réglages CORS.",
+ "remote_backend_error_network": "Le backend distant est inaccessible. Vérifiez son adresse, le réseau et le pare-feu.",
+ "remote_backend_error_timeout": "Le backend distant n’a pas répondu à temps.",
+ "remote_backend_error_http": "Le backend distant a renvoyé HTTP {{status}}.",
+ "remote_backend_error_wrong_port": "Ce service ne semble pas être l’API HTTP VoiceStudio. Le port 7443 sert généralement au contrôle du worker distant ; essayez le port 3900.",
"openapi": "API VoiceStudio",
"general": "Général",
"theme": "Thème",
diff --git a/frontend/src/i18n/locales/hi.json b/frontend/src/i18n/locales/hi.json
index e25a9b8a..d85c8a31 100644
--- a/frontend/src/i18n/locales/hi.json
+++ b/frontend/src/i18n/locales/hi.json
@@ -25,6 +25,15 @@
"workspaces": "वर्कस्पेस"
},
"settings": {
+ "remote_backend_recovery_title": "रिमोट बैकएंड से संपर्क नहीं हो सका",
+ "remote_backend_recovery_hint": "आप बाद में सेटिंग्स → शेयरिंग में रिमोट बैकएंड बदल सकते हैं।",
+ "remote_backend_use_local": "लोकल बैकएंड इस्तेमाल करें",
+ "remote_backend_error_tls": "सुरक्षित कनेक्शन सत्यापित नहीं हो सका। प्रमाणपत्र जाँचें और VoiceStudio HTTP API पोर्ट इस्तेमाल करें।",
+ "remote_backend_error_cors": "रिमोट बैकएंड ने इस ऐप को रोक दिया। CORS सेटिंग्स में ऐप के ओरिजिन को अनुमति दें।",
+ "remote_backend_error_network": "रिमोट बैकएंड तक पहुँचा नहीं जा सका। पता, नेटवर्क और फ़ायरवॉल जाँचें।",
+ "remote_backend_error_timeout": "रिमोट बैकएंड ने समय पर जवाब नहीं दिया।",
+ "remote_backend_error_http": "रिमोट बैकएंड ने HTTP {{status}} लौटाया।",
+ "remote_backend_error_wrong_port": "यह VoiceStudio HTTP API नहीं लगता। पोर्ट 7443 आम तौर पर रिमोट वर्कर नियंत्रण पोर्ट है; पोर्ट 3900 आज़माएँ।",
"openapi": "VoiceStudio API",
"general": "सामान्य",
"theme": "थीम",
diff --git a/frontend/src/i18n/locales/id.json b/frontend/src/i18n/locales/id.json
index 2fada0f8..50cb0852 100644
--- a/frontend/src/i18n/locales/id.json
+++ b/frontend/src/i18n/locales/id.json
@@ -25,6 +25,15 @@
"workspaces": "Ruang kerja"
},
"settings": {
+ "remote_backend_recovery_title": "Backend jarak jauh tidak dapat dijangkau",
+ "remote_backend_recovery_hint": "Anda dapat mengubah backend jarak jauh nanti di Pengaturan → Berbagi.",
+ "remote_backend_use_local": "Gunakan backend lokal",
+ "remote_backend_error_tls": "Koneksi aman tidak dapat diverifikasi. Periksa sertifikat dan gunakan port API HTTP VoiceStudio.",
+ "remote_backend_error_cors": "Backend jarak jauh memblokir aplikasi ini. Izinkan origin aplikasi di pengaturan CORS.",
+ "remote_backend_error_network": "Backend jarak jauh tidak dapat dijangkau. Periksa alamat, jaringan, dan firewall.",
+ "remote_backend_error_timeout": "Backend jarak jauh tidak merespons tepat waktu.",
+ "remote_backend_error_http": "Backend jarak jauh mengembalikan HTTP {{status}}.",
+ "remote_backend_error_wrong_port": "Layanan ini tampaknya bukan API HTTP VoiceStudio. Port 7443 biasanya merupakan port kontrol pekerja jarak jauh; coba port 3900.",
"openapi": "API VoiceStudio",
"general": "Umum",
"theme": "Tema",
diff --git a/frontend/src/i18n/locales/it.json b/frontend/src/i18n/locales/it.json
index 046b4fd3..c4669a2b 100644
--- a/frontend/src/i18n/locales/it.json
+++ b/frontend/src/i18n/locales/it.json
@@ -25,6 +25,15 @@
"workspaces": "Aree di lavoro"
},
"settings": {
+ "remote_backend_recovery_title": "Impossibile raggiungere il backend remoto",
+ "remote_backend_recovery_hint": "Puoi modificare il backend remoto in seguito in Impostazioni → Condivisione.",
+ "remote_backend_use_local": "Usa il backend locale",
+ "remote_backend_error_tls": "Impossibile verificare la connessione sicura. Controlla il certificato e usa la porta dell’API HTTP di VoiceStudio.",
+ "remote_backend_error_cors": "Il backend remoto ha bloccato questa app. Consenti l’origine dell’app nelle impostazioni CORS.",
+ "remote_backend_error_network": "Impossibile raggiungere il backend remoto. Controlla indirizzo, rete e firewall.",
+ "remote_backend_error_timeout": "Il backend remoto non ha risposto in tempo.",
+ "remote_backend_error_http": "Il backend remoto ha restituito HTTP {{status}}.",
+ "remote_backend_error_wrong_port": "Questo servizio non sembra l’API HTTP di VoiceStudio. La porta 7443 è solitamente la porta di controllo del worker remoto; prova la porta 3900.",
"openapi": "API VoiceStudio",
"general": "Generale",
"theme": "Tema",
diff --git a/frontend/src/i18n/locales/ja.json b/frontend/src/i18n/locales/ja.json
index 885c3801..43813a81 100644
--- a/frontend/src/i18n/locales/ja.json
+++ b/frontend/src/i18n/locales/ja.json
@@ -25,6 +25,15 @@
"workspaces": "ワークスペース"
},
"settings": {
+ "remote_backend_recovery_title": "リモートバックエンドに接続できません",
+ "remote_backend_recovery_hint": "リモートバックエンドは後で「設定」→「共有」から変更できます。",
+ "remote_backend_use_local": "ローカルバックエンドを使用",
+ "remote_backend_error_tls": "安全な接続を検証できませんでした。証明書を確認し、VoiceStudio HTTP API のポートを使用してください。",
+ "remote_backend_error_cors": "リモートバックエンドがこのアプリをブロックしました。CORS 設定でアプリのオリジンを許可してください。",
+ "remote_backend_error_network": "リモートバックエンドに到達できません。アドレス、ネットワーク、ファイアウォールを確認してください。",
+ "remote_backend_error_timeout": "リモートバックエンドが時間内に応答しませんでした。",
+ "remote_backend_error_http": "リモートバックエンドが HTTP {{status}} を返しました。",
+ "remote_backend_error_wrong_port": "VoiceStudio HTTP API ではないようです。ポート 7443 は通常リモートワーカーの制御用です。ポート 3900 をお試しください。",
"openapi": "VoiceStudio API",
"general": "一般",
"theme": "テーマ",
diff --git a/frontend/src/i18n/locales/ko.json b/frontend/src/i18n/locales/ko.json
index 312609d1..0cdc20d0 100644
--- a/frontend/src/i18n/locales/ko.json
+++ b/frontend/src/i18n/locales/ko.json
@@ -25,6 +25,15 @@
"workspaces": "작업 공간"
},
"settings": {
+ "remote_backend_recovery_title": "원격 백엔드에 연결할 수 없음",
+ "remote_backend_recovery_hint": "나중에 설정 → 공유에서 원격 백엔드를 변경할 수 있습니다.",
+ "remote_backend_use_local": "로컬 백엔드 사용",
+ "remote_backend_error_tls": "보안 연결을 확인할 수 없습니다. 인증서를 확인하고 VoiceStudio HTTP API 포트를 사용하세요.",
+ "remote_backend_error_cors": "원격 백엔드가 이 앱을 차단했습니다. CORS 설정에서 앱의 출처를 허용하세요.",
+ "remote_backend_error_network": "원격 백엔드에 연결할 수 없습니다. 주소, 네트워크 및 방화벽을 확인하세요.",
+ "remote_backend_error_timeout": "원격 백엔드가 제시간에 응답하지 않았습니다.",
+ "remote_backend_error_http": "원격 백엔드가 HTTP {{status}}을(를) 반환했습니다.",
+ "remote_backend_error_wrong_port": "VoiceStudio HTTP API가 아닌 것 같습니다. 포트 7443은 일반적으로 원격 워커 제어 포트입니다. 포트 3900을 사용해 보세요.",
"openapi": "VoiceStudio API",
"general": "일반",
"theme": "테마",
diff --git a/frontend/src/i18n/locales/nl.json b/frontend/src/i18n/locales/nl.json
index 6e2807f4..dd8ba8b2 100644
--- a/frontend/src/i18n/locales/nl.json
+++ b/frontend/src/i18n/locales/nl.json
@@ -25,6 +25,15 @@
"workspaces": "Werkruimtes"
},
"settings": {
+ "remote_backend_recovery_title": "Kan de externe backend niet bereiken",
+ "remote_backend_recovery_hint": "Je kunt de externe backend later wijzigen via Instellingen → Delen.",
+ "remote_backend_use_local": "Lokale backend gebruiken",
+ "remote_backend_error_tls": "De beveiligde verbinding kon niet worden geverifieerd. Controleer het certificaat en gebruik de HTTP-API-poort van VoiceStudio.",
+ "remote_backend_error_cors": "De externe backend heeft deze app geblokkeerd. Sta de oorsprong van de app toe in de CORS-instellingen.",
+ "remote_backend_error_network": "De externe backend is niet bereikbaar. Controleer het adres, netwerk en de firewall.",
+ "remote_backend_error_timeout": "De externe backend reageerde niet op tijd.",
+ "remote_backend_error_http": "De externe backend gaf HTTP {{status}} terug.",
+ "remote_backend_error_wrong_port": "Dit lijkt niet de VoiceStudio HTTP-API. Poort 7443 is meestal de besturingspoort van de externe worker; probeer poort 3900.",
"openapi": "VoiceStudio-API",
"general": "Algemeen",
"theme": "Thema",
diff --git a/frontend/src/i18n/locales/pl.json b/frontend/src/i18n/locales/pl.json
index e96d15f2..f3bd57e0 100644
--- a/frontend/src/i18n/locales/pl.json
+++ b/frontend/src/i18n/locales/pl.json
@@ -25,6 +25,15 @@
"workspaces": "Obszary robocze"
},
"settings": {
+ "remote_backend_recovery_title": "Nie można połączyć się ze zdalnym backendem",
+ "remote_backend_recovery_hint": "Zdalny backend możesz później zmienić w Ustawienia → Udostępnianie.",
+ "remote_backend_use_local": "Użyj lokalnego backendu",
+ "remote_backend_error_tls": "Nie udało się zweryfikować bezpiecznego połączenia. Sprawdź certyfikat i użyj portu HTTP API VoiceStudio.",
+ "remote_backend_error_cors": "Zdalny backend zablokował tę aplikację. Zezwól na źródło aplikacji w ustawieniach CORS.",
+ "remote_backend_error_network": "Nie można połączyć się ze zdalnym backendem. Sprawdź adres, sieć i zaporę.",
+ "remote_backend_error_timeout": "Zdalny backend nie odpowiedział na czas.",
+ "remote_backend_error_http": "Zdalny backend zwrócił HTTP {{status}}.",
+ "remote_backend_error_wrong_port": "To nie wygląda na HTTP API VoiceStudio. Port 7443 zwykle służy do sterowania zdalnym workerem; spróbuj portu 3900.",
"openapi": "API VoiceStudio",
"general": "Ogólne",
"theme": "Motyw",
diff --git a/frontend/src/i18n/locales/pt.json b/frontend/src/i18n/locales/pt.json
index d1e8be39..21dde9d6 100644
--- a/frontend/src/i18n/locales/pt.json
+++ b/frontend/src/i18n/locales/pt.json
@@ -25,6 +25,15 @@
"workspaces": "Áreas de trabalho"
},
"settings": {
+ "remote_backend_recovery_title": "Não foi possível acessar o backend remoto",
+ "remote_backend_recovery_hint": "Você pode alterar o backend remoto depois em Configurações → Compartilhamento.",
+ "remote_backend_use_local": "Usar backend local",
+ "remote_backend_error_tls": "Não foi possível verificar a conexão segura. Confira o certificado e use a porta da API HTTP do VoiceStudio.",
+ "remote_backend_error_cors": "O backend remoto bloqueou este aplicativo. Permita a origem do aplicativo nas configurações de CORS.",
+ "remote_backend_error_network": "Não foi possível acessar o backend remoto. Confira o endereço, a rede e o firewall.",
+ "remote_backend_error_timeout": "O backend remoto não respondeu a tempo.",
+ "remote_backend_error_http": "O backend remoto retornou HTTP {{status}}.",
+ "remote_backend_error_wrong_port": "Este serviço não parece ser a API HTTP do VoiceStudio. A porta 7443 geralmente controla o worker remoto; tente a porta 3900.",
"openapi": "API do VoiceStudio",
"general": "Geral",
"theme": "Tema",
diff --git a/frontend/src/i18n/locales/ru.json b/frontend/src/i18n/locales/ru.json
index 185e887c..6abde62d 100644
--- a/frontend/src/i18n/locales/ru.json
+++ b/frontend/src/i18n/locales/ru.json
@@ -25,6 +25,15 @@
"workspaces": "Рабочие пространства"
},
"settings": {
+ "remote_backend_recovery_title": "Не удалось подключиться к удалённому бэкенду",
+ "remote_backend_recovery_hint": "Позже удалённый бэкенд можно изменить в разделе «Настройки → Общий доступ».",
+ "remote_backend_use_local": "Использовать локальный бэкенд",
+ "remote_backend_error_tls": "Не удалось проверить защищённое соединение. Проверьте сертификат и используйте порт HTTP API VoiceStudio.",
+ "remote_backend_error_cors": "Удалённый бэкенд заблокировал приложение. Разрешите источник приложения в настройках CORS.",
+ "remote_backend_error_network": "Удалённый бэкенд недоступен. Проверьте адрес, сеть и брандмауэр.",
+ "remote_backend_error_timeout": "Удалённый бэкенд не ответил вовремя.",
+ "remote_backend_error_http": "Удалённый бэкенд вернул HTTP {{status}}.",
+ "remote_backend_error_wrong_port": "Похоже, это не HTTP API VoiceStudio. Порт 7443 обычно служит для управления удалённым worker; попробуйте порт 3900.",
"openapi": "API VoiceStudio",
"general": "Общие",
"theme": "Тема",
diff --git a/frontend/src/i18n/locales/sv.json b/frontend/src/i18n/locales/sv.json
index 7baaa793..be42e1f3 100644
--- a/frontend/src/i18n/locales/sv.json
+++ b/frontend/src/i18n/locales/sv.json
@@ -25,6 +25,15 @@
"workspaces": "Arbetsytor"
},
"settings": {
+ "remote_backend_recovery_title": "Det går inte att nå fjärrbackend",
+ "remote_backend_recovery_hint": "Du kan ändra fjärrbackend senare under Inställningar → Delning.",
+ "remote_backend_use_local": "Använd lokal backend",
+ "remote_backend_error_tls": "Den säkra anslutningen kunde inte verifieras. Kontrollera certifikatet och använd VoiceStudios HTTP API-port.",
+ "remote_backend_error_cors": "Fjärrbackend blockerade appen. Tillåt appens ursprung i CORS-inställningarna.",
+ "remote_backend_error_network": "Fjärrbackend kunde inte nås. Kontrollera adressen, nätverket och brandväggen.",
+ "remote_backend_error_timeout": "Fjärrbackend svarade inte i tid.",
+ "remote_backend_error_http": "Fjärrbackend returnerade HTTP {{status}}.",
+ "remote_backend_error_wrong_port": "Det här verkar inte vara VoiceStudios HTTP API. Port 7443 är vanligtvis kontrollporten för fjärrworkern; prova port 3900.",
"openapi": "VoiceStudio API",
"general": "Allmänt",
"theme": "Tema",
diff --git a/frontend/src/i18n/locales/th.json b/frontend/src/i18n/locales/th.json
index 38149f6a..cc119d7e 100644
--- a/frontend/src/i18n/locales/th.json
+++ b/frontend/src/i18n/locales/th.json
@@ -25,6 +25,15 @@
"workspaces": "พื้นที่ทำงาน"
},
"settings": {
+ "remote_backend_recovery_title": "ไม่สามารถเชื่อมต่อแบ็กเอนด์ระยะไกลได้",
+ "remote_backend_recovery_hint": "คุณเปลี่ยนแบ็กเอนด์ระยะไกลภายหลังได้ที่ การตั้งค่า → การแชร์",
+ "remote_backend_use_local": "ใช้แบ็กเอนด์ในเครื่อง",
+ "remote_backend_error_tls": "ไม่สามารถยืนยันการเชื่อมต่อที่ปลอดภัยได้ โปรดตรวจสอบใบรับรองและใช้พอร์ต HTTP API ของ VoiceStudio",
+ "remote_backend_error_cors": "แบ็กเอนด์ระยะไกลบล็อกแอปนี้ โปรดอนุญาตต้นทางของแอปในการตั้งค่า CORS",
+ "remote_backend_error_network": "ไม่สามารถเข้าถึงแบ็กเอนด์ระยะไกลได้ โปรดตรวจสอบที่อยู่ เครือข่าย และไฟร์วอลล์",
+ "remote_backend_error_timeout": "แบ็กเอนด์ระยะไกลไม่ตอบสนองภายในเวลาที่กำหนด",
+ "remote_backend_error_http": "แบ็กเอนด์ระยะไกลส่งคืน HTTP {{status}}",
+ "remote_backend_error_wrong_port": "บริการนี้ดูเหมือนไม่ใช่ HTTP API ของ VoiceStudio โดยปกติพอร์ต 7443 ใช้ควบคุมเวิร์กเกอร์ระยะไกล โปรดลองพอร์ต 3900",
"openapi": "API ของ VoiceStudio",
"general": "ทั่วไป",
"theme": "ธีม",
diff --git a/frontend/src/i18n/locales/tr.json b/frontend/src/i18n/locales/tr.json
index 3b1c21f6..2c16d705 100644
--- a/frontend/src/i18n/locales/tr.json
+++ b/frontend/src/i18n/locales/tr.json
@@ -25,6 +25,15 @@
"workspaces": "Çalışma alanları"
},
"settings": {
+ "remote_backend_recovery_title": "Uzak arka uca ulaşılamıyor",
+ "remote_backend_recovery_hint": "Uzak arka ucu daha sonra Ayarlar → Paylaşım bölümünden değiştirebilirsiniz.",
+ "remote_backend_use_local": "Yerel arka ucu kullan",
+ "remote_backend_error_tls": "Güvenli bağlantı doğrulanamadı. Sertifikayı kontrol edin ve VoiceStudio HTTP API bağlantı noktasını kullanın.",
+ "remote_backend_error_cors": "Uzak arka uç bu uygulamayı engelledi. CORS ayarlarında uygulamanın kaynağına izin verin.",
+ "remote_backend_error_network": "Uzak arka uca ulaşılamadı. Adresi, ağı ve güvenlik duvarını kontrol edin.",
+ "remote_backend_error_timeout": "Uzak arka uç zamanında yanıt vermedi.",
+ "remote_backend_error_http": "Uzak arka uç HTTP {{status}} döndürdü.",
+ "remote_backend_error_wrong_port": "Bu hizmet VoiceStudio HTTP API gibi görünmüyor. 7443 bağlantı noktası genellikle uzak worker denetimi içindir; 3900 bağlantı noktasını deneyin.",
"openapi": "VoiceStudio API",
"general": "Genel",
"theme": "Tema",
diff --git a/frontend/src/i18n/locales/uk.json b/frontend/src/i18n/locales/uk.json
index 3696ef95..30653265 100644
--- a/frontend/src/i18n/locales/uk.json
+++ b/frontend/src/i18n/locales/uk.json
@@ -25,6 +25,15 @@
"workspaces": "Робочі простори"
},
"settings": {
+ "remote_backend_recovery_title": "Не вдалося підключитися до віддаленого бекенду",
+ "remote_backend_recovery_hint": "Згодом віддалений бекенд можна змінити в Налаштування → Спільний доступ.",
+ "remote_backend_use_local": "Використовувати локальний бекенд",
+ "remote_backend_error_tls": "Не вдалося перевірити захищене з’єднання. Перевірте сертифікат і використовуйте порт HTTP API VoiceStudio.",
+ "remote_backend_error_cors": "Віддалений бекенд заблокував застосунок. Дозвольте джерело застосунку в налаштуваннях CORS.",
+ "remote_backend_error_network": "Віддалений бекенд недоступний. Перевірте адресу, мережу та брандмауер.",
+ "remote_backend_error_timeout": "Віддалений бекенд не відповів вчасно.",
+ "remote_backend_error_http": "Віддалений бекенд повернув HTTP {{status}}.",
+ "remote_backend_error_wrong_port": "Схоже, це не HTTP API VoiceStudio. Порт 7443 зазвичай керує віддаленим worker; спробуйте порт 3900.",
"openapi": "API VoiceStudio",
"general": "Загальні",
"theme": "Тема",
diff --git a/frontend/src/i18n/locales/vi.json b/frontend/src/i18n/locales/vi.json
index f88d900b..6b7fee13 100644
--- a/frontend/src/i18n/locales/vi.json
+++ b/frontend/src/i18n/locales/vi.json
@@ -25,6 +25,15 @@
"workspaces": "Không gian làm việc"
},
"settings": {
+ "remote_backend_recovery_title": "Không thể kết nối với backend từ xa",
+ "remote_backend_recovery_hint": "Bạn có thể đổi backend từ xa sau trong Cài đặt → Chia sẻ.",
+ "remote_backend_use_local": "Dùng backend cục bộ",
+ "remote_backend_error_tls": "Không thể xác minh kết nối bảo mật. Hãy kiểm tra chứng chỉ và dùng cổng HTTP API của VoiceStudio.",
+ "remote_backend_error_cors": "Backend từ xa đã chặn ứng dụng này. Hãy cho phép nguồn của ứng dụng trong cài đặt CORS.",
+ "remote_backend_error_network": "Không thể truy cập backend từ xa. Hãy kiểm tra địa chỉ, mạng và tường lửa.",
+ "remote_backend_error_timeout": "Backend từ xa không phản hồi kịp thời.",
+ "remote_backend_error_http": "Backend từ xa trả về HTTP {{status}}.",
+ "remote_backend_error_wrong_port": "Dịch vụ này có vẻ không phải HTTP API của VoiceStudio. Cổng 7443 thường dùng để điều khiển worker từ xa; hãy thử cổng 3900.",
"openapi": "API VoiceStudio",
"general": "Chung",
"theme": "Giao diện",
diff --git a/frontend/src/i18n/locales/zh-CN.json b/frontend/src/i18n/locales/zh-CN.json
index 900ee9fc..373a6e63 100644
--- a/frontend/src/i18n/locales/zh-CN.json
+++ b/frontend/src/i18n/locales/zh-CN.json
@@ -250,6 +250,15 @@
"seed_reroll_hint": "滚动一个新的随机种子并保留它"
},
"settings": {
+ "remote_backend_recovery_title": "无法连接远程后端",
+ "remote_backend_recovery_hint": "你可以稍后在“设置 → 共享”中更改远程后端。",
+ "remote_backend_use_local": "使用本地后端",
+ "remote_backend_error_tls": "无法验证安全连接。请检查证书并使用 VoiceStudio HTTP API 端口。",
+ "remote_backend_error_cors": "远程后端阻止了此应用。请在 CORS 设置中允许此应用的来源。",
+ "remote_backend_error_network": "无法访问远程后端。请检查地址、网络和防火墙。",
+ "remote_backend_error_timeout": "远程后端未及时响应。",
+ "remote_backend_error_http": "远程后端返回了 HTTP {{status}}。",
+ "remote_backend_error_wrong_port": "这似乎不是 VoiceStudio HTTP API。端口 7443 通常用于远程工作进程控制;请尝试端口 3900。",
"openapi": "VoiceStudio API",
"title": "设置",
"general": "通用",
diff --git a/frontend/src/i18n/locales/zh-TW.json b/frontend/src/i18n/locales/zh-TW.json
index 7e41b580..c24834c3 100644
--- a/frontend/src/i18n/locales/zh-TW.json
+++ b/frontend/src/i18n/locales/zh-TW.json
@@ -25,6 +25,15 @@
"workspaces": "工作區"
},
"settings": {
+ "remote_backend_recovery_title": "無法連線至遠端後端",
+ "remote_backend_recovery_hint": "你可以稍後在「設定 → 分享」中變更遠端後端。",
+ "remote_backend_use_local": "使用本機後端",
+ "remote_backend_error_tls": "無法驗證安全連線。請檢查憑證並使用 VoiceStudio HTTP API 連接埠。",
+ "remote_backend_error_cors": "遠端後端封鎖了此應用程式。請在 CORS 設定中允許此應用程式的來源。",
+ "remote_backend_error_network": "無法存取遠端後端。請檢查位址、網路和防火牆。",
+ "remote_backend_error_timeout": "遠端後端未及時回應。",
+ "remote_backend_error_http": "遠端後端回傳 HTTP {{status}}。",
+ "remote_backend_error_wrong_port": "這似乎不是 VoiceStudio HTTP API。連接埠 7443 通常用於遠端工作程序控制;請嘗試連接埠 3900。",
"openapi": "VoiceStudio API",
"general": "一般",
"theme": "佈景主題",
diff --git a/frontend/src/test/AppRemoteBackendGate.test.js b/frontend/src/test/AppRemoteBackendGate.test.js
new file mode 100644
index 00000000..bd103b4a
--- /dev/null
+++ b/frontend/src/test/AppRemoteBackendGate.test.js
@@ -0,0 +1,21 @@
+import { describe, expect, it } from 'vitest';
+import appSource from '../App.jsx?raw';
+
+describe('App remote-backend startup gate', () => {
+ it('probes a configured remote before the local setup-status path', () => {
+ const remoteProbe = appSource.indexOf('const remote = configuredRemoteBackend()');
+ const localSetup = appSource.indexOf("import('./api/setup')", remoteProbe);
+ expect(remoteProbe).toBeGreaterThan(-1);
+ expect(localSetup).toBeGreaterThan(remoteProbe);
+ expect(appSource.slice(remoteProbe, localSetup)).toContain('probeRemoteBackend');
+ expect(appSource.slice(remoteProbe, localSetup)).toContain('return;');
+ });
+
+ it('renders recovery before it can route to the local SetupWizard', () => {
+ const recovery = appSource.indexOf('if (remoteFailure)');
+ const wizard = appSource.indexOf("if (setupNeeded && bootstrapStage === 'ready')", recovery);
+ expect(recovery).toBeGreaterThan(-1);
+ expect(wizard).toBeGreaterThan(recovery);
+ expect(appSource.slice(recovery, wizard)).toContain('RemoteBackendRecovery');
+ });
+});
diff --git a/frontend/src/utils/remoteBackendProbe.test.ts b/frontend/src/utils/remoteBackendProbe.test.ts
new file mode 100644
index 00000000..78686333
--- /dev/null
+++ b/frontend/src/utils/remoteBackendProbe.test.ts
@@ -0,0 +1,142 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import {
+ configuredRemoteBackend,
+ disableRemoteBackend,
+ probeRemoteBackend,
+} from './remoteBackendProbe';
+
+const response = (body: unknown, status = 200) => new Response(JSON.stringify(body), { status });
+
+describe('remote backend probe', () => {
+ beforeEach(() => localStorage.clear());
+ afterEach(() => vi.useRealTimers());
+
+ it('reads a configured remote without exposing its key in the target', () => {
+ localStorage.setItem('ov_backend_url', 'https://gpu-box:3900/');
+ localStorage.setItem('ov_api_key', 'secret');
+ expect(configuredRemoteBackend()).toEqual({ url: 'https://gpu-box:3900', key: 'secret' });
+ });
+
+ it('probes the exact health endpoint with authentication', async () => {
+ const fetchImpl = vi
+ .fn()
+ .mockResolvedValue(response({ status: 'ok', version: '0.4.2', device: 'cuda' }));
+
+ await expect(
+ probeRemoteBackend('https://gpu-box:3900/', 'secret', { fetchImpl }),
+ ).resolves.toEqual({
+ ok: true,
+ detail: '0.4.2 on cuda',
+ target: 'https://gpu-box:3900',
+ });
+ expect(fetchImpl).toHaveBeenCalledWith(
+ 'https://gpu-box:3900/health',
+ expect.objectContaining({ headers: { Authorization: 'Bearer secret' } }),
+ );
+ });
+
+ it.each([
+ ['https://gpu-box:3900', new TypeError('certificate verify failed'), 'tls'],
+ ['https://gpu-box:3900', new TypeError('Failed to fetch'), 'network'],
+ ['https://gpu-box:3900', new TypeError('blocked by CORS policy'), 'cors'],
+ ['http://gpu-box:3900', new TypeError('Failed to fetch'), 'network'],
+ ['https://gpu-box:7443', new TypeError('Failed to fetch'), 'wrong_port'],
+ ])('classifies %s transport failures as %s', async (url, error, kind) => {
+ const result = await probeRemoteBackend(url, '', {
+ fetchImpl: vi.fn().mockRejectedValue(error),
+ });
+ expect(result).toMatchObject({ ok: false, kind, target: url });
+ });
+
+ it('classifies HTTP failures and non-VoiceStudio services', async () => {
+ await expect(
+ probeRemoteBackend('https://gpu-box:3900', '', {
+ fetchImpl: vi.fn().mockResolvedValue(response({}, 503)),
+ }),
+ ).resolves.toMatchObject({ ok: false, kind: 'http', status: 503 });
+ await expect(
+ probeRemoteBackend('https://gpu-box:7443', '', {
+ fetchImpl: vi.fn().mockResolvedValue(response({ status: 'ok' })),
+ }),
+ ).resolves.toMatchObject({ ok: false, kind: 'wrong_port' });
+ });
+
+ it('rejects an oversized health response without consuming it', async () => {
+ const getReader = vi.fn();
+ const fetchImpl = vi.fn().mockResolvedValue({
+ ok: true,
+ status: 200,
+ headers: new Headers({ 'content-length': '20000' }),
+ body: { getReader },
+ });
+ await expect(
+ probeRemoteBackend('https://gpu-box:3900', '', {
+ fetchImpl: fetchImpl as typeof fetch,
+ }),
+ ).resolves.toMatchObject({ ok: false, kind: 'wrong_port' });
+ expect(getReader).not.toHaveBeenCalled();
+ });
+
+ it('cancels a chunked health response once it exceeds the byte limit', async () => {
+ const cancel = vi.fn().mockResolvedValue(undefined);
+ const releaseLock = vi.fn();
+ const chunks = [new Uint8Array(10_000), new Uint8Array(7_000)];
+ const read = vi
+ .fn()
+ .mockResolvedValueOnce({ done: false, value: chunks[0] })
+ .mockResolvedValueOnce({ done: false, value: chunks[1] });
+ const fetchImpl = vi.fn().mockResolvedValue({
+ ok: true,
+ status: 200,
+ headers: new Headers(),
+ body: { getReader: () => ({ read, cancel, releaseLock }) },
+ });
+
+ await expect(
+ probeRemoteBackend('https://gpu-box:3900', '', {
+ fetchImpl: fetchImpl as typeof fetch,
+ }),
+ ).resolves.toMatchObject({ ok: false, kind: 'wrong_port' });
+ expect(cancel).toHaveBeenCalledOnce();
+ expect(read).toHaveBeenCalledTimes(2);
+ });
+
+ it('does not request or disclose credentials embedded in a legacy URL', async () => {
+ const fetchImpl = vi.fn();
+ await expect(
+ probeRemoteBackend('https://user:secret@gpu-box:3900', '', { fetchImpl }),
+ ).resolves.toEqual({
+ ok: false,
+ kind: 'network',
+ target: 'https://gpu-box:3900',
+ });
+ expect(fetchImpl).not.toHaveBeenCalled();
+ });
+
+ it('aborts a probe at its bounded deadline', async () => {
+ vi.useFakeTimers();
+ const fetchImpl = vi.fn((_url, init) => {
+ return new Promise((_resolve, reject) => {
+ init?.signal?.addEventListener('abort', () =>
+ reject(new DOMException('aborted', 'AbortError')),
+ );
+ });
+ });
+ const pending = probeRemoteBackend('http://gpu-box:3900', '', {
+ fetchImpl: fetchImpl as typeof fetch,
+ timeoutMs: 25,
+ });
+ await vi.advanceTimersByTimeAsync(25);
+ await expect(pending).resolves.toMatchObject({ ok: false, kind: 'timeout' });
+ });
+
+ it('clears both credentials and reloads', () => {
+ localStorage.setItem('ov_backend_url', 'http://gpu-box:3900');
+ localStorage.setItem('ov_api_key', 'secret');
+ const reload = vi.fn();
+ disableRemoteBackend(reload);
+ expect(localStorage.getItem('ov_backend_url')).toBeNull();
+ expect(localStorage.getItem('ov_api_key')).toBeNull();
+ expect(reload).toHaveBeenCalledOnce();
+ });
+});
diff --git a/frontend/src/utils/remoteBackendProbe.ts b/frontend/src/utils/remoteBackendProbe.ts
new file mode 100644
index 00000000..f447aa8f
--- /dev/null
+++ b/frontend/src/utils/remoteBackendProbe.ts
@@ -0,0 +1,133 @@
+import { LS_API_KEY, LS_BACKEND_URL } from '../api/client.ts';
+
+export type RemoteProbeKind = 'tls' | 'cors' | 'network' | 'timeout' | 'http' | 'wrong_port';
+
+export type RemoteProbeResult =
+ | { ok: true; detail: string; target: string }
+ | { ok: false; kind: RemoteProbeKind; status?: number; target: string };
+
+const MAX_HEALTH_BYTES = 16 * 1024;
+
+export function configuredRemoteBackend(): { url: string; key: string } | null {
+ try {
+ const url = localStorage.getItem(LS_BACKEND_URL)?.trim().replace(/\/+$/, '') || '';
+ return url ? { url, key: localStorage.getItem(LS_API_KEY)?.trim() || '' } : null;
+ } catch {
+ return null;
+ }
+}
+
+export function disableRemoteBackend(reload: () => void): void {
+ try {
+ localStorage.removeItem(LS_BACKEND_URL);
+ localStorage.removeItem(LS_API_KEY);
+ } finally {
+ reload();
+ }
+}
+
+function transportKind(url: URL, error: unknown): RemoteProbeKind {
+ const text = String((error as Error)?.message || error).toLowerCase();
+ if ((error as Error)?.name === 'AbortError') return 'timeout';
+ if (url.port === '7443') return 'wrong_port';
+ if (/certificate|cert_|ssl|tls/.test(text)) return 'tls';
+ if (/cors|cross-origin/.test(text)) return 'cors';
+ // Browser fetch deliberately hides DNS, connection, CORS, and TLS details
+ // behind the same generic TypeError. Only report TLS when the error says so.
+ return 'network';
+}
+
+async function readBoundedBody(response: Response): Promise {
+ const reader = response.body?.getReader();
+ if (!reader) return null;
+
+ const decoder = new TextDecoder();
+ const parts: string[] = [];
+ let bytes = 0;
+ try {
+ while (true) {
+ const { done, value } = await reader.read();
+ if (done) break;
+ bytes += value.byteLength;
+ if (bytes > MAX_HEALTH_BYTES) {
+ await reader.cancel();
+ return null;
+ }
+ parts.push(decoder.decode(value, { stream: true }));
+ }
+ parts.push(decoder.decode());
+ return parts.join('');
+ } finally {
+ reader.releaseLock();
+ }
+}
+
+export async function probeRemoteBackend(
+ rawUrl: string,
+ key = '',
+ { fetchImpl = fetch, timeoutMs = 5000 }: { fetchImpl?: typeof fetch; timeoutMs?: number } = {},
+): Promise {
+ const rawTarget = rawUrl.trim().replace(/\/+$/, '');
+ let url: URL;
+ try {
+ url = new URL(rawTarget);
+ } catch {
+ return { ok: false, kind: 'network', target: rawTarget };
+ }
+ const hasEmbeddedCredentials = Boolean(url.username || url.password);
+ const hasQueryOrFragment = Boolean(url.search || url.hash);
+ url.username = '';
+ url.password = '';
+ url.search = '';
+ url.hash = '';
+ const target = url.toString().replace(/\/+$/, '');
+ if (
+ (url.protocol !== 'http:' && url.protocol !== 'https:') ||
+ hasEmbeddedCredentials ||
+ hasQueryOrFragment
+ ) {
+ return { ok: false, kind: 'network', target };
+ }
+ const controller = new AbortController();
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
+ try {
+ const response = await fetchImpl(`${target}/health`, {
+ headers: key ? { Authorization: `Bearer ${key}` } : {},
+ signal: controller.signal,
+ cache: 'no-store',
+ });
+ if (!response.ok) return { ok: false, kind: 'http', status: response.status, target };
+ const advertisedBytes = Number(response.headers.get('content-length'));
+ if (Number.isFinite(advertisedBytes) && advertisedBytes > MAX_HEALTH_BYTES) {
+ return { ok: false, kind: 'wrong_port', target };
+ }
+ const payload = await readBoundedBody(response);
+ if (payload === null) {
+ return { ok: false, kind: 'wrong_port', target };
+ }
+ let body: unknown = null;
+ try {
+ body = JSON.parse(payload);
+ } catch {
+ // A healthy VoiceStudio API always returns a small JSON object.
+ }
+ if (
+ !body ||
+ typeof body !== 'object' ||
+ (body as any).status !== 'ok' ||
+ typeof (body as any).version !== 'string' ||
+ typeof (body as any).device !== 'string'
+ ) {
+ return { ok: false, kind: 'wrong_port', target };
+ }
+ return {
+ ok: true,
+ detail: `${(body as any).version} on ${(body as any).device}`,
+ target,
+ };
+ } catch (error) {
+ return { ok: false, kind: transportKind(url, error), target };
+ } finally {
+ clearTimeout(timer);
+ }
+}