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.
- package/README.md +71 -74
- package/dsh-mobile-credentials/lib/credentials.js +39 -16
- package/lib/impl.js +0 -4
- package/lib/index.js +1 -1
- package/lib/mobile/allowed-zone.js +100 -0
- package/lib/mobile/archive-clean.js +4 -3
- package/lib/mobile/attachments.js +5 -1
- package/lib/mobile/file-read.js +168 -0
- package/lib/mobile/history-derive.js +67 -98
- package/lib/mobile/local-images.js +38 -7
- package/lib/mobile/model-vendors.js +105 -0
- package/lib/mobile/plugin-market.js +475 -0
- package/lib/mobile/plugin-self.js +1 -1
- package/lib/mobile/plugin-update.js +1 -1
- package/lib/mobile/relay-control.js +131 -7
- package/lib/mobile/session-cwd.js +28 -0
- package/lib/mobile/session-file.js +1 -12
- package/lib/mobile/stats-collect.cjs +146 -15
- package/lib/mobile/stats-ledger.cjs +249 -59
- package/lib/mobile/stats-scheduler.cjs +41 -0
- package/lib/mobile/stream.js +30 -110
- package/lib/mobile/uploads.js +68 -5
- package/lib/mobile/vendor-logos.cjs +30 -0
- package/lib/mobile/wire-cut.js +212 -0
- package/lib/panel/index.html +2 -2
- package/lib/relay/apply-ledger.cjs +52 -0
- package/lib/relay/core.js +14 -3
- package/lib/relay/migrate.js +90 -0
- package/lib/relay/protocol.js +4 -0
- package/lib/relay/verify.js +52 -0
- package/lib/routes/control-plane.js +12 -3
- package/lib/routes/mobile-api.js +28 -2
- package/lib/session-cleanup.cjs +10 -0
- package/lib/shared/device-headers.js +12 -4
- package/lib/shell/updater.js +2 -2
- package/lib/sidecar-facade-worker.cjs +55 -8
- package/lib/sidecar-facade.cjs +11 -1
- package/lib/sidecar-lifecycle.cjs +14 -1
- package/lib/update-install.cjs +13 -6
- package/package.json +1 -1
- 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="
|
|
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://
|
|
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
|
-
-
|
|
28
|
-
-
|
|
29
|
-
-
|
|
30
|
-
-
|
|
31
|
-
-
|
|
32
|
-
- 🔐 **配对与凭据**:本机配置接口、可选的配对策略,设备凭据可以单独吊销。
|
|
38
|
+
- 新建、接着聊、改名、搜索会话,也能从任意一条分出分支单独往下走。
|
|
39
|
+
- 工作区建、改名、删除、置顶、归档都在手机上完成,手机端排的顺序会同步回电脑。
|
|
40
|
+
- 回复逐块流式到达,跑到一半就能看见内容,工具调用也是实时的。
|
|
41
|
+
- 空闲会话自动进归档,列表里不再占位,日志不删。
|
|
42
|
+
- 手机上读过,电脑那边的未读绿点跟着消失;电脑侧边栏的展开、折叠状态同步到手机,两边结构一样。
|
|
33
43
|
|
|
34
|
-
|
|
44
|
+
### 不用回电脑也能操作
|
|
35
45
|
|
|
36
|
-
-
|
|
37
|
-
-
|
|
38
|
-
-
|
|
39
|
-
-
|
|
46
|
+
- **审批与提问**:电脑上会弹的卡手机同样有,权限请求、选择题、多选、自定义回答都能直接点。
|
|
47
|
+
- **排队插话**:正在跑的时候补一句,按队列排在当前这一步之后。
|
|
48
|
+
- **Git**:看状态和 diff、暂存、提交、推送、切分支,提交信息可以先让模型写。
|
|
49
|
+
- **文件**:浏览目录、读文件,也能把手机上的文件传回电脑,落在固定的收件目录。
|
|
50
|
+
- **插件**:手机上的插件市场里搜索、安装、更新、卸载;插件自己也能升级到新版。
|
|
51
|
+
- **模型**:随时换模型,看有哪些渠道和可用模型,本机自建的渠道也能直接在手机上配。
|
|
52
|
+
- **日程与子代理**:查看定时任务、删掉不要的;看正在跑的子代理,打断它或者补一句话。
|
|
40
53
|
|
|
41
|
-
|
|
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
|
-
|
|
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
|
-
|
|
118
|
+
DSH 跑在电脑上,但我不是一直坐在电脑前。出门、在别的房间、躺床上,想知道刚才那个任务跑到哪一步了,或者它正卡在一个要我点的审批上,只能走回去。
|
|
87
119
|
|
|
88
|
-
|
|
89
|
-
cd <DSH_HOME>/profiles/<profile>
|
|
90
|
-
pnpm add dsh-mobile-plugin@<版本>
|
|
91
|
-
```
|
|
120
|
+
这个插件的出发点就这么窄:不是把整个 DSH 塞进手机屏幕,而是让人离开电脑之后,还能看见、还能接着聊、还能把该点的那一下点掉。
|
|
92
121
|
|
|
93
|
-
|
|
122
|
+
所以有两条线是硬的。一条是网络:不要求公网 IP、不要在路由器上开端口、不要为了用一次去配 DDNS,插件从内网主动往外出,手机顺着回来。另一条是能力:手机上只开需要的那几个口子,能看会话、能审批、能传文件、能改点配置,但不把 shell 和任意文件写入递到手机上 —— 手机丢了或者被人捡到,损失是可估的。
|
|
94
123
|
|
|
95
|
-
|
|
124
|
+
## 和别的方案比
|
|
96
125
|
|
|
97
|
-
|
|
126
|
+
**用手机浏览器开 DSH 的网页**:不用装东西,但每次都得记住地址,界面按桌面尺寸排,在手机上要靠缩放拖拽;没有推送,离开页面就什么都不知道;审批那种要点准的东西最难用。
|
|
98
127
|
|
|
99
|
-
|
|
128
|
+
**远程桌面**:本质还是操作那块屏幕,手机只是个显示器。网络断了就是黑屏,小屏上做任何事都要先放大。
|
|
100
129
|
|
|
101
|
-
|
|
130
|
+
**各家 AI 助手的手机 App**:那些跑在厂商的服务器上,会话、文件和额度都在别人那儿。这里连的是你自己电脑上那个 DSH,模型是你配的,文件在你硬盘上。
|
|
102
131
|
|
|
103
|
-
|
|
104
|
-
# 只打包(产物在当前目录)
|
|
105
|
-
node scripts/pack-ota.mjs
|
|
132
|
+
## 交流与反馈
|
|
106
133
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
134
|
+
<p align="center">
|
|
135
|
+
<img src="assets/wechat-group.png" alt="企业微信群二维码" width="180">
|
|
136
|
+
</p>
|
|
110
137
|
|
|
111
|
-
|
|
138
|
+
扫上面的码进企业微信群。不方便的话发邮件到 <admin@wnluo.com> 或 <wnluo2@gmail.com>。用着顺手就给仓库点个 star,这是最实在的支持。
|
|
112
139
|
|
|
113
|
-
|
|
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
|
-
|
|
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
|
-
|
|
420
|
-
|
|
439
|
+
// 撞名才编号:分组用的正是面板拿来当标题的那一格 —— **机型优先,没有机型才用名字**。
|
|
440
|
+
// 两台都叫「iPhone 15 Pro」时「· 1 / · 2」是人认得出的;挂着机器号后四位则不是。
|
|
441
|
+
const groups = new Map();
|
|
421
442
|
for (const row of rows.values()) {
|
|
422
|
-
const label =
|
|
423
|
-
|
|
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
|
-
|
|
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
|
-
// 每
|
|
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 =
|
|
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 {
|
|
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
|
}
|