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}` +}