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
This commit is contained in:
Neil
2026-10-20 19:54:55 -07:00
committed by GitHub
parent d98b8eddb4
commit fece03b5ac
48 changed files with 825 additions and 161 deletions
+2 -1
View File
@@ -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. */
@@ -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({
@@ -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<MobileSpee
if (isLegacyDesktopSpeechSetupReply(reply)) {
throw new Error(LEGACY_DESKTOP_SPEECH_SETUP_MESSAGE)
}
if (isSpeechlessServerReply(reply)) {
throw new Error(SPEECHLESS_SERVER_MESSAGE)
}
return interpretOrThrowRefusalMessage(
() => dictationSetupRead.interpret(reply),
'Failed to load dictation models'
@@ -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]
)
+1 -1
View File
@@ -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'
+1 -1
View File
@@ -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'
+7 -1
View File
@@ -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'), '<html></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')
+2 -1
View File
@@ -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()
+10 -46
View File
@@ -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<SpawnOptions, 'shell'> {
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
}
+45
View File
@@ -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
}
@@ -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'],
@@ -12,7 +12,7 @@ import {
resolveAppRoot,
resolveForegroundOrcaExecutable,
stripElectronRunAsNode
} from './launch'
} from './orca-app-executable'
import { RuntimeClientError } from './types'
import { canStartOrcaServer } from './serve-app-launcher'
+7 -2
View File
@@ -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<number> {
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)
@@ -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()
})
})
@@ -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'))
)
}
+36 -1
View File
@@ -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)
}
@@ -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')
@@ -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)
})
@@ -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<Record<string, number>> = {
tryStealInstallLockCommand: 9_022,
orcadLaunchCommand: 7_130,
probeRemoteInstallCompleteCommand: 12_189
orcadLaunchCommand: 7_130
}
type HostCase = { host: RemoteHostPlatform; target: NodeRuntimeTarget; nodePath: string }
@@ -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)
})
})
+18 -1
View File
@@ -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))
)
}
+3
View File
@@ -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)
}
@@ -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<VoiceSettings>) {
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<typeof settings>) => {
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)
})
})
+16
View File
@@ -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)
}
@@ -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)
@@ -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}`)
@@ -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)
@@ -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')
@@ -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<string, string>
@@ -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',
@@ -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)
@@ -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<void>
}
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',
@@ -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'
+17 -8
View File
@@ -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<void>
}
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',
+11 -8
View File
@@ -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<string, SpeechModelState>()
private progressCallbacks = new Set<ProgressCallback>()
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)
@@ -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<string, string>()
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<string, string> = {}) {
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()
})
})
@@ -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()
}
}
}
@@ -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()
+25 -1
View File
@@ -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 {
@@ -1,8 +1,25 @@
export type DownloadIncomingMessage = Electron.IncomingMessage &
NodeJS.ReadableStream & {
headers: Record<string, string | string[] | undefined>
destroy?: () => void
}
/** The response half both Electron's `net` and Node's `https` provide. */
export type DownloadIncomingMessage = NodeJS.ReadableStream & {
statusCode?: number
headers: Record<string, string | string[] | undefined>
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
+16 -13
View File
@@ -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<typeof setTimeout> | 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 })
}
+3 -8
View File
@@ -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 = {
+10 -4
View File
@@ -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
+46
View File
@@ -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'))
})
})
+36 -14
View File
@@ -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
}
+19 -1
View File
@@ -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<string> =>
(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))
+8 -4
View File
@@ -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)}`,
+2
View File
@@ -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.
+14
View File
@@ -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}`
}