Use ASCII-safe speech model cache paths on Windows (#7291)

sherpa-onnx cannot load model files or hotword lists from Windows
paths containing non-ASCII characters.

- Resolve speech model and hotwords paths to ASCII-safe alternatives
  like ProgramData when the default userData path contains non-ASCII
- Migrate existing downloaded models from the legacy non-ASCII cache
- Ensure deleted models are removed from the legacy source folder to
  prevent them from being re-migrated on subsequent launches
This commit is contained in:
Jinjing 2026-07-03 19:43:38 -07:00 committed by GitHub
parent bdb6d144c3
commit 0d2d44b989
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 310 additions and 6 deletions

View File

@ -1,4 +1,4 @@
import { ipcMain, BrowserWindow, systemPreferences, app } from 'electron'
import { ipcMain, BrowserWindow, systemPreferences } from 'electron'
import { join } from 'node:path'
import { writeFile, unlink } from 'node:fs/promises'
import { createHash } from 'node:crypto'
@ -80,7 +80,9 @@ export function registerSpeechHandlers(store: Store): void {
const getHotwordsFilePath = (content: string): string => {
const digest = createHash('sha256').update(content).digest('hex').slice(0, 12)
return join(app.getPath('userData'), `speech-hotwords-${digest}.txt`)
// Why: sherpa-onnx cannot read non-ASCII Windows paths, so co-locate the
// hotwords file with the ASCII-safe model cache instead of userData.
return join(getSpeechModelManager(store).getModelsDir(), `speech-hotwords-${digest}.txt`)
}
const getDesktopOwner = (senderId: number, sessionId: string): string =>

View File

@ -0,0 +1,104 @@
import { createHash } from 'node:crypto'
import { existsSync } from 'node:fs'
import { cp, mkdir, readdir, rename, stat } from 'node:fs/promises'
import { join, resolve } from 'node:path'
export type SpeechModelCacheDir = {
modelsDir: string
migrationSourceDir: string | null
}
const WINDOWS_SAFE_CACHE_HASH_LENGTH = 16
function hasNonAsciiCharacters(value: string): boolean {
for (const character of value) {
if (character.charCodeAt(0) > 0x7f) {
return true
}
}
return false
}
function getWindowsAsciiSharedDataRoots(): string[] {
const publicDir = process.env.PUBLIC
const systemDriveProgramData = process.env.SystemDrive
? `${process.env.SystemDrive}\\ProgramData`
: undefined
const candidates = [
process.env.PROGRAMDATA,
process.env.ProgramData,
process.env.ALLUSERSPROFILE,
process.env.PUBLIC ? join(process.env.PUBLIC, 'Documents') : undefined,
publicDir,
systemDriveProgramData,
'C:\\ProgramData'
]
const roots: string[] = []
for (const candidate of candidates) {
if (!candidate || hasNonAsciiCharacters(candidate) || roots.includes(candidate)) {
continue
}
roots.push(candidate)
}
return roots
}
export function getSpeechModelCacheDirCandidates(
requestedModelsDir: string
): SpeechModelCacheDir[] {
if (process.platform !== 'win32' || !hasNonAsciiCharacters(requestedModelsDir)) {
return [{ modelsDir: requestedModelsDir, migrationSourceDir: null }]
}
const requestedModelsDirHash = createHash('sha256')
.update(resolve(requestedModelsDir))
.digest('hex')
.slice(0, WINDOWS_SAFE_CACHE_HASH_LENGTH)
const candidates = getWindowsAsciiSharedDataRoots()
.map((root) => join(root, 'Orca', 'speech-models', requestedModelsDirHash))
.filter((modelsDir) => !hasNonAsciiCharacters(modelsDir))
.map((modelsDir) => ({ modelsDir, migrationSourceDir: requestedModelsDir }))
// Why: sherpa-onnx 1.12.x cannot load model files from non-ASCII Windows
// paths. Try ASCII shared caches first, but keep the requested path as a
// last fallback so cache setup failures do not prevent the app from opening.
return [...candidates, { modelsDir: requestedModelsDir, migrationSourceDir: null }]
}
async function copyMissingCacheEntry(sourcePath: string, targetPath: string): Promise<void> {
const sourceStat = await stat(sourcePath)
if (sourceStat.isDirectory()) {
await mkdir(targetPath, { recursive: true })
for (const entry of await readdir(sourcePath, { withFileTypes: true })) {
await copyMissingCacheEntry(join(sourcePath, entry.name), join(targetPath, entry.name))
}
return
}
if (existsSync(targetPath)) {
return
}
// Why: copy to a temp path and atomically rename so an interrupted migration
// never leaves a truncated model file that passes existence-only validation.
const tempPath = `${targetPath}.partial`
await cp(sourcePath, tempPath, { force: true })
await rename(tempPath, targetPath)
}
export async function migrateSpeechModelCacheIfNeeded(
sourceDir: string | null,
targetDir: string
): Promise<void> {
if (!sourceDir || resolve(sourceDir) === resolve(targetDir) || !existsSync(sourceDir)) {
return
}
try {
for (const entry of await readdir(sourceDir, { withFileTypes: true })) {
await copyMissingCacheEntry(join(sourceDir, entry.name), join(targetDir, entry.name))
}
} catch (error) {
console.warn('[speech] Failed to migrate speech model cache to ASCII path:', error)
}
}

View File

@ -0,0 +1,151 @@
import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
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'
const { appGetPathMock, netRequestMock } = vi.hoisted(() => ({
appGetPathMock: vi.fn(),
netRequestMock: vi.fn()
}))
vi.mock('electron', () => ({
app: {
getPath: appGetPathMock
},
net: {
request: netRequestMock
}
}))
const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform')
const originalProgramData = process.env.PROGRAMDATA
function setPlatform(platform: NodeJS.Platform): void {
Object.defineProperty(process, 'platform', { configurable: true, value: platform })
}
function restoreEnvironment(): void {
if (originalPlatform) {
Object.defineProperty(process, 'platform', originalPlatform)
}
if (originalProgramData === undefined) {
delete process.env.PROGRAMDATA
} else {
process.env.PROGRAMDATA = originalProgramData
}
}
function isAsciiPath(value: string): boolean {
return [...value].every((character) => character.charCodeAt(0) <= 0x7f)
}
describe('ModelManager Windows model path handling', () => {
beforeEach(() => {
appGetPathMock.mockReset()
netRequestMock.mockReset()
appGetPathMock.mockImplementation(() => join(tmpdir(), 'orca-speech-models-test'))
})
afterEach(() => {
restoreEnvironment()
})
it('uses an ASCII cache path when the Windows default user data path has non-ASCII characters', () => {
const dir = mkdtempSync(join(tmpdir(), 'orca-model-manager-'))
try {
setPlatform('win32')
const programDataDir = join(dir, 'ProgramData')
const userDataDir = join(dir, '用户', 'Orca')
process.env.PROGRAMDATA = programDataDir
appGetPathMock.mockImplementation((name: string) =>
name === 'userData' ? userDataDir : join(dir, name)
)
const manager = new ModelManager()
expect(manager.getModelsDir()).not.toContain(userDataDir)
expect(manager.getModelsDir()).toContain(join(programDataDir, 'Orca', 'speech-models'))
expect(isAsciiPath(manager.getModelsDir())).toBe(true)
expect(existsSync(manager.getModelsDir())).toBe(true)
} finally {
rmSync(dir, { recursive: true, force: true })
}
})
it('migrates existing ready model files from a non-ASCII Windows default cache', async () => {
const dir = mkdtempSync(join(tmpdir(), 'orca-model-manager-'))
try {
setPlatform('win32')
const programDataDir = join(dir, 'ProgramData')
const userDataDir = join(dir, '用户', 'Orca')
process.env.PROGRAMDATA = programDataDir
appGetPathMock.mockImplementation((name: string) =>
name === 'userData' ? userDataDir : join(dir, name)
)
const manifest = SPEECH_MODEL_CATALOG.find((model) => model.provider === 'local')
expect(manifest?.files).toBeDefined()
const legacyModelDir = join(userDataDir, 'speech-models', manifest!.id)
for (const file of manifest!.files ?? []) {
const filePath = join(legacyModelDir, file)
mkdirSync(dirname(filePath), { recursive: true })
writeFileSync(filePath, 'model file')
}
const manager = new ModelManager()
const migratedModelDir = manager.getModelDir(manifest!.id)
expect(migratedModelDir).not.toContain(userDataDir)
// Migration runs asynchronously; getModelState awaits it before reading files.
await expect(manager.getModelState(manifest!.id)).resolves.toEqual({
id: manifest!.id,
status: 'ready'
})
for (const file of manifest!.files ?? []) {
expect(existsSync(join(migratedModelDir, file))).toBe(true)
}
} finally {
rmSync(dir, { recursive: true, force: true })
}
})
it('deleting a migrated model removes the legacy copy so it is not resurrected on next launch', async () => {
const dir = mkdtempSync(join(tmpdir(), 'orca-model-manager-'))
try {
setPlatform('win32')
const programDataDir = join(dir, 'ProgramData')
const userDataDir = join(dir, '用户', 'Orca')
process.env.PROGRAMDATA = programDataDir
appGetPathMock.mockImplementation((name: string) =>
name === 'userData' ? userDataDir : join(dir, name)
)
const manifest = SPEECH_MODEL_CATALOG.find((model) => model.provider === 'local')
const legacyModelDir = join(userDataDir, 'speech-models', manifest!.id)
for (const file of manifest!.files ?? []) {
const filePath = join(legacyModelDir, file)
mkdirSync(dirname(filePath), { recursive: true })
writeFileSync(filePath, 'model file')
}
const manager = new ModelManager()
await expect(manager.getModelState(manifest!.id)).resolves.toEqual({
id: manifest!.id,
status: 'ready'
})
await manager.deleteModel(manifest!.id)
// The legacy source copy must be gone so migration cannot re-seed it.
expect(existsSync(legacyModelDir)).toBe(false)
// Simulate an app restart: a fresh manager migrates from the same source.
const restarted = new ModelManager()
await expect(restarted.getModelState(manifest!.id)).resolves.toEqual({
id: manifest!.id,
status: 'not-downloaded'
})
} finally {
rmSync(dir, { recursive: true, force: true })
}
})
})

View File

@ -14,6 +14,11 @@ import type {
import { SPEECH_MODEL_CATALOG, getCatalogModel, isLocalSpeechModel } from './model-catalog'
import { hasOpenAiSpeechApiKey } from './openai-api-key-store'
import { resolveTarExecutable } from './tar-executable'
import {
getSpeechModelCacheDirCandidates,
migrateSpeechModelCacheIfNeeded,
type SpeechModelCacheDir
} from './model-cache-path'
type DownloadHandle = {
abort: () => void
@ -31,13 +36,23 @@ const DOWNLOAD_IDLE_TIMEOUT_MS = 120_000
export class ModelManager {
private modelsDir: string
private migrationSourceDir: string | null
private migrationReady: Promise<void>
private activeDownloads = new Map<string, DownloadHandle>()
private modelStates = new Map<string, SpeechModelState>()
private progressCallbacks = new Set<ProgressCallback>()
constructor(customModelsDir?: string) {
this.modelsDir = customModelsDir || join(app.getPath('userData'), 'speech-models')
mkdirSync(this.modelsDir, { recursive: true })
const requestedModelsDir = customModelsDir || join(app.getPath('userData'), 'speech-models')
const prepared = this.prepareModelsDir(requestedModelsDir)
this.modelsDir = prepared.modelsDir
this.migrationSourceDir = prepared.migrationSourceDir
// Why: migrating a non-ASCII cache copies large model files; run it off the
// main thread and gate model-state reads on it so the UI stays responsive.
this.migrationReady = migrateSpeechModelCacheIfNeeded(
prepared.migrationSourceDir,
prepared.modelsDir
)
}
setProgressCallback(cb: ProgressCallback): () => void {
@ -53,6 +68,23 @@ export class ModelManager {
return this.modelsDir
}
private prepareModelsDir(requestedModelsDir: string): SpeechModelCacheDir {
let lastError: unknown = null
for (const candidate of getSpeechModelCacheDirCandidates(requestedModelsDir)) {
try {
mkdirSync(candidate.modelsDir, { recursive: true })
return candidate
} catch (error) {
lastError = error
if (candidate.migrationSourceDir) {
console.warn('[speech] Failed to prepare ASCII speech model cache:', error)
}
}
}
throw lastError instanceof Error ? lastError : new Error(String(lastError))
}
async getModelStates(): Promise<SpeechModelState[]> {
const states: SpeechModelState[] = []
for (const manifest of SPEECH_MODEL_CATALOG) {
@ -63,6 +95,7 @@ export class ModelManager {
}
async getModelState(modelId: string): Promise<SpeechModelState> {
await this.migrationReady
const cached = this.modelStates.get(modelId)
if (cached && (cached.status === 'downloading' || cached.status === 'extracting')) {
return cached
@ -94,12 +127,12 @@ export class ModelManager {
return this.getSafeModelDir(modelId)
}
private getSafeModelDir(modelId: string): string {
private getSafeModelDir(modelId: string, root: string = this.modelsDir): string {
const manifest = getCatalogModel(modelId)
if (!manifest) {
throw new Error(`Unknown model: ${modelId}`)
}
const modelsRoot = resolve(this.modelsDir)
const modelsRoot = resolve(root)
const modelDir = resolve(modelsRoot, modelId)
const rel = relative(modelsRoot, modelDir)
if (rel.startsWith('..') || rel === '' || rel.includes('..') || resolve(rel) === rel) {
@ -116,6 +149,10 @@ export class ModelManager {
}
async downloadModel(modelId: string): Promise<void> {
// Why: no migration await here — migration only copies dirs already present
// in the old cache (surfaced as ready via getModelState before download is
// offered), so it never races a download, and awaiting would defer the
// synchronous request setup that cancelDownload relies on.
if (this.activeDownloads.has(modelId)) {
return
}
@ -231,6 +268,7 @@ export class ModelManager {
}
async deleteModel(modelId: string): Promise<void> {
await this.migrationReady
if (!getCatalogModel(modelId)) {
throw new Error(`Unknown model: ${modelId}`)
}
@ -243,6 +281,15 @@ export class ModelManager {
if (existsSync(modelDir)) {
await rm(modelDir, { recursive: true, force: true })
}
// Why: also delete the pre-migration copy (awaited above, so the copy has
// finished) — otherwise the next launch re-migrates it and resurrects the
// model the user just deleted.
if (this.migrationSourceDir) {
const sourceModelDir = this.getSafeModelDir(modelId, this.migrationSourceDir)
if (existsSync(sourceModelDir)) {
await rm(sourceModelDir, { recursive: true, force: true })
}
}
this.modelStates.delete(modelId)
}