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
55 changes: 55 additions & 0 deletions apps/desktop/forge-node-pty.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import * as fs from 'node:fs';
import * as path from 'node:path';

/** Prepare node-pty in the disposable package copy, never in the source checkout. */
export function preparePackagedNodePty(buildPath: string, platform: string, arch: string) {
const packageDir = path.join(buildPath, 'node_modules', 'node-pty');
// The pinned npm package ships macOS/Windows prebuilds; Linux still builds from source.
if (platform !== 'win32' && platform !== 'darwin') {
return {
rebuild: true,
nativePath: path.join(packageDir, 'build', 'Release', 'pty.node'),
};
}

const prebuildDir = path.join(packageDir, 'prebuilds', `${platform}-${arch}`);
const required =
platform === 'win32'
? [
'pty.node',
'conpty.node',
'conpty_console_list.node',
'winpty-agent.exe',
'winpty.dll',
path.join('conpty', 'conpty.dll'),
path.join('conpty', 'OpenConsole.exe'),
]
: ['pty.node', 'spawn-helper'];
const missing = required.filter((file) => {
try {
const info = fs.statSync(path.join(prebuildDir, file));
return !info.isFile() || info.size === 0;
} catch {
return true;
}
});
if (missing.length) {
throw new Error(
`[forge:afterCopy] node-pty prebuild for ${platform}-${arch} is incomplete: ${missing.join(', ')}. ` +
'Reinstall workspace dependencies with pnpm install --force --frozen-lockfile, then package again.',
);
}

// node-pty loads build/Release and build/Debug before prebuilds. Remove copied
// local bindings so a stale Node ABI or another architecture cannot shadow the target.
for (const variant of ['Release', 'Debug']) {
for (const file of ['pty.node', 'conpty.node', 'conpty_console_list.node']) {
fs.rmSync(path.join(packageDir, 'build', variant, file), { force: true });
}
}
if (platform === 'darwin') {
// pnpm can lose the executable bit while importing spawn-helper from its store.
fs.chmodSync(path.join(prebuildDir, 'spawn-helper'), 0o755);
}
return { rebuild: false, nativePath: path.join(prebuildDir, 'pty.node') };
}
34 changes: 14 additions & 20 deletions apps/desktop/forge.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
resolveCindyRegion,
} from '@cindy/maker-shared/brand-identity';
import { stageMacIOSSimulatorHelper } from './forge-ios-simulator-helper';
import { preparePackagedNodePty } from './forge-node-pty';
import { stagePackagedThirdPartyNotices } from './forge-third-party-notices';
import {
swiftTargetTriple,
Expand Down Expand Up @@ -190,8 +191,8 @@ const NATIVE_RUNTIME_DEPS = [
// 无需带运行时闭包。注意: 该 runtime 用 CDP 接管用户已装 Chrome, 不需要
// playwright 自带的浏览器二进制, 故只带 JS 模块即可。
'playwright-core',
// node-pty (RSB 终端 tab 的 PTY 后端): .node 原生模块, 跟 better-sqlite3 同款 ——
// 必须 electron-rebuild (Node ABI ≠ Electron ABI), 必须随 packaged app 带,
// node-pty (RSB 终端 tab 的 PTY 后端): macOS/Windows 使用包内 N-API 预编译件,
// Linux 通过 electron-rebuild 编译;各平台都必须随 packaged app 带,
// AutoUnpackNativesPlugin 会把 .node 提取到 app.asar.unpacked/。main 进程通过
// createRequire 在运行时 require, 不让 vite bundle (见 vite.main.config.ts external)。
'node-pty',
Expand Down Expand Up @@ -405,24 +406,26 @@ function bundleNativeDeps(buildPath: string, targetPlatform: string, targetArch:
copyRuntimeDependencyTrees(READ_SHEET_RUNTIME_PACKAGES, destModules);
}

// 针对 packaged buildPath 的 node_modules 强制重建 better-sqlite3 —— force:true 确保
// 即使根 node_modules 里的 .node 是 Node ABI(pnpm install 默认),也会被 Electron ABI
// 覆盖重编。编完的 .node 落在 build/Release/better_sqlite3.node,下游
// AutoUnpackNativesPlugin 会在 asar 打包时把它提取到 app.asar.unpacked/。
// better-sqlite3 仍按 Electron ABI 重编。node-pty 在 macOS/Windows 使用 npm 包内
// 的 N-API 预编译件;缺件意味着依赖不完整,不能悄悄转入源码编译。Linux 继续重编。
async function rebuildNativeDepsInPackage(
buildPath: string,
electronVersion: string,
platform: string,
arch: string,
): Promise<void> {
const nodePty = preparePackagedNodePty(buildPath, platform, arch);
const modules = ['better-sqlite3'];
if (nodePty.rebuild) modules.push('node-pty');
console.log(
`[forge:afterCopy] rebuilding native modules (better-sqlite3, node-pty) for Electron ${electronVersion} (${arch})...`,
`[forge:afterCopy] rebuilding native modules (${modules.join(', ')}) for Electron ${electronVersion} (${arch})...`,
);
await electronRebuild({
buildPath,
electronVersion,
arch,
force: true,
onlyModules: ['better-sqlite3', 'node-pty'],
onlyModules: modules,
});
const sqliteNative = path.join(
buildPath,
Expand All @@ -435,18 +438,9 @@ async function rebuildNativeDepsInPackage(
if (!fs.existsSync(sqliteNative)) {
throw new Error(`[forge:afterCopy] rebuild reported success but ${sqliteNative} is missing`);
}
// node-pty 的 .node 在 build/Release/pty.node;Windows 上同名,Linux/macOS 同名。
// 跟 better-sqlite3 一样,缺了直接抛出,避免发出无法启动 PTY 的包。
const ptyNative = path.join(
buildPath,
'node_modules',
'node-pty',
'build',
'Release',
'pty.node',
);
const ptyNative = nodePty.nativePath;
if (!fs.existsSync(ptyNative)) {
throw new Error(`[forge:afterCopy] rebuild reported success but ${ptyNative} is missing`);
throw new Error(`[forge:afterCopy] node-pty native module missing: ${ptyNative}`);
}

// node-pty 被整目录纳入 asar.unpack(为放出 spawn-helper / winpty 等运行时二进制),
Expand Down Expand Up @@ -1979,7 +1973,7 @@ const config: ForgeConfig = {
(async () => {
try {
bundleNativeDeps(buildPath, platform, arch);
await rebuildNativeDepsInPackage(buildPath, electronVersion, arch);
await rebuildNativeDepsInPackage(buildPath, electronVersion, platform, arch);
copySqliteVecBinary(buildPath, platform, arch);
callback();
} catch (err) {
Expand Down
113 changes: 113 additions & 0 deletions apps/desktop/scripts/native-deps-packaging.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import * as fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { preparePackagedNodePty } from '../forge-node-pty';

const windowsFiles = [
'pty.node',
'conpty.node',
'conpty_console_list.node',
'winpty-agent.exe',
'winpty.dll',
path.join('conpty', 'conpty.dll'),
path.join('conpty', 'OpenConsole.exe'),
];
let root;
let fixtureId = 0;
beforeAll(() => {
root = fs.mkdtempSync(path.join(os.tmpdir(), 'cindy-pty-package-'));
});
afterAll(() => {
if (root) fs.rmSync(root, { recursive: true, force: true });
});

function fixture(platform = 'win32', arch = 'x64') {
const buildPath = path.join(root, String(++fixtureId));
const packageDir = path.join(buildPath, 'node_modules', 'node-pty');
const prebuildDir = path.join(packageDir, 'prebuilds', `${platform}-${arch}`);
const files = platform === 'win32' ? windowsFiles : ['pty.node', 'spawn-helper'];
for (const file of files) {
const target = path.join(prebuildDir, file);
fs.mkdirSync(path.dirname(target), { recursive: true });
fs.writeFileSync(target, 'fixture binary');
}
return { buildPath, packageDir, prebuildDir, files };
}

describe('native dependency packaging', () => {
it.each([
['win32', 'x64'],
['win32', 'arm64'],
['darwin', 'x64'],
['darwin', 'arm64'],
])('uses the complete %s/%s prebuild without requiring compilation', (platform, arch) => {
const f = fixture(platform, arch);
expect(preparePackagedNodePty(f.buildPath, platform, arch)).toEqual({
rebuild: false,
nativePath: path.join(f.prebuildDir, 'pty.node'),
});
for (const file of f.files) {
expect(fs.readFileSync(path.join(f.prebuildDir, file), 'utf8')).toBe('fixture binary');
}
});

it.each(windowsFiles)('rejects a missing Windows runtime file: %s', (file) => {
const f = fixture();
fs.unlinkSync(path.join(f.prebuildDir, file));
expect(() => preparePackagedNodePty(f.buildPath, 'win32', 'x64')).toThrow(
/prebuild.*incomplete.*Reinstall workspace dependencies/,
);
});

it.each(['empty', 'directory'])('rejects an %s binding instead of compiling it', (kind) => {
const f = fixture();
const file = path.join(f.prebuildDir, 'conpty.node');
fs.unlinkSync(file);
if (kind === 'empty') fs.writeFileSync(file, '');
else fs.mkdirSync(file);
expect(() => preparePackagedNodePty(f.buildPath, 'win32', 'x64')).toThrow('conpty.node');
});

it('does not accept prebuilds for another architecture or a stale local build', () => {
const f = fixture('win32', 'x64');
const stale = path.join(f.packageDir, 'build', 'Release', 'pty.node');
fs.mkdirSync(path.dirname(stale), { recursive: true });
fs.writeFileSync(stale, 'local binding');
expect(() => preparePackagedNodePty(f.buildPath, 'win32', 'arm64')).toThrow('win32-arm64');
expect(fs.readFileSync(stale, 'utf8')).toBe('local binding');
});

it('prevents copied Release and Debug bindings from shadowing valid target prebuilds', () => {
const f = fixture();
const stale = ['Release', 'Debug'].flatMap((variant) =>
['pty.node', 'conpty.node', 'conpty_console_list.node'].map((file) =>
path.join(f.packageDir, 'build', variant, file),
),
);
for (const file of stale) {
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, 'local binding');
}
const neighbor = path.join(f.packageDir, 'build', 'Release', 'keep.txt');
fs.writeFileSync(neighbor, 'keep');
preparePackagedNodePty(f.buildPath, 'win32', 'x64');
expect(stale.every((file) => !fs.existsSync(file))).toBe(true);
expect(fs.readFileSync(neighbor, 'utf8')).toBe('keep');
expect(fs.readFileSync(path.join(f.prebuildDir, 'conpty.node'), 'utf8')).toBe('fixture binary');
});

it('rejects a missing macOS spawn-helper before changing the package', () => {
const f = fixture('darwin', 'arm64');
fs.unlinkSync(path.join(f.prebuildDir, 'spawn-helper'));
expect(() => preparePackagedNodePty(f.buildPath, 'darwin', 'arm64')).toThrow('spawn-helper');
});

it.each(['x64', 'arm64'])('keeps the existing Linux/%s source build', (arch) => {
const f = fixture('linux', arch);
expect(preparePackagedNodePty(f.buildPath, 'linux', arch)).toEqual({
rebuild: true,
nativePath: path.join(f.packageDir, 'build', 'Release', 'pty.node'),
});
});
});
17 changes: 14 additions & 3 deletions apps/desktop/src/main/bootstrap-electron.ts
Original file line number Diff line number Diff line change
Expand Up @@ -383,8 +383,8 @@ import {
} from './cindy-make/versionService.js';
import {
deliverCindyVersionOpenEvents,
finishCindyVersionStartup,
isCindyVersionLaunchPending,
recordCindyVersionActive,
watchCindyVersionStartupResult,
} from './cindy-make/versionStartup.js';
import {
Expand Down Expand Up @@ -953,8 +953,10 @@ import {
findOpenFolderInArgv,
findOpenShareFileInArgv,
setDeepLinkMainWindow,
focusMainWindow as activateMainWindow,
takePendingDeepLink,
} from './deepLink.js';
import { createMakeTestWindowBehavior } from './cindy-make/testWindowBehavior.js';
import { registerFolderContextMenu } from './folderContextMenu.js';
import { healWindowsShortcuts } from './windowsShortcutSelfHeal.js';
import { CURRENT_APP_ID, CURRENT_CINDY_REGION } from '../shared/brandRegion.js';
Expand Down Expand Up @@ -3790,7 +3792,7 @@ if (
);
app.quit();
} else {
recordCindyVersionActive();
finishCindyVersionStartup();
app.on('second-instance', (_event, argv) => {
// Windows: 用户点 cindy://(或历史 xdt-maker://)链接 / 右键 "通过 Cindy 打开" 时,
// OS 会再起一个本 app 实例; 单例锁把它 redirect 成 second-instance 事件,
Expand Down Expand Up @@ -4017,8 +4019,15 @@ const createWindow = () => {
});
// Main-window close policy is explicit because hidden utility windows (for
// example the prewarmed global voice overlay) can keep the process alive.
const makeTestWindow = createMakeTestWindowBehavior({
isPackaged: app.isPackaged,
environment: process.env,
focus: () => activateMainWindow(),
quit: () => app.quit(),
});
mainWindow.on('close', (event) => {
if (isQuitting) return;
if (makeTestWindow.close(event)) return;
// macOS: keep the window + renderer alive and hide only, so Dock activation
// can restore it without remounting the renderer.
if (process.platform === 'darwin') {
Expand Down Expand Up @@ -4117,6 +4126,7 @@ const createWindow = () => {
showMainWindowAndRestoreFullscreen(mainWindow, {
restoreFullscreen: shouldRestoreMacFullscreen,
});
makeTestWindow.ready();
refreshWindowsAppBadge();
if (!app.isPackaged || isCindyVersionLaunchPending())
markDesktopDevWindowReady(mainWindow.webContents.getOSProcessId());
Expand Down Expand Up @@ -7614,7 +7624,7 @@ const registerIpcHandlers = () => {
const message = (error as { message?: unknown }).message;
const reason =
typeof message === 'string'
? /^\[PRECONDITION_FAILED\]\s*(busy|dirty|conflict|cleanupFailed|directoryBusy|unavailable)$/.exec(
? /^\[PRECONDITION_FAILED\]\s*(busy|dirty|conflict|cleanupFailed|directoryBusy|unavailable|stopFailed)$/.exec(
message,
)?.[1]
: undefined;
Expand Down Expand Up @@ -7662,6 +7672,7 @@ const registerIpcHandlers = () => {
assertTrustedAppRendererEvent(event);
return actCindyVersion(action, id);
});
onQuit('cindy-make-tests', () => cindyMakeTestController.stopAllAndWait(), 'async');
app.once('will-quit', () => cindyMakeTestController.stopAll());
ipcMain.handle(
'app:cindy-make-test',
Expand Down
Loading
Loading