Skip to content

Commit ba938f1

Browse files
committed
fix(desktop): bundle node runtime for packaged startup
1 parent 64b24bc commit ba938f1

5 files changed

Lines changed: 100 additions & 8 deletions

File tree

‎doc/desktop.md‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ Codex Mate 的桌面版使用 Tauri 作为 Windows / macOS 外壳,复用现有
99
- 桌面窗口加载 `http://127.0.0.1:3737`,避免重写现有 Web UI API。
1010
- Rust / Tauri 源码只参与桌面构建阶段,不进入主 npm CLI 包。
1111
- `npm run desktop:stage` 会先生成稳定运行时目录 `dist/desktop/codexmate/`,再由 Tauri 把这个目录作为单一 resource 打进 app。
12+
- 打包产物内置构建机当前 Node.js runtime,release 启动后端时优先使用 bundled `node-runtime/node(.exe)`,不依赖用户系统 PATH 里的 `node`。
1213

1314
## Staging 布局
1415

@@ -25,10 +26,11 @@ dist/desktop/codexmate/
2526
├── web-ui.html
2627
├── package.json
2728
├── package-lock.json
29+
├── node-runtime/ # bundled Node.js runtime used by release desktop startup
2830
└── node_modules/ # package-lock 中非 dev 的运行时依赖
2931
```
3032

31-
脚本会验证入口文件、Web UI、manifest、`node_modules` 和直接运行时依赖是否存在。这样可以提前暴露资源缺失,而不是等 `tauri build` 中途炸掉。
33+
脚本会验证入口文件、Web UI、manifest、`node_modules`、bundled Node runtime 和直接运行时依赖是否存在。这样可以提前暴露资源缺失,而不是等 `tauri build` 通过后才在用户机器上启动失败。
3234

3335
## 命令
3436

@@ -47,7 +49,7 @@ npm run desktop:build
4749
- Rust / Cargo
4850
- Tauri 对应平台依赖
4951

50-
当前实现仍通过系统 `node` 启动打包进 resources 的 Codex Mate 后端。后续如果要做完全免 Node 安装的分发,需要把 Node runtime 或预编译 sidecar 纳入打包流程。
52+
release 桌面包会内置 Node.js runtime 来启动打包进 resources 的 Codex Mate 后端;用户机器不需要预装 Node.js。调试或排障时仍可用 `CODEXMATE_NODE=/path/to/node` 显式覆盖 runtime。
5153

5254
## 启动诊断日志
5355

‎src-tauri/src/lib.rs‎

Lines changed: 46 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -481,6 +481,49 @@ fn find_cli_path(app: &tauri::App) -> Result<PathBuf, Box<dyn std::error::Error>
481481
.ok_or_else(|| "unable to locate bundled codexmate cli.js".into())
482482
}
483483

484+
fn bundled_node_executable_name() -> &'static str {
485+
if cfg!(windows) {
486+
"node.exe"
487+
} else {
488+
"node"
489+
}
490+
}
491+
492+
fn find_node_runtime_path(app: &tauri::App) -> Result<PathBuf, Box<dyn std::error::Error>> {
493+
if let Ok(value) = std::env::var("CODEXMATE_NODE") {
494+
let trimmed = value.trim();
495+
if !trimmed.is_empty() {
496+
return Ok(PathBuf::from(trimmed));
497+
}
498+
}
499+
500+
if let Ok(resource_dir) = app.path().resource_dir() {
501+
let candidates = [
502+
resource_dir
503+
.join("codexmate")
504+
.join("node-runtime")
505+
.join(bundled_node_executable_name()),
506+
resource_dir
507+
.join("node-runtime")
508+
.join(bundled_node_executable_name()),
509+
];
510+
511+
if let Some(candidate) = candidates.into_iter().find(|candidate| candidate.is_file()) {
512+
return Ok(candidate);
513+
}
514+
}
515+
516+
#[cfg(debug_assertions)]
517+
{
518+
Ok(PathBuf::from("node"))
519+
}
520+
521+
#[cfg(not(debug_assertions))]
522+
{
523+
startup_error("Codex Mate 打包产物缺少内置 Node.js runtime,无法启动后端。请重新下载安装包;如果问题持续,请查看 startup.log。详情:bundled node-runtime/node is missing")
524+
}
525+
}
526+
484527
fn spawn_backend(app: &tauri::App) -> Result<Option<Child>, Box<dyn std::error::Error>> {
485528
if std::env::var("CODEXMATE_DESKTOP_SKIP_BACKEND").ok().as_deref() == Some("1") {
486529
desktop_log("backend spawn skipped by CODEXMATE_DESKTOP_SKIP_BACKEND=1");
@@ -499,18 +542,18 @@ fn spawn_backend(app: &tauri::App) -> Result<Option<Child>, Box<dyn std::error::
499542
let cli_dir = cli_path
500543
.parent()
501544
.ok_or_else(|| "unable to resolve codexmate cli directory")?;
502-
let node_bin = std::env::var("CODEXMATE_NODE").unwrap_or_else(|_| "node".to_string());
545+
let node_bin = find_node_runtime_path(app)?;
503546
let inherit_backend_stdio = DESKTOP_CONSOLE_LOGGING.load(Ordering::Relaxed);
504547

505548
desktop_log(format!(
506549
"spawning backend; node={}; cli={}; cwd={}; inherit_stdio={}",
507-
node_bin,
550+
node_bin.display(),
508551
cli_path.display(),
509552
cli_dir.display(),
510553
inherit_backend_stdio
511554
));
512555

513-
let mut command = Command::new(node_bin);
556+
let mut command = Command::new(&node_bin);
514557
command
515558
.arg(&cli_path)
516559
.arg("run")

‎tests/unit/desktop-diagnostics-contract.test.mjs‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,24 @@ test('desktop backend startup diagnostics use fixed startup log for child stdio'
4242
assert.match(libSource, /if DESKTOP_CONSOLE_LOGGING\.load[\s\S]*return;[\s\S]*CREATE_NO_WINDOW/);
4343
});
4444

45+
46+
test('desktop release backend uses bundled Node runtime instead of requiring system PATH node', () => {
47+
const libSource = readSource('src-tauri/src/lib.rs');
48+
const stageSource = readSource('tools/desktop/prepare-tauri-resources.js');
49+
50+
assert.match(stageSource, /function copyNodeRuntime\(\)/);
51+
assert.match(stageSource, /process\.execPath/);
52+
assert.match(stageSource, /node-runtime/);
53+
assert.match(stageSource, /nodeRuntime/);
54+
assert.match(libSource, /fn find_node_runtime_path\(app: &tauri::App\)/);
55+
assert.match(libSource, /CODEXMATE_NODE/);
56+
assert.match(libSource, /node-runtime/);
57+
assert.match(libSource, /bundled_node_executable_name\(\)/);
58+
assert.match(libSource, /let node_bin = find_node_runtime_path\(app\)\?/);
59+
assert.match(libSource, /Command::new\(&node_bin\)/);
60+
assert.doesNotMatch(libSource, /unwrap_or_else\(\|_\| "node"\.to_string\(\)\)/);
61+
});
62+
4563
test('desktop startup force-cleans managed backend port listeners before spawning', () => {
4664
const libSource = readSource('src-tauri/src/lib.rs');
4765

‎tests/unit/desktop-stage.test.mjs‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,8 @@ test('desktop staging creates validated runtime resource layout', () => {
6767
'web-ui.html',
6868
'package.json',
6969
'package-lock.json',
70-
'node_modules'
70+
'node_modules',
71+
'node-runtime'
7172
];
7273

7374
for (const entry of requiredEntries) {
@@ -79,6 +80,8 @@ test('desktop staging creates validated runtime resource layout', () => {
7980
assert.strictEqual(manifest.layoutVersion, 1);
8081
assert.strictEqual(manifest.version, pkg.version);
8182
assert.strictEqual(manifest.entrypoint, 'cli.js');
83+
assert.match(manifest.nodeRuntime, /^node-runtime\/node(\.exe)?$/);
84+
assert.ok(fs.existsSync(path.join(stageRoot, manifest.nodeRuntime)), 'manifest should point at the bundled Node.js runtime');
8285
assert.ok(manifest.copiedRuntimeModules > 0, 'manifest should record copied runtime node modules');
8386

8487
for (const dependencyName of Object.keys(pkg.dependencies || {})) {

‎tools/desktop/prepare-tauri-resources.js‎

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ const cargoTomlPath = path.join(rootDir, 'src-tauri', 'Cargo.toml');
1313
const stageRelativePath = path.join('dist', 'desktop', 'codexmate');
1414
const stageDir = path.join(rootDir, stageRelativePath);
1515
const stageNodeModulesDir = path.join(stageDir, 'node_modules');
16+
const stageNodeRuntimeDir = path.join(stageDir, 'node-runtime');
1617
const TAURI_CSP = "default-src 'self' http://127.0.0.1:3737; connect-src 'self' http://127.0.0.1:3737; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self'";
1718

1819
const runtimeEntries = [
@@ -110,12 +111,32 @@ function copyRuntimeNodeModules(pkg, lockFile) {
110111
return copied;
111112
}
112113

113-
function writeStageManifest(pkg, copiedModules) {
114+
function nodeExecutableName() {
115+
return process.platform === 'win32' ? 'node.exe' : 'node';
116+
}
117+
118+
function copyNodeRuntime() {
119+
const source = process.execPath;
120+
if (!source || !fs.existsSync(source)) {
121+
throw new Error('unable to locate current Node.js executable for desktop packaging');
122+
}
123+
124+
fs.mkdirSync(stageNodeRuntimeDir, { recursive: true });
125+
const executableName = nodeExecutableName();
126+
const destination = path.join(stageNodeRuntimeDir, executableName);
127+
fs.copyFileSync(source, destination);
128+
const sourceMode = fs.statSync(source).mode;
129+
fs.chmodSync(destination, sourceMode | 0o755);
130+
return path.join('node-runtime', executableName).replace(/\\/g, '/');
131+
}
132+
133+
function writeStageManifest(pkg, copiedModules, nodeRuntime) {
114134
writeJson(path.join(stageDir, 'codexmate-desktop.json'), {
115135
layoutVersion: LAYOUT_VERSION,
116136
productName: 'Codex Mate',
117137
version: pkg.version,
118138
entrypoint: 'cli.js',
139+
nodeRuntime,
119140
nodeModules: 'node_modules',
120141
webUi: 'web-ui',
121142
copiedRuntimeModules: copiedModules.length
@@ -133,6 +154,7 @@ function validateStagedResources(pkg) {
133154
'web-ui',
134155
'web-ui.html',
135156
'node_modules',
157+
'node-runtime',
136158
'codexmate-desktop.json'
137159
];
138160

@@ -152,14 +174,18 @@ function validateStagedResources(pkg) {
152174
if (manifest.layoutVersion !== LAYOUT_VERSION || manifest.entrypoint !== 'cli.js') {
153175
throw new Error('staged desktop manifest is invalid');
154176
}
177+
if (!manifest.nodeRuntime || !fs.existsSync(path.join(stageDir, manifest.nodeRuntime))) {
178+
throw new Error('staged desktop Node.js runtime is missing');
179+
}
155180
}
156181

157182
function stageDesktopResources(pkg, lockFile) {
158183
fs.rmSync(stageDir, { recursive: true, force: true });
159184
fs.mkdirSync(stageDir, { recursive: true });
160185
copyRuntimeEntries();
161186
const copiedModules = copyRuntimeNodeModules(pkg, lockFile);
162-
writeStageManifest(pkg, copiedModules);
187+
const nodeRuntime = copyNodeRuntime();
188+
writeStageManifest(pkg, copiedModules, nodeRuntime);
163189
validateStagedResources(pkg);
164190
return copiedModules.length;
165191
}

0 commit comments

Comments
 (0)