从零把 dsh 打包成桌面版并开源

本教程以 DeepSeek Harness Desktop(开源仓库 WaHaiLong/dsh-desktop)为完整实例:
一个 CLI 工具 → 三平台安装包 → CI 自动发布 → 官网直链下载,全流程、全闭环。

0. 先看懂整体:这个 App 到底由什么组成

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 慢的根因。

1. 准备环境

三平台安装包(macOS/Windows/Linux)由 GitHub Actions 各平台独立构建,本地只需要能跑通一个平台即可。下面以 macOS 为例。

2. 建工程:一个最小的 Electron 壳

初始化并安装依赖:

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]); }
  });
}

要点:

3. 捆绑运行时:三件事

要让用户"下载即用",得把 独立 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 万个小文件搏斗。

4. 三平台打包配置

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 }
}

5. 本地跑通并出包

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 要干的。

6. 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

要点:

7. 开源配套:License、README、官网

License

直接在 GitHub 网页上创建 LICENSE(选 MIT 即可)。dsh 本身也是 MIT,见其 THIRD_PARTY_NOTICES。

README

一个合格的 README 至少包含:项目一句话介绍、官网链接、工作原理(几行说明它是"Electron 壳 + 捆绑 Node 跑 dsh web")、构建方法、发布产物表、License。中英双语可折叠更友好。

官网(GitHub Pages)

把官网源码放到 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 节的"文件名不带版本号",每次发新版自动生效,网页永不失效。

8. 发布闭环 & 踩坑实录

完整的闭环

git commit && git push origin main     # ① 改代码/官网
# ② GitHub → Actions → release → Run workflow 手动触发
# ③ 三平台并行构建,各自上传到同一个 Release
# ④ 用户打开官网,点直链 → 最新版安装包

以后每次发新版,只需推代码 + 点一次 Run workflow,产物自动上 Release,官网直链自动指向最新版。

坑 1:Windows 打包 1 小时 —— 压缩算法选错了

electron-builder 打 NSIS 安装器时,内部先用 7z 归档整个应用,默认 LZMA。而 app 里有几百 MB 预编译二进制(压无可压),LZMA 花 1 小时 CPU 只压掉一点。修复:给 NSIS 加 useZip: true,改用 Deflate —— 压缩效果一样,速度快 20 倍(几分钟)。同样,Windows 便携 zip 不要加 --config.compression=store,zip 默认就是 Deflate。

坑 2:为什么 Windows 还是比 mac/Linux 慢很多

同一份 ~500MB / 3 万个小文件:macOS 打包 1 分 44 秒,Windows 要 30 分钟。慢的不是压缩,是"复制文件"那步 —— Windows 的 NTFS 处理海量小文件比 APFS/ext4 慢一个数量级,加上 Defender 实时扫描每写一个文件就扫一次毒。这不是配置能解决的,治本要减少文件数/体积(见下)。

坑 3:upload-artifact 用空格分隔路径会报错

一条路径含空格(GitHub Actions 的环境变量不支持空格分隔列表),会静默失败或报错。绕开它 —— 反正最终要上传 Release,直接 gh release upload,根本不需要 artifact 中转,还省一次下载。

坑 4:三平台互相阻塞

最初是一个 job 等所有平台构建完再发布,Windows 慢时 mac/Linux 用户干等。改成每平台独立发布后,谁先好谁先上。

优化方向:给安装包瘦身

你的 node_modules 里往往躺着一堆全平台预编译二进制(node-pty/prebuilds/ 下 darwin/win32/linux 各一份,@img 的 sharp 同样)。每个平台实际只用其中一份,可以打包前只留当前平台那份,砍掉几十到上百 MB。原生二进制 + 其 .pdb 调试符号(纯废料)是最先该清的。

9. 一页速查

环节用什么一句话
壳Electron窗口 + 装下 web UI
运行时独立 Node + uv用户零安装
打包electron-builder一个配置出三平台安装包
发布GitHub Actions + Release三平台并行、各自上传、幂等
官网GitHub Pages + 写死直链最新版自动指向、网页永不失效