# DSH Launcher
> **简体中文** · [English](README.md)
> [!IMPORTANT]
> **从插件市场过来的?这个插件属于一个 macOS 应用。**
> 它单独运行时只会写一个描述当前服务器的小 JSON 文件 —— 这是刻意为之,它也只做这件事。
> 你真正要用的是一个 **macOS 菜单栏应用**,用本仓库的 `./build.sh` 构建。
> 请先安装应用,见[安装](#安装)。
> 不用 macOS?插件本身在哪都能跑,但没有那个应用,这里的东西对你没有用处。
一个极简的 macOS **菜单栏** 工具,用来管理本地开发服务器:启动、在浏览器中打开、重启或停止 —— 全程无需碰终端。
它常驻在状态栏里,没有 Dock 图标,不会打扰你。
专为 [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness)
(`pnpm dsh web`)编写,但命令、端口、项目目录和浏览器都可以配置,所以它也适用于任意长期运行的本地服务器。
原生 AppKit,单个 Swift 文件,**零依赖**。
仓库同时附带一个配套的 **DSH 插件**,让应用直接从服务端本身获知端口、PID 与带
token 的 URL,而不是去解析日志。见 [DSH 插件](#dsh-插件)。
## 为什么我要做这个工具
起因很简单:每次我想用 [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) 时,从源码运行就意味着必须从终端运行。
```sh
cd ~/Workspace/deepseek-harness
pnpm dsh web
```
命令本身很小 —— 但它周围的那套仪式却不小。整个会话期间终端标签页都得开着;拉取新的改动后要回到终端、杀掉服务器、重新启动;带进程级 token 的 URL 得从日志里复制出来;会话结束时还得手动停掉服务器,否则它就一直占着端口。
所有实际工作都发生在浏览器标签页里。终端从来都不是这个工具的重点 —— 它只是入口处的收费站。所以我把这套仪式换成了菜单栏中的一个图标:一键启动、一键打开浏览器、更新后重启、确认后停止 —— 服务器状态一目了然,而且不会占用 Dock 位置。
也正是因为想*好好*做一个 GUI 应用,我才发现了那些终端一直默默掩盖的坑——GUI 应用不继承 shell 的 `PATH`、DSH 的启动 token 必须跟着进入打开的标签页、停止时必须只针对监听该端口的进程。这些都在[它是如何工作的](#它是如何工作的)里做了说明。
## 菜单栏
状态图标是 DeepSeek 鲸鱼,用模板图像绘制,macOS 会自动为浅色和深色菜单栏着色。它让你一眼就能看到服务器状态 —— 运行时是**实心**的,停止时是**变暗**的 —— 所有操作都在一处点击即可完成:
顶部两条变暗的灰字是状态而非操作:服务器状态(**运行在 3080 端口**、**未运行**,或短暂的**正在启动…** / **正在停止…**)和 PID。
当服务器停止时,菜单会显示 **启动服务器**。
服务器状态每 3 秒轮询一次,即使你从终端启动或停止服务器,图标也会一直保持正确。
## 面板(可选)
**显示面板**会打开一个带有相同操作的小窗口:
关闭面板**不会**退出应用——它仍然保留在菜单栏中。
请注意窗口按钮:关闭和最小化可用,而缩放/全屏被刻意置灰——这是一个固定尺寸的面板,全屏只会拉出一片空白。
## 安装
本仓库提供**两个相互独立的部分**,先分清楚它们各自是什么:
| 部分 | 是什么 | 安装方式 | 是否必需 |
|---|---|---|---|
| **应用** | macOS 菜单栏应用 | `./build.sh` | **必需** —— 它就是启动器本身 |
| **插件** | DSH 插件(`dsh-menubar-launcher`) | `dsh plugin add` | 可选 —— 推荐安装 |
应用才是主体。插件只是一个运行在服务端内部的小助手,负责把该打开的 URL 告诉应用。
**没有插件应用也能用**,具体区别见[我需要装插件吗?](#我需要装插件吗)。
### 1. 安装应用
需要 macOS 13 及以上版本,以及 Xcode 命令行工具(`xcode-select --install`)。
```sh
git clone https://github.com/songer522/dsh-launcher.git
cd dsh-launcher
./build.sh
```
它会编译、打包、签名并安装到 `/Applications`。从那里启动即可 —— 它会出现在菜单栏,
没有 Dock 图标。
```sh
./build.sh --dev # 构建到 ./build 而不安装
./build.sh --uninstall # 卸载已安装的应用程序
```
### 2. 安装插件(推荐)
```sh
dsh plugin --profile web add https://github.com/songer522/dsh-launcher/releases/latest/download/dsh-menubar-launcher.tgz
```
**然后重启服务器** —— 用应用菜单里的**重启**,或者按你平时的方式停止再启动。插件是在
服务器启动时组合进去的,所以已经在运行的服务器不会带上它,不重启就什么都不会出现。
验证是否生效:
```sh
cat ~/.config/dsh-launcher/runtime.json
```
输出一段 JSON 就说明已经生效。提示 "No such file or directory" 则说明:要么装完插件后
还没重启服务器,要么服务器没在运行。
插件也会出现在 DSH 网页界面的**设置 → 插件**中,名为 `dsh-menubar-launcher`,带一个
绿色的 *active* 圆点。
> **要装到应用实际启动的那个 profile 上。** 上面的命令装进 `web` profile,也就是
> `dsh web` 和应用默认命令(`pnpm dsh web …`)都会启动的那个。如果你在偏好设置里把
> **命令**改成了别的 profile,请改用 `dsh plugin --profile <名称> add …`,否则应用启动的
> 服务器上并没有这个插件。
卸载:`dsh plugin --profile web remove dsh-menubar-launcher`。
### 我需要装插件吗?
不需要。应用一直是通过读取**它自己启动的**那个服务器的日志来获取 DSH 的进程级 token 的,
这条路依然有效。插件解决的是这套机制覆盖不到的情况 —— 服务器**不是**由应用启动的:
| 场景 | 不装插件 | 装了插件 |
|---|---|---|
| 由应用启动服务器 | ✅ 正常(读它的日志) | ✅ 正常 |
| 服务器是从终端启动的 | ❌ 打开的标签页返回 401 | ✅ 正常 |
| 服务器在应用之前就已启动 | ❌ 打开的标签页返回 401 | ✅ 正常 |
所以:如果你总是从应用启动服务器,只装应用就够了;如果你也会在终端跑 `dsh web`,或者
让服务器跨越应用重启一直运行,那就把插件也装上。
两条路径都是刻意保留的 —— 插件是增强,不是前提,应用从不假定它一定存在。
## 配置
首次运行时,应用会在常见位置(`~/Workspace`、`~/Projects`、`~/Developer`、`~/src`、`~/code`、`~`)查找 DeepSeek Harness 代码库。如果你的项目在别处,或者想启动一个完全不同的东西,请打开**偏好设置**(⌘,)。
设置以 JSON 形式存储在 `~/.config/dsh-launcher/config.json`:
```json
{
"repo": "/Users/you/Workspace/deepseek-harness",
"command": "pnpm dsh web --no-open --port {port}",
"port": "3080",
"browser": "Google Chrome",
"logFile": "/tmp/dsh-web.log",
"language": "system"
}
```
| 字段 | 含义 |
|---|---|
| `repo` | 命令运行的工作目录 |
| `command` | 启动命令;`{port}` 会被替换 |
| `port` | 要监听的端口,并替换到命令中 |
| `browser` | 要打开的应用名称,或 `""` 表示系统默认浏览器 |
| `logFile` | 服务器 stdout/stderr 的写入位置 |
| `language` | 界面语言:`system`、`en` 或 `zh-Hans` |
因为它本质上只是“目录 + 命令”,所以它也能轻松运行 `npm run dev`、`vite`、`python -m http.server` 或任何其它东西。
### 界面语言
界面提供 **English** 与 **简体中文** 两种语言,在**偏好设置(⌘,)→ 界面语言**中选择,
切换后立即生效,无需重启应用。
默认的**跟随系统**会跟随 macOS:系统首选语言是中文时显示中文,否则显示英文。之所以还
提供两个明确选项,是因为“让某一个应用用与系统不同的语言”在开发者中相当常见,值得单独
做成一个设置。
### 为什么启动标志很关键
标准命令使用 `--no-open`,因为 DSH 会默认在系统默认浏览器中打开 URL。在这里抑制它并在应用自身内打开浏览器,这样你可以在使用 Chrome 的同时,让 Safari 保持为系统默认浏览器。
## DSH 插件
应用可以管理一个并非由它启动的服务器 —— 但那样它就没有日志可读,而 DSH
的进程级 token 只存在于那份日志里。结果就是:菜单里那一项打开的标签页返回 401。
因此本仓库还附带一个 Host 插件。它运行在 harness **内部**,端口与 token 在那里
不需要解析、本就是已知的,插件把它们写到应用能读到的位置。安装方式见
[安装插件](#2-安装插件推荐);也可以直接从源码安装,同样不需要构建步骤:
```sh
dsh plugin --profile web add github:songer522/dsh-launcher
```
重启服务器后,它会写入 `~/.config/dsh-launcher/runtime.json`:
```json
{
"version": 1,
"pid": 29185,
"host": "127.0.0.1",
"port": 3396,
"url": "http://127.0.0.1:3396",
"authenticatedUrl": "http://127.0.0.1:3396/?token=…",
"startedAt": "2026-09-08T21:51:36.643Z"
}
```
该文件在服务器开始监听后创建,并在**服务器停止时删除**,所以它是否存在本身就是
一个存活信号。文件权限为 `0600`、目录为 `0700`:`authenticatedUrl` 里含有启动
token,那是一份可以完全访问该 harness 的凭据,请按凭据对待。
应用优先使用这个文件,文件不存在时回退到解析日志,所以插件是可选的 —— 不装它就
是原来的行为。只有当描述文件中的 PID 正是当前监听该端口的进程时才会被采用,因此
`kill -9` 之后残留的文件会被忽略,而不会被拿去打开一个已经失效的标签页。
这个插件并不局限于 macOS:任何想获取运行中 harness 的带认证 URL 的程序,都可以
读同一个文件。
要改变位置,请在 profile 的 `cordis.patch.yml` 中重述该行的完整配置(patch 是
替换而不是合并该行的 config)—— 注意应用只会查看默认路径:
```yaml
- id: dsh-launcher-runtime
config:
path: /somewhere/else/runtime.json
enabled: true
```
插件不声明任何依赖,因此可以配合任意 harness 版本安装,也不需要 `allowBuilds`
授权。在没有 Web 服务器的 profile(例如 `headless`)中,它什么也不做,harness
照常启动。
## 窗口行为
该应用是一个菜单栏工具(`LSUIElement`),所以它**没有 Dock 图标**,也没有 ⌘Tab 入口。只能从菜单栏退出。
对于可选面板:
| 控制项 | 状态 |
|---|---|
| 关闭(红色) | 可用 —— 隐藏面板;应用仍然保留在菜单栏 |
| 最小化(黄色) | 可用 |
| 缩放 / 全屏(绿色) | 禁用 —— 固定尺寸面板 |
全屏之所以被禁止,一方面是因为窗口的 `styleMask` 中省略了 `.resizable`,另一方面是给 `collectionBehavior` 添加了 `.fullScreenNone`,所以 ⌃⌘F 也无效。
服务器以**分离**方式启动,因此退出启动器永远不会停止服务器。停止永远是明确的、需要确认的操作——服务器可能正在托管实时会话。
## 它是如何工作的
四个容易弄错的关键细节:
**1. GUI 应用没有 shell PATH.** 双击的应用不会读取 `~/.zshrc`,也不会继承登录 `PATH`,所以 `pnpm` 根本找不到。会自动对照常见的安装位置显式解析二进制,并以登录 shell 查找为后备。生成的服务器也会在 `PATH` 前面加上这些目录:只解决 `pnpm` 是不够的,因为它会执行 `node`,否则会报 `env: node: No such file or directory`。
**2. 认证 token 必须带过来.** DSH 会为每个进程生成启动 token 并打印带 token 的 URL;裸域名会返回 HTTP 401,所以标签页会是死页。应用按顺序从两个来源解析该 URL:先是配套插件写入的[运行时描述文件](#dsh-插件),然后才是本次运行打印的日志。优先用描述文件,是因为它对从终端启动的服务器同样正确 —— 而那正是根本没有日志可读的情况。两者都没有的服务器会回退到普通 URL。
**3. 端口探测必须筛出监听者。** 这个应用使用:
```sh
lsof -ti tcp:3080 -sTCP:LISTEN
```
如果没有 `-sTCP:LISTEN`,`lsof` 还会报告连接到该端口的每一个**客户端**(你的浏览器、你的编辑器)——对这些 PID 操作会杀死无关的应用。只有一个进程能持有 `LISTEN`,所以它总能精确定位到服务器,不管 PID 是什么。
**4. 停止是优雅的。** 先发 `TERM`,只有在约 6 秒后端口仍然被占用时才升级为 `KILL`。
## 开发
一切都在一个文件里:`Sources/DSHLauncher.swift`。
```sh
swiftc -O Sources/DSHLauncher.swift -o build/DSHLauncher && ./build/DSHLauncher
```
### 代码签名的坑
如果你只是安装使用,这里无需做任何事 —— `build.sh` 已经处理好了,并会在结束前校验签名。它只在你要修改构建脚本时才有意义,所以这里记录下步骤顺序不能调整的原因。
`build.sh` **最后** 才对 bundle 签名,这个顺序不是随意的。签名后再修改 bundle —— 替换图标、编辑 `Info.plist` —— 会让签名失效,macOS 于是拒绝把该目录当作应用。肉眼可见的症状是 **Finder 显示的是文件夹图标**,看着像图标 bug,其实是签名坏了:
```text
$ codesign -v "/Applications/DSH Launcher.app"
invalid Info.plist (plist or signature have been modified)
```
绕过 Finder 缓存,去看 macOS 实际按什么文件版本解析的图标:
```sh
osascript -l JavaScript -e 'ObjC.import("AppKit");
var img=$.NSWorkspace.sharedWorkspace.iconForFile("/Applications/DSH Launcher.app");
var rep=$.NSBitmapImageRep.imageRepWithData(img.TIFFRepresentation);
rep.representationUsingTypeProperties($.NSPNGFileType,$()).writeToFileAtomically("/tmp/resolved.png",true);'
open /tmp/resolved.png
```
## Shell 等价命令
如果你更习惯终端,下面的函数能完成同样的事。两个细节很重要:`-sTCP:LISTEN` 过滤器,以及捕获带 token 的 URL 而不是打开裸监听地址(裸监听地址会 401)。
```sh
dshweb() {
local port="${DSH_WEB_PORT:-3080}"
local log="${TMPDIR:-/tmp}/dsh-web-${port}.log"
: > "$log" # 旧的 URL 携带的是过期的 token
(cd ~/Workspace/deepseek-harness && pnpm dsh web --no-open --port "$port" > "$log" 2>&1) &
local server=$!
tail -f "$log" & local tailer=$!
while ! nc -z 127.0.0.1 "$port" 2>/dev/null; do
kill -0 "$server" 2>/dev/null || { kill "$tailer" 2>/dev/null; return 1; }
sleep 0.3
done
# DSH 形如 `dsh web: http://127.0.0.1:PORT/?token=…` ——打开它,而不是裸监听地址
local url=""
for _ in {1..40}; do
url=$(grep -aoE "https?://[^[:space:]'\"()]*[?&]token=[^[:space:]'\"()]+" "$log" \
| grep -E "127\.0\.0\.1|localhost" | tail -1)
[ -n "$url" ] && break
sleep 0.25
done
open -a "Google Chrome" "${url:-http://127.0.0.1:$port}"
wait "$server"; kill "$tailer" 2>/dev/null
}
dshkill() { lsof -ti tcp:3080 -sTCP:LISTEN | xargs kill; }
```
注意,在 zsh 里后台**管道**(`cmd | tee log &`)会把 `$!` 设为 `tee` 而不是服务器,从而破坏了存活检测——所以要重定向到日志,再单独 tail 它。
还要当心,`lsof -ti tcp:PORT` **不带** `-sTCP:LISTEN` 时也会匹配连到该端口的客户端——那个广为流传的一行命令,可能会杀掉你的浏览器。
## 许可证
[MIT](LICENSE)