diff --git a/package.json b/package.json index 3c219a4..07c9304 100644 --- a/package.json +++ b/package.json @@ -75,8 +75,10 @@ "dependencies": { "@electron-toolkit/preload": "^3.0.2", "@electron-toolkit/utils": "^4.0.0", + "@koromix/koffi-darwin-x64": "3.1.0", "@koromix/koffi-win32-x64": "3.1.0", "@napi-rs/system-ocr": "1.2.0", + "@napi-rs/system-ocr-darwin-x64": "1.2.0", "@napi-rs/system-ocr-win32-x64-msvc": "1.2.0", "@radix-ui/react-alert-dialog": "^1.1.23", "@radix-ui/react-checkbox": "^1.3.11", @@ -108,7 +110,9 @@ "parse5": "^8.0.0", "pinyin-pro": "^3.26.0", "qrcode": "^1.5.4", + "sherpa-onnx-darwin-x64": "1.13.3", "sherpa-onnx-node": "1.13.3", + "sherpa-onnx-win-x64": "1.13.4", "silk-wasm": "^3.7.1", "tailwind-merge": "^3.6.0", "unzipper": "^0.12.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1533c85..23228f9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,8 +11,10 @@ specifiers: '@electron-toolkit/preload': ^3.0.2 '@electron-toolkit/tsconfig': ^2.0.0 '@electron-toolkit/utils': ^4.0.0 + '@koromix/koffi-darwin-x64': 3.1.0 '@koromix/koffi-win32-x64': 3.1.0 '@napi-rs/system-ocr': 1.2.0 + '@napi-rs/system-ocr-darwin-x64': 1.2.0 '@napi-rs/system-ocr-win32-x64-msvc': 1.2.0 '@playwright/test': ^1.62.1 '@radix-ui/react-alert-dialog': ^1.1.23 @@ -72,7 +74,9 @@ specifiers: react: ^19.2.1 react-dom: ^19.2.1 sass: ^1.102.0 + sherpa-onnx-darwin-x64: 1.13.3 sherpa-onnx-node: 1.13.3 + sherpa-onnx-win-x64: 1.13.4 silk-wasm: ^3.7.1 tailwind-merge: ^3.6.0 tailwindcss: 3.4.17 @@ -87,8 +91,10 @@ specifiers: dependencies: '@electron-toolkit/preload': 3.0.2_electron@43.1.0 '@electron-toolkit/utils': 4.0.0_electron@43.1.0 + '@koromix/koffi-darwin-x64': 3.1.0 '@koromix/koffi-win32-x64': 3.1.0 '@napi-rs/system-ocr': 1.2.0 + '@napi-rs/system-ocr-darwin-x64': 1.2.0 '@napi-rs/system-ocr-win32-x64-msvc': 1.2.0 '@radix-ui/react-alert-dialog': 1.1.23_eijghdl4n2x4hz6j4cg7ctgbuu '@radix-ui/react-checkbox': 1.3.11_eijghdl4n2x4hz6j4cg7ctgbuu @@ -120,7 +126,9 @@ dependencies: parse5: 8.0.1 pinyin-pro: 3.29.3 qrcode: 1.5.4 + sherpa-onnx-darwin-x64: 1.13.3 sherpa-onnx-node: 1.13.3 + sherpa-onnx-win-x64: 1.13.4 silk-wasm: 3.7.1 tailwind-merge: 3.6.0 unzipper: 0.12.5 @@ -1585,7 +1593,6 @@ packages: cpu: [x64] os: [darwin] dev: false - optional: true /@koromix/koffi-freebsd-arm64/3.1.0: resolution: {integrity: sha512-vazoPYIhOAlXZksVIqDRMIID4VeUZKx8F3dR90hOobT2ATyOkqNS5dv5UCV7Q7DSq22lQTrdbvENBAhROzCp0w==} @@ -1689,6 +1696,36 @@ packages: - supports-color dev: true + /@napi-rs/system-ocr-darwin-arm64/1.2.0: + resolution: {integrity: sha512-cK8dcDBEl3P4A04xmFJSHEJQxfDytaAIFyDCLqavTp92FVU5plESttWzZsqtTkS81/kzKiBfHyPQffSIndfWbQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + dev: false + optional: true + + /@napi-rs/system-ocr-darwin-x64/1.2.0: + resolution: {integrity: sha512-u3TBvBGrhmT5Os6AfaxbUEg6VHe8lvrFJNPgThJgshJHyRXUx/wCfTyOroJ22KdVCP5AE4GpwS5tFHMb6p6iaQ==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + dev: false + + /@napi-rs/system-ocr-win32-arm64-msvc/1.2.0: + resolution: {integrity: sha512-7ej8uMvmXomw3NXo5gZ5p2Nl6UKsHI+VRU3ELv0mhcxR0sJ6wFifYTu5bJrM1TGcz1/RsaX+TjWMmsDq8vriKQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + dev: false + optional: true + + /@napi-rs/system-ocr-win32-x64-msvc/1.2.0: + resolution: {integrity: sha512-oOoCj3FPWDVctTxx98vMBiMI6m51U+w7SMmMefvmtpcpLelzZ/zYTqdwtWZFAjShaHO+RdaKkcpeVcQuBQiVbA==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + dev: false + /@napi-rs/system-ocr/1.2.0: resolution: {integrity: sha512-r0f2xNH6U+sth44qF+lUP+2WuHSGUBAry5KSCNuaLDGRbgslFqeROr/qJJ/fb6AjBp3Ov+CJP5MdrOWoaoM3cw==} engines: {node: '>= 10'} @@ -1699,34 +1736,6 @@ packages: '@napi-rs/system-ocr-win32-x64-msvc': 1.2.0 dev: false - /@napi-rs/system-ocr-darwin-arm64/1.2.0: - resolution: {integrity: sha512-cK8dcDBEl3P4A04xmFJSHEJQxfDytaAIFyDCLqavTp92FVU5plESttWzZsqtTkS81/kzKiBfHyPQffSIndfWbQ==} - cpu: [arm64] - os: [darwin] - engines: {node: '>= 10'} - dev: false - - /@napi-rs/system-ocr-darwin-x64/1.2.0: - resolution: {integrity: sha512-u3TBvBGrhmT5Os6AfaxbUEg6VHe8lvrFJNPgThJgshJHyRXUx/wCfTyOroJ22KdVCP5AE4GpwS5tFHMb6p6iaQ==} - cpu: [x64] - os: [darwin] - engines: {node: '>= 10'} - dev: false - - /@napi-rs/system-ocr-win32-arm64-msvc/1.2.0: - resolution: {integrity: sha512-7ej8uMvmXomw3NXo5gZ5p2Nl6UKsHI+VRU3ELv0mhcxR0sJ6wFifYTu5bJrM1TGcz1/RsaX+TjWMmsDq8vriKQ==} - cpu: [arm64] - os: [win32] - engines: {node: '>= 10'} - dev: false - - /@napi-rs/system-ocr-win32-x64-msvc/1.2.0: - resolution: {integrity: sha512-oOoCj3FPWDVctTxx98vMBiMI6m51U+w7SMmMefvmtpcpLelzZ/zYTqdwtWZFAjShaHO+RdaKkcpeVcQuBQiVbA==} - cpu: [x64] - os: [win32] - engines: {node: '>= 10'} - dev: false - /@nodelib/fs.scandir/2.1.5: resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -7589,7 +7598,6 @@ packages: cpu: [x64] os: [darwin] dev: false - optional: true /sherpa-onnx-linux-arm64/1.13.4: resolution: {integrity: sha512-RMjMRqT82BgTXypNNGmLe6ZFYhc3WEvnAGl3DdkK7qB/kuXwkL3iHhV31wAecbnWPsnEpUoD+8cFovWSBzsCuw==} @@ -7628,7 +7636,6 @@ packages: cpu: [x64] os: [win32] dev: false - optional: true /side-channel-list/1.0.0: resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==} diff --git a/scripts/after-pack.cjs b/scripts/after-pack.cjs index 7ff4dd5..c2f990c 100644 --- a/scripts/after-pack.cjs +++ b/scripts/after-pack.cjs @@ -125,13 +125,62 @@ function validateSystemOcrRuntime(runtimeResources, platform, arch) { } } +/** + * koffi 运行期按 `${process.platform}-${process.arch}` 拼出原生包目录名 + * (node_modules/koffi/src/koffi/index.cjs:153/175),找不到就直接抛 + * "Cannot find the native Koffi module; did you bundle it correctly?"。 + * pnpm 7 不支持 supportedArchitectures,会静默跳过外平台可选依赖,所以每个目标平台的 + * koffi 原生包都必须在 package.json 里显式声明;这里再兜一层,缺了就让构建失败, + * 而不是发出一个装得上、却打不开 WCDB 的包。 + */ +function koffiNativeTarget(platform, arch) { + if (platform === 'win32') { + return arch === 'x64' + ? { label: 'Windows', segments: ['@koromix', 'koffi-win32-x64', 'win32_x64', 'koffi.node'] } + : null + } + if (platform === 'darwin' && (arch === 'x64' || arch === 'arm64')) { + return { + label: 'macOS', + segments: ['@koromix', `koffi-darwin-${arch}`, `darwin_${arch}`, 'koffi.node'] + } + } + return null +} + +function validateKoffiRuntime(runtimeResources, platform, arch) { + const target = koffiNativeTarget(platform, arch) + if (!target) return + const nativePath = path.join( + runtimeResources, + 'app.asar.unpacked', + 'node_modules', + ...target.segments + ) + if (!existsSync(nativePath)) { + throw new Error(`Missing ${target.label} Koffi native module: ${nativePath}`) + } +} + 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' }) + try { + execFileSync('/usr/bin/codesign', args, { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'] + }) + } catch (error) { + const stderr = + error && typeof error === 'object' && 'stderr' in error ? String(error.stderr) : '' + if (stderr.trim() && error instanceof Error) { + error.message += `\n${stderr.trim()}` + } + throw error + } } function isMacosCodeValid(targetPath, run = runCodesign) { @@ -174,8 +223,89 @@ function signMacosHelpers(runtimeResources, run = runCodesign) { return helperPaths } +/** + * codesign 只把这些位置当作「嵌套代码」并要求它们先各自签好,才肯签外层 app。 + * 只遍历这一组根目录,而不是整个 bundle:Contents/Resources 下的 + * app.asar.unpacked 里成千上万个原生文件不属于嵌套代码,逐个签既慢又无意义。 + */ +const MACOS_CODE_LOCATIONS = [ + 'Frameworks', + 'MacOS', + 'PlugIns', + 'XPCServices', + 'Helpers', + 'Library/LoginItems' +] + +function collectNestedMacosCode(dir, depth, targets) { + let entries + try { + entries = readdirSync(dir, { withFileTypes: true }) + } catch { + return + } + for (const entry of entries) { + const entryPath = path.join(dir, entry.name) + // framework 里的 Mantle -> Versions/Current/Mantle 这类符号链接指向真实文件, + // 真实文件会在更深的层级被走到;这里跳过以免重复签名。 + if (entry.isSymbolicLink()) continue + if (entry.isDirectory()) { + if (/\.(app|framework|xpc)$/.test(entry.name)) { + targets.push({ path: entryPath, depth, bundle: true }) + } + collectNestedMacosCode(entryPath, depth + 1, targets) + continue + } + if (!entry.isFile()) continue + if (readBinaryArchitectures(entryPath).length === 0) continue + targets.push({ path: entryPath, depth, bundle: false }) + } +} + +/** + * 返回嵌套代码的签名顺序:深度大的先签(framework 内部的 dylib、无扩展名的 + * crashpad handler 先于 framework 本身,helper 的可执行文件先于 helper app), + * 同深度时文件先于 bundle。 + */ +function findNestedMacosCodePaths(appBundlePath) { + const targets = [] + for (const location of MACOS_CODE_LOCATIONS) { + const root = path.join(appBundlePath, 'Contents', ...location.split('/')) + if (existsSync(root)) collectNestedMacosCode(root, 1, targets) + } + return targets + .map((target, index) => ({ ...target, index })) + .sort((a, b) => { + if (a.depth !== b.depth) return b.depth - a.depth + if (a.bundle !== b.bundle) return a.bundle ? 1 : -1 + return a.index - b.index + }) + .map((target) => target.path) +} + +/** + * Electron 43.1.0 的 darwin-x64 官方 zip(sha256 与上游 SHASUMS256.txt 一致) + * 里所有嵌套 Mach-O 都是未签名状态,darwin-arm64 那份则是 linker-signed。 + * codesign 签外层 bundle 时要求子组件已签,否则直接报 + * "code object is not signed at all" + "In subcomponent: ...", + * 所以 x64 出包时只签外层必然失败,必须先由内向外补签一遍。 + * + * 这里不采用 `--deep`(Apple 已标记 deprecated):它会把外层的签名选项套用到 + * 所有子组件上,将来接上 Developer ID + entitlements 时会把 app 的 entitlements + * 一并套到 helper 上,属于已知的坑。 + */ function signMacosAppBundle(appBundlePath, run = runCodesign) { if (isMacosCodeValid(appBundlePath, run)) return appBundlePath + for (const nestedPath of findNestedMacosCodePaths(appBundlePath)) { + try { + run(['--force', '--sign', '-', nestedPath]) + } catch (error) { + throw new Error( + 'macOS nested code signing failed: ' + path.relative(appBundlePath, nestedPath), + { cause: error } + ) + } + } run(['--force', '--sign', '-', appBundlePath]) try { run(['--verify', '--strict', appBundlePath]) @@ -401,6 +531,7 @@ exports.default = async function afterPack(context) { ) validateSherpaRuntime(runtimeResources, context.electronPlatformName, arch) validateSystemOcrRuntime(runtimeResources, context.electronPlatformName, arch) + validateKoffiRuntime(runtimeResources, context.electronPlatformName, arch) pruneIntelMacKeyTool(runtimeResources, context.electronPlatformName, arch) pruneForeignArchConnectors(runtimeResources, context.electronPlatformName, arch) pruneForeignArchNativeRuntimes(runtimeResources, context.electronPlatformName, arch) @@ -413,23 +544,6 @@ exports.default = async function afterPack(context) { 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 @@ -439,6 +553,7 @@ exports.validateFfmpegRuntime = validateFfmpegRuntime exports.validateSilkWasmRuntime = validateSilkWasmRuntime exports.validateSherpaRuntime = validateSherpaRuntime exports.validateSystemOcrRuntime = validateSystemOcrRuntime +exports.validateKoffiRuntime = validateKoffiRuntime exports.pruneIntelMacKeyTool = pruneIntelMacKeyTool exports.pruneForeignArchConnectors = pruneForeignArchConnectors exports.pruneForeignArchNativeRuntimes = pruneForeignArchNativeRuntimes @@ -447,6 +562,7 @@ exports.findMacosHelperPaths = findMacosHelperPaths exports.isMacosCodeValid = isMacosCodeValid exports.signMacosHelpers = signMacosHelpers exports.signMacosAppBundle = signMacosAppBundle +exports.findNestedMacosCodePaths = findNestedMacosCodePaths exports.sendRuntimeLocations = sendRuntimeLocations exports.findSendRuntime = findSendRuntime exports.enforceSendRuntimeBoundary = enforceSendRuntimeBoundary diff --git a/tests/unit/after-pack-signing.test.ts b/tests/unit/after-pack-signing.test.ts new file mode 100644 index 0000000..99f50fa --- /dev/null +++ b/tests/unit/after-pack-signing.test.ts @@ -0,0 +1,161 @@ +import { createRequire } from 'module' +import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'fs' +import { tmpdir } from 'os' +import { join } from 'path' +import { afterAll, describe, expect, it } from 'vitest' + +const nodeRequire = createRequire(import.meta.url) +const { findNestedMacosCodePaths, signMacosAppBundle } = nodeRequire( + '../../scripts/after-pack.cjs' +) as { + findNestedMacosCodePaths: (appBundlePath: string) => string[] + signMacosAppBundle: (appBundlePath: string, run?: (args: string[]) => void) => string +} + +const root = mkdtempSync(join(tmpdir(), 'wxe-after-pack-sign-')) + +/** 最小可用的 64 位 thin Mach-O 头,足以让 readBinaryArchitectures 判出 x64。 */ +function macho(): Buffer { + const buffer = Buffer.alloc(32) + buffer.writeUInt32LE(0xfeedfacf, 0) + buffer.writeUInt32LE(0x01000007, 4) + return buffer +} + +function file(...segments: string[]): string { + const target = join(root, ...segments) + mkdirSync(join(target, '..'), { recursive: true }) + writeFileSync(target, macho()) + return target +} + +/** 复刻 Electron 43.1.0 darwin-x64 的未签名 bundle 形状。 */ +function fixtureApp(): string { + const app = join(root, 'TraceMemo.app') + file('TraceMemo.app', 'Contents', 'MacOS', 'TraceMemo') + file('TraceMemo.app', 'Contents', 'Frameworks', 'Mantle.framework', 'Versions', 'A', 'Mantle') + file( + 'TraceMemo.app', + 'Contents', + 'Frameworks', + 'Electron Framework.framework', + 'Versions', + 'A', + 'Electron Framework' + ) + file( + 'TraceMemo.app', + 'Contents', + 'Frameworks', + 'Electron Framework.framework', + 'Versions', + 'A', + 'Libraries', + 'libffmpeg.dylib' + ) + file( + 'TraceMemo.app', + 'Contents', + 'Frameworks', + 'Electron Framework.framework', + 'Versions', + 'A', + 'Helpers', + 'chrome_crashpad_handler' + ) + file('TraceMemo.app', 'Contents', 'Frameworks', 'Helper.app', 'Contents', 'MacOS', 'Helper') + // framework 内指向 Versions/A 的符号链接:真实文件在更深层级被走到,这里要跳过。 + symlinkSync( + 'A', + join( + root, + 'TraceMemo.app', + 'Contents', + 'Frameworks', + 'Mantle.framework', + 'Versions', + 'Current' + ), + 'dir' + ) + // Contents/Resources 下的原生文件不是「嵌套代码」,不参与签名。 + file( + 'TraceMemo.app', + 'Contents', + 'Resources', + 'app.asar.unpacked', + 'node_modules', + 'sherpa-onnx-darwin-x64', + 'sherpa-onnx.node' + ) + // 非原生文件必须被忽略。 + writeFileSync(join(root, 'TraceMemo.app', 'Contents', 'Frameworks', 'README.md'), 'not a binary') + return app +} + +const app = fixtureApp() +const relative = (target: string): string => target.slice(app.length + 1) + +describe('macOS nested code signing order', () => { + afterAll(() => rmSync(root, { recursive: true, force: true })) + + it('signs nested code inside-out and ignores resources and symlinks', () => { + const paths = findNestedMacosCodePaths(app).map(relative) + + expect(paths).toContain(join('Contents', 'MacOS', 'TraceMemo')) + expect(paths).not.toContain(app) + expect(paths.some((entry) => entry.includes('Resources'))).toBe(false) + expect(paths.some((entry) => entry.endsWith('.md'))).toBe(false) + expect(paths.some((entry) => entry.includes('Versions/Current'))).toBe(false) + + const index = (needle: string): number => paths.indexOf(needle) + const handler = + 'Contents/Frameworks/Electron Framework.framework/Versions/A/Helpers/chrome_crashpad_handler' + const frameworkBinary = + 'Contents/Frameworks/Electron Framework.framework/Versions/A/Electron Framework' + const framework = 'Contents/Frameworks/Electron Framework.framework' + const helperBinary = 'Contents/Frameworks/Helper.app/Contents/MacOS/Helper' + const helperApp = 'Contents/Frameworks/Helper.app' + + expect(index(handler)).toBeGreaterThanOrEqual(0) + // 内层可执行文件先于其所属 bundle,helper 的可执行文件先于 helper app。 + expect(index(handler)).toBeLessThan(index(framework)) + expect(index(frameworkBinary)).toBeLessThan(index(framework)) + expect(index(helperBinary)).toBeLessThan(index(helperApp)) + // 最深的目标排在最前。 + expect(paths[0]).toBe(handler) + }) + + it('re-signs the app bundle after every nested target', () => { + const calls: string[][] = [] + let verifyCalls = 0 + const run = (args: string[]): void => { + if (args[0] === '--verify') { + verifyCalls += 1 + // 未签名来源包:外层首次校验必然失败,补签之后才允许通过。 + if (verifyCalls === 1) throw new Error('code object is not signed at all') + return + } + calls.push(args) + } + + signMacosAppBundle(app, run) + + const signed = calls.map((args) => args[args.length - 1]) + expect(signed[signed.length - 1]).toBe(app) + expect(signed.slice(0, -1)).toEqual(findNestedMacosCodePaths(app)) + expect(verifyCalls).toBe(2) + }) + + it('leaves an already valid bundle untouched', () => { + const calls: string[][] = [] + const run = (args: string[]): void => { + calls.push(args) + } + + signMacosAppBundle(app, run) + + // 只有首次 --verify,没有任何 --sign。 + expect(calls).toEqual([['--verify', '--strict', app]]) + }) +}) diff --git a/tests/unit/runtime-packaging.test.ts b/tests/unit/runtime-packaging.test.ts index 58e4715..0c74237 100644 --- a/tests/unit/runtime-packaging.test.ts +++ b/tests/unit/runtime-packaging.test.ts @@ -22,18 +22,26 @@ const { hasWindowsSherpaRuntime } = nodeRequire('../../scripts/prepare-win-runti const { validateAsarRuntimeDependencies, validateFfmpegRuntime, + validateKoffiRuntime, validateReaderSkillRuntime, validateSherpaRuntime, validateSilkWasmRuntime, + validateSystemOcrRuntime, findMacosHelperPaths, signMacosHelpers, signMacosAppBundle } = nodeRequire('../../scripts/after-pack.cjs') as { validateAsarRuntimeDependencies: (runtimeResources: string) => void validateFfmpegRuntime: (runtimeResources: string, platform?: NodeJS.Platform) => void + validateKoffiRuntime: (runtimeResources: string, platform: NodeJS.Platform, arch: string) => void validateReaderSkillRuntime: (runtimeResources: string) => string validateSherpaRuntime: (runtimeResources: string, platform: NodeJS.Platform, arch: string) => void validateSilkWasmRuntime: (runtimeResources: string) => void + validateSystemOcrRuntime: ( + runtimeResources: string, + platform: NodeJS.Platform, + arch: string + ) => void findMacosHelperPaths: (runtimeResources: string) => string[] signMacosHelpers: (runtimeResources: string, run?: CodesignRunner) => string[] signMacosAppBundle: (appBundlePath: string, run?: CodesignRunner) => string @@ -265,6 +273,91 @@ describe('production runtime packaging', () => { expect(config).toContain('node_modules/sherpa-onnx-*/**') }) + it('requires the matching System OCR native runtime', () => { + const resources = join(root, 'system-ocr-resources') + const modules = join(resources, 'app.asar.unpacked', 'node_modules', '@napi-rs') + const base = join(modules, 'system-ocr') + + expect(() => validateSystemOcrRuntime(resources, 'darwin', 'arm64')).toThrow( + /Missing unpacked System OCR runtime:.*system-ocr/ + ) + + mkdirSync(base, { recursive: true }) + writeFileSync(join(base, 'package.json'), '{}') + writeFileSync(join(base, 'index.js'), 'module.exports = {}') + + const mac = join(modules, 'system-ocr-darwin-arm64') + mkdirSync(mac, { recursive: true }) + writeFileSync(join(mac, 'package.json'), '{}') + writeFileSync(join(mac, 'system-ocr.darwin-arm64.node'), 'fixture') + expect(() => validateSystemOcrRuntime(resources, 'darwin', 'arm64')).not.toThrow() + + // Windows 的原生包名带 -msvc 后缀,查找规则必须跟着改。 + expect(() => validateSystemOcrRuntime(resources, 'win32', 'x64')).toThrow(/win32-x64-msvc/) + const windows = join(modules, 'system-ocr-win32-x64-msvc') + mkdirSync(windows, { recursive: true }) + writeFileSync(join(windows, 'package.json'), '{}') + writeFileSync(join(windows, 'system-ocr.win32-x64-msvc.node'), 'fixture') + expect(() => validateSystemOcrRuntime(resources, 'win32', 'x64')).not.toThrow() + + // Linux 不是 supported target,不应做硬校验。 + expect(() => validateSystemOcrRuntime(resources, 'linux', 'x64')).not.toThrow() + }) + + it('requires the koffi native module for the packaged platform', () => { + const resources = join(root, 'koffi-resources') + const modules = join(resources, 'app.asar.unpacked', 'node_modules', '@koromix') + + expect(() => validateKoffiRuntime(resources, 'darwin', 'x64')).toThrow( + /Missing macOS Koffi native module:.*koffi-darwin-x64/ + ) + expect(() => validateKoffiRuntime(resources, 'darwin', 'arm64')).toThrow( + /koffi-darwin-arm64[/\\]darwin_arm64[/\\]koffi\.node/ + ) + expect(() => validateKoffiRuntime(resources, 'win32', 'x64')).toThrow( + /Missing Windows Koffi native module:.*koffi-win32-x64/ + ) + + // koffi 运行期按 `${platform}-${arch}` 拼目录名,darwin 用 darwin_, + // win32 用 win32_x64(见 node_modules/koffi/src/koffi/index.cjs)。 + for (const segments of [ + ['koffi-darwin-x64', 'darwin_x64'], + ['koffi-darwin-arm64', 'darwin_arm64'], + ['koffi-win32-x64', 'win32_x64'] + ]) { + const nativeDirectory = join(modules, ...segments) + mkdirSync(nativeDirectory, { recursive: true }) + writeFileSync(join(nativeDirectory, 'koffi.node'), 'fixture') + } + + expect(() => validateKoffiRuntime(resources, 'darwin', 'x64')).not.toThrow() + expect(() => validateKoffiRuntime(resources, 'darwin', 'arm64')).not.toThrow() + expect(() => validateKoffiRuntime(resources, 'win32', 'x64')).not.toThrow() + + // 没有对应原生包的组合应静默跳过,而不是误报。 + expect(() => validateKoffiRuntime(resources, 'linux', 'x64')).not.toThrow() + expect(() => validateKoffiRuntime(resources, 'win32', 'arm64')).not.toThrow() + }) + + it('declares the cross-arch native runtimes pnpm 7 would otherwise skip', () => { + const packageJson = JSON.parse( + readFileSync(resolve(__dirname, '../../package.json'), 'utf8') + ) as { dependencies: Record } + + // pnpm 7.33.7 不支持 supportedArchitectures,非宿主平台的可选依赖会被静默跳过, + // 而这些原生包必须在 dependencies 里显式声明,否则打包阶段才在 afterPack 报缺。 + for (const name of [ + 'sherpa-onnx-darwin-x64', + 'sherpa-onnx-win-x64', + '@napi-rs/system-ocr-darwin-x64', + '@napi-rs/system-ocr-win32-x64-msvc', + '@koromix/koffi-darwin-x64', + '@koromix/koffi-win32-x64' + ]) { + expect(packageJson.dependencies).toHaveProperty(name) + } + }) + it('finds only the macOS helpers that exist in packaged resources', () => { const resources = join(root, 'helper-detect-resources', 'resources') mkdirSync(resources, { recursive: true })