给一个每天都在升级的 CLI 做桌面壳,我踩到的五个坑

DeepSeek Harness(dsh)跑起来只要一行:

sh 复制代码
npx dsh web

浏览器打开,能用。所以过去一周我一直在回答同一个问题:既然一行命令就能跑,桌面壳到底在解决什么?

我的答案是:npx dsh web 把「跑起来」解决了,把「一直跑着」留给了你。运行时装在哪、怎么升、升坏了怎么退回去、端口被占了怎么办、关掉应用之后子进程还在不在、服务半夜 OOM 了谁拉起来、GUI 应用为什么找不到你装的 pnpm------这些没有一个是 dsh 该管的,但每一个都会在某个周三下午找上你。

我把这些做进了一个 Electron 壳里。下面是五个我原以为很简单、结果都不简单的地方。

先说清楚:社区里已经有一个做得很好的 DSH Desktop,star 比我多三个数量级,安装体验也更完整。这篇不是来抢位置的------它的思路是固定一个上游版本、原样跑;我的思路是把运行时当成一个需要长期升级、回滚、监管的服务来对待。同一个问题的两种解法,下面讲的是第二种会遇到什么。


一、升级不能是「删掉旧的装新的」

最朴素的升级实现是:npm install @deepseek-ai/dsh@latest,装完重启。

问题是这一步一旦失败------网络断了、包坏了、新版本起不来------用户手上就什么都没有了。他昨天还能用的版本被覆盖掉了。

所以运行时在我这里是双槽 的:runtime/slot-aruntime/slot-b,外加一个 current.json 指明哪个在用。升级装进空闲的那个槽,装完之后不直接切换,而是先拿它起一个探针服务,等它真的返回 HTTP 200,才把指针挪过去。

这样有两个后果,都是我要的:

  • 升级失败不会动到能用的版本,用户完全无感。
  • 升级成功之后,旧版本还在另一个槽里。所以「回退到上一版」是一次指针移动,不联网、不重装、一秒完成。菜单上就一个菜单项。

这件事的成本是磁盘上多一份运行时(几百 MB)。我认为这笔交易划算:磁盘便宜,一个装坏了的 AI 客户端在你正要干活的时候罢工不便宜。

二、npm 的 latest 不是「最新」

这个坑我是被结结实实绊了一跤才发现的。

dsh 发版的习惯是:新构建先推到 npm 的 next dist-tag,过一段时间再把 latest 挪过来。也就是说------一个版本可以已经公开发布了,但对任何盯着 latest 的人来说是不存在的

我最早的「检查更新」就是查 latest。结果是用户明明看到 GitHub 上发了新版,我的应用告诉他「已是最新」。这不是 bug,是我把 latest 当成了「最新」。它其实是「维护者认为可以推给所有人的那个」。

现在的做法是:一次请求同时读 latestnext 两个 tag,界面上分成「稳定版」和「预览版」两个通道。稳定版上已经是最新时,如果预览版有更新的,会明说------而不是把这个变成一道谜题。通道往版本指的时候,也照实说这是一次降级,自检和另一个槽的保护对降级同样生效。

给同类项目的教训:任何依赖 npm 版本检查的更新逻辑,先确认上游用不用 dist-tag 分发。查错 tag 的表现是「功能正常但结论错误」,非常难查。

三、双击启动的 GUI 应用,拿不到你的 PATH

macOS 上从 Finder 双击打开的应用,拿到的 PATH/usr/bin:/bin:/usr/sbin:/sbin。你在 .zshrc 里 export 的那些,它一个也看不见。

这在普通应用里没什么,在这里是致命的:dsh 要在 PATH 上找 pnpm 来装插件,要找 Claude Code / Codex CLI 来做委派。用户装了,应用找不到,报错还长得像是插件坏了。

常见的解法是硬编码一串常见路径:/opt/homebrew/bin~/.nvm/versions/node/*/bin~/.volta/bin......我写了一版,然后意识到这是在猜------猜用户用哪个版本管理器、装在哪。猜不完。

现在的做法是直接问用户自己的 shell :每次运行启动一次登录 shell,问它 PATH 是什么,把答案接上。这覆盖的是用户实际在用的那套,而不是我想到的那几种。硬编码的目录列表还在,退到后面当兜底,用在 shell 问不到的时候。

同样的理由,打包出来的应用自带一份 Node 运行时:目标机器不需要预装任何东西,也就绕开了「装了 Node 但 GUI 看不见」这整层问题。

四、退出应用 ≠ 子进程退出

这是那种代码评审里绝对看不出来、单元测试里绝对复现不了 的 bug:应用看上去干干净净地退出了,任务管理器里躺着一堆孤儿 node 进程,占着内存和端口。

服务进程在启动时就被放进自己的进程组(POSIX)或作业树(Windows),退出时整棵树一起终止。听起来像一件事,实际是两套完全不同的机制:POSIX 上是给负 pid 发信号,Windows 上是 taskkill /T

我在真机上验过之后才敢写「已验证」。Windows 上的自检就一行,任何改动过退出逻辑之后跑一次,应该什么都不输出:

powershell 复制代码
Get-Process node -ErrorAction SilentlyContinue | Where-Object { $_.Path -like '*dsh-desktop*' }

顺带一提,服务被非正常终止时------包括 OOM,它是以信号而不是退出码的形式到达的------会自动重启,退避 1s / 3s / 8s,连续三次失败弹对话框而不是无限重试。已经稳定运行一分钟的服务会重新拿到完整的三次预算,所以偶发的一次崩溃不会把额度耗在昨天。

五、自动更新会悄悄毁掉 macOS 的权限授权

最后这个最阴。

macOS 把隐私权限(麦克风、摄像头、文件夹访问)绑定在应用的代码签名上。你重装一次应用,签名变了,用户之前给过的授权全部作废,需要重新点一遍。对一个几天发一版的项目来说,这意味着用户每周都要重新授权几次。

所以应用更新分成了两种:

  • 热更新 :新版本只改了壳的 JavaScript 和标记时走这条路。几百 KB 下到数据目录里,重启即生效,安装好的应用包一个字节都没动------签名还在,权限也就还在。
  • 安装包更新:Electron 本身或者内置的 Node 变了才走这条,下载安装包交给用户。

判断依据是构建时记下的 Electron 版本:大版本不一样的壳不允许热更新过去。另外,热更新的启动入口是唯一不能被热更新的那个文件,它做的事情尽可能少------挑一个 bundle,import 它,失败就回落到打包时那份。这个回落是整套机制安全的原因:一个下坏了的更新,最差也就是回到用户当初装的那个版本,因为那个版本还在原地。


结果长这样

插件市场:DSH Market 的目录收进窗口,可搜索,本地缓存可离线浏览,已验证的条目一键装。

插件管理:npm 包名、GitHub 链接、本地路径、zip 都能装;装好的能更新、停用、配置、卸载。停用写的是加载器行上的 disabled: true,也就是运行时自己的机制,而不是去改 profile 的 bundle 列表------后者会被 dsh plugin 在每次操作时重建。

一处代理设置,两条网络:窗口自己的请求走 Chromium,npm、pnpm 和真正调模型 API 的 dsh 服务走各自的环境变量。GUI 应用两条都拿不到,所以这里设一次,分开测。

性能浮标:一个常驻小窗,看这套东西此刻在花多少 CPU、内存、线程、进程。CPU 是采样两次算出来的真实速率------ps 报的是进程整个生命周期的平均值,对一个从周二就开着的服务来说,那是个关于过去的数字。顺便可以换成水豚。


拿去用

  • 仓库:github.com/huyang218/d...
  • 安装包挂在 releases 里,macOS(Apple Silicon / Intel)和 Windows 都有,MIT
  • 目标机器不需要预装任何东西
  • 非官方项目,与 DeepSeek 无隶属关系

macOS 构建是 ad-hoc 签名、未公证的,首次启动要在「系统设置 → 隐私与安全性」里放行一下------没有 Apple 开发者账号,这条暂时绕不过去。

上面五个坑里有四个跟 dsh 没什么关系,任何一个「给长期运行的 CLI 套壳」的项目都会撞上。如果你在做类似的东西,希望这篇能省下你几个下午。

相关推荐
绿岛之北2 小时前
Electron 安全第四章: Preload 与 IPC
前端·electron
NeilCarmack2 天前
Deepseek-harness增加桌面版端序列:第 2 讲 · spawn Electron:当前进程如何“交棒“
前端·javascript·electron
绿岛之北2 天前
Electron 安全第三章:URL 加载与 WebView
前端·electron
菜鸟前端程序员3 天前
当 AI 文件遇上 PDF:一个基于 Electron + Ghostscript 的矢量格式转换引擎深度剖析
electron
clorinda4 天前
把 DeepSeek Harness 打包成类似 Codex 的 Windows 桌面应用:Electron、一键启动、插件管理和动态壁纸
前端·javascript·windows·electron
晴天166 天前
Mojo IPC 和 Electron 的关系-Day21
javascript·electron·mojo
卸任6 天前
AI英语学习助手:从翻译工具到 AI 英语学习助手
前端·electron
kaixin_learn_qt_ing6 天前
Electron程序---初体验
javascript·electron
晴天167 天前
Electron面试题-Day19
java·javascript·electron