From f1d3e10346d9f4a694f3399dc4b603b9962e8c6e Mon Sep 17 00:00:00 2001 From: J3n5en Date: Thu, 24 Sep 2026 01:36:33 +0800 Subject: [PATCH 1/7] =?UTF-8?q?ci:=20mac=20x64=20=E6=94=B9=E4=B8=BA=20Appl?= =?UTF-8?q?e=20=E8=8A=AF=E7=89=87=E4=BA=A4=E5=8F=89=E6=89=93=E5=8C=85?= =?UTF-8?q?=EF=BC=8C=E5=8E=9F=E7=94=9F=E4=BE=9D=E8=B5=96=E4=B8=8D=E5=86=8D?= =?UTF-8?q?=E4=B8=BA=20Electron=20=E9=87=8D=E7=BC=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 原生依赖均为 N-API 预编译:去掉 @electron/rebuild,better-sqlite3 不再跑空转的 node-gyp(Windows 约 78s),node-pty 直接用自带 prebuilds - mac x64 在 macos-15 上按目标架构装包(--cpu + npm_config_arch,关构建结果缓存) - afterPack 只留目标架构的 node-pty / pi-tui 预编译,并校验包内 Mach-O 均含目标架构 - 三平台打包后用产物自带 Electron 冒烟加载 better-sqlite3、sqlite-vec、classic-level、 node-pty、node-datachannel,并执行 ripgrep、rtk --- .github/workflows/build.yml | 29 ++++++--- package.json | 7 ++- pnpm-lock.yaml | 3 - scripts/after-pack.mjs | 10 +++ scripts/smoke-packaged-natives.cjs | 79 ++++++++++++++++++++++++ src/tooling/packagedArch.d.mts | 8 +++ src/tooling/packagedArch.mjs | 57 +++++++++++++++++ src/tooling/packagedArch.test.ts | 58 +++++++++++++++++ src/tooling/stripPackagedNatives.d.mts | 6 ++ src/tooling/stripPackagedNatives.mjs | 28 +++++++-- src/tooling/stripPackagedNatives.test.ts | 36 +++++++++-- 11 files changed, 297 insertions(+), 24 deletions(-) create mode 100644 scripts/smoke-packaged-natives.cjs create mode 100644 src/tooling/packagedArch.d.mts create mode 100644 src/tooling/packagedArch.mjs create mode 100644 src/tooling/packagedArch.test.ts diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index ecb77767..d24756eb 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -57,12 +57,10 @@ jobs: needs: build-app strategy: matrix: - include: - - arch: arm64 - runner: macos-15 - - arch: x64 - runner: macos-15-intel - runs-on: ${{ matrix.runner }} + arch: [arm64, x64] + # x64 也在 Apple 芯片上交叉打包:Intel runner 各阶段慢 2–4 倍。 + # 原生依赖全是 N-API 预编译,按目标架构装包即可;after-pack 校验包内 Mach-O 架构。 + runs-on: macos-15 steps: - name: Checkout uses: actions/checkout@v5 @@ -83,9 +81,11 @@ jobs: uses: pnpm/action-setup@v5 - name: Install dependencies - run: pnpm install --frozen-lockfile + # 关掉构建结果缓存:否则 ripgrep 等 postinstall 会复用本机架构的产物 + run: pnpm install --frozen-lockfile --cpu=${{ matrix.arch }} --config.side-effects-cache=false env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + npm_config_arch: ${{ matrix.arch }} - name: Import Apple Certificate env: @@ -115,6 +115,14 @@ jobs: APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }} APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + - name: Smoke test packaged natives + run: | + if [ "${{ matrix.arch }}" = x64 ] && ! arch -x86_64 /usr/bin/true 2>/dev/null; then + sudo softwareupdate --install-rosetta --agree-to-license + fi + APP=$(ls -d dist/mac*/EnsoCode.app | head -1) + ELECTRON_RUN_AS_NODE=1 "$APP/Contents/MacOS/EnsoCode" scripts/smoke-packaged-natives.cjs "$APP/Contents/Resources" + - name: Upload latest-mac.yml uses: actions/upload-artifact@v6 with: @@ -155,6 +163,10 @@ jobs: env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Smoke test packaged natives + shell: bash + run: ELECTRON_RUN_AS_NODE=1 dist/win-unpacked/EnsoCode.exe scripts/smoke-packaged-natives.cjs dist/win-unpacked/resources + # 非 tag 触发时 electron-builder 不发布,产物只留在 runner 上; # 传上来才能直接下载试装,不必为了拿个安装包去发版 - name: Upload installer @@ -208,6 +220,9 @@ jobs: env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Smoke test packaged natives + run: ELECTRON_RUN_AS_NODE=1 dist/linux-unpacked/enso-code scripts/smoke-packaged-natives.cjs dist/linux-unpacked/resources + build-relay: runs-on: ubuntu-latest timeout-minutes: 20 diff --git a/package.json b/package.json index c63977ab..d03a5f1d 100644 --- a/package.json +++ b/package.json @@ -29,7 +29,7 @@ "test": "node --test scripts/rtk.test.mjs && vitest run", "test:watch": "vitest", "format": "biome format --write .", - "postinstall": "node node_modules/electron/install.js && electron-rebuild --only better-sqlite3,node-pty" + "postinstall": "node node_modules/electron/install.js" }, "dependencies": { "@bufbuild/protobuf": "^2.14.0", @@ -65,7 +65,6 @@ "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", "@electron-toolkit/tsconfig": "^2.0.0", - "@electron/rebuild": "4.2.0", "@pierre/diffs": "^1.3.5", "@shikijs/themes": "4.4.3", "@tailwindcss/vite": "^4.3.3", @@ -124,7 +123,6 @@ "onlyBuiltDependencies": [ "@google/genai", "@vscode/ripgrep", - "better-sqlite3", "classic-level", "electron", "electron-winstaller", @@ -135,6 +133,9 @@ "protobufjs", "workerd" ], + "ignoredBuiltDependencies": [ + "better-sqlite3" + ], "patchedDependencies": { "@rahularya01/pi-cursor@1.4.36": "patches/@rahularya01__pi-cursor@1.4.36.patch" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 44b5ba9f..63acfe9a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -107,9 +107,6 @@ importers: '@electron-toolkit/tsconfig': specifier: ^2.0.0 version: 2.0.0(@types/node@26.2.0) - '@electron/rebuild': - specifier: 4.2.0 - version: 4.2.0 '@pierre/diffs': specifier: ^1.3.5 version: 1.3.5(@shikijs/themes@4.4.3)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) diff --git a/scripts/after-pack.mjs b/scripts/after-pack.mjs index eb86d190..171c6aa3 100644 --- a/scripts/after-pack.mjs +++ b/scripts/after-pack.mjs @@ -1,5 +1,6 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; +import { findArchMismatches } from '../src/tooling/packagedArch.mjs'; import stripPackagedNatives from '../src/tooling/stripPackagedNatives.mjs'; import { copyRtkDistribution } from './rtk.mjs'; @@ -31,4 +32,13 @@ export default async function afterPack(context) { context.electronPlatformName, archName(context.arch) ); + // mac 会在 Apple 芯片上交叉打 x64:签名前拦住混进包里的别架构二进制 + if (context.electronPlatformName === 'darwin') { + const arch = archName(context.arch); + const mismatches = findArchMismatches(context.appOutDir, arch); + if (mismatches.length) { + const list = mismatches.map(({ file, archs }) => ` ${file} (${archs.join(', ')})`); + throw new Error(`packaged app has binaries without ${arch}:\n${list.join('\n')}`); + } + } } diff --git a/scripts/smoke-packaged-natives.cjs b/scripts/smoke-packaged-natives.cjs new file mode 100644 index 00000000..d91724e9 --- /dev/null +++ b/scripts/smoke-packaged-natives.cjs @@ -0,0 +1,79 @@ +// 用打包产物自带的 Electron 以 node 模式真实加载每个原生依赖、执行每个内置二进制。 +// 用法:ELECTRON_RUN_AS_NODE=1 <打包后的可执行文件> scripts/smoke-packaged-natives.cjs +const { execFileSync } = require('node:child_process'); +const { mkdtempSync, rmSync } = require('node:fs'); +const { createRequire } = require('node:module'); +const os = require('node:os'); +const path = require('node:path'); + +const resources = path.resolve(process.argv[2] ?? ''); +const appRequire = createRequire(path.join(resources, 'app.asar', 'package.json')); +const unpacked = (file) => file.replace(/app\.asar([\\/])/, 'app.asar.unpacked$1'); +const isWindows = process.platform === 'win32'; + +const checks = { + 'better-sqlite3 + sqlite-vec': () => { + const Database = appRequire('better-sqlite3'); + const db = new Database(':memory:'); + db.loadExtension(unpacked(appRequire('sqlite-vec').getLoadablePath())); + return db.prepare('select vec_version() as v').get().v; + }, + 'classic-level': async () => { + const { Level } = await import(appRequire.resolve('level')); + const dir = mkdtempSync(path.join(os.tmpdir(), 'enso-smoke-level-')); + try { + const db = new Level(dir); + await db.put('k', 'v'); + const value = await db.get('k'); + await db.close(); + return value; + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }, + 'node-pty': () => + new Promise((resolve, reject) => { + const pty = appRequire('node-pty'); + const [file, args] = isWindows + ? ['cmd.exe', ['/c', 'echo pty-ok']] + : ['/bin/echo', ['pty-ok']]; + const term = pty.spawn(file, args, { cols: 80, rows: 24 }); + let output = ''; + term.onData((data) => { + output += data; + }); + term.onExit(({ exitCode }) => + exitCode === 0 && output.includes('pty-ok') + ? resolve('pty-ok') + : reject(new Error(`exit ${exitCode}: ${output}`)) + ); + }), + 'node-datachannel': async () => { + const ndc = await import(appRequire.resolve('node-datachannel')); + const peer = new ndc.PeerConnection('smoke', { iceServers: [] }); + peer.close(); + return 'loaded'; + }, + ripgrep: () => + execFileSync(unpacked(appRequire('@vscode/ripgrep').rgPath), ['--version']) + .toString() + .split('\n')[0], + rtk: () => + execFileSync(path.join(resources, 'rtk', isWindows ? 'rtk.exe' : 'rtk'), ['--version']) + .toString() + .trim(), +}; + +(async () => { + let failed = false; + for (const [name, check] of Object.entries(checks)) { + try { + console.log(`ok ${name}: ${await check()}`); + } catch (error) { + failed = true; + console.error(`FAIL ${name}: ${error instanceof Error ? error.stack : error}`); + } + } + console.log(`arch ${process.arch}, electron ${process.versions.electron}`); + process.exit(failed ? 1 : 0); +})(); diff --git a/src/tooling/packagedArch.d.mts b/src/tooling/packagedArch.d.mts new file mode 100644 index 00000000..a6a43e2f --- /dev/null +++ b/src/tooling/packagedArch.d.mts @@ -0,0 +1,8 @@ +export interface ArchMismatch { + file: string; + archs: string[]; +} + +export function machOArchs(buffer: Buffer): string[] | null; + +export function findArchMismatches(root: string, arch: string): ArchMismatch[]; diff --git a/src/tooling/packagedArch.mjs b/src/tooling/packagedArch.mjs new file mode 100644 index 00000000..a49ffbf6 --- /dev/null +++ b/src/tooling/packagedArch.mjs @@ -0,0 +1,57 @@ +import { closeSync, lstatSync, openSync, readdirSync, readSync } from 'node:fs'; +import path from 'node:path'; + +const CPU_ARCH = new Map([ + [0x01000007, 'x64'], + [0x0100000c, 'arm64'], +]); +const MAX_FAT_ARCHS = 16; + +const cpuName = (cpu) => CPU_ARCH.get(cpu) ?? `cpu:${cpu.toString(16)}`; + +/** Mach-O 头里的架构列表;非 Mach-O 返回 null */ +export function machOArchs(buffer) { + if (buffer.length < 8) return null; + if (buffer.readUInt32LE(0) === 0xfeedfacf || buffer.readUInt32LE(0) === 0xfeedface) { + return [cpuName(buffer.readInt32LE(4))]; + } + const magic = buffer.readUInt32BE(0); + if (magic !== 0xcafebabe && magic !== 0xcafebabf) return null; + const count = buffer.readUInt32BE(4); + const entrySize = magic === 0xcafebabf ? 32 : 20; + // Java class 同魔数,这个位置是版本号(>=45),真实 fat 文件架构数很少 + if (count === 0 || count > MAX_FAT_ARCHS || buffer.length < 8 + count * entrySize) return null; + return Array.from({ length: count }, (_, index) => + cpuName(buffer.readInt32BE(8 + index * entrySize)) + ); +} + +function readHead(file) { + const fd = openSync(file, 'r'); + try { + const buffer = Buffer.alloc(8 + MAX_FAT_ARCHS * 32); + return buffer.subarray(0, readSync(fd, buffer, 0, buffer.length, 0)); + } finally { + closeSync(fd); + } +} + +/** 遍历目录,返回不含目标架构的 Mach-O(相对路径);符号链接不跟随 */ +export function findArchMismatches(root, arch) { + const mismatches = []; + const walk = (dir) => { + for (const name of readdirSync(dir).sort()) { + const file = path.join(dir, name); + const stat = lstatSync(file); + if (stat.isDirectory()) walk(file); + else if (stat.isFile()) { + const archs = machOArchs(readHead(file)); + if (archs && !archs.includes(arch)) { + mismatches.push({ file: path.relative(root, file), archs }); + } + } + } + }; + walk(root); + return mismatches; +} diff --git a/src/tooling/packagedArch.test.ts b/src/tooling/packagedArch.test.ts new file mode 100644 index 00000000..cf33ee7f --- /dev/null +++ b/src/tooling/packagedArch.test.ts @@ -0,0 +1,58 @@ +import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { findArchMismatches, machOArchs } from './packagedArch.mjs'; + +const X86_64 = 0x01000007; +const ARM64 = 0x0100000c; + +function thin(cpu: number): Buffer { + const buffer = Buffer.alloc(32); + buffer.writeUInt32LE(0xfeedfacf, 0); + buffer.writeInt32LE(cpu, 4); + return buffer; +} + +function fat(...cpus: number[]): Buffer { + const buffer = Buffer.alloc(8 + cpus.length * 20); + buffer.writeUInt32BE(0xcafebabe, 0); + buffer.writeUInt32BE(cpus.length, 4); + for (const [index, cpu] of cpus.entries()) buffer.writeInt32BE(cpu, 8 + index * 20); + return buffer; +} + +const dirs: string[] = []; +afterEach(() => { + for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true }); +}); + +describe('packagedArch', () => { + it('读出 thin / universal Mach-O 的架构,非 Mach-O 返回 null', () => { + expect(machOArchs(thin(X86_64))).toEqual(['x64']); + expect(machOArchs(thin(ARM64))).toEqual(['arm64']); + expect(machOArchs(fat(X86_64, ARM64))).toEqual(['x64', 'arm64']); + expect(machOArchs(Buffer.from('#!/bin/sh\n'))).toBeNull(); + expect(machOArchs(Buffer.alloc(2))).toBeNull(); + // Java class 与 fat Mach-O 同魔数,靠架构数量区分 + const javaClass = Buffer.alloc(8); + javaClass.writeUInt32BE(0xcafebabe, 0); + javaClass.writeUInt32BE(52, 4); + expect(machOArchs(javaClass)).toBeNull(); + }); + + it('列出缺少目标架构的 Mach-O,跳过非 Mach-O 与符号链接', () => { + const root = mkdtempSync(path.join(tmpdir(), 'enso-arch-')); + dirs.push(root); + mkdirSync(path.join(root, 'a', 'b'), { recursive: true }); + writeFileSync(path.join(root, 'x64.node'), thin(X86_64)); + writeFileSync(path.join(root, 'universal'), fat(ARM64, X86_64)); + writeFileSync(path.join(root, 'a', 'b', 'wrong.node'), thin(ARM64)); + writeFileSync(path.join(root, 'a', 'readme.txt'), 'hello'); + symlinkSync(path.join(root, 'a', 'b', 'wrong.node'), path.join(root, 'link.node')); + expect(findArchMismatches(root, 'x64')).toEqual([ + { file: path.join('a', 'b', 'wrong.node'), archs: ['arm64'] }, + ]); + expect(findArchMismatches(root, 'arm64')).toEqual([{ file: 'x64.node', archs: ['x64'] }]); + }); +}); diff --git a/src/tooling/stripPackagedNatives.d.mts b/src/tooling/stripPackagedNatives.d.mts index 9a67f845..9f69e58f 100644 --- a/src/tooling/stripPackagedNatives.d.mts +++ b/src/tooling/stripPackagedNatives.d.mts @@ -27,6 +27,12 @@ export function stripForeignSqlitePrebuilds( arch: string ): string[]; +export function stripForeignPrebuildDirs( + prebuildsDir: string, + platform: string, + arch: string +): string[]; + export function afterPack(context: AfterPackContext): Promise; export default afterPack; diff --git a/src/tooling/stripPackagedNatives.mjs b/src/tooling/stripPackagedNatives.mjs index 57a7c784..0235118e 100644 --- a/src/tooling/stripPackagedNatives.mjs +++ b/src/tooling/stripPackagedNatives.mjs @@ -33,6 +33,19 @@ export function stripForeignSqlitePrebuilds(prebuildsDir, platform, arch) { return removed; } +/** 按 `-/` 分目录的预编译只留目标一份,交叉打包时别架构的 .node 不混进包 */ +export function stripForeignPrebuildDirs(prebuildsDir, platform, arch) { + if (!existsSync(prebuildsDir)) return []; + const keep = `${platform}-${arch}`; + const removed = []; + for (const name of readdirSync(prebuildsDir)) { + if (name === keep) continue; + rmSync(path.join(prebuildsDir, name), { recursive: true, force: true }); + removed.push(name); + } + return removed; +} + function archName(arch) { if (typeof arch === 'string') return arch; return ARCH_NAME[arch] ?? String(arch); @@ -42,13 +55,18 @@ export async function afterPack(context) { const platform = context.electronPlatformName; const arch = archName(context.arch); const productFilename = context.packager.appInfo.productFilename; - const prebuildsDir = path.join( + const modules = path.join( unpackedAppDir(context.appOutDir, platform, productFilename), - 'node_modules', - 'better-sqlite3', - 'prebuilds' + 'node_modules' ); - stripForeignSqlitePrebuilds(prebuildsDir, platform, arch); + stripForeignSqlitePrebuilds(path.join(modules, 'better-sqlite3', 'prebuilds'), platform, arch); + stripForeignPrebuildDirs(path.join(modules, 'node-pty', 'prebuilds'), platform, arch); + const tuiNative = path.join(modules, '@earendil-works', 'pi-tui', 'native'); + if (existsSync(tuiNative)) { + for (const name of readdirSync(tuiNative)) { + stripForeignPrebuildDirs(path.join(tuiNative, name, 'prebuilds'), platform, arch); + } + } } export default afterPack; diff --git a/src/tooling/stripPackagedNatives.test.ts b/src/tooling/stripPackagedNatives.test.ts index f0ce16db..16fe6a1d 100644 --- a/src/tooling/stripPackagedNatives.test.ts +++ b/src/tooling/stripPackagedNatives.test.ts @@ -5,6 +5,7 @@ import { afterEach, describe, expect, it } from 'vitest'; import { afterPack, hostPrebuildName, + stripForeignPrebuildDirs, stripForeignSqlitePrebuilds, unpackedAppDir, } from './stripPackagedNatives.mjs'; @@ -56,20 +57,40 @@ describe('stripPackagedNatives', () => { const root = mkdtempSync(path.join(tmpdir(), 'enso-missing-')); dirs.push(root); expect(stripForeignSqlitePrebuilds(path.join(root, 'nope'), 'darwin', 'arm64')).toEqual([]); + expect(stripForeignPrebuildDirs(path.join(root, 'nope'), 'darwin', 'x64')).toEqual([]); + }); + + it('keeps only the target - prebuild directory', () => { + const root = mkdtempSync(path.join(tmpdir(), 'enso-pty-')); + dirs.push(root); + for (const name of ['darwin-arm64', 'darwin-x64', 'win32-x64', 'win32-arm64']) { + mkdirSync(path.join(root, name)); + writeFileSync(path.join(root, name, 'pty.node'), 'x'); + } + expect(stripForeignPrebuildDirs(root, 'darwin', 'x64').sort()).toEqual([ + 'darwin-arm64', + 'win32-arm64', + 'win32-x64', + ]); + expect(readdirSync(root)).toEqual(['darwin-x64']); }); it('afterPack strips foreign prebuilds from the mac unpacked tree', async () => { const appOutDir = mkdtempSync(path.join(tmpdir(), 'enso-appout-')); dirs.push(appOutDir); - const prebuilds = path.join( - unpackedAppDir(appOutDir, 'darwin', 'EnsoCode'), - 'node_modules', - 'better-sqlite3', - 'prebuilds' - ); + const modules = path.join(unpackedAppDir(appOutDir, 'darwin', 'EnsoCode'), 'node_modules'); + const prebuilds = path.join(modules, 'better-sqlite3', 'prebuilds'); + const ptyPrebuilds = path.join(modules, 'node-pty', 'prebuilds'); + const tuiNative = path.join(modules, '@earendil-works', 'pi-tui', 'native'); mkdirSync(prebuilds, { recursive: true }); writeFileSync(path.join(prebuilds, 'darwin-arm64.node'), 'keep'); writeFileSync(path.join(prebuilds, 'linux-x64.node'), 'drop'); + mkdirSync(path.join(ptyPrebuilds, 'darwin-arm64'), { recursive: true }); + mkdirSync(path.join(ptyPrebuilds, 'darwin-x64'), { recursive: true }); + for (const dir of ['darwin/prebuilds/darwin-arm64', 'darwin/prebuilds/darwin-x64']) { + mkdirSync(path.join(tuiNative, dir), { recursive: true }); + } + mkdirSync(path.join(tuiNative, 'win32', 'prebuilds', 'win32-x64'), { recursive: true }); await afterPack({ appOutDir, electronPlatformName: 'darwin', @@ -77,5 +98,8 @@ describe('stripPackagedNatives', () => { packager: { appInfo: { productFilename: 'EnsoCode' } }, }); expect(readdirSync(prebuilds)).toEqual(['darwin-arm64.node']); + expect(readdirSync(ptyPrebuilds)).toEqual(['darwin-arm64']); + expect(readdirSync(path.join(tuiNative, 'darwin', 'prebuilds'))).toEqual(['darwin-arm64']); + expect(readdirSync(path.join(tuiNative, 'win32', 'prebuilds'))).toEqual([]); }); }); From 0609a9ce1c7ea9f8c2231a18e639343778e2128b Mon Sep 17 00:00:00 2001 From: J3n5en Date: Thu, 24 Sep 2026 01:46:30 +0800 Subject: [PATCH 2/7] =?UTF-8?q?fix(packaging):=20Windows=20=E5=AE=89?= =?UTF-8?q?=E8=A3=85=E5=8C=85=E8=A1=A5=E4=B8=8A=20sqlite-vec=20=E5=B9=B3?= =?UTF-8?q?=E5=8F=B0=E5=8C=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 收集器漏掉 optional 平台兄弟包(同 node-datachannel),v0.1.34 Windows 包内无 sqlite-vec-windows-x64,记忆向量检索退回扫 BLOB。显式拷贝平台包进包。 --- electron-builder.yml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/electron-builder.yml b/electron-builder.yml index f88c3303..0fb110a5 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -3,6 +3,10 @@ productName: EnsoCode directories: buildResources: build extraResources: + # 配合 files 里的 sqlite-vec-windows-x64:from/to 拷进来的文件不吃 asarUnpack, + # 而 loadExtension 读不了 asar 内的 dll,真实文件放到 app.asar.unpacked + - from: node_modules/.pnpm/sqlite-vec-windows-x64@0.1.9/node_modules/sqlite-vec-windows-x64 + to: app.asar.unpacked/node_modules/sqlite-vec-windows-x64 - from: build/trayTemplate.png to: trayTemplate.png - from: build/trayTemplate@2x.png @@ -31,6 +35,10 @@ files: # require('@node-datachannel/') 在安装包里找不到(Windows 直连会降级中继) - from: node_modules/.pnpm/node-datachannel@0.33.2/node_modules/@node-datachannel to: node_modules/@node-datachannel + # 收集器在 Windows 上不跟进 pnpm junction,漏掉 sqlite-vec 平台包(向量检索退回扫 BLOB); + # 直接拷真实目录供 require.resolve 定位(dll 本体见 extraResources),其他平台没有该目录时跳过 + - from: node_modules/.pnpm/sqlite-vec-windows-x64@0.1.9/node_modules/sqlite-vec-windows-x64 + to: node_modules/sqlite-vec-windows-x64 # node-llama-cpp 平台预编译全部按需下载(含 CPU / Metal / CUDA / Vulkan) - '!node_modules/@node-llama-cpp/**' - '!node_modules/.pnpm/@node-llama-cpp+*/**' From d6e72ce767611bee0c337ae7c98a225f22136dbd Mon Sep 17 00:00:00 2001 From: J3n5en Date: Thu, 24 Sep 2026 01:46:30 +0800 Subject: [PATCH 3/7] =?UTF-8?q?ci:=20=E5=86=92=E7=83=9F=E8=84=9A=E6=9C=AC?= =?UTF-8?q?=E6=8C=89=20file=20URL=20=E5=AF=BC=E5=85=A5=20ESM=EF=BC=8Cmac?= =?UTF-8?q?=20=E5=8F=8C=E6=9E=B6=E6=9E=84=E4=BA=92=E4=B8=8D=E5=8F=96?= =?UTF-8?q?=E6=B6=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/build.yml | 1 + scripts/smoke-packaged-natives.cjs | 6 ++++-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index d24756eb..e688807d 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -56,6 +56,7 @@ jobs: build-mac: needs: build-app strategy: + fail-fast: false matrix: arch: [arm64, x64] # x64 也在 Apple 芯片上交叉打包:Intel runner 各阶段慢 2–4 倍。 diff --git a/scripts/smoke-packaged-natives.cjs b/scripts/smoke-packaged-natives.cjs index d91724e9..48b2ffec 100644 --- a/scripts/smoke-packaged-natives.cjs +++ b/scripts/smoke-packaged-natives.cjs @@ -5,9 +5,11 @@ const { mkdtempSync, rmSync } = require('node:fs'); const { createRequire } = require('node:module'); const os = require('node:os'); const path = require('node:path'); +const { pathToFileURL } = require('node:url'); const resources = path.resolve(process.argv[2] ?? ''); const appRequire = createRequire(path.join(resources, 'app.asar', 'package.json')); +const appImport = (name) => import(pathToFileURL(appRequire.resolve(name)).href); const unpacked = (file) => file.replace(/app\.asar([\\/])/, 'app.asar.unpacked$1'); const isWindows = process.platform === 'win32'; @@ -19,7 +21,7 @@ const checks = { return db.prepare('select vec_version() as v').get().v; }, 'classic-level': async () => { - const { Level } = await import(appRequire.resolve('level')); + const { Level } = await appImport('level'); const dir = mkdtempSync(path.join(os.tmpdir(), 'enso-smoke-level-')); try { const db = new Level(dir); @@ -49,7 +51,7 @@ const checks = { ); }), 'node-datachannel': async () => { - const ndc = await import(appRequire.resolve('node-datachannel')); + const ndc = await appImport('node-datachannel'); const peer = new ndc.PeerConnection('smoke', { iceServers: [] }); peer.close(); return 'loaded'; From 79c4622c4f00302c8ddf0dd0c97c9d58459e99c6 Mon Sep 17 00:00:00 2001 From: J3n5en Date: Thu, 24 Sep 2026 01:55:20 +0800 Subject: [PATCH 4/7] =?UTF-8?q?fix(packaging):=20node-pty=20=E9=A2=84?= =?UTF-8?q?=E7=BC=96=E8=AF=91=20spawn-helper=20=E8=A1=A5=E6=89=A7=E8=A1=8C?= =?UTF-8?q?=E4=BD=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit node-pty 1.1.0 发布包内 prebuilds/darwin-*/spawn-helper 为 644;不再为 Electron 重编后直接用 prebuilds,posix_spawnp 失败导致终端起不来。afterPack 与 postinstall 都补执行位。 --- package.json | 2 +- scripts/fix-node-pty-helper.mjs | 7 +++++++ src/tooling/stripPackagedNatives.d.mts | 2 ++ src/tooling/stripPackagedNatives.mjs | 19 +++++++++++++++++-- src/tooling/stripPackagedNatives.test.ts | 20 +++++++++++++++++++- 5 files changed, 46 insertions(+), 4 deletions(-) create mode 100644 scripts/fix-node-pty-helper.mjs diff --git a/package.json b/package.json index d03a5f1d..5e36de96 100644 --- a/package.json +++ b/package.json @@ -29,7 +29,7 @@ "test": "node --test scripts/rtk.test.mjs && vitest run", "test:watch": "vitest", "format": "biome format --write .", - "postinstall": "node node_modules/electron/install.js" + "postinstall": "node node_modules/electron/install.js && node scripts/fix-node-pty-helper.mjs" }, "dependencies": { "@bufbuild/protobuf": "^2.14.0", diff --git a/scripts/fix-node-pty-helper.mjs b/scripts/fix-node-pty-helper.mjs new file mode 100644 index 00000000..e346f496 --- /dev/null +++ b/scripts/fix-node-pty-helper.mjs @@ -0,0 +1,7 @@ +import { createRequire } from 'node:module'; +import path from 'node:path'; +import { ensurePtyHelpersExecutable } from '../src/tooling/stripPackagedNatives.mjs'; + +// 不再为 Electron 重编 node-pty 后直接用它的 prebuilds,本地开发同样需要可执行的 spawn-helper +const root = path.dirname(createRequire(import.meta.url).resolve('node-pty/package.json')); +ensurePtyHelpersExecutable(path.join(root, 'prebuilds')); diff --git a/src/tooling/stripPackagedNatives.d.mts b/src/tooling/stripPackagedNatives.d.mts index 9f69e58f..83aa73b6 100644 --- a/src/tooling/stripPackagedNatives.d.mts +++ b/src/tooling/stripPackagedNatives.d.mts @@ -33,6 +33,8 @@ export function stripForeignPrebuildDirs( arch: string ): string[]; +export function ensurePtyHelpersExecutable(prebuildsDir: string): string[]; + export function afterPack(context: AfterPackContext): Promise; export default afterPack; diff --git a/src/tooling/stripPackagedNatives.mjs b/src/tooling/stripPackagedNatives.mjs index 0235118e..5d1d4b81 100644 --- a/src/tooling/stripPackagedNatives.mjs +++ b/src/tooling/stripPackagedNatives.mjs @@ -1,4 +1,4 @@ -import { existsSync, readdirSync, rmSync } from 'node:fs'; +import { chmodSync, existsSync, readdirSync, rmSync, statSync } from 'node:fs'; import path from 'node:path'; /** electron-builder Arch enum: ia32, x64, armv7l, arm64, universal */ @@ -46,6 +46,19 @@ export function stripForeignPrebuildDirs(prebuildsDir, platform, arch) { return removed; } +/** node-pty 1.1.0 发布包里 prebuilds 的 spawn-helper 是 644,posix_spawnp 会直接失败 */ +export function ensurePtyHelpersExecutable(prebuildsDir) { + if (!existsSync(prebuildsDir)) return []; + const fixed = []; + for (const name of readdirSync(prebuildsDir)) { + const helper = path.join(prebuildsDir, name, 'spawn-helper'); + if (!existsSync(helper)) continue; + chmodSync(helper, statSync(helper).mode | 0o755); + fixed.push(helper); + } + return fixed; +} + function archName(arch) { if (typeof arch === 'string') return arch; return ARCH_NAME[arch] ?? String(arch); @@ -60,7 +73,9 @@ export async function afterPack(context) { 'node_modules' ); stripForeignSqlitePrebuilds(path.join(modules, 'better-sqlite3', 'prebuilds'), platform, arch); - stripForeignPrebuildDirs(path.join(modules, 'node-pty', 'prebuilds'), platform, arch); + const ptyPrebuilds = path.join(modules, 'node-pty', 'prebuilds'); + stripForeignPrebuildDirs(ptyPrebuilds, platform, arch); + ensurePtyHelpersExecutable(ptyPrebuilds); const tuiNative = path.join(modules, '@earendil-works', 'pi-tui', 'native'); if (existsSync(tuiNative)) { for (const name of readdirSync(tuiNative)) { diff --git a/src/tooling/stripPackagedNatives.test.ts b/src/tooling/stripPackagedNatives.test.ts index 16fe6a1d..69ee64ca 100644 --- a/src/tooling/stripPackagedNatives.test.ts +++ b/src/tooling/stripPackagedNatives.test.ts @@ -1,9 +1,10 @@ -import { mkdirSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from 'node:fs'; +import { mkdirSync, mkdtempSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import path from 'node:path'; import { afterEach, describe, expect, it } from 'vitest'; import { afterPack, + ensurePtyHelpersExecutable, hostPrebuildName, stripForeignPrebuildDirs, stripForeignSqlitePrebuilds, @@ -75,6 +76,19 @@ describe('stripPackagedNatives', () => { expect(readdirSync(root)).toEqual(['darwin-x64']); }); + it('给 node-pty 预编译的 spawn-helper 补执行位(npm 包里是 644)', () => { + const root = mkdtempSync(path.join(tmpdir(), 'enso-pty-helper-')); + dirs.push(root); + for (const name of ['darwin-arm64', 'darwin-x64', 'win32-x64']) { + mkdirSync(path.join(root, name)); + } + writeFileSync(path.join(root, 'darwin-arm64', 'spawn-helper'), 'x', { mode: 0o644 }); + writeFileSync(path.join(root, 'darwin-x64', 'spawn-helper'), 'x', { mode: 0o644 }); + expect(ensurePtyHelpersExecutable(root)).toHaveLength(2); + expect(statSync(path.join(root, 'darwin-x64', 'spawn-helper')).mode & 0o111).toBe(0o111); + expect(ensurePtyHelpersExecutable(path.join(root, 'nope'))).toEqual([]); + }); + it('afterPack strips foreign prebuilds from the mac unpacked tree', async () => { const appOutDir = mkdtempSync(path.join(tmpdir(), 'enso-appout-')); dirs.push(appOutDir); @@ -86,6 +100,7 @@ describe('stripPackagedNatives', () => { writeFileSync(path.join(prebuilds, 'darwin-arm64.node'), 'keep'); writeFileSync(path.join(prebuilds, 'linux-x64.node'), 'drop'); mkdirSync(path.join(ptyPrebuilds, 'darwin-arm64'), { recursive: true }); + writeFileSync(path.join(ptyPrebuilds, 'darwin-arm64', 'spawn-helper'), 'x', { mode: 0o644 }); mkdirSync(path.join(ptyPrebuilds, 'darwin-x64'), { recursive: true }); for (const dir of ['darwin/prebuilds/darwin-arm64', 'darwin/prebuilds/darwin-x64']) { mkdirSync(path.join(tuiNative, dir), { recursive: true }); @@ -99,6 +114,9 @@ describe('stripPackagedNatives', () => { }); expect(readdirSync(prebuilds)).toEqual(['darwin-arm64.node']); expect(readdirSync(ptyPrebuilds)).toEqual(['darwin-arm64']); + expect(statSync(path.join(ptyPrebuilds, 'darwin-arm64', 'spawn-helper')).mode & 0o111).toBe( + 0o111 + ); expect(readdirSync(path.join(tuiNative, 'darwin', 'prebuilds'))).toEqual(['darwin-arm64']); expect(readdirSync(path.join(tuiNative, 'win32', 'prebuilds'))).toEqual([]); }); From 212939225bce734d52d2626bd497317eab3f2d31 Mon Sep 17 00:00:00 2001 From: J3n5en Date: Thu, 24 Sep 2026 04:50:01 +0800 Subject: [PATCH 5/7] =?UTF-8?q?ci:=20=E6=89=93=E5=8C=85=E5=86=92=E7=83=9F?= =?UTF-8?q?=E8=A6=86=E7=9B=96=20pi-tui=20=E5=8E=9F=E7=94=9F=E5=89=AA?= =?UTF-8?q?=E8=B4=B4=E6=9D=BF=E6=A8=A1=E5=9D=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/smoke-packaged-natives.cjs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/scripts/smoke-packaged-natives.cjs b/scripts/smoke-packaged-natives.cjs index 48b2ffec..eaa22fb5 100644 --- a/scripts/smoke-packaged-natives.cjs +++ b/scripts/smoke-packaged-natives.cjs @@ -56,6 +56,14 @@ const checks = { peer.close(); return 'loaded'; }, + 'pi-tui native': async () => { + const { getNativeClipboard } = await appImport('@earendil-works/pi-tui'); + // Linux 原生剪贴板依赖 X11 DISPLAY,CI 无头环境按设计返回 undefined + if (process.platform === 'linux' && !process.env.DISPLAY) return 'skipped (no DISPLAY)'; + const helper = getNativeClipboard(); + if (typeof helper?.getText !== 'function') throw new Error('native clipboard helper missing'); + return 'loaded'; + }, ripgrep: () => execFileSync(unpacked(appRequire('@vscode/ripgrep').rgPath), ['--version']) .toString() From 80c55544ebc5cda4b46db2c54d10131a4a44d643 Mon Sep 17 00:00:00 2001 From: J3n5en Date: Thu, 24 Sep 2026 04:59:41 +0800 Subject: [PATCH 6/7] =?UTF-8?q?ci:=20=E9=9D=9E=20tag=20=E6=9E=84=E5=BB=BA?= =?UTF-8?q?=E4=B8=8A=E4=BC=A0=20mac=20dmg=20=E4=BE=9B=E4=B8=8B=E8=BD=BD?= =?UTF-8?q?=E8=AF=95=E8=A3=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/build.yml | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index e688807d..f9c109dd 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -131,6 +131,15 @@ jobs: path: dist/latest-mac.yml retention-days: 1 + # 同 Windows:非 tag 构建不发布,传上来才能下载试装(x64 交叉包可拿到 Intel Mac 上验) + - name: Upload installer + if: ${{ !startsWith(github.ref, 'refs/tags/') }} + uses: actions/upload-artifact@v6 + with: + name: mac-installer-${{ matrix.arch }} + path: dist/*.dmg + retention-days: 7 + build-windows: needs: build-app # windows-latest now ships VS 2026, which node-gyp <12.1 cannot detect From 984deca05e1d9e4c8fd6920d7a88d4d54cf3fe65 Mon Sep 17 00:00:00 2001 From: J3n5en Date: Thu, 24 Sep 2026 05:14:40 +0800 Subject: [PATCH 7/7] =?UTF-8?q?chore(packaging):=20=E5=8F=AA=E4=BF=9D?= =?UTF-8?q?=E7=95=99=E7=9B=AE=E6=A0=87=E5=B9=B3=E5=8F=B0=E7=9A=84=20classi?= =?UTF-8?q?c-level=20=E9=A2=84=E7=BC=96=E8=AF=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit mac 包里 Linux/Windows/Android 预编译约 3.9MB,dmg 小 1.8MB。支持 x64+arm64 这类多架构目录名。 --- src/tooling/stripPackagedNatives.mjs | 9 ++++++--- src/tooling/stripPackagedNatives.test.ts | 19 +++++++++++++++++++ 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/src/tooling/stripPackagedNatives.mjs b/src/tooling/stripPackagedNatives.mjs index 5d1d4b81..d4d16de1 100644 --- a/src/tooling/stripPackagedNatives.mjs +++ b/src/tooling/stripPackagedNatives.mjs @@ -33,13 +33,15 @@ export function stripForeignSqlitePrebuilds(prebuildsDir, platform, arch) { return removed; } -/** 按 `-/` 分目录的预编译只留目标一份,交叉打包时别架构的 .node 不混进包 */ +/** 按 `-[+]/` 分目录的预编译只留目标一份,交叉打包时别架构的 .node 不混进包 */ export function stripForeignPrebuildDirs(prebuildsDir, platform, arch) { if (!existsSync(prebuildsDir)) return []; - const keep = `${platform}-${arch}`; + const prefix = `${platform}-`; + const keep = (name) => + name.startsWith(prefix) && name.slice(prefix.length).split('+').includes(arch); const removed = []; for (const name of readdirSync(prebuildsDir)) { - if (name === keep) continue; + if (keep(name)) continue; rmSync(path.join(prebuildsDir, name), { recursive: true, force: true }); removed.push(name); } @@ -76,6 +78,7 @@ export async function afterPack(context) { const ptyPrebuilds = path.join(modules, 'node-pty', 'prebuilds'); stripForeignPrebuildDirs(ptyPrebuilds, platform, arch); ensurePtyHelpersExecutable(ptyPrebuilds); + stripForeignPrebuildDirs(path.join(modules, 'classic-level', 'prebuilds'), platform, arch); const tuiNative = path.join(modules, '@earendil-works', 'pi-tui', 'native'); if (existsSync(tuiNative)) { for (const name of readdirSync(tuiNative)) { diff --git a/src/tooling/stripPackagedNatives.test.ts b/src/tooling/stripPackagedNatives.test.ts index 69ee64ca..dca843ce 100644 --- a/src/tooling/stripPackagedNatives.test.ts +++ b/src/tooling/stripPackagedNatives.test.ts @@ -76,6 +76,20 @@ describe('stripPackagedNatives', () => { expect(readdirSync(root)).toEqual(['darwin-x64']); }); + it('多架构目录(x64+arm64)对其中任一架构都保留', () => { + const root = mkdtempSync(path.join(tmpdir(), 'enso-multiarch-')); + dirs.push(root); + for (const name of ['darwin-x64+arm64', 'linux-x64', 'win32-x64', 'android-arm64']) { + mkdirSync(path.join(root, name)); + } + expect(stripForeignPrebuildDirs(root, 'darwin', 'arm64').sort()).toEqual([ + 'android-arm64', + 'linux-x64', + 'win32-x64', + ]); + expect(readdirSync(root)).toEqual(['darwin-x64+arm64']); + }); + it('给 node-pty 预编译的 spawn-helper 补执行位(npm 包里是 644)', () => { const root = mkdtempSync(path.join(tmpdir(), 'enso-pty-helper-')); dirs.push(root); @@ -96,6 +110,7 @@ describe('stripPackagedNatives', () => { const prebuilds = path.join(modules, 'better-sqlite3', 'prebuilds'); const ptyPrebuilds = path.join(modules, 'node-pty', 'prebuilds'); const tuiNative = path.join(modules, '@earendil-works', 'pi-tui', 'native'); + const levelPrebuilds = path.join(modules, 'classic-level', 'prebuilds'); mkdirSync(prebuilds, { recursive: true }); writeFileSync(path.join(prebuilds, 'darwin-arm64.node'), 'keep'); writeFileSync(path.join(prebuilds, 'linux-x64.node'), 'drop'); @@ -106,6 +121,9 @@ describe('stripPackagedNatives', () => { mkdirSync(path.join(tuiNative, dir), { recursive: true }); } mkdirSync(path.join(tuiNative, 'win32', 'prebuilds', 'win32-x64'), { recursive: true }); + for (const dir of ['darwin-x64+arm64', 'linux-x64', 'win32-x64']) { + mkdirSync(path.join(levelPrebuilds, dir), { recursive: true }); + } await afterPack({ appOutDir, electronPlatformName: 'darwin', @@ -119,5 +137,6 @@ describe('stripPackagedNatives', () => { ); expect(readdirSync(path.join(tuiNative, 'darwin', 'prebuilds'))).toEqual(['darwin-arm64']); expect(readdirSync(path.join(tuiNative, 'win32', 'prebuilds'))).toEqual([]); + expect(readdirSync(levelPrebuilds)).toEqual(['darwin-x64+arm64']); }); });