dsh-mobile-plugin 0.1.73 → 0.1.75
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/README.md +63 -164
- package/lib/index.js +15 -15
- package/lib/mobile/archive-clean.js +34 -8
- package/lib/mobile/deepseek-account.js +125 -0
- package/lib/mobile/host-bridge/endpoints.js +5 -0
- package/lib/mobile/session-cache.js +36 -0
- package/lib/mobile/sessions.js +6 -5
- package/lib/panel/index.html +9 -9
- package/lib/routes/control-plane.js +26 -0
- package/lib/routes/mobile-api.js +3 -0
- package/lib/session-cleanup.cjs +21 -8
- package/lib/shell/device-ota.js +126 -0
- package/lib/shell/manager.js +31 -32
- package/lib/shell/runtime.js +23 -11
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,202 +1,101 @@
|
|
|
1
1
|
# dsh-mobile-plugin
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
隧道客户端、局域网代理,以及受限的 `/api/mobile/v1/*` 移动 API。本包**不依赖任何桌面外壳或
|
|
5
|
-
Electron 运行时**,`node host.js` 即可独立运行。
|
|
3
|
+
[English](EN-README.md)
|
|
6
4
|
|
|
7
|
-
|
|
5
|
+
AI-HUB 手机端插件,为 DSH 提供移动 API、远程连接和局域网代理。插件也可作为无头 Cordis Host 独立运行,不依赖桌面外壳或 Electron。
|
|
8
6
|
|
|
9
|
-
|
|
7
|
+
## 功能
|
|
10
8
|
|
|
11
|
-
|
|
9
|
+
- 通过 relay v2 隧道连接手机 App,并支持局域网代理。
|
|
10
|
+
- 提供受限的 `/api/mobile/v1/*` 移动 API。
|
|
11
|
+
- 支持会话创建、工作区归属与会话排序同步。
|
|
12
|
+
- 支持设备事件 SSE 转发、自动归档和会话清理。
|
|
13
|
+
- 提供本机配置接口与配对授权策略。
|
|
12
14
|
|
|
13
|
-
##
|
|
15
|
+
## 安装与使用
|
|
14
16
|
|
|
15
|
-
|
|
17
|
+
### 在 DSH 桌面端
|
|
16
18
|
|
|
17
|
-
|
|
18
|
-
技术支持 / 常见问题(上架时填的「技术支持网址」):[SUPPORT.md](SUPPORT.md)。
|
|
19
|
+
在 DSH 的 **设置 → 插件** 中搜索并安装 `dsh-mobile-plugin`。安装后,从 **设置 → 移动端 → 打开配置页面** 配置连接与配对。插件市场负责检查和安装更新;通常更新后无需重启 DSH。
|
|
19
20
|
|
|
20
|
-
|
|
21
|
-
不是项目历史 —— 每次发版,把同一次改动一起推到这里。
|
|
21
|
+
### 无头 Host(Linux)
|
|
22
22
|
|
|
23
|
-
|
|
24
|
-
(列表侧从注册表自动采集,条目里不许手写 `npm:`)。改了它要等下一次 `npm publish` 才会体现在注册表上。
|
|
25
|
-
- **要被插件列表(awesome-dsh-plugin)收录**:往那个仓库提一个数据文件
|
|
26
|
-
`data/plugins/<owner>__<repo>.yml` 就行,**一个文件即全部投稿**;`description.en` 必填,分类用 `remote`。
|
|
27
|
-
硬门槛是 `package.json` 里声明 `dsh.bundle`(只声明 `dsh.client` 会被 CI 拒)、仓库创建满 1 天、
|
|
28
|
-
以及本仓库打了 `dsh-plugin` topic。那条「满 1 天」的检查**会自己转绿**(列表侧每 6 小时重跑一次),
|
|
29
|
-
当天看到红不用重提、也不用关了重开。
|
|
30
|
-
- **截图**:市场详情页要图,就在 `package.json` 旁边放一份 `screenshots.json`,列 1-8 张**本仓库内**的图片路径
|
|
31
|
-
(相对路径不能跳出仓库;绝对地址只收 GitHub 托管的 https)。不声明也行,市场会自己从本 README 里找图。
|
|
32
|
-
- **描述会被对着代码核**:条目里写的每个数字和 API 名字都要在代码里找得到。夸大是唯一会被打回的原因 ——
|
|
33
|
-
分类选得不准不会,维护者会直接改。
|
|
23
|
+
需要 Node.js、pnpm,以及可用的 DSH 插件宿主依赖。仓库提供 `host.js` 作为独立入口:
|
|
34
24
|
|
|
35
|
-
|
|
25
|
+
```sh
|
|
26
|
+
pnpm install --frozen-lockfile
|
|
27
|
+
DSH_HOME=/opt/dsh-mobile/.dsh DSH_MOBILE_PROFILE=mobile node host.js
|
|
28
|
+
```
|
|
36
29
|
|
|
37
|
-
|
|
30
|
+
`DSH_HOME` 是数据根目录,默认 `~/.dsh`;`DSH_MOBILE_PROFILE` 是 profile 名称,默认 `mobile`。无头 Host 的 Web 服务默认监听 `127.0.0.1:3080`,不对局域网直接开放。
|
|
38
31
|
|
|
39
|
-
|
|
32
|
+
配置接口仅限本机访问。可用以下地址查看状态和获取配对信息:
|
|
40
33
|
|
|
41
|
-
```
|
|
42
|
-
|
|
43
|
-
|
|
34
|
+
```sh
|
|
35
|
+
curl http://127.0.0.1:3080/api/relay/status
|
|
36
|
+
curl http://127.0.0.1:3080/api/relay/pairing
|
|
44
37
|
```
|
|
45
38
|
|
|
46
|
-
|
|
47
|
-
/opt/dsh-mobile/
|
|
48
|
-
├── dsh-mobile-plugin/
|
|
49
|
-
│ └── dsh-mobile-credentials/ # 嵌在插件目录内,漏掉就启动即崩
|
|
50
|
-
└── dsh-mobile-server/
|
|
51
|
-
```
|
|
39
|
+
更多无头部署方式见下文「无头部署」。
|
|
52
40
|
|
|
53
|
-
|
|
54
|
-
>
|
|
55
|
-
> 本包的 `pnpm-lock.yaml` 是入库的依赖锁,而 pnpm 的 `autoInstallPeers` 会把 peer 冲突
|
|
56
|
-
> 一起解掉:`cordis-plugin-hmr@1.0.19` 要 `@deepseek-ai/cordis@~4.0.4`,锁里解析到的
|
|
57
|
-
> 正是 4.0.4。
|
|
58
|
-
>
|
|
59
|
-
> 这份文档以前教的是 `npm install --legacy-peer-deps`。那个参数只是让 npm 跳过 peer
|
|
60
|
-
> 冲突检查,不是解决冲突;仓库里又没有 `package-lock.json`,也就是说那次安装**什么都
|
|
61
|
-
> 没锁住**——每次干净安装解析出的树都可能不同。
|
|
41
|
+
## 配置与 API
|
|
62
42
|
|
|
63
|
-
|
|
64
|
-
cd dsh-mobile-plugin
|
|
65
|
-
pnpm install --frozen-lockfile # 锁文件与 package.json 不同步时直接失败,而不是悄悄升级
|
|
66
|
-
```
|
|
43
|
+
无头运行时可通过本机接口管理远程连接:
|
|
67
44
|
|
|
68
|
-
|
|
69
|
-
|
|
45
|
+
- `POST /api/relay/toggle`:启用或关闭远程控制。
|
|
46
|
+
- `POST /api/relay/regenerate`:重新生成配对码。
|
|
47
|
+
- `POST /api/relay/config`:设置 `relayUrl`、`lanEnabled`、`lanPort`、`name` 和 `pairingPolicy`。
|
|
48
|
+
- `POST /api/relay/reconnect`:重新连接隧道与局域网代理。
|
|
70
49
|
|
|
71
|
-
|
|
50
|
+
移动 API 需要 Bearer token。设备事件流 `GET /api/mobile/v1/events/stream` 使用 SSE 转发宿主事件。创建会话时可传 `workspaceId` 或 `cwd`;前者优先,并将会话归入指定工作区。移动端排序也会同步到宿主工作区。
|
|
72
51
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
```
|
|
52
|
+
配对策略默认为 `code`(凭配对码放行);设为 `deny` 时拒绝所有新配对。自动归档只隐藏符合条件的闲置会话,不删除日志。`session_cleanup` 工具可归档会话,也可永久删除已归档会话;删除不可恢复,需要显式确认。
|
|
53
|
+
|
|
54
|
+
## 无头部署
|
|
77
55
|
|
|
78
|
-
|
|
79
|
-
决定 profile 名(默认 `mobile`),文件生成在 `$DSH_HOME/profiles/<name>/`。
|
|
80
|
-
|
|
81
|
-
**Web 端口固定 `3080` 且只绑 `127.0.0.1`**(`webserver` 行的端口表达式是 `ctx.webStartup.port ?? 3080`,
|
|
82
|
-
而 `host.js` 传空命令行参数,因此 `--port` 无效)。无头模式的 `web-runtime` 行关闭了
|
|
83
|
-
`openBrowser`/`printUrl`,所以**启动日志不会打印端口**,这不是故障。
|
|
84
|
-
|
|
85
|
-
## 安装与更新
|
|
86
|
-
|
|
87
|
-
本包有两条完全不同的安装路径,别混用。
|
|
88
|
-
|
|
89
|
-
**服务器 / 无头部署:启动器 rsync + 插件本体走 npm。**(2026-10-11 改)那一台是**两半**,别混着看:
|
|
90
|
-
|
|
91
|
-
- **启动器**:`host.js`、两层 patch、`config/`、内嵌的凭据包。把仓库目录 rsync 到 `/opt/dsh-mobile/`,
|
|
92
|
-
然后 `systemctl restart dsh-mobile-host` —— 它只负责把宿主那棵树拼起来。
|
|
93
|
-
- **插件本体**:装在 profile 里,用 pnpm 从 npm 装:
|
|
94
|
-
`cd <DSH_HOME>/profiles/<profile> && pnpm add dsh-mobile-plugin@<版本>`。宿主按**绝对路径**挂那份的
|
|
95
|
-
`lib/index.js`(**稳定外壳**),所以这台面板里也有「检查 / 立即更新」,换代**不用重启宿主**。
|
|
96
|
-
profile 的 `autoInstallPeers: false` 是刻意的:peer 依赖(`@deepseek-ai/*`)由宿主自己的依赖树供,
|
|
97
|
-
两边必须解析到**同一份 realpath**,否则会出现两份 cordis(见 `lib/shell/manager.js` 的文件头)。
|
|
98
|
-
- 挂哪一份由 `host.js` 现场判断(`profile` 里有没有这个包,不看环境变量):装了挂那份外壳;
|
|
99
|
-
没装就退回**源码树直挂**(没有外壳,面板里更新不了,只能同步源码 + 重启宿主)。两种现场都会在
|
|
100
|
-
启动日志里打一行「挂载方式 = …」,`npm run test:plugin:server` 会核对它。
|
|
101
|
-
|
|
102
|
-
**桌面端:从 npm 装,更新交给 DSH 的插件市场。**(2026-10 改的:此前是本包自带的 OTA +
|
|
103
|
-
「稳定外壳 + 可热替换的代」,那套已经删掉。)
|
|
104
|
-
|
|
105
|
-
- 包名 `dsh-mobile-plugin`,凭据库 `dsh-mobile-credentials` **内联在本包里**(`files` 收它),
|
|
106
|
-
所以只发一个包、只有一个版本号要管。
|
|
107
|
-
- 装/更新都在 DSH 里做:**设置 → 插件**(插件市场)里安装或更新本插件,底层就是 profile 里
|
|
108
|
-
跑一次 pnpm。装了之后「设置 → 移动端 → 打开配置页面」那一页的更新卡片会直接调市场的公共
|
|
109
|
-
更新接口(`/dsh-market/api/v1/*`),能在页面上检查更新、开始更新、看进度,并在需要时请
|
|
110
|
-
市场代劳重启 DSH。
|
|
111
|
-
- **更新后不需要重启 DSH**(2026-10-10 起):插件入口是一个稳定外壳,市场在 profile 里装完新版,
|
|
112
|
-
外壳会**就地换到新代码**(先把旧实现卸干净、再装新实现),面板那句「重启 DSH 才生效」不再是必须的。
|
|
113
|
-
代价与边界照实说:① **装了外壳的那一版本身仍需重启一次**(旧版本入口就是实现本身,没有外壳可换),
|
|
114
|
-
从再下一版起才真正免重启;② **外壳自己**(`lib/index.js` + `lib/shell/*`)在进程启动时就被 Node
|
|
115
|
-
缓存了,改它要等下次启动 —— 面板会用 `shellChanged` 把这件事说出来,不让你猜;③ 客户端那半个文件
|
|
116
|
-
(设置菜单里的入口)仍然是渲染器直读安装目录的,如果它改了需要刷新浏览器页面(所以这一半刻意保持
|
|
117
|
-
不变,更新界面都放在宿主吐的面板里)。
|
|
118
|
-
- **自动更新**(面板 → 插件更新卡片里那个开关,默认关):打开后每 10 分钟问一次 DSH 插件市场,
|
|
119
|
-
有新版就自动装好并**立即切换**,全程不需要重启。手动点「更新」走的是同一条路。
|
|
120
|
-
- 有一个前提要认:市场**只从注册表更新**。如果这台机器上的插件是本地目录 / `file:` tgz 装的
|
|
121
|
-
(市场把它报成 `linked`),自动更新会如实跳过并说明原因 —— 要享受这条链,得先在插件市场里按包名
|
|
122
|
-
重装一次,切到 npm 来源。
|
|
123
|
-
- 如果「设置 → 移动端 → 打开配置页面」打不开、页面吐一段 `panel_missing` JSON:那段文案会
|
|
124
|
-
写清是**哪一种**读不到 —— 「装完没重启」(旧实例还挂在这次安装删掉的那个目录上,最常见)、
|
|
125
|
-
「装了老版本」(0.1.47 及更早还没有这一页)还是「真没构建」(源码树里才会遇到)。三种解法
|
|
126
|
-
不同,别照着「没重启」的现场去跑 React 构建。同一句话也会进宿主 stderr。
|
|
127
|
-
- 从前那套的教训记在这里:热更新只换宿主侧的实现代,**换不掉设置菜单那一半**(渲染器直接从
|
|
128
|
-
安装目录读它),于是出现过「更新报成功、界面一动不动」,重启 DSH、重启 sidecar 都没用。
|
|
129
|
-
现在只有一条更新路径,这类「哪一半是旧的」不再存在。
|
|
130
|
-
|
|
131
|
-
### 发版流程(自己发一版更新)
|
|
132
|
-
|
|
133
|
-
1. **抬版本号**:`node scripts/bump-version.mjs`(三段各 `0..999`)。
|
|
134
|
-
2. **改过 `React/` 就先重建面板**:`cd React && pnpm run build`。产物 `lib/panel/index.html`
|
|
135
|
-
是**提交物**,要和源码一起提交 —— 忘了重建,发出去的就是旧页面,而仓库里没有任何东西能
|
|
136
|
-
替你发现(守卫只比字节形状,不比新旧)。
|
|
137
|
-
3. **自检**:`npm test`(其中 `panel-artifact` 管产物形状与「读不到时报什么」,
|
|
138
|
-
`publish-payload` 管发布清单收得住每个会被加载的文件);`npm pack --dry-run` 扫一眼发了什么。
|
|
139
|
-
4. **发布**:`npm publish`。
|
|
140
|
-
5. **核对**:`npm view dsh-mobile-plugin version`。
|
|
141
|
-
6. **到机器上更新**:DSH → 设置 → 插件 里更新,**然后完整重启 DSH**。
|
|
142
|
-
|
|
143
|
-
**发布这一步现在要你现场过二次验证**(2026-10 起,别以为是配错了):
|
|
144
|
-
|
|
145
|
-
- `npm publish` 会打印一条 `https://www.npmjs.com/auth/cli/…` 链接(或直接开浏览器),用账号
|
|
146
|
-
绑定的**安全密钥**确认一下,它自己接着把包发出去。
|
|
147
|
-
- 所以这一步**必须能打开 www.npmjs.com**:机房/代理出口 IP 会被它的反机器人拦成 403
|
|
148
|
-
(页面写「访问暂时受限」并带上你的 IP)。换个节点或直连再发。
|
|
149
|
-
- npm 已经关掉了「命令行建号」和「验证器 App(TOTP)」两条路:建号只能在网页上做,2FA 只能挂
|
|
150
|
-
安全密钥。这两件事上试命令是白费时间,认它就行。
|
|
151
|
-
- **别指望用「绕过 2FA」的令牌跳掉上面这一下**(2026-10-11 实测):换上这把令牌之后 `npm whoami` 能过,但写操作照样要求交互式验证(拿 `npm dist-tag add` 试的),而 npm 2026-07-31 的公告说这类令牌 2027-01 起连直接发布也会失去。要自动化发布得迁到 trusted publishing(OIDC)或 staged publishing,本项目的发布是人在开发机上发,这两条都不顺手。
|
|
152
|
-
- 想少点一次确认,可以建一把「允许绕过 2FA」的细粒度令牌少输几次验证:
|
|
153
|
-
`npm token create --bypass-2fa --packages dsh-mobile-plugin --packages-and-scopes-permission read-write --expires 90`
|
|
154
|
-
(**最长 90 天**)。代价:这类令牌 **2027-01 起不能再直接发布**,到期或到点都得回到上面
|
|
155
|
-
那条交互式路径。
|
|
156
|
-
- 还有个死结记在这里:令牌不能指向**还不存在**的包(会报
|
|
157
|
-
`You do not have access to the requested package(s)`),所以**第一版只能用浏览器验证发**,
|
|
158
|
-
令牌是「包已经存在之后」才有的便利。
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
插件没有 GUI 外壳,配置入口是插件注册在 `webServer` 上的**仅限本机访问**(`localOnly` 守卫)
|
|
162
|
-
的 HTTP 端点。`host.js` 启动后,其 `webServer.port` 就是下面 URL 里的 `<port>`
|
|
163
|
-
(无头部署下固定 `3080`):
|
|
56
|
+
无头服务器的启动器与插件本体分开管理:启动器由部署流程同步到 `/opt/dsh-mobile/`,插件本体安装在 profile 中:
|
|
164
57
|
|
|
165
58
|
```sh
|
|
166
|
-
|
|
167
|
-
|
|
59
|
+
cd <DSH_HOME>/profiles/<profile>
|
|
60
|
+
pnpm add dsh-mobile-plugin@<版本>
|
|
168
61
|
```
|
|
169
62
|
|
|
170
|
-
|
|
171
|
-
- `POST /api/relay/regenerate` —— 轮换配对码
|
|
172
|
-
- `POST /api/relay/config` —— 改写 `relayUrl` / `lanEnabled` / `lanPort` / `name` / `pairingPolicy`(无 GUI 外壳时唯一的配置入口)
|
|
173
|
-
- `POST /api/relay/reconnect` —— 重新握手隧道与局域网代理
|
|
63
|
+
宿主会优先挂载 profile 中安装的插件;未安装时才使用源码树版本。设备凭据库随 npm 包一同提供,不需要单独安装。仓库锁文件应使用 `pnpm install --frozen-lockfile`。
|
|
174
64
|
|
|
175
|
-
|
|
176
|
-
(默认空,即只用局域网)、`lanEnabled`(默认 `true`)、`lanPort`(默认 `18787`)、`name`。
|
|
177
|
-
插件启动后**不向控制台打印配对信息**,只能经上述端点获取。
|
|
65
|
+
> 不要使用 `npm install --legacy-peer-deps` 安装插件;正式安装请使用 DSH 插件市场。
|
|
178
66
|
|
|
179
|
-
##
|
|
67
|
+
## 本机调试 OTA
|
|
180
68
|
|
|
181
|
-
|
|
69
|
+
新版插件的管理面板提供「本机调试 OTA」卡片,可手动检查并安装中转发布的紧急完整包。入口仅限宿主本机访问,不会由手机或公网触发;安装后通过实现代切换生效,切换失败会恢复上一代。npm 仍是正式发布源;后续 npm 正式版本达到 OTA 版本时会接管该版本。首次发布带此功能的 npm 包需要重启一次 DSH,让稳定外壳加载 OTA 能力。
|
|
182
70
|
|
|
183
|
-
|
|
71
|
+
发布 OTA 完整包时,在插件仓库根目录执行 `npm pack`,再按中转服务器的部署说明上传到 `dsh-hub.wnluo.com`。
|
|
184
72
|
|
|
185
|
-
|
|
73
|
+
## 开发
|
|
186
74
|
|
|
187
|
-
|
|
75
|
+
```sh
|
|
76
|
+
pnpm install --frozen-lockfile
|
|
77
|
+
npm test
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
修改 `React/` 面板源码后,先在该目录运行 `pnpm run build`,并将生成的 `lib/panel/index.html` 与源码一起提交。
|
|
188
81
|
|
|
189
|
-
|
|
82
|
+
## 仓库与发布
|
|
190
83
|
|
|
191
|
-
|
|
84
|
+
本仓库是插件的公开发布仓库,仓库根目录就是 npm 包根目录。开发与 App、中转服务器位于私有 monorepo,因此公开仓库的提交历史从拆分时开始;发布时需将同一次改动同步到公开仓库。
|
|
192
85
|
|
|
193
|
-
|
|
86
|
+
- npm 包的 `repository` 字段用于关联插件列表条目;修改后需再次发布,注册表信息才会更新。
|
|
87
|
+
- 插件列表收录通过向 awesome-dsh-plugin 提交 `data/plugins/<owner>__<repo>.yml` 完成。描述中的数字和 API 名称应能由代码核实;列表要求 `description.en`、`category: remote`,包需声明 `dsh.bundle`,仓库需有 `dsh-plugin` topic。
|
|
88
|
+
- 仓库创建满一天的检查会自动重跑;截图可通过 `screenshots.json` 提供,也可由市场从 README 查找。
|
|
89
|
+
- 隐私政策:[PRIVACY.md](PRIVACY.md)。技术支持与常见问题:[SUPPORT.md](SUPPORT.md)。
|
|
194
90
|
|
|
195
|
-
|
|
196
|
-
| --- | --- |
|
|
197
|
-
| `code`(默认) | 凭配对码放行 |
|
|
198
|
-
| `deny` | 拒绝一切新配对 |
|
|
91
|
+
### 发布新版本
|
|
199
92
|
|
|
200
|
-
|
|
93
|
+
1. 运行 `node scripts/bump-version.mjs` 更新版本号。
|
|
94
|
+
2. 若修改了 `React/`,运行 `cd React && pnpm run build`。
|
|
95
|
+
3. 运行 `npm test`,并用 `npm pack --dry-run` 检查发布文件。
|
|
96
|
+
4. 运行 `npm publish`,按提示完成 npm 二次验证。
|
|
97
|
+
5. 用 `npm view dsh-mobile-plugin version` 确认注册表版本,再同步改动到公开仓库。
|
|
98
|
+
|
|
99
|
+
## 远程控制元数据
|
|
201
100
|
|
|
202
|
-
|
|
101
|
+
配对请求可携带设备信息,App 当前发送 `id`、`deviceId`、`name`、`platform`、`osVersion` 和 `appVersion`;局域网代理与中转会补充观察到的手机 IP。
|
package/lib/index.js
CHANGED
|
@@ -15,16 +15,11 @@
|
|
|
15
15
|
* 这一层同时是**故障边界**:代装载失败时外壳回滚到上一代并把错误暴露出去,而不是让整个
|
|
16
16
|
* 插件从宿主里消失(那会让手机连不上,且日志里只剩一行 pending)。
|
|
17
17
|
*
|
|
18
|
-
* ##
|
|
18
|
+
* ## 更新来源
|
|
19
19
|
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
* realpath 重新 import 一代即可,**不需要重启 DSH**。自动更新(默认关)按 `AUTO_INTERVAL_MS`
|
|
24
|
-
* 轮询,见 `shell/updater.js`。
|
|
25
|
-
*
|
|
26
|
-
* 这里那个 `updateState` 对象记的是「最近一次查/装的结果」。名字里别再叫 market:里面那份
|
|
27
|
-
* 市场信息(`marketResult`)只是外壳从市场那条老路继承下来的兜底,装的时候用不到它。
|
|
20
|
+
* 正式 npm 更新由 `shell/updater.js` 处理(自动安装默认关闭);紧急完整包由
|
|
21
|
+
* `shell/device-ota.js` 经本机控制面激活,正式版成功接管前保留 OTA 回退包。
|
|
22
|
+
* `updateState` 记录 npm 查装结果;`marketResult` 是旧外壳保留的兜底状态。
|
|
28
23
|
*/
|
|
29
24
|
|
|
30
25
|
import path from 'node:path'
|
|
@@ -32,6 +27,7 @@ import { fileURLToPath } from 'node:url'
|
|
|
32
27
|
import { createRuntime } from './shell/runtime.js'
|
|
33
28
|
import * as updater from './shell/updater.js'
|
|
34
29
|
import { installedPackageDir, installedVersion, profileNodeModules } from './shell/manager.js'
|
|
30
|
+
import { createDeviceOta } from './shell/device-ota.js'
|
|
35
31
|
|
|
36
32
|
export const name = 'dsh-mobile-plugin'
|
|
37
33
|
export const inject = ['webServer', 'sessions']
|
|
@@ -45,6 +41,7 @@ export const inject = ['webServer', 'sessions']
|
|
|
45
41
|
*/
|
|
46
42
|
export function apply(ctx) {
|
|
47
43
|
const runtime = createRuntime()
|
|
44
|
+
const deviceOta = createDeviceOta({ installed: installedVersion, runtime })
|
|
48
45
|
// 跑着的外壳的指纹。它必须在**启动时**算:之后盘上那份可能已经被市场换掉了。
|
|
49
46
|
const runningShellHash = updater.shellFingerprint(path.dirname(path.dirname(fileURLToPath(import.meta.url))))
|
|
50
47
|
const updateState = {
|
|
@@ -70,6 +67,7 @@ export function apply(ctx) {
|
|
|
70
67
|
if (updateState.checking) return updateState.result
|
|
71
68
|
updateState.checking = true
|
|
72
69
|
try {
|
|
70
|
+
await deviceOta.reconcileOfficial()
|
|
73
71
|
updateState.result = await updater.checkUpdate(base())
|
|
74
72
|
updateState.lastError = null
|
|
75
73
|
} catch (error) {
|
|
@@ -153,6 +151,7 @@ export function apply(ctx) {
|
|
|
153
151
|
check,
|
|
154
152
|
apply: applyUpdate,
|
|
155
153
|
reload: version => runtime.reload(version),
|
|
154
|
+
deviceOta,
|
|
156
155
|
setAutoUpdate: enabled => {
|
|
157
156
|
const value = updater.setAutoUpdate(enabled)
|
|
158
157
|
log('info', 'dsh-mobile-plugin: 自动更新已%s', value ? '开启' : '关闭')
|
|
@@ -164,13 +163,14 @@ export function apply(ctx) {
|
|
|
164
163
|
.start(ctx)
|
|
165
164
|
.then(() => {
|
|
166
165
|
log('info', 'dsh-mobile-plugin: 已装载 %s(来源 %s)', runtime.status().version, runtime.status().source)
|
|
167
|
-
//
|
|
166
|
+
// 每 10 分钟让已落盘的正式版接管 OTA;自动安装仍由开关控制。
|
|
168
167
|
const timer = setInterval(() => {
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
168
|
+
deviceOta.reconcileOfficial()
|
|
169
|
+
.then(() => updater.autoUpdateEnabled() ? applyUpdate() : null)
|
|
170
|
+
.catch(error => {
|
|
171
|
+
updateState.lastError = String(error?.message || error)
|
|
172
|
+
log('warn', 'dsh-mobile-plugin: 更新检查失败:%s', updateState.lastError)
|
|
173
|
+
})
|
|
174
174
|
}, updater.AUTO_INTERVAL_MS)
|
|
175
175
|
if (typeof timer.unref === 'function') timer.unref()
|
|
176
176
|
ctx.effect(() => () => clearInterval(timer), 'dsh-mobile-plugin: auto update timer')
|
|
@@ -6,20 +6,25 @@
|
|
|
6
6
|
* 以 workspace.list 的 archivedSessionIds 为清理范围,不用可能过时的列表状态缩小范围;
|
|
7
7
|
* 请求必须显式带 confirm: true —— 磁盘删除不可恢复,手机端「少点一次」不值得把它变成滑一下就没了的动作。
|
|
8
8
|
*
|
|
9
|
-
* 删除动作不重写:读日志 header.id →
|
|
9
|
+
* 删除动作不重写:读日志 header.id → 删会话目录 + 投影缓存 → 擦归档集合这套只在 session-cleanup.cjs
|
|
10
10
|
* 里存在(见地图 A3-17),这里只是把它的触发面从 agent 工具扩到移动端路由。
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
13
|
const { sendJson, readJsonBody } = require('../shared/http-util');
|
|
14
14
|
const { withIdempotency, requestIdOf } = require('./idempotency.js');
|
|
15
|
-
const {
|
|
15
|
+
const {
|
|
16
|
+
canonicalDeviceKey,
|
|
17
|
+
rememberDeletedSessionIds,
|
|
18
|
+
pendingUnarchiveSessionIdsOf,
|
|
19
|
+
clearPendingUnarchiveSessionIds,
|
|
20
|
+
} = require('./session-cache.js');
|
|
16
21
|
const { invalidateSessionList } = require('./sessions.js');
|
|
17
22
|
const { unwrap } = require('./envelope.js');
|
|
18
23
|
const { deleteSessionData, forgetArchivedSessions } = require('../session-cleanup.cjs');
|
|
19
24
|
|
|
20
25
|
/**
|
|
21
26
|
* POST /api/mobile/v1/workspace/clear-archived(token)
|
|
22
|
-
* body: { confirm: true } → { ok: true, deleted: N }。confirm 不为 true 一律 400。
|
|
27
|
+
* body: { confirm: true } → { ok: true, deleted: N, unarchiveFailedSessionIds }。confirm 不为 true 一律 400。
|
|
23
28
|
*/
|
|
24
29
|
async function handleClearArchived(ctx, req, res) {
|
|
25
30
|
const body = await readJsonBody(req, 64 * 1024);
|
|
@@ -36,14 +41,35 @@ async function handleClearArchived(ctx, req, res) {
|
|
|
36
41
|
const archivedIds = Array.isArray(workspaceResponse.value?.archivedSessionIds) ? workspaceResponse.value.archivedSessionIds : [];
|
|
37
42
|
const selected = archivedIds.filter((id) => typeof id === 'string' && id);
|
|
38
43
|
if (selected.length === 0) return { status: 200, body: { ok: true, deleted: 0, archivedSessionIds: archivedIds } };
|
|
39
|
-
//
|
|
40
|
-
//
|
|
41
|
-
|
|
44
|
+
// 顺序是**先删日志、再擦归档集合**。删日志才是用户要的那件事,擦集合只是收尾。
|
|
45
|
+
//
|
|
46
|
+
// 反过来(先擦后删)曾导致用户报的现象:擦归档是逐条 RPC,中途任何一条失败或链路超时,
|
|
47
|
+
// 日志一份没删、名字却已经从归档集合里消失——会话退回普通列表,看着像「清理只是把它
|
|
48
|
+
// 移出了归档」,必须重启桌面端才恢复。先删就没这个窗口:日志没了就是**真的清理掉了**,
|
|
49
|
+
// 立刻生效、不必等桌面端重启;擦集合失败最多在「已归档」里留一行点不开的死行,
|
|
50
|
+
// 重试一次即可,不会让操作白做。
|
|
51
|
+
//
|
|
52
|
+
// 擦集合是 best-effort:它失败**不**让整条清理报错(日志已经删成功,报错等于骗用户)。
|
|
42
53
|
const removed = await deleteSessionData(selected);
|
|
54
|
+
rememberDeletedSessionIds(ctx, removed);
|
|
55
|
+
const pendingUnarchive = pendingUnarchiveSessionIdsOf(ctx);
|
|
56
|
+
const retryable = [...new Set(selected)].filter((id) => pendingUnarchive.has(id));
|
|
57
|
+
const failedToUnarchive = await forgetArchivedSessions(ctx, retryable, 'mobile');
|
|
58
|
+
const failed = new Set(failedToUnarchive);
|
|
59
|
+
clearPendingUnarchiveSessionIds(ctx, retryable.filter((id) => !failed.has(id)));
|
|
43
60
|
// 删的是列表里可见的行:手机端正开着列表,不立刻失效最长 10 秒还能点到死行。
|
|
44
61
|
invalidateSessionList(ctx);
|
|
45
|
-
|
|
46
|
-
|
|
62
|
+
// 只从返回的归档集合中移除宿主确认已取消归档的 ID;失败项保留为重试候选。
|
|
63
|
+
const cleared = new Set(retryable.filter((id) => !failed.has(id)));
|
|
64
|
+
return {
|
|
65
|
+
status: 200,
|
|
66
|
+
body: {
|
|
67
|
+
ok: true,
|
|
68
|
+
deleted: removed.length,
|
|
69
|
+
archivedSessionIds: archivedIds.filter((id) => !cleared.has(id)),
|
|
70
|
+
unarchiveFailedSessionIds: failedToUnarchive,
|
|
71
|
+
},
|
|
72
|
+
};
|
|
47
73
|
});
|
|
48
74
|
sendJson(res, outcome.status, outcome.body);
|
|
49
75
|
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/** Read-only DeepSeek account projection for the mobile app. */
|
|
4
|
+
|
|
5
|
+
const { sendJson } = require('../shared/http-util');
|
|
6
|
+
const { unwrap } = require('./envelope.js');
|
|
7
|
+
|
|
8
|
+
function readClientMetadata(req) {
|
|
9
|
+
let query;
|
|
10
|
+
try {
|
|
11
|
+
query = new URL(req.url, 'http://127.0.0.1').searchParams;
|
|
12
|
+
} catch {
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
15
|
+
const version = query.get('version');
|
|
16
|
+
const locale = query.get('locale');
|
|
17
|
+
const timezoneOffset = query.get('timezoneOffsetSeconds');
|
|
18
|
+
const timezoneOffsetSeconds = Number(timezoneOffset);
|
|
19
|
+
if (!version || !locale || timezoneOffset === null || !timezoneOffset.trim() || !Number.isInteger(timezoneOffsetSeconds)) return null;
|
|
20
|
+
return { version, locale, timezoneOffsetSeconds };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async function callAccount(ctx, method, payload = {}) {
|
|
24
|
+
try {
|
|
25
|
+
const call = ctx.apiProxy && ctx.apiProxy.account && ctx.apiProxy.account[method];
|
|
26
|
+
if (typeof call !== 'function') return { error: { code: 'host-unavailable' } };
|
|
27
|
+
return unwrap(await call({
|
|
28
|
+
rpcId: 'mobile',
|
|
29
|
+
method: `account.${method}`,
|
|
30
|
+
payload,
|
|
31
|
+
}));
|
|
32
|
+
} catch {
|
|
33
|
+
return { error: { code: 'host-unavailable' } };
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function textOrNull(value) {
|
|
38
|
+
return typeof value === 'string' && value.trim() ? value.trim() : null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function normalizeProfile(result) {
|
|
42
|
+
const profile = result && result.status === 'ready' ? result.value : null;
|
|
43
|
+
if (!profile || typeof profile !== 'object') return { status: 'failed' };
|
|
44
|
+
return {
|
|
45
|
+
status: 'ready',
|
|
46
|
+
name: textOrNull(profile.name),
|
|
47
|
+
contact: textOrNull(profile.contact),
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function normalizeWallets(result) {
|
|
52
|
+
if (!result || result.status !== 'ready') return { status: 'failed' };
|
|
53
|
+
const walletsOf = (value) => Array.isArray(value)
|
|
54
|
+
? value.filter((wallet) => wallet && (wallet.currency === 'CNY' || wallet.currency === 'USD') && typeof wallet.balance === 'string')
|
|
55
|
+
.map((wallet) => ({ currency: wallet.currency, balance: wallet.balance }))
|
|
56
|
+
: [];
|
|
57
|
+
return {
|
|
58
|
+
status: 'ready',
|
|
59
|
+
wallets: walletsOf(result.value),
|
|
60
|
+
bonusWallets: walletsOf(result.bonusWallets),
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function safeLinks(state) {
|
|
65
|
+
const links = state && state.links;
|
|
66
|
+
return {
|
|
67
|
+
usageUrl: textOrNull(links && links.usageUrl),
|
|
68
|
+
topUpUrl: textOrNull(links && links.topUpUrl),
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** GET /api/mobile/v1/deepseek-account — safe account details only; never login state or credentials. */
|
|
73
|
+
async function handleDeepSeekAccount(ctx, req, res) {
|
|
74
|
+
const client = readClientMetadata(req);
|
|
75
|
+
if (!client) {
|
|
76
|
+
return sendJson(res, 400, {
|
|
77
|
+
ok: false,
|
|
78
|
+
error: { code: 'bad-request', message: '需要 version、locale 和 timezoneOffsetSeconds' },
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const stateResult = await callAccount(ctx, 'getState');
|
|
83
|
+
if (stateResult.error) {
|
|
84
|
+
return sendJson(res, 502, {
|
|
85
|
+
ok: false,
|
|
86
|
+
error: { code: stateResult.error.code || 'host-unavailable', message: '这台宿主暂时无法读取 DeepSeek 账号信息' },
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const state = stateResult.value;
|
|
91
|
+
if (!state || (state.status !== 'signed-out' && state.status !== 'credential-stored')) {
|
|
92
|
+
return sendJson(res, 502, {
|
|
93
|
+
ok: false,
|
|
94
|
+
error: { code: 'host-response-invalid', message: '宿主返回的 DeepSeek 账号状态无效' },
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const links = safeLinks(state);
|
|
99
|
+
if (state.status === 'signed-out') {
|
|
100
|
+
return sendJson(res, 200, {
|
|
101
|
+
ok: true,
|
|
102
|
+
status: 'signed-out',
|
|
103
|
+
profile: { status: 'unavailable' },
|
|
104
|
+
balance: { status: 'unavailable' },
|
|
105
|
+
links,
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const [profileResult, balanceResult] = await Promise.all([
|
|
110
|
+
callAccount(ctx, 'getProfile', { client }),
|
|
111
|
+
callAccount(ctx, 'getBalance', { client }),
|
|
112
|
+
]);
|
|
113
|
+
const profile = normalizeProfile(profileResult.value);
|
|
114
|
+
const balance = normalizeWallets(balanceResult.value);
|
|
115
|
+
|
|
116
|
+
return sendJson(res, 200, {
|
|
117
|
+
ok: true,
|
|
118
|
+
status: 'credential-stored',
|
|
119
|
+
profile: profileResult.error ? { status: 'failed' } : profile,
|
|
120
|
+
balance: balanceResult.error ? { status: 'failed' } : balance,
|
|
121
|
+
links,
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
module.exports = { handleDeepSeekAccount, readClientMetadata, normalizeProfile, normalizeWallets, safeLinks };
|
|
@@ -136,6 +136,11 @@ const ENDPOINTS = {
|
|
|
136
136
|
'goals.complete': (ctx, payload, signal) => goalAction(ctx, 'complete', payload, signal),
|
|
137
137
|
'goals.clear': (ctx, payload, signal) => goalAction(ctx, 'clear', payload, signal),
|
|
138
138
|
|
|
139
|
+
// account: mobile can inspect the stored DeepSeek account and its safe profile/balances only.
|
|
140
|
+
'account.getState': inv('account', 'getState', () => ({})),
|
|
141
|
+
'account.getProfile': inv('account', 'getProfile', (p) => ({ client: p.client })),
|
|
142
|
+
'account.getBalance': inv('account', 'getBalance', (p) => ({ client: p.client })),
|
|
143
|
+
|
|
139
144
|
// settings / llm / credentials
|
|
140
145
|
'settings.describe': inv('settings', 'describe', () => ({})),
|
|
141
146
|
'settings.mutate': inv('settings', 'mutate', (p) => pick(p, ['ns', 'ops', 'expectedRevision'])),
|
|
@@ -21,6 +21,7 @@ const sessionModelsCache = new WeakMap();
|
|
|
21
21
|
const MAX_SESSION_MODEL_CACHE_ENTRIES = 256;
|
|
22
22
|
|
|
23
23
|
const sessionListResponseCache = new WeakMap();
|
|
24
|
+
const deletedSessionState = new WeakMap();
|
|
24
25
|
const mirrorHistoryCache = new WeakMap();
|
|
25
26
|
// Cache only the derived last-message preview. The full history stays outside
|
|
26
27
|
// the cache so repeated mobile list refreshes do not re-read/decompress logs.
|
|
@@ -81,6 +82,37 @@ function sessionListResponseState(ctx, deviceKey = 'legacy') {
|
|
|
81
82
|
return state;
|
|
82
83
|
}
|
|
83
84
|
|
|
85
|
+
function sessionCleanupStateOf(ctx) {
|
|
86
|
+
let state = deletedSessionState.get(ctx);
|
|
87
|
+
if (!state) {
|
|
88
|
+
state = { deleted: new Set(), pendingUnarchive: new Set() };
|
|
89
|
+
deletedSessionState.set(ctx, state);
|
|
90
|
+
}
|
|
91
|
+
return state;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function rememberDeletedSessionIds(ctx, sessionIds) {
|
|
95
|
+
const state = sessionCleanupStateOf(ctx);
|
|
96
|
+
for (const sessionId of sessionIds) {
|
|
97
|
+
if (typeof sessionId !== 'string' || !sessionId) continue;
|
|
98
|
+
state.deleted.add(sessionId);
|
|
99
|
+
state.pendingUnarchive.add(sessionId);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function deletedSessionIdsOf(ctx) {
|
|
104
|
+
return sessionCleanupStateOf(ctx).deleted;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function pendingUnarchiveSessionIdsOf(ctx) {
|
|
108
|
+
return sessionCleanupStateOf(ctx).pendingUnarchive;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function clearPendingUnarchiveSessionIds(ctx, sessionIds) {
|
|
112
|
+
const pending = sessionCleanupStateOf(ctx).pendingUnarchive;
|
|
113
|
+
for (const sessionId of sessionIds) pending.delete(sessionId);
|
|
114
|
+
}
|
|
115
|
+
|
|
84
116
|
function sessionPreviewStateOf(ctx) {
|
|
85
117
|
let state = sessionPreviewCache.get(ctx);
|
|
86
118
|
if (!state) {
|
|
@@ -249,6 +281,10 @@ module.exports = {
|
|
|
249
281
|
invalidateSessionModels,
|
|
250
282
|
sessionListResponseState,
|
|
251
283
|
invalidateSessionList,
|
|
284
|
+
rememberDeletedSessionIds,
|
|
285
|
+
deletedSessionIdsOf,
|
|
286
|
+
pendingUnarchiveSessionIdsOf,
|
|
287
|
+
clearPendingUnarchiveSessionIds,
|
|
252
288
|
dropSessionListCache,
|
|
253
289
|
sessionPreviewStateOf,
|
|
254
290
|
sessionPreviewInFlightStateOf,
|