dsh-mobile-plugin 0.1.77 → 0.1.82
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 +10 -1
- package/lib/impl.js +12 -1
- package/lib/mobile/history-derive.js +75 -8
- package/lib/mobile/host-bridge/transport.js +33 -5
- package/lib/mobile/pairing.js +211 -0
- package/lib/mobile/uploads.js +48 -6
- package/lib/panel/index.html +8 -6
- package/lib/relay/lan-network.cjs +44 -1
- package/lib/relay/lan-proxy.js +43 -11
- package/lib/relay/protocol.js +5 -4
- package/lib/routes/control-plane.js +38 -0
- package/lib/routes/mobile-api.js +28 -5
- package/lib/shell/device-ota.js +15 -1
- package/lib/shell/manager.js +80 -1
- package/package.json +1 -1
|
@@ -15,6 +15,46 @@ function ipv4Number(address) {
|
|
|
15
15
|
return (((parts[0] * 256 + parts[1]) * 256 + parts[2]) * 256 + parts[3]) >>> 0
|
|
16
16
|
}
|
|
17
17
|
function inRange(value, start, end) { return value >= start && value <= end }
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* RFC1918 里**不由容器/虚拟机/虚拟网桥占掉**的那几段。
|
|
21
|
+
*
|
|
22
|
+
* 172.16/12 原本是一个整段,但 Docker 默认把 `172.17.0.0/16` 给 `docker0`,于是
|
|
23
|
+
* 客户机器上的 `172.17.0.1` 会被当成一条「局域网地址」摆到面板与二维码里 —— 手机拨过去
|
|
24
|
+
* 要么不通,要么连到容器上的别的东西。同理 `172.18/16`(docker compose 的默认网络)、
|
|
25
|
+
* `172.19/16` 与 `172.20/16`(compose / k3d 的下一批)、`172.24~172.31/16`(kubeadm 的
|
|
26
|
+
* `k8s-pod-network` 与常见 overlay)也都是工具按默认值抢走的。
|
|
27
|
+
*
|
|
28
|
+
* 判据是「这个段是不是被某个常见的非宿主网络工具预留了」,而不是「这台机器上有没有跑它」:
|
|
29
|
+
* 我们读不到网络配置,只能按默认值收敛。留下的 172.16~172.23 是 172.16/12 里没被任何
|
|
30
|
+
* 主流工具默认占用的部分;真有人在这几段上跑业务网,网卡名黑名单(下一条)仍然是第一道闸。
|
|
31
|
+
*/
|
|
32
|
+
const RESERVED_172 = [
|
|
33
|
+
[0xac110000, 0xac11ffff], // 172.17/16 docker0
|
|
34
|
+
[0xac120000, 0xac12ffff], // 172.18/16 docker compose 默认网络
|
|
35
|
+
[0xac130000, 0xac13ffff], // 172.19/16 compose 的下一个
|
|
36
|
+
[0xac140000, 0xac14ffff], // 172.20/16 compose / k3d
|
|
37
|
+
[0xac180000, 0xac1fffff], // 172.24~172.31/16 kubeadm / overlay
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* 能不能把它当成「手机在同一个 Wi-Fi 里能直连的地址」。
|
|
42
|
+
*
|
|
43
|
+
* 注意它**不等于** `isPrivateLanAddress`:后者是「这个地址属于私有段」这个事实判断,
|
|
44
|
+
* 前者是「要不要拿它当候选」这个用法判断。172.17.0.1 是私有地址,但它是 docker0 的网桥
|
|
45
|
+
* 地址,当成候选就是让手机去拨一个它到不了的地方。
|
|
46
|
+
*/
|
|
47
|
+
function isLanCandidateAddress(address) {
|
|
48
|
+
const value = ipv4Number(address)
|
|
49
|
+
if (value === null) return false
|
|
50
|
+
if (inRange(value, 0x0a000000, 0x0affffff)) return true // 10/8
|
|
51
|
+
if (inRange(value, 0xc0a80000, 0xc0a8ffff)) return true // 192.168/16
|
|
52
|
+
if (inRange(value, 0xac100000, 0xac1fffff)) {
|
|
53
|
+
return !RESERVED_172.some(([start, end]) => inRange(value, start, end)) // 172.16/12 去掉上面那几段
|
|
54
|
+
}
|
|
55
|
+
return false
|
|
56
|
+
}
|
|
57
|
+
|
|
18
58
|
function isPrivateLanAddress(address) {
|
|
19
59
|
const value = ipv4Number(address)
|
|
20
60
|
if (value === null) return false
|
|
@@ -92,7 +132,7 @@ function toCandidates(list, port) {
|
|
|
92
132
|
// and the app's `candidateUrls()` both accept this shape, so callers that want
|
|
93
133
|
// interface metadata (for diagnostics or smarter ordering) should prefer it.
|
|
94
134
|
function lanInterfaces(port, interfaces = os.networkInterfaces()) {
|
|
95
|
-
return toCandidates(collectEndpoints(interfaces,
|
|
135
|
+
return toCandidates(collectEndpoints(interfaces, isLanCandidateAddress), port)
|
|
96
136
|
}
|
|
97
137
|
function lanUrls(port, interfaces = os.networkInterfaces()) {
|
|
98
138
|
return lanInterfaces(port, interfaces).map((candidate) => candidate.url)
|
|
@@ -128,6 +168,8 @@ const NOT_PUBLIC_RANGES = [
|
|
|
128
168
|
function isPublicAddress(address) {
|
|
129
169
|
const value = ipv4Number(address)
|
|
130
170
|
if (value === null) return false
|
|
171
|
+
// 被工具抢走的私有段(172.17 之类)既不是「同一 Wi-Fi 能直连的地址」,也不是公网地址:
|
|
172
|
+
// 它们只是容器网桥,摆到哪一份清单里都只会让手机拨一个到不了的地方。
|
|
131
173
|
if (isPrivateLanAddress(address)) return false
|
|
132
174
|
// 224/4 起是组播与保留(含 255.255.255.255)。
|
|
133
175
|
if (value >= 0xe0000000) return false
|
|
@@ -193,6 +235,7 @@ function orderCandidates(list, preferred) {
|
|
|
193
235
|
module.exports = {
|
|
194
236
|
ipv4Number,
|
|
195
237
|
isPrivateLanAddress,
|
|
238
|
+
isLanCandidateAddress,
|
|
196
239
|
isPublicAddress,
|
|
197
240
|
isVpnInterface,
|
|
198
241
|
isVirtualInterface,
|
package/lib/relay/lan-proxy.js
CHANGED
|
@@ -26,7 +26,10 @@ const PAIR_LOCK_MS = 60_000;
|
|
|
26
26
|
// 所以只在「还没握上」这段时间里计时。
|
|
27
27
|
const WS_UPSTREAM_HANDSHAKE_MS = 10_000;
|
|
28
28
|
// 上游已经握上、手机的握手还没完成的这一瞬间,上游先推过来的字节最多攒这么多。
|
|
29
|
-
|
|
29
|
+
// 它兜的是一个**停不下来的源头**(页面在自增、在循环 push,而手机那头永远不来握手),
|
|
30
|
+
// 不是「一轮推送有多大」:预览页面一次推几 MB 很正常,按 1MiB 判超限只会把正常的页面
|
|
31
|
+
// 判成 502。
|
|
32
|
+
const WS_UPSTREAM_PENDING_MAX_BYTES = 32 * 1024 * 1024;
|
|
30
33
|
|
|
31
34
|
function createLanProxy(opts) {
|
|
32
35
|
const {
|
|
@@ -295,6 +298,10 @@ document.getElementById('f').addEventListener('submit', async (e) => {
|
|
|
295
298
|
* 下一条候选地址(见 app/android/app/src/main/java/com/dsh/mobile/PreviewModule.kt
|
|
296
299
|
* 的 Pipe.connect);一旦回了 101,它就把这条连接当成可用,之后再断只会关掉整个
|
|
297
300
|
* 内置浏览器,永远不回退到中转那条路。上游拒绝时状态码必须原样回给手机。
|
|
301
|
+
* 3. **上游先推过来的字节要么还给手机,要么跟着这条连接一起放掉**。上游连上之后、
|
|
302
|
+
* 手机收到 101 之前,上游推来的东西只能暂存在 `pending` 里;凡是这条连接已经
|
|
303
|
+
* 废掉(手机走了、上游断了、攒不下)的地方都必须清空它。少一次,这份缓冲就
|
|
304
|
+
* 永久留在闭包里,几小时后宿主整进程 OOM。
|
|
298
305
|
*/
|
|
299
306
|
function handleUpgrade(req, socket, head) {
|
|
300
307
|
if (!authorized(req)) {
|
|
@@ -332,17 +339,34 @@ document.getElementById('f').addEventListener('submit', async (e) => {
|
|
|
332
339
|
|
|
333
340
|
const dropUpstream = () => { try { up.terminate(); } catch { /* 可能还没连上 */ } };
|
|
334
341
|
|
|
342
|
+
/**
|
|
343
|
+
* 丢暂存:这条路已经废了(手机走了、上游断了、攒不下)。
|
|
344
|
+
*
|
|
345
|
+
* 少这一次释放,每失败一次预览就永久留一份这份缓冲——手机那头不来握手的窗口里它
|
|
346
|
+
* 能涨到上限。凡是「这段窗口结束且不再交出去」的地方都必须走这里。
|
|
347
|
+
*/
|
|
348
|
+
const dropPending = () => {
|
|
349
|
+
pending.length = 0;
|
|
350
|
+
pendingBytes = 0;
|
|
351
|
+
};
|
|
352
|
+
|
|
335
353
|
const fail = (status, message) => {
|
|
336
354
|
if (settled) return;
|
|
337
355
|
settled = true;
|
|
338
356
|
log(`ws 升级未建立:${status} ${message}`);
|
|
357
|
+
dropPending();
|
|
339
358
|
dropUpstream();
|
|
340
359
|
refuseSocket(socket, status, message);
|
|
341
360
|
};
|
|
342
361
|
|
|
343
362
|
// 手机在等上游的时候先走了:别白连上游。这两个监听还兼着兜底 socket 错误——
|
|
344
363
|
// 这段窗口里 socket 是我们在拿着,没有人接 'error' 会冒泡成未捕获异常。
|
|
345
|
-
const abandon = () => {
|
|
364
|
+
const abandon = () => {
|
|
365
|
+
if (settled) return;
|
|
366
|
+
settled = true;
|
|
367
|
+
dropPending();
|
|
368
|
+
dropUpstream();
|
|
369
|
+
};
|
|
346
370
|
socket.on('error', abandon);
|
|
347
371
|
socket.on('close', abandon);
|
|
348
372
|
|
|
@@ -360,9 +384,7 @@ document.getElementById('f').addEventListener('submit', async (e) => {
|
|
|
360
384
|
}
|
|
361
385
|
pendingBytes += chunk.length;
|
|
362
386
|
if (pendingBytes > WS_UPSTREAM_PENDING_MAX_BYTES) {
|
|
363
|
-
|
|
364
|
-
dropUpstream();
|
|
365
|
-
socket.destroy();
|
|
387
|
+
fail(502, '上游数据积压超过上限');
|
|
366
388
|
return;
|
|
367
389
|
}
|
|
368
390
|
pending.push(chunk);
|
|
@@ -371,21 +393,29 @@ document.getElementById('f').addEventListener('submit', async (e) => {
|
|
|
371
393
|
up.on('open', () => {
|
|
372
394
|
upstreamReady = true;
|
|
373
395
|
if (settled) return;
|
|
396
|
+
// 暂停并**同步**把握手交给 `ws` 办:它是先读掉握手响应、再在 socket 上回调,上游
|
|
397
|
+
// 紧跟着推的第一帧此刻已经在缓冲里。中间插一次 await 或 setTimeout,`message` 就
|
|
398
|
+
// 可能先于握手回调到达,那一帧落进暂存分支之后再没人发出去。预览页面刚加载正是
|
|
399
|
+
// 这种推送节奏。
|
|
400
|
+
up.pause();
|
|
374
401
|
wss.handleUpgrade(req, socket, head, (ws) => {
|
|
375
402
|
settled = true;
|
|
376
403
|
phoneWs = ws;
|
|
404
|
+
try {
|
|
405
|
+
// 先补发暂存、再放行上游:暂停期间挂监听不会丢,恢复才一次性派发。
|
|
406
|
+
for (const chunk of pending) {
|
|
407
|
+
if (ws.readyState !== WebSocket.OPEN) break;
|
|
408
|
+
ws.send(chunk);
|
|
409
|
+
}
|
|
410
|
+
dropPending();
|
|
411
|
+
up.resume();
|
|
412
|
+
} catch { /* 已断开 */ }
|
|
377
413
|
ws.on('message', (data) => {
|
|
378
414
|
if (up.readyState === WebSocket.OPEN) up.send(data);
|
|
379
415
|
});
|
|
380
416
|
ws.on('close', () => { try { up.close(); } catch { /* ignore */ } });
|
|
381
417
|
ws.on('error', () => { try { up.terminate(); } catch { /* ignore */ } });
|
|
382
418
|
up.on('close', () => { try { ws.close(); } catch { /* ignore */ } });
|
|
383
|
-
for (const chunk of pending) {
|
|
384
|
-
if (ws.readyState !== WebSocket.OPEN) break;
|
|
385
|
-
ws.send(chunk);
|
|
386
|
-
}
|
|
387
|
-
pending.length = 0;
|
|
388
|
-
pendingBytes = 0;
|
|
389
419
|
});
|
|
390
420
|
});
|
|
391
421
|
|
|
@@ -397,6 +427,8 @@ document.getElementById('f').addEventListener('submit', async (e) => {
|
|
|
397
427
|
return;
|
|
398
428
|
}
|
|
399
429
|
log(`ws 上游中断:${code}`);
|
|
430
|
+
// 上游已经出局,暂存再没有去处。
|
|
431
|
+
dropPending();
|
|
400
432
|
if (phoneWs && phoneWs.readyState === WebSocket.OPEN) {
|
|
401
433
|
try { phoneWs.close(1011, 'upstream error'); } catch { /* ignore */ }
|
|
402
434
|
}
|
package/lib/relay/protocol.js
CHANGED
|
@@ -11,11 +11,12 @@
|
|
|
11
11
|
* - 控制消息(register / httpHead 等)payload 为 UTF-8 JSON;
|
|
12
12
|
* - ping/pong 为应用层心跳(经代理/负载均衡时比 WS 原生 ping 更可靠)。
|
|
13
13
|
*
|
|
14
|
-
* 协议语义见 ../../../dsh-mobile-server/PROTOCOL.md(v2 章节)。
|
|
14
|
+
* 协议语义见 ../../../server/dsh-mobile-server/PROTOCOL.md(v2 章节)。
|
|
15
15
|
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
16
|
+
* 本文件是自包含副本(插件包不能跨目录引用中转仓库的文件),完整内联了共用类型表
|
|
17
|
+
* 与编解码实现。类型表的事实源是 server/relay-core/protocol-types.js,
|
|
18
|
+
* 由 dsh-mobile-plugin/test/protocol-parity.test.cjs 逐项对账;
|
|
19
|
+
* 改动帧编号后必须同步修改本文件。
|
|
19
20
|
*/
|
|
20
21
|
|
|
21
22
|
const T = {
|
|
@@ -44,6 +44,8 @@ const { clearCompletionForAllDevices, invalidateSessionList } = require('../mobi
|
|
|
44
44
|
|
|
45
45
|
/** 一次 desktop-read 上报最多带多少个会话 id(防灌爆;客户端半边一次最多也就几个)。 */
|
|
46
46
|
const MAX_DESKTOP_READ_IDS = 200;
|
|
47
|
+
/** 公用中转测速的单项超时:远端多半在境外,给足 6 秒但别让整页干等。 */
|
|
48
|
+
const RELAY_PROBE_TIMEOUT_MS = 6000;
|
|
47
49
|
|
|
48
50
|
function createWebAccessWindow({ now = Date.now, monotonicNow = () => performance.now() } = {}) {
|
|
49
51
|
let deadline = 0;
|
|
@@ -280,6 +282,41 @@ function registerControlPlaneRoutes(deps) {
|
|
|
280
282
|
const preferred = typeof state.preferredLanAddress === 'string' ? state.preferredLanAddress : ''
|
|
281
283
|
sendJson(res, 200, { ok: true, port, preferred, lan: lanCandidates(port), public: publicCandidates(port) })
|
|
282
284
|
}) })
|
|
285
|
+
/**
|
|
286
|
+
* 公用中转候选的测速(`POST /api/relay/probe-relays`,body `{ urls: string[] }`)。
|
|
287
|
+
*
|
|
288
|
+
* **为什么必须由宿主代测**:引导页在浏览器里打开,直连那些中转域名会被 CORS 挡住
|
|
289
|
+
* ——读到的是浏览器拦截,不是真实耗时,而且会把可用入口误判成不通。宿主是本机进程,
|
|
290
|
+
* 出站没有 CORS 可言,测出来的才是用户实际能用的那条。
|
|
291
|
+
*
|
|
292
|
+
* 判定与 App 探活一致:打 `/healthz`,2xx/3xx 即通,耗时从发出到收完响应头。
|
|
293
|
+
* 超时的照实报 `null`(前端按「不通」排在后面),不编造数字——这一条早先整个是 mock,
|
|
294
|
+
* 用户看到「测速结果」其实是随机数,选中的地址可能根本不存在。
|
|
295
|
+
*/
|
|
296
|
+
registerRoute({ kind: 'exact', path: '/api/relay/probe-relays', handler: localOnly('POST', async (req, res) => {
|
|
297
|
+
const body = await readJson(req)
|
|
298
|
+
const list = Array.isArray(body?.urls) ? body.urls : []
|
|
299
|
+
// 只认 http(s),别让前端一句糊话把宿主变成任意地址探测工具;并发上限 8 条。
|
|
300
|
+
const targets = list
|
|
301
|
+
.filter((u) => typeof u === 'string' && /^https?:\/\//i.test(u.trim()))
|
|
302
|
+
.slice(0, 8)
|
|
303
|
+
.map((u) => u.trim().replace(/\/+$/, ''))
|
|
304
|
+
const results = await Promise.all(targets.map(async (url) => {
|
|
305
|
+
const started = Date.now()
|
|
306
|
+
try {
|
|
307
|
+
const controller = new AbortController()
|
|
308
|
+
const timer = setTimeout(() => controller.abort(), RELAY_PROBE_TIMEOUT_MS)
|
|
309
|
+
const response = await fetch(`${url}/healthz`, { signal: controller.signal, redirect: 'follow' })
|
|
310
|
+
clearTimeout(timer)
|
|
311
|
+
// 读完响应体再停表:只等到响应头会把「连得上但发不出东西」算成很快。
|
|
312
|
+
await response.text().catch(() => '')
|
|
313
|
+
return response.ok ? { url, ms: Date.now() - started } : { url, ms: null }
|
|
314
|
+
} catch {
|
|
315
|
+
return { url, ms: null }
|
|
316
|
+
}
|
|
317
|
+
}))
|
|
318
|
+
sendJson(res, 200, { ok: true, results })
|
|
319
|
+
}) })
|
|
283
320
|
// 只有可信本机设置入口可以开 / 关窗口;窗口状态仅驻留进程内,DSH 重启后必定关闭。
|
|
284
321
|
registerRoute({ kind: 'exact', path: '/api/relay/web-access', handler: localOnly('POST', async (req, res) => {
|
|
285
322
|
if (typeof trustedLocalUi !== 'function' || !trustedLocalUi(req)) return sendJson(res, 403, { ok: false, error: { code: 'local_ui_only', message: '请从 DSH 本机设置页管理网页访问' } });
|
|
@@ -676,6 +713,7 @@ function registerControlPlaneRoutes(deps) {
|
|
|
676
713
|
const WEB_ACCESS_WINDOW_MS = 2 * 60 * 60 * 1000;
|
|
677
714
|
const WEB_ACCESS_ROUTES = new Set([
|
|
678
715
|
'/api/relay/addresses',
|
|
716
|
+
'/api/relay/probe-relays',
|
|
679
717
|
'/api/relay/status',
|
|
680
718
|
'/api/relay/panel',
|
|
681
719
|
'/api/relay/panel/setup',
|
package/lib/routes/mobile-api.js
CHANGED
|
@@ -38,6 +38,8 @@ const subagents = require('../mobile/subagents.js');
|
|
|
38
38
|
const references = require('../mobile/references.js');
|
|
39
39
|
const plugins = require('../mobile/plugins.js');
|
|
40
40
|
const uploads = require('../mobile/uploads.js');
|
|
41
|
+
const pairing = require('../mobile/pairing.js');
|
|
42
|
+
const { credentialsEntry } = require('../shared/credentials-entry.js');
|
|
41
43
|
// 体检接口要自报身份:版本号只有一个来源,就是包自己的 package.json(与 relay/status.js 同)。
|
|
42
44
|
const pkg = require('../../package.json');
|
|
43
45
|
const desktopView = require('../desktop-view.cjs');
|
|
@@ -68,7 +70,7 @@ const API_VERSION = '1';
|
|
|
68
70
|
* 一次拒绝或一次握手。
|
|
69
71
|
*/
|
|
70
72
|
function registerMobileRoutes(deps) {
|
|
71
|
-
const { registerRoute, registerUpgradeRoute, guarded, state, hostMetadata, getSidecarSnapshot, safeLanCandidates, lanPortInUse, preview } = deps;
|
|
73
|
+
const { registerRoute, registerUpgradeRoute, guarded, state, hostMetadata, getSidecarSnapshot, safeLanCandidates, lanPortInUse, preview, core, pairingApproval } = deps;
|
|
72
74
|
const route = (path, handler) => registerRoute({ kind: 'exact', path, handler: guarded(handler) })
|
|
73
75
|
|
|
74
76
|
registerRoute({ kind: 'exact', path: '/api/mobile/v1/status', handler: guarded(async (_ctx, _req, res) => {
|
|
@@ -98,10 +100,31 @@ function registerMobileRoutes(deps) {
|
|
|
98
100
|
registerRoute({ kind: 'exact', path: '/api/mobile/v1/health', handler: async (_req, res) => {
|
|
99
101
|
sendJson(res, 200, { ok: true, service: 'dsh-mobile-plugin', apiVersion: API_VERSION, version: pkg.version })
|
|
100
102
|
} })
|
|
101
|
-
//
|
|
102
|
-
//
|
|
103
|
-
//
|
|
104
|
-
//
|
|
103
|
+
// 配对:手机拿配对码换 token 与设备凭据。**这条归宿主**——「谁可以进这台机器」是宿主
|
|
104
|
+
// 自己的判断,配对码本来就存在宿主的状态里,中转只按实例标识把请求转过来。
|
|
105
|
+
//
|
|
106
|
+
// 刻意**不**包 `guarded`:配对之前手机手上还没有任何凭据,包上就是死循环。豁免理由与
|
|
107
|
+
// /health 一样登记在 test/mobile-routes.test.cjs 的 PUBLIC_ROUTES。
|
|
108
|
+
//
|
|
109
|
+
// 这条路径与局域网那条 `/__lan_pair` 是同一件事的两个入口,签发的是同一种 HMAC token;
|
|
110
|
+
// 差别只在网络路径。旧实现把它长在独立的中转进程里,换成 Rust 中转时整块漏了搬,
|
|
111
|
+
// 于是手机上点「配对并添加」收到的是宿主的 405。
|
|
112
|
+
registerRoute({
|
|
113
|
+
kind: 'exact',
|
|
114
|
+
path: '/api/mobile/v1/pair',
|
|
115
|
+
handler: pairing.createPairHandler({ state, core, credentials: credentialsEntry(), pairingApproval, log: (...args) => console.warn('[dsh-mobile-plugin] [pair]', ...args) }),
|
|
116
|
+
})
|
|
117
|
+
// 局域网/中转连接信息。**这条由宿主自己回答**:中转只转发,不再代答。
|
|
118
|
+
// 纯局域网配对(App 只扫到局域网二维码、完全不经中转)需要一个宿主自己回答的地址,
|
|
119
|
+
// 否则 App 拿不到 lanCandidates,换网卡后就再也找不到这台机器;中转在中间时这条
|
|
120
|
+
// 同样转发到这里,所以两条链路走的是同一份实现。
|
|
121
|
+
// 设备凭据吊销。**由宿主应答,中转只转发**——「哪台设备还能用」是宿主的判断。
|
|
122
|
+
// App 那边是尽力而为的调用(失败不阻断本地删除),所以这里回 ok 即可:
|
|
123
|
+
// 凭据库里的记录本身带有 TTL,残留会自然过期。
|
|
124
|
+
route('/api/mobile/v1/device-credential/revoke', async (_ctx, _req, res) => {
|
|
125
|
+
sendJson(res, 200, { ok: true })
|
|
126
|
+
})
|
|
127
|
+
|
|
105
128
|
route('/api/mobile/v1/connection-info', async (_ctx, _req, res) => {
|
|
106
129
|
// 端口取「手机该拨的那个」(sidecar 真 bind 成的),不是配置里的首选端口:
|
|
107
130
|
// 两者不一致时这里报出去的候选是死地址,手机只能退回公网中转。
|
package/lib/shell/device-ota.js
CHANGED
|
@@ -8,7 +8,21 @@ import { compareVersion } from './manager.js'
|
|
|
8
8
|
|
|
9
9
|
const require_ = createRequire(import.meta.url)
|
|
10
10
|
const zlib = require_('node:zlib')
|
|
11
|
-
|
|
11
|
+
/**
|
|
12
|
+
* OTA 包落在哪。
|
|
13
|
+
*
|
|
14
|
+
* 默认 `<DSH_HOME>/plugin-ota`,但外壳知道 profile 在哪之后会把它挪到**profile 里面**
|
|
15
|
+
* (`<profile>/.dsh-mobile-ota`)。原因不是洁癖:DSH 宿主的 profile-resolution 拦截层
|
|
16
|
+
* 只认 `profiles/` 树内的模块,树外的裸包名走的是原生解析 —— 而宿主提供的那批 peer
|
|
17
|
+
* (`@deepseek-ai/dsh-tools` 之类)压根不在 profile 的 node_modules 里,于是 OTA 代
|
|
18
|
+
* 第一句 `require('@deepseek-ai/dsh-tools')` 就 MODULE_NOT_FOUND(2026-10-17 真机:
|
|
19
|
+
* 0.1.80 装载失败)。落在 profile 里之后,OTA 代与装进来的那一代走**同一条**解析链。
|
|
20
|
+
*/
|
|
21
|
+
let storageRoot = ''
|
|
22
|
+
export function configureOtaRoot(dir) {
|
|
23
|
+
if (typeof dir === 'string' && dir.length > 0) storageRoot = dir
|
|
24
|
+
}
|
|
25
|
+
const ROOT = () => storageRoot || path.join(dshHome(), 'plugin-ota')
|
|
12
26
|
const PACKAGE = 'dsh-mobile-plugin'
|
|
13
27
|
const RELAY = 'https://dsh-hub.wnluo.com/api/desktop-plugins/ota'
|
|
14
28
|
const CHANNEL = 'stable'
|
package/lib/shell/manager.js
CHANGED
|
@@ -32,7 +32,8 @@ import fs from 'node:fs'
|
|
|
32
32
|
import { createRequire } from 'node:module'
|
|
33
33
|
import path from 'node:path'
|
|
34
34
|
import { fileURLToPath, pathToFileURL } from 'node:url'
|
|
35
|
-
import { availableOta } from './device-ota.js'
|
|
35
|
+
import { availableOta, configureOtaRoot } from './device-ota.js'
|
|
36
|
+
import { dshHome } from '../shared/paths.js'
|
|
36
37
|
|
|
37
38
|
/** CJS 模块缓存。实现树里 `lib/relay`、`lib/routes`、`lib/mobile` 都是 CJS。 */
|
|
38
39
|
const require_ = createRequire(import.meta.url)
|
|
@@ -227,6 +228,83 @@ function evictCommonJsTree(roots) {
|
|
|
227
228
|
}
|
|
228
229
|
}
|
|
229
230
|
|
|
231
|
+
/**
|
|
232
|
+
* OTA 包落到 `<profile>/.dsh-mobile-ota`(不再放 `<DSH_HOME>/plugin-ota`)。
|
|
233
|
+
*
|
|
234
|
+
* 宿主对模块解析有一层拦截:**`profiles/` 树内**的模块,裸包名会先按 profile 的
|
|
235
|
+
* 本地包解析、再回落到宿主自己那份;树外的模块走原生解析。而宿主提供的那批 peer
|
|
236
|
+
* (`@deepseek-ai/dsh-tools` 等)并不在 profile 的 `node_modules` 里 —— 所以 OTA 包
|
|
237
|
+
* 一旦落在树外,那一代 `require('@deepseek-ai/dsh-tools')` 就是 MODULE_NOT_FOUND
|
|
238
|
+
* (2026-10-17 真机:0.1.80 装载失败)。位置挪进 profile 后,OTA 代与装进来的那一代
|
|
239
|
+
* 走同一条解析链,不必再靠调用点逐个打补丁。
|
|
240
|
+
*
|
|
241
|
+
* 老位置残留的包顺手搬过来:换代失败时它还是磁盘上的回退包,不能在升级路径上丢。
|
|
242
|
+
*/
|
|
243
|
+
function moveOtaRootIntoProfile() {
|
|
244
|
+
if (path.basename(PROFILE_NODE_MODULES) !== 'node_modules') return
|
|
245
|
+
const next = path.join(path.dirname(PROFILE_NODE_MODULES), '.dsh-mobile-ota')
|
|
246
|
+
const legacy = path.join(dshHome(), 'plugin-ota')
|
|
247
|
+
configureOtaRoot(next)
|
|
248
|
+
try {
|
|
249
|
+
if (!fs.existsSync(legacy) || fs.lstatSync(legacy).isSymbolicLink()) return
|
|
250
|
+
fs.mkdirSync(next, { recursive: true })
|
|
251
|
+
for (const entry of fs.readdirSync(legacy)) {
|
|
252
|
+
const target = path.join(next, entry)
|
|
253
|
+
if (fs.existsSync(target)) continue
|
|
254
|
+
fs.renameSync(path.join(legacy, entry), target)
|
|
255
|
+
}
|
|
256
|
+
fs.rmSync(legacy, { recursive: true, force: true })
|
|
257
|
+
} catch {
|
|
258
|
+
// 搬不动就用老位置:那是「OTA 仍可下载但可能装不起来」,比让外壳启动失败强
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* 给 OTA 代补齐「裸包名怎么解析」。
|
|
264
|
+
*
|
|
265
|
+
* Node 只沿**自己的祖先目录**找 `node_modules`,而 OTA 落在
|
|
266
|
+
* `<DSH_HOME>/plugin-ota/<version>/` —— 这条路径上没有任何一份 `node_modules`。于是
|
|
267
|
+
* `lib/impl.js` 第一句 `import { z } from '@deepseek-ai/schemastery'` 就报
|
|
268
|
+
* `Cannot find package`,一代整个装不起来(2026-10-17 真机:0.1.79 的 OTA 装载失败,
|
|
269
|
+
* 外壳按规矩回滚到旧代,机器上什么都没变)。
|
|
270
|
+
*
|
|
271
|
+
* 修法是补上基础代本来就有、而 OTA 路径上没有的那两层软链:
|
|
272
|
+
* `<version>/node_modules` → profile 的 `node_modules`
|
|
273
|
+
* `<version>/dsh-mobile-plugin/node_modules` → profile 里那份插件的 `node_modules`(pnpm 严格布局才有)
|
|
274
|
+
* 从 `<version>/dsh-mobile-plugin/lib` 往上第一站就命中,解析到的是**同一份** peer 与依赖,
|
|
275
|
+
* 与基础代的 real path 一致 —— 不会出现两份 cordis/schemastery。
|
|
276
|
+
*/
|
|
277
|
+
/** 外壳一启动就把 OTA 落盘位置定下来(见 moveOtaRootIntoProfile 的注释)。 */
|
|
278
|
+
moveOtaRootIntoProfile()
|
|
279
|
+
|
|
280
|
+
function ensureGenerationModules(generation) {
|
|
281
|
+
if (!generation || generation.source !== 'ota' || !generation.dir) return
|
|
282
|
+
// 只认真正的 `node_modules`:`findProfileNodeModules()` 找不到时会回落到 profile 根,
|
|
283
|
+
// 把那个当依赖目录链过去等于凭空造一层错的解析路径。
|
|
284
|
+
if (path.basename(PROFILE_NODE_MODULES) !== 'node_modules' || !fs.existsSync(PROFILE_NODE_MODULES)) return
|
|
285
|
+
const versionDir = path.dirname(generation.dir)
|
|
286
|
+
const link = (target, at) => {
|
|
287
|
+
try {
|
|
288
|
+
if (fs.lstatSync(at).isSymbolicLink()) {
|
|
289
|
+
if (fs.readlinkSync(at) === target) return
|
|
290
|
+
fs.unlinkSync(at)
|
|
291
|
+
} else {
|
|
292
|
+
return // 已经是实体目录(有人手工铺过依赖):不动它
|
|
293
|
+
}
|
|
294
|
+
} catch {
|
|
295
|
+
// 还不存在,往下建
|
|
296
|
+
}
|
|
297
|
+
try {
|
|
298
|
+
fs.symlinkSync(target, at, 'dir')
|
|
299
|
+
} catch {
|
|
300
|
+
// 盘上不让写(只读 home / 权限不足)就让解析失败如实报出来,别把它变成静默的空实现
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
link(PROFILE_NODE_MODULES, path.join(versionDir, 'node_modules'))
|
|
304
|
+
const nested = path.join(PROFILE_NODE_MODULES, PLUGIN_PKG, 'node_modules')
|
|
305
|
+
if (fs.existsSync(nested)) link(nested, path.join(versionDir, PLUGIN_PKG, 'node_modules'))
|
|
306
|
+
}
|
|
307
|
+
|
|
230
308
|
/**
|
|
231
309
|
* 动态 import 一个代。
|
|
232
310
|
*
|
|
@@ -247,6 +325,7 @@ export async function importGeneration(generation) {
|
|
|
247
325
|
const cacheKey = generationCacheKey(generation)
|
|
248
326
|
const cached = imported.get(cacheKey)
|
|
249
327
|
if (cached) return cached
|
|
328
|
+
ensureGenerationModules(generation)
|
|
250
329
|
// 先把 CJS 缓存清干净再 import,否则「入口是新的、子模块还是旧的」,等于换了个半成品上去。
|
|
251
330
|
evictCommonJsTree(evictionRoots(generation))
|
|
252
331
|
importSeq += 1
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-mobile-plugin",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.82",
|
|
4
4
|
"description": "Mobile API (/api/mobile/v1) and relay client for the DSH mobile app — desktop profile build (0.2.0-rc.2 compatible, peers resolved by the host)",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|