dsh-plugin-maoxuan-pet 0.3.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,25 @@
1
1
  # CHANGELOG
2
2
 
3
+ ## 0.3.1 — 2026-11-01
4
+
5
+ 修复:预设面板上 maoxuan-pet 显示「加载失败 / Failed to load」;桌宠壳补上宿主鉴权支持。
6
+
7
+ ### 修复
8
+ - **preset 挂载必败的缺参**:`cordis.patch.yml` 里 preset 的 `tool-todo` 子项没有给 `allowParallelInProgress`,而宿主的 `@deepseek-ai/dsh-tool-todo` 从 rc.2 起就把它声明为 **required**(且无默认值可合并)。挂载时校验失败,注册表把该行标记为 `broken`,界面徽章显示「加载失败」,悬停可见原文:`tool-todo (@deepseek-ai/dsh-tool-todo): invalid config: $.allowParallelInProgress missing required value`。现在显式声明 `allowParallelInProgress: false`(本预设一次只推进一件事,与 `false` 语义一致)。
9
+ - 用 dsh 0.2.1-rc.5 运行时在真实桌面同构环境里探测过 `agentPresets.list()`:修复前 maoxuan-pet 带 `broken` 字段,修复后消失(dev-assistant 的失败是 dsh-plugin-dev-workflow 自己的 `connection` 注入问题,与本插件无关)。
10
+
11
+ ### 桌宠壳
12
+ - **壳不再被宿主鉴权卡死**:DSH `0.2.1-rc.2` 起 Web API 全部要鉴权(launch token 只在 `GET /` 换签名 cookie,`/api/*` 裸请求一律 401,且不接受 Authorization 头),而 token 只有 CLI 宿主会打印、桌面 App 从不外泄——壳此前在桌面 App 旁边永远是「连接中断」。现在两条路:
13
+ - **`--public`(默认推荐)**:插件在 web server 上直接注册一组 loopback 公开路由 `SHELL_ROUTES`(`/dsh-maoxuan-pet/pet.json`、`asset.png`、`generate`、`dismiss`),GET 只读、POST 强制 JSON + `x-dsh-maoxuan-pet: 1` 自定义头(跨站页面发不出来,非 CSRF 洞)、监听只绑 loopback;壳不需要 token 也不需要 cookie,桌面 App 旁边同样满血。可用 `publicRoutes: false` 关闭。
14
+ - **`--token=<TOKEN>`**:给 CLI 宿主用,`GET /?token=…` 换 `dsh-auth-*` 签名 cookie(URLSession 内存 jar、不落盘、遇 401 自动重换)。
15
+ - **桌面模式互斥显示(新)**:壳在桌面上时,界面里的小毛自动隐身,右下角只留一枚「小毛在桌面上 · 点此收回」小徽章;收回(或右键壳退出)后界面宠物自动回来。避免「两只小毛同时显示」的困惑。
16
+ - **界面开关(新)**:红皮书卡片页脚新增 **「放她到桌面 / 收回桌宠」**——宿主新增第六条路由 `GET|POST /api/dsh-maoxuan-pet.shell`(状态快照 + launch/quit,origin 只接受 `http://127.0.0.1:<port>` 防注入;产物缺失时先自动跑 `desktop/build.sh`,最长 120s)。壳写 pidfile(`$DSH_HOME/storages/dsh-maoxuan-pet.shell.pid`,读取方探活、退出清理)+ 自身单实例保护,手动双击与按钮互相认得,不会叠出两只。已对活宿主端到端验证:GET 状态 → launch 起进程并落 pidfile → 重复 launch 幂等 → quit 退净并清 pidfile。
17
+ - **双击备用**:仓库根目录新增 `启动小毛.command`——自动编译(首次)、自动找最近写入的 `~/.dsh/agent-tree-dock-<port>.json` 得到宿主地址、自动 `--public` 起壳,重复双击不会叠出第二只。
18
+ - 验证:13091(鉴权过、插件未装:404 非 401)、30888/30891(maoxuan 宿主:`--public` 下 state 200 + 四帧立绘原生拉取、POST 缺头 403 / 带头 200);`verify/host.test.mjs` 新增 2 条公开路由用例(假 ctx 的 webServer 记录注册 + 走 node 风格 req/res 断言 200/403),全套 20/20。
19
+
20
+ ### 排查备注
21
+ - 挂载失败只在客户端读取名册(打开预设面板)时可见,宿主启动日志默认不打印该警告,所以此前一直没暴露。
22
+
3
23
  ## 0.3.0 — 2026-11-01
4
24
 
5
25
  新能力:独立 macOS 桌宠壳——小毛从 DSH 界面里走出来,站到真正的桌面上;DSH 最小化甚至关掉界面,她都站在原地。
package/README.md CHANGED
@@ -17,12 +17,16 @@ dsh plugin --profile web add dsh-plugin-maoxuan-pet
17
17
 
18
18
  Web 宠物住在 DSH 界面里,界面最小化她就跟着没了——这是浏览器沙箱的硬边界。`desktop/` 目录自带一个**独立进程的桌宠壳**(纯 Swift + 系统 WebKit,约 450 行、零依赖):透明、置顶、跨所有桌面空间,DSH 最小化甚至关掉界面她都站着;毛选的灵魂(每小时读一篇)仍由插件负责,壳只把「显示器」搬了出来。
19
19
 
20
- ```bash
21
- npm run pet:build # swiftc 编译,几秒
22
- npm run pet # 她就站在屏幕右下角了
23
- ```
20
+ **最省事:不用碰终端。** 打开小毛的红皮书卡片,页脚有一个 **「放她到桌面」** 按钮——点她一下,小毛就以独立窗口站上桌面(首次会自动编译,几秒);再点一次「收回桌宠」她就回界面里。不需要 token,不需要双击任何文件。
21
+
22
+ 两条备用路:
23
+
24
+ - 双击仓库根目录的 `启动小毛.command`(自动连当前 DSH、自动 `--public`,重复双击不会叠出第二只);
25
+ - 命令行:`./desktop/build/maoxuan-pet --public`。
26
+
27
+ 点她开合红皮书卡片、拖她换位置(下次还在那儿、能拖出 DSH 窗口)、右键她 = 复位位置 / 退出。详见 [desktop/README.md](desktop/README.md)。
24
28
 
25
- 点她开合红皮书卡片、拖她换位置(下次还在那儿)、右键她 = 复位位置 / 退出。详见 [desktop/README.md](desktop/README.md)。
29
+ > Windows / Linux:界面里的宠物照常可用,但「拖出窗口」的独立壳目前只有 macOS 实现;页面 `pet.html` 与路由是跨平台的,照 `desktop/README.md` 用 Tauri/Electron 包一层即可。
26
30
 
27
31
  ## 界面
28
32
 
package/client.js CHANGED
@@ -22,6 +22,7 @@ window.__ModuleLoader__.load({
22
22
  state: '/api/dsh-maoxuan-pet.state',
23
23
  generate: '/api/dsh-maoxuan-pet.generate',
24
24
  dismiss: '/api/dsh-maoxuan-pet.dismiss',
25
+ shell: '/api/dsh-maoxuan-pet.shell',
25
26
  };
26
27
  const POLL_MS = 20000;
27
28
  const POLL_BUSY_MS = 3000;
@@ -195,6 +196,8 @@ window.__ModuleLoader__.load({
195
196
  const [data, setData] = useState(null);
196
197
  const [error, setError] = useState(null);
197
198
  const [busy, setBusy] = useState(false);
199
+ const [shell, setShell] = useState(null);
200
+ const [shellBusy, setShellBusy] = useState(false);
198
201
  const [open, setOpen] = useState(false);
199
202
  const [pos, setPos] = useState(readPos);
200
203
  const [spriteBroken, setSpriteBroken] = useState(false);
@@ -228,8 +231,12 @@ window.__ModuleLoader__.load({
228
231
 
229
232
  const refresh = useCallback(async () => {
230
233
  try {
231
- const next = await fetchState();
234
+ const [next, shellState] = await Promise.all([
235
+ fetchState(),
236
+ fetch(`${API.shell}?t=${Date.now()}`).then((r) => (r.ok ? r.json() : null)).catch(() => null),
237
+ ]);
232
238
  setData(next);
239
+ setShell(shellState);
233
240
  setError(null);
234
241
  return next;
235
242
  } catch (failure) {
@@ -342,6 +349,32 @@ window.__ModuleLoader__.load({
342
349
  }
343
350
  }, [refresh]);
344
351
 
352
+ // 独立桌宠壳:macOS 上把小毛请出 DSH 窗口(再点一次收回)。
353
+ const toggleShell = useCallback(async () => {
354
+ if (shellBusy) return;
355
+ setShellBusy(true);
356
+ try {
357
+ const response = await fetch(API.shell, {
358
+ method: 'POST',
359
+ headers: { 'content-type': 'application/json' },
360
+ body: JSON.stringify({
361
+ action: shell?.running ? 'quit' : 'launch',
362
+ origin: window.location.origin,
363
+ }),
364
+ });
365
+ const result = await response.json().catch(() => null);
366
+ // 400/500 的 error 也值得说人话:编译失败、平台不支持都会带中文指引
367
+ if (result && result.error) {
368
+ setShell((prev) => ({ ...(prev || {}), error: result.error }));
369
+ }
370
+ await refresh();
371
+ } catch (failure) {
372
+ setShell((prev) => ({ ...(prev || {}), error: String(failure?.message || failure) }));
373
+ } finally {
374
+ setShellBusy(false);
375
+ }
376
+ }, [shell, shellBusy, refresh]);
377
+
345
378
  const startDrag = useCallback((event) => {
346
379
  if (event.button !== 0) return;
347
380
  suppress.current = false;
@@ -427,6 +460,17 @@ window.__ModuleLoader__.load({
427
460
  : '内置语料',
428
461
  // 离线摘录不再用 chip 抢眼位,改成页脚一行小字,诚实但不喧宾夺主。
429
462
  current.mode === 'offline' ? h('span', { className: 'mp-offline' }, ' · 离线摘录') : null),
463
+ shell?.supported
464
+ ? h('button', {
465
+ className: 'mp-btn', type: 'button',
466
+ disabled: shellBusy || shell?.building,
467
+ title: shell?.running
468
+ ? '关掉独立桌宠窗口(她回到 DSH 界面里)'
469
+ : '让她作为一个独立窗口站到桌面上——可以拖到 DSH 窗口外面,关掉 DSH 也还在',
470
+ onClick: toggleShell,
471
+ 'aria-label': shell?.running ? '收回独立桌宠' : '放她到桌面',
472
+ }, shellBusy || shell?.building ? '开关中…' : shell?.running ? '收回桌宠' : '放她到桌面')
473
+ : null,
430
474
  h('button', { className: 'mp-btn', type: 'button', onClick: dismiss, 'aria-label': '收起这条督促' }, '收起'),
431
475
  h('button', {
432
476
  className: 'mp-btn', type: 'button', 'data-primary': 'on',
@@ -438,6 +482,7 @@ window.__ModuleLoader__.load({
438
482
  className: 'mp-btn', type: 'button', 'data-primary': 'on',
439
483
  disabled: running, onClick: regenerate, 'aria-label': '让小毛现在读一篇',
440
484
  }, running ? '翻书中…' : '现在读一篇')),
485
+ shell?.error ? h('p', { className: 'mp-warn' }, `桌宠壳:${String(shell.error).slice(0, 90)}`) : null,
441
486
  data?.lastError ? h('p', { className: 'mp-warn' }, `上次生成没成功:${String(data.lastError.message || '').slice(0, 90)}`) : null,
442
487
  error ? h('p', { className: 'mp-warn' }, '连接中断,重试中…') : null)));
443
488
 
@@ -480,6 +525,22 @@ window.__ModuleLoader__.load({
480
525
  // dragged close to the left edge does it flip to the right side.
481
526
  const side = pos && pos.x < 380 ? 'right' : 'left';
482
527
 
528
+ // 桌面模式:独立壳已经站在真桌面上了,界面里的小毛就隐身,
529
+ // 只留一枚收回徽章——两只同时显示只会让人困惑「哪只才是她」。
530
+ if (shell?.running) {
531
+ return h('button', {
532
+ className: 'mp-dock-chip', type: 'button', onClick: toggleShell,
533
+ style: {
534
+ position: 'fixed', right: 18, bottom: 18, zIndex: 2147483000,
535
+ border: '1px solid #ffd9a855', borderRadius: 999, cursor: 'pointer',
536
+ padding: '6px 14px', font: '12px/1.6 -apple-system, sans-serif',
537
+ color: '#ffe9c8', background: 'linear-gradient(180deg,#8f1d22,#6d1418)',
538
+ boxShadow: '0 4px 14px rgba(0,0,0,.28)',
539
+ },
540
+ 'aria-label': '收回桌面上的小毛',
541
+ }, shellBusy ? '收回中…' : '小毛在桌面上 · 点此收回');
542
+ }
543
+
483
544
  return h('div', { style, 'data-maoxuan-pet': true },
484
545
  h('style', { dangerouslySetInnerHTML: { __html: CSS } }),
485
546
  h('div', { className: 'mp-anchor', 'data-side': side }, pet, open ? card : null));
package/cordis.patch.yml CHANGED
@@ -63,6 +63,12 @@
63
63
  name: '@deepseek-ai/dsh-tool-skill'
64
64
  - id: tool-todo
65
65
  name: '@deepseek-ai/dsh-tool-todo'
66
+ config:
67
+ # @deepseek-ai/dsh-tool-todo 把 allowParallelInProgress 声明为
68
+ # required(rc.2 起如此),缺省不会合并默认值;不写这里 preset
69
+ # 挂载就会以 “$.allowParallelInProgress missing required value”
70
+ # 失败,预设面板上表现为「加载失败 / Failed to load」。
71
+ allowParallelInProgress: false
66
72
  - id: tool-ask-user
67
73
  name: '@deepseek-ai/dsh-tool-ask-user'
68
74
  - id: tool-web
package/desktop/README.md CHANGED
@@ -47,12 +47,49 @@ npm run pet # 编译并前台运行(Ctrl-C 或右键小毛→退出
47
47
  | --- | --- |
48
48
  | `--origin=URL` | 宿主地址,默认 `http://127.0.0.1:3081` |
49
49
  | `--port=N` | 等价 `--origin=http://127.0.0.1:N` |
50
+ | `--public` | **推荐**:走插件注册的 cookie-less 公开路由(`/dsh-maoxuan-pet/…`),不需要 token |
51
+ | `--token=TOKEN` | 备选:宿主 launch token,先换签名 cookie 再走 `/api` |
50
52
  | `--reset` | 忽略保存的位置,回到屏幕右下 |
51
53
  | `--verbose` | 向 stderr 打轮询/POST/输入区日志 |
52
54
 
53
55
  无 Dock 图标、不进 Cmd-Tab(`NSActivationPolicy.accessory`)。窗口位置存在
54
56
  `UserDefaults`(suite `dsh-maoxuan-pet.window`),拖到哪儿下次就在哪儿。
55
57
 
58
+ ## 鉴权(DSH 0.2.1-rc.2+ 宿主必读)
59
+
60
+ 从 DSH `0.2.1-rc.2` 起宿主 Web API 全部要鉴权:launch token 只在 `GET /` 换签名
61
+ cookie(`dsh-auth-*`),`/api/*` 不带 cookie 一律 401,也不接受 Authorization 头。
62
+ 而 token 只有 CLI 宿主(`dsh web`)会打印,桌面 App 从不外泄——所以壳不能只依赖 `/api`。
63
+
64
+ 本插件因此自己开了一组 **loopback 公开路由**(`SHELL_ROUTES`,默认开启):
65
+
66
+ | 路由 | 方法 | 说明 |
67
+ | --- | --- | --- |
68
+ | `/dsh-maoxuan-pet/pet.json` | GET | 与 `/api/dsh-maoxuan-pet.state` 同一份快照 |
69
+ | `/dsh-maoxuan-pet/asset.png?name=idle` | GET | 立绘(idle/talk/blink/read) |
70
+ | `/dsh-maoxuan-pet/generate` | POST | 同「再督促」(要 `x-dsh-maoxuan-pet: 1` 头) |
71
+ | `/dsh-maoxuan-pet/dismiss` | POST | 同「收起」(要 `x-dsh-maoxuan-pet: 1` 头) |
72
+
73
+ 安全性:监听只绑 loopback,公网到不了;GET 只有毛选快照与立绘;POST 强制 JSON
74
+ content-type + 自定义头,跨站页面发不出这样的请求(需要 CORS 预检),因此不是 CSRF 洞。
75
+ 不需要时可用 `publicRoutes: false` 在插件配置里关掉。
76
+
77
+ 两条路怎么选:
78
+
79
+ - **`--public`(默认推荐)**:不需要 token,桌面 App 旁边也能满血。
80
+ - **`--token=<TOKEN>`**:CLI 宿主会打印 `dsh web: http://127.0.0.1:<port>/?token=<TOKEN>`,
81
+ 复制给壳即可;壳用它换签名 cookie 后走 `/api`(cookie 失效会自动重换)。
82
+
83
+ 日常最省事:双击仓库根目录的 `启动小毛.command`——它自动找最近一次写入的
84
+ `~/.dsh/agent-tree-dock-<port>.json` 得到宿主地址,然后用 `--public` 起壳。
85
+
86
+ ## 界面开关(不用双击)
87
+
88
+ 打开 Web 界面里小毛的红皮书卡片,页脚有 **「放她到桌面」/「收回桌宠」** 按钮:
89
+ 宿主插件通过 `POST /api/dsh-maoxuan-pet.shell` 拉起/结束本进程(见 `docs/API-CONTRACT.md`)。
90
+ 状态读 pidfile,所以无论壳是按钮起的还是双击 `启动小毛.command` 起的,按钮都认得出;
91
+ 壳自己还有单实例保护(已有活着的实例时新实例直接退出),不会叠出两只。
92
+
56
93
  ## 交互
57
94
 
58
95
  - **点小毛**:开合红皮书卡片;新气泡照样自动弹出、600ms 后自动标记已读——与 web 版同语义。
package/desktop/build.sh CHANGED
@@ -14,4 +14,4 @@ swiftc -O \
14
14
  cp desktop/pet.html desktop/build/pet.html
15
15
 
16
16
  echo "built: desktop/build/maoxuan-pet"
17
- echo "run: ./desktop/build/maoxuan-pet [--origin=http://127.0.0.1:3081] [--reset] [--verbose]"
17
+ echo "run: ./desktop/build/maoxuan-pet [--origin=http://127.0.0.1:3081] [--token=…] [--reset] [--verbose]"
@@ -8,7 +8,11 @@
8
8
  // 绕开 WKWebView 的跨域限制,页面只管渲染;
9
9
  // - DSH 主窗口最小化甚至退出都不影响她站着——她是自己的进程。
10
10
  //
11
- // 用法:maoxuan-pet [--origin=http://127.0.0.1:3081 | --port=3081] [--reset] [--verbose]
11
+ // 用法:maoxuan-pet [--origin=http://127.0.0.1:3081 | --port=3081] [--token=… | --public] [--reset] [--verbose]
12
+ // --token= 宿主 launch token(dsh web 启动日志 URL 里的 ?token=…);开了鉴权的宿主(0.2.1-rc.2+)
13
+ // 必须带,否则 API 全 401。先用它 GET /?token= 换签名 cookie,之后所有请求自动携带。
14
+ // --public 走插件自己的 loopback 公开路由(/dsh-maoxuan-pet/…):不需要 token,
15
+ // 桌面 App(不打印 token)旁边也能满血——这是日常推荐用法。
12
16
  // 退出:右键小毛 → 退出小毛。
13
17
 
14
18
  import AppKit
@@ -19,9 +23,18 @@ import WebKit
19
23
  enum Shell {
20
24
  static var origin = "http://127.0.0.1:3081"
21
25
  static var verbose = false
22
- static let statePath = "/api/dsh-maoxuan-pet.state"
23
- static let generatePath = "/api/dsh-maoxuan-pet.generate"
24
- static let dismissPath = "/api/dsh-maoxuan-pet.dismiss"
26
+ /// 宿主 launch token(`dsh web` 启动时打印在 URL 的 ?token=…)。给了 token 就先走一次
27
+ /// `GET /?token=` 换回签名 cookie(dsh-auth-*,绑定 127.0.0.1:端口,30 天),之后
28
+ /// state/sprite/POST 全部自带 cookie——0.2.1-rc.2 起宿主 API 有鉴权,无 cookie 一律 401。
29
+ static var token: String?
30
+ /// 公开路由模式(`--public`):走插件注册的 loopback 公开路由(/dsh-maoxuan-pet/…),
31
+ /// 不要 token、不要 cookie。宿主的 /api 有鉴权且拿不到 launch token 时(典型:桌面 App)
32
+ /// 用这个。POST 仍带 `x-dsh-maoxuan-pet: 1` 头,宿主据此拒绝跨站请求。
33
+ static var publicRoutes = false
34
+ static var statePath = "/api/dsh-maoxuan-pet.state"
35
+ static var generatePath = "/api/dsh-maoxuan-pet.generate"
36
+ static var dismissPath = "/api/dsh-maoxuan-pet.dismiss"
37
+ static var assetPath = "/api/dsh-maoxuan-pet.asset"
25
38
  static let windowWidth: CGFloat = 640
26
39
  static let windowHeight: CGFloat = 720
27
40
  static let defaults = UserDefaults(suiteName: "dsh-maoxuan-pet.window")!
@@ -75,8 +88,16 @@ final class ShellController: NSObject, NSApplicationDelegate, WKNavigationDelega
75
88
  private var webView: WKWebView!
76
89
  private var clickView: ClickThroughView!
77
90
  private var pollItem: DispatchWorkItem?
78
- /// ephemeral:无磁盘缓存/cookie,壳不写任何本地数据(也躲开 ~/Library/Caches 权限噪音)
79
- private lazy var http: URLSession = URLSession(configuration: .ephemeral)
91
+ /// ephemeral:无磁盘缓存,壳不写任何本地数据(也躲开 ~/Library/Caches 权限噪音)。
92
+ /// cookie 用内存 jar:--token 换回的 dsh-auth cookie 存在这里,每个请求自动携带,
93
+ /// 进程退出即消失,不落盘。
94
+ private lazy var httpConfig: URLSessionConfiguration = {
95
+ let configuration = URLSessionConfiguration.ephemeral
96
+ configuration.httpCookieAcceptPolicy = .always
97
+ configuration.httpShouldSetCookies = true
98
+ return configuration
99
+ }()
100
+ private lazy var http: URLSession = URLSession(configuration: httpConfig)
80
101
  private var dragBaseOrigin: NSPoint?
81
102
  private var pageReady = false
82
103
  private var lastInterval: TimeInterval = 20
@@ -84,10 +105,45 @@ final class ShellController: NSObject, NSApplicationDelegate, WKNavigationDelega
84
105
  /// 所以素材也由原生层拉取后转 base64 注入,WebKit 全程零网络请求。
85
106
  private var spriteDataURLs: [String: String] = [:]
86
107
  private var spritePending = Set<String>()
108
+ private var authed = false
109
+ private var authInFlight = false
110
+
111
+ // MARK 壳的单实例与 pidfile
112
+
113
+ /// pidfile:让宿主插件(界面里的开关)认得出「壳是否活着」,包括手动起的。
114
+ /// 崩溃留下的陈旧 pid 由读取方用 kill(pid,0) 探活过滤,不依赖正常退出清理。
115
+ private func pidFilePath() -> String {
116
+ let home = ProcessInfo.processInfo.environment["DSH_HOME"] ?? NSHomeDirectory() + "/.dsh"
117
+ return home + "/storages/dsh-maoxuan-pet.shell.pid"
118
+ }
119
+
120
+ private func readLivePid() -> Int32? {
121
+ guard let raw = try? String(contentsOfFile: pidFilePath(), encoding: .utf8),
122
+ let pid = Int32(raw.trimmingCharacters(in: .whitespacesAndNewlines)), pid > 1 else { return nil }
123
+ return kill(pid, 0) == 0 ? pid : nil
124
+ }
125
+
126
+ private func writePid() {
127
+ let path = pidFilePath()
128
+ let dir = (path as NSString).deletingLastPathComponent
129
+ try? FileManager.default.createDirectory(atPath: dir, withIntermediateDirectories: true)
130
+ try? String(ProcessInfo.processInfo.processIdentifier.description)
131
+ .write(toFile: path, atomically: true, encoding: .utf8)
132
+ }
133
+
134
+ private func clearPid() {
135
+ try? FileManager.default.removeItem(atPath: pidFilePath())
136
+ }
87
137
 
88
138
  // MARK 生命周期
89
139
 
90
140
  func applicationDidFinishLaunching(_ notification: Notification) {
141
+ // 单实例:屏幕上已经有一只就不重复出来(宿主开关和手动双击都可能触发)
142
+ if let existing = readLivePid(), existing != ProcessInfo.processInfo.processIdentifier {
143
+ FileHandle.standardError.write(Data("[maoxuan-pet] 已有一只小毛在跑(pid \(existing)),这次就不重复出现了\n".utf8))
144
+ exit(0)
145
+ }
146
+ writePid()
91
147
  let content = NSRect(x: 0, y: 0, width: Shell.windowWidth, height: Shell.windowHeight)
92
148
  let window = PetWindow(
93
149
  contentRect: restoredFrame() ?? defaultFrame(),
@@ -144,10 +200,15 @@ final class ShellController: NSObject, NSApplicationDelegate, WKNavigationDelega
144
200
  }
145
201
 
146
202
  window.orderFrontRegardless()
147
- FileHandle.standardError.write(Data("[maoxuan-pet] 小毛已站上桌面 — \(Shell.origin)(独立进程,DSH 最小化不影响她)\n".utf8))
203
+ let authNote: String
204
+ if Shell.publicRoutes { authNote = ",公开路由模式(不需要 token)" }
205
+ else if Shell.token != nil { authNote = ",带 launch token(自动换 dsh-auth cookie)" }
206
+ else { authNote = ",无 token(宿主开鉴权时会是离线模式)" }
207
+ FileHandle.standardError.write(Data("[maoxuan-pet] 小毛已站上桌面 — \(Shell.origin)\(authNote)(独立进程,DSH 最小化不影响她)\n".utf8))
148
208
  }
149
209
 
150
210
  func applicationWillTerminate(_ notification: Notification) {
211
+ clearPid()
151
212
  persistFrame()
152
213
  }
153
214
 
@@ -206,6 +267,51 @@ final class ShellController: NSObject, NSApplicationDelegate, WKNavigationDelega
206
267
 
207
268
  // MARK 轮询(原生层负责全部 HTTP,页面不跨域)
208
269
 
270
+ /// 用 launch token 换签名 cookie:`GET /?token=…` → 303 + Set-Cookie(dsh-auth-*,
271
+ /// Max-Age 30 天,绑定 127.0.0.1:端口)。与浏览器打开 GUI 走的是同一条路;cookie 落在
272
+ /// URLSession 内存 jar 里,之后的 state / sprite / POST 每个请求自动携带。
273
+ /// token 来源:`dsh web`(CLI 宿主)启动日志第一行;桌面 App 不打印,拿不到就只能离线模式。
274
+ private func authenticate(_ token: String) {
275
+ if authInFlight { return }
276
+ authInFlight = true
277
+ guard var components = URLComponents(string: Shell.origin + "/") else {
278
+ authInFlight = false
279
+ return
280
+ }
281
+ components.queryItems = [URLQueryItem(name: "token", value: token)]
282
+ guard let url = components.url else {
283
+ authInFlight = false
284
+ return
285
+ }
286
+ var request = URLRequest(url: url, cachePolicy: .reloadIgnoringLocalCacheData, timeoutInterval: 8)
287
+ request.httpMethod = "GET"
288
+ Shell.log("auth: 用 launch token 换 cookie…")
289
+ http.dataTask(with: request) { [weak self] _, response, error in
290
+ DispatchQueue.main.async {
291
+ guard let self else { return }
292
+ self.authInFlight = false
293
+ if let error {
294
+ Shell.log("auth failed: \(error.localizedDescription)")
295
+ self.evaluate("window.__petOffline && window.__petOffline();")
296
+ self.schedulePoll(after: 30)
297
+ return
298
+ }
299
+ let status = (response as? HTTPURLResponse)?.statusCode ?? 0
300
+ let jar = self.httpConfig.httpCookieStorage
301
+ let hasAuthCookie = jar?.cookies?.contains { $0.name.hasPrefix("dsh-auth-") } ?? false
302
+ guard (200..<400).contains(status), hasAuthCookie else {
303
+ Shell.log("auth HTTP \(status),没换到 dsh-auth cookie——token 要来自同一个宿主进程(dsh web 启动日志里的 ?token=)")
304
+ self.evaluate("window.__petOffline && window.__petOffline();")
305
+ self.schedulePoll(after: 30)
306
+ return
307
+ }
308
+ self.authed = true
309
+ Shell.log("auth ok:dsh-auth cookie 已入 jar,开始拉取 state")
310
+ self.pollNow()
311
+ }
312
+ }.resume()
313
+ }
314
+
209
315
  private func schedulePoll(after interval: TimeInterval) {
210
316
  pollItem?.cancel()
211
317
  let item = DispatchWorkItem { [weak self] in self?.pollNow() }
@@ -214,6 +320,10 @@ final class ShellController: NSObject, NSApplicationDelegate, WKNavigationDelega
214
320
  }
215
321
 
216
322
  private func pollNow() {
323
+ if let token = Shell.token, !authed {
324
+ authenticate(token)
325
+ return
326
+ }
217
327
  guard let url = URL(string: Shell.origin + Shell.statePath) else { return }
218
328
  var request = URLRequest(url: url, cachePolicy: .reloadIgnoringLocalCacheData, timeoutInterval: 8)
219
329
  request.httpMethod = "GET"
@@ -229,6 +339,9 @@ final class ShellController: NSObject, NSApplicationDelegate, WKNavigationDelega
229
339
  if let http = response as? HTTPURLResponse, http.statusCode != 200 {
230
340
  Shell.log("state HTTP \(http.statusCode)")
231
341
  self.evaluate("window.__petOffline && window.__petOffline();")
342
+ // 401 = jar 里的 cookie 失效了(宿主重启换了 launch token、或 30 天过期):
343
+ // 置回未鉴权,下一轮先用 --token 重新换一次 cookie 再拉。
344
+ if http.statusCode == 401, Shell.token != nil { self.authed = false }
232
345
  self.schedulePoll(after: 8)
233
346
  return
234
347
  }
@@ -261,7 +374,11 @@ final class ShellController: NSObject, NSApplicationDelegate, WKNavigationDelega
261
374
  }
262
375
  guard !newlyNeeded.isEmpty else { return }
263
376
  for entry in newlyNeeded {
264
- guard let url = URL(string: Shell.origin + entry.path) else {
377
+ // 公开路由模式下 state 里的 sprite 路径仍是 /api/…,换到公开素材路由
378
+ let path = Shell.publicRoutes
379
+ ? entry.path.replacingOccurrences(of: "/api/dsh-maoxuan-pet.asset", with: "/dsh-maoxuan-pet/asset.png")
380
+ : entry.path
381
+ guard let url = URL(string: Shell.origin + path) else {
265
382
  spritePending.remove(entry.name)
266
383
  continue
267
384
  }
@@ -303,6 +420,8 @@ final class ShellController: NSObject, NSApplicationDelegate, WKNavigationDelega
303
420
  var request = URLRequest(url: url, cachePolicy: .reloadIgnoringLocalCacheData, timeoutInterval: 200)
304
421
  request.httpMethod = "POST"
305
422
  request.setValue("application/json", forHTTPHeaderField: "content-type")
423
+ // 公开路由的 POST 要求这个头(跨站页面发不出来,宿主据此挡 CSRF);/api 模式忽略它
424
+ request.setValue("1", forHTTPHeaderField: "x-dsh-maoxuan-pet")
306
425
  request.httpBody = body.data(using: .utf8)
307
426
  Shell.log("POST \(path) \(body)")
308
427
  http.dataTask(with: request) { [weak self] data, response, error in
@@ -405,12 +524,27 @@ while index < arguments.count {
405
524
  } else if argument.hasPrefix("--port=") {
406
525
  let value = String(argument.dropFirst("--port=".count))
407
526
  Shell.origin = "http://127.0.0.1:\(value)"
527
+ } else if argument.hasPrefix("--token=") {
528
+ // 容错:允许把「?token=xxx」整段或「token=xxx」一起粘进来
529
+ var value = String(argument.dropFirst("--token=".count)).trimmingCharacters(in: .whitespaces)
530
+ if value.hasPrefix("token=") { value = String(value.dropFirst("token=".count)) }
531
+ while value.hasSuffix("/") { value.removeLast() }
532
+ Shell.token = value.isEmpty ? nil : value
533
+ } else if argument == "--public" {
534
+ Shell.publicRoutes = true
408
535
  } else if argument == "--verbose" {
409
536
  Shell.verbose = true
410
537
  }
411
538
  index += 1
412
539
  }
413
540
 
541
+ if Shell.publicRoutes {
542
+ Shell.statePath = "/dsh-maoxuan-pet/pet.json"
543
+ Shell.generatePath = "/dsh-maoxuan-pet/generate"
544
+ Shell.dismissPath = "/dsh-maoxuan-pet/dismiss"
545
+ Shell.assetPath = "/dsh-maoxuan-pet/asset.png"
546
+ }
547
+
414
548
  let app = NSApplication.shared
415
549
  app.setActivationPolicy(.accessory) // 不占 Dock、不进 Cmd-Tab
416
550
  let controller = ShellController()
@@ -143,6 +143,20 @@
143
143
 
144
144
  `{ "articles": [ { "id", "title", "year", "tags", "quote", "summary" } ] }`(不含全文,供 Client 展示“这小时读了哪篇”)。
145
145
 
146
+ ### `GET|POST /api/dsh-maoxuan-pet.shell`
147
+
148
+ 独立桌宠壳(macOS)的界面开关。
149
+
150
+ - `GET`:状态快照 `{ "supported": true, "running": false, "pid": null, "building": false, "error": null }`。
151
+ `running` 读壳自己写的 pidfile(`$DSH_HOME/storages/dsh-maoxuan-pet.shell.pid`,读取方用
152
+ `kill(pid,0)` 探活),因此手动用 `启动小毛.command` 起的壳同样被认出。
153
+ - `POST`:请求体 `{ "action": "launch" | "quit", "origin"?: "http://127.0.0.1:<port>" }`。
154
+ `launch` 时若产物缺失会先跑一次 `desktop/build.sh`(swiftc,最长 120s),随后以
155
+ `detached` 方式拉起 `desktop/build/maoxuan-pet --public [--origin=…]`——壳独立于宿主进程,
156
+ 宿主重启不影响她。`origin` 必须匹配 `http://127.0.0.1:<port>` 才会透传(防注入)。
157
+ 响应统一 `{ "ok": boolean, "supported", "running", "pid", "building", "error"? }`;
158
+ `action` 非法时 400。壳已活着时 `launch` 幂等返回 `{ ok: true, running: true }`。
159
+
146
160
  ## 4. 语料契约(`corpus/`)
147
161
 
148
162
  `corpus/index.json`:
package/index.js CHANGED
@@ -16,7 +16,7 @@
16
16
  * node_modules without resolving any DSH-internal module.
17
17
  */
18
18
  import { readFile, writeFile, rename, mkdir, stat } from 'node:fs/promises';
19
- import { readFileSync } from 'node:fs';
19
+ import { existsSync, readFileSync } from 'node:fs';
20
20
  import { spawn } from 'node:child_process';
21
21
  import { dirname, join } from 'node:path';
22
22
  import { fileURLToPath } from 'node:url';
@@ -37,11 +37,30 @@ export const ROUTES = {
37
37
  dismiss: '/api/dsh-maoxuan-pet.dismiss',
38
38
  asset: '/api/dsh-maoxuan-pet.asset',
39
39
  corpus: '/api/dsh-maoxuan-pet.corpus',
40
+ shell: '/api/dsh-maoxuan-pet.shell',
41
+ };
42
+
43
+ /**
44
+ * Public (loopback, cookie-less) routes for the standalone macOS shell.
45
+ *
46
+ * `/api` is auth-gated from DSH 0.2.1-rc.2 on: a bare URLSession gets 401, and
47
+ * the launch token is only obtainable from a CLI-launched host (the desktop app
48
+ * never prints it). The native shell therefore needs its own door. These routes
49
+ * are registered straight on the web server (like static assets) and are
50
+ * deliberately narrow: GETs are read-only public data; POSTs additionally demand
51
+ * a JSON content-type plus `x-dsh-maoxuan-pet: 1` — a cross-site page cannot
52
+ * send that without a CORS preflight, so it is not a CSRF hole. The listener
53
+ * itself only binds loopback, so this is not reachable from the network.
54
+ */
55
+ export const SHELL_ROUTES = {
56
+ state: '/dsh-maoxuan-pet/pet.json',
57
+ asset: '/dsh-maoxuan-pet/asset.png',
58
+ generate: '/dsh-maoxuan-pet/generate',
59
+ dismiss: '/dsh-maoxuan-pet/dismiss',
40
60
  };
41
61
 
42
62
  /** Sprite name -> file under assets/. */
43
- const SPRITES = {
44
- idle: 'pet-idle.png',
63
+ const SPRITES = { idle: 'pet-idle.png',
45
64
  talk: 'pet-talk.png',
46
65
  blink: 'pet-blink.png',
47
66
  read: 'pet-read.png',
@@ -89,6 +108,10 @@ export function normaliseConfig(config) {
89
108
  timeoutMs: int(raw.timeoutMs, 180000, 10000, 900000),
90
109
  businessContext: typeof raw.businessContext === 'string' ? raw.businessContext.trim() : '',
91
110
  modelHint: typeof raw.modelHint === 'string' ? raw.modelHint.trim() : '',
111
+ // Loopback-public routes for the standalone desktop shell (see SHELL_ROUTES).
112
+ // On by default — without them the shell cannot show content next to the
113
+ // desktop app, which is auth-gated and never reveals its launch token.
114
+ publicRoutes: raw.publicRoutes !== false,
92
115
  };
93
116
  }
94
117
 
@@ -1135,82 +1158,160 @@ export function apply(ctx, config) {
1135
1158
  void ready.then(install).catch((error) => log('warn', `skill 注册失败:${error?.message ?? error}`));
1136
1159
  });
1137
1160
 
1161
+ // --- desktop shell (macOS) supervisor -------------------------------------
1162
+ // 独立桌宠壳是个单独的 Swift 进程;界面里的开关只负责 spawn / SIGTERM。
1163
+ // 运行状态读壳自己写的 pidfile,所以用 启动小毛.command 手动起的壳也认得出,
1164
+ // 不会出现「按钮说她没出来、其实她就在屏幕上」。
1165
+ const shellBin = join(here, 'desktop', 'build', 'maoxuan-pet');
1166
+ const shellScript = join(here, 'desktop', 'build.sh');
1167
+ const shellPidPath = join(resolveDshHome(), 'storages', 'dsh-maoxuan-pet.shell.pid');
1168
+ let shellChild = null;
1169
+ let shellBusy = false;
1170
+ let shellError = null;
1171
+ const shellPlatform = process.platform === 'darwin';
1172
+
1173
+ const shellPid = () => {
1174
+ try {
1175
+ const pid = Number(readFileSync(shellPidPath, 'utf8').trim());
1176
+ if (!Number.isInteger(pid) || pid <= 1) return null;
1177
+ process.kill(pid, 0);
1178
+ return pid;
1179
+ } catch { return null; }
1180
+ };
1181
+
1182
+ const shellStatus = () => {
1183
+ const pid = shellPid();
1184
+ return {
1185
+ supported: shellPlatform,
1186
+ running: Boolean(pid),
1187
+ pid: pid ?? null,
1188
+ building: shellBusy,
1189
+ error: shellError,
1190
+ };
1191
+ };
1192
+
1193
+ /** 首次启动:产物不存在就先编(swiftc 几秒钟),编完再起。 */
1194
+ const buildShell = () => new Promise((resolve, reject) => {
1195
+ const child = spawn('bash', [shellScript], { cwd: here, stdio: 'ignore' });
1196
+ const timer = setTimeout(() => {
1197
+ try { child.kill('SIGKILL'); } catch { /* already gone */ }
1198
+ reject(new Error('编译超时(120s)'));
1199
+ }, 120000);
1200
+ child.on('error', (error) => { clearTimeout(timer); reject(error); });
1201
+ child.on('exit', (code) => {
1202
+ clearTimeout(timer);
1203
+ if (code === 0 && existsSync(shellBin)) resolve();
1204
+ else reject(new Error(`编译失败(exit ${code})——终端里跑一次 npm run pet:build 看报错`));
1205
+ });
1206
+ });
1207
+
1208
+ const launchShell = async (origin) => {
1209
+ if (!shellPlatform) return { ok: false, supported: false, error: '独立桌宠壳目前只支持 macOS' };
1210
+ const pid = shellPid();
1211
+ if (pid) return { ok: true, ...shellStatus() };
1212
+ if (shellBusy) return { ok: false, ...shellStatus(), error: '正在编译桌宠壳,等几秒' };
1213
+ shellError = null;
1214
+ try {
1215
+ if (!existsSync(shellBin)) {
1216
+ shellBusy = true;
1217
+ await buildShell();
1218
+ }
1219
+ const args = ['--public'];
1220
+ // 只接受 loopback origin:这个值来自浏览器 location.origin,别让它带去别处
1221
+ if (typeof origin === 'string' && /^http:\/\/127\.0\.0\.1:\d{2,5}$/.test(origin)) {
1222
+ args.push(`--origin=${origin}`);
1223
+ }
1224
+ const child = spawn(shellBin, args, { detached: true, stdio: 'ignore' });
1225
+ child.unref();
1226
+ shellChild = child;
1227
+ return { ok: true, ...shellStatus() };
1228
+ } catch (error) {
1229
+ shellError = String(error?.message || error);
1230
+ return { ok: false, ...shellStatus(), error: shellError };
1231
+ } finally {
1232
+ shellBusy = false;
1233
+ }
1234
+ };
1235
+
1236
+ const quitShell = () => {
1237
+ const pid = shellPid();
1238
+ if (pid) {
1239
+ try { process.kill(pid, 'SIGTERM'); } catch { /* already gone */ }
1240
+ }
1241
+ if (shellChild) {
1242
+ try { shellChild.kill(); } catch { /* already gone */ }
1243
+ shellChild = null;
1244
+ }
1245
+ shellError = null;
1246
+ return { ok: true, ...shellStatus() };
1247
+ };
1248
+
1138
1249
  // --- transport -----------------------------------------------------------
1250
+ // The same five handlers serve both doors: the authenticated `/api` routes the
1251
+ // Web client uses, and the loopback-public routes the native shell uses.
1252
+ const handlers = {
1253
+ state: async () => { await ready; return json(snapshot()); },
1254
+ corpus: async () => {
1255
+ await ready;
1256
+ return json({
1257
+ articles: corpus.articles.map((a) => ({
1258
+ id: a.id, title: a.title, year: a.year, tags: a.tags, quote: a.quote, summary: a.summary,
1259
+ })),
1260
+ });
1261
+ },
1262
+ generate: async () => {
1263
+ await ready;
1264
+ if (running) return json({ ok: true, running: true, bubble: null }, 202);
1265
+ const result = await runOnce('manual');
1266
+ if (!result.ok) return json(result, 500);
1267
+ return json(result, 200);
1268
+ },
1269
+ dismiss: async (request) => {
1270
+ await ready;
1271
+ const body = await readBody(request);
1272
+ const target = typeof body.id === 'string' && body.id
1273
+ ? state.bubbles.find((b) => b.id === body.id)
1274
+ : state.bubbles.find((b) => !b.read);
1275
+ if (target && !target.read) {
1276
+ target.read = true;
1277
+ await persist();
1278
+ }
1279
+ return json({ ok: true, unread: state.bubbles.filter((b) => !b.read).length });
1280
+ },
1281
+ shell: async (request) => {
1282
+ if (request.method !== 'POST') return json(shellStatus());
1283
+ const body = await readBody(request);
1284
+ const action = String(body.action || '');
1285
+ if (action === 'launch') return json(await launchShell(body.origin));
1286
+ if (action === 'quit') return json(quitShell());
1287
+ return json({ ok: false, error: 'action 必须是 launch 或 quit' }, 400);
1288
+ },
1289
+ asset: async (request) => {
1290
+ let sprite = 'idle';
1291
+ try { sprite = new URL(request.url).searchParams.get('name') || 'idle'; } catch { sprite = 'idle'; }
1292
+ const file = SPRITES[sprite] || SPRITES.idle;
1293
+ try {
1294
+ const [bytes, info] = await Promise.all([readFile(join(assetsDir, file)), stat(join(assetsDir, file))]);
1295
+ if (!info.size) throw new MaoxuanPetError('空文件');
1296
+ return new Response(bytes, {
1297
+ headers: { 'content-type': 'image/png', 'cache-control': 'public, max-age=3600' },
1298
+ });
1299
+ } catch {
1300
+ return text(`缺少素材:assets/${file}`, 404);
1301
+ }
1302
+ },
1303
+ };
1304
+
1139
1305
  ctx.inject?.(['connection'], (scope) => {
1140
1306
  const routes = [
1141
- {
1142
- path: ROUTES.state,
1143
- methods: ['GET'],
1144
- // Required: without an explicit body mode the bridge builds a GET request
1145
- // with a stream body and Node throws, which the Host surfaces as 400.
1146
- requestBody: 'buffered',
1147
- fetch: async () => { await ready; return json(snapshot()); },
1148
- },
1149
- {
1150
- path: ROUTES.corpus,
1151
- methods: ['GET'],
1152
- // Required: without an explicit body mode the bridge builds a GET request
1153
- // with a stream body and Node throws, which the Host surfaces as 400.
1154
- requestBody: 'buffered',
1155
- fetch: async () => {
1156
- await ready;
1157
- return json({
1158
- articles: corpus.articles.map((a) => ({
1159
- id: a.id, title: a.title, year: a.year, tags: a.tags, quote: a.quote, summary: a.summary,
1160
- })),
1161
- });
1162
- },
1163
- },
1164
- {
1165
- path: ROUTES.generate,
1166
- methods: ['POST'],
1167
- requestBody: 'buffered',
1168
- fetch: async () => {
1169
- await ready;
1170
- if (running) return json({ ok: true, running: true, bubble: null }, 202);
1171
- const result = await runOnce('manual');
1172
- if (!result.ok) return json(result, 500);
1173
- return json(result, 200);
1174
- },
1175
- },
1176
- {
1177
- path: ROUTES.dismiss,
1178
- methods: ['POST'],
1179
- requestBody: 'buffered',
1180
- fetch: async (request) => {
1181
- await ready;
1182
- const body = await readBody(request);
1183
- const target = typeof body.id === 'string' && body.id
1184
- ? state.bubbles.find((b) => b.id === body.id)
1185
- : state.bubbles.find((b) => !b.read);
1186
- if (target && !target.read) {
1187
- target.read = true;
1188
- await persist();
1189
- }
1190
- return json({ ok: true, unread: state.bubbles.filter((b) => !b.read).length });
1191
- },
1192
- },
1193
- {
1194
- path: ROUTES.asset,
1195
- methods: ['GET'],
1196
- // Required: without an explicit body mode the bridge builds a GET request
1197
- // with a stream body and Node throws, which the Host surfaces as 400.
1198
- requestBody: 'buffered',
1199
- fetch: async (request) => {
1200
- let sprite = 'idle';
1201
- try { sprite = new URL(request.url).searchParams.get('name') || 'idle'; } catch { sprite = 'idle'; }
1202
- const file = SPRITES[sprite] || SPRITES.idle;
1203
- try {
1204
- const [bytes, info] = await Promise.all([readFile(join(assetsDir, file)), stat(join(assetsDir, file))]);
1205
- if (!info.size) throw new MaoxuanPetError('空文件');
1206
- return new Response(bytes, {
1207
- headers: { 'content-type': 'image/png', 'cache-control': 'public, max-age=3600' },
1208
- });
1209
- } catch {
1210
- return text(`缺少素材:assets/${file}`, 404);
1211
- }
1212
- },
1213
- },
1307
+ // Required: without an explicit body mode the bridge builds a GET request
1308
+ // with a stream body and Node throws, which the Host surfaces as 400.
1309
+ { path: ROUTES.state, methods: ['GET'], requestBody: 'buffered', fetch: () => handlers.state() },
1310
+ { path: ROUTES.corpus, methods: ['GET'], requestBody: 'buffered', fetch: () => handlers.corpus() },
1311
+ { path: ROUTES.generate, methods: ['POST'], requestBody: 'buffered', fetch: () => handlers.generate() },
1312
+ { path: ROUTES.dismiss, methods: ['POST'], requestBody: 'buffered', fetch: (request) => handlers.dismiss(request) },
1313
+ { path: ROUTES.asset, methods: ['GET'], requestBody: 'buffered', fetch: (request) => handlers.asset(request) },
1314
+ { path: ROUTES.shell, methods: ['GET', 'POST'], requestBody: 'buffered', fetch: (request) => handlers.shell(request) },
1214
1315
  ];
1215
1316
  const disposers = routes.map((route) => scope.connection.fetch.register(route));
1216
1317
  scope.effect(() => () => {
@@ -1220,6 +1321,68 @@ export function apply(ctx, config) {
1220
1321
  }, 'maoxuan-pet.routes');
1221
1322
  });
1222
1323
 
1324
+ // --- cookie-less transport for the standalone macOS shell -----------------
1325
+ if (cfg.publicRoutes) {
1326
+ ctx.inject?.(['webServer'], (webCtx) => {
1327
+ // 只认真正的 webServer 服务:没有它(测试里的假 ctx、或非 web 宿主)就安静跳过,
1328
+ // 可选增强绝不该把插件主体挂掉。
1329
+ const server = webCtx?.webServer;
1330
+ if (typeof server?.register !== 'function') {
1331
+ log('debug', '公开路由不可用(宿主无 webServer),桌宠壳请改用 --token 走 /api');
1332
+ return;
1333
+ }
1334
+ const register = (path, methods, handler) => {
1335
+ const dispose = server.register({
1336
+ kind: 'exact',
1337
+ path,
1338
+ handler: async (req, res) => {
1339
+ if (!methods.includes(req.method)) {
1340
+ res.writeHead(405, { 'content-type': 'text/plain; charset=utf-8' });
1341
+ res.end('method not allowed');
1342
+ return;
1343
+ }
1344
+ if (req.method === 'POST') {
1345
+ const type = String(req.headers['content-type'] || '');
1346
+ if (!type.includes('application/json') || req.headers['x-dsh-maoxuan-pet'] !== '1') {
1347
+ res.writeHead(403, { 'content-type': 'text/plain; charset=utf-8' });
1348
+ res.end('forbidden');
1349
+ return;
1350
+ }
1351
+ }
1352
+ try {
1353
+ const chunks = [];
1354
+ for await (const chunk of req) chunks.push(chunk);
1355
+ const body = chunks.length ? Buffer.concat(chunks).toString('utf8') : '';
1356
+ const request = new Request(`http://127.0.0.1${req.url || path}`, {
1357
+ method: req.method,
1358
+ headers: req.headers,
1359
+ body: body || undefined,
1360
+ });
1361
+ const response = await handler(request);
1362
+ res.writeHead(response.status, Object.fromEntries(response.headers));
1363
+ res.end(Buffer.from(await response.arrayBuffer()));
1364
+ } catch (error) {
1365
+ res.writeHead(500, { 'content-type': 'text/plain; charset=utf-8' });
1366
+ res.end(String(error?.message || error));
1367
+ }
1368
+ },
1369
+ });
1370
+ return dispose;
1371
+ };
1372
+ const disposers = [
1373
+ register(SHELL_ROUTES.state, ['GET'], () => handlers.state()),
1374
+ register(SHELL_ROUTES.asset, ['GET'], (request) => handlers.asset(request)),
1375
+ register(SHELL_ROUTES.generate, ['POST'], () => handlers.generate()),
1376
+ register(SHELL_ROUTES.dismiss, ['POST'], (request) => handlers.dismiss(request)),
1377
+ ];
1378
+ webCtx.effect(() => () => {
1379
+ for (const dispose of disposers) {
1380
+ try { dispose?.(); } catch { /* already gone */ }
1381
+ }
1382
+ }, 'maoxuan-pet.shell-routes');
1383
+ });
1384
+ }
1385
+
1223
1386
  // Start the clock only after the persisted snapshot is loaded.
1224
1387
  void ready.then(() => {
1225
1388
  if (!cfg.enabled) return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-plugin-maoxuan-pet",
3
- "version": "0.3.0",
3
+ "version": "0.3.1",
4
4
  "description": "DSH 悬浮宠物「鲸鱼娘·小毛」:每小时读一篇毛选,给创业中的你说一句能动手的督促;内置毛选公开语料与 maoxuan-pet preset。",
5
5
  "keywords": [
6
6
  "dsh",