Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 32 additions & 7 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -56,13 +56,12 @@ jobs:
build-mac:
needs: build-app
strategy:
fail-fast: false
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
Expand All @@ -83,9 +82,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:
Expand Down Expand Up @@ -115,13 +116,30 @@ 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:
name: latest-mac-${{ matrix.arch }}
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
Expand Down Expand Up @@ -155,6 +173,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
Expand Down Expand Up @@ -208,6 +230,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
Expand Down
8 changes: 8 additions & 0 deletions electron-builder.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -31,6 +35,10 @@ files:
# require('@node-datachannel/<platform>') 在安装包里找不到(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+*/**'
Expand Down
7 changes: 4 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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 && node scripts/fix-node-pty-helper.mjs"
},
"dependencies": {
"@bufbuild/protobuf": "^2.14.0",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -124,7 +123,6 @@
"onlyBuiltDependencies": [
"@google/genai",
"@vscode/ripgrep",
"better-sqlite3",
"classic-level",
"electron",
"electron-winstaller",
Expand All @@ -135,6 +133,9 @@
"protobufjs",
"workerd"
],
"ignoredBuiltDependencies": [
"better-sqlite3"
],
"patchedDependencies": {
"@rahularya01/pi-cursor@1.4.36": "patches/@rahularya01__pi-cursor@1.4.36.patch"
}
Expand Down
3 changes: 0 additions & 3 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

10 changes: 10 additions & 0 deletions scripts/after-pack.mjs
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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')}`);
}
}
}
7 changes: 7 additions & 0 deletions scripts/fix-node-pty-helper.mjs
Original file line number Diff line number Diff line change
@@ -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'));
89 changes: 89 additions & 0 deletions scripts/smoke-packaged-natives.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
// 用打包产物自带的 Electron 以 node 模式真实加载每个原生依赖、执行每个内置二进制。
// 用法:ELECTRON_RUN_AS_NODE=1 <打包后的可执行文件> scripts/smoke-packaged-natives.cjs <resources 目录>
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 { 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';

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 appImport('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 appImport('node-datachannel');
const peer = new ndc.PeerConnection('smoke', { iceServers: [] });
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()
.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);
})();
8 changes: 8 additions & 0 deletions src/tooling/packagedArch.d.mts
Original file line number Diff line number Diff line change
@@ -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[];
57 changes: 57 additions & 0 deletions src/tooling/packagedArch.mjs
Original file line number Diff line number Diff line change
@@ -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;
}
58 changes: 58 additions & 0 deletions src/tooling/packagedArch.test.ts
Original file line number Diff line number Diff line change
@@ -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'] }]);
});
});
Loading
Loading