dsh-mobile-plugin 0.4.1 → 0.4.4

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.
Files changed (41) hide show
  1. package/README.md +71 -74
  2. package/dsh-mobile-credentials/lib/credentials.js +39 -16
  3. package/lib/impl.js +0 -4
  4. package/lib/index.js +1 -1
  5. package/lib/mobile/allowed-zone.js +100 -0
  6. package/lib/mobile/archive-clean.js +4 -3
  7. package/lib/mobile/attachments.js +5 -1
  8. package/lib/mobile/file-read.js +168 -0
  9. package/lib/mobile/history-derive.js +67 -98
  10. package/lib/mobile/local-images.js +38 -7
  11. package/lib/mobile/model-vendors.js +105 -0
  12. package/lib/mobile/plugin-market.js +475 -0
  13. package/lib/mobile/plugin-self.js +1 -1
  14. package/lib/mobile/plugin-update.js +1 -1
  15. package/lib/mobile/relay-control.js +131 -7
  16. package/lib/mobile/session-cwd.js +28 -0
  17. package/lib/mobile/session-file.js +1 -12
  18. package/lib/mobile/stats-collect.cjs +146 -15
  19. package/lib/mobile/stats-ledger.cjs +249 -59
  20. package/lib/mobile/stats-scheduler.cjs +41 -0
  21. package/lib/mobile/stream.js +30 -110
  22. package/lib/mobile/uploads.js +68 -5
  23. package/lib/mobile/vendor-logos.cjs +30 -0
  24. package/lib/mobile/wire-cut.js +212 -0
  25. package/lib/panel/index.html +2 -2
  26. package/lib/relay/apply-ledger.cjs +52 -0
  27. package/lib/relay/core.js +14 -3
  28. package/lib/relay/migrate.js +90 -0
  29. package/lib/relay/protocol.js +4 -0
  30. package/lib/relay/verify.js +52 -0
  31. package/lib/routes/control-plane.js +12 -3
  32. package/lib/routes/mobile-api.js +28 -2
  33. package/lib/session-cleanup.cjs +10 -0
  34. package/lib/shared/device-headers.js +12 -4
  35. package/lib/shell/updater.js +2 -2
  36. package/lib/sidecar-facade-worker.cjs +55 -8
  37. package/lib/sidecar-facade.cjs +11 -1
  38. package/lib/sidecar-lifecycle.cjs +14 -1
  39. package/lib/update-install.cjs +13 -6
  40. package/package.json +1 -1
  41. package/lib/auto-archive.cjs +0 -98
package/README.md CHANGED
@@ -1,14 +1,14 @@
1
1
  <h3 align="center">AI-HUB 移动端插件</h3>
2
2
 
3
3
  <p align="center">
4
- <img src="https://raw.githubusercontent.com/WNLUO/dsh-mobile-plugin/main/assets/banner.png" alt="AI-HUB:用手机接着电脑上的 DSH 往下用" width="100%">
4
+ <img src="assets/banner.jpg" alt="AI-HUB:手机和平板上的 DSH 会话界面" width="760">
5
5
  </p>
6
6
 
7
7
  <p align="center">
8
+ <a href="https://app.wnluo.com/public/apps/14"><img alt="安卓 分发下载" src="https://img.shields.io/badge/%E5%AE%89%E5%8D%93-%E5%88%86%E5%8F%91%E4%B8%8B%E8%BD%BD-3DDC84?style=for-the-badge&logo=android&logoColor=white"></a>
9
+ <a href="https://github.com/WNLUO/dsh-mobile-plugin/releases/latest/download/ai-hub-android.apk"><img alt="安卓 GitHub 下载" src="https://img.shields.io/badge/%E5%AE%89%E5%8D%93-GitHub%E4%B8%8B%E8%BD%BD-181717?style=for-the-badge&logo=github"></a>
8
10
  <a href="https://apps.apple.com/us/app/ai-hub/id6821681438"><img alt="App Store" src="https://img.shields.io/badge/App_Store-%E4%B8%8B%E8%BD%BD-black?style=for-the-badge&logo=appstore&logoColor=white"></a>
9
- <a href="https://app.wnluo.com/public/apps/14"><img alt="Android APK" src="https://img.shields.io/badge/Android_APK-%E4%B8%8B%E8%BD%BD-3DDC84?style=for-the-badge&logo=android&logoColor=white"></a>
10
- <a href="https://github.com/WNLUO/dsh-mobile-plugin/releases/latest/download/ai-hub-android.apk"><img alt="GitHub APK" src="https://img.shields.io/badge/GitHub_APK-%E6%9C%80%E6%96%B0%E7%89%88-181717?style=for-the-badge&logo=github"></a>
11
- <a href="https://testflight.apple.com/join/4fn5PCMD"><img alt="TestFlight" src="https://img.shields.io/badge/TestFlight-%E6%B5%8B%E8%AF%95%E7%89%88-0D96F6?style=for-the-badge&logo=apple&logoColor=white"></a>
11
+ <a href="https://testflight.apple.com/join/4fn5PCMD"><img alt="苹果测试版 下载" src="https://img.shields.io/badge/%E8%8B%B9%E6%9E%9C%E6%B5%8B%E8%AF%95%E7%89%88-%E4%B8%8B%E8%BD%BD-0D96F6?style=for-the-badge&logo=apple&logoColor=white"></a>
12
12
  </p>
13
13
 
14
14
  <p align="center">
@@ -22,25 +22,46 @@
22
22
 
23
23
  **AI-HUB 的手机端插件。** 装在电脑这边,手机 App 就通过一条中转隧道或局域网配对连进你的 DSH —— 看会话、接着聊、管理文件和工作区,进度实时同步。它也可以脱离桌面外壳单独跑在服务器上,当作无头宿主用。
24
24
 
25
- ## 🌟 功能
25
+ <p align="center">
26
+ <picture>
27
+ <source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/WNLUO/dsh-mobile-plugin/main/assets/connection-dark.svg">
28
+ <img src="https://raw.githubusercontent.com/WNLUO/dsh-mobile-plugin/main/assets/connection-light.svg" alt="手机 App 经中转或局域网连到你电脑上的 DSH" width="720">
29
+ </picture>
30
+ </p>
31
+
32
+ 手机连的是**你自己那台电脑上的 DSH**,不是某个云端服务。电脑不用公网 IP,也不用在路由器上开端口:插件从内网主动往外建一条隧道,手机沿这条路回来。
33
+
34
+ ## 功能
35
+
36
+ ### 会话与工作区
26
37
 
27
- - 🚀 **两种连法**:经 relay v2 隧道从公网连回来,或者在同一个 Wi-Fi 下走局域网代理直连。
28
- - 📱 **受限的移动 API**:`/api/mobile/v1/*` 只开手机需要的口子 —— 不暴露 shell、系统设置与任意文件写入。
29
- - 🗂️ **会话与工作区**:新建会话、归入指定工作区,手机端排的顺序也会同步回电脑。
30
- - 📡 **实时事件**:设备事件 SSE 全程转发,手机上看到的就是电脑那边的进度。
31
- - 🧹 **自动归档**:闲置会话自动收进归档,只从列表里隐藏,日志不删。
32
- - 🔐 **配对与凭据**:本机配置接口、可选的配对策略,设备凭据可以单独吊销。
38
+ - 新建、接着聊、改名、搜索会话,也能从任意一条分出分支单独往下走。
39
+ - 工作区建、改名、删除、置顶、归档都在手机上完成,手机端排的顺序会同步回电脑。
40
+ - 回复逐块流式到达,跑到一半就能看见内容,工具调用也是实时的。
41
+ - 空闲会话自动进归档,列表里不再占位,日志不删。
42
+ - 手机上读过,电脑那边的未读绿点跟着消失;电脑侧边栏的展开、折叠状态同步到手机,两边结构一样。
33
43
 
34
- ## 📥 下载 AI-HUB 手机 App
44
+ ### 不用回电脑也能操作
35
45
 
36
- - [App Store](https://apps.apple.com/us/app/ai-hub/id6821681438)(正式版,已在架)
37
- - [Android APK](https://app.wnluo.com/public/apps/14)
38
- - [Android APK(GitHub 渠道)](https://github.com/WNLUO/dsh-mobile-plugin/releases/latest/download/ai-hub-android.apk);[历史版本](https://github.com/WNLUO/dsh-mobile-plugin/releases)
39
- - [TestFlight 测试版](https://testflight.apple.com/join/4fn5PCMD)(比商店版先拿到新构建,正式版照常从 App Store 更新)
46
+ - **审批与提问**:电脑上会弹的卡手机同样有,权限请求、选择题、多选、自定义回答都能直接点。
47
+ - **排队插话**:正在跑的时候补一句,按队列排在当前这一步之后。
48
+ - **Git**:看状态和 diff、暂存、提交、推送、切分支,提交信息可以先让模型写。
49
+ - **文件**:浏览目录、读文件,也能把手机上的文件传回电脑,落在固定的收件目录。
50
+ - **插件**:手机上的插件市场里搜索、安装、更新、卸载;插件自己也能升级到新版。
51
+ - **模型**:随时换模型,看有哪些渠道和可用模型,本机自建的渠道也能直接在手机上配。
52
+ - **日程与子代理**:查看定时任务、删掉不要的;看正在跑的子代理,打断它或者补一句话。
40
53
 
41
- 装好 App 只有一半,另一半是这个插件。
54
+ ### 连接
42
55
 
43
- ## 🚀 安装与更新
56
+ - 两种连法:经 relay v2 隧道从公网连回来,或者同一个 Wi-Fi 下走局域网直连。
57
+ - 配对码与配对策略可选,设备凭据能单独吊销,也能一次把全部撤掉。
58
+ - iOS 离线推送可选,不配也不影响其它功能。
59
+
60
+ 移动 API 只开手机需要的口子(`/api/mobile/v1/*`)—— 不暴露 shell、系统设置与任意文件写入。
61
+
62
+ ## 安装与更新
63
+
64
+ 装好手机 App 只是第一步,插件装在这边才算接通。
44
65
 
45
66
  ### 桌面端
46
67
 
@@ -64,9 +85,20 @@ curl http://127.0.0.1:3080/api/relay/status
64
85
  curl http://127.0.0.1:3080/api/relay/pairing
65
86
  ```
66
87
 
67
- 更多的部署方式见下面的「无头部署」。
88
+ ### 无头部署
89
+
90
+ 无头服务器上,启动器和插件本体是分开管的:启动器由部署流程同步到 `/opt/dsh-mobile/`,插件本体装进 profile:
91
+
92
+ ```sh
93
+ cd <DSH_HOME>/profiles/<profile>
94
+ pnpm add dsh-mobile-plugin@<版本>
95
+ ```
96
+
97
+ 宿主优先挂 profile 里装的那份,没装才用源码树版本。设备凭据库随 npm 包一起发,不用单独装。仓库锁文件用 `pnpm install --frozen-lockfile`。
68
98
 
69
- ## 🔌 配置与 API
99
+ > 别用 `npm install --legacy-peer-deps` 装插件;正式安装走 DSH 插件市场。
100
+
101
+ ## 配置与 API
70
102
 
71
103
  无头运行时用这几个本机接口管远程连接:
72
104
 
@@ -77,42 +109,37 @@ curl http://127.0.0.1:3080/api/relay/pairing
77
109
 
78
110
  移动 API 要 Bearer token。设备事件流 `GET /api/mobile/v1/events/stream` 用 SSE 转发宿主事件。建会话时可以传 `workspaceId` 或 `cwd`,前者优先,并会把会话归进那个工作区;手机端的排序同样会同步到宿主工作区。
79
111
 
80
- 配对策略默认 `code`,凭配对码放行;设成 `deny` 就拒绝所有新配对。
112
+ 配对策略默认 `code`,凭配对码放行;设成 `deny` 就拒绝所有新配对。配对请求可以带设备信息,App 会发 `id`、`deviceId`、`name`、`platform`、`osVersion` 和 `appVersion`,局域网代理与中转会补上它们观察到的手机 IP。
81
113
 
82
- `session_cleanup` 工具能归档会话,也能永久删除归档过的会话,删除前要显式确认,删掉不可恢复。**删除是排队的**:会话立刻从列表里消失,日志和投影缓存等宿主下次启动才从磁盘上删 —— 宿主没有「删除会话」的接口,进程活着的时候从它脚底下删文件,它会打开一个已经不在的日志,那是未捕获异常,会带走整个宿主进程。
114
+ `session_cleanup` 能归档会话,也能永久删除归档过的会话,删除前要显式确认,删掉不可恢复。**删除是排队的**:会话立刻从列表里消失,日志和投影缓存等宿主下次启动才从磁盘上清掉 —— 宿主没有「删除会话」的接口,运行中从它脚底下删文件会带走整个宿主进程。
83
115
 
84
- ## 🖥️ 无头部署
116
+ ## 为什么做这个
85
117
 
86
- 无头服务器上,启动器和插件本体是分开管的:启动器由部署流程同步到 `/opt/dsh-mobile/`,插件本体装进 profile:
118
+ DSH 跑在电脑上,但我不是一直坐在电脑前。出门、在别的房间、躺床上,想知道刚才那个任务跑到哪一步了,或者它正卡在一个要我点的审批上,只能走回去。
87
119
 
88
- ```sh
89
- cd <DSH_HOME>/profiles/<profile>
90
- pnpm add dsh-mobile-plugin@<版本>
91
- ```
120
+ 这个插件的出发点就这么窄:不是把整个 DSH 塞进手机屏幕,而是让人离开电脑之后,还能看见、还能接着聊、还能把该点的那一下点掉。
92
121
 
93
- 宿主优先挂 profile 里装的那份,没装才用源码树版本。设备凭据库随 npm 包一起发,不用单独装。仓库锁文件用 `pnpm install --frozen-lockfile`。
122
+ 所以有两条线是硬的。一条是网络:不要求公网 IP、不要在路由器上开端口、不要为了用一次去配 DDNS,插件从内网主动往外出,手机顺着回来。另一条是能力:手机上只开需要的那几个口子,能看会话、能审批、能传文件、能改点配置,但不把 shell 和任意文件写入递到手机上 —— 手机丢了或者被人捡到,损失是可估的。
94
123
 
95
- > 别用 `npm install --legacy-peer-deps` 装插件;正式安装走 DSH 插件市场。
124
+ ## 和别的方案比
96
125
 
97
- ## 🔄 本机调试 OTA
126
+ **用手机浏览器开 DSH 的网页**:不用装东西,但每次都得记住地址,界面按桌面尺寸排,在手机上要靠缩放拖拽;没有推送,离开页面就什么都不知道;审批那种要点准的东西最难用。
98
127
 
99
- 新版插件的管理面板里有张「本机调试 OTA」卡片,可以手动检查并安装中转发布的紧急完整包。入口只认宿主本机的访问,手机和公网都触发不了;装完靠切换实现代生效,切换失败会退回上一代。正式发布源仍然是 npm,等 npm 上的版本号追到 OTA 版本时就自动接管。第一次发带这个功能的 npm 包需要重启一次 DSH,让稳定外壳把 OTA 能力加载进来。
128
+ **远程桌面**:本质还是操作那块屏幕,手机只是个显示器。网络断了就是黑屏,小屏上做任何事都要先放大。
100
129
 
101
- 要发一个紧急完整包,用仓库里的打包脚本,别手工 `tar`:
130
+ **各家 AI 助手的手机 App**:那些跑在厂商的服务器上,会话、文件和额度都在别人那儿。这里连的是你自己电脑上那个 DSH,模型是你配的,文件在你硬盘上。
102
131
 
103
- ```sh
104
- # 只打包(产物在当前目录)
105
- node scripts/pack-ota.mjs
132
+ ## 交流与反馈
106
133
 
107
- # 打包并发布,发布后会下载回来核对哈希
108
- node scripts/pack-ota.mjs --publish https://你的中转 --token <中转的 OTA 发布 token>
109
- ```
134
+ <p align="center">
135
+ <img src="assets/wechat-group.png" alt="企业微信群二维码" width="180">
136
+ </p>
110
137
 
111
- 脚本干四件事:文件清单问 `npm pack --dry-run --json` 要(**不碰系统 tar**,三个平台同一份逻辑)、往包里写一份 `lib/ota-manifest.json`(版本 + 每个文件的路径与字节数)、用客户端的真解包器自检、发布后再下载回来比 sha256。中转那边也记哈希,客户端装之前会核对 —— 半个包、被中间层改过的包都装不上去。
138
+ 扫上面的码进企业微信群。不方便的话发邮件到 <admin@wnluo.com> 或 <wnluo2@gmail.com>。用着顺手就给仓库点个 star,这是最实在的支持。
112
139
 
113
- `--platform` 默认 `process.platform`(`darwin` / `linux` / `win32`);这个插件是纯 JS,也可以 `--platform any` 发一份三个系统通用,客户端在自己平台没有包时会回落去问 `any`。`channel` 一般 `stable`。发布接口没配 token 时是关闭的,下载不受影响;版本按号比,最后发的那个才是「最新」。
140
+ 隐私政策见 [PRIVACY.md](PRIVACY.md),常见问题见 [SUPPORT.md](SUPPORT.md)。
114
141
 
115
- ## 👨‍💻 开发
142
+ ## 开发
116
143
 
117
144
  ```sh
118
145
  pnpm install --frozen-lockfile
@@ -121,36 +148,6 @@ npm test
121
148
 
122
149
  改过 `React/` 面板源码,先在该目录跑 `pnpm run build`,并把生成的 `lib/panel/index.html` 和源码一起提交。
123
150
 
124
- ## 📦 仓库与发布
125
-
126
- 这里是插件的公开发布仓库,仓库根就是 npm 包根。开发和 App、中转服务器一起放在私有 monorepo 里,所以这个仓库的提交历史从拆分那天开始;发布时要把同一次改动同步过来。
127
-
128
- - npm 包的 `repository` 字段负责关联插件列表条目,改完要再发一次版,注册表里的信息才会更新。
129
- - 收录靠往 awesome-dsh-plugin 提 `data/plugins/<owner>__<repo>.yml`。描述里的数字和 API 名称都要能在代码里核实;列表要求 `description.en`、`category: remote`,包要声明 `dsh.bundle`,仓库要有 `dsh-plugin` topic。
130
- - 「仓库创建满一天」这条检查过一阵会自己重跑。截图可以用 `screenshots.json` 提供,也可以让市场从这个 README 里找。
131
- - GitHub 的 release 页放手机 App 的 APK(下载入口见开头那一节):资产名固定用 `ai-hub-android.apk`,而且要发成正式版 —— 草稿和预发布都不会成为「最新」,README 里那条直连跟着就断了。插件自己走 npm 发布,不在这个仓库发 release。
132
- - 隐私政策:[PRIVACY.md](PRIVACY.md)。技术支持与常见问题:[SUPPORT.md](SUPPORT.md)。
133
-
134
- ### 发版流程
135
-
136
- **顺序是死的:抬号重建 → 提交 → 预检全绿 → 同步 → 打 tag → 等流水线。** 预检没过不许往下走,尤其不许「先把 tag 推上去看看」。
137
-
138
- 1. 跑 `node scripts/bump-version.mjs` 更新版本号(演示页的 `DEMO_VERSION` 会一起改,别手改)。
139
- 2. 改过 `React/` 就跑 `cd React && pnpm run build`。
140
- 3. 提交这次发版该带的文件,别 `git add -A`。
141
- 4. 在**主仓**的插件目录跑 `npm run release:check`。它按固定顺序查六件事:是不是在主仓里(跨仓对账要读 `app/`)、版本号与 tag(三段 `0..999`、这个 tag 没打过、不比上一版低)、工作区干不干净、面板产物跟不跟得上源码(拿当前源码真重建一次,与盘上那份逐字节比)、整套测试、真打一次包看文件清单。任何一步红就停,并说明该修哪里。**它要求在提交之后跑**,因为查的是「发出去的那一份」。
142
- 5. 把提交同步到公开仓库 `WNLUO/dsh-mobile-plugin`。
143
- 6. 推一个与版本号同名的 tag(版本 0.1.99 就推 `v0.1.99`)。那个仓库的 `.github/workflows/publish.yml` 会跑测试并直接发布,不需要人在浏览器里点二次验证 —— 它走 npm 的 trusted publishing(OIDC),npm 认的是这条流水线而不是某个令牌。
144
- 7. 等流水线绿。它末尾自己会核 `npm view dsh-mobile-plugin version`,你也可以手动看一眼。
145
- 8. 要在本机用上新版就跑 `node scripts/reload-dev.mjs`(打包 + 正规安装 + 就地重载),它报 ✔ 才算数。
146
-
147
- 两条别弄反:
148
-
149
- - **流水线是第二道闸,不是唯一一道。** 它在单独检出里跑,跨仓对账(手机端接口常量 ↔ 插件路由表)会自己跳过;面板产物它只查「在不在包里」,查不出新旧。这两件事只有第 4 步能查。
150
- - **热重载报 ✔ 不算测过。** 它把当前工作区装进 profile 跑一遍,不跑测试、不打包,也碰不到任何跨版本组合(老 App 配新插件之类)。它证明的是装载没问题,不是这一版能发。
151
-
152
- `npm publish` 仍能在开发机上手动跑,但现在要人过二次验证:账号上只有安全密钥时,npm 会打印一条 `www.npmjs.com/auth/cli/…` 链接,要用密钥在浏览器里点一下,且发起那台机器的出口 IP 要能打开 npm 官网。这条路留着应急,日常发版走 tag。
153
-
154
- ## 📎 远程控制元数据
151
+ ## License
155
152
 
156
- 配对请求可以带设备信息,App 现在会发 `id`、`deviceId`、`name`、`platform`、`osVersion` 和 `appVersion`;局域网代理与中转会补上它们观察到的手机 IP。
153
+ MIT,见 [LICENSE](LICENSE)。
@@ -132,6 +132,10 @@ function deviceMeta(device) {
132
132
  const meta = {};
133
133
  const name = truncate(device?.name, NAME_MAX);
134
134
  if (name) meta.name = name;
135
+ // 机型(「iPhone 15 Pro」/「Xiaomi 9」):名字是用户起的、两台可能一模一样,机型是手机
136
+ // 自己报的,用来补上「这台到底是什么」。缺了就不写 —— 判重仍只看 `deviceId`。
137
+ const model = truncate(device?.model, NAME_MAX);
138
+ if (model) meta.model = model;
135
139
  const platform = truncate(device?.platform, FIELD_MAX);
136
140
  if (platform) meta.platform = platform;
137
141
  const osVersion = truncate(device?.osVersion ?? device?.version, FIELD_MAX);
@@ -364,9 +368,20 @@ function listActive() {
364
368
  /**
365
369
  * 面板要的那份设备清单。
366
370
  *
367
- * 按 `deviceId` 聚成一行:同一台手机重配几次不该在列表里重复出现。名字与平台取最新一条
368
- * **有值的**记录(老记录这些字段是空的,空的不能覆盖有值的),绑定时间取最早,最近连接取
369
- * 最大,走哪条路取最近那条的。
371
+ * 按 `deviceId` 聚成一行:同一台手机重配几次不该在列表里重复出现。机型号、名字、平台、
372
+ * 系统版本、App 版本都取最新一条**有值的**记录(老记录这些字段是空的,空的不能覆盖有值的),
373
+ * 绑定时间取最早,最近连接取最大,走哪条路取最近那条的。
374
+ *
375
+ * **`deviceId` 是这一页唯一的键**:它是手机第一次启动时自己生成的,配对那一刻交给宿主,
376
+ * 之后每条请求都带着它。名字、机型、平台、版本一概不参与判重 —— 改个名字不该变成两台。
377
+ *
378
+ * `bindings` 是这台名下**还有几条活凭据**:同一台手机重配一次就多一条,旧的那条仍然能用。
379
+ * 大于 1 时面板要把「另有几条旧绑定」摆出来,否则人只在解除绑定那一下看到「2 条凭据」,
380
+ * 之前一直以为只有一份。
381
+ *
382
+ * `suffix` 只在**同一批设备里标题会撞**时给出序号(`1`、`2`…),其余是空串——分组用的就是
383
+ * 面板拿来当标题的那一格:机型优先,机型空着才用名字。按绑定时间编号:早绑的是 1。
384
+ * 序号是显示用的,不进库、也不回给手机。
370
385
  *
371
386
  * `legacy` 标的是「这个 deviceId 下还有不带设备密钥的活记录」:那种凭据的吊销杀不掉旧
372
387
  * Bearer,界面上的撤销文案必须区别对待,不能写「立即失效」。
@@ -386,6 +401,7 @@ function listDevices(instanceId) {
386
401
  row = {
387
402
  deviceId: key,
388
403
  name: '',
404
+ model: '',
389
405
  platform: '',
390
406
  osVersion: '',
391
407
  appVersion: '',
@@ -393,14 +409,18 @@ function listDevices(instanceId) {
393
409
  lastSeenAt: 0,
394
410
  lastChannel: '',
395
411
  lastSeenFrom: 0,
412
+ bindings: 0,
413
+ suffix: '',
396
414
  legacy: false,
397
415
  encrypted: false,
398
416
  expiresAt: 0,
399
417
  };
400
418
  rows.set(key, row);
401
419
  }
420
+ row.bindings += 1;
402
421
  if (createdAt && (!row.boundAt || createdAt < row.boundAt)) row.boundAt = createdAt;
403
- // 名字与平台取最新一条有值的:老记录没有这些字段,空不能覆盖有值。
422
+ // 机型号、名字、平台等取最新一条有值的:老记录没有这些字段,空不能覆盖有值。
423
+ if (record.model && createdAt >= (row.modelAt || 0)) { row.model = record.model; row.modelAt = createdAt; }
404
424
  if (record.name && createdAt >= (row.nameAt || 0)) { row.name = record.name; row.nameAt = createdAt; }
405
425
  if (record.platform && createdAt >= (row.platformAt || 0)) { row.platform = record.platform; row.platformAt = createdAt; }
406
426
  if (record.osVersion && createdAt >= (row.osVersionAt || 0)) { row.osVersion = record.osVersion; row.osVersionAt = createdAt; }
@@ -416,21 +436,24 @@ function listDevices(instanceId) {
416
436
  // 带设备密钥的记录才走逐请求签名;只带 6 位码配对的那个版本永远是明文。
417
437
  if (record.deviceKey) row.encrypted = true;
418
438
  }
419
- const out = [];
420
- const labelCount = new Map();
439
+ // 撞名才编号:分组用的正是面板拿来当标题的那一格 —— **机型优先,没有机型才用名字**。
440
+ // 两台都叫「iPhone 15 Pro」时「· 1 / · 2」是人认得出的;挂着机器号后四位则不是。
441
+ const groups = new Map();
421
442
  for (const row of rows.values()) {
422
- const label = `${row.name}\u0000${row.platform}`;
423
- labelCount.set(label, (labelCount.get(label) || 0) + 1);
443
+ const label = row.model || row.name || '';
444
+ const bucket = groups.get(label);
445
+ if (bucket) bucket.push(row);
446
+ else groups.set(label, [row]);
447
+ }
448
+ for (const bucket of groups.values()) {
449
+ if (bucket.length < 2) continue;
450
+ const ordered = [...bucket].sort((a, b) => (a.boundAt - b.boundAt) || String(a.deviceId).localeCompare(String(b.deviceId)));
451
+ ordered.forEach((row, index) => { row.suffix = String(index + 1); });
424
452
  }
453
+ const out = [];
425
454
  for (const row of rows.values()) {
426
- const { nameAt, platformAt, osVersionAt, appVersionAt, lastSeenFrom, ...rest } = row;
427
- // 同名同平台的两台手机在列表里长得一样,加 deviceId 后 4 位才分得清;名字本来就不重复的
428
- // 不加,别让每行都挂一串十六进制。
429
- const duplicated = Boolean(row.name) && (labelCount.get(`${row.name}\u0000${row.platform}`) || 0) > 1;
430
- out.push({
431
- ...rest,
432
- name: duplicated ? `${row.name} · ${String(row.deviceId).slice(-4)}` : row.name,
433
- });
455
+ const { modelAt, nameAt, platformAt, osVersionAt, appVersionAt, lastSeenFrom, ...rest } = row;
456
+ out.push(rest);
434
457
  }
435
458
  // 最近连着的排前面;从没连过的按绑定时间倒序垫后。
436
459
  out.sort((a, b) => (b.lastSeenAt - a.lastSeenAt) || (b.boundAt - a.boundAt));
package/lib/impl.js CHANGED
@@ -40,7 +40,6 @@ const { installApprovalBridge } = require('./mobile/approvals.js')
40
40
  const { installQuestionBridge } = require('./mobile/questions.js')
41
41
  // 「手机上最近来过没有」:守卫记、控制面状态读。单独一个模块,装配层只转交。
42
42
  const activity = require('./mobile/activity.cjs')
43
- const { installAutoArchive } = require('./auto-archive.cjs')
44
43
  const { installSessionCleanup } = require('./session-cleanup.cjs')
45
44
  // 扩展卡的宿主侧:`extension_present` 工具与卡状态账本(见 mobile/extensions.js)。
46
45
  const { installExtensions } = require('./mobile/extensions.js')
@@ -330,11 +329,8 @@ function apply(ctx) {
330
329
  ctx.effect(() => sidecarLifecycle.start(), `${name}: facade sidecar`)
331
330
  ctx.effect(() => mirror.start(), `${name}: mobile mirror sync`)
332
331
 
333
- // Keep stale project conversations out of the host's session browser.
334
- // This is deliberately Host-side so it remains active even with no operator present.
335
332
  if (!hasHostApiProxy) ctx.effect(() => installApprovalBridge(ctx), `${name}: host approval bridge`)
336
333
  if (!hasHostApiProxy) ctx.effect(() => installQuestionBridge(ctx), `${name}: host question bridge`)
337
- ctx.effect(() => installAutoArchive(ctx), `${name}: automatic session archive`)
338
334
  ctx.effect(() => {
339
335
  // Profiles without the optional tools injection still support core remote control.
340
336
  try { installSessionCleanup(ctx) } catch (error) {
package/lib/index.js CHANGED
@@ -157,7 +157,7 @@ export function apply(ctx) {
157
157
  .start(ctx, { log })
158
158
  .then(() => {
159
159
  log('info', 'dsh-mobile-plugin: 已装载 %s(来源 %s)', runtime.status().version, runtime.status().source)
160
- // 每 10 分钟让已落盘的正式版接管 OTA,并补上「盘上比运行中新」的那一次换代;
160
+ // 每 2 分钟让已落盘的正式版接管 OTA,并补上「盘上比运行中新」的那一次换代;
161
161
  // 自动安装仍由开关控制,开着就**不等正在跑的对话**(`auto` 那一档,见 update-install)。
162
162
  // 补换代不受开关管:它不下载任何东西,只是让已经在盘上的代码生效。
163
163
  const timer = setInterval(() => {
@@ -0,0 +1,100 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * 手机端能读、能写的目录范围:宿主已登记的工作区 ∪ 当前会话的项目目录。
5
+ *
6
+ * 读(`file-read.js` 的按路径取字节)与写(`uploads.js` 的 `destPath`)共用这一份判据 ——
7
+ * 抄两份一定会漂,而漂出来的组合最难解释:同一个目录里的文件「能打开、却存不回去」。
8
+ *
9
+ * 判的是 **resolve 之后的落点**,不是输入字符串:`a/../../x` 这类写法按最终指向算,`..`
10
+ * 爬出允许区的路径一律不认。
11
+ *
12
+ * 能力面的口径是「这台电脑上我自己登记过的目录,加上这条会话正在干活的那一个」,
13
+ * **不是**整台电脑。要再放宽(任意绝对路径、任意路径前缀)先改设计,别在这里顺手加分支。
14
+ *
15
+ * 工作区清单取法与 `workspaces.js` 同一处(`callWorkspace(ctx,'list')`):宿主**报错或形状
16
+ * 不对都要如实抛出去**,绝不当成「没有工作区」—— 那样每个路径都会变成 403,用户看到的
17
+ * 是「我的目录突然不能看了」,而不是「宿主那边出错了」。
18
+ */
19
+
20
+ const path = require('node:path');
21
+
22
+ const { unwrap } = require('./envelope.js');
23
+ const { cwdOf } = require('./session-cwd.js');
24
+
25
+ /** 调宿主 apiProxy.workspace.*(与 `workspaces.js` 的 `callWorkspace` 同一形状)。 */
26
+ function callWorkspace(ctx, method, payload) {
27
+ return ctx.apiProxy.workspace[method]({ rpcId: 'mobile', method: `workspace.${method}`, payload });
28
+ }
29
+
30
+ /** 允许区判据统一抛这一种错:调用方按 `code` 转成 502,把宿主那句话原样回给手机。 */
31
+ function zoneError(code, message) {
32
+ return Object.assign(new Error(message), { code });
33
+ }
34
+
35
+ /**
36
+ * 宿主已登记的、能当根用的目录(绝对路径,去重、保持宿主的顺序)。
37
+ *
38
+ * 没有 `path` 的条目直接跳过:它在宿主侧连目录都没有,拿来当根只会把每次判定都拖成
39
+ * 「看起来在工作区里」。宿主的两种回执形状(裸数组 / `{ items }`)都要认,与
40
+ * `buildWorkspacesBody` 同口径。
41
+ */
42
+ async function workspaceRoots(ctx) {
43
+ const { value, error } = unwrap(await callWorkspace(ctx, 'list', {}));
44
+ if (error) {
45
+ throw zoneError(error.code || 'workspace-list-failed', error.message || '读工作区清单失败');
46
+ }
47
+ const items = Array.isArray(value) ? value : (value && Array.isArray(value.items) ? value.items : null);
48
+ if (items === null) {
49
+ throw zoneError('host-workspace-list-degraded', '宿主工作区清单形状异常:响应里没有 items 数组');
50
+ }
51
+ const roots = [];
52
+ for (const item of items) {
53
+ const raw = item && typeof item.path === 'string' ? item.path.trim() : '';
54
+ if (!raw || !path.isAbsolute(raw)) continue;
55
+ const root = path.resolve(raw);
56
+ if (!roots.includes(root)) roots.push(root);
57
+ }
58
+ return roots;
59
+ }
60
+
61
+ /**
62
+ * 这一次请求的允许区。
63
+ *
64
+ * @param {object} ctx 插件上下文
65
+ * @param {string} [sessionId] 给了才把这条会话的项目目录并进来(右栏那份「当前目录」
66
+ * 未必是登记过的工作区,比如直接从会话进的文件树)
67
+ * @returns {Promise<string[]>} 绝对路径形式的根目录
68
+ */
69
+ async function allowedRoots(ctx, sessionId) {
70
+ const roots = await workspaceRoots(ctx);
71
+ if (!sessionId) return roots;
72
+ // cwd 取不到(会话没工作区、或宿主那边读失败)就只剩工作区那几个根:
73
+ // 这是缩小范围,不是扩大,按「不认」处理是安全的。
74
+ const cwd = await cwdOf(ctx, sessionId);
75
+ if (!cwd || !path.isAbsolute(cwd)) return roots;
76
+ const root = path.resolve(cwd);
77
+ if (!roots.includes(root)) roots.push(root);
78
+ return roots;
79
+ }
80
+
81
+ /**
82
+ * target 在不在 root 里面(含 root 本身)。
83
+ *
84
+ * `path.relative` 出来是 `..`、`../x` 或以 `..` + 分隔符开头就是爬出去了;绝对路径说明
85
+ * 两者不在同一棵树(Windows 换盘符)。`..foo` 这种**名字**以两点开头的目录不算越界 ——
86
+ * 判据是整段,不是前缀字符串。
87
+ */
88
+ function isWithin(root, target) {
89
+ const rel = path.relative(root, target);
90
+ if (rel === '') return true;
91
+ if (path.isAbsolute(rel)) return false;
92
+ return rel !== '..' && !rel.startsWith(`..${path.sep}`);
93
+ }
94
+
95
+ /** target 在不在允许区里的任意一个根之下。 */
96
+ function inAllowedZone(roots, target) {
97
+ return Array.isArray(roots) && roots.some((root) => isWithin(root, target));
98
+ }
99
+
100
+ module.exports = { workspaceRoots, allowedRoots, isWithin, inAllowedZone };
@@ -16,19 +16,20 @@ const { withIdempotency, requestIdOf } = require('./idempotency.js');
16
16
  const { canonicalDeviceKey, rememberDeletedSessionIds } = require('./session-cache.js');
17
17
  const { invalidateSessionList } = require('./sessions.js');
18
18
  const { unwrap } = require('./envelope.js');
19
- const { collectStats } = require('./stats-collect.cjs');
19
+ const { collectStats, DELETING_COLLECT_BUDGET_MS } = require('./stats-collect.cjs');
20
20
  const { queueArchivedClearIds } = require('../session-cleanup.cjs');
21
21
 
22
22
  /**
23
23
  * 清理前这次补采的预算。
24
24
  *
25
25
  * 统计页只给 1.5 秒是为了别让用户转好久;清理是不可恢复的删除,值得多等几秒把账补全,
26
- * 但不能超过 App 单发请求的 8 秒总预算,否则手机先报超时、这边照旧执行。
26
+ * 但不能超过 App 单发请求的 8 秒总预算,否则手机先报超时、这边照旧执行。数值与 agent
27
+ * 删除工具那条路共用一份(`DELETING_COLLECT_BUDGET_MS`),改一处就两条路都对。
27
28
  *
28
29
  * 这次补采跳过「最后一句」预览:那一步要读会话日志,是整轮最重的一趟。删日志前先把数字
29
30
  * 记住,比给即将消失的卡片多留一行摘要重要。
30
31
  */
31
- const CLEAR_ARCHIVED_COLLECT_BUDGET_MS = 6_000;
32
+ const CLEAR_ARCHIVED_COLLECT_BUDGET_MS = DELETING_COLLECT_BUDGET_MS;
32
33
 
33
34
  /**
34
35
  * POST /api/mobile/v1/workspace/clear-archived(token)
@@ -17,7 +17,8 @@
17
17
 
18
18
  const { unwrap } = require('./envelope.js');
19
19
  const { callSessions } = require('./rpc.js');
20
- const { inlineTextImages, createTextImageBudget } = require('./local-images.js');
20
+ const { cwdOf } = require('./session-cwd.js');
21
+ const { inlineTextImages, createTextImageBudget, hasRelativeImage } = require('./local-images.js');
21
22
 
22
23
  /**
23
24
  * 取不到字节的图片块:原样留着,只加一个标记。
@@ -43,6 +44,9 @@ async function resolveEventAttachments(ctx, sessionId, event, opts = {}) {
43
44
  const resolveBlock = async (block) => {
44
45
  if (!block || typeof block !== 'object') return block;
45
46
  if (block.type === 'text' && typeof block.text === 'string') {
47
+ // 相对地址的图片按会话工作区解析(与桌面端同口径):只有正文里真的出现这种写法时
48
+ // 才去查一次会话列表,同一份 budget(一条响应)只查一次。
49
+ if (budget.cwd === undefined && hasRelativeImage(block.text)) budget.cwd = await cwdOf(ctx, sessionId);
46
50
  const text = await inlineTextImages(block.text, budget);
47
51
  return text === block.text ? block : { ...block, text };
48
52
  }