/* eslint-disable @typescript-eslint/no-require-imports, @typescript-eslint/explicit-function-return-type */ 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', '@electron-toolkit/utils', 'archiver', 'electron-updater', 'ffmpeg-static', 'fs-extra', 'jsonrepair', 'koffi' ] // electron-builder 26 skips macOS signing entirely when no Developer ID // identity is configured, so an unpacked bundle can ship without a usable // signature. macOS kills a helper whose code or signature is missing or // modified even when SIP is disabled, which is what customers hit on newer // macOS releases. Ad-hoc re-sign the runtime helpers and the outer bundle so // every Mach-O verifies strictly; spctl still rejects ad-hoc code, which is // acceptable for the SIP-disabled customer workflow. const MACOS_HELPER_NAMES = ['xkey_helper', 'xkey_helper_4_1_13'] function getRuntimeResources(context) { const productName = context.packager.appInfo.productFilename return context.electronPlatformName === 'darwin' ? path.join(context.appOutDir, `${productName}.app`, 'Contents', 'Resources') : path.join(context.appOutDir, 'resources') } function validateSilkWasmRuntime(runtimeResources) { const packagePath = path.join(runtimeResources, 'app.asar.unpacked', 'node_modules', 'silk-wasm') const requiredFiles = [ path.join(packagePath, 'package.json'), path.join(packagePath, 'lib', 'index.cjs'), path.join(packagePath, 'lib', 'silk.wasm') ] const missingFiles = requiredFiles.filter((filePath) => !existsSync(filePath)) if (missingFiles.length > 0) { throw new Error(`Missing unpacked silk-wasm runtime: ${missingFiles.join(', ')}`) } } function validateFfmpegRuntime(runtimeResources, platform = process.platform) { const executable = platform === 'win32' ? 'ffmpeg.exe' : 'ffmpeg' const ffmpegPath = path.join( runtimeResources, 'app.asar.unpacked', 'node_modules', 'ffmpeg-static', executable ) if (!existsSync(ffmpegPath)) { throw new Error(`Missing unpacked ffmpeg-static runtime: ${ffmpegPath}`) } if (platform !== 'win32') chmodSync(ffmpegPath, 0o755) return ffmpegPath } function validateSherpaRuntime(runtimeResources, platform, arch) { const platformName = platform === 'win32' ? 'win' : platform const basePath = path.join( runtimeResources, 'app.asar.unpacked', 'node_modules', 'sherpa-onnx-node' ) const nativePath = path.join( runtimeResources, 'app.asar.unpacked', 'node_modules', `sherpa-onnx-${platformName}-${arch}` ) const requiredFiles = [ path.join(basePath, 'package.json'), path.join(basePath, 'sherpa-onnx.js'), path.join(nativePath, 'package.json'), path.join(nativePath, 'sherpa-onnx.node') ] const missingFiles = requiredFiles.filter((filePath) => !existsSync(filePath)) if (missingFiles.length > 0) { throw new Error(`Missing unpacked sherpa-onnx runtime: ${missingFiles.join(', ')}`) } } /** * System OCR 用 native package(@napi-rs/system-ocr)。它是 external + asarUnpack, * 打包后必须以 unpacked 形式存在,否则运行时会 MODULE_NOT_FOUND / native binding missing。 * Windows 与 macOS 都是 supported target,都要做硬校验(Linux 不是)。 */ function systemOcrTarget(platform, arch) { return platform === 'win32' ? `${platform}-${arch}-msvc` : `${platform}-${arch}` } function validateSystemOcrRuntime(runtimeResources, platform, arch) { if (platform !== 'win32' && platform !== 'darwin') return const target = systemOcrTarget(platform, arch) const basePath = path.join( runtimeResources, 'app.asar.unpacked', 'node_modules', '@napi-rs', 'system-ocr' ) const nativePath = path.join( runtimeResources, 'app.asar.unpacked', 'node_modules', '@napi-rs', `system-ocr-${target}` ) const requiredFiles = [ path.join(basePath, 'package.json'), path.join(basePath, 'index.js'), path.join(nativePath, 'package.json'), path.join(nativePath, `system-ocr.${target}.node`) ] const missingFiles = requiredFiles.filter((filePath) => !existsSync(filePath)) if (missingFiles.length > 0) { throw new Error(`Missing unpacked System OCR runtime: ${missingFiles.join(', ')}`) } } function normalizeBuilderArch(arch) { if (typeof arch === 'string') return arch return { 0: 'ia32', 1: 'x64', 2: 'armv7l', 3: 'arm64', 4: 'universal' }[arch] || String(arch) } function runCodesign(args) { execFileSync('/usr/bin/codesign', args, { stdio: 'ignore' }) } function isMacosCodeValid(targetPath, run = runCodesign) { try { run(['--verify', '--strict', targetPath]) return true } catch { return false } } function findMacosHelperPaths(runtimeResources) { return MACOS_HELPER_NAMES.map((name) => path.join(runtimeResources, 'resources', name)).filter( (helperPath) => existsSync(helperPath) ) } function signMacosHelpers(runtimeResources, run = runCodesign) { const helperPaths = findMacosHelperPaths(runtimeResources) for (const helperPath of helperPaths) { chmodSync(helperPath, 0o755) if (!isMacosCodeValid(helperPath, run)) { run(['--force', '--sign', '-', helperPath]) } for (const arch of ['arm64', 'x86_64']) { try { run(['--verify', '--strict', '--arch', arch, helperPath]) } catch (error) { throw new Error( 'macOS helper signature verification failed: ' + path.basename(helperPath) + ' (' + arch + ')', { cause: error } ) } } } return helperPaths } function signMacosAppBundle(appBundlePath, run = runCodesign) { if (isMacosCodeValid(appBundlePath, run)) return appBundlePath run(['--force', '--sign', '-', appBundlePath]) try { run(['--verify', '--strict', appBundlePath]) } catch (error) { throw new Error('macOS app bundle signature verification failed: ' + appBundlePath, { cause: error }) } return appBundlePath } /** * 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}`) // @electron/asar returns platform-native separators. Normalize to POSIX // paths so validation behaves consistently on Windows and macOS/Linux. const entries = new Set(asar.listPackage(asarPath).map((entry) => entry.replaceAll('\\', '/'))) const missingPackages = REQUIRED_RUNTIME_PACKAGES.filter( (packageName) => !entries.has(`/node_modules/${packageName}/package.json`) ) if (missingPackages.length > 0) { throw new Error( `Missing packaged runtime dependencies: ${missingPackages.join(', ')}. ` + 'Use pnpm 7.33.7 so electron-builder can read pnpm-lock.yaml.' ) } } function validateReaderSkillRuntime(runtimeResources) { const skillPath = path.join(runtimeResources, 'skill', 'tracememo-reader', 'SKILL.md') if (!existsSync(skillPath)) { throw new Error(`Missing bundled TraceMemo Reader Skill: ${skillPath}`) } 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. */ // 每个条目返回 platform package 的**完整后缀**(不含 package 前缀与连字符)。 const NATIVE_RUNTIME_PACKAGES = [ { modules: [], prefix: 'sherpa-onnx', platformName: (platform, arch) => `${platform === 'win32' ? 'win' : platform}-${arch}` }, { modules: ['@koromix'], prefix: 'koffi', platformName: (platform, arch) => `${platform}-${arch}` }, { // @napi-rs 的 platform package 目录名带 -msvc 后缀(win32-x64-msvc)。 modules: ['@napi-rs'], prefix: 'system-ocr', platformName: (platform, arch) => systemOcrTarget(platform, arch), foreignPattern: /^system-ocr-[a-z0-9]+-(arm64|x64|ia32|loong64|riscv64)(-msvc)?$/ } ] 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 = runtime.foreignPattern || 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 * "-". 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 } /** * 微信发送运行时打包边界 */ const SEND_RUNTIME_RELATIVE = ['resources', 'runtime', 'darwin-arm64'] const SEND_RUNTIME_ENTRY = 'tm-wechat-host' function sendRuntimeLocations(runtimeResources) { return [ path.join(runtimeResources, ...SEND_RUNTIME_RELATIVE), path.join(runtimeResources, 'app.asar.unpacked', ...SEND_RUNTIME_RELATIVE) ] } function findSendRuntime(runtimeResources) { return ( sendRuntimeLocations(runtimeResources).find((directory) => existsSync(path.join(directory, SEND_RUNTIME_ENTRY)) ) || null ) } function isSendRuntimeBuild() { return process.env.TM_SEND_RUNTIME_BUILD === '1' } function enforceSendRuntimeBoundary( runtimeResources, platform, bundlesSendRuntime = isSendRuntimeBuild() ) { if (platform !== 'darwin') return null const found = findSendRuntime(runtimeResources) if (bundlesSendRuntime) { if (!found) { throw new Error( 'This macOS build requires the WeChat send runtime but resources/runtime/darwin-arm64 is missing. ' + 'Run `pnpm prepare:wechat-native` first, or point TM_NATIVE_RUNTIME_DIR at the artifact.' ) } return found } if (found) { throw new Error( 'macOS bundle must not include the WeChat send runtime: ' + found + '. Build with `pnpm build:mac:arm64:send-runtime` (TM_SEND_RUNTIME_BUILD=1) when it is required, ' + 'or fix the resources filter in electron-builder.yml.' ) } return null } exports.default = async function afterPack(context) { const runtimeResources = getRuntimeResources(context) const arch = normalizeBuilderArch(context.arch) // 边界先判,越早失败越好。 const sendRuntime = enforceSendRuntimeBoundary(runtimeResources, context.electronPlatformName) if (context.electronPlatformName === 'darwin') { console.log( sendRuntime ? `[afterPack] send runtime bundled at ${sendRuntime}` : '[afterPack] send runtime excluded' ) } validateAsarRuntimeDependencies(runtimeResources) validateReaderSkillRuntime(runtimeResources) validateSilkWasmRuntime(runtimeResources) const ffmpegPath = validateFfmpegRuntime(runtimeResources, context.electronPlatformName) validateRuntimeBinaryArchitecture( ffmpegPath, context.electronPlatformName, arch, 'Bundled ffmpeg' ) validateSherpaRuntime(runtimeResources, context.electronPlatformName, arch) validateSystemOcrRuntime(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], { stdio: 'ignore' }) signMacosHelpers(runtimeResources) const productName = context.packager.appInfo.productFilename signMacosAppBundle(path.join(context.appOutDir, productName + '.app')) } if (context.electronPlatformName === 'win32') { const koffiNative = path.join( context.appOutDir, 'resources', 'app.asar.unpacked', 'node_modules', '@koromix', 'koffi-win32-x64', 'win32_x64', 'koffi.node' ) if (!existsSync(koffiNative)) { throw new Error(`Missing Windows Koffi native module: ${koffiNative}`) } return } } exports.getRuntimeResources = getRuntimeResources exports.validateAsarRuntimeDependencies = validateAsarRuntimeDependencies exports.validateReaderSkillRuntime = validateReaderSkillRuntime exports.validateFfmpegRuntime = validateFfmpegRuntime exports.validateSilkWasmRuntime = validateSilkWasmRuntime exports.validateSherpaRuntime = validateSherpaRuntime exports.validateSystemOcrRuntime = validateSystemOcrRuntime exports.pruneIntelMacKeyTool = pruneIntelMacKeyTool exports.pruneForeignArchConnectors = pruneForeignArchConnectors exports.pruneForeignArchNativeRuntimes = pruneForeignArchNativeRuntimes exports.validateRuntimeBinaryArchitecture = validateRuntimeBinaryArchitecture exports.findMacosHelperPaths = findMacosHelperPaths exports.isMacosCodeValid = isMacosCodeValid exports.signMacosHelpers = signMacosHelpers exports.signMacosAppBundle = signMacosAppBundle exports.sendRuntimeLocations = sendRuntimeLocations exports.findSendRuntime = findSendRuntime exports.enforceSendRuntimeBoundary = enforceSendRuntimeBoundary