日常用 DeepSeek Harness(DSH)的 Web 界面和 AI 结对干活,每次都要先开终端敲 dsh web、再开浏览器输地址,步骤琐碎。于是让 AI 直接给我做了一个 macOS 应用:双击图标就打开 DSH 页面;如果 dsh web 没在跑,它会自动在后台把服务拉起来,就绪后再弹开浏览器。

整个过程一次会话搞定,中间翻了两次挺有意思的车——一次在图标,一次在认证——记录如下。

一、应用本体:手写 .app 结构

macOS 的应用本质上就是一个遵守约定目录结构的文件夹,~/Applications/DSH.app

DSH.app/
└── Contents/
    ├── Info.plist            # 应用元信息(指定可执行文件、图标、Bundle ID)
    ├── MacOS/
    │   └── dsh-launcher      # 启动脚本(chmod +x)
    └── Resources/
        └── icon.icns         # 图标

不需要 Xcode,不需要 Automator,三个文件就是全部。

二、启动脚本:四个关键设计

核心逻辑:探测 3080 端口,通了就开网址;不通就拉起服务、等就绪、再开。但有四个容易踩的坑:

1. GUI 应用的 PATH 是残的

从 Finder/Dock 启动的应用不会继承 shell 环境,PATH 只有 /usr/bin:/bin:...。我的 node 在 nvm 里(~/.nvm/versions/node/v24.15.0/bin),脚本里必须显式补 PATH,否则双击永远找不到 dsh 命令——终端里测试一切正常,双击就失败,非常经典的迷惑现场。

2. 后台拉起用 nohup,加 --no-open --port

1
nohup "$DSH" web --no-open --port "$PORT" >>"$LOG" 2>&1 &

nohup + 重定向让服务进程脱离启动器的生命周期,脚本退出后服务照常运行。dsh web 默认会自己弹浏览器,但就绪时序和要打开的 URL 都由启动器统一控制更可控(原因见第 3 点),所以加 --no-open 收归一处;--port 显式传,别让「检测的端口」和「启动的端口」各说各话——这个不一致我在测试时真踩了一次:环境变量改了检测端口,启动的实例却奔着默认端口去抢,EADDRINUSE 秒退。

3. 认证机制:必须打开它「打印出来」的 URL(本篇最大翻车)

第一版上线后博主实测就翻车了:双击后页面报 “dsh web authentication required; reopen the URL printed by dsh web”

读源码才搞明白,dsh web 有一套 browser-trust 认证机制:

  • 每次 dsh web 启动会生成一个进程内一次性 launch tokenrandomBytes,只在内存里);
  • 它向 stdout 打印 dsh web: http://127.0.0.1:3080/?token=…,访问这个带 token 的 URL 会 303 重定向并种一个 30 天的签名 cookie(签名密钥持久化,跨重启有效),然后跳回干净的 /
  • 裸地址只有在浏览器已持有有效 cookie 时才能用。我 open 的是裸地址,而默认浏览器里恰恰没有有效 cookie → 401。

用 curl 三连验证了整条链路:

1
2
3
裸地址             → 401
/?token=…         → 303 + Set-Cookie
带 cookie 再访问    → 200

所以启动器的正确姿势是:等日志里出现那行 dsh web: <url>,打开它。这行还是比 nc 探端口更准的就绪信号——它打印出来就意味着 HTTP 服务已经可用。解析时有两个细节:

  • 只认「本次启动之后」打印的、且端口匹配的行。日志是追加式的,上一任实例留下的旧 token 对新进程无效,直接全量 tail -1 会在冷启动竞速里抢到过期 token;
  • 区分「谁拉起的服务」:pidfile 里的 PID 必须和当前端口的监听者一致,才走「取日志 token」路径;如果是用户在终端手动启动的实例,从外面拿不到它的进程 token,只能开裸地址降级,并弹通知告知「若提示认证失败请重启服务」。

4. 超时兜底

实测冷启动约 1 秒就绪;90 秒超时后弹出系统对话框,可一键打开日志(~/Library/Logs/dsh-web.log)。启动期间用 osascript 发系统通知告知进度,体验比干等着好。

三、图标翻车记

第一版:qlmanage 渲染 SVG,埋了两颗雷

DSH 的 Web 界面有个鲸鱼 favicon.svg,就地取材。macOS 没有自带 SVG 转 PNG 的正经工具,第一反应是用 qlmanage -t(Quick Look 缩略图)渲染。当时看着"出图了"就直接打包成 icns 用了,结果双击后 Dock 里还是默认图标。

排查分两层:

雷一:LaunchServices 图标缓存。 图标文件是在向系统注册(lsregister)之前就位的,系统一直用缓存里的默认图标。标准修法:

1
2
3
touch ~/Applications/DSH.app          # 更新时间戳,触发重读
lsregister -f ~/Applications/DSH.app  # 强制重新注册
killall Dock && killall Finder        # 重启显示层

雷二(更隐蔽):图标图稿本身就是坏的。 缓存问题解决后,我用一个客观方法验证图标内容——写个 Swift 脚本调 NSWorkspace.icon(forFile:) 把系统实际显示的图标导出成 PNG,再用 PIL 做像素统计。结果傻眼了:按颜色包围盒分析,蓝色背景只占了画面下半截(y=394~1022),上半截全被白色填充——qlmanage 对这个 SVG 的渲染完全是错的,只是 1024 缩略图肉眼没细看就信了。

第二版:无头 Chrome + PIL 重造

qlmanage 靠不住,换真正的浏览器引擎。机器上正好有 Chrome:

1
2
3
4
"Google Chrome" --headless --disable-gpu \
  --screenshot=render.png --window-size=1024,1024 \
  --default-background-color=00000000 \
  file:///tmp/render.html        # HTML 里内联 SVG,CSS 拉满 1024×1024

顺手做了两个设计调整:

  1. 鲸鱼缩放居中:favicon 的鲸鱼是满铺设计的,直接当 app 图标太挤。包一层 <g transform="translate(10,10) scale(0.6)"> 缩到 60% 居中,留出蓝色呼吸边距。
  2. 圆角透明蒙版:Chrome 渲染的是方图,用 PIL 画一个 22.5% 半径的 rounded_rectangle 作为 alpha 通道,四角变透明,贴合 macOS 图标风格。最终:鲸鱼白色像素占 12.7%、蓝底 82.9%,比例舒服。

然后走标准流程:sips 缩出 16~1024 全套尺寸 → iconutil -c icns 打包 → 替换进 .app → touch + lsregister + killall 三连。

验证方法论:对照组思维

最后怎么确认"系统里看到的图标真的对了"?光看自己的渲染不算数。用同一个 Swift 提取脚本,把 Safari 的图标也导出来做对照:

指标DSHSafari(对照)
不透明像素占比60.7%63.3%
内容包围盒(69,78,955,963)(25,78,955,1001)

两者结构几乎一致——NSWorkspace 返回的图标自带系统级边距,我们的图标渲染特征和一个正经苹果应用完全对齐。中心取样是鲸鱼白、边缘取样是 DeepSeek 蓝、四角透明,收工。

五、成果与备忘

  • 一键启动:拖进 Dock 或 ⌘空格搜 “DSH”,点一下进页面;服务没在跑时自动拉起,全程约 1 秒。
  • 日志~/Library/Logs/dsh-web.log,PID 在 dsh-web.pid,想停服务 kill $(cat ...) 即可。
  • 认证的边界:应用自己拉起的实例,token 就在日志里,随时能给出认证 URL;但终端里手动启动的实例,token 只在它的 stdout 上,外部拿不到——这种情况下双击应用走裸地址降级,浏览器有有效 cookie(30 天内登录过)就无事,没有就请重启服务。
  • 一个已知维护点:脚本里写死了 nvm 的 node 版本路径(v24.15.0),以后 node 升级大版本要改 NODE_BIN 一行。这也算是 nvm 类版本管理器和"脱离 shell 运行的 GUI 应用"之间固有的摩擦了。

小结

这篇里真正值得带走的不是某个命令,而是三个调试直觉:

  1. “出图了”≠“图对了”。qlmanage 的缩略图管道对 SVG 的支持是有残缺的,肉眼扫一眼缩略图很难发现布局错位;用包围盒/像素统计做客观验证,一次就现形。
  2. 验证拿对照组。怀疑"系统显示的图标不对劲"时,把 Safari 这种原生应用用同一条管道过一遍——如果它也"不对劲",那不对劲的就是你的测量管道,而不是被测对象。
  3. 报错文案里藏着答案。“reopen the URL printed by dsh web” 不是一句客套的报错,它就是解法本身:认证模型是"一次性 launch token 换 30 天签名 cookie",而 token 只随进程打印在 stdout 上。第一版我把它当成了可有可无的启动横幅,直接被现实教育了——遇到带具体指令的报错,先照做,再深究

本文由 Kimi K3(模型 ID:kimi-k3,月之暗面出品)在 DeepSeek Harness 会话中全程实操(应用创建、图标调试与重造、认证翻车修复与验证)并整理撰写,我负责提出需求、实测反馈、确认效果与审核。