From fece03b5ac4749a50f24f7ad5a148e1f0154a4c8 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Tue, 20 Oct 2026 19:54:55 -0700 Subject: [PATCH] fix(orcad): voice dictation on headless orca serve hosts (#31217) * fix(orcad): voice dictation on headless orca serve hosts Mobile dictation against an orca serve host answered speech_unavailable since serve moved to orcad: orcad never installed the speech service factories, and the model manager and download imported Electron. The model download now takes a host request opener (Chromium net on the desktop, Node https on orcad, with redirects mapped to the same event), the STT worker ships in the orcad slot, and orca serve hands orcad the installed app's on-device recognizer. A host without a recognizer (an SSH or WSL managed slot) lists only cloud models and sends a phone that picked an on-device model to the setup sheet. Mobile names the fix when an older server still has no speech at all. The parity test now requires both roots to install speech. Reported-by: delorean2020 (@delorean2020) * fix(orcad): retry Node download drops, stage the recognizer on Windows, review fixes - Node https errors (ECONNRESET, ETIMEDOUT, ...) are retryable, so an interrupted orcad model download resumes (Codex P2). - On Windows orca serve hands orcad a per-version copy of the recognizer in the profile, so a loaded addon never locks the app's files against an update (Opus). - Drop a recognizer candidate path that could never match; one SAFETY helper per test file instead of repeated casts; comments. * fix(orcad): cloud speech is ready only with a key this host can decrypt; prune superseded recognizer copies Codex round 2: a desktop-sealed OpenAI key exists on disk but orcad's secret store cannot open it, so the catalog reported cloud models ready and dictation failed at the end. orcad's model manager now proves the key decrypts. The desktop keeps its existence check (no keychain prompt). * fix(orcad): never prune staged recognizers before the profile lock; copy linked packages as real files Codex round 3. ref-oss check: versioned copies are cleaned only by the process that owns them, so the pre-lock prune is dropped rather than patched. * fix(orcad): remove a partial recognizer copy when staging fails (Codex round 4) * fix(speech): never hand out another store's ciphertext as the OpenAI key (Codex round 5) * refactor(orcad): no recognizer copy on Windows; Windows orcad keeps cloud dictation only Design check after review round 5: every remaining finding came from the Windows copy that dodges addon file locks. Dropping it shrinks the surface to a path lookup; Linux and macOS serve keep on-device models. * fix(speech): name the fix when an old server refuses a mic tap; always set the recognizer env; retry ENOTFOUND (final Opus review) * fix(managed-server): probe install completeness relative to the slot dir CI: shipping stt-worker.js added one more absolute slot path to the install-complete probe, pushing it further past the exec-size budget. Testing each artifact relative to the slot (inside a subshell cd) cuts the probe from ~12.2 KB to ~3.6 KB, so it drops off the over-budget list instead of raising a ceiling. * fix(mobile): pin host.platform's read to a platform type, independent of which zod copy resolves With mobile's own dependencies installed (which CI does for any PR that touches mobile/), the e2e program resolves two zod copies, and the salvaged platform arm from src/shared inferred as {}. The schema now narrows through an explicit platform guard. * fix(mobile): keep the host.platform read line intact for its RPC mutant; narrow in a second step Mobile verify: the recorded-RPC mutant anchors on the read line, which the previous guard rewrote, so the mutant could not apply. * refactor(cli): app-executable resolution in its own module; drop the platform guard now zod is aligned launch.ts went one line past max-lines with main; resolveAppRoot, resolveForegroundOrcaExecutable, getExecutableAppArgs and stripElectronRunAsNode move to orca-app-executable.ts (re-exported, so callers are unchanged). With mobile and root on one zod (#31260), the host.platform schema no longer needs the extra narrowing step. * refactor(cli): import app-executable helpers from their module; no re-exports from launch.ts --- config/scripts/orcad-entry-build.mjs | 3 +- .../dictation/mobile-dictation-setup.test.ts | 21 +++++ .../src/dictation/mobile-dictation-setup.ts | 14 +++ ...se-mobile-session-native-chat-dictation.ts | 6 +- src/cli/handlers/account.ts | 2 +- src/cli/handlers/core.ts | 2 +- src/cli/runtime/launch-serve-runtime.test.ts | 8 +- src/cli/runtime/launch.test.ts | 3 +- src/cli/runtime/launch.ts | 56 +++--------- src/cli/runtime/orca-app-executable.ts | 45 ++++++++++ .../profile-state-recovery-launch.test.ts | 2 +- .../runtime/profile-state-recovery-launch.ts | 2 +- src/cli/runtime/serve-orcad-launch.ts | 9 +- .../runtime/serve-speech-recognizer.test.ts | 46 ++++++++++ src/cli/runtime/serve-speech-recognizer.ts | 24 ++++++ src/main/host/electron-speech-services.ts | 37 +++++++- .../ipc/speech-worker-exit-lifetime.test.ts | 7 +- .../install/remote-versioned-install.test.ts | 3 +- .../posix-host-op-byte-budget.test.ts | 3 +- .../network/transient-download-error.test.ts | 32 +++++++ src/main/network/transient-download-error.ts | 19 +++- src/main/orcad/orcad-host-adapters.ts | 3 + src/main/orcad/orcad-speech-services.test.ts | 70 +++++++++++++++ src/main/orcad/orcad-speech-services.ts | 16 ++++ .../runtime-composition-parity.test.ts | 9 +- .../runtime-mobile-dictation-controller.ts | 9 ++ .../runtime/runtime-mobile-speech-catalog.ts | 9 +- .../model-manager-download-error.test.ts | 3 +- .../model-manager-download-resume.test.ts | 39 +++++---- .../model-manager-progress-callback.test.ts | 5 +- .../model-manager-stream-cleanup.test.ts | 11 ++- .../speech/model-manager-windows-path.test.ts | 13 ++- src/main/speech/model-manager.test.ts | 25 ++++-- src/main/speech/model-manager.ts | 19 ++-- .../node-speech-download-request.test.ts | 86 +++++++++++++++++++ .../speech/node-speech-download-request.ts | 45 ++++++++++ src/main/speech/openai-api-key-store.test.ts | 29 +++++++ src/main/speech/openai-api-key-store.ts | 26 +++++- .../speech/speech-model-download-response.ts | 27 ++++-- src/main/speech/speech-model-http-download.ts | 29 ++++--- src/main/speech/speech-runtime-service.ts | 11 +-- src/main/speech/stt-session-start.ts | 14 ++- src/main/speech/stt-worker-paths.test.ts | 46 ++++++++++ src/main/speech/stt-worker-paths.ts | 50 ++++++++--- src/main/ssh/ssh-remote-commands.test.ts | 20 ++++- src/main/ssh/ssh-remote-commands.ts | 12 ++- src/shared/orcad-artifacts.ts | 2 + src/shared/speech-recognizer-package.ts | 14 +++ 48 files changed, 825 insertions(+), 161 deletions(-) create mode 100644 src/cli/runtime/orca-app-executable.ts create mode 100644 src/cli/runtime/serve-speech-recognizer.test.ts create mode 100644 src/cli/runtime/serve-speech-recognizer.ts create mode 100644 src/main/network/transient-download-error.test.ts create mode 100644 src/main/orcad/orcad-speech-services.test.ts create mode 100644 src/main/orcad/orcad-speech-services.ts create mode 100644 src/main/speech/node-speech-download-request.test.ts create mode 100644 src/main/speech/node-speech-download-request.ts create mode 100644 src/main/speech/stt-worker-paths.test.ts create mode 100644 src/shared/speech-recognizer-package.ts diff --git a/config/scripts/orcad-entry-build.mjs b/config/scripts/orcad-entry-build.mjs index bf99500e14e..89dd1b16608 100644 --- a/config/scripts/orcad-entry-build.mjs +++ b/config/scripts/orcad-entry-build.mjs @@ -20,7 +20,8 @@ export const ORCAD_CHILD_ENTRY_POINTS = { sessionScanner: 'src/main/ai-vault/session-scanner-service-entry.ts', workerThreadChildProcess: 'src/main/worker-thread-child-process/worker-thread-child-process-entry.ts', - claudeProfileSetup: 'src/main/claude-accounts/claude-profile-setup-worker-entry.ts' + claudeProfileSetup: 'src/main/claude-accounts/claude-profile-setup-worker-entry.ts', + sttWorker: 'src/main/speech/stt-worker.ts' } /** Each child ships flat beside orcad.js under its source basename; the runtime resolvers look there. */ diff --git a/mobile/src/dictation/mobile-dictation-setup.test.ts b/mobile/src/dictation/mobile-dictation-setup.test.ts index 68f8230aaf4..8313b88fd0e 100644 --- a/mobile/src/dictation/mobile-dictation-setup.test.ts +++ b/mobile/src/dictation/mobile-dictation-setup.test.ts @@ -9,6 +9,7 @@ import { fetchDictationSetup, isDictationReady, isDictationSetupRequiredError, + isSpeechlessServerError, isModelInFlight, setDictationConfig, type MobileSpeechModel, @@ -170,6 +171,26 @@ describe('rpc wrappers', () => { ) }) + it('maps a headless server with no speech to update guidance (#31174)', async () => { + const client = clientWith([ + malformedFailure({ + code: 'runtime_error', + message: 'speech_unavailable: this host has no speech services' + }) + ]) + + await expect(fetchDictationSetup(client)).rejects.toThrow( + 'Update Orca on this server to use voice dictation.' + ) + }) + + it('recognizes an old server refusing a dictation start', () => { + expect(isSpeechlessServerError('speech_unavailable: this host has no speech services')).toBe( + true + ) + expect(isSpeechlessServerError('voice_dictation_disabled')).toBe(false) + }) + it('keeps unrelated speech model failures specific', async () => { const client = clientWith([ malformedFailure({ diff --git a/mobile/src/dictation/mobile-dictation-setup.ts b/mobile/src/dictation/mobile-dictation-setup.ts index cb63ab38aee..8fed4a9aeef 100644 --- a/mobile/src/dictation/mobile-dictation-setup.ts +++ b/mobile/src/dictation/mobile-dictation-setup.ts @@ -38,6 +38,17 @@ function isLegacyDesktopSpeechSetupReply(reply: RpcResponse): boolean { ) } +// Why: headless servers from 1.4.229 until #31174 had no speech at all; name the fix, not the code. +export const SPEECHLESS_SERVER_MESSAGE = 'Update Orca on this server to use voice dictation.' + +export function isSpeechlessServerError(message: string): boolean { + return message.startsWith('speech_unavailable') +} + +function isSpeechlessServerReply(reply: RpcResponse): boolean { + return !reply.ok && isSpeechlessServerError(reply.error?.message ?? '') +} + export function isDictationSetupRequiredError(message: string): boolean { return SETUP_REQUIRED_CODES.has(message) || message.startsWith('voice_model_not_ready:') } @@ -47,6 +58,9 @@ export async function fetchDictationSetup(client: RpcClient): Promise dictationSetupRead.interpret(reply), 'Failed to load dictation models' diff --git a/mobile/src/session/use-mobile-session-native-chat-dictation.ts b/mobile/src/session/use-mobile-session-native-chat-dictation.ts index 075ee17c19e..c5548c8cc96 100644 --- a/mobile/src/session/use-mobile-session-native-chat-dictation.ts +++ b/mobile/src/session/use-mobile-session-native-chat-dictation.ts @@ -8,7 +8,9 @@ import { } from '../terminal/terminal-live-dictation-routing' import { fetchDictationSetup, - isDictationSetupRequiredError + isDictationSetupRequiredError, + isSpeechlessServerError, + SPEECHLESS_SERVER_MESSAGE } from '../dictation/mobile-dictation-setup' import { useMobileNativeChatController } from './use-mobile-native-chat-controller' import { useMobileNativeChatReadability } from './use-mobile-native-chat-readability' @@ -107,7 +109,7 @@ export function useMobileSessionNativeChatDictation( return } triggerError() - showToast(message) + showToast(isSpeechlessServerError(message) ? SPEECHLESS_SERVER_MESSAGE : message) }, [setShowDictationSetup, showToast] ) diff --git a/src/cli/handlers/account.ts b/src/cli/handlers/account.ts index 2d64a29aade..70d2b35369b 100644 --- a/src/cli/handlers/account.ts +++ b/src/cli/handlers/account.ts @@ -5,7 +5,7 @@ import { delimiter, join } from 'node:path' import type { CommandHandler, HandlerContext } from '../dispatch' import { printResult } from '../format' import { RuntimeClientError } from '../runtime-client' -import { stripElectronRunAsNode } from '../runtime/launch' +import { stripElectronRunAsNode } from '../runtime/orca-app-executable' import { rejectRemoteSelectionFlags } from '../remote-selection-flag-rejection' import { buildWslExecArgs, buildWslLoginShellCommand } from '../../shared/wsl-login-shell-command' import { quotePosixShell } from '../../shared/shell-argument-quoting' diff --git a/src/cli/handlers/core.ts b/src/cli/handlers/core.ts index 7bb429b7c09..dd999dc3142 100644 --- a/src/cli/handlers/core.ts +++ b/src/cli/handlers/core.ts @@ -2,7 +2,7 @@ import { spawn } from 'node:child_process' import type { CommandHandler } from '../dispatch' import { formatCliStatus, formatStatus, printResult } from '../format' import { RuntimeClientError, serveOrcaApp } from '../runtime-client' -import { stripElectronRunAsNode } from '../runtime/launch' +import { stripElectronRunAsNode } from '../runtime/orca-app-executable' import { resolveCliStatusCaller } from '../runtime/status-caller' import { getServeSuperviseValidationError } from '../runtime/serve-supervised-service' import { getOptionalServePort } from '../runtime/serve-port-flag' diff --git a/src/cli/runtime/launch-serve-runtime.test.ts b/src/cli/runtime/launch-serve-runtime.test.ts index 8aa5be9afe5..9a64d824c9f 100644 --- a/src/cli/runtime/launch-serve-runtime.test.ts +++ b/src/cli/runtime/launch-serve-runtime.test.ts @@ -27,6 +27,7 @@ vi.mock('./serve-orcad-launch', async (importOriginal) => ({ })) import { serveOrcaApp } from './launch' +import { speechRecognizerPackageName } from '../../shared/speech-recognizer-package' class FakeChildProcess extends EventEmitter { stdout = new EventEmitter() @@ -101,6 +102,9 @@ describe('orca serve host selection', () => { try { await mkdir(join(appRoot, 'out', 'web'), { recursive: true }) await writeFile(join(appRoot, 'out', 'web', 'web-index.html'), '') + const recognizer = speechRecognizerPackageName(process.platform, process.arch) + await mkdir(join(appRoot, 'node_modules', recognizer), { recursive: true }) + await writeFile(join(appRoot, 'node_modules', recognizer, 'sherpa-onnx.node'), '') process.env.ORCA_SERVE_SUPERVISOR_APP_ROOT = appRoot const selection = { kind: 'orcad', runtime: '/node', entry: '/slot/orcad.js', version: '1' } resolveLocalServeRuntimeMock.mockResolvedValue(selection) @@ -112,7 +116,9 @@ describe('orca serve host selection', () => { { json: true }, expect.any(String), expect.not.objectContaining({ ELECTRON_RUN_AS_NODE: expect.anything() }), - join(appRoot, 'out', 'web') + join(appRoot, 'out', 'web'), + // #31174: and the app's speech recognizer. + process.platform === 'win32' ? undefined : join(appRoot, 'node_modules', recognizer) ) expect(spawnMock).not.toHaveBeenCalled() expect(stderr.join('')).toContain('[serve] running on orcad 1') diff --git a/src/cli/runtime/launch.test.ts b/src/cli/runtime/launch.test.ts index a1388ae6280..894f264fbe3 100644 --- a/src/cli/runtime/launch.test.ts +++ b/src/cli/runtime/launch.test.ts @@ -24,7 +24,8 @@ vi.mock('child_process', () => ({ spawnSync: spawnSyncMock })) -import { getExecutableAppArgs, launchOrcaApp, serveOrcaApp, stripElectronRunAsNode } from './launch' +import { launchOrcaApp, serveOrcaApp } from './launch' +import { getExecutableAppArgs, stripElectronRunAsNode } from './orca-app-executable' class FakeChildProcess extends EventEmitter { stdout = new EventEmitter() diff --git a/src/cli/runtime/launch.ts b/src/cli/runtime/launch.ts index f7d6476bbd5..710ae1403fe 100644 --- a/src/cli/runtime/launch.ts +++ b/src/cli/runtime/launch.ts @@ -1,5 +1,4 @@ import { spawn, type SpawnOptions } from 'node:child_process' -import { resolve } from 'node:path' import { spawnProcess } from '@orca/process-host' import type { ProcessSpec } from '@orca/process-host/process-spec' import { @@ -13,8 +12,6 @@ import { } from './launch-user-data-path' import { foregroundServeIsolatedProfileSwitches } from '../../shared/electron-isolated-profile-switches' import { headlessServeOzonePlatformSwitches } from '../../shared/headless-serve-ozone-platform' -import { ELECTRON_NO_SANDBOX_REQUEST_ENV } from '../../shared/electron-no-sandbox-request' -import { needsElectronNoSandbox } from './electron-sandbox-switch' import { getMacAppBundlePath } from './mac-app-update-bundle' import { getPlatformUserDataPath } from './metadata' import { isSameUserDataPath } from '../../shared/serve-user-data-path' @@ -24,9 +21,16 @@ import { superviseForegroundServe } from './serve-update-supervisor' import { RuntimeClientError } from './types' +import { + getExecutableAppArgs, + resolveAppRoot, + resolveForegroundOrcaExecutable, + stripElectronRunAsNode +} from './orca-app-executable' import { SERVE_RUNTIME_ELECTRON, SERVE_RUNTIME_ENV } from '../../shared/orcad-local-serve-selection' import { findBundledWebClientRoot } from '../../shared/bundled-web-client-root' import { resolveLocalServeRuntime, serveWithOrcad } from './serve-orcad-launch' +import { findAppSpeechRecognizerDir } from './serve-speech-recognizer' import { serveOptionArgs, type ServeOrcaAppArgs } from '../../shared/orcad-serve-argv' import { waitForRecipeJson } from './serve-recipe-json' import { @@ -38,15 +42,11 @@ import { MacServeStartupAbortError } from './serve-signal-exit-diagnostic' import { superviseServeService } from './serve-supervised-service' import { reexecThisServeUnderInitReaper } from './serve-pid1-init-reaper' import { supervisedAppImageUpdate } from './serve-appimage-update' -import { - relocateThisServeSupervisor, - SERVE_SUPERVISOR_APP_ROOT_ENV -} from './serve-supervisor-relocation' +import { relocateThisServeSupervisor } from './serve-supervisor-relocation' import { noteServeSupervisorStart, restartThisServeSupervisorIfReplaced } from './serve-replaced-app-restart' -import { serverOnlyServeMessage } from './serve-app-launcher' import { isSupervisedSpawnHeld } from './serve-supervised-stop' import { openServeBundlePark } from '../../shared/serve-bundle-park' import { @@ -199,7 +199,8 @@ async function serveWithSelectedRuntime( args, userDataPath, stripElectronRunAsNode(process.env), - findBundledWebClientRoot(resolveAppRoot()) + findBundledWebClientRoot(resolveAppRoot()), + findAppSpeechRecognizerDir(resolveAppRoot()) ) } if (selection.reason) { @@ -306,43 +307,6 @@ function spawnElectronServe( }) } -export function getExecutableAppArgs(executable: string): string[] { - const args = process.env.ORCA_APP_EXECUTABLE_NEEDS_APP_ROOT === '1' ? [resolveAppRoot()] : [] - if (needsElectronNoSandbox(executable)) { - args.push('--no-sandbox') - } - return args -} - function getExecutableSpawnOptions(executable: string): Pick { return process.platform === 'win32' && /\.(?:cmd|bat)$/i.test(executable) ? { shell: true } : {} } - -export function resolveAppRoot(): string { - // Why: dev-mode resource resolution in the Electron child may consult - // process.cwd(). Pin it to the app root so `orca serve` behaves the same - // regardless of the shell directory it was launched from. A supervisor re-run from its copy - // outside the app must still start the app's own files. - return process.env[SERVE_SUPERVISOR_APP_ROOT_ENV] || resolve(__dirname, '../../..') -} - -export function resolveForegroundOrcaExecutable(): string { - const overrideExecutable = process.env.ORCA_APP_EXECUTABLE - if (typeof overrideExecutable === 'string' && overrideExecutable.trim().length > 0) { - return overrideExecutable - } - if (process.env.ELECTRON_RUN_AS_NODE === '1') { - return process.execPath - } - throw new RuntimeClientError('runtime_serve_failed', serverOnlyServeMessage(process.platform)) -} - -export function stripElectronRunAsNode(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { - const next = { ...env } - delete next.ELECTRON_RUN_AS_NODE - // Why: the request is applied as a switch; the server's terminals must not inherit it. - delete next[ELECTRON_NO_SANDBOX_REQUEST_ENV] - // Why: only the relocated supervisor itself may resolve its app root this way. - delete next[SERVE_SUPERVISOR_APP_ROOT_ENV] - return next -} diff --git a/src/cli/runtime/orca-app-executable.ts b/src/cli/runtime/orca-app-executable.ts new file mode 100644 index 00000000000..386b8ffb137 --- /dev/null +++ b/src/cli/runtime/orca-app-executable.ts @@ -0,0 +1,45 @@ +import { resolve } from 'node:path' +import { ELECTRON_NO_SANDBOX_REQUEST_ENV } from '../../shared/electron-no-sandbox-request' +import { needsElectronNoSandbox } from './electron-sandbox-switch' +import { serverOnlyServeMessage } from './serve-app-launcher' +import { SERVE_SUPERVISOR_APP_ROOT_ENV } from './serve-supervisor-relocation' +import { RuntimeClientError } from './types' + +/** Which Orca app `orca` runs, from where, and with what environment. */ + +export function getExecutableAppArgs(executable: string): string[] { + const args = process.env.ORCA_APP_EXECUTABLE_NEEDS_APP_ROOT === '1' ? [resolveAppRoot()] : [] + if (needsElectronNoSandbox(executable)) { + args.push('--no-sandbox') + } + return args +} + +export function resolveAppRoot(): string { + // Why: dev-mode resource resolution in the Electron child may consult + // process.cwd(). Pin it to the app root so `orca serve` behaves the same + // regardless of the shell directory it was launched from. A supervisor re-run from its copy + // outside the app must still start the app's own files. + return process.env[SERVE_SUPERVISOR_APP_ROOT_ENV] || resolve(__dirname, '../../..') +} + +export function resolveForegroundOrcaExecutable(): string { + const overrideExecutable = process.env.ORCA_APP_EXECUTABLE + if (typeof overrideExecutable === 'string' && overrideExecutable.trim().length > 0) { + return overrideExecutable + } + if (process.env.ELECTRON_RUN_AS_NODE === '1') { + return process.execPath + } + throw new RuntimeClientError('runtime_serve_failed', serverOnlyServeMessage(process.platform)) +} + +export function stripElectronRunAsNode(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { + const next = { ...env } + delete next.ELECTRON_RUN_AS_NODE + // Why: the request is applied as a switch; the server's terminals must not inherit it. + delete next[ELECTRON_NO_SANDBOX_REQUEST_ENV] + // Why: only the relocated supervisor itself may resolve its app root this way. + delete next[SERVE_SUPERVISOR_APP_ROOT_ENV] + return next +} diff --git a/src/cli/runtime/profile-state-recovery-launch.test.ts b/src/cli/runtime/profile-state-recovery-launch.test.ts index b9e0b1304f5..38f04efefbf 100644 --- a/src/cli/runtime/profile-state-recovery-launch.test.ts +++ b/src/cli/runtime/profile-state-recovery-launch.test.ts @@ -11,7 +11,7 @@ import { const mocks = vi.hoisted(() => ({ run: vi.fn() })) vi.mock('@orca/process-host', () => ({ runProcess: mocks.run })) -vi.mock('./launch', () => ({ +vi.mock('./orca-app-executable', () => ({ resolveForegroundOrcaExecutable: () => '/packaged/Orca', resolveAppRoot: () => '/application', getExecutableAppArgs: () => ['/application'], diff --git a/src/cli/runtime/profile-state-recovery-launch.ts b/src/cli/runtime/profile-state-recovery-launch.ts index 9514a6b58bc..2dad884ebfc 100644 --- a/src/cli/runtime/profile-state-recovery-launch.ts +++ b/src/cli/runtime/profile-state-recovery-launch.ts @@ -12,7 +12,7 @@ import { resolveAppRoot, resolveForegroundOrcaExecutable, stripElectronRunAsNode -} from './launch' +} from './orca-app-executable' import { RuntimeClientError } from './types' import { canStartOrcaServer } from './serve-app-launcher' diff --git a/src/cli/runtime/serve-orcad-launch.ts b/src/cli/runtime/serve-orcad-launch.ts index 302c83d2e82..4276cd7e907 100644 --- a/src/cli/runtime/serve-orcad-launch.ts +++ b/src/cli/runtime/serve-orcad-launch.ts @@ -16,6 +16,7 @@ import { superviseForegroundServe } from './serve-update-supervisor' import { isSupervisedSpawnHeld } from './serve-supervised-stop' import { redactingSpawn, shouldRedactSupervisedStdout } from './serve-pairing-redaction' import { orcadServeArgs, type ServeOrcaAppArgs } from '../../shared/orcad-serve-argv' +import { SPEECH_RECOGNIZER_DIR_ENV } from '../../shared/speech-recognizer-package' /** A first run may download and verify the pinned Node; bound it well past that. */ const SELECTION_TIMEOUT_MS = 10 * 60_000 @@ -72,7 +73,9 @@ export function serveWithOrcad( /** The caller's environment without `ELECTRON_RUN_AS_NODE`. */ baseEnv: NodeJS.ProcessEnv, /** The app's browser client; the slot does not carry one, so orcad serves it from here. */ - webClientRoot?: string + webClientRoot?: string, + /** The app's speech recognizer; the slot does not carry one either. */ + speechRecognizerDir?: string ): Promise { const spec: ProcessSpec = { program: selection.runtime, @@ -84,7 +87,9 @@ export function serveWithOrcad( ...baseEnv, // The desktop's profile: its instance lock makes the two refuse each other. ORCA_USER_DATA: userDataPath, - ORCA_VERSION: selection.version + ORCA_VERSION: selection.version, + // Always set: a value inherited from an Orca terminal would name another install's copy. + [SPEECH_RECOGNIZER_DIR_ENV]: speechRecognizerDir ?? '' } } const spawnChild = shouldRedactSupervisedStdout(args.supervise === true) diff --git a/src/cli/runtime/serve-speech-recognizer.test.ts b/src/cli/runtime/serve-speech-recognizer.test.ts new file mode 100644 index 00000000000..67e08eadc21 --- /dev/null +++ b/src/cli/runtime/serve-speech-recognizer.test.ts @@ -0,0 +1,46 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { findAppSpeechRecognizerDir } from './serve-speech-recognizer' + +const roots: string[] = [] +afterEach(() => { + for (const root of roots.splice(0)) { + rmSync(root, { recursive: true, force: true }) + } +}) + +/** A packaged app's `resources`, holding a recognizer package; the app root is app.asar.unpacked. */ +function resourcesWith(packageName: string): string { + const resources = mkdtempSync(join(tmpdir(), 'orca-serve-speech-')) + roots.push(resources) + const dir = join(resources, 'node_modules', packageName) + mkdirSync(dir, { recursive: true }) + writeFileSync(join(dir, 'sherpa-onnx.node'), '') + return resources +} + +describe("finding the app's speech recognizer (#31174)", () => { + it('finds the copy a packaged app ships in its resources', () => { + const resources = resourcesWith('sherpa-onnx-linux-x64') + expect(findAppSpeechRecognizerDir(join(resources, 'app.asar.unpacked'), 'linux', 'x64')).toBe( + join(resources, 'node_modules', 'sherpa-onnx-linux-x64') + ) + }) + + it('answers nothing when the app ships no recognizer for this platform', () => { + const resources = resourcesWith('sherpa-onnx-linux-arm64') + expect( + findAppSpeechRecognizerDir(join(resources, 'app.asar.unpacked'), 'linux', 'x64') + ).toBeUndefined() + }) + + // A loaded addon is locked on Windows; orcad holding the app's copy would block app updates. + it('never hands orcad the recognizer on Windows', () => { + const resources = resourcesWith('sherpa-onnx-win-x64') + expect( + findAppSpeechRecognizerDir(join(resources, 'app.asar.unpacked'), 'win32', 'x64') + ).toBeUndefined() + }) +}) diff --git a/src/cli/runtime/serve-speech-recognizer.ts b/src/cli/runtime/serve-speech-recognizer.ts new file mode 100644 index 00000000000..a0e2679236b --- /dev/null +++ b/src/cli/runtime/serve-speech-recognizer.ts @@ -0,0 +1,24 @@ +import { existsSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { speechRecognizerPackageName } from '../../shared/speech-recognizer-package' + +/** + * The installed app's on-device recognizer, for orcad's dictation (#31174). electron-builder puts + * it in `resources/node_modules`, beside the app root; a dev checkout keeps it in node_modules. + * + * Not on Windows: a loaded addon locks its files for the server's life, which would make the next + * app update fail to replace them. Windows orcad keeps cloud dictation only. + */ +export function findAppSpeechRecognizerDir( + appRoot: string, + platform: string = process.platform, + arch: string = process.arch +): string | undefined { + if (platform === 'win32') { + return undefined + } + const name = speechRecognizerPackageName(platform, arch) + return [join(dirname(appRoot), 'node_modules', name), join(appRoot, 'node_modules', name)].find( + (dir) => existsSync(join(dir, 'sherpa-onnx.node')) + ) +} diff --git a/src/main/host/electron-speech-services.ts b/src/main/host/electron-speech-services.ts index 52ccf9099c1..7a87f2c51af 100644 --- a/src/main/host/electron-speech-services.ts +++ b/src/main/host/electron-speech-services.ts @@ -1,9 +1,44 @@ +import { net } from 'electron' import { ModelManager } from '../speech/model-manager' import { SttService } from '../speech/stt-service' import type { SpeechServiceFactories } from '../speech/speech-runtime-service' +import type { + DownloadIncomingMessage, + OpenSpeechDownloadRequest +} from '../speech/speech-model-download-response' + +/** Why Chromium's stack: it honors the app's proxy settings, which Node's `https` does not. */ +export const openElectronSpeechDownloadRequest: OpenSpeechDownloadRequest = (url) => { + const request = net.request({ method: 'GET', url }) + return { + setHeader: (name, value) => { + request.setHeader(name, value) + }, + listen: ({ error, response, redirect }) => { + const onResponse = (incoming: Electron.IncomingMessage): void => + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Electron's IncomingMessage is a Readable stream at runtime; its typings omit the stream half. + response(incoming as unknown as DownloadIncomingMessage) + request.on('error', error) + request.on('response', onResponse) + request.on('redirect', redirect) + return () => { + request.off('error', error) + request.off('response', onResponse) + request.off('redirect', redirect) + } + }, + abort: () => { + request.abort() + }, + end: () => { + request.end() + } + } +} /** The desktop speech factories. Importing this file is what pulls Electron's net in. */ export const electronSpeechServiceFactories: SpeechServiceFactories = { - createModelManager: (customModelsDir) => new ModelManager(customModelsDir), + createModelManager: (customModelsDir) => + new ModelManager(customModelsDir, openElectronSpeechDownloadRequest), createSttService: (models) => new SttService(models) } diff --git a/src/main/ipc/speech-worker-exit-lifetime.test.ts b/src/main/ipc/speech-worker-exit-lifetime.test.ts index 63d0e16b402..eb95c54a3a3 100644 --- a/src/main/ipc/speech-worker-exit-lifetime.test.ts +++ b/src/main/ipc/speech-worker-exit-lifetime.test.ts @@ -74,11 +74,14 @@ vi.mock('../speech/model-catalog', () => ({ streaming: true, sampleRate: 16000, files: [] - }) + }), + isLocalSpeechModel: () => true })) vi.mock('../speech/stt-worker-paths', () => ({ + LOCAL_SPEECH_UNAVAILABLE_ERROR: 'voice_model_not_ready:unavailable', getSttWorkerPath: () => 'unused-worker', - getSherpaModulePath: () => 'unused-module' + getSherpaModulePath: () => 'unused-module', + hasLocalSpeechRecognizer: () => true })) vi.mock('node:worker_threads', async () => { const { EventEmitter: WorkerEvents } = await import('node:events') diff --git a/src/main/managed-server/install/remote-versioned-install.test.ts b/src/main/managed-server/install/remote-versioned-install.test.ts index 80f244b897a..745cf3bb906 100644 --- a/src/main/managed-server/install/remote-versioned-install.test.ts +++ b/src/main/managed-server/install/remote-versioned-install.test.ts @@ -134,8 +134,9 @@ describe('isRemoteInstallComplete', () => { await isRemoteInstallComplete(conn, ORCAD_INSTALL_MODEL, '/r', host) const cmd = mockExec.mock.calls.at(-1)?.[1] ?? '' expect(ORCAD_INSTALL_MODEL.requiredArtifacts(host).length).toBeGreaterThan(0) + expect(cmd).toContain("cd '/r'") for (const artifact of ORCAD_INSTALL_MODEL.requiredArtifacts(host)) { - expect(cmd).toContain(`/r/${artifact}`) + expect(cmd).toContain(`test -f '${artifact}'`) } expect(cmd).toContain(ORCAD_INSTALL_MODEL.installCompleteFilename) }) diff --git a/src/main/managed-server/posix-host-op-byte-budget.test.ts b/src/main/managed-server/posix-host-op-byte-budget.test.ts index 95a0ff9ce92..bce3dbfced8 100644 --- a/src/main/managed-server/posix-host-op-byte-budget.test.ts +++ b/src/main/managed-server/posix-host-op-byte-budget.test.ts @@ -98,8 +98,7 @@ const POSIX_HOST_OP_BYTE_BUDGET = 6 * 1024 // Ops already past the budget, at their measured size. Ceilings may only shrink. const OVER_BUDGET_CEILINGS: Readonly> = { tryStealInstallLockCommand: 9_022, - orcadLaunchCommand: 7_130, - probeRemoteInstallCompleteCommand: 12_189 + orcadLaunchCommand: 7_130 } type HostCase = { host: RemoteHostPlatform; target: NodeRuntimeTarget; nodePath: string } diff --git a/src/main/network/transient-download-error.test.ts b/src/main/network/transient-download-error.test.ts new file mode 100644 index 00000000000..3498d3f979b --- /dev/null +++ b/src/main/network/transient-download-error.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest' +import { isRetryableDownloadError } from './transient-download-error' + +function nodeError(message: string, code: string): Error { + return Object.assign(new Error(message), { code }) +} + +describe('isRetryableDownloadError', () => { + it("retries Chromium's dropped-connection errors", () => { + expect(isRetryableDownloadError(new Error('net::ERR_CONNECTION_RESET'))).toBe(true) + }) + + // orcad downloads through Node's https, which reports drops by code (#31174). + it.each([ + ['a response cut mid-body', nodeError('aborted', 'ECONNRESET')], + ['a reset socket', nodeError('socket hang up', 'ECONNRESET')], + ['a timed-out connect', nodeError('connect ETIMEDOUT 1.2.3.4:443', 'ETIMEDOUT')], + ['a DNS hiccup', nodeError('getaddrinfo EAI_AGAIN example.com', 'EAI_AGAIN')], + ['a DNS outage', nodeError('getaddrinfo ENOTFOUND example.com', 'ENOTFOUND')], + ['a stream closed early', nodeError('Premature close', 'ERR_STREAM_PREMATURE_CLOSE')] + ])("retries Node's %s", (_label, error) => { + expect(isRetryableDownloadError(error)).toBe(true) + }) + + it('does not retry a failure another attempt would repeat', () => { + expect(isRetryableDownloadError(nodeError('no such file', 'ENOENT'))).toBe(false) + expect(isRetryableDownloadError(new Error('Model downloads must use HTTPS'))).toBe(false) + expect( + isRetryableDownloadError(Object.assign(new Error('HTTP 404'), { httpStatusCode: 404 })) + ).toBe(false) + }) +}) diff --git a/src/main/network/transient-download-error.ts b/src/main/network/transient-download-error.ts index 1e95d7b120e..b699ecd197b 100644 --- a/src/main/network/transient-download-error.ts +++ b/src/main/network/transient-download-error.ts @@ -7,6 +7,20 @@ export type HttpStatusError = Error & { const RETRYABLE_NET_ERROR = /net::ERR_(CONTENT_LENGTH_MISMATCH|INCOMPLETE_CHUNKED_ENCODING|CONNECTION_(RESET|CLOSED|ABORTED|REFUSED|TIMED_OUT)|EMPTY_RESPONSE|NETWORK_CHANGED|TIMED_OUT|INTERNET_DISCONNECTED|ADDRESS_UNREACHABLE|NAME_NOT_RESOLVED|SOCKET_NOT_CONNECTED|HTTP2_PROTOCOL_ERROR|QUIC_PROTOCOL_ERROR)\b/ +// Node's `https` (orcad) names the same drops by code; `aborted` is a response cut mid-body. +const RETRYABLE_NODE_ERROR_CODES = new Set([ + 'ECONNRESET', + 'ECONNREFUSED', + 'ECONNABORTED', + 'ETIMEDOUT', + 'EPIPE', + 'ENETDOWN', + 'ENETUNREACH', + 'EHOSTUNREACH', + 'EAI_AGAIN', + 'ENOTFOUND', + 'ERR_STREAM_PREMATURE_CLOSE' +]) const RETRYABLE_HTTP_STATUSES = new Set([408, 416, 425, 429, 500, 502, 503, 504]) export function isRetryableDownloadError(error: unknown): boolean { @@ -21,7 +35,10 @@ export function isRetryableDownloadError(error: unknown): boolean { if (statusCode !== undefined) { return RETRYABLE_HTTP_STATUSES.has(statusCode) } + const code = 'code' in error && typeof error.code === 'string' ? error.code : null return ( - RETRYABLE_NET_ERROR.test(error.message) || error.message.includes('without network activity') + RETRYABLE_NET_ERROR.test(error.message) || + error.message.includes('without network activity') || + (code !== null && RETRYABLE_NODE_ERROR_CODES.has(code)) ) } diff --git a/src/main/orcad/orcad-host-adapters.ts b/src/main/orcad/orcad-host-adapters.ts index 0490c73f88f..a4c05b401a3 100644 --- a/src/main/orcad/orcad-host-adapters.ts +++ b/src/main/orcad/orcad-host-adapters.ts @@ -8,6 +8,8 @@ import { setSecretStore } from '../../shared/secret-store' import { resolveOrcadInstallRoot, resolveOrcadPath } from './orcad-app-paths' import { getOrcadCliLauncherPath } from './orcad-cli-launcher' import { createOrcadSecretStore, type OrcadSecretStore } from './orcad-secret-store' +import { setSpeechServiceFactories } from '../speech/speech-runtime-service' +import { orcadSpeechServiceFactories } from './orcad-speech-services' let quitRegisteredHandlers = (): void => {} @@ -88,4 +90,5 @@ export function installOrcadHostAdapters(): void { setAppEnvironment(createNodeAppEnvironment()) orcadSecretStore = createOrcadSecretStore() setSecretStore(orcadSecretStore) + setSpeechServiceFactories(orcadSpeechServiceFactories) } diff --git a/src/main/orcad/orcad-speech-services.test.ts b/src/main/orcad/orcad-speech-services.test.ts new file mode 100644 index 00000000000..af7e0288532 --- /dev/null +++ b/src/main/orcad/orcad-speech-services.test.ts @@ -0,0 +1,70 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { installFakeAppEnvironment } from '../../../config/scripts/vitest-host-ports-setup' +import { getDefaultVoiceSettings } from '../../shared/constants' +import type { VoiceSettings } from '../../shared/speech-types' +import { SPEECH_RECOGNIZER_DIR_ENV } from '../../shared/speech-recognizer-package' +import { isLocalSpeechModel, SPEECH_MODEL_CATALOG } from '../speech/model-catalog' +import { setSpeechServiceFactories } from '../speech/speech-runtime-service' +import { LOCAL_SPEECH_UNAVAILABLE_ERROR } from '../speech/stt-worker-paths' +import { RuntimeMobileDictationController } from '../runtime/runtime-mobile-dictation-controller' +import { RuntimeMobileSpeechCatalog } from '../runtime/runtime-mobile-speech-catalog' +import type { RuntimeStore } from '../runtime/runtime-store-contract' +import { orcadSpeechServiceFactories } from './orcad-speech-services' + +const roots: string[] = [] +afterEach(() => { + setSpeechServiceFactories(null) + delete process.env[SPEECH_RECOGNIZER_DIR_ENV] + for (const root of roots.splice(0)) { + rmSync(root, { recursive: true, force: true }) + } +}) + +function orcadHost(voice: Partial) { + const userData = mkdtempSync(join(tmpdir(), 'orcad-speech-')) + roots.push(userData) + // A packaged host with no Electron resources tree and no recognizer handed to it. + installFakeAppEnvironment({ isPackaged: () => true, getPath: () => userData }) + setSpeechServiceFactories(orcadSpeechServiceFactories) + let settings = { voice: { ...getDefaultVoiceSettings(), ...voice } } + const store = { + getSettings: () => settings, + updateSettings: (updates: Partial) => { + settings = { ...settings, ...updates } + } + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the speech controllers read only getSettings and updateSettings. + return store as unknown as RuntimeStore +} + +describe('speech on orcad (#31174)', () => { + it('answers speech.models.list instead of speech_unavailable, with only models it can run', async () => { + const store = orcadHost({}) + const setup = await new RuntimeMobileSpeechCatalog(() => store).list() + const cloudIds = SPEECH_MODEL_CATALOG.filter((m) => !isLocalSpeechModel(m)).map((m) => m.id) + expect(setup.models.map((m) => m.id)).toEqual(cloudIds) + }) + + it('refuses to download an on-device model it could never run', async () => { + const store = orcadHost({}) + const local = SPEECH_MODEL_CATALOG.find(isLocalSpeechModel) + await expect(new RuntimeMobileSpeechCatalog(() => store).download(local!.id)).rejects.toThrow( + 'voice_model_not_downloadable' + ) + }) + + // Every mobile build opens the setup sheet on `voice_model_not_ready:`, so older phones recover. + it('sends a phone with an on-device model selected to the setup sheet', async () => { + const local = SPEECH_MODEL_CATALOG.find(isLocalSpeechModel) + const store = orcadHost({ enabled: true, sttModel: local!.id }) + await expect( + new RuntimeMobileDictationController(() => store).start({ + dictationId: 'd-1', + clientId: 'phone' + }) + ).rejects.toThrow(LOCAL_SPEECH_UNAVAILABLE_ERROR) + }) +}) diff --git a/src/main/orcad/orcad-speech-services.ts b/src/main/orcad/orcad-speech-services.ts new file mode 100644 index 00000000000..21ddc289bca --- /dev/null +++ b/src/main/orcad/orcad-speech-services.ts @@ -0,0 +1,16 @@ +import { ModelManager } from '../speech/model-manager' +import { openNodeSpeechDownloadRequest } from '../speech/node-speech-download-request' +import { canReadOpenAiSpeechApiKey } from '../speech/openai-api-key-store' +import type { SpeechServiceFactories } from '../speech/speech-runtime-service' +import { SttService } from '../speech/stt-service' + +/** + * orcad's speech (#31174): cloud models with a key this host can read, on-device models when + * `orca serve` hands it the installed app's recognizer. Models land in this profile's `speech-models`, as on Electron serve. + */ +export const orcadSpeechServiceFactories: SpeechServiceFactories = { + createModelManager: (customModelsDir) => + // No keychain here to prompt, so readiness can prove the key decrypts. + new ModelManager(customModelsDir, openNodeSpeechDownloadRequest, canReadOpenAiSpeechApiKey), + createSttService: (models) => new SttService(models) +} diff --git a/src/main/runtime/construction/runtime-composition-parity.test.ts b/src/main/runtime/construction/runtime-composition-parity.test.ts index bb4aac48b5f..959ef24f899 100644 --- a/src/main/runtime/construction/runtime-composition-parity.test.ts +++ b/src/main/runtime/construction/runtime-composition-parity.test.ts @@ -121,11 +121,6 @@ const DECLARED_HOST_DIFFERENCES: readonly DeclaredDifference[] = [ missingOn: 'electron', reason: 'The desktop binds loopback until a device pairs (STA-2370).' }, - { - wiring: 'host:setSpeechServiceFactories', - missingOn: 'orcad', - reason: 'Unwired gap (#31174): speech calls on orcad refuse with speech_unavailable.' - }, { wiring: 'host:setMainHttpClient', missingOn: 'orcad', @@ -442,7 +437,9 @@ describe('runtime composition parity (Electron vs orcad)', () => { 'ctor-arg:stats', 'host:setAppEnvironment', 'host:setSecretStore', - 'host:setRuntimeBrowserCommandsFactory' + 'host:setRuntimeBrowserCommandsFactory', + // #31174: without it every speech call refuses with speech_unavailable. + 'host:setSpeechServiceFactories' ]) { expect(electron, shared).toContain(shared) expect(orcad, shared).toContain(shared) diff --git a/src/main/runtime/runtime-mobile-dictation-controller.ts b/src/main/runtime/runtime-mobile-dictation-controller.ts index c515d5188f8..b7da6af0acd 100644 --- a/src/main/runtime/runtime-mobile-dictation-controller.ts +++ b/src/main/runtime/runtime-mobile-dictation-controller.ts @@ -1,5 +1,10 @@ import { getDefaultVoiceSettings } from '../../shared/constants' import { getSpeechModelManager, getSpeechSttService } from '../speech/speech-runtime-service' +import { getCatalogModel, isLocalSpeechModel } from '../speech/model-catalog' +import { + hasLocalSpeechRecognizer, + LOCAL_SPEECH_UNAVAILABLE_ERROR +} from '../speech/stt-worker-paths' import type { RuntimeStore } from './runtime-store-contract' type MobileDictationSession = { @@ -33,6 +38,10 @@ export class RuntimeMobileDictationController { if (!modelId) { throw new Error('voice_model_not_selected') } + const manifest = getCatalogModel(modelId) + if (manifest && isLocalSpeechModel(manifest) && !hasLocalSpeechRecognizer()) { + throw new Error(LOCAL_SPEECH_UNAVAILABLE_ERROR) + } const modelState = await getSpeechModelManager(store).getModelState(modelId) if (modelState.status !== 'ready') { throw new Error(`voice_model_not_ready:${modelState.status}`) diff --git a/src/main/runtime/runtime-mobile-speech-catalog.ts b/src/main/runtime/runtime-mobile-speech-catalog.ts index 8a5d234436d..9e9907c1be5 100644 --- a/src/main/runtime/runtime-mobile-speech-catalog.ts +++ b/src/main/runtime/runtime-mobile-speech-catalog.ts @@ -3,6 +3,7 @@ import type { RuntimeSpeechModelSummary, RuntimeSpeechSetupState } from '../../s import { getDefaultVoiceSettings } from '../../shared/constants' import { getCatalogModel, isLocalSpeechModel, SPEECH_MODEL_CATALOG } from '../speech/model-catalog' import { getSpeechModelManager, getSpeechSttService } from '../speech/speech-runtime-service' +import { hasLocalSpeechRecognizer } from '../speech/stt-worker-paths' import { deleteLocalSpeechModel, getSpeechModelDeletionErrorCode @@ -29,7 +30,11 @@ export class RuntimeMobileSpeechCatalog { const voice = store.getSettings().voice ?? getDefaultVoiceSettings() const states = await getSpeechModelManager(store).getModelStates() const stateById = new Map(states.map((state) => [state.id, state])) - const models: RuntimeSpeechModelSummary[] = SPEECH_MODEL_CATALOG.map((manifest) => { + // Why omit, not mark: a status arm older phones don't know would read as a model they can pick. + const runnable = hasLocalSpeechRecognizer() + ? SPEECH_MODEL_CATALOG + : SPEECH_MODEL_CATALOG.filter((manifest) => !isLocalSpeechModel(manifest)) + const models: RuntimeSpeechModelSummary[] = runnable.map((manifest) => { const state = stateById.get(manifest.id) return { id: manifest.id, @@ -52,7 +57,7 @@ export class RuntimeMobileSpeechCatalog { async download(modelId: string): Promise<{ started: true }> { const store = this.requireStore() const manifest = getCatalogModel(modelId) - if (!manifest || !isLocalSpeechModel(manifest)) { + if (!manifest || !isLocalSpeechModel(manifest) || !hasLocalSpeechRecognizer()) { throw new Error('voice_model_not_downloadable') } void getSpeechModelManager(store) diff --git a/src/main/speech/model-manager-download-error.test.ts b/src/main/speech/model-manager-download-error.test.ts index 3f1bc44d099..2841521ae76 100644 --- a/src/main/speech/model-manager-download-error.test.ts +++ b/src/main/speech/model-manager-download-error.test.ts @@ -4,6 +4,7 @@ import { join } from 'node:path' import { beforeEach, describe, expect, it, vi } from 'vitest' import { SPEECH_MODEL_CATALOG } from './model-catalog' import { ModelManager } from './model-manager' +import { openElectronSpeechDownloadRequest } from '../host/electron-speech-services' const { netRequestMock } = vi.hoisted(() => ({ netRequestMock: vi.fn() @@ -55,7 +56,7 @@ describe('ModelManager download failures', () => { }) } netRequestMock.mockReturnValue(request) - const manager = new ModelManager(dir) + const manager = new ModelManager(dir, openElectronSpeechDownloadRequest) await expect(manager.downloadModel(manifest.id)).rejects.toThrow('network down') diff --git a/src/main/speech/model-manager-download-resume.test.ts b/src/main/speech/model-manager-download-resume.test.ts index b4091e8caf3..ec9f5aa0d6a 100644 --- a/src/main/speech/model-manager-download-resume.test.ts +++ b/src/main/speech/model-manager-download-resume.test.ts @@ -4,6 +4,7 @@ import { join } from 'node:path' import { Readable } from 'node:stream' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { ModelManager } from './model-manager' +import { openElectronSpeechDownloadRequest } from '../host/electron-speech-services' const { netRequestMock } = vi.hoisted(() => ({ netRequestMock: vi.fn() @@ -40,6 +41,14 @@ type ModelManagerInternals = { getPartialDownloadBytes: (filePath: string) => number } +function managerInternals(dir: string): ModelManagerInternals { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the tests drive the download hooks the class keeps protected. + return new ModelManager( + dir, + openElectronSpeechDownloadRequest + ) as unknown as ModelManagerInternals +} + type ScriptedResponse = { statusCode: number headers?: Record @@ -163,7 +172,7 @@ describe('ModelManager download resume', () => { chunks: [PAYLOAD.subarray(10)] } }) - const manager = new ModelManager(dir) as unknown as ModelManagerInternals + const manager = managerInternals(dir) const filePath = join(dir, 'model.bin') const attempt = vi.spyOn(manager, 'downloadFile') @@ -204,7 +213,7 @@ describe('ModelManager download resume', () => { chunks: [PAYLOAD], failWith: 'net::ERR_CONNECTION_RESET' })) - const manager = new ModelManager(dir) as unknown as ModelManagerInternals + const manager = managerInternals(dir) const filePath = join(dir, 'model.bin') await manager.downloadFileWithRetry( @@ -248,7 +257,7 @@ describe('ModelManager download resume', () => { chunks: [PAYLOAD.subarray(15)] } }) - const manager = new ModelManager(dir) as unknown as ModelManagerInternals + const manager = managerInternals(dir) const filePath = join(dir, 'model.bin') writeFileSync(filePath, PAYLOAD.subarray(0, 10)) @@ -297,7 +306,7 @@ describe('ModelManager download resume', () => { chunks: [PAYLOAD.subarray(10)] } }) - const manager = new ModelManager(dir) as unknown as ModelManagerInternals + const manager = managerInternals(dir) const filePath = join(dir, 'model.bin') writeFileSync(filePath, PAYLOAD.subarray(0, 1)) @@ -320,7 +329,7 @@ describe('ModelManager download resume', () => { it('bounds a server that advances by pathologically tiny segments forever', async () => { const dir = mkdtempSync(join(tmpdir(), 'orca-model-resume-')) try { - const manager = new ModelManager(dir) as unknown as ModelManagerInternals + const manager = managerInternals(dir) const filePath = join(dir, 'model.bin') let bytesWritten = 0 // Why: the ceiling costs 4096 iterations, so read progress from memory — @@ -360,7 +369,7 @@ describe('ModelManager download resume', () => { // forward progress, the download must complete no matter how many drops // it takes (regression guard: a fixed failure budget used to abandon a // still-advancing large download around attempt 8). - const manager = new ModelManager(dir) as unknown as ModelManagerInternals + const manager = managerInternals(dir) const filePath = join(dir, 'model.bin') const SLICE = 2 let delivered = 0 @@ -420,7 +429,7 @@ describe('ModelManager download resume', () => { chunks: [PAYLOAD.subarray(15)] } }) - const manager = new ModelManager(dir) as unknown as ModelManagerInternals + const manager = managerInternals(dir) const filePath = join(dir, 'model.bin') writeFileSync(filePath, PAYLOAD.subarray(0, 10)) @@ -451,7 +460,7 @@ describe('ModelManager download resume', () => { }, chunks: [PAYLOAD.subarray(0, 10)] })) - const manager = new ModelManager(dir) as unknown as ModelManagerInternals + const manager = managerInternals(dir) const filePath = join(dir, 'model.bin') writeFileSync(filePath, PAYLOAD.subarray(0, 10)) @@ -494,7 +503,7 @@ describe('ModelManager download resume', () => { headers: { 'content-length': String(PAYLOAD.length) }, chunks: [PAYLOAD] })) - const manager = new ModelManager(dir) as unknown as ModelManagerInternals + const manager = managerInternals(dir) const filePath = join(dir, 'model.bin') const attempt = vi.spyOn(manager, 'downloadFile') @@ -529,7 +538,7 @@ describe('ModelManager download resume', () => { const dir = mkdtempSync(join(tmpdir(), 'orca-model-resume-')) try { scriptRequest(() => ({ statusCode: 404 })) - const manager = new ModelManager(dir) as unknown as ModelManagerInternals + const manager = managerInternals(dir) await expect( manager.downloadFileWithRetry( @@ -555,7 +564,7 @@ describe('ModelManager download resume', () => { statusCode: 429, headers: { 'retry-after': '3' } })) - const manager = new ModelManager(dir) as unknown as ModelManagerInternals + const manager = managerInternals(dir) const error = await manager .downloadFile( @@ -578,7 +587,7 @@ describe('ModelManager download resume', () => { vi.useFakeTimers() const dir = mkdtempSync(join(tmpdir(), 'orca-model-resume-')) try { - const manager = new ModelManager(dir) as unknown as ModelManagerInternals + const manager = managerInternals(dir) const filePath = join(dir, 'model.bin') const rateLimitError = Object.assign(new Error('HTTP 429'), { httpStatusCode: 429, @@ -614,7 +623,7 @@ describe('ModelManager download resume', () => { it('does not retry before an excessively long Retry-After window', async () => { const dir = mkdtempSync(join(tmpdir(), 'orca-model-resume-')) try { - const manager = new ModelManager(dir) as unknown as ModelManagerInternals + const manager = managerInternals(dir) const rateLimitError = Object.assign(new Error('HTTP 429'), { httpStatusCode: 429, retryAfterMs: 300_000 @@ -642,7 +651,7 @@ describe('ModelManager download resume', () => { vi.useFakeTimers() const dir = mkdtempSync(join(tmpdir(), 'orca-model-resume-')) try { - const manager = new ModelManager(dir) as unknown as ModelManagerInternals + const manager = managerInternals(dir) const downloadFileMock = vi .spyOn(manager, 'downloadFile') .mockRejectedValue(new Error('net::ERR_CONNECTION_RESET')) @@ -693,7 +702,7 @@ describe('ModelManager download resume', () => { failWith: 'net::ERR_CONNECTION_RESET' })) const controller = new AbortController() - const manager = new ModelManager(dir) as unknown as ModelManagerInternals + const manager = managerInternals(dir) const download = manager.downloadFileWithRetry( 'https://example.com/model.bin', diff --git a/src/main/speech/model-manager-progress-callback.test.ts b/src/main/speech/model-manager-progress-callback.test.ts index 1c8633188e7..49cff109461 100644 --- a/src/main/speech/model-manager-progress-callback.test.ts +++ b/src/main/speech/model-manager-progress-callback.test.ts @@ -3,6 +3,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { describe, expect, it, vi } from 'vitest' import { ModelManager } from './model-manager' +import { openElectronSpeechDownloadRequest } from '../host/electron-speech-services' vi.mock('electron', () => ({ app: { @@ -23,7 +24,7 @@ describe('ModelManager progress callbacks', () => { it('unsubscribes progress callbacks without replacing other listeners', () => { const dir = mkdtempSync(join(tmpdir(), 'orca-model-manager-')) try { - const manager = new ModelManager(dir) + const manager = new ModelManager(dir, openElectronSpeechDownloadRequest) const internals = manager as unknown as ModelManagerInternals const first = vi.fn() const second = vi.fn() @@ -49,7 +50,7 @@ describe('ModelManager progress callbacks', () => { it('coalesces per-chunk download progress to whole percent', () => { const dir = mkdtempSync(join(tmpdir(), 'orca-model-manager-')) try { - const manager = new ModelManager(dir) + const manager = new ModelManager(dir, openElectronSpeechDownloadRequest) const internals = manager as unknown as ModelManagerInternals const listener = vi.fn() manager.setProgressCallback(listener) diff --git a/src/main/speech/model-manager-stream-cleanup.test.ts b/src/main/speech/model-manager-stream-cleanup.test.ts index 34b7aa893d8..01ef908044a 100644 --- a/src/main/speech/model-manager-stream-cleanup.test.ts +++ b/src/main/speech/model-manager-stream-cleanup.test.ts @@ -4,6 +4,7 @@ import { join } from 'node:path' import { PassThrough } from 'node:stream' import { beforeEach, describe, expect, it, vi } from 'vitest' import { ModelManager } from './model-manager' +import { openElectronSpeechDownloadRequest } from '../host/electron-speech-services' const { netRequestMock } = vi.hoisted(() => ({ netRequestMock: vi.fn() @@ -29,6 +30,14 @@ type ModelManagerInternals = { ) => Promise } +function managerInternals(dir: string): ModelManagerInternals { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the tests drive the download hooks the class keeps protected. + return new ModelManager( + dir, + openElectronSpeechDownloadRequest + ) as unknown as ModelManagerInternals +} + describe('ModelManager stream cleanup', () => { beforeEach(() => { netRequestMock.mockReset() @@ -63,7 +72,7 @@ describe('ModelManager stream cleanup', () => { off: vi.fn(() => request) } netRequestMock.mockReturnValue(request) - const manager = new ModelManager(dir) as unknown as ModelManagerInternals + const manager = managerInternals(dir) const download = manager.downloadFile( 'https://example.com/model.bin', diff --git a/src/main/speech/model-manager-windows-path.test.ts b/src/main/speech/model-manager-windows-path.test.ts index 3b72cc83229..7569fc25bdc 100644 --- a/src/main/speech/model-manager-windows-path.test.ts +++ b/src/main/speech/model-manager-windows-path.test.ts @@ -4,6 +4,8 @@ import { dirname, join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { SPEECH_MODEL_CATALOG } from './model-catalog' import { ModelManager } from './model-manager' +import { openElectronSpeechDownloadRequest } from '../host/electron-speech-services' +import { installFakeAppEnvironment } from '../../../config/scripts/vitest-host-ports-setup' const { appGetPathMock, netRequestMock } = vi.hoisted(() => ({ appGetPathMock: vi.fn(), @@ -19,6 +21,9 @@ vi.mock('electron', () => ({ } })) +// ModelManager reads userData through the app environment, which the desktop backs with `app`. +beforeEach(() => installFakeAppEnvironment({ getPath: (name) => appGetPathMock(name) })) + const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') const originalProgramData = process.env.PROGRAMDATA @@ -63,7 +68,7 @@ describe('ModelManager Windows model path handling', () => { name === 'userData' ? userDataDir : join(dir, name) ) - const manager = new ModelManager() + const manager = new ModelManager(undefined, openElectronSpeechDownloadRequest) expect(manager.getModelsDir()).not.toContain(userDataDir) expect(manager.getModelsDir()).toContain(join(programDataDir, 'Orca', 'speech-models')) @@ -96,7 +101,7 @@ describe('ModelManager Windows model path handling', () => { truncateSync(filePath, file.sizeBytes) } - const manager = new ModelManager() + const manager = new ModelManager(undefined, openElectronSpeechDownloadRequest) const migratedModelDir = manager.getModelDir(manifest!.id) expect(migratedModelDir).not.toContain(userDataDir) @@ -134,7 +139,7 @@ describe('ModelManager Windows model path handling', () => { truncateSync(filePath, file.sizeBytes) } - const manager = new ModelManager() + const manager = new ModelManager(undefined, openElectronSpeechDownloadRequest) await expect(manager.getModelState(manifest!.id)).resolves.toEqual({ id: manifest!.id, status: 'ready' @@ -145,7 +150,7 @@ describe('ModelManager Windows model path handling', () => { expect(existsSync(legacyModelDir)).toBe(false) // Simulate an app restart: a fresh manager migrates from the same source. - const restarted = new ModelManager() + const restarted = new ModelManager(undefined, openElectronSpeechDownloadRequest) await expect(restarted.getModelState(manifest!.id)).resolves.toEqual({ id: manifest!.id, status: 'not-downloaded' diff --git a/src/main/speech/model-manager.test.ts b/src/main/speech/model-manager.test.ts index 006ba707fae..69a005af026 100644 --- a/src/main/speech/model-manager.test.ts +++ b/src/main/speech/model-manager.test.ts @@ -5,6 +5,7 @@ import { dirname, join } from 'node:path' import { beforeEach, describe, expect, it, vi } from 'vitest' import { SPEECH_MODEL_CATALOG } from './model-catalog' import { ModelManager } from './model-manager' +import { openElectronSpeechDownloadRequest } from '../host/electron-speech-services' const { hasOpenAiSpeechApiKeyMock, netRequestMock } = vi.hoisted(() => ({ hasOpenAiSpeechApiKeyMock: vi.fn(), @@ -46,6 +47,14 @@ type ModelManagerInternals = { ) => Promise } +function managerInternals(dir: string): ModelManagerInternals { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the tests drive the download hooks the class keeps protected. + return new ModelManager( + dir, + openElectronSpeechDownloadRequest + ) as unknown as ModelManagerInternals +} + describe('ModelManager', () => { beforeEach(() => { netRequestMock.mockReset() @@ -79,7 +88,7 @@ describe('ModelManager', () => { const filePath = join(dir, 'model.onnx') writeFileSync(filePath, 'known model bytes') const expected = createHash('sha256').update('known model bytes').digest('hex') - const manager = new ModelManager(dir) as unknown as ModelManagerInternals + const manager = managerInternals(dir) await expect(manager.verifyFileSha256(filePath, expected)).resolves.toBeUndefined() await expect(manager.verifyFileSha256(filePath, '0'.repeat(64))).rejects.toThrow( @@ -93,7 +102,7 @@ describe('ModelManager', () => { it('rejects non-HTTPS model downloads', async () => { const dir = mkdtempSync(join(tmpdir(), 'orca-model-manager-')) try { - const manager = new ModelManager(dir) as unknown as ModelManagerInternals + const manager = managerInternals(dir) await expect( manager.downloadFile( @@ -115,7 +124,7 @@ describe('ModelManager', () => { const manifest = SPEECH_MODEL_CATALOG.find( (model) => model.id === 'zipformer-streaming-zh-14m' )! - const manager = new ModelManager(dir) + const manager = new ModelManager(dir, openElectronSpeechDownloadRequest) const internals = manager as unknown as ModelManagerInternals const downloadMock = vi .spyOn(internals, 'downloadFileWithRetry') @@ -152,7 +161,7 @@ describe('ModelManager', () => { it('marks OpenAI transcription models ready only when an API key is configured', async () => { const dir = mkdtempSync(join(tmpdir(), 'orca-model-manager-')) try { - const manager = new ModelManager(dir) + const manager = new ModelManager(dir, openElectronSpeechDownloadRequest) await expect(manager.getModelState('openai-gpt-4o-mini-transcribe')).resolves.toEqual({ id: 'openai-gpt-4o-mini-transcribe', @@ -177,7 +186,7 @@ describe('ModelManager', () => { (model) => model.id === 'zipformer-streaming-zh-14m' ) expect(manifest?.files).toBeDefined() - const manager = new ModelManager(dir) + const manager = new ModelManager(dir, openElectronSpeechDownloadRequest) const modelDir = manager.getModelDir(manifest!.id) for (const file of manifest!.downloadFiles ?? []) { const path = join(modelDir, file.name) @@ -260,7 +269,7 @@ describe('ModelManager', () => { end: vi.fn(() => request) } netRequestMock.mockReturnValue(request) - const manager = new ModelManager(dir) + const manager = new ModelManager(dir, openElectronSpeechDownloadRequest) const download = manager.downloadModel(manifest.id) manager.cancelDownload(manifest.id) @@ -336,7 +345,7 @@ describe('ModelManager', () => { } netRequestMock.mockReturnValue(request) const controller = new AbortController() - const manager = new ModelManager(dir) as unknown as ModelManagerInternals + const manager = managerInternals(dir) const download = manager.downloadFile( 'https://example.com/model.bin', @@ -418,7 +427,7 @@ describe('ModelManager', () => { end: vi.fn(() => request) } netRequestMock.mockReturnValue(request) - const manager = new ModelManager(dir) as unknown as ModelManagerInternals + const manager = managerInternals(dir) const download = manager.downloadFile( 'https://example.com/model.bin', diff --git a/src/main/speech/model-manager.ts b/src/main/speech/model-manager.ts index 8d3cf1bb1de..96100e674f3 100644 --- a/src/main/speech/model-manager.ts +++ b/src/main/speech/model-manager.ts @@ -1,4 +1,3 @@ -import { app } from 'electron' import { existsSync, mkdirSync, rmSync, statSync } from 'node:fs' import { rename, rm } from 'node:fs/promises' import { join, relative, resolve } from 'node:path' @@ -15,6 +14,8 @@ import { type SpeechModelCacheDir } from './model-cache-path' import { SpeechModelDownloadTransport } from './speech-model-download-transport' +import type { OpenSpeechDownloadRequest } from './speech-model-download-response' +import { getAppEnvironment } from '../../shared/app-environment' import { removeModelDownloadFiles, removeModelDownloadStaging @@ -34,9 +35,14 @@ export class ModelManager extends SpeechModelDownloadTransport { private modelStates = new Map() private progressCallbacks = new Set() - constructor(customModelsDir?: string) { - super() - const requestedModelsDir = customModelsDir || join(app.getPath('userData'), 'speech-models') + constructor( + customModelsDir: string | undefined, + openRequest: OpenSpeechDownloadRequest, + private readonly hasOpenAiKey: () => boolean = hasOpenAiSpeechApiKey + ) { + super(openRequest) + const requestedModelsDir = + customModelsDir || join(getAppEnvironment().getPath('userData'), 'speech-models') const prepared = this.prepareModelsDir(requestedModelsDir) this.modelsDir = prepared.modelsDir this.migrationSourceDir = prepared.migrationSourceDir @@ -98,10 +104,7 @@ export class ModelManager extends SpeechModelDownloadTransport { } if (manifest.provider === 'openai') { - return { - id: modelId, - status: hasOpenAiSpeechApiKey() ? 'ready' : 'not-downloaded' - } + return { id: modelId, status: this.hasOpenAiKey() ? 'ready' : 'not-downloaded' } } const modelDir = this.getModelDir(modelId) diff --git a/src/main/speech/node-speech-download-request.test.ts b/src/main/speech/node-speech-download-request.test.ts new file mode 100644 index 00000000000..3a928ef7609 --- /dev/null +++ b/src/main/speech/node-speech-download-request.test.ts @@ -0,0 +1,86 @@ +import { EventEmitter } from 'node:events' +import { PassThrough } from 'node:stream' +import { describe, expect, it, vi } from 'vitest' + +class FakeClientRequest extends EventEmitter { + readonly headers = new Map() + ended = false + destroyed = false + setHeader(name: string, value: string): void { + this.headers.set(name, value) + } + end(): void { + this.ended = true + } + destroy(): void { + this.destroyed = true + this.emit('error', new Error('socket hang up')) + } +} + +const requests = vi.hoisted(() => { + const made: { url: string; request: unknown }[] = [] + return { made } +}) + +vi.mock('node:https', () => ({ + request: (url: string) => { + const request = new FakeClientRequest() + requests.made.push({ url, request }) + return request + } +})) + +const { openNodeSpeechDownloadRequest } = await import('./node-speech-download-request') + +function lastRequest(): FakeClientRequest { + const made = requests.made.at(-1)?.request + if (!(made instanceof FakeClientRequest)) { + throw new Error('no request made') + } + return made +} + +function incoming(statusCode: number, headers: Record = {}) { + return Object.assign(new PassThrough(), { statusCode, headers }) +} + +describe('openNodeSpeechDownloadRequest', () => { + it('reports a 3xx with a location as a redirect and drains it', () => { + const handle = openNodeSpeechDownloadRequest('https://models.example/a.tar.bz2') + const listeners = { error: vi.fn(), response: vi.fn(), redirect: vi.fn() } + handle.listen(listeners) + handle.setHeader('Range', 'bytes=10-') + handle.end() + const request = lastRequest() + const redirect = incoming(302, { location: 'https://cdn.example/a.tar.bz2' }) + const resume = vi.spyOn(redirect, 'resume') + request.emit('response', redirect) + + expect(request.headers.get('Range')).toBe('bytes=10-') + expect(request.ended).toBe(true) + expect(listeners.redirect).toHaveBeenCalledWith(302, 'GET', 'https://cdn.example/a.tar.bz2') + expect(resume).toHaveBeenCalled() + expect(listeners.response).not.toHaveBeenCalled() + }) + + it('hands any other response to the download', () => { + const handle = openNodeSpeechDownloadRequest('https://models.example/a.tar.bz2') + const listeners = { error: vi.fn(), response: vi.fn(), redirect: vi.fn() } + handle.listen(listeners) + const ok = incoming(206, { 'content-range': 'bytes 10-19/20' }) + lastRequest().emit('response', ok) + expect(listeners.response).toHaveBeenCalledWith(ok) + }) + + // Why: the download detaches before aborting; an unheard ClientRequest error crashes orcad. + it('swallows the error abort raises after the download detached', () => { + const handle = openNodeSpeechDownloadRequest('https://models.example/a.tar.bz2') + const listeners = { error: vi.fn(), response: vi.fn(), redirect: vi.fn() } + const detach = handle.listen(listeners) + detach() + expect(() => handle.abort()).not.toThrow() + expect(lastRequest().destroyed).toBe(true) + expect(listeners.error).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/speech/node-speech-download-request.ts b/src/main/speech/node-speech-download-request.ts new file mode 100644 index 00000000000..32ed6d4038f --- /dev/null +++ b/src/main/speech/node-speech-download-request.ts @@ -0,0 +1,45 @@ +import type { IncomingMessage } from 'node:http' +import { request } from 'node:https' +import type { OpenSpeechDownloadRequest } from './speech-model-download-response' + +/** + * Model downloads on a host without Chromium (orcad). Node does not follow redirects, so a 3xx + * surfaces as the `redirect` Electron's `net` reports, and the download re-requests it the same + * way. Node's `https` uses no proxy, so the host must reach the model host directly. + */ +export const openNodeSpeechDownloadRequest: OpenSpeechDownloadRequest = (url) => { + const outgoing = request(url, { method: 'GET' }) + // Why permanent: abort() destroys the request after the download detached, and an unheard + // ClientRequest error would crash the server. + outgoing.on('error', () => {}) + return { + setHeader: (name, value) => { + outgoing.setHeader(name, value) + }, + listen: ({ error, response, redirect }) => { + const onResponse = (incoming: IncomingMessage): void => { + const status = incoming.statusCode ?? 0 + const location = incoming.headers.location + if (status >= 300 && status < 400 && location) { + // Drained so an unread redirect body cannot hold the socket before the caller aborts. + incoming.resume() + redirect(status, 'GET', location) + return + } + response(incoming) + } + outgoing.on('error', error) + outgoing.on('response', onResponse) + return () => { + outgoing.off('error', error) + outgoing.off('response', onResponse) + } + }, + abort: () => { + outgoing.destroy() + }, + end: () => { + outgoing.end() + } + } +} diff --git a/src/main/speech/openai-api-key-store.test.ts b/src/main/speech/openai-api-key-store.test.ts index e9dafd63781..e632f14e37e 100644 --- a/src/main/speech/openai-api-key-store.test.ts +++ b/src/main/speech/openai-api-key-store.test.ts @@ -54,6 +54,35 @@ describe('OpenAI speech API key store', () => { expect(safeStorageMock.decryptString).not.toHaveBeenCalled() }) + // #31174: orcad cannot open a key the desktop sealed with safeStorage, so it must not say ready. + it('reports a stored key this host cannot decrypt as unusable', async () => { + writeStoredOpenAiKey('v10\u0000ÿþ sealed by another store') + const store = await loadStoreModule() + safeStorageMock.decryptString.mockImplementation(() => { + throw new Error('not our ciphertext') + }) + try { + expect(store.hasOpenAiSpeechApiKey()).toBe(true) + expect(store.canReadOpenAiSpeechApiKey()).toBe(false) + } finally { + safeStorageMock.decryptString.mockImplementation((value: Buffer) => value.toString('utf8')) + } + }) + + it("does not hand out another store's ciphertext when this host stores plaintext", async () => { + writeStoredOpenAiKey('v10\u0000\u00ff\u00fe sealed by another store') + const store = await loadStoreModule() + safeStorageMock.isEncryptionAvailable.mockReturnValue(false) + expect(store.canReadOpenAiSpeechApiKey()).toBe(false) + expect(() => store.readOpenAiSpeechApiKey()).toThrow('OpenAI API key could not be decrypted') + }) + + it('reports a stored key this host can decrypt as usable', async () => { + writeStoredOpenAiKey('encrypted-key') + const store = await loadStoreModule() + expect(store.canReadOpenAiSpeechApiKey()).toBe(true) + }) + it('decrypts only when the key is read for an API request', async () => { writeStoredOpenAiKey('encrypted-key') const store = await loadStoreModule() diff --git a/src/main/speech/openai-api-key-store.ts b/src/main/speech/openai-api-key-store.ts index 1c334b7b5be..c8b1fe9a42b 100644 --- a/src/main/speech/openai-api-key-store.ts +++ b/src/main/speech/openai-api-key-store.ts @@ -52,6 +52,22 @@ export function hasOpenAiSpeechApiKey(): boolean { return existsSync(getOpenAiKeyPath()) } +/** + * Whether the stored key decrypts on this host. Why orcad needs it: a key the desktop sealed with + * Electron's safeStorage exists on disk but orcad's own secret store cannot open it. + */ +export function canReadOpenAiSpeechApiKey(): boolean { + if (!hasOpenAiSpeechApiKey()) { + return false + } + try { + readOpenAiSpeechApiKey() + return true + } catch { + return false + } +} + /** How the stored speech key sits on disk, or null when none is stored. */ export function getOpenAiSpeechApiKeyProtection(): SecretAtRestProtection | null { // The legacy JSON wrapper only ever held base64 ciphertext, so it is sealed by shape. @@ -98,13 +114,21 @@ export function readOpenAiSpeechApiKey(): string { } cachedOpenAiSpeechApiKey = getSecretStore().isEncryptionAvailable() ? decryptOrLegacyPlaintext(raw) - : raw.toString('utf8') + : plaintextOnly(raw) return cachedOpenAiSpeechApiKey } catch { throw new Error('OpenAI API key could not be decrypted') } } +// Why: with no sealing here, sealed bytes are another store's ciphertext, never a usable key. +function plaintextOnly(raw: Buffer): string { + if (classifyUnenvelopedCredential(raw) !== 'plaintext') { + throw new Error('sealed by another secret store') + } + return raw.toString('utf8') +} + // Why: a key saved before sealing became available stays plaintext until it is migrated or re-saved. function decryptOrLegacyPlaintext(raw: Buffer): string { try { diff --git a/src/main/speech/speech-model-download-response.ts b/src/main/speech/speech-model-download-response.ts index 64275b586e6..6539b2e1573 100644 --- a/src/main/speech/speech-model-download-response.ts +++ b/src/main/speech/speech-model-download-response.ts @@ -1,8 +1,25 @@ -export type DownloadIncomingMessage = Electron.IncomingMessage & - NodeJS.ReadableStream & { - headers: Record - destroy?: () => void - } +/** The response half both Electron's `net` and Node's `https` provide. */ +export type DownloadIncomingMessage = NodeJS.ReadableStream & { + statusCode?: number + headers: Record + destroy?: () => void +} + +/** The request half both Electron's `net` and Node's `https` provide, through a host adapter. */ +export type SpeechDownloadRequest = { + setHeader(name: string, value: string): void + /** Returns the detach. Node's request errors on abort, so its adapter keeps a no-op listener. */ + listen(listeners: { + error: (error: Error) => void + response: (response: DownloadIncomingMessage) => void + redirect: (statusCode: number, method: string, redirectUrl: string) => void + }): () => void + abort(): void + end(): void +} + +/** Opens one GET; the host decides the network stack (Chromium on the desktop, Node on orcad). */ +export type OpenSpeechDownloadRequest = (url: string) => SpeechDownloadRequest export type DownloadTotals = { totalBytes: number completedBytes: number diff --git a/src/main/speech/speech-model-http-download.ts b/src/main/speech/speech-model-http-download.ts index 7c059db783d..ccafaae5b93 100644 --- a/src/main/speech/speech-model-http-download.ts +++ b/src/main/speech/speech-model-http-download.ts @@ -1,17 +1,20 @@ import { createWriteStream, rmSync } from 'node:fs' import { pipeline } from 'node:stream/promises' -import { net } from 'electron' import { DOWNLOAD_IDLE_TIMEOUT_MS, getHeaderValue, parseContentRange, parseRetryAfterMs, type DownloadIncomingMessage, - type DownloadTotals + type DownloadTotals, + type OpenSpeechDownloadRequest, + type SpeechDownloadRequest } from './speech-model-download-response' import type { HttpStatusError } from '../network/transient-download-error' export abstract class SpeechModelHttpDownload { + constructor(private readonly openRequest: OpenSpeechDownloadRequest) {} + protected abstract reportDownloadProgress(modelId: string, progress: number): void protected downloadFile( @@ -45,7 +48,8 @@ export abstract class SpeechModelHttpDownload { } let settled = false - let request: Electron.ClientRequest | null = null + let request: SpeechDownloadRequest | null = null + let detachRequest = (): void => {} let idleTimeout: ReturnType | null = null const onSignalAbort = (): void => { const activeRequest = request @@ -64,9 +68,7 @@ export abstract class SpeechModelHttpDownload { if (!activeRequest) { return } - activeRequest.off('error', onRequestError) - activeRequest.off('response', onResponse) - activeRequest.off('redirect', onRedirect) + detachRequest() signal?.removeEventListener('abort', onSignalAbort) request = null } @@ -142,8 +144,7 @@ export abstract class SpeechModelHttpDownload { .then(resolveOnce) .catch(rejectOnce) } - const onResponse = (incoming: Electron.IncomingMessage): void => { - const response = incoming as DownloadIncomingMessage + const onResponse = (response: DownloadIncomingMessage): void => { const contentLength = response.headers['content-length'] const headerLength = Number.parseInt(getHeaderValue(contentLength) || '0', 10) const parsedLength = @@ -242,16 +243,18 @@ export abstract class SpeechModelHttpDownload { }) } - request = net.request({ method: 'GET', url: parsedUrl.toString() }) + request = this.openRequest(parsedUrl.toString()) if (resumeOffset > 0) { request.setHeader('Range', `bytes=${resumeOffset}-`) } - // Why: Electron net honors app proxy settings (unlike Node https) but exposes no setTimeout, so time out manually. + // Why manual: neither stack's request exposes an idle timeout that covers the response body. resetIdleTimeout() - request.on('error', onRequestError) - request.on('response', onResponse) - request.on('redirect', onRedirect) + detachRequest = request.listen({ + error: onRequestError, + response: onResponse, + redirect: onRedirect + }) if (signal) { signal.addEventListener('abort', onSignalAbort, { once: true }) } diff --git a/src/main/speech/speech-runtime-service.ts b/src/main/speech/speech-runtime-service.ts index f5a65a939dc..5a551133ec6 100644 --- a/src/main/speech/speech-runtime-service.ts +++ b/src/main/speech/speech-runtime-service.ts @@ -5,14 +5,9 @@ import type { VoiceSettings } from '../../shared/speech-types' /** * Lazy accessors for the speech services. * - * Why the construction is injected: `ModelManager` downloads models through Electron's - * streaming `net.request` — byte-range resume, progress events, a manual idle timeout — - * and `SttService` resolves paths inside the packaged app. Importing either for its - * *type* is free; constructing one is what pulls Electron in. - * - * The desktop installs the factories. A host without them rejects per call rather than - * returning a stub that silently does nothing: speech is a desktop feature, and a - * headless host saying so is more useful than one that appears to transcribe. + * Why the construction is injected: each host picks how `ModelManager` downloads (Chromium's + * `net` on the desktop, Node's `https` on orcad). Both the desktop and orcad install factories; + * a host without them rejects per call rather than returning a stub that appears to transcribe. */ type SpeechSettingsStore = { diff --git a/src/main/speech/stt-session-start.ts b/src/main/speech/stt-session-start.ts index eb42418cd0b..6b9b26dfde2 100644 --- a/src/main/speech/stt-session-start.ts +++ b/src/main/speech/stt-session-start.ts @@ -11,7 +11,11 @@ import { stopSttDictation, teardownSttWorker } from './stt-session-stop' -import { getSherpaModulePath, getSttWorkerPath } from './stt-worker-paths' +import { + getSherpaModulePath, + getSttWorkerPath, + LOCAL_SPEECH_UNAVAILABLE_ERROR +} from './stt-worker-paths' import { attachSttWorkerLifecycle, initializeSttWorker, @@ -124,9 +128,11 @@ async function startSttSession( throw new Error(`Model not ready: ${modelState.status}`) } - const worker = new Worker(getSttWorkerPath(), { - workerData: { sherpaModulePath: getSherpaModulePath() } - }) + const sherpaModulePath = getSherpaModulePath() + if (!sherpaModulePath) { + throw new Error(LOCAL_SPEECH_UNAVAILABLE_ERROR) + } + const worker = new Worker(getSttWorkerPath(), { workerData: { sherpaModulePath } }) state.worker = worker state.activeModelId = modelId state.activeHotwordsFilePath = hotwordsFilePath diff --git a/src/main/speech/stt-worker-paths.test.ts b/src/main/speech/stt-worker-paths.test.ts new file mode 100644 index 00000000000..c4f9261632a --- /dev/null +++ b/src/main/speech/stt-worker-paths.test.ts @@ -0,0 +1,46 @@ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { installFakeAppEnvironment } from '../../../config/scripts/vitest-host-ports-setup' +import { SPEECH_RECOGNIZER_DIR_ENV } from '../../shared/speech-recognizer-package' +import { getSherpaModulePath, getSttWorkerPath, hasLocalSpeechRecognizer } from './stt-worker-paths' + +const roots: string[] = [] +afterEach(() => { + delete process.env[SPEECH_RECOGNIZER_DIR_ENV] + for (const root of roots.splice(0)) { + rmSync(root, { recursive: true, force: true }) + } +}) + +// orcad: a packaged host with no Electron resources tree. +describe('speech worker paths on orcad (#31174)', () => { + it('has no on-device recognizer unless orca serve names one', () => { + installFakeAppEnvironment({ isPackaged: () => true }) + expect(getSherpaModulePath()).toBeNull() + expect(hasLocalSpeechRecognizer()).toBe(false) + }) + + it("uses the installed app's recognizer that orca serve names", () => { + installFakeAppEnvironment({ isPackaged: () => true }) + const dir = mkdtempSync(join(tmpdir(), 'orca-recognizer-')) + roots.push(dir) + writeFileSync(join(dir, 'sherpa-onnx.node'), '') + process.env[SPEECH_RECOGNIZER_DIR_ENV] = dir + expect(getSherpaModulePath()).toBe(dir) + }) + + it('ignores a named directory that holds no recognizer', () => { + installFakeAppEnvironment({ isPackaged: () => true }) + const dir = mkdtempSync(join(tmpdir(), 'orca-recognizer-')) + roots.push(dir) + process.env[SPEECH_RECOGNIZER_DIR_ENV] = dir + expect(getSherpaModulePath()).toBeNull() + }) + + it('starts the worker shipped beside the server bundle', () => { + installFakeAppEnvironment({ isPackaged: () => true }) + expect(getSttWorkerPath()).toBe(join(__dirname, 'stt-worker.js')) + }) +}) diff --git a/src/main/speech/stt-worker-paths.ts b/src/main/speech/stt-worker-paths.ts index e18515a0d98..34598dac907 100644 --- a/src/main/speech/stt-worker-paths.ts +++ b/src/main/speech/stt-worker-paths.ts @@ -1,28 +1,50 @@ import { existsSync } from 'node:fs' import { join } from 'node:path' import { getAppEnvironment } from '../../shared/app-environment' +import { + SPEECH_RECOGNIZER_DIR_ENV, + speechRecognizerPackageName +} from '../../shared/speech-recognizer-package' +import { currentWorkerEntryLayout, resolveWorkerThreadEntryPath } from '../worker-thread-entry-path' + +/** + * A local model asked of a host with no on-device recognizer. Why this code: every mobile build maps + * `voice_model_not_ready:` to the setup sheet, which lists only the models this host can run. + */ +export const LOCAL_SPEECH_UNAVAILABLE_ERROR = 'voice_model_not_ready:unavailable' export function getSttWorkerPath(): string { - if (getAppEnvironment().isPackaged()) { - return join(process.resourcesPath, 'app.asar', 'out', 'main', 'stt-worker.js') - } - return join(__dirname, 'stt-worker.js') + return resolveWorkerThreadEntryPath(currentWorkerEntryLayout(__dirname), 'stt-worker.js') } -export function getSherpaModulePath(): string { - const nativePackage = - process.platform === 'win32' && process.arch === 'x64' - ? 'sherpa-onnx-win-x64' - : `sherpa-onnx-${process.platform}-${process.arch}` +/** The recognizer addon's directory, or null on a host that has none (an orcad slot alone). */ +export function getSherpaModulePath(): string | null { + const nativePackage = speechRecognizerPackageName(process.platform, process.arch) + const resourcesPath = + 'resourcesPath' in process && typeof process.resourcesPath === 'string' + ? process.resourcesPath + : undefined - if (getAppEnvironment().isPackaged()) { - const resourcesNodeModule = join(process.resourcesPath, 'node_modules', nativePackage) + if (getAppEnvironment().isPackaged() && resourcesPath) { + const resourcesNodeModule = join(resourcesPath, 'node_modules', nativePackage) if (existsSync(resourcesNodeModule)) { return resourcesNodeModule } - return join(process.resourcesPath, 'app.asar.unpacked', 'node_modules', nativePackage) + return join(resourcesPath, 'app.asar.unpacked', 'node_modules', nativePackage) + } + if (getAppEnvironment().isPackaged()) { + // orcad: only the installed app's copy, which `orca serve` names. + const handed = process.env[SPEECH_RECOGNIZER_DIR_ENV] + return handed && existsSync(join(handed, 'sherpa-onnx.node')) ? handed : null } - const resolved = require.resolve(nativePackage) - return join(resolved, '..') + try { + return join(require.resolve(nativePackage), '..') + } catch { + return null + } +} + +export function hasLocalSpeechRecognizer(): boolean { + return getSherpaModulePath() !== null } diff --git a/src/main/ssh/ssh-remote-commands.test.ts b/src/main/ssh/ssh-remote-commands.test.ts index a382e6c8501..330ba6f3ead 100644 --- a/src/main/ssh/ssh-remote-commands.test.ts +++ b/src/main/ssh/ssh-remote-commands.test.ts @@ -136,7 +136,7 @@ describe('ssh remote command builders', () => { expect(readRemoteHomeCommand(posix)).toBe('echo $HOME') expect(removeRemoteTreeCommand(posix, '/home/me/.orca-remote/x')).toContain('rm -rf') const probe = probeOrcadInstalledCommand(posix, '/home/me/orcad') - expect(probe).toContain('test -d') + expect(probe).toContain("cd '/home/me/orcad'") expect(probe).toContain(ORCAD_INSTALL_MODEL.installCompleteFilename) }) @@ -168,6 +168,24 @@ describe('ssh remote command builders', () => { return { dir, artifact: artifacts.at(-1)! } } + it.skipIf(process.platform === 'win32')( + 'answers a POSIX install complete only with every artifact present, in its own dir', + async () => { + const { dir, artifact } = stageOrcadInstall(nativePosix) + try { + const run = async (installDir: string): Promise => + (await runShellCommand(probeOrcadInstalledCommand(nativePosix, installDir))).trim() + expect(await run(dir)).toBe('OK') + // The relative test must not resolve against the caller's working directory. + expect(await run(join(dir, 'missing slot'))).toBe('MISSING') + rmSync(join(dir, artifact)) + expect(await run(dir)).toBe('MISSING') + } finally { + rmSync(dir, { recursive: true, force: true }) + } + } + ) + it.skipIf(!powerShellExecutable)('rejects a Windows install missing one artifact', async () => { const { dir, artifact } = stageOrcadInstall(windows) const probe = (): string => decodePowerShellCommand(probeOrcadInstalledCommand(windows, dir)) diff --git a/src/main/ssh/ssh-remote-commands.ts b/src/main/ssh/ssh-remote-commands.ts index 6ccff9b9eb3..012224cc69d 100644 --- a/src/main/ssh/ssh-remote-commands.ts +++ b/src/main/ssh/ssh-remote-commands.ts @@ -83,13 +83,17 @@ export function probeRemoteInstallCompleteCommand( remoteInstallDir: string, requiredFilenames: readonly string[] ): string { + if (!isWindowsRemoteHost(host)) { + // Why relative to the dir: repeating a long slot path per artifact pushed the probe past the + // exec-size budget gateways enforce (#30491). The subshell keeps the cd from leaking. + const fileTests = requiredFilenames + .map((filename) => `&& test -f ${quotePosixShell(filename)} `) + .join('') + return `(cd ${quotePosixShell(remoteInstallDir)} 2>/dev/null ${fileTests}) && echo OK || echo MISSING` + } const required = requiredFilenames.map((filename) => joinRemotePath(host, remoteInstallDir, filename) ) - if (!isWindowsRemoteHost(host)) { - const fileTests = required.map((path) => `&& test -f ${quotePosixShell(path)} `).join('') - return `test -d ${quotePosixShell(remoteInstallDir)} ${fileTests}&& echo OK || echo MISSING` - } return powerShellCommand( [ `$dir = ${powerShellLiteral(remoteInstallDir)}`, diff --git a/src/shared/orcad-artifacts.ts b/src/shared/orcad-artifacts.ts index 3db8242444a..63271c86b9d 100644 --- a/src/shared/orcad-artifacts.ts +++ b/src/shared/orcad-artifacts.ts @@ -246,6 +246,8 @@ export const ORCAD_ARTIFACTS: readonly OrcadArtifact[] = [ // Worker threads the account services start: CLI probes off the event loop, Claude account setup. { filename: 'worker-thread-child-process-entry.js' }, { filename: 'claude-profile-setup-worker-entry.js' }, + // Dictation's worker thread; it loads the recognizer `orca serve` names (#31174). + { filename: 'stt-worker.js' }, // Target-specific even when the JavaScript bundle is shared across packaged slots. { filename: ORCAD_SERVER_TARGET_FILENAME }, // orcad never depends on a host runtime or host-installed native module. diff --git a/src/shared/speech-recognizer-package.ts b/src/shared/speech-recognizer-package.ts new file mode 100644 index 00000000000..f9b039005fc --- /dev/null +++ b/src/shared/speech-recognizer-package.ts @@ -0,0 +1,14 @@ +/** + * The on-device speech recognizer (sherpa-onnx's N-API addon) by platform. Shared by the desktop's + * worker paths and `orca serve`, which hands orcad the copy the installed app ships, because orcad + * slots do not carry the ~70 MB recognizer themselves. + */ + +/** Set by `orca serve` for orcad: the directory of the app's recognizer package. */ +export const SPEECH_RECOGNIZER_DIR_ENV = 'ORCA_SPEECH_RECOGNIZER_DIR' + +export function speechRecognizerPackageName(platform: string, arch: string): string { + return platform === 'win32' && arch === 'x64' + ? 'sherpa-onnx-win-x64' + : `sherpa-onnx-${platform}-${arch}` +}