dsh-plugin-maoxuan-pet 0.3.0 → 0.3.2
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 +29 -0
- package/README.md +9 -5
- package/client.js +71 -1
- package/cordis.patch.yml +6 -0
- package/desktop/README.md +37 -0
- package/desktop/build.sh +1 -1
- package/desktop/maoxuan-pet.swift +331 -17
- package/desktop/pet.html +22 -9
- package/docs/API-CONTRACT.md +14 -0
- package/index.js +246 -77
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,34 @@
|
|
|
1
1
|
# CHANGELOG
|
|
2
2
|
|
|
3
|
+
## 0.3.2(2026-11-01)
|
|
4
|
+
|
|
5
|
+
### 桌宠壳
|
|
6
|
+
|
|
7
|
+
- **新增:点击真穿透(窗口随内容收缩)**——原 hitTest 白名单只能在 AppKit 层丢事件,WindowServer 已把点击判给壳窗口(640×720 的透明玻璃),后面 App 点不到。曾试过 CGS 窗口形状(`CGSSetWindowShape`):坐标约定对不上且**连渲染一起裁**(小毛活着但隐形),弃用。最终方案:壳窗口随页面报告的可点区域收缩(右下角锚定,stage 定位固定 ⇒ 小毛纹丝不动),卡片高度与视口解耦(固定 480px)防止窗口伸缩反馈回路;窗口外点击天然直达下层。
|
|
8
|
+
- **修复:恢复的窗口位置会把小毛放回屏幕外**——保存的位置 {{1218,-19}} 挂在屏幕右缘外(屏幕 1512pt 宽,窗口右缘 1858pt),她本人活着但站在不可见区域,看起来就是「放出去没了」。现在恢复前校验小毛站立处(窗口右下 220×300)是否整块在屏内,不在就回退默认位置并留日志。
|
|
9
|
+
- **新增:跟随宿主换端口**——桌面版重启会换 web 端口,壳还指着旧 origin 就是反复出现的「连接中断」。连续 3 次拉取失败后扫 `$DSH_HOME/agent-tree-dock-*.json`(文件名带端口,取最新),用公开路由探活成功即自动跟随;约 30 秒内自愈。`--token` 模式不自动跟随(token 按次发放),日志提示重新放她到桌面。
|
|
10
|
+
- **修复:立绘「下载成功但画面不换装」(蓝色大脑袋)**——立绘/状态注入是单次 `evaluate`,若那一刻 WKWebView 还没加载完 `pet.html`,注入落在即将被丢弃的上下文里,数据永久丢失;而立绘只在下载完成时注入一次,错过就永远停在壳内置占位形象。0.3.1 的 `--public` 模式无鉴权流程、轮询极快,把窗口期放大成必现。现在:注入前先落页面全局 `__petSpriteData` / `__petStateData`(页面脚本就绪时自取),并且壳以页面 rects 上报为「活着」心跳,发现立绘/首份 state 尚未送达就补发——时序无论怎么排都能收敛。新增恒落盘诊断日志 `$DSH_HOME/storages/dsh-maoxuan-pet.shell.log`(256KB 自动截断;宿主按钮拉起时 stdio 被丢弃,这份日志是唯一黑盒透视)。
|
|
11
|
+
|
|
12
|
+
## 0.3.1 — 2026-11-01
|
|
13
|
+
|
|
14
|
+
修复:预设面板上 maoxuan-pet 显示「加载失败 / Failed to load」;桌宠壳补上宿主鉴权支持。
|
|
15
|
+
|
|
16
|
+
### 修复
|
|
17
|
+
- **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` 语义一致)。
|
|
18
|
+
- 用 dsh 0.2.1-rc.5 运行时在真实桌面同构环境里探测过 `agentPresets.list()`:修复前 maoxuan-pet 带 `broken` 字段,修复后消失(dev-assistant 的失败是 dsh-plugin-dev-workflow 自己的 `connection` 注入问题,与本插件无关)。
|
|
19
|
+
|
|
20
|
+
### 桌宠壳
|
|
21
|
+
- **壳不再被宿主鉴权卡死**:DSH `0.2.1-rc.2` 起 Web API 全部要鉴权(launch token 只在 `GET /` 换签名 cookie,`/api/*` 裸请求一律 401,且不接受 Authorization 头),而 token 只有 CLI 宿主会打印、桌面 App 从不外泄——壳此前在桌面 App 旁边永远是「连接中断」。现在两条路:
|
|
22
|
+
- **`--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` 关闭。
|
|
23
|
+
- **`--token=<TOKEN>`**:给 CLI 宿主用,`GET /?token=…` 换 `dsh-auth-*` 签名 cookie(URLSession 内存 jar、不落盘、遇 401 自动重换)。
|
|
24
|
+
- **桌面模式互斥显示(新)**:壳在桌面上时,界面里的小毛自动隐身,右下角只留一枚「小毛在桌面上 · 点此收回」小徽章;收回(或右键壳退出)后界面宠物自动回来。避免「两只小毛同时显示」的困惑。
|
|
25
|
+
- **界面开关(新)**:红皮书卡片页脚新增 **「放她到桌面 / 收回桌宠」**——宿主新增第六条路由 `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。
|
|
26
|
+
- **双击备用**:仓库根目录新增 `启动小毛.command`——自动编译(首次)、自动找最近写入的 `~/.dsh/agent-tree-dock-<port>.json` 得到宿主地址、自动 `--public` 起壳,重复双击不会叠出第二只。
|
|
27
|
+
- 验证: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。
|
|
28
|
+
|
|
29
|
+
### 排查备注
|
|
30
|
+
- 挂载失败只在客户端读取名册(打开预设面板)时可见,宿主启动日志默认不打印该警告,所以此前一直没暴露。
|
|
31
|
+
|
|
3
32
|
## 0.3.0 — 2026-11-01
|
|
4
33
|
|
|
5
34
|
新能力:独立 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
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
-
|
|
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
|
|
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,31 @@ 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
|
+
title: '收回桌面上的小毛',
|
|
534
|
+
style: {
|
|
535
|
+
position: 'fixed', right: 16, bottom: 16, zIndex: 2147483000,
|
|
536
|
+
display: 'flex', alignItems: 'center', gap: 5,
|
|
537
|
+
border: '1px solid rgba(255,217,168,.35)', borderRadius: 999, cursor: 'pointer',
|
|
538
|
+
padding: '3px 8px 3px 4px', font: '11px/1.4 -apple-system, sans-serif',
|
|
539
|
+
color: '#ffe9c8', background: 'rgba(109,20,24,.92)',
|
|
540
|
+
boxShadow: '0 2px 8px rgba(0,0,0,.24)',
|
|
541
|
+
},
|
|
542
|
+
'aria-label': '收回桌面上的小毛',
|
|
543
|
+
},
|
|
544
|
+
h('svg', { width: 14, height: 14, viewBox: '0 0 512 512', 'aria-hidden': true },
|
|
545
|
+
h('rect', { x: 0, y: 0, width: 512, height: 512, rx: 116, fill: '#FFF6E9' }),
|
|
546
|
+
h('rect', { x: 120, y: 104, width: 232, height: 304, rx: 26, fill: '#B3271E' }),
|
|
547
|
+
h('rect', { x: 120, y: 104, width: 46, height: 304, rx: 22, fill: '#8F1E17' }),
|
|
548
|
+
h('polygon', { fill: '#E8C36A', points: '263,150 273.32,181.76 306.72,181.76 279.7,201.74 289.02,234.5 263,214.25 236.98,234.5 246.3,201.74 219.28,181.76 252.68,181.76' }),
|
|
549
|
+
h('rect', { x: 215, y: 272, width: 96, height: 16, rx: 8, fill: '#E8C36A' })),
|
|
550
|
+
shellBusy ? '收回中…' : '收回');
|
|
551
|
+
}
|
|
552
|
+
|
|
483
553
|
return h('div', { style, 'data-maoxuan-pet': true },
|
|
484
554
|
h('style', { dangerouslySetInnerHTML: { __html: CSS } }),
|
|
485
555
|
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,29 +8,78 @@
|
|
|
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
|
|
15
19
|
import WebKit
|
|
16
20
|
|
|
21
|
+
|
|
17
22
|
// MARK: - 配置
|
|
18
23
|
|
|
19
24
|
enum Shell {
|
|
20
25
|
static var origin = "http://127.0.0.1:3081"
|
|
21
26
|
static var verbose = false
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
27
|
+
/// 宿主 launch token(`dsh web` 启动时打印在 URL 的 ?token=…)。给了 token 就先走一次
|
|
28
|
+
/// `GET /?token=` 换回签名 cookie(dsh-auth-*,绑定 127.0.0.1:端口,30 天),之后
|
|
29
|
+
/// state/sprite/POST 全部自带 cookie——0.2.1-rc.2 起宿主 API 有鉴权,无 cookie 一律 401。
|
|
30
|
+
static var token: String?
|
|
31
|
+
/// 公开路由模式(`--public`):走插件注册的 loopback 公开路由(/dsh-maoxuan-pet/…),
|
|
32
|
+
/// 不要 token、不要 cookie。宿主的 /api 有鉴权且拿不到 launch token 时(典型:桌面 App)
|
|
33
|
+
/// 用这个。POST 仍带 `x-dsh-maoxuan-pet: 1` 头,宿主据此拒绝跨站请求。
|
|
34
|
+
static var publicRoutes = false
|
|
35
|
+
static var statePath = "/api/dsh-maoxuan-pet.state"
|
|
36
|
+
static var generatePath = "/api/dsh-maoxuan-pet.generate"
|
|
37
|
+
static var dismissPath = "/api/dsh-maoxuan-pet.dismiss"
|
|
38
|
+
static var assetPath = "/api/dsh-maoxuan-pet.asset"
|
|
25
39
|
static let windowWidth: CGFloat = 640
|
|
26
40
|
static let windowHeight: CGFloat = 720
|
|
27
41
|
static let defaults = UserDefaults(suiteName: "dsh-maoxuan-pet.window")!
|
|
28
42
|
|
|
29
43
|
static func log(_ message: String) {
|
|
44
|
+
let line = "[maoxuan-pet] \(message)\n"
|
|
45
|
+
if verbose {
|
|
46
|
+
FileHandle.standardError.write(Data(line.utf8))
|
|
47
|
+
}
|
|
48
|
+
// 恒落文件:按钮拉起的壳 stdio 被宿主丢弃(stdio: 'ignore'),
|
|
49
|
+
// 没有这份日志,出问题就是黑盒。256KB 自动截断。
|
|
50
|
+
Self.appendToFile(line)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/// 高频日志(每秒多次的 input rects 等)只进 stderr,不刷文件。
|
|
54
|
+
static func debug(_ message: String) {
|
|
30
55
|
guard verbose else { return }
|
|
31
56
|
FileHandle.standardError.write(Data("[maoxuan-pet] \(message)\n".utf8))
|
|
32
57
|
}
|
|
33
58
|
|
|
59
|
+
/// 关键诊断恒落一份到文件:按钮拉起的壳 stdio 被宿主丢弃(stdio: 'ignore'),
|
|
60
|
+
/// 没有这份日志,出问题就是黑盒。超过 256KB 从头截断,避免无限增长。
|
|
61
|
+
private static func appendToFile(_ line: String) {
|
|
62
|
+
let home = ProcessInfo.processInfo.environment["DSH_HOME"] ?? NSHomeDirectory() + "/.dsh"
|
|
63
|
+
let path = home + "/storages/dsh-maoxuan-pet.shell.log"
|
|
64
|
+
let dir = (path as NSString).deletingLastPathComponent
|
|
65
|
+
try? FileManager.default.createDirectory(atPath: dir, withIntermediateDirectories: true)
|
|
66
|
+
let formatter = DateFormatter()
|
|
67
|
+
formatter.dateFormat = "HH:mm:ss"
|
|
68
|
+
let stamped = "[" + formatter.string(from: Date()) + "] " + line
|
|
69
|
+
guard let data = stamped.data(using: .utf8) else { return }
|
|
70
|
+
if let handle = FileHandle(forWritingAtPath: path) {
|
|
71
|
+
if let attrs = try? FileManager.default.attributesOfItem(atPath: path),
|
|
72
|
+
let size = attrs[.size] as? NSNumber, size.intValue > 262_144 {
|
|
73
|
+
try? handle.truncate(atOffset: 0)
|
|
74
|
+
}
|
|
75
|
+
try? handle.seekToEndOfFile()
|
|
76
|
+
try? handle.write(contentsOf: data)
|
|
77
|
+
try? handle.close()
|
|
78
|
+
} else {
|
|
79
|
+
FileManager.default.createFile(atPath: path, contents: data)
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
34
83
|
static func jsonString(_ value: Any) -> String {
|
|
35
84
|
guard JSONSerialization.isValidJSONObject([value]),
|
|
36
85
|
let data = try? JSONSerialization.data(withJSONObject: [value]),
|
|
@@ -75,19 +124,137 @@ final class ShellController: NSObject, NSApplicationDelegate, WKNavigationDelega
|
|
|
75
124
|
private var webView: WKWebView!
|
|
76
125
|
private var clickView: ClickThroughView!
|
|
77
126
|
private var pollItem: DispatchWorkItem?
|
|
78
|
-
/// ephemeral
|
|
79
|
-
|
|
127
|
+
/// ephemeral:无磁盘缓存,壳不写任何本地数据(也躲开 ~/Library/Caches 权限噪音)。
|
|
128
|
+
/// cookie 用内存 jar:--token 换回的 dsh-auth cookie 存在这里,每个请求自动携带,
|
|
129
|
+
/// 进程退出即消失,不落盘。
|
|
130
|
+
private lazy var httpConfig: URLSessionConfiguration = {
|
|
131
|
+
let configuration = URLSessionConfiguration.ephemeral
|
|
132
|
+
configuration.httpCookieAcceptPolicy = .always
|
|
133
|
+
configuration.httpShouldSetCookies = true
|
|
134
|
+
return configuration
|
|
135
|
+
}()
|
|
136
|
+
private lazy var http: URLSession = URLSession(configuration: httpConfig)
|
|
80
137
|
private var dragBaseOrigin: NSPoint?
|
|
81
138
|
private var pageReady = false
|
|
82
139
|
private var lastInterval: TimeInterval = 20
|
|
83
140
|
/// 精灵图 name → data URL:WebKit 的 ATS 会拦 file:// 页面直挂的 http 图,
|
|
84
141
|
/// 所以素材也由原生层拉取后转 base64 注入,WebKit 全程零网络请求。
|
|
85
142
|
private var spriteDataURLs: [String: String] = [:]
|
|
143
|
+
/// 页面就绪回放:WKWebView 加载完成前 evaluate 会落在将被丢弃的上下文里
|
|
144
|
+
/// (立绘/状态一次性注入从此丢失,页面永远停在占位形象)。页面第一次上报
|
|
145
|
+
/// rects 即「活着」,此时把手里已有的 state 与立绘重放一次,时序就无关紧要了。
|
|
146
|
+
private var replayedSpriteCount = 0
|
|
147
|
+
private var consecutiveFailures = 0
|
|
148
|
+
private var everSucceeded = false
|
|
149
|
+
private var rediscoveryCooldown = Date.distantPast
|
|
150
|
+
private var rediscoverHintShown = false
|
|
151
|
+
private var lastRegionDesc = ""
|
|
152
|
+
/// 穿透问题的釜底抽薪:窗口收缩到只有小毛(+展开的卡片)那么大,
|
|
153
|
+
/// 挡住后面 App 的区域天然就只有那一块——不用任何私有 API,渲染永远完整。
|
|
154
|
+
/// 右下角锚定缩放:stage 定位 right:44 bottom:56 固定,小毛本人纹丝不动。
|
|
155
|
+
private func syncWindowFrame(to union: CGRect) {
|
|
156
|
+
guard let window, union.width > 0 else { return }
|
|
157
|
+
// 只用 union 的「尺寸」、不用它的「位置」:位置随窗口高度 relayout 漂移,
|
|
158
|
+
// 尺寸稳定——这样窗口缩放没有反馈回路。右下角锚定 + stage right:44
|
|
159
|
+
// bottom:56 固定 ⇒ 小毛的屏幕位置纹丝不动。
|
|
160
|
+
let pad: CGFloat = 14
|
|
161
|
+
let width = max(union.width + 44 + pad, 160)
|
|
162
|
+
let height = max(union.height + 56 + pad, 220)
|
|
163
|
+
let frame = window.frame
|
|
164
|
+
guard abs(frame.width - width) > 1 || abs(frame.height - height) > 1 else { return }
|
|
165
|
+
window.setFrame(NSRect(x: frame.maxX - width, y: frame.minY, width: width, height: height), display: true)
|
|
166
|
+
Shell.log("window → \(Int(width))x\(Int(height)) @右下角锚定")
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
/// 宿主重启会换端口,壳却还指着旧 origin——这正是「连接中断」反复出现的原因。
|
|
171
|
+
/// 连续失败后扫 ~/.dsh/agent-tree-dock-*.json(文件名带端口,取最新修改的那个),
|
|
172
|
+
/// 用公开路由探活成功就跟随过去。--token 模式不自动跟随(token 是按次发放的)。
|
|
173
|
+
private func tryRediscover() {
|
|
174
|
+
guard Shell.token == nil else {
|
|
175
|
+
if !rediscoverHintShown {
|
|
176
|
+
rediscoverHintShown = true
|
|
177
|
+
Shell.log("宿主可能换了端口;token 模式不自动跟随——请点「收回」后重新放她到桌面")
|
|
178
|
+
}
|
|
179
|
+
return
|
|
180
|
+
}
|
|
181
|
+
let home = ProcessInfo.processInfo.environment["DSH_HOME"] ?? NSHomeDirectory() + "/.dsh"
|
|
182
|
+
guard let names = try? FileManager.default.contentsOfDirectory(atPath: home) else { return }
|
|
183
|
+
let newest = names
|
|
184
|
+
.filter { $0.hasPrefix("agent-tree-dock-") && $0.hasSuffix(".json") }
|
|
185
|
+
.compactMap { name -> (port: String, mtime: Date)? in
|
|
186
|
+
guard let start = name.range(of: "agent-tree-dock-")?.upperBound,
|
|
187
|
+
let end = name.range(of: ".json")?.lowerBound, start < end else { return nil }
|
|
188
|
+
let port = String(name[start..<end])
|
|
189
|
+
let path = home + "/" + name
|
|
190
|
+
guard let attrs = try? FileManager.default.attributesOfItem(atPath: path),
|
|
191
|
+
let mtime = attrs[.modificationDate] as? Date else { return nil }
|
|
192
|
+
return (port, mtime)
|
|
193
|
+
}
|
|
194
|
+
.max { $0.mtime < $1.mtime }
|
|
195
|
+
guard let newest, let port = Int(newest.port), (1024..<65536).contains(port) else { return }
|
|
196
|
+
let candidate = "http://127.0.0.1:\(port)"
|
|
197
|
+
guard candidate != Shell.origin, let url = URL(string: candidate + Shell.statePath) else { return }
|
|
198
|
+
var request = URLRequest(url: url, cachePolicy: .reloadIgnoringLocalCacheData, timeoutInterval: 4)
|
|
199
|
+
request.httpMethod = "GET"
|
|
200
|
+
http.dataTask(with: request) { [weak self] _, response, _ in
|
|
201
|
+
guard let http = response as? HTTPURLResponse, http.statusCode == 200 else { return }
|
|
202
|
+
DispatchQueue.main.async {
|
|
203
|
+
guard let self, Shell.origin != candidate else { return }
|
|
204
|
+
Shell.log("DSH 换了端口 → 跟随 \(candidate)(公开路由探活 200)")
|
|
205
|
+
Shell.origin = candidate
|
|
206
|
+
self.consecutiveFailures = 0
|
|
207
|
+
self.pollNow()
|
|
208
|
+
}
|
|
209
|
+
}.resume()
|
|
210
|
+
}
|
|
211
|
+
private var stateReplayed = false
|
|
212
|
+
private var lastStateJSON: String?
|
|
86
213
|
private var spritePending = Set<String>()
|
|
214
|
+
private var authed = false
|
|
215
|
+
private var authInFlight = false
|
|
216
|
+
|
|
217
|
+
// MARK 壳的单实例与 pidfile
|
|
218
|
+
|
|
219
|
+
/// pidfile:让宿主插件(界面里的开关)认得出「壳是否活着」,包括手动起的。
|
|
220
|
+
/// 崩溃留下的陈旧 pid 由读取方用 kill(pid,0) 探活过滤,不依赖正常退出清理。
|
|
221
|
+
private func pidFilePath() -> String {
|
|
222
|
+
let home = ProcessInfo.processInfo.environment["DSH_HOME"] ?? NSHomeDirectory() + "/.dsh"
|
|
223
|
+
return home + "/storages/dsh-maoxuan-pet.shell.pid"
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
private func readLivePid() -> Int32? {
|
|
227
|
+
guard let raw = try? String(contentsOfFile: pidFilePath(), encoding: .utf8),
|
|
228
|
+
let pid = Int32(raw.trimmingCharacters(in: .whitespacesAndNewlines)), pid > 1 else { return nil }
|
|
229
|
+
guard kill(pid, 0) == 0 else { return nil }
|
|
230
|
+
// pid 会被系统复用:文件里的编号活着 ≠ 还是我们的小毛。校验进程身份。
|
|
231
|
+
var buffer = [CChar](repeating: 0, count: Int(MAXPATHLEN))
|
|
232
|
+
guard proc_pidpath(pid, &buffer, UInt32(MAXPATHLEN)) > 0,
|
|
233
|
+
String(cString: buffer).hasSuffix("maoxuan-pet") else { return nil }
|
|
234
|
+
return pid
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
private func writePid() {
|
|
238
|
+
let path = pidFilePath()
|
|
239
|
+
let dir = (path as NSString).deletingLastPathComponent
|
|
240
|
+
try? FileManager.default.createDirectory(atPath: dir, withIntermediateDirectories: true)
|
|
241
|
+
try? String(ProcessInfo.processInfo.processIdentifier.description)
|
|
242
|
+
.write(toFile: path, atomically: true, encoding: .utf8)
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
private func clearPid() {
|
|
246
|
+
try? FileManager.default.removeItem(atPath: pidFilePath())
|
|
247
|
+
}
|
|
87
248
|
|
|
88
249
|
// MARK 生命周期
|
|
89
250
|
|
|
90
251
|
func applicationDidFinishLaunching(_ notification: Notification) {
|
|
252
|
+
// 单实例:屏幕上已经有一只就不重复出来(宿主开关和手动双击都可能触发)
|
|
253
|
+
if let existing = readLivePid(), existing != ProcessInfo.processInfo.processIdentifier {
|
|
254
|
+
FileHandle.standardError.write(Data("[maoxuan-pet] 已有一只小毛在跑(pid \(existing)),这次就不重复出现了\n".utf8))
|
|
255
|
+
exit(0)
|
|
256
|
+
}
|
|
257
|
+
writePid()
|
|
91
258
|
let content = NSRect(x: 0, y: 0, width: Shell.windowWidth, height: Shell.windowHeight)
|
|
92
259
|
let window = PetWindow(
|
|
93
260
|
contentRect: restoredFrame() ?? defaultFrame(),
|
|
@@ -135,6 +302,21 @@ final class ShellController: NSObject, NSApplicationDelegate, WKNavigationDelega
|
|
|
135
302
|
self.webView = webView
|
|
136
303
|
self.clickView = container
|
|
137
304
|
|
|
305
|
+
// 被谁杀的要有案底:SIGTERM/SIGINT/SIGHUP 落日志再走(SIGKILL 无法捕获)。
|
|
306
|
+
for name in [SIGTERM, SIGINT, SIGHUP] {
|
|
307
|
+
signal(name) { code in
|
|
308
|
+
Shell.log("收到信号 \(code),退出")
|
|
309
|
+
exit(0)
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
DispatchQueue.main.async { [weak self] in
|
|
313
|
+
guard let self else { return }
|
|
314
|
+
let start = Date()
|
|
315
|
+
Timer.scheduledTimer(withTimeInterval: 30, repeats: true) { _ in
|
|
316
|
+
Shell.log("heartbeat up=\(Int(-start.timeIntervalSinceNow))s origin=\(Shell.origin)")
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
138
320
|
if let html = locateHTML() {
|
|
139
321
|
webView.loadFileURL(html, allowingReadAccessTo: html.deletingLastPathComponent())
|
|
140
322
|
} else {
|
|
@@ -144,10 +326,16 @@ final class ShellController: NSObject, NSApplicationDelegate, WKNavigationDelega
|
|
|
144
326
|
}
|
|
145
327
|
|
|
146
328
|
window.orderFrontRegardless()
|
|
147
|
-
|
|
329
|
+
let authNote: String
|
|
330
|
+
if Shell.publicRoutes { authNote = ",公开路由模式(不需要 token)" }
|
|
331
|
+
else if Shell.token != nil { authNote = ",带 launch token(自动换 dsh-auth cookie)" }
|
|
332
|
+
else { authNote = ",无 token(宿主开鉴权时会是离线模式)" }
|
|
333
|
+
FileHandle.standardError.write(Data("[maoxuan-pet] 小毛已站上桌面 — \(Shell.origin)\(authNote)(独立进程,DSH 最小化不影响她)\n".utf8))
|
|
334
|
+
Shell.log("started pid=\(ProcessInfo.processInfo.processIdentifier) ppid=\(getppid()) origin=\(Shell.origin) auth=\(Shell.token == nil ? "public" : "token")")
|
|
148
335
|
}
|
|
149
336
|
|
|
150
337
|
func applicationWillTerminate(_ notification: Notification) {
|
|
338
|
+
clearPid()
|
|
151
339
|
persistFrame()
|
|
152
340
|
}
|
|
153
341
|
|
|
@@ -175,8 +363,14 @@ final class ShellController: NSObject, NSApplicationDelegate, WKNavigationDelega
|
|
|
175
363
|
guard ProcessInfo.processInfo.arguments.contains("--reset") == false,
|
|
176
364
|
let raw = Shell.defaults.string(forKey: "frame") else { return nil }
|
|
177
365
|
let frame = NSRectFromString(raw)
|
|
178
|
-
|
|
179
|
-
|
|
366
|
+
guard frame.width > 100, frame.height > 100 else { return nil }
|
|
367
|
+
// 小毛站在窗口右下角(stage right:44 bottom:56):那一块必须在屏内。
|
|
368
|
+
// 只判「窗口与屏幕相交」会恢复出人在屏外的幽灵窗口——看起来就是丢了。
|
|
369
|
+
let petCorner = NSRect(x: frame.maxX - 220, y: frame.minY, width: 220, height: 300)
|
|
370
|
+
guard NSScreen.screens.contains(where: { $0.visibleFrame.intersection(petCorner) == petCorner }) else {
|
|
371
|
+
Shell.log("保存的位置会让小毛站在屏幕外(\(NSStringFromRect(frame))),改用默认位置")
|
|
372
|
+
return nil
|
|
373
|
+
}
|
|
180
374
|
return frame
|
|
181
375
|
}
|
|
182
376
|
|
|
@@ -206,6 +400,51 @@ final class ShellController: NSObject, NSApplicationDelegate, WKNavigationDelega
|
|
|
206
400
|
|
|
207
401
|
// MARK 轮询(原生层负责全部 HTTP,页面不跨域)
|
|
208
402
|
|
|
403
|
+
/// 用 launch token 换签名 cookie:`GET /?token=…` → 303 + Set-Cookie(dsh-auth-*,
|
|
404
|
+
/// Max-Age 30 天,绑定 127.0.0.1:端口)。与浏览器打开 GUI 走的是同一条路;cookie 落在
|
|
405
|
+
/// URLSession 内存 jar 里,之后的 state / sprite / POST 每个请求自动携带。
|
|
406
|
+
/// token 来源:`dsh web`(CLI 宿主)启动日志第一行;桌面 App 不打印,拿不到就只能离线模式。
|
|
407
|
+
private func authenticate(_ token: String) {
|
|
408
|
+
if authInFlight { return }
|
|
409
|
+
authInFlight = true
|
|
410
|
+
guard var components = URLComponents(string: Shell.origin + "/") else {
|
|
411
|
+
authInFlight = false
|
|
412
|
+
return
|
|
413
|
+
}
|
|
414
|
+
components.queryItems = [URLQueryItem(name: "token", value: token)]
|
|
415
|
+
guard let url = components.url else {
|
|
416
|
+
authInFlight = false
|
|
417
|
+
return
|
|
418
|
+
}
|
|
419
|
+
var request = URLRequest(url: url, cachePolicy: .reloadIgnoringLocalCacheData, timeoutInterval: 8)
|
|
420
|
+
request.httpMethod = "GET"
|
|
421
|
+
Shell.log("auth: 用 launch token 换 cookie…")
|
|
422
|
+
http.dataTask(with: request) { [weak self] _, response, error in
|
|
423
|
+
DispatchQueue.main.async {
|
|
424
|
+
guard let self else { return }
|
|
425
|
+
self.authInFlight = false
|
|
426
|
+
if let error {
|
|
427
|
+
Shell.log("auth failed: \(error.localizedDescription)")
|
|
428
|
+
self.evaluate("window.__petOfflineSeen=true;if (window.__petOffline) window.__petOffline();")
|
|
429
|
+
self.schedulePoll(after: 30)
|
|
430
|
+
return
|
|
431
|
+
}
|
|
432
|
+
let status = (response as? HTTPURLResponse)?.statusCode ?? 0
|
|
433
|
+
let jar = self.httpConfig.httpCookieStorage
|
|
434
|
+
let hasAuthCookie = jar?.cookies?.contains { $0.name.hasPrefix("dsh-auth-") } ?? false
|
|
435
|
+
guard (200..<400).contains(status), hasAuthCookie else {
|
|
436
|
+
Shell.log("auth HTTP \(status),没换到 dsh-auth cookie——token 要来自同一个宿主进程(dsh web 启动日志里的 ?token=)")
|
|
437
|
+
self.evaluate("window.__petOfflineSeen=true;if (window.__petOffline) window.__petOffline();")
|
|
438
|
+
self.schedulePoll(after: 30)
|
|
439
|
+
return
|
|
440
|
+
}
|
|
441
|
+
self.authed = true
|
|
442
|
+
Shell.log("auth ok:dsh-auth cookie 已入 jar,开始拉取 state")
|
|
443
|
+
self.pollNow()
|
|
444
|
+
}
|
|
445
|
+
}.resume()
|
|
446
|
+
}
|
|
447
|
+
|
|
209
448
|
private func schedulePoll(after interval: TimeInterval) {
|
|
210
449
|
pollItem?.cancel()
|
|
211
450
|
let item = DispatchWorkItem { [weak self] in self?.pollNow() }
|
|
@@ -214,6 +453,10 @@ final class ShellController: NSObject, NSApplicationDelegate, WKNavigationDelega
|
|
|
214
453
|
}
|
|
215
454
|
|
|
216
455
|
private func pollNow() {
|
|
456
|
+
if let token = Shell.token, !authed {
|
|
457
|
+
authenticate(token)
|
|
458
|
+
return
|
|
459
|
+
}
|
|
217
460
|
guard let url = URL(string: Shell.origin + Shell.statePath) else { return }
|
|
218
461
|
var request = URLRequest(url: url, cachePolicy: .reloadIgnoringLocalCacheData, timeoutInterval: 8)
|
|
219
462
|
request.httpMethod = "GET"
|
|
@@ -222,26 +465,44 @@ final class ShellController: NSObject, NSApplicationDelegate, WKNavigationDelega
|
|
|
222
465
|
guard let self else { return }
|
|
223
466
|
if let error {
|
|
224
467
|
Shell.log("state failed: \(error.localizedDescription)")
|
|
225
|
-
self.evaluate("window.__petOffline
|
|
468
|
+
self.evaluate("window.__petOfflineSeen=true;if (window.__petOffline) window.__petOffline();")
|
|
469
|
+
self.consecutiveFailures += 1
|
|
470
|
+
if self.consecutiveFailures >= 3 || (self.consecutiveFailures >= 1 && !self.everSucceeded), Date() >= self.rediscoveryCooldown {
|
|
471
|
+
self.rediscoveryCooldown = Date().addingTimeInterval(20)
|
|
472
|
+
self.tryRediscover()
|
|
473
|
+
}
|
|
226
474
|
self.schedulePoll(after: 8)
|
|
227
475
|
return
|
|
228
476
|
}
|
|
229
477
|
if let http = response as? HTTPURLResponse, http.statusCode != 200 {
|
|
230
|
-
|
|
231
|
-
|
|
478
|
+
let snippet = String(data: (data ?? Data()).prefix(160), encoding: .utf8) ?? "-"
|
|
479
|
+
Shell.log("state HTTP \(http.statusCode) url=\(url.absoluteString) body=\(snippet)")
|
|
480
|
+
self.evaluate("window.__petOfflineSeen=true;if (window.__petOffline) window.__petOffline();")
|
|
481
|
+
// 401 = jar 里的 cookie 失效了(宿主重启换了 launch token、或 30 天过期):
|
|
482
|
+
// 置回未鉴权,下一轮先用 --token 重新换一次 cookie 再拉。
|
|
483
|
+
if http.statusCode == 401, Shell.token != nil { self.authed = false }
|
|
484
|
+
self.consecutiveFailures += 1
|
|
485
|
+
if self.consecutiveFailures >= 3 || (self.consecutiveFailures >= 1 && !self.everSucceeded), Date() >= self.rediscoveryCooldown {
|
|
486
|
+
self.rediscoveryCooldown = Date().addingTimeInterval(20)
|
|
487
|
+
self.tryRediscover()
|
|
488
|
+
}
|
|
232
489
|
self.schedulePoll(after: 8)
|
|
233
490
|
return
|
|
234
491
|
}
|
|
235
492
|
guard let data, let text = String(data: data, encoding: .utf8) else {
|
|
236
|
-
self.evaluate("window.__petOffline
|
|
493
|
+
self.evaluate("window.__petOfflineSeen=true;if (window.__petOffline) window.__petOffline();")
|
|
237
494
|
self.schedulePoll(after: 8)
|
|
238
495
|
return
|
|
239
496
|
}
|
|
497
|
+
self.consecutiveFailures = 0
|
|
498
|
+
self.everSucceeded = true
|
|
240
499
|
// 生成中的话 3s 后紧跟下一条,让气泡尽快落地
|
|
241
500
|
let running = (text.contains("\"running\":true") || text.contains("\"running\" : true"))
|
|
242
501
|
self.lastInterval = running ? 3 : 20
|
|
243
502
|
self.ensureSprites(from: data)
|
|
244
|
-
|
|
503
|
+
let payload = self.sanitized(text)
|
|
504
|
+
self.lastStateJSON = payload
|
|
505
|
+
self.evaluate("window.__petStateData=\(payload);if (window.__petState) window.__petState(window.__petStateData);")
|
|
245
506
|
self.schedulePoll(after: self.lastInterval)
|
|
246
507
|
}
|
|
247
508
|
}.resume()
|
|
@@ -261,7 +522,11 @@ final class ShellController: NSObject, NSApplicationDelegate, WKNavigationDelega
|
|
|
261
522
|
}
|
|
262
523
|
guard !newlyNeeded.isEmpty else { return }
|
|
263
524
|
for entry in newlyNeeded {
|
|
264
|
-
|
|
525
|
+
// 公开路由模式下 state 里的 sprite 路径仍是 /api/…,换到公开素材路由
|
|
526
|
+
let path = Shell.publicRoutes
|
|
527
|
+
? entry.path.replacingOccurrences(of: "/api/dsh-maoxuan-pet.asset", with: "/dsh-maoxuan-pet/asset.png")
|
|
528
|
+
: entry.path
|
|
529
|
+
guard let url = URL(string: Shell.origin + path) else {
|
|
265
530
|
spritePending.remove(entry.name)
|
|
266
531
|
continue
|
|
267
532
|
}
|
|
@@ -276,7 +541,7 @@ final class ShellController: NSObject, NSApplicationDelegate, WKNavigationDelega
|
|
|
276
541
|
self.spriteDataURLs[entry.name] = "data:\(mime);base64,\(data.base64EncodedString())"
|
|
277
542
|
Shell.log("sprite \(entry.name) ready (\(data.count) bytes)")
|
|
278
543
|
}
|
|
279
|
-
self.evaluate("window.
|
|
544
|
+
self.evaluate("window.__petSpriteData=\(Shell.jsonString(self.spriteDataURLs));if (window.__petSprites) window.__petSprites(window.__petSpriteData);")
|
|
280
545
|
}
|
|
281
546
|
}.resume()
|
|
282
547
|
}
|
|
@@ -303,6 +568,8 @@ final class ShellController: NSObject, NSApplicationDelegate, WKNavigationDelega
|
|
|
303
568
|
var request = URLRequest(url: url, cachePolicy: .reloadIgnoringLocalCacheData, timeoutInterval: 200)
|
|
304
569
|
request.httpMethod = "POST"
|
|
305
570
|
request.setValue("application/json", forHTTPHeaderField: "content-type")
|
|
571
|
+
// 公开路由的 POST 要求这个头(跨站页面发不出来,宿主据此挡 CSRF);/api 模式忽略它
|
|
572
|
+
request.setValue("1", forHTTPHeaderField: "x-dsh-maoxuan-pet")
|
|
306
573
|
request.httpBody = body.data(using: .utf8)
|
|
307
574
|
Shell.log("POST \(path) \(body)")
|
|
308
575
|
http.dataTask(with: request) { [weak self] data, response, error in
|
|
@@ -341,6 +608,15 @@ final class ShellController: NSObject, NSApplicationDelegate, WKNavigationDelega
|
|
|
341
608
|
case "input":
|
|
342
609
|
// 页面上报的可交互矩形(CSS px,左上原点)→ 容器本地坐标(左下原点)
|
|
343
610
|
guard let rects = body["rects"] as? [[String: Any]] else { return }
|
|
611
|
+
// 原始页面坐标(top-left)正是 CGS 形状要的坐标系
|
|
612
|
+
let raw: [CGRect] = rects.compactMap { dict in
|
|
613
|
+
guard let x = (dict["x"] as? NSNumber)?.doubleValue,
|
|
614
|
+
let y = (dict["y"] as? NSNumber)?.doubleValue,
|
|
615
|
+
let w = (dict["w"] as? NSNumber)?.doubleValue,
|
|
616
|
+
let h = (dict["h"] as? NSNumber)?.doubleValue,
|
|
617
|
+
w > 0, h > 0 else { return nil }
|
|
618
|
+
return CGRect(x: x, y: y, width: w, height: h)
|
|
619
|
+
}
|
|
344
620
|
let contentHeight = webView.bounds.height
|
|
345
621
|
let converted: [CGRect] = rects.compactMap { dict in
|
|
346
622
|
guard let x = (dict["x"] as? NSNumber)?.doubleValue,
|
|
@@ -351,7 +627,30 @@ final class ShellController: NSObject, NSApplicationDelegate, WKNavigationDelega
|
|
|
351
627
|
return CGRect(x: x, y: contentHeight - (y + h), width: w, height: h)
|
|
352
628
|
}
|
|
353
629
|
clickView.passRects = converted
|
|
354
|
-
Shell.
|
|
630
|
+
Shell.debug("input rects: \(converted.count)")
|
|
631
|
+
// 可点区域的并集写进日志:用户体感「隐形区域大」时,拿这行对账
|
|
632
|
+
let union = converted.reduce(CGRect.null) { $0.union($1) }
|
|
633
|
+
syncWindowFrame(to: union)
|
|
634
|
+
let desc = String(format: "input region: x=%.0f y=%.0f w=%.0f h=%.0f(窗口 %.0fx%.0f)",
|
|
635
|
+
union.minX, union.minY, union.width, union.height,
|
|
636
|
+
webView.bounds.width, webView.bounds.height)
|
|
637
|
+
if desc != lastRegionDesc {
|
|
638
|
+
lastRegionDesc = desc
|
|
639
|
+
Shell.log(desc)
|
|
640
|
+
}
|
|
641
|
+
// 页面就绪补发:立绘/首份 state 可能比页面加载完成更晚/更早到达,
|
|
642
|
+
// 一次性 evaluate 会落在将被丢弃的上下文里。rects 是页面「活着」的
|
|
643
|
+
// 心跳,数据比上次补发多就再补一轮——最终一定补齐。
|
|
644
|
+
if !stateReplayed, let payload = lastStateJSON {
|
|
645
|
+
stateReplayed = true
|
|
646
|
+
self.evaluate("window.__petStateData=\(payload);if (window.__petState) window.__petState(window.__petStateData);")
|
|
647
|
+
Shell.log("page ready → 补发 state")
|
|
648
|
+
}
|
|
649
|
+
if spriteDataURLs.count > replayedSpriteCount {
|
|
650
|
+
replayedSpriteCount = spriteDataURLs.count
|
|
651
|
+
self.evaluate("window.__petSpriteData=\(Shell.jsonString(spriteDataURLs));if (window.__petSprites) window.__petSprites(window.__petSpriteData);")
|
|
652
|
+
Shell.log("page ready → 补发立绘 \(replayedSpriteCount) 张")
|
|
653
|
+
}
|
|
355
654
|
|
|
356
655
|
case "dragstart":
|
|
357
656
|
dragBaseOrigin = window.frame.origin
|
|
@@ -405,12 +704,27 @@ while index < arguments.count {
|
|
|
405
704
|
} else if argument.hasPrefix("--port=") {
|
|
406
705
|
let value = String(argument.dropFirst("--port=".count))
|
|
407
706
|
Shell.origin = "http://127.0.0.1:\(value)"
|
|
707
|
+
} else if argument.hasPrefix("--token=") {
|
|
708
|
+
// 容错:允许把「?token=xxx」整段或「token=xxx」一起粘进来
|
|
709
|
+
var value = String(argument.dropFirst("--token=".count)).trimmingCharacters(in: .whitespaces)
|
|
710
|
+
if value.hasPrefix("token=") { value = String(value.dropFirst("token=".count)) }
|
|
711
|
+
while value.hasSuffix("/") { value.removeLast() }
|
|
712
|
+
Shell.token = value.isEmpty ? nil : value
|
|
713
|
+
} else if argument == "--public" {
|
|
714
|
+
Shell.publicRoutes = true
|
|
408
715
|
} else if argument == "--verbose" {
|
|
409
716
|
Shell.verbose = true
|
|
410
717
|
}
|
|
411
718
|
index += 1
|
|
412
719
|
}
|
|
413
720
|
|
|
721
|
+
if Shell.publicRoutes {
|
|
722
|
+
Shell.statePath = "/dsh-maoxuan-pet/pet.json"
|
|
723
|
+
Shell.generatePath = "/dsh-maoxuan-pet/generate"
|
|
724
|
+
Shell.dismissPath = "/dsh-maoxuan-pet/dismiss"
|
|
725
|
+
Shell.assetPath = "/dsh-maoxuan-pet/asset.png"
|
|
726
|
+
}
|
|
727
|
+
|
|
414
728
|
let app = NSApplication.shared
|
|
415
729
|
app.setActivationPolicy(.accessory) // 不占 Dock、不进 Cmd-Tab
|
|
416
730
|
let controller = ShellController()
|
package/desktop/pet.html
CHANGED
|
@@ -21,12 +21,12 @@
|
|
|
21
21
|
.mp-pet:hover{transform:translateY(-3px)}
|
|
22
22
|
.mp-img{display:block;height:132px;width:auto;max-width:140px;object-fit:contain;filter:drop-shadow(0 10px 16px #050b14aa)}
|
|
23
23
|
.mp-img[data-talking="on"]{animation:mp-bob 1.6s ease-in-out infinite}
|
|
24
|
-
.mp-fallback{height:
|
|
24
|
+
.mp-fallback{height:118px;width:118px;filter:drop-shadow(0 10px 16px #050b14aa)}
|
|
25
25
|
.mp-tag{margin-top:2px;font-size:10px;font-weight:700;letter-spacing:.06em;color:#ffe2bd;background:#5c0f0fd9;border:1px solid #ffd9a833;border-radius:999px;padding:2px 8px;white-space:nowrap;backdrop-filter:blur(8px)}
|
|
26
26
|
.mp-dot{display:inline-block;width:6px;height:6px;border-radius:50%;margin-right:5px;background:#f6cf9a;box-shadow:0 0 6px #f6cf9aaa;vertical-align:1px}
|
|
27
27
|
.mp-dot[data-off="off"]{background:#fca5a5;box-shadow:0 0 6px #fca5a5aa}
|
|
28
28
|
.mp-bubble{position:absolute;z-index:2;right:calc(100% + 12px);bottom:30px;width:340px;animation:mp-in .16s ease-out}
|
|
29
|
-
.mp-card{position:relative;max-height:
|
|
29
|
+
.mp-card{position:relative;max-height:480px;overflow-y:auto;overflow-x:hidden;overscroll-behavior:contain;scrollbar-width:thin;scrollbar-color:#f6cf9a #5f1010dd;color:#fff3e4;border:1px solid #8f1616;border-radius:12px;background:linear-gradient(155deg,#b32323 0%,#941a1a 46%,#771111 100%);box-shadow:0 22px 54px #35040466,0 0 0 1px #ffd9a814 inset}
|
|
30
30
|
.mp-card::-webkit-scrollbar{width:8px;height:8px}
|
|
31
31
|
.mp-card::-webkit-scrollbar-track{background:#5f1010cc;border-radius:8px}
|
|
32
32
|
.mp-card::-webkit-scrollbar-thumb{background:linear-gradient(180deg,#f7d9a4,#d09a3f);border-radius:8px;border:1px solid #5f1010}
|
|
@@ -147,13 +147,21 @@ function current() {
|
|
|
147
147
|
}
|
|
148
148
|
|
|
149
149
|
// --- 渲染:宠物本体(一次性搭好,之后只改动态位) ----------------------------
|
|
150
|
-
const FALLBACK_SVG = `<svg class="mp-fallback" viewBox="0 0
|
|
151
|
-
<
|
|
152
|
-
<
|
|
153
|
-
<
|
|
154
|
-
<
|
|
155
|
-
|
|
156
|
-
|
|
150
|
+
const FALLBACK_SVG = `<svg class="mp-fallback" viewBox="0 0 512 512" role="img" aria-label="鲸鱼娘·小毛 图标:红皮书《毛泽东选集》">
|
|
151
|
+
<rect x="0" y="0" width="512" height="512" rx="116" fill="#FFF6E9"/>
|
|
152
|
+
<rect x="14" y="14" width="484" height="484" rx="104" fill="none" stroke="#C1342B" stroke-width="6" stroke-opacity="0.16"/>
|
|
153
|
+
<g opacity="0.22"><rect x="132" y="112" width="286" height="308" rx="28" fill="#7A1410"/></g>
|
|
154
|
+
<g transform="translate(252,256) scale(1.08) translate(-258,-256)">
|
|
155
|
+
<path d="M330 118 h78 a14 14 0 0 1 14 14 v248 a14 14 0 0 1 -14 14 h-78 z" fill="#EFDFC4"/>
|
|
156
|
+
<path d="M356 138 v236 M382 138 v236" stroke="#D9C4A2" stroke-width="5" stroke-linecap="round" fill="none"/>
|
|
157
|
+
<rect x="120" y="104" width="232" height="304" rx="26" fill="#B3271E"/>
|
|
158
|
+
<rect x="120" y="104" width="46" height="304" rx="22" fill="#8F1E17"/>
|
|
159
|
+
<path d="M143 148 v216" stroke="#E8C36A" stroke-width="7" stroke-linecap="round" opacity="0.9" fill="none"/>
|
|
160
|
+
<rect x="192" y="126" width="142" height="260" rx="14" fill="none" stroke="#E8C36A" stroke-width="5" stroke-opacity="0.55"/>
|
|
161
|
+
<polygon fill="#E8C36A" points="263,150 273.32,181.76 306.72,181.76 279.7,201.74 289.02,234.5 263,214.25 236.98,234.5 246.3,201.74 219.28,181.76 252.68,181.76"/>
|
|
162
|
+
<g fill="#E8C36A"><rect x="215" y="272" width="96" height="16" rx="8"/><rect x="232" y="304" width="62" height="13" rx="6.5" opacity="0.78"/></g>
|
|
163
|
+
</g>
|
|
164
|
+
</svg>`;
|
|
157
165
|
|
|
158
166
|
PET_TAG.innerHTML = `
|
|
159
167
|
<span id="pet-visual"></span>
|
|
@@ -381,6 +389,8 @@ window.__petSprites = (map) => {
|
|
|
381
389
|
renderPetVisual();
|
|
382
390
|
reportRects();
|
|
383
391
|
};
|
|
392
|
+
// 壳可能在当前脚本执行前就注入了立绘(--public 模式轮询极快)——这里回放
|
|
393
|
+
if (window.__petSpriteData) window.__petSprites(window.__petSpriteData);
|
|
384
394
|
window.__petState = (json) => {
|
|
385
395
|
offline = false;
|
|
386
396
|
state = json;
|
|
@@ -402,6 +412,9 @@ window.__petState = (json) => {
|
|
|
402
412
|
renderAll();
|
|
403
413
|
};
|
|
404
414
|
window.__petOffline = () => { offline = true; busy = false; renderMeta(); renderCard(); };
|
|
415
|
+
// 状态/离线标记可能早于本脚本到达(--public 轮询极快)——回放
|
|
416
|
+
if (window.__petStateData) window.__petState(window.__petStateData);
|
|
417
|
+
else if (window.__petOfflineSeen) window.__petOffline();
|
|
405
418
|
window.__petActionDone = () => { busy = false; cardKey = ''; renderCard(); renderMeta(); };
|
|
406
419
|
|
|
407
420
|
// 时钟:倒计时文案每秒刷新
|
package/docs/API-CONTRACT.md
CHANGED
|
@@ -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,8 +16,8 @@
|
|
|
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';
|
|
20
|
-
import { spawn } from 'node:child_process';
|
|
19
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
20
|
+
import { spawn, execFileSync } from 'node:child_process';
|
|
21
21
|
import { dirname, join } from 'node:path';
|
|
22
22
|
import { fileURLToPath } from 'node:url';
|
|
23
23
|
import { homedir } from 'node:os';
|
|
@@ -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,166 @@ 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
|
+
// pid 会被系统复用:编号活着 ≠ 还是小毛。校验进程名,防止徽章状态失真。
|
|
1179
|
+
if (shellPlatform) {
|
|
1180
|
+
const comm = execFileSync('ps', ['-p', String(pid), '-o', 'comm='], { encoding: 'utf8' });
|
|
1181
|
+
if (!comm.trim().endsWith('maoxuan-pet')) return null;
|
|
1182
|
+
}
|
|
1183
|
+
return pid;
|
|
1184
|
+
} catch { return null; }
|
|
1185
|
+
};
|
|
1186
|
+
|
|
1187
|
+
const shellStatus = () => {
|
|
1188
|
+
const pid = shellPid();
|
|
1189
|
+
return {
|
|
1190
|
+
supported: shellPlatform,
|
|
1191
|
+
running: Boolean(pid),
|
|
1192
|
+
pid: pid ?? null,
|
|
1193
|
+
building: shellBusy,
|
|
1194
|
+
error: shellError,
|
|
1195
|
+
};
|
|
1196
|
+
};
|
|
1197
|
+
|
|
1198
|
+
/** 首次启动:产物不存在就先编(swiftc 几秒钟),编完再起。 */
|
|
1199
|
+
const buildShell = () => new Promise((resolve, reject) => {
|
|
1200
|
+
const child = spawn('bash', [shellScript], { cwd: here, stdio: 'ignore' });
|
|
1201
|
+
const timer = setTimeout(() => {
|
|
1202
|
+
try { child.kill('SIGKILL'); } catch { /* already gone */ }
|
|
1203
|
+
reject(new Error('编译超时(120s)'));
|
|
1204
|
+
}, 120000);
|
|
1205
|
+
child.on('error', (error) => { clearTimeout(timer); reject(error); });
|
|
1206
|
+
child.on('exit', (code) => {
|
|
1207
|
+
clearTimeout(timer);
|
|
1208
|
+
if (code === 0 && existsSync(shellBin)) resolve();
|
|
1209
|
+
else reject(new Error(`编译失败(exit ${code})——终端里跑一次 npm run pet:build 看报错`));
|
|
1210
|
+
});
|
|
1211
|
+
});
|
|
1212
|
+
|
|
1213
|
+
const launchShell = async (origin) => {
|
|
1214
|
+
if (!shellPlatform) return { ok: false, supported: false, error: '独立桌宠壳目前只支持 macOS' };
|
|
1215
|
+
const pid = shellPid();
|
|
1216
|
+
if (pid) return { ok: true, ...shellStatus() };
|
|
1217
|
+
if (shellBusy) return { ok: false, ...shellStatus(), error: '正在编译桌宠壳,等几秒' };
|
|
1218
|
+
shellError = null;
|
|
1219
|
+
try {
|
|
1220
|
+
if (!existsSync(shellBin)) {
|
|
1221
|
+
shellBusy = true;
|
|
1222
|
+
await buildShell();
|
|
1223
|
+
}
|
|
1224
|
+
const args = ['--public'];
|
|
1225
|
+
// 只接受 loopback origin:这个值来自浏览器 location.origin,别让它带去别处
|
|
1226
|
+
if (typeof origin === 'string' && /^http:\/\/(127\.0\.0\.1|localhost|\[::1\]):\d{2,5}$/.test(origin)) {
|
|
1227
|
+
origin = origin.replace('localhost', '127.0.0.1').replace('[::1]', '127.0.0.1');
|
|
1228
|
+
args.push(`--origin=${origin}`);
|
|
1229
|
+
}
|
|
1230
|
+
const child = spawn(shellBin, args, { detached: true, stdio: 'ignore' });
|
|
1231
|
+
child.unref();
|
|
1232
|
+
shellChild = child;
|
|
1233
|
+
return { ok: true, ...shellStatus() };
|
|
1234
|
+
} catch (error) {
|
|
1235
|
+
shellError = String(error?.message || error);
|
|
1236
|
+
return { ok: false, ...shellStatus(), error: shellError };
|
|
1237
|
+
} finally {
|
|
1238
|
+
shellBusy = false;
|
|
1239
|
+
}
|
|
1240
|
+
};
|
|
1241
|
+
|
|
1242
|
+
const quitShell = () => {
|
|
1243
|
+
const pid = shellPid();
|
|
1244
|
+
if (pid) {
|
|
1245
|
+
try { process.kill(pid, 'SIGTERM'); } catch { /* already gone */ }
|
|
1246
|
+
}
|
|
1247
|
+
if (shellChild) {
|
|
1248
|
+
try { shellChild.kill(); } catch { /* already gone */ }
|
|
1249
|
+
shellChild = null;
|
|
1250
|
+
}
|
|
1251
|
+
shellError = null;
|
|
1252
|
+
return { ok: true, ...shellStatus() };
|
|
1253
|
+
};
|
|
1254
|
+
|
|
1138
1255
|
// --- transport -----------------------------------------------------------
|
|
1256
|
+
// The same five handlers serve both doors: the authenticated `/api` routes the
|
|
1257
|
+
// Web client uses, and the loopback-public routes the native shell uses.
|
|
1258
|
+
const handlers = {
|
|
1259
|
+
state: async () => { await ready; return json(snapshot()); },
|
|
1260
|
+
corpus: async () => {
|
|
1261
|
+
await ready;
|
|
1262
|
+
return json({
|
|
1263
|
+
articles: corpus.articles.map((a) => ({
|
|
1264
|
+
id: a.id, title: a.title, year: a.year, tags: a.tags, quote: a.quote, summary: a.summary,
|
|
1265
|
+
})),
|
|
1266
|
+
});
|
|
1267
|
+
},
|
|
1268
|
+
generate: async () => {
|
|
1269
|
+
await ready;
|
|
1270
|
+
if (running) return json({ ok: true, running: true, bubble: null }, 202);
|
|
1271
|
+
const result = await runOnce('manual');
|
|
1272
|
+
if (!result.ok) return json(result, 500);
|
|
1273
|
+
return json(result, 200);
|
|
1274
|
+
},
|
|
1275
|
+
dismiss: async (request) => {
|
|
1276
|
+
await ready;
|
|
1277
|
+
const body = await readBody(request);
|
|
1278
|
+
const target = typeof body.id === 'string' && body.id
|
|
1279
|
+
? state.bubbles.find((b) => b.id === body.id)
|
|
1280
|
+
: state.bubbles.find((b) => !b.read);
|
|
1281
|
+
if (target && !target.read) {
|
|
1282
|
+
target.read = true;
|
|
1283
|
+
await persist();
|
|
1284
|
+
}
|
|
1285
|
+
return json({ ok: true, unread: state.bubbles.filter((b) => !b.read).length });
|
|
1286
|
+
},
|
|
1287
|
+
shell: async (request) => {
|
|
1288
|
+
if (request.method !== 'POST') return json(shellStatus());
|
|
1289
|
+
const body = await readBody(request);
|
|
1290
|
+
const action = String(body.action || '');
|
|
1291
|
+
if (action === 'launch') return json(await launchShell(body.origin));
|
|
1292
|
+
if (action === 'quit') return json(quitShell());
|
|
1293
|
+
return json({ ok: false, error: 'action 必须是 launch 或 quit' }, 400);
|
|
1294
|
+
},
|
|
1295
|
+
asset: async (request) => {
|
|
1296
|
+
let sprite = 'idle';
|
|
1297
|
+
try { sprite = new URL(request.url).searchParams.get('name') || 'idle'; } catch { sprite = 'idle'; }
|
|
1298
|
+
const file = SPRITES[sprite] || SPRITES.idle;
|
|
1299
|
+
try {
|
|
1300
|
+
const [bytes, info] = await Promise.all([readFile(join(assetsDir, file)), stat(join(assetsDir, file))]);
|
|
1301
|
+
if (!info.size) throw new MaoxuanPetError('空文件');
|
|
1302
|
+
return new Response(bytes, {
|
|
1303
|
+
headers: { 'content-type': 'image/png', 'cache-control': 'public, max-age=3600' },
|
|
1304
|
+
});
|
|
1305
|
+
} catch {
|
|
1306
|
+
return text(`缺少素材:assets/${file}`, 404);
|
|
1307
|
+
}
|
|
1308
|
+
},
|
|
1309
|
+
};
|
|
1310
|
+
|
|
1139
1311
|
ctx.inject?.(['connection'], (scope) => {
|
|
1140
1312
|
const routes = [
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
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
|
-
},
|
|
1313
|
+
// Required: without an explicit body mode the bridge builds a GET request
|
|
1314
|
+
// with a stream body and Node throws, which the Host surfaces as 400.
|
|
1315
|
+
{ path: ROUTES.state, methods: ['GET'], requestBody: 'buffered', fetch: () => handlers.state() },
|
|
1316
|
+
{ path: ROUTES.corpus, methods: ['GET'], requestBody: 'buffered', fetch: () => handlers.corpus() },
|
|
1317
|
+
{ path: ROUTES.generate, methods: ['POST'], requestBody: 'buffered', fetch: () => handlers.generate() },
|
|
1318
|
+
{ path: ROUTES.dismiss, methods: ['POST'], requestBody: 'buffered', fetch: (request) => handlers.dismiss(request) },
|
|
1319
|
+
{ path: ROUTES.asset, methods: ['GET'], requestBody: 'buffered', fetch: (request) => handlers.asset(request) },
|
|
1320
|
+
{ path: ROUTES.shell, methods: ['GET', 'POST'], requestBody: 'buffered', fetch: (request) => handlers.shell(request) },
|
|
1214
1321
|
];
|
|
1215
1322
|
const disposers = routes.map((route) => scope.connection.fetch.register(route));
|
|
1216
1323
|
scope.effect(() => () => {
|
|
@@ -1220,6 +1327,68 @@ export function apply(ctx, config) {
|
|
|
1220
1327
|
}, 'maoxuan-pet.routes');
|
|
1221
1328
|
});
|
|
1222
1329
|
|
|
1330
|
+
// --- cookie-less transport for the standalone macOS shell -----------------
|
|
1331
|
+
if (cfg.publicRoutes) {
|
|
1332
|
+
ctx.inject?.(['webServer'], (webCtx) => {
|
|
1333
|
+
// 只认真正的 webServer 服务:没有它(测试里的假 ctx、或非 web 宿主)就安静跳过,
|
|
1334
|
+
// 可选增强绝不该把插件主体挂掉。
|
|
1335
|
+
const server = webCtx?.webServer;
|
|
1336
|
+
if (typeof server?.register !== 'function') {
|
|
1337
|
+
log('debug', '公开路由不可用(宿主无 webServer),桌宠壳请改用 --token 走 /api');
|
|
1338
|
+
return;
|
|
1339
|
+
}
|
|
1340
|
+
const register = (path, methods, handler) => {
|
|
1341
|
+
const dispose = server.register({
|
|
1342
|
+
kind: 'exact',
|
|
1343
|
+
path,
|
|
1344
|
+
handler: async (req, res) => {
|
|
1345
|
+
if (!methods.includes(req.method)) {
|
|
1346
|
+
res.writeHead(405, { 'content-type': 'text/plain; charset=utf-8' });
|
|
1347
|
+
res.end('method not allowed');
|
|
1348
|
+
return;
|
|
1349
|
+
}
|
|
1350
|
+
if (req.method === 'POST') {
|
|
1351
|
+
const type = String(req.headers['content-type'] || '');
|
|
1352
|
+
if (!type.includes('application/json') || req.headers['x-dsh-maoxuan-pet'] !== '1') {
|
|
1353
|
+
res.writeHead(403, { 'content-type': 'text/plain; charset=utf-8' });
|
|
1354
|
+
res.end('forbidden');
|
|
1355
|
+
return;
|
|
1356
|
+
}
|
|
1357
|
+
}
|
|
1358
|
+
try {
|
|
1359
|
+
const chunks = [];
|
|
1360
|
+
for await (const chunk of req) chunks.push(chunk);
|
|
1361
|
+
const body = chunks.length ? Buffer.concat(chunks).toString('utf8') : '';
|
|
1362
|
+
const request = new Request(`http://127.0.0.1${req.url || path}`, {
|
|
1363
|
+
method: req.method,
|
|
1364
|
+
headers: req.headers,
|
|
1365
|
+
body: body || undefined,
|
|
1366
|
+
});
|
|
1367
|
+
const response = await handler(request);
|
|
1368
|
+
res.writeHead(response.status, Object.fromEntries(response.headers));
|
|
1369
|
+
res.end(Buffer.from(await response.arrayBuffer()));
|
|
1370
|
+
} catch (error) {
|
|
1371
|
+
res.writeHead(500, { 'content-type': 'text/plain; charset=utf-8' });
|
|
1372
|
+
res.end(String(error?.message || error));
|
|
1373
|
+
}
|
|
1374
|
+
},
|
|
1375
|
+
});
|
|
1376
|
+
return dispose;
|
|
1377
|
+
};
|
|
1378
|
+
const disposers = [
|
|
1379
|
+
register(SHELL_ROUTES.state, ['GET'], () => handlers.state()),
|
|
1380
|
+
register(SHELL_ROUTES.asset, ['GET'], (request) => handlers.asset(request)),
|
|
1381
|
+
register(SHELL_ROUTES.generate, ['POST'], () => handlers.generate()),
|
|
1382
|
+
register(SHELL_ROUTES.dismiss, ['POST'], (request) => handlers.dismiss(request)),
|
|
1383
|
+
];
|
|
1384
|
+
webCtx.effect(() => () => {
|
|
1385
|
+
for (const dispose of disposers) {
|
|
1386
|
+
try { dispose?.(); } catch { /* already gone */ }
|
|
1387
|
+
}
|
|
1388
|
+
}, 'maoxuan-pet.shell-routes');
|
|
1389
|
+
});
|
|
1390
|
+
}
|
|
1391
|
+
|
|
1223
1392
|
// Start the clock only after the persisted snapshot is loaded.
|
|
1224
1393
|
void ready.then(() => {
|
|
1225
1394
|
if (!cfg.enabled) return;
|