From a390da15900569e2d3f7ab68d86f5226da110a80 Mon Sep 17 00:00:00 2001 From: Palash Debnath <4178343+debpalash@users.noreply.github.com> Date: Thu, 17 Sep 2026 12:37:34 +0530 Subject: [PATCH] fix(catalogue): explain local-only native installation --- CHANGELOG.md | 4 ++ backend/api/routers/engines.py | 31 ++++++++++----- docs/install/troubleshooting.md | 4 ++ .../src/features/settings/engine-install.tsx | 15 +++++++- .../features/settings/model-settings.test.tsx | 38 ++++++++++++++++++- .../src/features/settings/model-settings.tsx | 28 +++++++++++--- .../src/renderer/src/i18n/locales/ar.json | 3 +- .../src/renderer/src/i18n/locales/de.json | 3 +- .../src/renderer/src/i18n/locales/en.json | 3 +- .../src/renderer/src/i18n/locales/es.json | 3 +- .../src/renderer/src/i18n/locales/fr.json | 3 +- .../src/renderer/src/i18n/locales/hi.json | 3 +- .../src/renderer/src/i18n/locales/id.json | 3 +- .../src/renderer/src/i18n/locales/it.json | 3 +- .../src/renderer/src/i18n/locales/ja.json | 3 +- .../src/renderer/src/i18n/locales/ko.json | 3 +- .../src/renderer/src/i18n/locales/nl.json | 3 +- .../src/renderer/src/i18n/locales/pl.json | 3 +- .../src/renderer/src/i18n/locales/pt.json | 3 +- .../src/renderer/src/i18n/locales/ru.json | 3 +- .../src/renderer/src/i18n/locales/sv.json | 3 +- .../src/renderer/src/i18n/locales/th.json | 3 +- .../src/renderer/src/i18n/locales/tr.json | 3 +- .../src/renderer/src/i18n/locales/uk.json | 3 +- .../src/renderer/src/i18n/locales/vi.json | 3 +- .../src/renderer/src/i18n/locales/zh-CN.json | 3 +- .../src/renderer/src/i18n/locales/zh-TW.json | 3 +- electron/src/renderer/src/lib/api/types.ts | 1 + frontend/src/api/types.ts | 1 + .../src/components/engines/EngineDetail.jsx | 3 ++ .../components/engines/useEngineInventory.js | 1 + frontend/src/i18n/locales/ar.json | 3 +- frontend/src/i18n/locales/de.json | 3 +- frontend/src/i18n/locales/en.json | 3 +- frontend/src/i18n/locales/es.json | 3 +- frontend/src/i18n/locales/fr.json | 3 +- frontend/src/i18n/locales/hi.json | 3 +- frontend/src/i18n/locales/id.json | 3 +- frontend/src/i18n/locales/it.json | 3 +- frontend/src/i18n/locales/ja.json | 3 +- frontend/src/i18n/locales/ko.json | 3 +- frontend/src/i18n/locales/nl.json | 3 +- frontend/src/i18n/locales/pl.json | 3 +- frontend/src/i18n/locales/pt.json | 3 +- frontend/src/i18n/locales/ru.json | 3 +- frontend/src/i18n/locales/sv.json | 3 +- frontend/src/i18n/locales/th.json | 3 +- frontend/src/i18n/locales/tr.json | 3 +- frontend/src/i18n/locales/uk.json | 3 +- frontend/src/i18n/locales/vi.json | 3 +- frontend/src/i18n/locales/zh-CN.json | 3 +- frontend/src/i18n/locales/zh-TW.json | 3 +- tests/test_engine_install_capability.py | 32 ++++++++++++++++ 53 files changed, 223 insertions(+), 61 deletions(-) create mode 100644 tests/test_engine_install_capability.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 2c6df8b7..dd9f23d5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,10 @@ the frozen-backend fallback mirror it for their toolchains. - Handle missing Electron signing credentials and retry packaging fixes without moving release tags (#2157) +### Fixed + +- Show local setup guidance when remote native engine installation is unavailable (#2166) + ## [0.5.3] — 2026-09-17 **Highlights** diff --git a/backend/api/routers/engines.py b/backend/api/routers/engines.py index 7d4547bd..cd689cc4 100644 --- a/backend/api/routers/engines.py +++ b/backend/api/routers/engines.py @@ -21,12 +21,12 @@ import os import threading from time import perf_counter -from fastapi import APIRouter, Depends, HTTPException +from fastapi import APIRouter, Depends, HTTPException, Request from huggingface_hub import utils as hf_utils from huggingface_hub.errors import HFValidationError from pydantic import BaseModel, Field -from api.dependencies import require_admin, require_admin_action, require_desktop +from api.dependencies import require_admin, require_admin_action, require_desktop, is_loopback from core import prefs from core.engine_licenses import LICENSE_GATED_ENGINES from services import tts_backend, asr_backend, llm_backend, translation_engines @@ -116,18 +116,29 @@ def _is_hf_repo_id(value: str) -> bool: return True +def _request_install_capability(payload, request): + allowed = bool(request and request.client and is_loopback(request.client.host)) + result = dict(payload) + result["backends"] = [dict(entry) for entry in payload["backends"]] + for entry in result["backends"]: + if entry.get("one_click_install") and not allowed: + entry["one_click_install"] = False + entry["local_install_required"] = True + return result + + @router.get("/engines") -def list_all_engines(): +def list_all_engines(request: Request): return { - "tts": _family_payload("tts", tts_backend), + "tts": _request_install_capability(_family_payload("tts", tts_backend), request), "asr": _family_payload("asr", asr_backend), "llm": _family_payload("llm", llm_backend), } @router.get("/engines/tts") -def list_tts_backends(): - return _family_payload("tts", tts_backend) +def list_tts_backends(request: Request): + return _request_install_capability(_family_payload("tts", tts_backend), request) @router.get( @@ -409,10 +420,10 @@ async def uninstall_translation_engine(engine_id: str): "/engines/audiocpp/runtime/install/status", dependencies=[Depends(require_admin)], ) -def audiocpp_runtime_install_status(): +def audiocpp_runtime_install_status(request: Request): from services import audiocpp_runtime_install - return audiocpp_runtime_install.status() + return {**audiocpp_runtime_install.status(), "install_allowed": bool(request.client and is_loopback(request.client.host))} @router.post( @@ -485,7 +496,7 @@ def install_sidecar_engine(engine_id: str): "/engines/sidecar/{engine_id}/install/status", dependencies=[Depends(require_admin)], ) -def sidecar_install_status(engine_id: str): +def sidecar_install_status(engine_id: str, request: Request = None): """Step-by-step status of the sidecar install job (poll while running). Shape: ``{engine_id, installed, managed, install_dir, job}`` where job is @@ -494,7 +505,7 @@ def sidecar_install_status(engine_id: str): """ from services import sidecar_install try: - return sidecar_install.get_status(engine_id) + return {**sidecar_install.get_status(engine_id), "install_allowed": bool(request and request.client and is_loopback(request.client.host))} except KeyError: raise HTTPException( status_code=404, diff --git a/docs/install/troubleshooting.md b/docs/install/troubleshooting.md index ee9701d9..ee13895e 100644 --- a/docs/install/troubleshooting.md +++ b/docs/install/troubleshooting.md @@ -1148,3 +1148,7 @@ remove the app binary itself are in [docs/install/uninstall.md](uninstall.md). **Linked issue:** [#1089](https://github.com/debpalash/VoiceStudio/issues/1089) + +### Native engine installation from a remote client + +Sidecar and audio.cpp runtime installation is restricted to requests from the backend computer's loopback interface. An API key does not bypass this restriction. The catalogue now shows local setup guidance instead of offering a remote install that will be rejected. Open the backend through `localhost` on that computer, or follow the engine's setup guide there. In Docker, bridge-network requests may not be loopback even when the browser runs on the host; use the documented container setup rather than weakening the native-install gate. diff --git a/electron/src/renderer/src/features/settings/engine-install.tsx b/electron/src/renderer/src/features/settings/engine-install.tsx index abcda4ed..5860c3cc 100644 --- a/electron/src/renderer/src/features/settings/engine-install.tsx +++ b/electron/src/renderer/src/features/settings/engine-install.tsx @@ -18,6 +18,7 @@ export function EngineInstall({ id }: { id: string }) { queryFn: () => apiJson<{ installed: boolean; + install_allowed?: boolean; job: null | { state: string; steps: { name?: string; state: string }[]; @@ -36,8 +37,15 @@ export function EngineInstall({ id }: { id: string }) { + {status.data?.install_allowed === false && ( +

+ {t('engines.localInstallRequired')} +

+ )} {!dismissedFailure && (failed || status.isError || status.data?.job?.state === 'failed') && ( - expect(mock.toast.success).toHaveBeenCalledWith('settings.engine_switched'), + await waitFor(() => expect(mock.toast.success).toHaveBeenCalledWith('settings.engine_switched')); +}); + +it('explains remote native installation restrictions before a POST', async () => { + mock.api.mockImplementation((path: string) => + Promise.resolve( + path === '/engines/diarisation' + ? { + active: 'pyannote', + options: [ + { + id: 'audiocpp-sortformer', + label: 'Sortformer', + model_installed: true, + runtime_installed: false, + installed: false, + }, + ], + } + : { + installed: false, + supported: true, + install_allowed: false, + job: { state: 'idle', progress: 0 }, + }, + ), ); + render( + + + , + ); + expect(await screen.findByText('engines.localInstallRequired')).toBeInTheDocument(); + const button = screen.getByRole('button', { name: 'modelMaintenance.install' }); + expect(button).toBeDisabled(); + fireEvent.click(button); + expect(mock.api.mock.calls.some(([, init]) => init?.method === 'POST')).toBe(false); }); diff --git a/electron/src/renderer/src/features/settings/model-settings.tsx b/electron/src/renderer/src/features/settings/model-settings.tsx index 4504ae26..4e9517c7 100644 --- a/electron/src/renderer/src/features/settings/model-settings.tsx +++ b/electron/src/renderer/src/features/settings/model-settings.tsx @@ -61,6 +61,7 @@ export function DiarisationSettings() { apiJson<{ installed: boolean; supported: boolean; + install_allowed?: boolean; job: { state: string; progress: number; error?: string | null }; }>('/engines/audiocpp/runtime/install/status'), refetchInterval: (state) => (state.state.data?.job.state === 'running' ? 1_500 : 10_000), @@ -75,6 +76,7 @@ export function DiarisationSettings() { }, [client, runtime.data?.installed]); const runtimeRunning = busy || runtime.data?.job.state === 'running'; const installRuntime = async () => { + if (runtime.data?.install_allowed === false) return; setBusy(true); setFailed(null); try { @@ -152,7 +154,12 @@ export function DiarisationSettings() { )} + {option.id === 'audiocpp-sortformer' && + !option.runtime_installed && + runtime.data?.install_allowed === false && ( +

+ {t('engines.localInstallRequired')} +

+ )} {option.installed && (