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:
parent
bdb6d144c3
commit
0d2d44b989
|
|
@ -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 =>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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 })
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue