onnxruntime (wake word) and ctranslate2 (local speech-to-text) link against msvcp140.dll and msvcp140_1.dll, which the managed CPython does not ship. On Windows without the system VC++ Redistributable both imports failed with "DLL load failed: The specified module could not be found", so "Hey Hermes" and voice chat transcription never worked. - Bundle the Microsoft-signed VC143 CRT set (14.44.35211, matching the managed CPython's vcruntime140) as a Windows extra resource with a SHA-256 manifest. - Before every local backend start, copy missing or mismatching CRT files beside the managed python.exe (the process image directory, searched first), verifying digests; never overwrite the interpreter's own vcruntime and never block boot. Existing installs heal on their next launch. - Report an unloadable native voice wheel as a VC++ runtime problem in the wake-word requirements, local transcription and `hermes doctor`, instead of treating installed package metadata as proof the wheel loads. - Bump AITURK IDE to 1.0.0-beta.12. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
169 lines
5.1 KiB
TypeScript
169 lines
5.1 KiB
TypeScript
import crypto from 'node:crypto'
|
|
import fs from 'node:fs'
|
|
import path from 'node:path'
|
|
|
|
// ── Windows VC++ runtime for the managed Python ─────────────────────────────
|
|
//
|
|
// Voice features load native wheels (onnxruntime for the wake word,
|
|
// ctranslate2 for local speech-to-text) that link against msvcp140.dll and
|
|
// msvcp140_1.dll. The managed CPython only ships vcruntime140*.dll, so on a
|
|
// Windows install without the system-wide VC++ Redistributable those imports
|
|
// fail with "DLL load failed / The specified module could not be found".
|
|
//
|
|
// The desktop package carries the Microsoft-signed VC143 CRT set and copies it
|
|
// next to the managed python.exe before every local backend start. That
|
|
// directory is the process image directory, which Windows searches first for
|
|
// extension-module dependencies, so a stale msvcp140.dll elsewhere on PATH can
|
|
// never win. Existing installs are healed on their next launch.
|
|
|
|
export interface VcRuntimeFile {
|
|
name: string
|
|
size: number
|
|
sha256: string
|
|
// Files the managed CPython already ships. Never overwrite an existing copy:
|
|
// the interpreter owns it and replacing a loaded DLL is not possible anyway.
|
|
ownedByPython?: boolean
|
|
}
|
|
|
|
export interface VcRuntimeManifest {
|
|
schemaVersion: 1
|
|
version: string
|
|
architecture: string
|
|
files: VcRuntimeFile[]
|
|
}
|
|
|
|
export type VcRuntimeResult =
|
|
| { status: 'skipped'; reason: string }
|
|
| { status: 'ok' | 'repaired'; pythonHome: string; copied: string[] }
|
|
| { status: 'failed'; pythonHome: string | null; copied: string[]; error: string }
|
|
|
|
const FILE_NAME_RE = /^[a-z0-9_]+\.dll$/i
|
|
const SHA256_RE = /^[0-9a-f]{64}$/
|
|
|
|
export function readPythonHome(venvRoot: string): string | null {
|
|
let text: string
|
|
|
|
try {
|
|
text = fs.readFileSync(path.join(venvRoot, 'pyvenv.cfg'), 'utf8')
|
|
} catch {
|
|
return null
|
|
}
|
|
|
|
for (const line of text.split(/\r?\n/)) {
|
|
const match = /^\s*home\s*=\s*(.+?)\s*$/i.exec(line)
|
|
|
|
if (match) {
|
|
return match[1]
|
|
}
|
|
}
|
|
|
|
return null
|
|
}
|
|
|
|
export function loadVcRuntimeManifest(sourceDir: string): VcRuntimeManifest {
|
|
const manifest = JSON.parse(fs.readFileSync(path.join(sourceDir, 'manifest.json'), 'utf8'))
|
|
|
|
if (manifest?.schemaVersion !== 1 || !Array.isArray(manifest.files) || manifest.files.length === 0) {
|
|
throw new Error('invalid VC++ runtime manifest')
|
|
}
|
|
|
|
for (const file of manifest.files) {
|
|
if (!FILE_NAME_RE.test(file?.name) || !SHA256_RE.test(file?.sha256) || !Number.isInteger(file?.size)) {
|
|
throw new Error(`invalid VC++ runtime manifest entry: ${JSON.stringify(file?.name)}`)
|
|
}
|
|
}
|
|
|
|
return manifest
|
|
}
|
|
|
|
function sha256File(filePath: string): string {
|
|
return crypto.createHash('sha256').update(fs.readFileSync(filePath)).digest('hex')
|
|
}
|
|
|
|
function matches(filePath: string, file: VcRuntimeFile): boolean {
|
|
try {
|
|
return fs.statSync(filePath).size === file.size && sha256File(filePath) === file.sha256
|
|
} catch {
|
|
return false
|
|
}
|
|
}
|
|
|
|
function installFile(sourceDir: string, pythonHome: string, file: VcRuntimeFile) {
|
|
const source = path.join(sourceDir, file.name)
|
|
|
|
if (!matches(source, file)) {
|
|
throw new Error(`bundled ${file.name} does not match its manifest digest`)
|
|
}
|
|
|
|
const target = path.join(pythonHome, file.name)
|
|
const temp = `${target}.aiturk-${process.pid}.tmp`
|
|
|
|
try {
|
|
fs.copyFileSync(source, temp)
|
|
|
|
if (!matches(temp, file)) {
|
|
throw new Error(`copied ${file.name} failed digest verification`)
|
|
}
|
|
|
|
fs.renameSync(temp, target)
|
|
} finally {
|
|
fs.rmSync(temp, { force: true })
|
|
}
|
|
}
|
|
|
|
export function ensureWindowsVcRuntime({
|
|
platform,
|
|
venvRoot,
|
|
sourceDir
|
|
}: {
|
|
platform: NodeJS.Platform
|
|
venvRoot: string
|
|
sourceDir: string
|
|
}): VcRuntimeResult {
|
|
if (platform !== 'win32') {
|
|
return { status: 'skipped', reason: 'not windows' }
|
|
}
|
|
|
|
const pythonHome = readPythonHome(venvRoot)
|
|
|
|
if (!pythonHome || !fs.existsSync(path.join(pythonHome, 'python.exe'))) {
|
|
return { status: 'failed', pythonHome, copied: [], error: 'managed python home not found' }
|
|
}
|
|
|
|
const copied: string[] = []
|
|
|
|
try {
|
|
const manifest = loadVcRuntimeManifest(sourceDir)
|
|
|
|
for (const file of manifest.files) {
|
|
const target = path.join(pythonHome, file.name)
|
|
|
|
if (file.ownedByPython ? fs.existsSync(target) : matches(target, file)) {
|
|
continue
|
|
}
|
|
|
|
installFile(sourceDir, pythonHome, file)
|
|
copied.push(file.name)
|
|
}
|
|
} catch (error) {
|
|
return { status: 'failed', pythonHome, copied, error: error instanceof Error ? error.message : String(error) }
|
|
}
|
|
|
|
return { status: copied.length ? 'repaired' : 'ok', pythonHome, copied }
|
|
}
|
|
|
|
export function describeVcRuntimeResult(result: VcRuntimeResult): string {
|
|
if (result.status === 'skipped') {
|
|
return `skipped (${result.reason})`
|
|
}
|
|
|
|
if (result.status === 'failed') {
|
|
return `failed for ${result.pythonHome ?? 'unknown python home'}: ${result.error}` +
|
|
(result.copied.length ? ` (copied before failure: ${result.copied.join(', ')})` : '')
|
|
}
|
|
|
|
return result.status === 'repaired'
|
|
? `installed ${result.copied.join(', ')} into ${result.pythonHome}`
|
|
: `verified in ${result.pythonHome}`
|
|
}
|