ARTICLE 2026—0814—02FOLIO 2026—0814—02ACRETIONDISKARCHIVUM PERSONAE

DSH 桌面版开发复盘:用 Go + go-webview2 把一个 Agent 运行时打包成双击即用的 exe

把 DeepSeek Harness(DSH)做成轻量化 Windows 桌面版:Go + go-webview2 外壳、go:embed 内嵌便携 Node 与完整依赖树、自解压自安装 exe、无边框自绘标题栏。复盘从 0 到发布踩过的 12 个坑与可迁移的桌面化方法论。

DSH 桌面版开发复盘:用 Go + go-webview2 把一个 Agent 运行时打包成双击即用的 exe

DeepSeek Harness(DSH) 做了一个轻量化 Windows 桌面版:一个自解压、自安装的 exe。用户下载后双击,剩下的“解压 → 建快捷方式 → 启动 dsh web → 开一个原生窗口”全自动完成,界面就是 DSH 官方 Web UI,外壳不重写任何 UI。

项目从 0 做到可运行 + 已发布:Go 源码 test / vet / build 全通过(full 与 slim 两种 tag),完整自解压版 ~123.9 MB、轻量外壳 ~11.1 MB,无边框窗口 + 自绘三键、加载画面、图标全部就位,GitHub Release desktop-v0.1.0 已发布。端到端测试的真实输出长这样:

server.log:  dsh web: http://127.0.0.1:13463
HTTP status: 200

这篇不写“怎么复刻”,写方法论:外壳选型为什么弃 .NET 改 Go、内嵌负载为什么用 go:embed、以及开发中真实踩过的 12 个坑——每一个都带根因,因为根因才是可迁移的知识。

一、技术栈:一个 exe,三层结构

DSH-Desktop.exe                       ← 用户下载到的那个文件(自解压、自安装)
├── Go 外壳(go-webview2 原生窗口,内嵌 WebView2Loader)
└── go:embed payload.zip              ← ~118 MB 负载编译进二进制
    ├── runtime/                      ← 便携 Node.js(node.exe + libnode + ICU)
    └── app/node_modules/             ← @deepseek-ai/dsh 及完整依赖树
技术 职责
外壳 Go + go-webview2 起服务 + 开窗口,纯 Go、免 CGo、单静态二进制
负载 go:embed 内嵌 payload.zip 便携 Node(v24.14.1)+ DSH 依赖树,随 exe 一起分发
界面 DSH 官方 Web UI 外壳不重写 UI,只管“起服务 + 开窗口”
无边框 自绘标题栏(chrome.go 去系统 ControlBox,最小化/最大化/关闭三键页面自绘
单实例 net.Listen("tcp", "127.0.0.1:38450") 回环端口做锁,进程退出即释放,无残留锁文件
数据 ~/.dsh(可被 $DSH_HOME 覆盖) 与 CLI 共享会话/配置

首次运行流程:先开加载画面(open.png + 提示语 + spinner)→ 检查内嵌负载版本,不一致就解压到 %LOCALAPPDATA%\DSH-Desktop\ → 建开始菜单/桌面快捷方式 → 挑空闲端口用内置 Node 起 dsh web(只绑 127.0.0.1)→ 轮询到 HTTP 200 再在同一窗口导航到 DSH → 关窗口 taskkill /T 杀掉 Node 进程树。

一个贯穿始终的设计原则:外壳只做“进程编排 + 原生窗口”这两件 Web 干不了的事,界面和数据完全交给 DSH 本体。这让桌面版和 CLI 版永远共享同一套逻辑,改 UI 直接改 DSH 就行,外壳几乎不用动。

二、两个关键选型:为什么弃 .NET 改 Go,为什么用 go:embed

坑 1:.NET 单文件不能尾部追加负载

最初想走“下载 exe → 尾部追加 payload”的自解压方案,很快撞墙:.NET 单文件 bundle 的“包尾标记”在文件最后 8 字节,往尾部追加任何字节都会破坏启动结构,exe 直接起不来。

于是改走 Go + go:embed:负载作为内嵌资源编译进二进制,没有“尾部追加”、没有 bundle 标记问题,天然适合单文件分发。这也顺势对齐了 DeepSeek-Reasonix 用 WebView2 做桌面的路线——纯 Go、免 CGo、单静态二进制。

方法论:先确认目标格式的“结构约束”,再决定打包方式。 .NET 单文件的尾部标记、Go embed 的编译期内嵌、electron-builder 的 asar 布局——每个格式都有自己的边界,方案要顺着边界选,而不是先选方案再对抗边界。

坑 2:Node 版本必须 ≥ v22.15.0(最关键的坑)

这是整个项目里最隐蔽、症状最迷惑的一个坑。dsh-session-persistence-jsonlimport { createZstdDecompress, zstdCompress, ... } from "node:zlib"——这是 v22.15.0 才有的 zstd 导出,v22.14.0 及更早版本根本没有。

症状极具欺骗性:WebView2 窗口正常打开、但页面连不上;server.log 里报 does not provide an export named 'createZstdDecompress'连 node 子进程都没有。如果不看日志,你会以为是自己窗口代码写错了。

修复:把默认捆绑的便携 Node 版本定为 v24.14.1(与本机 harness 一致,向上兼容 v22.15+ 即可)。

方法论:运行时依赖的最小版本,一定要显式验证而不是“看着像就行”。 这种“某个 API 在某版本才加入”的坑,只有端到端跑一遍才能暴露——单独测外壳、单独测 Node、单独测 DSH 都测不出来,它们拼起来才崩。

三、难点拆解:其余十个坑

# 问题 根因 修复
3 npm 11+ 报 EALLOWSCRIPTS 本机 ~/.npmrcallow-scripts 白名单 + 环境变量 npm_config_allow_scripts;dsh 依赖树里 5 个包带 install 脚本(dsh-subprocess-local/@google/genai/koffi/node-pty/protobufjs build.ps1 里 Remove-Item Env:npm_config_allow_scripts + 在 staging 写项目级 .npmrcallow-scripts=<这5个>);注意 --allow-scripts 这类 CLI flag 在项目级安装里会被 npm 直接拒绝,必须走 .npmrc
4 PowerShell 5.1 构建脚本莫名抛异常 $ErrorActionPreference="Stop" 下,2>$null 把 npm/tar/go 的 stderr 包装成 ErrorRecord 并抛异常 改为 "Continue",靠显式 $LASTEXITCODE 检查兜底
5 Node zip 解压后路径不对 node-vXX-win-x64.zip 有顶层目录,node.exe 不在根 解压后扁平化(Move-Item 子目录内容到 runtime/
6 WebView2 数据目录到处乱放 go-webview2 默认用 %APPDATA%\DSH-Desktop.exe\EBWebView 通过 WebViewOptions.DataPath 显式固定到 %LOCALAPPDATA%\DSH-Desktop\WebView2
7 高 DPI 下窗口尺寸/居中错乱 旧实现把 1280×800 传给所有显示器,小屏会触发居中坐标的无符号下溢 启动时启用 Per-Monitor V2 DPI,按鼠标所在显示器工作区算尺寸,并校正/记忆窗口位置
8 首次启动像“卡死” 首次解压内嵌负载耗时 20–30s,旧实现要等服务起来才建窗口 先显示启动页(open.png + 提示语 + spinner),后台解压/起服务,完成后同窗口导航到 DSH
9 exe 秒退、无日志无窗口 CreateIconIndirect 挂在 gdi32.dll 上(它在 user32.dll)→ panic: Failed to find CreateIconIndirect procedure in gdi32.dll user32.NewProc("CreateIconIndirect")CreateDIBSection/DeleteObject 才在 gdi32)
10 无边框窗口最大化遮住任务栏 直接清掉全部样式标志 只清 `WS_CAPTION
11 go mod tidy 拉依赖超时 proxy.golang.org / golang.org 直连被墙 $env:GOPROXY="https://goproxy.cn,direct"; $env:GOSUMDB="off"
12 沙箱下测试 exe 会崩(仅 agent 环境) 只能写工作区的沙箱里,程序往 %LOCALAPPDATA%\DSH-Desktop 写 WebView2 数据失败,go-webview2 控制器返回 nil 并 panic $env:DSH_DESKTOP_ROOT 把安装目录指到可写路径(普通用户双击无此问题)

几条值得单独展开的:

坑 10 的完整逻辑:无边框窗口不是“删掉标题栏”就完了——它是接管系统的部分职责。去 ControlBox 后,最小化/最大化/关闭三键得页面自己画;拖动得自己处理 WM_NCHITTEST(标题栏区域返回 HTCAPTION、按钮区放行给页面);最大化不遮任务栏得在 WM_GETMINMAXINFO 里把 PtMaxPosition/PtMaxSize 钳到显示器工作区。接管哪一块,就要给出哪一块的等价交互,否则就是在给用户挖洞。这套逻辑落在 chrome.go 里,标题栏高度 40 CSS px、按钮区宽 138 CSS px,改高度要“CSS + JS + Go”三处同步。

坑 9 的教训:Windows API 的 DLL 归属是最容易凭印象出错的地方。panic 之后 exe 秒退、连 server.log 都没有——排查时“没有日志”本身就是线索,说明崩在了比“起服务”更早的阶段。

坑 3 的细节:npm 11 的 allow-scripts 语义很反直觉——--allow-scripts / --dangerously-allow-all-scripts 这两个 CLI flag 在“项目级安装”里会被 npm 直接拒绝,只能通过 package.json.npmrc 配置。工具链的约束写进构建脚本(.npmrc、镜像、执行策略),而不是靠命令行备忘,是这轮最大的工程习惯收获。

四、方法论精炼:可迁移的几条

把 12 个坑收拢成清单,任何“把 Web 服务打包成桌面应用”的项目都适用:

  1. 端到端验证驱动:外壳、Node、DSH 三件套单独测都绿没用,拼起来才见真章。每次改完跑 exe 读 server.log,确认 HTTP 200。
  2. 先确认格式约束再选打包方式:.NET 尾部标记、go:embed 编译期内嵌——方案顺着格式边界选。
  3. 运行时最小版本显式验证node:zlib 的 zstd 导出、依赖的 install 脚本白名单,都要“跑一遍”而非“看着像”。
  4. 无边框 = 接管系统职责:删标题栏就要自绘三键、自己处理拖动与最大化,且三处尺寸保持同步。
  5. 平台 API 细节别凭印象CreateIconIndirectuser32.dll 不在 gdi32.dll;高 DPI 下坐标会无符号下溢。
  6. 把重活从首次交互里摘出来:首次解压慢,就先给加载画面 + spinner,别让用户对着空白等。
  7. 环境约束写进脚本:镜像(goproxy.cn)、npm allow-scripts、PowerShell 执行策略,全部固化到 build.ps1,接手者不用重新踩。

五、还没完:诚实清单

交接文档里的“待办”同样值得记录——它让项目知道自己在哪里:

  • GitHub Release desktop-v0.1.0 已发布(full / slim exe + SHA256SUMS + LICENSE 四个资产)
  • apps/desktop 接进 fork 根构建(加一条 desktop:build 脚本)
  • 首次解压进度条(目前只有加载画面 + 提示语,无百分比)
  • 单实例“唤起已有窗口”(目前第二实例只弹“已在运行”)
  • exe 数字签名(Authenticode,避免 SmartScreen“未知发布者”提示)
  • 若想更小,外壳换 Tauri——逻辑(起服务 + 开窗口 + 自绘标题栏)可复用,只换 webview 层

体积这块要诚实说明:DSH 是完整的 agent 运行时,依赖树较大(本机实测 node_modules 约 246 MB,主要来自 node-pty@opentelemetry、各家 provider SDK),完整安装包 ~150–200 MB 量级是“体积换零配置”的取舍。想要小,就用 slim(~11 MB 外壳,复用已装的 Node/DSH),或在 fork 里裁掉不需要的 provider/工具包再 pack。

结尾

这个项目本身不复杂——外壳就是“起服务 + 开窗口”两件事。真正值得记下来的是那 12 个坑:每一个都不是“读文档能预见的”,而是端到端跑出来的、真金白银换来的。把根因写进交接文档(HANDOFF.md),下一个接手者(人或 AI)读一份文件就能开工,不用重踩一遍。

项目源码在 AnAcretiondisk9986/deepseek-harness-Personal-EDITapps/desktop/ 目录,Release 资产在仓库的 desktop-v0.1.0


本文基于项目 HANDOFF.md 交接文档与 apps/desktop/README.md 整理,覆盖从 0 到发布的全过程;文中产物体积、版本号、坑位根因均来自真实构建与测试记录。

读完了FINIS

文章到这里结束本条目至此结束

评论COMMENTS IN THE MARGIN— ITEMS
仅文字 · 最多 300 字 · 显示 IP 属地仅文字 · 不能附加图片 · 发送后标注 IP 属地0 / 300