dsh-mobile-plugin 0.4.0 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dsh-mobile-credentials/lib/credentials.js +8 -8
- package/host.js +22 -36
- package/lib/client.js +29 -82
- package/lib/desktop-view.cjs +5 -10
- package/lib/git/read.js +6 -8
- package/lib/host-compat.cjs +13 -25
- package/lib/host-guard.cjs +9 -39
- package/lib/host-patches.cjs +5 -16
- package/lib/http/routing.js +7 -27
- package/lib/impl.js +6 -8
- package/lib/mirror/pusher.cjs +33 -85
- package/lib/mirror-snapshot.cjs +6 -24
- package/lib/mobile/agent-presets.js +6 -26
- package/lib/mobile/approvals.js +18 -44
- package/lib/mobile/archive-clean.js +10 -19
- package/lib/mobile/attachments.js +11 -36
- package/lib/mobile/channels.js +15 -41
- package/lib/mobile/credential-upgrade.js +7 -16
- package/lib/mobile/credentials.js +5 -21
- package/lib/mobile/directory-browse.js +8 -27
- package/lib/mobile/directory-listing.js +10 -23
- package/lib/mobile/drive-roots.js +59 -0
- package/lib/mobile/envelope.js +4 -15
- package/lib/mobile/events.js +35 -65
- package/lib/mobile/extensions.js +5 -8
- package/lib/mobile/git-repos.js +6 -15
- package/lib/mobile/git.js +11 -19
- package/lib/mobile/history-derive.js +23 -41
- package/lib/mobile/host-bridge/endpoints.js +4 -6
- package/lib/mobile/host-bridge/event-synthesis.js +21 -35
- package/lib/mobile/host-bridge/shims.js +12 -22
- package/lib/mobile/host-bridge.js +8 -20
- package/lib/mobile/host-settings.js +9 -19
- package/lib/mobile/inbox-clean.js +7 -24
- package/lib/mobile/local-images.js +11 -17
- package/lib/mobile/monitor.js +8 -17
- package/lib/mobile/pairing.js +7 -19
- package/lib/mobile/plugin-self.js +5 -22
- package/lib/mobile/plugin-update.js +8 -27
- package/lib/mobile/plugins.js +6 -24
- package/lib/mobile/questions.js +15 -40
- package/lib/mobile/references.js +11 -31
- package/lib/mobile/relay-control.js +6 -27
- package/lib/mobile/rpc.js +5 -13
- package/lib/mobile/schedule.js +8 -33
- package/lib/mobile/self-check.cjs +9 -36
- package/lib/mobile/session-cache.js +13 -19
- package/lib/mobile/session-file.js +8 -21
- package/lib/mobile/session-flags.js +7 -13
- package/lib/mobile/session-mirror.js +8 -15
- package/lib/mobile/session-models.js +3 -3
- package/lib/mobile/session-schedule-mark.js +8 -14
- package/lib/mobile/session-send.js +13 -21
- package/lib/mobile/session-snapshot.js +4 -6
- package/lib/mobile/sessions.js +7 -23
- package/lib/mobile/stats-collect.cjs +10 -24
- package/lib/mobile/stats-ledger.cjs +13 -20
- package/lib/mobile/stream.js +12 -23
- package/lib/mobile/subagents.js +5 -18
- package/lib/mobile/uploads.js +8 -17
- package/lib/mobile/workspaces.js +20 -8
- package/lib/panel/index.html +7 -7
- package/lib/read-receipt.cjs +10 -21
- package/lib/relay/lan-network.cjs +14 -28
- package/lib/relay/lan-proxy.js +14 -21
- package/lib/relay/pairing-policy.js +6 -9
- package/lib/relay/preview.js +8 -25
- package/lib/relay/protocol.js +6 -13
- package/lib/relay/status.js +5 -8
- package/lib/relay/tunnel.js +9 -14
- package/lib/routes/control-plane.js +28 -45
- package/lib/routes/mobile-api.js +19 -32
- package/lib/session-cleanup.cjs +16 -27
- package/lib/shared/credentials-entry.js +4 -11
- package/lib/shared/dshtoken.js +4 -4
- package/lib/shared/e2ee-request.js +1 -1
- package/lib/shared/e2ee.js +8 -14
- package/lib/shared/fresh-request.js +6 -22
- package/lib/shared/http-util.js +3 -4
- package/lib/shared/list-revision.js +5 -14
- package/lib/shared/session-signal.js +7 -16
- package/lib/shared/sse-write.js +14 -20
- package/lib/shared/workspace-guard.js +7 -15
- package/lib/shell/device-ota.js +4 -6
- package/lib/shell/manager.js +12 -27
- package/lib/shell/ota-package.js +7 -12
- package/lib/shell/runtime.js +8 -16
- package/lib/shell/self-check-after-swap.js +9 -23
- package/lib/shell/updater.js +13 -26
- package/lib/sidecar-facade-worker.cjs +20 -33
- package/lib/sidecar-facade.cjs +7 -7
- package/lib/sidecar-lifecycle.cjs +7 -9
- package/lib/update-install.cjs +14 -59
- package/lib/update-registry.cjs +6 -18
- package/package.json +1 -1
package/lib/read-receipt.cjs
CHANGED
|
@@ -3,30 +3,19 @@
|
|
|
3
3
|
/**
|
|
4
4
|
* 「已读回执」台账:哪个会话在什么时候被读过,桌面端据此清掉自己的未读绿点。
|
|
5
5
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* - 桌面端绿点 = 浏览器渲染进程内存里的 `UiSession.completionUnread`
|
|
10
|
-
* (@deepseek-ai/dsh-client-ui-session),既不落盘也不过服务端。
|
|
11
|
-
* 所以手机上点开一个会话,桌面那份完全不知情,绿点照旧。
|
|
6
|
+
* 两端的绿点来自两份互不相干的状态:手机端是宿主按设备维度维护的完成提醒(completion.js),
|
|
7
|
+
* 桌面端是渲染进程内存里的 `UiSession.completionUnread`,既不落盘也不过服务端。所以手机上
|
|
8
|
+
* 点开一个会话,桌面那份完全不知情。
|
|
12
9
|
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
* `sessions.retain(id, { source: 'mainView' })` + `release()`,借桌面自己的
|
|
17
|
-
* `reconcileStatus()` 把 `completionUnread` 摘掉。
|
|
10
|
+
* 这里补那条通道:只要有人在某一端读过,宿主就记一条 `sessionId → readAt` 水位;客户端半边
|
|
11
|
+
* 每 2 秒拉一次,对水位前进过的会话做一次瞬时 retain + release,借桌面自己的
|
|
12
|
+
* `reconcileStatus()` 把绿点摘掉。
|
|
18
13
|
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
* 重启就空 —— 所以 `readAtOf` 让它在重启后从这盘上补回来。两头都写同一本账,
|
|
22
|
-
* 「读过」这件事才不会随进程消失。
|
|
14
|
+
* 宿主自己也读这本账:`readAtOf` 让进程重启后的已读水位从盘上补回来,两头写同一本账,
|
|
15
|
+
* 「读过」才不会随进程消失。
|
|
23
16
|
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
* **所有设备**维度上的完成提醒(`clearCompletionForAllDevices`)。而「读完又来一轮」
|
|
27
|
-
* 必须还能重新亮起来 —— 这一条由 completion.js 的 delete 语义本身保证:条目被清掉后
|
|
28
|
-
* 重建出来的 `wasRunning` 是 false,历史折叠会拒绝确认,于是只有**之后**真正跑完的
|
|
29
|
-
* 轮次才会把 completed 重新置真。
|
|
17
|
+
* 存 readAt 而不是布尔:冲突策略是 last-write-wins;「读完又来一轮」由 completion.js 的
|
|
18
|
+
* 条目重建语义保证能重新亮起来。
|
|
30
19
|
*/
|
|
31
20
|
|
|
32
21
|
const fs = require('node:fs');
|
|
@@ -1,12 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* 网卡枚举与候选地址:私网 / 公网 / IPv6 全球单播三份清单,外加「用户选中的那条排最前」。
|
|
3
3
|
*
|
|
4
|
-
*
|
|
4
|
+
* 收口在一个文件里,是因为 App 只认「按顺序挨个试」这一种语义,顺序就是优先级。
|
|
5
5
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* `ipv6Candidates` 的注释)。
|
|
6
|
+
* 三份的区别在手机在哪:私网是「同一个 Wi-Fi 里能直连」,公网是「这台机器自己就挂在公网上」,
|
|
7
|
+
* IPv6 是「手机在别的网络里拨过来」。IPv6 不并进前两份:App 对它的用法不同,要和公网中转
|
|
8
|
+
* 比速度。
|
|
10
9
|
*/
|
|
11
10
|
'use strict'
|
|
12
11
|
|
|
@@ -22,17 +21,11 @@ function ipv4Number(address) {
|
|
|
22
21
|
function inRange(value, start, end) { return value >= start && value <= end }
|
|
23
22
|
|
|
24
23
|
/**
|
|
25
|
-
* RFC1918
|
|
24
|
+
* RFC1918 里不由容器 / 虚拟机 / 虚拟网桥占掉的那几段。
|
|
26
25
|
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
* `172.19/16` 与 `172.20/16`(compose / k3d 的下一批)、`172.24~172.31/16`(kubeadm 的
|
|
31
|
-
* `k8s-pod-network` 与常见 overlay)也都是工具按默认值抢走的。
|
|
32
|
-
*
|
|
33
|
-
* 判据是「这个段是不是被某个常见的非宿主网络工具预留了」,而不是「这台机器上有没有跑它」:
|
|
34
|
-
* 我们读不到网络配置,只能按默认值收敛。留下的 172.16~172.23 是 172.16/12 里没被任何
|
|
35
|
-
* 主流工具默认占用的部分;真有人在这几段上跑业务网,网卡名黑名单(下一条)仍然是第一道闸。
|
|
26
|
+
* Docker 默认拿走 172.17/16,compose / k3d / kubeadm 又按默认值各占几段;把它们当候选,
|
|
27
|
+
* 手机拨过去要么不通,要么连到容器上的别的东西。我们读不到网络配置,只能按「常见工具会不会
|
|
28
|
+
* 默认占用」收敛;网卡名黑名单仍然是第一道闸。
|
|
36
29
|
*/
|
|
37
30
|
const RESERVED_172 = [
|
|
38
31
|
[0xac110000, 0xac11ffff], // 172.17/16 docker0
|
|
@@ -175,21 +168,14 @@ function lanCandidates(port, interfaces = os.networkInterfaces()) {
|
|
|
175
168
|
}
|
|
176
169
|
|
|
177
170
|
/**
|
|
178
|
-
*
|
|
171
|
+
* 公网地址:同一遍扫描,挑出真能从外网拨过来的那些。
|
|
179
172
|
*
|
|
180
173
|
* 判据是「不在任何特殊网段里」,而不是「不是 RFC1918」——后者会把 169.254/16(链路本地)、
|
|
181
174
|
* 100.64/10(CGNAT)、198.18/15(基准测试)这些同样拨不通的地址当成公网地址发出去。
|
|
182
|
-
* 实测踩过:macOS 上一块多出来的网卡带着 `169.254.87.131`,被当成「公网地址」摆到面板上。
|
|
183
|
-
* 这份特殊网段清单和本文件开头那句「intentionally excludes VPN/CGNAT, link-local and
|
|
184
|
-
* benchmarking/test ranges」是同一条口径。
|
|
185
|
-
*
|
|
186
|
-
* 为什么不并进 `lanInterfaces`:那份列表的语义是「手机和这台机器在同一个 Wi-Fi 里能直连的
|
|
187
|
-
* 地址」,混进公网地址会改掉 App 的探测顺序与中转上报的内容(`test/lan-network.test.cjs`
|
|
188
|
-
* 既钉着「WAF 容器的 192.168.0.1 被当成候选」那次事故,也钉着「只有公网地址的机器候选为空」)。
|
|
189
175
|
*
|
|
190
|
-
*
|
|
191
|
-
*
|
|
192
|
-
*
|
|
176
|
+
* 不并进 `lanInterfaces`:那份的语义是「同一 Wi-Fi 里能直连」,混进公网地址会改掉 App 的
|
|
177
|
+
* 探测顺序与中转上报的内容。需要公网地址的只有两处:面板显示该填哪个地址,以及一台只有
|
|
178
|
+
* 公网 IP 的机器上给不出任何私网候选时的兜底。
|
|
193
179
|
*/
|
|
194
180
|
const NOT_PUBLIC_RANGES = [
|
|
195
181
|
[0x00000000, 0x00ffffff], // 0/8 “本网络”
|
|
@@ -328,8 +314,8 @@ function ipv6Candidates(port, interfaces = os.networkInterfaces()) {
|
|
|
328
314
|
* 对外公布的候选地址:**先私网,一个都没有时才退到公网**。
|
|
329
315
|
*
|
|
330
316
|
* 为什么要有这一条:`lanInterfaces` 只认 RFC1918,于是「DSH 装在一台只有公网 IP 的服务器上」
|
|
331
|
-
* 时那份列表**恒为空** ——
|
|
332
|
-
*
|
|
317
|
+
* 时那份列表**恒为空** —— 二维码里一个可拨的地址都没有,「手机直连」这条路从根上走不通。
|
|
318
|
+
* 只在为空时才退:有私网地址的机器,候选列表一个字都不变。
|
|
333
319
|
*
|
|
334
320
|
* 取不到网卡(异常)时回空清单、绝不抛:它喂的是二维码与移动端的连接信息,
|
|
335
321
|
* 让它把整条控制面拖下水不值得。
|
package/lib/relay/lan-proxy.js
CHANGED
|
@@ -4,11 +4,10 @@
|
|
|
4
4
|
* 局域网直连代理:手机在同一个 Wi-Fi 下直接访问
|
|
5
5
|
* http://<电脑IP>:<lanPort>,带上配对拿到的令牌后代理到本机 dsh web。
|
|
6
6
|
*
|
|
7
|
-
* 只服务 App:未认证的请求一律 JSON 401
|
|
8
|
-
* 与配对后能进入的网页本体已删除(见 docs/decisions.md 2026-10-19)。
|
|
7
|
+
* 只服务 App:未认证的请求一律 JSON 401,不给任何网页(网页版配对页与网页本体已删除)。
|
|
9
8
|
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
9
|
+
* 与中转隧道的关系:同网走本代理(零延迟、不依赖公网),异地走 dsh-mobile-server 中转;
|
|
10
|
+
* 两者互不影响,可同时开启。
|
|
12
11
|
*/
|
|
13
12
|
|
|
14
13
|
const http = require('node:http');
|
|
@@ -352,22 +351,18 @@ function createLanProxy(opts) {
|
|
|
352
351
|
/**
|
|
353
352
|
* 手机走了,必须把上游这条请求一起拆掉。
|
|
354
353
|
*
|
|
355
|
-
*
|
|
356
|
-
*
|
|
357
|
-
*
|
|
358
|
-
* 宿主进程涨到 3.5~5G、CPU 30~40%,重启后回到 530M。同一个插件的中转路径有
|
|
359
|
-
* `httpCancel` 帧负责拆(tunnel.js),只有局域网这条路漏了。
|
|
354
|
+
* 只挂 `error` 的话,手机断开后上游请求还活着,宿主那边的响应永远等不到 `close`,events
|
|
355
|
+
* 流与每条会话流的订阅就永不退订,宿主继续往一条没人读的连接上生成帧与正文。中转路径有
|
|
356
|
+
* `httpCancel` 帧负责拆,局域网这条路同样要有。
|
|
360
357
|
*
|
|
361
|
-
* `res.writableEnded
|
|
362
|
-
* 只有「客户端提前走」才会落到 false。
|
|
358
|
+
* 判据是 `res.writableEnded`:正常活完的响应结束时就已置位,只有客户端提前走才落到 false。
|
|
363
359
|
*/
|
|
364
360
|
res.on('close', () => {
|
|
365
361
|
if (res.writableEnded) return;
|
|
366
362
|
try { up.destroy() } catch { /* 已断开 */ }
|
|
367
363
|
});
|
|
368
364
|
// GET/HEAD 没有请求体:直接收尾发出去,不要依赖 `req` 的 `end` 事件 —— SSE 客户端的
|
|
369
|
-
// 那条连接未必会像普通 fetch 一样把请求流收掉,`pipe` 一直不 end
|
|
370
|
-
// (2026-10-24 现场:设备级 SSE 每 32 秒重试,宿主侧一次都没进处理器)。
|
|
365
|
+
// 那条连接未必会像普通 fetch 一样把请求流收掉,`pipe` 一直不 end,上游就永远不处理。
|
|
371
366
|
if (req.method === 'GET' || req.method === 'HEAD') up.end();
|
|
372
367
|
else req.pipe(up);
|
|
373
368
|
}
|
|
@@ -783,16 +778,14 @@ function createLanProxy(opts) {
|
|
|
783
778
|
})
|
|
784
779
|
|
|
785
780
|
/**
|
|
786
|
-
* IPv6 那条监听:拿 IPv4 实绑的端口,`::` +
|
|
781
|
+
* IPv6 那条监听:拿 IPv4 实绑的端口,`::` + 显式 `ipv6Only: true`。
|
|
787
782
|
*
|
|
788
|
-
*
|
|
789
|
-
*
|
|
790
|
-
*
|
|
791
|
-
*
|
|
792
|
-
* 之后 v6 那个 socket 不碰 IPv4(不设这个标志在 Linux 上才会撞),同端口可以共存。
|
|
783
|
+
* 不合成一条双栈监听:`impl.js` 的端口占用探测真去 `listen(port, '0.0.0.0')` 试探,
|
|
784
|
+
* 一条双栈的 `::` 会把首选端口一律探成「被占」,于是落到内核随机端口、端口每次启动都
|
|
785
|
+
* 可能漂;把探测也改成 `::`,关掉 IPv6 的机器上又会因 EAFNOSUPPORT 永远判「被占」。
|
|
786
|
+
* 各管一个协议族就没这个问题。
|
|
793
787
|
*
|
|
794
|
-
*
|
|
795
|
-
* 只留 IPv4 直连——绝不影响 IPv4,也绝不往上抛。
|
|
788
|
+
* 它是软的:只有「手机不在同一 Wi-Fi、只有 IPv6」时才用得上,绑不上就记一行、只留 IPv4。
|
|
796
789
|
*/
|
|
797
790
|
const startV6 = () => new Promise((onDone) => {
|
|
798
791
|
const v6 = http.createServer(handleRequest);
|
|
@@ -3,16 +3,13 @@
|
|
|
3
3
|
/**
|
|
4
4
|
* 配对授权策略。
|
|
5
5
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* 返回 { approved: false } 的行为完全一致。
|
|
6
|
+
* 无头宿主没有弹窗确认界面,授权改由本地策略决定。两条链路都汇聚到父进程的
|
|
7
|
+
* `/api/relay/internal/approval`:隧道收到 pairRequest 时,以及局域网代理的 onApprove。
|
|
8
|
+
* sidecar 只接受显式 true,所以 'deny' 与恒定返回 `{ approved: false }` 等价。
|
|
10
9
|
*
|
|
11
|
-
* 'code' —
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
* /api/relay/regenerate 轮换、用 /api/relay/revoke-all 连同 lanSecret
|
|
15
|
-
* 一并作废。
|
|
10
|
+
* 'code' — 凭配对码放行。两条链路在询问宿主之前都已用恒定时间比较校验过配对码,中转还有
|
|
11
|
+
* 失败锁定;配对码只在用户主动索取时展示,可用 `/api/relay/regenerate` 轮换、
|
|
12
|
+
* 用 `/api/relay/revoke-all` 连同 lanSecret 一并作废。
|
|
16
13
|
* 'deny' — 拒绝一切新配对。
|
|
17
14
|
*/
|
|
18
15
|
|
package/lib/relay/preview.js
CHANGED
|
@@ -3,34 +3,17 @@
|
|
|
3
3
|
/**
|
|
4
4
|
* 预览字节管道(宿主侧)。
|
|
5
5
|
*
|
|
6
|
-
* 手机 App
|
|
7
|
-
*
|
|
8
|
-
* 段跑在宿主进程里的搬运工——就是这里。
|
|
6
|
+
* 手机 App 的内置浏览器要看到桌面机上「开发中的网页」,而那些 dev server 只监听宿主的
|
|
7
|
+
* 127.0.0.1:<port>,手机、中转、局域网代理都够不着。所以链路上有一段跑在宿主进程里的搬运工。
|
|
9
8
|
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
* 建立管道时协商一次,之后这管道对内容一无所知,只搬字节。
|
|
9
|
+
* 是字节管道而不是 HTTP 代理:一个页面是几百次请求加一条活着的 HMR WebSocket,端口在建立
|
|
10
|
+
* 管道时协商一次,之后完全不解析内容。于是内置浏览器的地址栏与实际 Host 都是
|
|
11
|
+
* `localhost:3001`,dev server 生成的绝对地址自动闭环、零配置;做路径改写反而会立刻全 404。
|
|
14
12
|
*
|
|
15
|
-
*
|
|
16
|
-
* 的 dev server 看到的 Host 也是 `localhost:3001`——两边对「我在哪」的认知完全一致。
|
|
17
|
-
* 于是 dev server 生成的一切绝对地址(资源路径、接口地址、跳转 Location、HMR 连接
|
|
18
|
-
* 地址)都自动闭环,**dev server 零配置**。反过来说,如果这里做半吊子的路径改写,
|
|
19
|
-
* `/assets/app.js` 这类绝对路径立刻全 404。
|
|
13
|
+
* 环回地址要双栈试(macOS 上不少 dev server 只监听 `::1`)。WS 升级、SSE、分块传输原样穿过。
|
|
20
14
|
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
* - 手机侧的原生模块把 WebView 写下的原始字节塞进 WebSocket 的二进制帧;
|
|
24
|
-
* - 这里 `net.connect(<环回地址>, port)` 之后双向对搬。环回地址要**双栈**试:macOS 上
|
|
25
|
-
* 不少 dev server(Vite 等)默认只监听 `::1`,只拨 `127.0.0.1` 会连不上;
|
|
26
|
-
* - 中间任何一层都不解析、不改写,所以 WS 升级、SSE、分块传输、自定义子协议全都
|
|
27
|
-
* 原样穿过(HMR 不用砍也不用绕——浏览器和 Vite 自己完成握手与分帧)。
|
|
28
|
-
*
|
|
29
|
-
* 代价要说清楚:正因为宿主无法审查内容,**端口就是这里唯一的边界**,所以下面有
|
|
30
|
-
* `protectedPorts()` 那道按身份的拒绝(DSH 自身的 web 端口永远不许从预览进来)。
|
|
31
|
-
*
|
|
32
|
-
* 与仓库里 `dsh-mobile-server/scripts/adb-ws-bridge.cjs` 是同一个套路(原始 TCP 流包进 WebSocket 帧),
|
|
33
|
-
* 那个先例证明这条路在本项目里走得通。
|
|
15
|
+
* 代价:宿主无法审查内容,端口就是唯一边界,所以 `protectedPorts()` 按身份拒绝——DSH 自身的
|
|
16
|
+
* web 端口永远不许从预览进来。
|
|
34
17
|
*/
|
|
35
18
|
|
|
36
19
|
const net = require('node:net');
|
package/lib/relay/protocol.js
CHANGED
|
@@ -3,20 +3,13 @@
|
|
|
3
3
|
/**
|
|
4
4
|
* dsh-mobile-server 隧道协议 v2 编解码(取代 v1 的 JSON/base64 文本帧)。
|
|
5
5
|
*
|
|
6
|
-
* 每条隧道消息 = 一个 WS binary
|
|
7
|
-
*
|
|
6
|
+
* 每条隧道消息 = 一个 WS binary 帧:`[type: 1B][id: 4B 大端][length: 4B 大端][payload]`。
|
|
7
|
+
* 数据通道的 payload 是原始字节(不再 base64),控制消息是 UTF-8 JSON,ping/pong 为应用层
|
|
8
|
+
* 心跳(经代理/负载均衡时比 WS 原生 ping 可靠)。
|
|
8
9
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* - ping/pong 为应用层心跳(经代理/负载均衡时比 WS 原生 ping 更可靠)。
|
|
13
|
-
*
|
|
14
|
-
* 协议语义见 ../../../server/dsh-mobile-server/server/relay-core/PROTOCOL.md。
|
|
15
|
-
*
|
|
16
|
-
* 本文件是自包含副本(插件包不能跨目录引用中转仓库的文件),完整内联了共用类型表
|
|
17
|
-
* 与编解码实现。类型表的事实源在独立中转仓库的 server/relay-core/protocol-types.js;
|
|
18
|
-
* 本仓的 protocol-parity 测试钉住线格式,中转仓库另做 Rust 与 JS 字节对撞;
|
|
19
|
-
* 改动帧编号后必须同步修改本文件。
|
|
10
|
+
* 本文件是自包含副本(插件包不能跨目录引用中转仓库):完整内联了共用类型表与编解码实现。
|
|
11
|
+
* 事实源在中转仓库的 relay-core,本仓的 protocol-parity 测试钉住线格式;改动帧编号后必须
|
|
12
|
+
* 同步修改本文件。
|
|
20
13
|
*/
|
|
21
14
|
|
|
22
15
|
const T = {
|
package/lib/relay/status.js
CHANGED
|
@@ -3,15 +3,12 @@
|
|
|
3
3
|
/**
|
|
4
4
|
* 中转状态视图:把「此刻这台宿主到底怎么样」拼成一份 JSON。
|
|
5
5
|
*
|
|
6
|
-
* 这份 JSON
|
|
7
|
-
* `/api/mobile/v1/status
|
|
8
|
-
*
|
|
9
|
-
* 某个字段拼对没有,只能通读 1000 行装配代码。抽出来之后可以直接 require 单元测试,
|
|
10
|
-
* 也让 index.js 只剩装配。
|
|
6
|
+
* 这份 JSON 有三个消费方,形状必须一模一样:桌面设置页、手机首屏都轮询
|
|
7
|
+
* `/api/mobile/v1/status`,二维码里嵌的也是它的子集。抽出来之后可以直接 require 测试,
|
|
8
|
+
* index.js 只剩装配。
|
|
11
9
|
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
* 当前唯一穿过隧道边界回来的信息。判定放宽到只认关键词,见下。
|
|
10
|
+
* `TUNNEL_ADMISSION` 靠正则匹配中转返回的错误文案来判断隧道为什么被拒。这是个脆弱契约
|
|
11
|
+
* (中转改一个字就漏判),但错误文案是当前唯一穿过隧道边界回来的信号,判定放宽到只认关键词。
|
|
15
12
|
*/
|
|
16
13
|
|
|
17
14
|
const os = require('node:os')
|
package/lib/relay/tunnel.js
CHANGED
|
@@ -44,18 +44,17 @@ class RelayTunnel {
|
|
|
44
44
|
* @param {object} opts
|
|
45
45
|
* @param {string} opts.relayUrl 隧道 WSS 地址(含 /tunnel),如 wss://relay.example.com/tunnel
|
|
46
46
|
* @param {string} opts.instanceId
|
|
47
|
-
* @param {string} [opts.pairingCode]
|
|
47
|
+
* @param {string} [opts.pairingCode] 仅本进程自用(面板显示),不发往中转
|
|
48
48
|
* @param {string} [opts.name]
|
|
49
49
|
* @param {string[]} [opts.lanUrls] LAN sidecar candidates (metadata only)
|
|
50
50
|
* @param {object[]} [opts.lanCandidates] LAN candidate metadata (metadata only)
|
|
51
51
|
* @param {string} opts.upstream 本地上游,如 http://127.0.0.1:52323
|
|
52
|
-
* @param {string} [opts.registerSecret] 中转侧 TUNNEL_REGISTER_SECRET
|
|
53
|
-
*
|
|
54
|
-
*
|
|
52
|
+
* @param {string} [opts.registerSecret] 中转侧 TUNNEL_REGISTER_SECRET 的对偶值,优先于环境
|
|
53
|
+
* 变量 DSH_RELAY_REGISTER_SECRET:桌面版从图标拉起时拿不到 shell 环境变量,密钥只能走
|
|
54
|
+
* relay.json(0600)。
|
|
55
55
|
* @param {() => void} [opts.onRegistered] 注册成功回调(用于状态展示)
|
|
56
56
|
* @param {(code: number, reason: string) => void} [opts.onClose] 连接断开回调(用于断线重连)
|
|
57
|
-
* @param {(msg:
|
|
58
|
-
* 中转转发来的配对授权请求(手机配对码已通过校验,需桌面用户确认)
|
|
57
|
+
* @param {(msg: object) => void} [opts.onPairRequest] 中转转发来的配对授权请求(配对码已校验)
|
|
59
58
|
* @param {(msg: string) => void} [opts.onLog]
|
|
60
59
|
*/
|
|
61
60
|
constructor(opts) {
|
|
@@ -110,15 +109,11 @@ class RelayTunnel {
|
|
|
110
109
|
}
|
|
111
110
|
|
|
112
111
|
/**
|
|
113
|
-
*
|
|
112
|
+
* 发送队列积压到上限时只丢这一帧,不断开隧道。
|
|
114
113
|
*
|
|
115
|
-
*
|
|
116
|
-
*
|
|
117
|
-
*
|
|
118
|
-
* 的 sendFrame)已经改成只 `return false`,两侧口径必须一致:丢一帧可以恢复,
|
|
119
|
-
* 关一条隧道是全网断流。
|
|
120
|
-
*
|
|
121
|
-
* 真正的「隧道已死」由心跳兜底:PONG_TIMEOUT_MS 收不到 pong 时才会 terminate。
|
|
114
|
+
* 积压本身是「手机/中转读得慢」,不是隧道坏了;掐掉整条隧道会让所有设备一起掉线再等 5s
|
|
115
|
+
* 重连。中转侧的同名判定已经改成只 `return false`,两侧口径必须一致:丢一帧可以恢复,
|
|
116
|
+
* 关一条隧道是全网断流。真正的「隧道已死」由心跳兜底:收不到 pong 才 terminate。
|
|
122
117
|
*/
|
|
123
118
|
dropOverBudget(bytes, frameLength) {
|
|
124
119
|
const total = bytes + frameLength;
|
|
@@ -3,19 +3,13 @@
|
|
|
3
3
|
/**
|
|
4
4
|
* 控制面路由(`/api/relay/*`):桌面端设置页与宿主本机运维动作走这里。
|
|
5
5
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* 后门开到公网;反过来,移动端请求可能来自中转,根本没有本机 socket 可言。
|
|
6
|
+
* 与移动端路由表是刻意的两套:控制面只认本机来源(localOnly),移动端只认 token(guarded)。
|
|
7
|
+
* 控制面会读进程级错误账本(含堆栈)、能改配置、能吊销全部授权,暴露给中转等于把后门开到
|
|
8
|
+
* 公网;反过来移动端请求可能来自中转,根本没有本机 socket 可言。
|
|
10
9
|
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
* sidecarState…)会被整体替换或重新赋值。`getSidecar` 尤其重要:sidecar 会被自重启换掉,
|
|
15
|
-
* 拿到实例本身就等于拿到一份指向旧 facade 的快照。
|
|
16
|
-
*
|
|
17
|
-
* 插件更新那两条:`check` 自己问 npm 注册表(不要求外壳在场),`apply` 只委托外壳的
|
|
18
|
-
* `apply`(判定 / 装 / 换代 / 落账同一层)——各自的取舍写在路由头上。
|
|
10
|
+
* 依赖以取值器 / 函数注入,不捕获对象:闭包里那些 `let`(sidecarSnapshot、sidecarState…)
|
|
11
|
+
* 会被整体替换。`getSidecar` 尤其重要:sidecar 会被自重启换掉,拿到实例就等于拿到旧 facade
|
|
12
|
+
* 的快照。
|
|
19
13
|
*/
|
|
20
14
|
|
|
21
15
|
const fs = require('node:fs');
|
|
@@ -99,14 +93,10 @@ function createWebAccessLocalOnly({ localOnly, trustedLocalUi, webAccessWindow,
|
|
|
99
93
|
}
|
|
100
94
|
|
|
101
95
|
/**
|
|
102
|
-
* 面板页(`React/`
|
|
103
|
-
*
|
|
104
|
-
* 用 `__dirname` 而不是包根,是为了「谁跟着这份路由一起走」:HTML 是**提交物**,和
|
|
105
|
-
* `lib/` 在同一份安装里,装哪个版本就带哪个版本的页面。
|
|
96
|
+
* 面板页(`React/` 构建成的单文件 HTML)在包里的位置。
|
|
106
97
|
*
|
|
107
|
-
*
|
|
108
|
-
*
|
|
109
|
-
* 一条路:`React/` 里重建 → 提交产物 → 发新版本 → 市场里更新 → 完整重启 DSH。
|
|
98
|
+
* 用 `__dirname` 而不是包根:HTML 是提交物,和 `lib/` 在同一份安装里,装哪个版本就带哪个
|
|
99
|
+
* 版本的页面。改版式只有一条路:`React/` 里重建 → 提交产物 → 发新版本 → 完整重启 DSH。
|
|
110
100
|
*/
|
|
111
101
|
const PANEL_FILE = path.join(__dirname, '..', 'panel', 'index.html');
|
|
112
102
|
|
|
@@ -145,20 +135,15 @@ function panelHtml() {
|
|
|
145
135
|
}
|
|
146
136
|
|
|
147
137
|
/**
|
|
148
|
-
*
|
|
138
|
+
* 面板读不到时,说清楚是哪一种读不到。
|
|
149
139
|
*
|
|
150
|
-
*
|
|
151
|
-
*
|
|
140
|
+
* 两种情形解法完全不同,所以分开报,并把路径与版本一起带上:
|
|
141
|
+
* 1. 安装目录整个不见了——市场里装/更新过、DSH 还没完整重启,跑着的仍是旧实例,而旧实例
|
|
142
|
+
* 所在的目录已被这次安装删掉。代码在内存里所以路由照常响应,`fs` 一读就 ENOENT。解法是
|
|
143
|
+
* 重启 DSH,不是去跑构建。
|
|
144
|
+
* 2. 这份安装里根本没有产物——老版本还没有这一页。解法是更新到当前版本。
|
|
152
145
|
*
|
|
153
|
-
*
|
|
154
|
-
* 重启,跑着的仍是旧实例,而旧实例所在的 pnpm 目录已经被这次安装删掉了 —— 代码在内存里,
|
|
155
|
-
* 所以路由照常响应,`fs` 一读就 ENOENT,面板页于是吐一段 JSON。解法是**重启 DSH**,
|
|
156
|
-
* 不是去跑构建。
|
|
157
|
-
* 2. **这份安装里根本没有产物**:0.1.47 及更早的版本还没有这一页(`files` 白名单当时
|
|
158
|
-
* 没收 `lib/` 下的 html),老包里点开就是这条。解法是**更新到当前版本**。
|
|
159
|
-
*
|
|
160
|
-
* 两件事的解法完全不同,所以分开报,并把路径与版本一起带上 —— 下次看到这段报错,一眼就知道
|
|
161
|
-
* 该重启还是该升级。三种情形由 `test/panel-artifact.test.cjs` 钉住。
|
|
146
|
+
* 三种情形由 `test/panel-artifact.test.cjs` 钉住。
|
|
162
147
|
*
|
|
163
148
|
* @param {{panelFile?: string, packageDir?: string}} [paths] 只给测试喂临时目录用;运行时走默认路径
|
|
164
149
|
* @returns {{reason: 'install-gone'|'artifact-missing'|'unreadable', message: string}}
|
|
@@ -358,18 +343,16 @@ function registerControlPlaneRoutes(deps) {
|
|
|
358
343
|
registerRoute({ kind: 'exact', path: '/api/relay/addresses', handler: localOnly('GET', async (_req, res) => {
|
|
359
344
|
const port = lanPortInUse()
|
|
360
345
|
const preferred = typeof state.preferredLanAddress === 'string' ? state.preferredLanAddress : ''
|
|
361
|
-
|
|
346
|
+
// `manual` 是用户手填的直连地址(内网穿透域名):网卡扫不出来,所以单列一格让面板回显与修改。
|
|
347
|
+
const manual = typeof state.manualLanUrl === 'string' ? state.manualLanUrl : ''
|
|
348
|
+
sendJson(res, 200, { ok: true, port, preferred, manual, lan: lanCandidates(port), public: publicCandidates(port), ipv6: ipv6Candidates(port) })
|
|
362
349
|
}) })
|
|
363
350
|
/**
|
|
364
351
|
* 公用中转候选的测速(`POST /api/relay/probe-relays`,body `{ urls: string[] }`)。
|
|
365
352
|
*
|
|
366
|
-
*
|
|
367
|
-
*
|
|
368
|
-
*
|
|
369
|
-
*
|
|
370
|
-
* 判定分两步:`/healthz` 回 2xx **且** `/api/mobile/v1/health` 回的 `service` 是 `dsh-mobile-relay`。
|
|
371
|
-
* 只看 `/healthz` 不行——任何静态站都可能回 200,随手敲一个域名就被当成「可用中转」。
|
|
372
|
-
* 超时的照实报 `null`(前端按「不通」排在后面),不编造数字。
|
|
353
|
+
* 必须由宿主代测:引导页在浏览器里,直连那些域名会被 CORS 挡住,读到的是浏览器拦截而不是
|
|
354
|
+
* 真实耗时。判定分两步:`/healthz` 回 2xx 且 `/api/mobile/v1/health` 回的 `service` 是
|
|
355
|
+
* `dsh-mobile-relay`——只看 `/healthz` 的话任何静态站都会被当成可用中转。超时照实报 null。
|
|
373
356
|
*/
|
|
374
357
|
registerRoute({ kind: 'exact', path: '/api/relay/probe-relays', handler: localOnly('POST', async (req, res) => {
|
|
375
358
|
const body = await readJson(req)
|
|
@@ -392,7 +375,7 @@ function registerControlPlaneRoutes(deps) {
|
|
|
392
375
|
return { url, ms: null }
|
|
393
376
|
}
|
|
394
377
|
// **只认运维意义上「这一台确实是中转」**:`/healthz` 回 200 的站点满互联网都是,
|
|
395
|
-
//
|
|
378
|
+
// 光看它等于把随便一个域名也当可用中转。
|
|
396
379
|
// 中转自己那条地址体检 `/api/mobile/v1/health` 会带回 `service`,对不上就不是它。
|
|
397
380
|
const who = await fetch(`${url}/api/mobile/v1/health`, { signal: controller.signal, redirect: 'follow' })
|
|
398
381
|
.then((r) => (r.ok ? r.json() : null))
|
|
@@ -538,7 +521,7 @@ function registerControlPlaneRoutes(deps) {
|
|
|
538
521
|
/**
|
|
539
522
|
* `?relayUrl=` 是**只看不写**的预览:按这个候选地址生成二维码,但一个字都不落盘。
|
|
540
523
|
*
|
|
541
|
-
*
|
|
524
|
+
* 给「自建中转填完就想看到码」用。以前那版是「填完就把地址写进
|
|
542
525
|
* 配置」,等于拿一条还没验过的地址覆盖正在用的设置;现在预览走这里,真写入只在用户
|
|
543
526
|
* 明确按下「连接中转」时发生。
|
|
544
527
|
*/
|
|
@@ -703,9 +686,9 @@ function registerControlPlaneRoutes(deps) {
|
|
|
703
686
|
await syncSidecar()
|
|
704
687
|
sendJson(res, 200, status())
|
|
705
688
|
}) })
|
|
706
|
-
// ──
|
|
689
|
+
// ── 插件更新 ──
|
|
707
690
|
//
|
|
708
|
-
//
|
|
691
|
+
// 分工:**查归插件自己**(问 npm 注册表,`../update-registry.cjs`)、
|
|
709
692
|
// **装也归插件自己**(在 profile 里跑它自己的包管理器,`../update-install.cjs`),
|
|
710
693
|
// **换代码留给外壳**(`lib/index.js` 的 runtime)。市场不再是这条路上的任何一环。
|
|
711
694
|
//
|
|
@@ -733,7 +716,7 @@ function registerControlPlaneRoutes(deps) {
|
|
|
733
716
|
// 拿不到(无头部署 / 没有外壳的那一版)就退回这一代自己的版本号。
|
|
734
717
|
registerRoute({ kind: 'exact', path: '/api/relay/update/check', handler: localOnly('POST', async (_req, res) => {
|
|
735
718
|
const runtime = pluginRuntime()
|
|
736
|
-
// 形状必须和上面那条 `update/status` **一模一样**(含 `available
|
|
719
|
+
// 形状必须和上面那条 `update/status` **一模一样**(含 `available`):
|
|
737
720
|
// 这条只回 `runtime.status()`,面板拿它 `setHost()` 覆盖掉好状态,`available` 变成 undefined,
|
|
738
721
|
// 卡片当场从「宿主模式」掉进取降级那一半 —— 用户看到的现象是「有新版本却没有『立即更新』」。
|
|
739
722
|
const snapshot = runtime ? { available: true, ...runtime.status() } : null
|
|
@@ -757,7 +740,7 @@ function registerControlPlaneRoutes(deps) {
|
|
|
757
740
|
// 装器装新版、装完就地换代,并把这次结果写进它那本 `lastApply` 账 —— 面板「最近一次更新」
|
|
758
741
|
// 与那颗「强制更新」按钮的「上次没装成」判据读的都是这本账。
|
|
759
742
|
//
|
|
760
|
-
//
|
|
743
|
+
// 为什么这条路由不再自己去装:从前它自己调 `installLatestIfNeeded` 再
|
|
761
744
|
// `reload`,判定确实和自动更新是同一套(外壳那条路最后也走盘上那一代的同一个函数),
|
|
762
745
|
// **但结果没人落账** —— 手动(含强制)更新装完,外壳的账一动不动,面板那一行永远停在上一次
|
|
763
746
|
// 自动更新的结果上。判定与记账必须是同一个动作,所以这里只负责传参、把外壳记完账的状态
|
package/lib/routes/mobile-api.js
CHANGED
|
@@ -3,20 +3,12 @@
|
|
|
3
3
|
/**
|
|
4
4
|
* 移动端 API 路由表(`/api/mobile/v1/*`)。
|
|
5
5
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
6
|
+
* 它本身没有逻辑,绝大部分是「路径 → 已写好的 handler」的纯映射,只有 status /
|
|
7
|
+
* connection-info / desktop-view 三条带一点内联逻辑。抽成一张表,是为了能一眼确认「App 打的
|
|
8
|
+
* 某个地址到底注册没有」——历史上 `workspace/rename` 与 `workspace/archive-session` 就是
|
|
9
|
+
* App 一直在打、插件从来没注册,手机端必然 404;这张表也能直接对着 App 侧 apiPaths.ts 核对。
|
|
10
10
|
*
|
|
11
|
-
*
|
|
12
|
-
* 历史上 `workspace/rename` 与 `workspace/archive-session` 就是 App 一直在打、
|
|
13
|
-
* 插件从来没注册,手机端必然 404。
|
|
14
|
-
* 2. 改一条路径要在 1000 行文件里找。
|
|
15
|
-
*
|
|
16
|
-
* 现在它是一张可以一眼读完、可以对着 App 侧 apiPaths.ts 核对的表。
|
|
17
|
-
*
|
|
18
|
-
* 依赖注入只留三样:注册器、移动端守卫、以及两个会变的值。handler 们直接 require,
|
|
19
|
-
* 因为它们本来就只是被这张表挂上去的。
|
|
11
|
+
* 依赖注入只留注册器、移动端守卫与几个收益于取值器的值;handler 们直接 require。
|
|
20
12
|
*/
|
|
21
13
|
|
|
22
14
|
const { sendJson } = require('../http/routing.js');
|
|
@@ -67,25 +59,17 @@ const API_VERSION = '1';
|
|
|
67
59
|
* @param {(handler: Function) => Function} deps.guarded 移动端守卫(token + 500 信封)
|
|
68
60
|
* @param {object} deps.state 内存里的中转状态(本进程唯一真值)
|
|
69
61
|
* @param {() => object} deps.hostMetadata 宿主元信息(隧道准入状态等)
|
|
70
|
-
* @param {() => object} deps.getSidecarSnapshot
|
|
71
|
-
*
|
|
72
|
-
* let 变量,闭包捕获对象会让 handler 永远读到启动那一刻的旧快照(lan.updatedAt 停在
|
|
73
|
-
* 进程启动时间,手机端看连接信息像失联了一样)。
|
|
62
|
+
* @param {() => object} deps.getSidecarSnapshot 取快照的取值器,必须传函数:快照会被整体替换,
|
|
63
|
+
* 闭包捕获对象会永远读到启动那一刻的旧快照。
|
|
74
64
|
* @param {(port: number) => Array<object>} deps.safeLanCandidates 局域网候选地址
|
|
75
|
-
* @param {(port: number) => Array<object>} deps.safeIpv6Candidates 公网 IPv6
|
|
76
|
-
*
|
|
77
|
-
*
|
|
78
|
-
*
|
|
79
|
-
*
|
|
80
|
-
*
|
|
81
|
-
*
|
|
82
|
-
*
|
|
83
|
-
* 交给 ctx.webServer 的**升级**路由注册器(`registerUpgrade`)。与 registerRoute 是
|
|
84
|
-
* 两套名字空间:同一个 path 可以既有一条普通路由又有一条升级路由,但升级路由重复注册
|
|
85
|
-
* 会抛错。
|
|
86
|
-
* @param {object} deps.preview 预览字节管道服务(lib/relay/preview.js)。它自带鉴权与
|
|
87
|
-
* 端口边界,所以**不能**包在 guarded 里——guarded 回的是 JSON,而升级请求需要的是
|
|
88
|
-
* 一次拒绝或一次握手。
|
|
65
|
+
* @param {(port: number) => Array<object>} deps.safeIpv6Candidates 公网 IPv6 直连候选,与上一份
|
|
66
|
+
* 并列:手机拿它和中转比快慢,掺进局域网那份会共用失败退避。
|
|
67
|
+
* @param {() => number} deps.lanPortInUse 手机该拨的局域网端口,必须用取值器:sidecar 实绑的
|
|
68
|
+
* 端口可能与 state 里解析出的不同。
|
|
69
|
+
* @param {(route: { path: string, handler: Function }) => void} deps.registerUpgradeRoute 升级路由
|
|
70
|
+
* 注册器;与 registerRoute 是两套名字空间,同一 path 可以两者都有,但升级路由重复注册会抛错。
|
|
71
|
+
* @param {object} deps.preview 预览字节管道服务。它自带鉴权与端口边界,不能包在 guarded 里——
|
|
72
|
+
* 升级请求需要的是一次拒绝或一次握手,不是 JSON。
|
|
89
73
|
*/
|
|
90
74
|
function registerMobileRoutes(deps) {
|
|
91
75
|
const { registerRoute, registerUpgradeRoute, guarded, state, hostMetadata, getSidecarSnapshot, safeLanCandidates, safeIpv6Candidates, lanPortInUse, preview, core, pairingApproval, rotatePairing } = deps;
|
|
@@ -122,7 +106,7 @@ function registerMobileRoutes(deps) {
|
|
|
122
106
|
// 凭据,所以这条刻意不包 guarded(豁免与理由见 test/mobile-routes.test.cjs 的
|
|
123
107
|
// PUBLIC_ROUTES)。它能说的话只有服务名与版本——没有实例信息、没有任何能力面。
|
|
124
108
|
// 中转侧在 server.js 的同名路径上回 'dsh-mobile-relay',App 靠这个字段分辨
|
|
125
|
-
//
|
|
109
|
+
// 「填的是宿主还是中转」。
|
|
126
110
|
//
|
|
127
111
|
// ⚠️ 签名必须是 `(req, res)` **两个**参数:宿主 webServer 就是 `handler(req, res)` 调的,
|
|
128
112
|
// 上面那条 `guarded(...)` 之所以能收 `(ctx, req, res)`,是因为那三个参数由 guarded 自己补。
|
|
@@ -278,6 +262,9 @@ function registerMobileRoutes(deps) {
|
|
|
278
262
|
// 「宿主最近收到的快照」;从未收到过时返回 view: null,App 按桌面的折叠缺省处理。
|
|
279
263
|
route('/api/mobile/v1/desktop-view', async (_ctx, _req, res) => sendJson(res, 200, { ok: true, view: desktopView.snapshot() }))
|
|
280
264
|
route('/api/mobile/v1/directory/list', workspaces.handleListDirectory)
|
|
265
|
+
// 盘符层:宿主 browse 后端只列目录内容、不枚举盘符,主目录在 C 盘时手机端看不到别的盘
|
|
266
|
+
// (Windows 多硬盘现场)。枚举在被控端做,见 mobile/drive-roots.js。
|
|
267
|
+
route('/api/mobile/v1/directory/roots', workspaces.handleDirectoryRoots)
|
|
281
268
|
// 右栏文件浏览器要的那一份:文件与文件夹都列、带类型/大小/修改时间,见
|
|
282
269
|
// mobile/directory-listing.js。与上面那条**并存**:那条是「挑工作区目录」用的,只列
|
|
283
270
|
// 能进去的目录,行为不变。
|