先交代一下这件事的荒诞之处:dsh(DeepSeek Harness)本身是个 AI agent harness,命令行工具,让人跟 AI 协作写代码、干活。而这次编译它的桌面端,我是拿旧版 dsh 当 agent 来干的。等于让 AI 编译它自己的继任者。
这个开头基本预告了后面的一切。AI 干活快,会自己摸索方向、半路换技术栈;但也不会像人那样停下来想一想:这个测试版自带的强制更新策略,装到别人机器上会不会把启动卡死。后面这一大堆状况,根子都在这儿。
这件事分两段:第一段编译打包,第二段装完启动卡死。两段都不顺,第二段尤其磨人。
第一段:编译,主线是 Electron,还踩了个 Windows 的权限坑
我没指定技术栈,只给了句”读新源码、自我升级”。它从源码起手,先试的是 Tauri(Rust + WebView2),盘算了半天 WebView2 运行时、tauri-build 的 icon、cargo 首编要拉几百个 crate,最后自己判断这条路不合适,掉头走了 Electron。它内部到底怎么权衡的,我到现在也没完全搞清楚,但结果就是这样。
Electron 这边用 electron-builder 打 NSIS 安装包,Electron 二进制走 npmmirror 的国内镜像,不然下载慢得没法忍。第一版本地装上是能跑的,问题出在换台机器装上。
装到虚拟机里,启动就起不来,日志里刷了一屏:
junction rebuild failed for packages\bundle\web-app\node_modules\@deepseek-ai\dsh-client-ui-tool: EPERM: operation not permitted, mkdir 'C:\Program Files\DeepSeek Harness\resources\dsh\...'copy fallback: EPERM: operation not permitted, mkdir '...'原因得从 dsh 的依赖组织方式说起。它的 node_modules 是拿 junction(Windows 的目录联接)串起来的,装进 C:\Program Files 之后,程序启动时会尝试重建这些 junction。但普通用户进程在 Program Files 里连 mkdir 都没有权限,更别说建 junction 了,于是 EPERM 刷屏。
当时理出来的方案有三个:
- A:requireAdministrator,每次启动弹 UAC。能用,但每次双击都弹一次提权,太烦。
- B:EPERM 时 fallback 成解引用拷贝(把 junction 展开成真实目录拷贝)。逻辑上对,但有个盲点——拷贝也是在 Program Files 里写,普通用户照样 EPERM。这条路在 Program Files 下其实走不通。
- C:打包时就全部解引用,最干净但工作量最大。
最后走的是另一条:把 NSIS 安装改成 perUser,装到 AppData\Local,普通用户对自己目录有完整写权限,junction 想建就建;同时在 main.js 里加一道防御性的 dereference-copy fallback 兜底。这也是为什么这个程序最终的安装路径是 C:\Users\...\AppData\Local\Programs\DeepSeek Harness,而不是 Program Files。
编译这关就算过了。装上去,双击,然后第二段开始了。
第二段:装完启动,卡在一个说不上来的地方
程序起来了,任务栏有窗口,但整个界面是模糊的,点不了任何东西,像盖了层毛玻璃,也没有任何报错弹窗。不闪退,不崩溃,就是卡在那。你要我描述,我真说不上来——“卡在一个奇怪的环节上”,就这么个感觉。
先排端口,结果是个障眼法
这个桌面版不是单纯套壳网页,它是两层:Electron 壳负责窗口和协议,另外 fork 出一个宿主进程,在 127.0.0.1:19387 上跑本地 web 服务,窗口加载的其实是这个服务。
因为它要绑端口,我第一反应就是端口被占。自己写个 TCP 监听占住 19387 再启动,果然报错:
dsh: startup failed: 2 required plugins did not activatewebserver (required) — Error: listen EADDRINUSE: address already in use 127.0.0.1:19387能复现,但这个报错是弹错误框的,跟实际看到的”模糊卡死”对不上。而且查了下,19387 端口是空的,没有残留进程。端口这条线排除。
看进程在等什么
程序卡住无非两种:死循环(CPU 跑满)或等待(CPU 空闲)。盯了它几秒,宿主进程和两个渲染进程的 CPU 增量基本是零。不是死循环,是在等。
等什么?看 TCP 连接,全是 127.0.0.1 回环,没有任何对外连接。也就是说它没在等外部 API,是卡在自己内部。到这一步,光看进程已经没用了,得进渲染进程里面看。
用 CDP 直接读渲染进程
Electron 支持开 Chrome 调试端口,启动加 --remote-debugging-port=9222,访问 /json/list 能看到所有窗口:
type=page title='dsh-app://shell/update-dialog.html' url=dsh-app://shell/update-dialog.htmltype=page title='测试DeepSeek新模型更新 — DeepSeek Harness' url=dsh-app://app/两个窗口:主应用 dsh-app://app/,和一个 dsh-app://shell/update-dialog.html——更新对话框。
通过 CDP 的 WebSocket 连上去,在这个对话框页面上执行 JS 看它渲染了什么:
{"readyState":"complete","title":"","text":"","children":0}body 里一个子元素都没有,空的。一个”更新对话框”,内容是空白。
追源码:为什么会有更新对话框,为什么它是空的
对照源码,这个 alpha 版的安装包 manifest 里带着一条强制更新策略:
"dshMandatoryUpdatePolicy": { "origin": "https://harness-test.deepseek.com", "authentication": "feishu-test"}启动时它会先请求这个测试服务器的策略接口,没认证就返回 401,于是程序弹一个”需要飞书登录”的模态对话框挡在主窗口上。这个对话框就是那个 update-dialog.html。
而它为什么是空的——看 Electron 壳里 protocol.handle 的实现:
protocol.handle(SCHEME, (request) => { const url = new URL(request.url); if (url.hostname === "app") { // 只处理主应用 return ...; } return Promise.resolve(new Response(null, { status: 404 }));});只认 app 这个主机名,其余——包括 shell——一律 404。所以 shell/update-dialog.html 加载回来是 404,对话框自然是空白。
完整因果链:启动 → 查测试服务器策略 → 401 → 触发飞书登录 → 弹 shell/update-dialog.html → 协议处理器对 shell 返回 404 → 对话框空白 → 一个透明的空白模态窗口盖在主界面上、主界面被模糊 → 你看到的”毛玻璃 + 点不了”。
两层问题叠在一起:一层是”测试版为什么要登录飞书”,一层是”对话框为什么空白”。
解包 app.asar 时踩的坑
确认 manifest 内容要解包 app.asar。asar 格式不复杂,但手动解析有几个坑,记下来免得下次再踩:
- 头部不是直接放 JSON。前 4 字节是
4(pickle 的 payload 大小),JSON 字符串实际从 offset 16 开始 - JSON 长度在 offset 12 这个位置(
readUInt32LE(12)),不是 4 也不是 8 - 文件索引里每个文件的
offset是字符串("127580522"),不是数字,直接base + entry.offset会变成字符串拼接读出空串,得先Number()
这几个坑一个个踩过去,才正确读出 manifest。
修复:改一个字节
最省事的修法不是去补协议处理器的 shell 分支(那要改 main.js 重新打包),而是让程序压根不触发这个强制更新策略。这条策略本来就是测试版专用(feishu-test 认证、harness-test 域名),对正常使用没意义。
判断逻辑是 'dshMandatoryUpdatePolicy' in manifest,只要让这个字段在 manifest 里”不存在”,整条策略流程就跳过了。而改名不需要重新打包,把字段名最后一个字符从 y 改成 x,字节数不变,asar 结构不受影响:
dshMandatoryUpdatePolicy → dshMandatoryUpdatePolicx一个字节。改完启动,CDP 再看,update-dialog.html 那个窗口消失了,只剩主应用窗口,工作区、会话列表、历史对话全部正常渲染。完事。
复盘
- 端口冲突是个障眼法。它确实能复现一个报错,但跟实际现象对不上。要是停在”端口占用”这个结论上,就修错方向了。
- CDP 读渲染进程是最关键的一步。窗口卡死这类问题,光看进程状态和日志经常什么都看不到,直接连进去读 DOM 和页面状态,一目了然——这里甚至不需要视觉模型,读
innerText就够了。 - 这个 bug 是两层叠加:强制更新策略是”设计如此”(测试版要登录),协议处理器的 404 才是真正的实现 bug。但用户感受到的就一件事:卡死。
- 编译阶段的 junction/EPERM 和排查阶段的”飞书认证”其实是同一个味道:测试版的产物,带着测试环境的假设(Program Files 权限、测试服务器认证),一到真实使用环境就翻车。
最后说句题外话:一个 alpha 测试版,装着测试服务器的强制更新策略、飞书测试认证,再叠加一个协议 404 的 bug,普通用户装上就是”启动卡死”四连。说到底还是开头那句话——拿 AI 编译 AI,产出来的东西是给”另一个 AI 接着继续用”准备的,测试环境里那一堆假设(Program Files 权限、测试服务器认证)一旦落地到真实机器上,就全成了坑。
还有件事值得记一笔:这次排查和修复也不是人肉手撸出来的,接手的是另一个 AI 助手(Codewhale),同样是 deepseek 的模型。旧 dsh 编译出了带坑的新版,新版卡死,再由另一个 AI 来补位——查日志、连渲染进程、解包、改一个字节。两个 AI 接力,一个负责把自己升级坏,一个负责把坑填上。这篇,就算给这段接力留个底。
部分信息可能已经过时