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 ( +
+
+
+
+ ); +} 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); + } +}