diff --git a/CHANGELOG.md b/CHANGELOG.md index 2e6dd900..6dcd5b8a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -61,6 +61,8 @@ the frozen-backend fallback mirror it for their toolchains. - Correct the Windows Rustup installation command in tooling and documentation (#2066) — thanks @Rukhaam! +- 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 e25807eb..b7238399 100644 --- a/docs/install/troubleshooting.md +++ b/docs/install/troubleshooting.md @@ -1193,3 +1193,7 @@ Electron and web imports accept UTF-8, UTF-16 with a byte-order mark, and Window ### Compile fallback after startup An architecture accepted by the torch.compile preflight may still encounter independent Dynamo, Inductor, Triton, or CUDA-graph runtime errors. VoiceStudio distinguishes those from GPU memory exhaustion and retries with eager execution; architecture support alone does not guarantee compilation succeeds. + +### 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 && (