feat: 完全支持 mac Intel 环境

抽离 Intel 专用包、connector 与跨架构语音库
修改 publish 脚本,发布先落草稿
修复重装依赖后 Electron 二进制缺失
This commit is contained in:
Wxw-Gu
2026-09-15 14:12:00 +08:00
parent ed4c21a68f
commit f5158fd3e4
13 changed files with 575 additions and 34 deletions
+116 -4
View File
@@ -1,8 +1,9 @@
/* eslint-disable @typescript-eslint/no-require-imports, @typescript-eslint/explicit-function-return-type */
const { chmodSync, existsSync } = require('node:fs')
const { chmodSync, existsSync, readdirSync, rmSync } = require('node:fs')
const { execFileSync } = require('node:child_process')
const path = require('node:path')
const asar = require('@electron/asar')
const { readBinaryArchitectures } = require('./binary-arch.cjs')
const REQUIRED_RUNTIME_PACKAGES = [
'@electron-toolkit/preload',
@@ -82,6 +83,43 @@ function normalizeBuilderArch(arch) {
return { 0: 'ia32', 1: 'x64', 2: 'armv7l', 3: 'arm64', 4: 'universal' }[arch] || String(arch)
}
/**
* A foreign-architecture binary only fails once the user touches the feature
* that needs it, so verify the ones whose filename is shared across
* architectures (ffmpeg-static keeps a single "ffmpeg" per platform) and fail
* the build instead of shipping a broken bundle.
*/
function validateRuntimeBinaryArchitecture(filePath, platform, arch, label) {
if (platform !== 'darwin' && platform !== 'win32') return
if (arch === 'universal') return
const architectures = readBinaryArchitectures(filePath)
if (!architectures.length || architectures.includes(arch)) return
throw new Error(
`${label} is ${architectures.join('/')} but this bundle targets ${arch}: ${filePath}`
)
}
/**
* The Intel Mac key helper is an x86_64 executable that only the x64 (or
* universal) macOS bundle can run. Every other target — Apple Silicon macOS,
* Windows, Linux — would otherwise ship a ~34MB binary it can never execute,
* so it is dropped from those bundles. x64/universal builds fail fast instead
* of silently shipping an Intel Mac app that cannot read keys.
*/
function pruneIntelMacKeyTool(runtimeResources, platform, arch) {
const keyToolDirectory = path.join(runtimeResources, 'resources', 'macos-key-tool')
const usable = platform === 'darwin' && (arch === 'x64' || arch === 'universal')
if (!usable) {
rmSync(keyToolDirectory, { recursive: true, force: true })
return null
}
const helperPath = path.join(keyToolDirectory, 'intel_mac_key_helper')
if (!existsSync(helperPath)) {
throw new Error(`Missing Intel Mac key helper in a ${arch} bundle: ${helperPath}`)
}
return helperPath
}
function validateAsarRuntimeDependencies(runtimeResources) {
const asarPath = path.join(runtimeResources, 'app.asar')
if (!existsSync(asarPath)) throw new Error(`Missing packaged application archive: ${asarPath}`)
@@ -107,17 +145,87 @@ function validateReaderSkillRuntime(runtimeResources) {
return skillPath
}
/**
* Native runtime packages are published once per platform-arch pair, and pnpm
* installs all of them, so every bundle ends up carrying the native libraries
* of every platform (measured: ~129MB of speech models plus ~16MB of koffi).
* The loaders pick their package from process.platform/arch, so the siblings
* are dead weight — drop them.
*/
const NATIVE_RUNTIME_PACKAGES = [
{
modules: [],
prefix: 'sherpa-onnx',
platformName: (platform) => (platform === 'win32' ? 'win' : platform)
},
{
modules: ['@koromix'],
prefix: 'koffi',
platformName: (platform) => platform
}
]
function pruneForeignArchNativeRuntimes(runtimeResources, platform, arch) {
if (arch === 'universal') return []
const unpackedRoot = path.join(runtimeResources, 'app.asar.unpacked', 'node_modules')
if (!existsSync(unpackedRoot)) return []
const removed = []
for (const runtime of NATIVE_RUNTIME_PACKAGES) {
const modulesRoot = path.join(unpackedRoot, ...runtime.modules)
if (!existsSync(modulesRoot)) continue
const expected = `${runtime.prefix}-${runtime.platformName(platform)}-${arch}`
const foreign = new RegExp(`^${runtime.prefix}-[a-z0-9]+-(arm64|x64|ia32|loong64|riscv64)$`)
for (const entry of readdirSync(modulesRoot, { withFileTypes: true })) {
if (!entry.isDirectory() || entry.name === expected || !foreign.test(entry.name)) continue
rmSync(path.join(modulesRoot, entry.name), { recursive: true, force: true })
removed.push(runtime.modules.length ? `${runtime.modules.join('/')}/${entry.name}` : entry.name)
}
}
return removed
}
/**
* Bundled native directories under resources/connectors are named
* "<platform>-<arch>". Cross-building both macOS architectures leaves both on
* disk, but a bundle can only execute its own, so drop the foreign ones
* instead of shipping every connector twice.
*/
function pruneForeignArchConnectors(runtimeResources, platform, arch) {
if (arch === 'universal') return []
const connectorsRoot = path.join(runtimeResources, 'resources', 'connectors')
if (!existsSync(connectorsRoot)) return []
const expected = `${platform}-${arch}`
const removed = []
for (const packageEntry of readdirSync(connectorsRoot, { withFileTypes: true })) {
if (!packageEntry.isDirectory()) continue
const packageRoot = path.join(connectorsRoot, packageEntry.name)
for (const targetEntry of readdirSync(packageRoot, { withFileTypes: true })) {
if (!targetEntry.isDirectory() || targetEntry.name === expected) continue
if (!/^[a-z0-9]+-(arm64|x64|ia32)$/.test(targetEntry.name)) continue
rmSync(path.join(packageRoot, targetEntry.name), { recursive: true, force: true })
removed.push(`${packageEntry.name}/${targetEntry.name}`)
}
}
return removed
}
exports.default = async function afterPack(context) {
const runtimeResources = getRuntimeResources(context)
const arch = normalizeBuilderArch(context.arch)
validateAsarRuntimeDependencies(runtimeResources)
validateReaderSkillRuntime(runtimeResources)
validateSilkWasmRuntime(runtimeResources)
const ffmpegPath = validateFfmpegRuntime(runtimeResources, context.electronPlatformName)
validateSherpaRuntime(
runtimeResources,
validateRuntimeBinaryArchitecture(
ffmpegPath,
context.electronPlatformName,
normalizeBuilderArch(context.arch)
arch,
'Bundled ffmpeg'
)
validateSherpaRuntime(runtimeResources, context.electronPlatformName, arch)
pruneIntelMacKeyTool(runtimeResources, context.electronPlatformName, arch)
pruneForeignArchConnectors(runtimeResources, context.electronPlatformName, arch)
pruneForeignArchNativeRuntimes(runtimeResources, context.electronPlatformName, arch)
if (context.electronPlatformName === 'darwin') {
execFileSync('/usr/bin/codesign', ['--force', '--sign', '-', ffmpegPath], {
@@ -150,3 +258,7 @@ exports.validateReaderSkillRuntime = validateReaderSkillRuntime
exports.validateFfmpegRuntime = validateFfmpegRuntime
exports.validateSilkWasmRuntime = validateSilkWasmRuntime
exports.validateSherpaRuntime = validateSherpaRuntime
exports.pruneIntelMacKeyTool = pruneIntelMacKeyTool
exports.pruneForeignArchConnectors = pruneForeignArchConnectors
exports.pruneForeignArchNativeRuntimes = pruneForeignArchNativeRuntimes
exports.validateRuntimeBinaryArchitecture = validateRuntimeBinaryArchitecture
+83
View File
@@ -0,0 +1,83 @@
/* eslint-disable @typescript-eslint/no-require-imports */
const fs = require('node:fs')
const MACHO_MAGIC_32 = 0xfeedface
const MACHO_MAGIC_64 = 0xfeedfacf
const FAT_MAGIC = 0xcafebabe
const FAT_MAGIC_64 = 0xcafebabf
const PE_SIGNATURE = 0x00004550
const PE_MACHINE_X64 = 0x8664
const PE_MACHINE_ARM64 = 0xaa64
const CPU_TYPE_IA32 = 0x00000007
const CPU_TYPE_X86_64 = 0x01000007
const CPU_TYPE_ARM64 = 0x0100000c
/** Only the headers are needed; native binaries can be tens of megabytes. */
const HEADER_BYTES = 64 * 1024
function readHeader(filePath) {
const descriptor = fs.openSync(filePath, 'r')
try {
const buffer = Buffer.alloc(HEADER_BYTES)
const bytesRead = fs.readSync(descriptor, buffer, 0, HEADER_BYTES, 0)
return buffer.subarray(0, bytesRead)
} finally {
fs.closeSync(descriptor)
}
}
function normalizeMachoCpuType(cpuType) {
if (cpuType === CPU_TYPE_X86_64) return 'x64'
if (cpuType === CPU_TYPE_ARM64) return 'arm64'
if (cpuType === CPU_TYPE_IA32) return 'ia32'
return ''
}
/**
* Returns every architecture contained in a Mach-O or PE binary, as
* electron-builder arch names ("x64", "arm64"). Universal binaries report both.
* Returns an empty array for anything that is not a native executable (scripts,
* wasm), so callers can treat "unknown" separately from "wrong architecture".
*/
function readBinaryArchitectures(filePath) {
let buffer
try {
buffer = readHeader(filePath)
} catch {
return []
}
if (buffer.length < 8) return []
const fatMagic = buffer.readUInt32BE(0)
if (fatMagic === FAT_MAGIC || fatMagic === FAT_MAGIC_64) {
const entrySize = fatMagic === FAT_MAGIC_64 ? 32 : 20
const count = Math.min(buffer.readUInt32BE(4), 32)
const architectures = []
for (let index = 0; index < count; index += 1) {
const entryOffset = 8 + index * entrySize
if (entryOffset + 4 > buffer.length) break
const name = normalizeMachoCpuType(buffer.readUInt32BE(entryOffset))
if (name && !architectures.includes(name)) architectures.push(name)
}
return architectures
}
const thinMagic = buffer.readUInt32LE(0)
if (thinMagic === MACHO_MAGIC_32 || thinMagic === MACHO_MAGIC_64) {
const name = normalizeMachoCpuType(buffer.readUInt32LE(4))
return name ? [name] : []
}
if (buffer.readUInt16LE(0) === 0x5a4d) {
const peOffset = buffer.readUInt32LE(0x3c)
if (peOffset + 6 > buffer.length || buffer.readUInt32LE(peOffset) !== PE_SIGNATURE) return []
const machine = buffer.readUInt16LE(peOffset + 4)
if (machine === PE_MACHINE_X64) return ['x64']
if (machine === PE_MACHINE_ARM64) return ['arm64']
}
return []
}
module.exports = { readBinaryArchitectures }
+103
View File
@@ -0,0 +1,103 @@
/* eslint-disable @typescript-eslint/no-require-imports */
const fs = require('node:fs')
const { execFileSync } = require('node:child_process')
const path = require('node:path')
/**
* electron@43 的 npm 包不再声明 postinstall(它自己的 package.json 里 scripts 是空的),
* 下载被改成「首次 require('electron') 时的懒加载」。后果是 `pnpm install` 之后
* node_modules/electron 里只有一个空壳:dist/ 与 path.txt 都不在,而
* package.json 里的 pnpm.onlyBuiltDependencies: ["electron"] 对着空气发号施令 ——
* 上游没有脚本可跑,pnpm 自然什么也不做。新 clone 于是直到 `pnpm dev` 才炸。
* 这里显式补上下载,并把它挂在 postinstall / predev 上。
*/
const MIRROR_FALLBACK = 'https://npmmirror.com/mirrors/electron/'
const projectRoot = path.resolve(__dirname, '..')
function resolveElectronPackageRoot() {
try {
return path.dirname(require.resolve('electron/package.json'))
} catch {
return ''
}
}
/**
* .npmrc 的 electron_mirror 只有在 npm/pnpm 执行脚本时才会变成 npm_config_* 环境变量;
* 直接 `node node_modules/electron/install.js` 时读不到,于是会绕开镜像去打 GitHub。
* 这里显式读出来当 ELECTRON_MIRROR 传下去。显式设置的环境变量优先。
*/
function resolveMirror() {
if (process.env.ELECTRON_MIRROR) return process.env.ELECTRON_MIRROR
try {
const npmrc = fs.readFileSync(path.join(projectRoot, '.npmrc'), 'utf8')
const match = npmrc.match(/^\s*electron_mirror\s*=\s*(\S+)\s*$/m)
return match ? match[1] : ''
} catch {
return ''
}
}
/**
* path.txt 只是 electron 写下的相对路径,光有它不算装好 —— 指向的可执行文件
* 必须真的存在,否则仍会在启动时报「Electron failed to install correctly」。
*/
function isElectronBinaryInstalled(packageRoot) {
if (!packageRoot) return false
try {
const executable = fs.readFileSync(path.join(packageRoot, 'path.txt'), 'utf8').trim()
return executable !== '' && fs.existsSync(path.join(packageRoot, 'dist', executable))
} catch {
return false
}
}
function runInstaller(packageRoot) {
const installer = path.join(packageRoot, 'install.js')
if (!fs.existsSync(installer)) {
throw new Error(`[ensure-electron] missing ${installer}; run pnpm install first`)
}
const mirror = resolveMirror()
const attempts = mirror
? [{ label: mirror, env: { ELECTRON_MIRROR: mirror } }]
: [{ label: 'default source', env: {} }]
// 配的镜像本身不是 npmmirror 时,再兜一层:镜像挂掉时不至于完全没退路。
if (!mirror.includes('npmmirror.com')) {
attempts.push({ label: MIRROR_FALLBACK, env: { ELECTRON_MIRROR: MIRROR_FALLBACK } })
}
let lastError
for (let index = 0; index < attempts.length; index += 1) {
const attempt = attempts[index]
console.log(`[ensure-electron] downloading from ${attempt.label}`)
try {
execFileSync(process.execPath, [installer], {
stdio: 'inherit',
env: { ...process.env, ...attempt.env }
})
return
} catch (error) {
lastError = error
const next = attempts[index + 1]
if (next) {
console.warn(`[ensure-electron] download failed, retrying from ${next.label}`)
}
}
}
throw lastError
}
function ensureElectronBinary() {
const packageRoot = resolveElectronPackageRoot()
if (isElectronBinaryInstalled(packageRoot)) return false
console.log('[ensure-electron] Electron binary is missing; downloading it now')
runInstaller(packageRoot)
if (!isElectronBinaryInstalled(packageRoot)) {
throw new Error('[ensure-electron] Electron binary is still missing after installing')
}
console.log('[ensure-electron] Electron binary is ready')
return true
}
if (require.main === module) ensureElectronBinary()
module.exports = { ensureElectronBinary, isElectronBinaryInstalled, resolveElectronPackageRoot }
+32 -3
View File
@@ -1,6 +1,7 @@
const fs = require('node:fs')
const { execFileSync } = require('node:child_process')
const path = require('node:path')
const { readBinaryArchitectures } = require('./binary-arch.cjs')
const runtimeNames = ['msvcp140.dll', 'msvcp140_1.dll', 'vcruntime140.dll', 'vcruntime140_1.dll']
@@ -24,6 +25,31 @@ function readOption(name, fallback) {
return index >= 0 && process.argv[index + 1] ? process.argv[index + 1] : fallback
}
function ffmpegExecutableName(targetPlatform) {
return targetPlatform === 'win32' ? 'ffmpeg.exe' : 'ffmpeg'
}
/**
* ffmpeg-static keeps a single binary per platform ("ffmpeg" everywhere except
* Windows), so an arm64 and an x64 macOS checkout cannot coexist in
* node_modules. Its installer also exits early whenever the file already
* exists, so a binary left over from the other architecture would be packed
* silently. Check the real architecture and drop the file when it differs, so
* the caller re-downloads the requested one.
*/
function ensureFfmpegArchitecture(ffmpegPath, targetPlatform, targetArch) {
if (!fs.existsSync(ffmpegPath)) return 'missing'
const architectures = readBinaryArchitectures(ffmpegPath)
if (architectures.includes(targetArch)) return 'match'
console.log(
`[prepare-electron-runtime] ffmpeg-static is ${
architectures.join('/') || 'not a native binary'
} but ${targetPlatform}-${targetArch} was requested; replacing it`
)
fs.rmSync(ffmpegPath, { force: true })
return 'replaced'
}
function prepareFfmpegRuntime(targetPlatform = process.platform, targetArch = process.arch) {
let packageRoot = ''
try {
@@ -31,10 +57,11 @@ function prepareFfmpegRuntime(targetPlatform = process.platform, targetArch = pr
} catch {
return
}
const executable = targetPlatform === 'win32' ? 'ffmpeg.exe' : 'ffmpeg'
const executable = ffmpegExecutableName(targetPlatform)
const ffmpegPath = path.join(packageRoot, executable)
const architectureState = ensureFfmpegArchitecture(ffmpegPath, targetPlatform, targetArch)
if (!fs.existsSync(ffmpegPath)) {
if (architectureState !== 'match') {
const installScript = path.join(packageRoot, 'install.js')
console.log(
`[prepare-electron-runtime] downloading ffmpeg-static for ${targetPlatform}-${targetArch}`
@@ -83,4 +110,6 @@ function main() {
}
}
main()
if (require.main === module) main()
module.exports = { ensureFfmpegArchitecture, ffmpegExecutableName, prepareFfmpegRuntime }