Replace electron with Bun compile for single-file exe distribution

This commit is contained in:
2026-03-13 04:57:12 +08:00
parent a078666173
commit 181168cba5
10 changed files with 522 additions and 5210 deletions

2
.gitignore vendored
View File

@@ -34,6 +34,8 @@ build/
dist-output/
.gradle/
.*.bun-build
OpenChamber.exe
embedded-static.js
# Built webview assets (generated during build)
src/main/resources/webview/

View File

@@ -63,19 +63,23 @@ All scripts are in `package.json`.
## Distribution
## Electron single-exe distribution
To package as a single portable exe with built-in server:
## Single-file exe distribution
To build a standalone executable with embedded static files:
```bash
cd web/electron
npx electron-builder --win --x64
cd web
bun run build:exe
```
Output: `web/electron/release/OpenChamber.exe` (single portable exe, ~260MB)
Output: `web/OpenChamber.exe` (~320MB single file)
The exe includes:
- Bundled Node.js runtime
- Server code, static files, and CLI
- System tray icon with context menu
- Auto-starts server on port 3000
- Bun runtime
- Server code and all dependencies
- 300+ static files (dist/, embedded)
- Extracts static files to temp directory on startup
Files involved:
- `web/entry-singlefile.js` - Entry point for compiled exe
- `web/embed-static.js` - Script to embed dist/ as base64
## Runtime entry points
- Web bootstrap: `web/src/main.tsx`

View File

@@ -1,142 +0,0 @@
# Electron 单文件打包指南
## 概述
将 OpenChamber 打包成单个可执行文件 (exe),用户双击即可运行服务器。
## 核心挑战
Electron 打包后面临的最大问题:**如何启动服务器?**
- 开发环境:直接用 `spawn('node', ...)` 即可,因为系统有 node
- 打包后electron 内置的是 chromium + node 融合体,没有独立的 `node` 命令
- 解决:把 node.exe 打包进 resources用批处理脚本启动
## 实现步骤
### 1. 目录结构
```
web/electron/
├── main.js # electron 入口
├── package.json # 打包配置
└── release/ # 输出目录
└── OpenChamber.exe
```
### 2. main.js 关键代码
```javascript
const { app, Tray, Menu, nativeImage } = require('electron');
const path = require('path');
const fs = require('fs');
const { spawn } = require('child_process');
function startServer() {
// 打包后路径
const serverPath = path.join(process.resourcesPath, 'server', 'index.js');
const cwdPath = path.join(process.resourcesPath);
const execPath = path.join(process.resourcesPath, 'nodejs', 'node.exe');
// 创建批处理脚本
const batContent = `"${execPath}" "${serverPath}"`;
const batPath = path.join(cwdPath, 'start-server.bat');
fs.writeFileSync(batPath, batContent);
// 启动服务器
spawn('cmd.exe', ['/c', batPath], {
cwd: cwdPath,
stdio: 'inherit',
env: { ...process.env, OPENCHAMBER_PORT: '3000', OPENCODE_SKIP_START: 'true' }
});
}
function createTray() {
// 系统托盘图标
const iconPath = path.join(process.resourcesPath, 'public', 'favicon.png');
const tray = new Tray(nativeImage.createFromPath(iconPath));
tray.setContextMenu(Menu.buildFromTemplate([
{ label: 'Open http://localhost:3000', click: () => {
require('electron').shell.openExternal('http://localhost:3000');
}},
{ label: 'Quit', click: () => app.quit() }
]));
}
app.whenReady().then(() => {
startServer();
createTray();
});
```
### 3. package.json 打包配置
```json
{
"name": "openchamber",
"main": "main.js",
"build": {
"appId": "com.openchamber.app",
"productName": "OpenChamber",
"directories": {
"output": "release"
},
"files": [
"main.js",
"package.json"
],
"extraResources": [
{ "from": "../dist", "to": "dist" },
{ "from": "../server", "to": "server" },
{ "from": "../bin", "to": "bin" },
{ "from": "../public", "to": "public" },
{ "from": "D:\\Program Files\\nodejs", "to": "nodejs", "filter": ["node.exe"] }
],
"win": { "target": "portable" },
"portable": { "artifactName": "OpenChamber.exe" },
"asar": true
}
}
```
关键点:
- `extraResources` 把 dist、server、bin、public、node.exe 打包进去
- `win.target: portable` 生成单个 exe
### 4. 打包命令
```bash
cd web/electron
npx electron-builder --win --x64
```
输出:`web/electron/release/OpenChamber.exe` (~260MB)
## 原理图
```
OpenChamber.exe (便携版)
├── electron.exe (运行时)
├── resources/
│ ├── app.asar (electron 主代码)
│ ├── dist/ (静态页面)
│ ├── server/ (服务器代码)
│ ├── bin/ (CLI)
│ ├── public/ (静态资源)
│ └── nodejs/
│ └── node.exe (Node 运行时)
```
用户双击 exe
1. electron 启动
2. main.js 执行
3. 创建批处理脚本调用 node.exe 运行 server/index.js
4. 服务器在 3000 端口启动
5. 系统托盘显示图标
## 注意事项
1. **路径问题**:打包后用 `process.resourcesPath` 获取资源目录
2. **node 路径**:必须把系统 node.exe 复制到打包目录
3. **托盘图标**:需要 favicon.png否则用空图标
4. **端口占用**:默认 3000 端口

View File

@@ -0,0 +1,411 @@
# OpenChamber 单文件可执行文件构建指南
## 概述
本文档详细记录了如何将 OpenChamber Web 应用打包成一个独立的单文件可执行文件exe用户无需安装 Node.js、Bun 或任何依赖,只需双击 exe 即可运行。
最终产物:`OpenChamber.exe`约320MB包含完整的服务器代码、依赖和静态文件。
---
## 目录
1. [背景与需求](#背景与需求)
2. [技术选型](#技术选型)
3. [尝试过程与问题](#尝试过程与问题)
4. [最终方案Bun Compile](#最终方案bun-compile)
5. [实现细节](#实现细节)
6. [构建命令](#构建命令)
7. [已知限制](#已知限制)
---
## 背景与需求
### 需求
用户希望将 OpenChamber 打包成类似 Gitea 的单文件可执行文件:
- 无需安装 Node.js/Bun 运行时
- 无需解压即可运行
- 单个 exe 文件,便于分发
- 双击启动,访问 Web UI
### 项目现状
| 组件 | 技术 | 代码量 |
|------|------|--------|
| 前端 | React + TypeScript + Vite | ~10,000 行 |
| 后端 | Express.js + Node.js | ~12,000 行 |
| 静态文件 | HTML/CSS/JS/字体/图片 | ~150MB |
---
## 技术选型
### 方案对比
| 方案 | 单文件 | ESM支持 | 静态文件嵌入 | 结果 |
|------|--------|---------|--------------|------|
| Electron portable | 自解压 | ✅ | ❌ 需外部文件 | 失败 |
| pkg (Node.js) | ✅ | ❌ 仅CJS | ❌ 需外部文件 | 失败 |
| **Bun compile** | ✅ | ✅ | ✅ 需手动嵌入 | **成功** |
| nexe | ✅ | ❌ 仅CJS | ❌ | 未尝试 |
| Tauri | ✅ | - | ✅ | 工作量大,未尝试 |
---
## 尝试过程与问题
### 方案一Electron Portable
最初使用 Electron 打包,配置如下:
```json
// web/electron/package.json
{
"build": {
"win": { "target": "portable" },
"extraResources": [
{ "from": "../dist", "to": "dist" },
{ "from": "../server", "to": "server" },
{ "from": "D:\\Program Files\\nodejs", "to": "nodejs", "filter": ["node.exe"] }
]
}
}
```
**问题:**
1. **不是真正的单文件** - Electron portable 本质是自解压包,运行时解压到 `%TEMP%` 目录
2. **中文路径问题** - 路径包含中文字符导致 spawn 失败
3. **node-pty 原生模块** - 需要重新编译,兼容性复杂
4. **文件体积大** - 约 300MB且需要外部静态文件目录
**结论:** Electron portable 并非真正的单文件 exe用户体验不佳。
### 方案二pkg
尝试使用 Node.js 官方的 pkg 工具:
```bash
pkg bin/cli.js --targets node18-win-x64 --output OpenChamber.exe
```
**问题:**
1. **不支持 ESM** - pkg 只支持 CommonJS而项目使用 `"type": "module"`
```
Warning Babel parse has failed: import.meta may appear only with 'sourceType: "module"'
```
2. **尝试创建 CJS 入口** - 仍然失败
```
Error: Cannot find module 'C:\snapshot\bin\cli.js'
```
3. **中文路径问题** - pkg 的 snapshot 路径在中文目录下无法正确解析
**结论:** pkg 无法处理 ESM 项目,且对中文路径支持不佳。
### 方案三Bun Compile
Bun 1.0+ 提供了 `bun build --compile` 功能,可以将 JavaScript 应用编译成独立的可执行文件。
**优势:**
- 原生支持 ESM
- 编译速度快
- 支持所有 Bun API
- 单文件输出
**初步尝试:**
```bash
bun build server/index.js --compile --outfile OpenChamber.exe
```
**成功生成 113MB 的 exe** 但运行时报错:
```
Error: Static files not found at dist/
```
**问题:** 静态文件dist/、public/)没有被嵌入 exe。
---
## 最终方案Bun Compile + 静态文件嵌入
### 核心思路
1. 将静态文件dist/)转换为 base64 编码的 JS 模块
2. 在入口文件中导入该模块
3. 运行时解压到临时目录
4. 服务器从临时目录读取静态文件
### 架构图
```
OpenChamber.exe
├── Bun Runtime (~80MB)
├── Server Code (~20MB)
├── Dependencies (~50MB)
└── Embedded Static Files (base64, ~150MB compressed)
启动时:
┌─────────────────┐
│ OpenChamber.exe │
└────────┬────────┘
│ 启动
┌─────────────────────────────────┐
│ 解压静态文件到 %TEMP%\openchamber-static-{PID}\ │
└────────┬────────────────────────┘
┌─────────────────────────────────┐
│ 启动 Express 服务器 │
│ 监听 localhost:3000 │
│ 从临时目录读取静态文件 │
└─────────────────────────────────┘
```
---
## 实现细节
### 1. 静态文件嵌入脚本 (`embed-static.js`)
```javascript
import { readdirSync, statSync, readFileSync, writeFileSync } from 'fs';
import { join, relative } from 'path';
const distDir = './dist';
const outputFile = './embedded-static.js';
function walkDir(dir, baseDir, result = {}) {
const items = readdirSync(dir);
for (const item of items) {
const fullPath = join(dir, item);
const stat = statSync(fullPath);
if (stat.isDirectory()) {
walkDir(fullPath, baseDir, result);
} else {
const relPath = relative(baseDir, fullPath).replace(/\\/g, '/');
const content = readFileSync(fullPath);
result[relPath] = content.toString('base64');
}
}
return result;
}
console.log('Embedding static files...');
const files = walkDir(distDir, distDir);
console.log('Found', Object.keys(files).length, 'files in dist');
const jsContent = `// Auto-generated - embedded static files
export const staticFiles = ${JSON.stringify(files)};
export const fileCount = ${Object.keys(files).length};
`;
writeFileSync(outputFile, jsContent);
console.log('Written to', outputFile);
```
**说明:**
- 遍历 `dist/` 目录下的所有文件
- 将每个文件内容转换为 base64 字符串
- 生成一个 JS 模块导出文件映射
### 2. 入口文件 (`entry-singlefile.js`)
```javascript
import { existsSync, mkdirSync, writeFileSync } from 'fs';
import { join, dirname } from 'path';
import { tmpdir } from 'os';
// 导入嵌入的静态文件
import { staticFiles } from './embedded-static.js';
// 导入服务器模块
import * as serverModule from './server/index.js';
// 解析命令行参数
const args = process.argv.slice(2);
let port = 3000;
for (let i = 0; i < args.length; i++) {
if (args[i] === '--port' || args[i] === '-p') {
port = parseInt(args[++i]) || 3000;
} else if (args[i].startsWith('--port=')) {
port = parseInt(args[i].split('=')[1]) || 3000;
} else if (args[i] === '--help' || args[i] === '-h') {
console.log(`OpenChamber - Web UI for OpenCode
Usage: OpenChamber [options]
Options:
--port, -p Port (default: 3000)
--help, -h Show help`);
process.exit(0);
}
}
// 解压静态文件到临时目录
const extractDir = join(tmpdir(), 'openchamber-static-' + process.pid);
const distPath = join(extractDir, 'dist');
console.log('Extracting static files...');
mkdirSync(distPath, { recursive: true });
let extractedCount = 0;
for (const [relPath, base64Content] of Object.entries(staticFiles)) {
const fullPath = join(distPath, relPath);
const dir = dirname(fullPath);
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true });
}
const content = Buffer.from(base64Content, 'base64');
writeFileSync(fullPath, content);
extractedCount++;
}
console.log(`Extracted ${extractedCount} files to ${distPath}`);
// 设置环境变量
process.env.OPENCHAMBER_DIST_DIR = distPath;
process.env.OPENCHAMBER_PORT = String(port);
console.log(`Starting OpenChamber on port ${port}...`);
// 启动服务器
if (typeof serverModule.startWebUiServer === 'function') {
await serverModule.startWebUiServer({ port, attachSignals: true, exitOnShutdown: true });
} else {
console.error('Error: startWebUiServer not found in server module');
process.exit(1);
}
```
**关键点:**
- 使用 `import` 静态导入 `embedded-static.js`
- 运行时将 base64 解码并写入临时目录
- 设置 `OPENCHAMBER_DIST_DIR` 让服务器知道静态文件位置
### 3. 服务器适配
服务器需要支持从环境变量读取静态文件目录:
```javascript
// web/server/index.js
const distPath = (() => {
const env = typeof process.env.OPENCHAMBER_DIST_DIR === 'string'
? process.env.OPENCHAMBER_DIST_DIR.trim() : '';
if (env) {
return path.resolve(env);
}
return path.join(__dirname, '..', 'dist');
})();
```
### 4. package.json 配置
```json
{
"scripts": {
"build": "vite build",
"embed:static": "node embed-static.js",
"build:exe": "vite build && node embed-static.js && bun build entry-singlefile.js --compile --outfile OpenChamber.exe"
}
}
```
---
## 构建命令
### 完整构建流程
```bash
cd web
bun run build:exe
```
### 分步执行
```bash
# 1. 构建前端静态文件
bun run build
# 2. 嵌入静态文件到 JS 模块
node embed-static.js
# 3. 编译成单文件 exe
bun build entry-singlefile.js --compile --outfile OpenChamber.exe
```
### 输出
```
web/
├── OpenChamber.exe # 最终产物 (~320MB)
├── embedded-static.js # 临时生成,可删除
├── entry-singlefile.js # 入口文件
└── embed-static.js # 嵌入脚本
```
---
## 已知限制
### 1. 文件体积
- 最终 exe 约 320MB
- 原因Bun 运行时 (~80MB) + 依赖 + 静态文件
### 2. 启动时间
- 首次启动需要 2-3 秒解压静态文件
- 后续启动仍需解压(每次使用新的临时目录)
### 3. 临时文件
- 静态文件解压到 `%TEMP%\openchamber-static-{PID}\`
- 关闭程序后不会自动清理
- 可手动清理 `%TEMP%\openchamber-static-*`
### 4. 平台限制
- 需要在目标平台编译Windows exe 需在 Windows 上编译)
- Bun 支持跨平台编译,但需要对应平台的 Bun 二进制
### 5. 原生模块
- `node-pty` 等原生模块需要特殊处理
- 当前方案依赖 Bun 的原生模块支持
---
## 总结
通过 Bun compile + 静态文件嵌入,我们成功实现了:
1. **真正的单文件 exe** - 无需外部依赖
2. **简单分发** - 用户只需下载一个文件
3. **保留完整功能** - 所有 Web UI 功能正常
关键难点:
- pkg 不支持 ESM → 改用 Bun
- 静态文件无法嵌入 → base64 编码嵌入
- 运行时路径问题 → 使用临时目录
---
## 附录:相关文件
| 文件 | 用途 |
|------|------|
| `web/entry-singlefile.js` | 编译入口,处理启动逻辑 |
| `web/embed-static.js` | 将 dist/ 转换为 JS 模块 |
| `web/OpenChamber.exe` | 最终产物 |
## 参考资料
- [Bun Documentation - Compile](https://bun.sh/docs/bundler#compile)
- [pkg - Node.js 单文件打包](https://github.com/vercel/pkg)
- [Electron Builder](https://www.electron.build/)

View File

@@ -1,111 +0,0 @@
const { app, Tray, Menu, nativeImage } = require('electron');
const path = require('path');
const fs = require('fs');
const { spawn } = require('child_process');
let tray = null;
let serverPort = '3000';
function startServer() {
console.log('Starting OpenChamber server...');
let serverPath;
let cwdPath;
let execPath;
if (app.isPackaged) {
serverPath = path.join(process.resourcesPath, 'server', 'index.js');
cwdPath = path.join(process.resourcesPath);
execPath = path.join(process.resourcesPath, 'nodejs', 'node.exe');
} else {
serverPath = path.join(__dirname, '..', 'server', 'index.js');
cwdPath = path.join(__dirname, '..');
execPath = 'node';
}
console.log('Server path:', serverPath);
console.log('CWD:', cwdPath);
console.log('Exec:', execPath);
// 支持命令行参数或环境变量指定端口
const port = process.argv.find(arg => arg.startsWith('--port='))?.split('=')[1]
|| process.env.OPENCHAMBER_PORT
|| '3000';
serverPort = port;
console.log('Port:', port);
try {
// 设置环境变量
const env = { ...process.env };
env.OPENCHAMBER_PORT = port;
env.OPENCODE_SKIP_START = 'true';
// 直接 spawn用命令行参数指定端口
const child = spawn(execPath, [serverPath, '--port=' + port], {
cwd: cwdPath,
stdio: 'inherit',
env: env,
windowsHide: true
});
child.on('error', (err) => {
console.error('Server error:', err);
});
child.on('exit', (code) => {
console.log('Server exited:', code);
});
} catch (e) {
console.error('Failed to start:', e);
}
}
function createTray(port) {
try {
let iconPath;
if (app.isPackaged) {
iconPath = path.join(process.resourcesPath, 'public', 'favicon.png');
} else {
iconPath = path.join(__dirname, '..', 'public', 'favicon.png');
}
let icon;
try {
icon = nativeImage.createFromPath(iconPath);
if (icon.isEmpty()) {
icon = nativeImage.createEmpty();
}
} catch (e) {
icon = nativeImage.createEmpty();
}
tray = new Tray(icon);
const contextMenu = Menu.buildFromTemplate([
{ label: `OpenChamber Server (:${port})`, enabled: false },
{ type: 'separator' },
{ label: `Open http://localhost:${port}`, click: () => {
require('electron').shell.openExternal(`http://localhost:${port}`);
}},
{ type: 'separator' },
{ label: 'Quit', click: () => {
app.quit();
}}
]);
tray.setToolTip(`OpenChamber Server (:${port})`);
tray.setContextMenu(contextMenu);
} catch (e) {
console.error('Failed to create tray:', e);
}
}
app.whenReady().then(() => {
startServer();
createTray(serverPort);
});
app.on('window-all-closed', () => {
app.quit();
});

File diff suppressed because it is too large Load Diff

View File

@@ -1,62 +0,0 @@
{
"name": "openchamber",
"version": "1.8.5",
"description": "OpenChamber - AI Agent Web UI",
"author": "OpenChamber",
"main": "main.js",
"private": true,
"scripts": {
"electron:dev": "electron .",
"electron:build": "electron-builder --win --x64"
},
"build": {
"appId": "com.openchamber.app",
"productName": "OpenChamber",
"directories": {
"output": "release"
},
"files": [
"main.js",
"package.json"
],
"extraResources": [
{
"from": "../dist",
"to": "dist",
"filter": ["**/*"]
},
{
"from": "../server",
"to": "server",
"filter": ["**/*"]
},
{
"from": "../bin",
"to": "bin",
"filter": ["**/*"]
},
{
"from": "../public",
"to": "public",
"filter": ["**/*"]
},
{
"from": "D:\\Program Files\\nodejs",
"to": "nodejs",
"filter": ["node.exe"]
}
],
"win": {
"target": "portable"
},
"portable": {
"artifactName": "OpenChamber.exe"
},
"asar": true,
"npmRebuild": true
},
"devDependencies": {
"electron": "^41.0.0",
"electron-builder": "^26.8.1"
}
}

33
web/embed-static.js Normal file
View File

@@ -0,0 +1,33 @@
import { readdirSync, statSync, readFileSync, writeFileSync } from 'fs';
import { join, relative } from 'path';
const distDir = './dist';
const outputFile = './embedded-static.js';
function walkDir(dir, baseDir, result = {}) {
const items = readdirSync(dir);
for (const item of items) {
const fullPath = join(dir, item);
const stat = statSync(fullPath);
if (stat.isDirectory()) {
walkDir(fullPath, baseDir, result);
} else {
const relPath = relative(baseDir, fullPath).replace(/\\/g, '/');
const content = readFileSync(fullPath);
result[relPath] = content.toString('base64');
}
}
return result;
}
console.log('Embedding static files...');
const files = walkDir(distDir, distDir);
console.log('Found', Object.keys(files).length, 'files in dist');
const jsContent = `// Auto-generated - embedded static files
export const staticFiles = ${JSON.stringify(files, null, 0)};
export const fileCount = ${Object.keys(files).length};
`;
writeFileSync(outputFile, jsContent);
console.log('Written to', outputFile, 'size:', statSync(outputFile).size);

61
web/entry-singlefile.js Normal file
View File

@@ -0,0 +1,61 @@
import { existsSync, mkdirSync, writeFileSync } from 'fs';
import { join, dirname } from 'path';
import { tmpdir } from 'os';
// Import embedded static files
import { staticFiles } from './embedded-static.js';
// Import server statically
import * as serverModule from './server/index.js';
// Parse args
const args = process.argv.slice(2);
let port = 3000;
for (let i = 0; i < args.length; i++) {
if (args[i] === '--port' || args[i] === '-p') {
port = parseInt(args[++i]) || 3000;
} else if (args[i].startsWith('--port=')) {
port = parseInt(args[i].split('=')[1]) || 3000;
} else if (args[i] === '--help' || args[i] === '-h') {
console.log(`OpenChamber - Web UI for OpenCode
Usage: OpenChamber [options]
Options:
--port, -p Port (default: 3000)
--help, -h Show help`);
process.exit(0);
}
}
// Extract static files to temp directory
const extractDir = join(tmpdir(), 'openchamber-static-' + process.pid);
const distPath = join(extractDir, 'dist');
console.log('Extracting static files...');
mkdirSync(distPath, { recursive: true });
let extractedCount = 0;
for (const [relPath, base64Content] of Object.entries(staticFiles)) {
const fullPath = join(distPath, relPath);
const dir = dirname(fullPath);
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true });
}
const content = Buffer.from(base64Content, 'base64');
writeFileSync(fullPath, content);
extractedCount++;
}
console.log(`Extracted ${extractedCount} files to ${distPath}`);
// Set environment
process.env.OPENCHAMBER_DIST_DIR = distPath;
process.env.OPENCHAMBER_PORT = String(port);
console.log(`Starting OpenChamber on port ${port}...`);
// Start server
if (typeof serverModule.startWebUiServer === 'function') {
await serverModule.startWebUiServer({ port, attachSignals: true, exitOnShutdown: true });
} else {
console.error('Error: startWebUiServer not found in server module');
process.exit(1);
}

View File

@@ -20,7 +20,8 @@
"type-check": "tsc --noEmit",
"lint": "eslint \"./src/**/*.{ts,tsx}\" --config ../eslint.config.js",
"start": "node bin/cli.js serve",
"bundle": "pkg bin/cli.js --public --targets node18-win-x64 --output ../OpenChamber.exe"
"embed:static": "node embed-static.js",
"build:exe": "vite build && node embed-static.js && bun build entry-singlefile.js --compile --outfile OpenChamber.exe"
},
"dependencies": {
"@codemirror/lang-cpp": "^6.0.3",