本教程以 DeepSeek Harness Desktop(开源仓库 WaHaiLong/dsh-desktop)为完整实例:
一个 CLI 工具 → 三平台安装包 → CI 自动发布 → 官网直链下载,全流程、全闭环。
DeepSeek Harness(dsh)是一个 Node.js CLI,自带一个 dsh web 命令,会在本机起一个 Web 界面。桌面版要做的事就一件:
给它套一个 Electron 壳,并把运行它需要的所有东西捆绑进安装包,让用户下载即用、不用装 Node。
整个 App 分四层:
| 层 | 内容 | 为什么捆绑 |
|---|---|---|
| 壳 | Electron(Chromium + 自带 Node) | 把 dsh web 的网页装进桌面窗口 |
| 核心 | @deepseek-ai/dsh 及全部依赖(node_modules) | 用户机器零安装 |
| 运行时 | 独立 Node.js(不是 Electron 内置那个) | 原生模块(node-pty/sharp/koffi)要以正确 ABI 加载 |
| 工具链 | uv(可选的 Python 运行时) | 让 Python 写的 MCP 服务器零安装拉起 |
所以「打包」的本质,就是:把上面这四层文件复制到一起,再压成一个安装包。慢的永远是"复制文件"那步——这也是后文 Windows 慢的根因。
三平台安装包(macOS/Windows/Linux)由 GitHub Actions 各平台独立构建,本地只需要能跑通一个平台即可。下面以 macOS 为例。
初始化并安装依赖:
mkdir dsh-desktop && cd dsh-desktop
npm init -y
npm install --save-dev electron electron-builder
核心文件就两个:package.json 和主进程 electron/main.cjs。壳的逻辑很简单——Electron 只负责:用捆绑的 Node 把 dsh web 拉起来,把它的网页装进窗口。
// electron/main.cjs(关键逻辑)
function startServer() {
const nodeBin = path.join(resourcesRoot(), 'node', process.platform === 'win32' ? 'node.exe' : 'node');
const dshBin = path.join(resourcesRoot(), 'dsh', 'node_modules', '@deepseek-ai', 'dsh', 'lib', 'bin.js');
serverProc = spawn(nodeBin, [dshBin, 'web', '--host', '127.0.0.1', '--port', '0'], {
env: { ...process.env, DSH_HOME: dshHome },
stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true,
});
// 从 stdout 里解析 dsh 自报的端口,然后把窗口指向 http://127.0.0.1:<port>/
serverProc.stdout.on('data', (c) => {
const m = c.toString().match(/https?:\/\/(?:127\.0\.0\.1|localhost):(\d+)/);
if (m) { loadApp(m[1]); }
});
}
要点:
resources/ 取运行时,打包后从 process.resourcesPath 取 —— 一个 resourcesRoot() 函数根据 app.isPackaged 切换。DSH_HOME 指向每用户数据目录,配置和 API Key 持久保存在本机。dsh 子进程,避免残留后台进程。要让用户"下载即用",得把 独立 Node、dsh 本体、uv 塞进包里。用三个 npm script 在打包前准备好:
"scripts": {
"fetch:node": "node scripts/fetch-node.mjs", // 下载独立 Node → resources/node/
"fetch:uv": "node scripts/fetch-uv.mjs", // 下载 uv → resources/uv/
"runtime:install": "npm install --prefix resources/dsh", // 装 dsh 及其依赖 → resources/dsh/
"dist": "electron-builder --publish never",
"build": "npm run fetch:node && npm run fetch:uv && npm run runtime:install && npm run dist"
}
两个下载脚本的核心逻辑:
// scripts/fetch-node.mjs —— 只下载当前平台的独立 Node 二进制
const url = `https://nodejs.org/dist/${version}/node-${version}-${platform}-${arch}.${ext}`;
const res = await fetch(url);
await pipeline(Readable.fromWeb(res.body), createWriteStream(archive)); // 流式下载
// tar 解出 bin/node,移到 resources/node/ —— 每个平台要在对应平台构建(二进制分系统)
// scripts/fetch-uv.mjs —— 同理下载 uv;支持 UV_MIRROR 走代理、UV_LOCAL 复用本地二进制
然后告诉 electron-builder 把这些作为额外资源放进安装包(extraResources)。注意:3 万个小文件的 node_modules 先被打成单个 dsh-runtime.tar.gz,安装包只复制这一个文件——Windows 打包因此从 30 分钟降到几分钟(见第 8 节):
"scripts": { "runtime:pack": "node scripts/pack-runtime.mjs" },
"build": {
"files": ["electron/**/*", "package.json"],
"extraResources": [
{ "from": "resources/node", "to": "node" },
{ "from": "resources/dsh-runtime.tar.gz", "to": "dsh-runtime.tar.gz" },
{ "from": "resources/uv", "to": "uv" }
]
}
files 是打进 app.asar 的代码;extraResources 是原样复制到 resources/ 的运行时。首次运行时,主进程用系统 tar 把 dsh-runtime.tar.gz 解压到每用户数据目录(用户目录可写,装到 Program Files 也能解),之后直接复用——安装包里只有 4 个左右的文件,Windows 打包不再和 3 万个小文件搏斗。
electron-builder 用一个 build 配置同时覆盖三平台:
"build": {
"appId": "com.deepseek.harness.desktop",
"productName": "DeepSeek Harness",
"asar": true, "npmRebuild": false,
"mac": { "target": ["dmg", "zip"], "artifactName": "DeepSeekHarness-mac-${arch}.${ext}" },
"win": { "target": ["nsis", "zip"],"artifactName": "DeepSeekHarness-windows-${arch}.${ext}" },
"linux": { "target": ["AppImage", "deb"], "artifactName": "DeepSeekHarness-linux-${arch}.${ext}" },
"nsis": { "oneClick": false, "allowToChangeInstallationDirectory": true,
"deleteAppDataOnUninstall": false, "useZip": true }
}
releases/latest/download/DeepSeekHarness-mac-arm64.dmg,每次发新版不用改网页。useZip: true 是个关键优化(见第 8 节):让 Windows 安装器用 Deflate 而非默认的 LZMA,打包从 1 小时缩到几分钟。npmRebuild: false:原生模块直接用 dsh 依赖里的预编译产物,不重新编译。npm install # 应用 devDeps(electron、electron-builder)
npm run build # 一键:下载 Node/uv + 装 dsh + 打包
ls dist/ # 本地平台的安装包就出来了
开发调试则用:
npm install && npm run fetch:node && npm run runtime:install
npm start # Electron 启动,dsh web 以子进程跑起来,窗口加载它
注意:独立 Node 二进制是分系统的,fetch:node 在 mac 上下到的是 mac 版。所以三平台必须各自在对应平台构建 —— 这正是 CI 要干的。
核心思路:每个平台构建完,自己上传到同一个 GitHub Release —— 谁先好谁先上,慢平台不阻塞快的平台(Windows 打包慢,不该卡着 mac/Linux 的用户)。
# .github/workflows/release.yml(结构)
permissions:
contents: write # 建 Release 需要写权限,token 默认只读,必须显式声明
jobs:
build:
strategy:
fail-fast: false
matrix:
include:
- os: macos-latest
target: --mac
paths: "dist/*.dmg dist/*.zip"
- os: ubuntu-latest
target: --linux
paths: "dist/*.AppImage dist/*.deb"
- os: windows-latest
target: --win zip
paths: "dist/*.zip"
runs-on: ${{ matrix.os }}
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4 # Node 22 + npm 缓存
- run: npm install
- run: npm run runtime:install
- run: npm run fetch:node
- run: npm run fetch:uv
- run: npx electron-builder ${{ matrix.target }} --publish never
- run: | # 本平台发布:幂等 + 并发重试兜底
TAG="v$(node -p 'require("./package.json").version")"
if ! gh release view "$TAG" >/dev/null 2>&1; then
gh release create "$TAG" --title "..." --notes "..." --target main \
|| gh release view "$TAG" >/dev/null 2>&1 || exit 1
fi
gh release upload "$TAG" ${{ matrix.paths }} --clobber
nsis: # Windows 安装版单独一个 job,不阻塞任何人
runs-on: windows-latest
steps:
- run: npx electron-builder --win nsis --publish never
- run: gh release upload "$TAG" dist/*.exe --clobber
要点:
gh release view 判断存在则跳过 create,--clobber 覆盖同名资产,重复跑不报错。|| gh release view || exit 1 兜住 "already exists" 竞争。fail-fast: false,一个平台挂了其他照常发。直接在 GitHub 网页上创建 LICENSE(选 MIT 即可)。dsh 本身也是 MIT,见其 THIRD_PARTY_NOTICES。
一个合格的 README 至少包含:项目一句话介绍、官网链接、工作原理(几行说明它是"Electron 壳 + 捆绑 Node 跑 dsh web")、构建方法、发布产物表、License。中英双语可折叠更友好。
把官网源码放到 docs/,仓库 Settings → Pages → 选 main / docs,即得 https://<用户名>.github.io/<仓库名>/。改动推上去自动重新部署。
官网的下载区用写死的直链,指向 Release 的最新版:
<a href="https://github.com/<org>/<repo>/releases/latest/download/DeepSeekHarness-mac-arm64.dmg">下载 .dmg</a>
配合第 4 节的"文件名不带版本号",每次发新版自动生效,网页永不失效。
git commit && git push origin main # ① 改代码/官网
# ② GitHub → Actions → release → Run workflow 手动触发
# ③ 三平台并行构建,各自上传到同一个 Release
# ④ 用户打开官网,点直链 → 最新版安装包
以后每次发新版,只需推代码 + 点一次 Run workflow,产物自动上 Release,官网直链自动指向最新版。
electron-builder 打 NSIS 安装器时,内部先用 7z 归档整个应用,默认 LZMA。而 app 里有几百 MB 预编译二进制(压无可压),LZMA 花 1 小时 CPU 只压掉一点。修复:给 NSIS 加 useZip: true,改用 Deflate —— 压缩效果一样,速度快 20 倍(几分钟)。同样,Windows 便携 zip 不要加 --config.compression=store,zip 默认就是 Deflate。
同一份 ~500MB / 3 万个小文件:macOS 打包 1 分 44 秒,Windows 要 30 分钟。慢的不是压缩,是"复制文件"那步 —— Windows 的 NTFS 处理海量小文件比 APFS/ext4 慢一个数量级,加上 Defender 实时扫描每写一个文件就扫一次毒。这不是配置能解决的,治本要减少文件数/体积(见下)。
一条路径含空格(GitHub Actions 的环境变量不支持空格分隔列表),会静默失败或报错。绕开它 —— 反正最终要上传 Release,直接 gh release upload,根本不需要 artifact 中转,还省一次下载。
最初是一个 job 等所有平台构建完再发布,Windows 慢时 mac/Linux 用户干等。改成每平台独立发布后,谁先好谁先上。
你的 node_modules 里往往躺着一堆全平台预编译二进制(node-pty/prebuilds/ 下 darwin/win32/linux 各一份,@img 的 sharp 同样)。每个平台实际只用其中一份,可以打包前只留当前平台那份,砍掉几十到上百 MB。原生二进制 + 其 .pdb 调试符号(纯废料)是最先该清的。
| 环节 | 用什么 | 一句话 |
|---|---|---|
| 壳 | Electron | 窗口 + 装下 web UI |
| 运行时 | 独立 Node + uv | 用户零安装 |
| 打包 | electron-builder | 一个配置出三平台安装包 |
| 发布 | GitHub Actions + Release | 三平台并行、各自上传、幂等 |
| 官网 | GitHub Pages + 写死直链 | 最新版自动指向、网页永不失效 |