dsh-mobile-plugin 0.5.20 → 0.5.22
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 +3 -12
- package/assets/pets/README.md +9 -0
- package/assets/pets/deepy-whale/attention.gif +0 -0
- package/assets/pets/deepy-whale/done.gif +0 -0
- package/assets/pets/deepy-whale/error.gif +0 -0
- package/assets/pets/deepy-whale/idle.gif +0 -0
- package/assets/pets/deepy-whale/pack.json +54 -0
- package/assets/pets/deepy-whale/sleeping.gif +0 -0
- package/assets/pets/deepy-whale/working-3.gif +0 -0
- package/assets/pets/deepy-whale/working.gif +0 -0
- package/extensions/README.md +64 -0
- package/extensions/catalog.json +1 -0
- package/lib/client.js +318 -27
- package/lib/impl.js +21 -1
- package/lib/mobile/approvals.js +22 -2
- package/lib/mobile/cli/acp.js +34 -20
- package/lib/mobile/cli/mobile.js +8 -1
- package/lib/mobile/cli/service.js +151 -10
- package/lib/mobile/extension-catalog.js +244 -0
- package/lib/mobile/local-images.js +10 -5
- package/lib/mobile/monitor.js +5 -2
- package/lib/mobile/pet-tools.js +215 -0
- package/lib/mobile/pets.js +559 -0
- package/lib/mobile/session-flags.js +61 -8
- package/lib/mobile/session-local-image.js +9 -1
- package/lib/mobile/stats-collect.cjs +15 -7
- package/lib/mobile/stats-ledger.cjs +44 -5
- package/lib/mobile/wechat-tools.js +257 -0
- package/lib/mobile/wechat.js +489 -0
- package/lib/panel/index.html +12 -11
- package/lib/relay/pairing-payload.cjs +13 -5
- package/lib/relay/snapshot-auth.js +82 -0
- package/lib/relay/status.js +3 -0
- package/lib/routes/control-plane.js +74 -3
- package/lib/routes/mobile-api.js +54 -1
- package/lib/session-cleanup.cjs +22 -7
- package/lib/sidecar-facade-worker.cjs +8 -0
- package/lib/tailcat/host-process.cjs +266 -0
- package/lib/tailcat/ingress.cjs +98 -0
- package/lib/tailcat/session.cjs +264 -0
- package/lib/tailcat/wiring.cjs +96 -0
- package/package.json +10 -1
|
@@ -58,10 +58,18 @@ function pairingPayload(state, lanPort) {
|
|
|
58
58
|
? withManualIpv6Candidate(ipv6Candidates(port), state.manualIpv6Url).map(candidate => candidate.url).filter(Boolean)
|
|
59
59
|
: [],
|
|
60
60
|
};
|
|
61
|
-
return {
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
61
|
+
return { payload, text: pairingText(payload) };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* 载荷 → 二维码里那一串 `dshpair://`。
|
|
66
|
+
*
|
|
67
|
+
* 单独导出是因为**直连隧道那段信息要在拼 text 之前塞进 payload**:隧道窗口是异步起的
|
|
68
|
+
* (要拉一个节点),拼装函数本身是纯的,不能为了它变成异步。调用方拿到 payload、补上
|
|
69
|
+
* `tailcat`,再调这里生成 text —— 仍然只有这一处拼格式。
|
|
70
|
+
*/
|
|
71
|
+
function pairingText(payload) {
|
|
72
|
+
return `dshpair://v1?data=${encodeURIComponent(JSON.stringify(payload))}`;
|
|
65
73
|
}
|
|
66
74
|
|
|
67
|
-
module.exports = { pairingPayload };
|
|
75
|
+
module.exports = { pairingPayload, pairingText };
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 读取隔离(镜像快照直回)这层的鉴权。
|
|
5
|
+
*
|
|
6
|
+
* `snapshotRead` 命中本地快照时直接序列化好 JSON 回给手机,**绕开了主进程的守卫**。早先
|
|
7
|
+
* 这里只挡了「协议 v2 要回源信封」那一面,鉴权这一面还开着:只要知道实例号(它就印在配对
|
|
8
|
+
* 二维码上),不带任何凭据也能经中转读到会话列表、工作区列表。所以这条捷径在回任何快照
|
|
9
|
+
* 之前,必须自己按主进程守卫同样的口径验一遍凭据。
|
|
10
|
+
*
|
|
11
|
+
* 口径(与 `lib/mobile/auth.js` 的 `authOutcome`、`lib/relay/lan-proxy.js` 的 `authOutcome`
|
|
12
|
+
* 一致,顺序是这里唯一要紧的东西):
|
|
13
|
+
* - 凭据头 / `Authorization: Bearer` / `dsh_lan` cookie 三处,取所有非空候选;
|
|
14
|
+
* - 候选里**只要有一枚**是本机签发过的记录,就以记录为准:被吊销、已过期立即判否,
|
|
15
|
+
* **不许**再退回更宽松的无状态令牌(否则吊销永远不生效);
|
|
16
|
+
* - 本机**一枚都查不到**时,才回退旧的无状态 HMAC token(升级前配过的老 App 走这条)
|
|
17
|
+
* 与「中转签发的凭据」。
|
|
18
|
+
*
|
|
19
|
+
* 这里不复用 `authOutcome`:那条路每次都要读一遍 relay 状态文件拿实例号,而这是每条高频
|
|
20
|
+
* 读都走的快路径;实例号、lanSecret 在本进程的 config 里都是现成的,用依赖注入传进来即可。
|
|
21
|
+
*
|
|
22
|
+
* @param {object} headers 请求头(原始大小写都行)
|
|
23
|
+
* @param {{ lookup?: (presented: string) => object|null, verifyLanToken?: (token: string) => boolean }} deps
|
|
24
|
+
* @returns {boolean} 是否放行;false 时调用方应回源,交给主进程守卫如实驳回。
|
|
25
|
+
*/
|
|
26
|
+
function snapshotAuthorized(headers, deps = {}) {
|
|
27
|
+
const { lookup, verifyLanToken } = deps;
|
|
28
|
+
const candidates = credentialCandidates(headers);
|
|
29
|
+
if (!candidates.length) return false;
|
|
30
|
+
|
|
31
|
+
const now = Date.now();
|
|
32
|
+
for (const presented of candidates) {
|
|
33
|
+
const known = typeof lookup === 'function' ? lookup(presented) : null;
|
|
34
|
+
if (!known) continue;
|
|
35
|
+
if (known.revokedAt) return false;
|
|
36
|
+
if (known.expiresAt && known.expiresAt <= now) return false;
|
|
37
|
+
return true;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// 本机不认识这些凭据:可能是升级前的 HMAC token,或中转签的。按无状态令牌再验一次。
|
|
41
|
+
for (const presented of candidates) {
|
|
42
|
+
try {
|
|
43
|
+
if (typeof verifyLanToken === 'function' && verifyLanToken(presented)) return true;
|
|
44
|
+
} catch { /* 验不过就继续看下一枚 */ }
|
|
45
|
+
}
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** 按大小写不敏感取一个头部的值;数组取第一个,非字符串或空值返回空串。 */
|
|
50
|
+
function headerValue(headers, target) {
|
|
51
|
+
if (!headers || typeof headers !== 'object') return '';
|
|
52
|
+
const lower = target.toLowerCase();
|
|
53
|
+
for (const key of Object.keys(headers)) {
|
|
54
|
+
if (key.toLowerCase() !== lower) continue;
|
|
55
|
+
const raw = headers[key];
|
|
56
|
+
const value = Array.isArray(raw) ? raw[0] : raw;
|
|
57
|
+
return typeof value === 'string' ? value.trim() : '';
|
|
58
|
+
}
|
|
59
|
+
return '';
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** 三条出示凭据的通道:独立凭据头 → Bearer → dsh_lan cookie。去重后返回非空候选。 */
|
|
63
|
+
function credentialCandidates(headers) {
|
|
64
|
+
const out = [];
|
|
65
|
+
const seen = new Set();
|
|
66
|
+
const push = (value) => {
|
|
67
|
+
if (typeof value !== 'string') return;
|
|
68
|
+
const token = value.trim();
|
|
69
|
+
if (!token || seen.has(token)) return;
|
|
70
|
+
seen.add(token);
|
|
71
|
+
out.push(token);
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
push(headerValue(headers, 'x-dsh-device-credential'));
|
|
75
|
+
const bearer = /^Bearer\s+([^\s]+)$/i.exec(headerValue(headers, 'authorization'));
|
|
76
|
+
if (bearer) push(bearer[1]);
|
|
77
|
+
const cookie = /(?:^|;\s*)dsh_lan=([^;]+)/.exec(headerValue(headers, 'cookie'));
|
|
78
|
+
if (cookie) push(cookie[1]);
|
|
79
|
+
return out;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
module.exports = { snapshotAuthorized, credentialCandidates };
|
package/lib/relay/status.js
CHANGED
|
@@ -107,6 +107,9 @@ function createStatusView(deps) {
|
|
|
107
107
|
...hostMetadata(),
|
|
108
108
|
localPort: webServerPort,
|
|
109
109
|
tunnel: describeTunnel(sidecarSnapshot.tunnel),
|
|
110
|
+
// 直连隧道(tailcat):手机据此自己把通道加进来,**不需要重新扫码**。
|
|
111
|
+
// 没开或起不来就是 null,老手机不认识这个字段也无害。
|
|
112
|
+
tailcat: typeof deps.getTailcat === 'function' ? deps.getTailcat() : null,
|
|
110
113
|
// 以本进程解析出的实际端口为准:sidecar 的快照里 `port` 才是最终 bind 成功的
|
|
111
114
|
// 那个(它在 listen 之后才拿得到),而这里在探测阶段就已经知道了,两个值一致
|
|
112
115
|
// 才不会让状态接口和二维码互相打架。
|
|
@@ -20,6 +20,7 @@ const fs = require('node:fs');
|
|
|
20
20
|
const net = require('node:net');
|
|
21
21
|
const path = require('node:path');
|
|
22
22
|
const QRCode = require('qrcode');
|
|
23
|
+
const { pairingText } = require('../relay/pairing-payload.cjs');
|
|
23
24
|
const { randomBytes } = require('node:crypto');
|
|
24
25
|
|
|
25
26
|
const core = require('../relay/core.js');
|
|
@@ -285,6 +286,7 @@ function registerControlPlaneRoutes(deps) {
|
|
|
285
286
|
lanPortInUse,
|
|
286
287
|
getLanPortAdjusted,
|
|
287
288
|
pairingPayload,
|
|
289
|
+
tailcat,
|
|
288
290
|
restartSidecar,
|
|
289
291
|
// 「手机上最近来过没有」。**只往本机这条状态路由上补**(和 panelUrl 一样),
|
|
290
292
|
// 不动 createStatusView——那份 JSON 手机端也在读,给它加字段是另一个决定。
|
|
@@ -503,12 +505,49 @@ function registerControlPlaneRoutes(deps) {
|
|
|
503
505
|
const webAccess = body.enabled ? webAccessWindow.enable() : webAccessWindow.disable();
|
|
504
506
|
return sendJson(res, 200, { ok: true, webAccess });
|
|
505
507
|
}) });
|
|
506
|
-
registerRoute({ kind: 'exact', path: '/api/relay/status', handler: localOnly('GET', async (req, res) => sendJson(res, 200, { ...status(), panelUrl: panelUrlOf(req), panelScope: panelScopeOf(req), webAccess: webAccessWindow.status(), ...mobileActivity(), selfCheck: selfCheck.snapshot() })) })
|
|
508
|
+
registerRoute({ kind: 'exact', path: '/api/relay/status', handler: localOnly('GET', async (req, res) => sendJson(res, 200, { ...status(), tailcat: deps.tailcat && typeof deps.tailcat.status === 'function' ? deps.tailcat.status() : null, panelUrl: panelUrlOf(req), panelScope: panelScopeOf(req), webAccess: webAccessWindow.status(), ...mobileActivity(), selfCheck: selfCheck.snapshot() })) })
|
|
509
|
+
|
|
510
|
+
// ── 手机直连隧道(Tailcat):面板查看与管理 ────────────────────────────────
|
|
511
|
+
//
|
|
512
|
+
// 状态那一格已经跟着 `/api/relay/status` 一起回了(`tailcat`),这里再补两条**动作**:
|
|
513
|
+
// 一是这整条通道的总开关,二是把某台手机从白名单里摘掉。两道都直接转给宿主侧的
|
|
514
|
+
// tailcat 会话(`lib/tailcat/session.cjs`),面板不碰 tailcat 的任何细节。
|
|
515
|
+
//
|
|
516
|
+
// 为什么白名单是「节点公钥」而不是设备 id:隧道那一层只认 WireGuard 公钥,和移动端
|
|
517
|
+
// 凭据是两套东西 —— 吊销设备凭据并不会把它从隧道白名单里摘掉(见 A3-87)。所以这里
|
|
518
|
+
// 单独给一个出口,否则「踢一台手机」在隧道这一层无法完成。
|
|
519
|
+
const tailcatSnapshot = () => {
|
|
520
|
+
const state = deps.tailcat && typeof deps.tailcat.status === 'function' ? deps.tailcat.status() : null
|
|
521
|
+
const allowed = deps.tailcat && typeof deps.tailcat.allowedClients === 'function' ? deps.tailcat.allowedClients() : []
|
|
522
|
+
return { ok: true, status: state, allowedClients: Array.isArray(allowed) ? allowed : [] }
|
|
523
|
+
}
|
|
524
|
+
registerRoute({ kind: 'exact', path: '/api/relay/tailcat', handler: localOnly('GET', async (_req, res) => {
|
|
525
|
+
sendJson(res, 200, tailcatSnapshot())
|
|
526
|
+
}) })
|
|
527
|
+
registerRoute({ kind: 'exact', path: '/api/relay/tailcat/enabled', handler: localOnly('POST', async (req, res) => {
|
|
528
|
+
const body = await readJson(req)
|
|
529
|
+
if (deps.tailcat && typeof deps.tailcat.setEnabled === 'function') {
|
|
530
|
+
deps.tailcat.setEnabled(Boolean(body && body.enabled))
|
|
531
|
+
}
|
|
532
|
+
sendJson(res, 200, tailcatSnapshot())
|
|
533
|
+
}) })
|
|
534
|
+
registerRoute({ kind: 'exact', path: '/api/relay/tailcat/revoke', handler: localOnly('POST', async (req, res) => {
|
|
535
|
+
const body = await readJson(req)
|
|
536
|
+
const client = body && typeof body.client === 'string' ? body.client.trim() : ''
|
|
537
|
+
if (!client) return sendJson(res, 400, { ok: false, error: { code: 'bad-request', message: '缺少 client' } })
|
|
538
|
+
const current = deps.tailcat && typeof deps.tailcat.allowedClients === 'function' ? deps.tailcat.allowedClients() : []
|
|
539
|
+
const next = (Array.isArray(current) ? current : []).filter((key) => key !== client)
|
|
540
|
+
if (deps.tailcat && typeof deps.tailcat.setAllowed === 'function') {
|
|
541
|
+
deps.tailcat.setAllowed(next)
|
|
542
|
+
}
|
|
543
|
+
sendJson(res, 200, tailcatSnapshot())
|
|
544
|
+
}) })
|
|
507
545
|
// 面板页本体:设置界面从客户端半边搬到这里,浏览器(或桌面端里的「打开配置页面」)
|
|
508
546
|
// 直接打开它。三条地址吐的是同一份 HTML,分流在 React 侧(见 React/src/Root.tsx):
|
|
509
547
|
// /api/relay/panel 看宿主记的「配没配过」,自己决定先走引导还是直接进主页
|
|
510
548
|
// /api/relay/panel/setup 强制走引导(想再看一遍流程时用)
|
|
511
549
|
// /api/relay/panel/settings 强制进主页(演示 / 排查时跳过引导)
|
|
550
|
+
// /api/relay/panel/debug 强制进调试页(桌面端那颗手机图标点开的就是它)
|
|
512
551
|
// 整页 HTML 不需要 token,但这几条仍然走 localOnly —— 它读的是本机运维界面,
|
|
513
552
|
// 而局域网与中转那两条链路本来就不该看到它(它们带 dsh_pair/dsh_lan 凭据,会被挡掉)。
|
|
514
553
|
const servePanel = (res) => {
|
|
@@ -527,6 +566,13 @@ function registerControlPlaneRoutes(deps) {
|
|
|
527
566
|
registerRoute({ kind: 'exact', path: '/api/relay/panel', handler: localOnly('GET', async (_req, res) => servePanel(res), { allowDocumentNavigation: true }) })
|
|
528
567
|
registerRoute({ kind: 'exact', path: '/api/relay/panel/setup', handler: localOnly('GET', async (_req, res) => servePanel(res), { allowDocumentNavigation: true }) })
|
|
529
568
|
registerRoute({ kind: 'exact', path: '/api/relay/panel/settings', handler: localOnly('GET', async (_req, res) => servePanel(res), { allowDocumentNavigation: true }) })
|
|
569
|
+
// 调试页与上面几条吐同一份 HTML,分流同样在 React 侧(`React/src/Root.tsx` 认 `/debug`)。
|
|
570
|
+
//
|
|
571
|
+
// 为什么它也放开 `allowDocumentNavigation`:这一页唯一的入口是桌面端那颗手机图标弹的窗
|
|
572
|
+
// (同源 iframe),而社区桌面壳会把接住的链接原地打开、从自定义协议页跳过来必带
|
|
573
|
+
// `sec-fetch-site: cross-site` —— 与面板页同一条理由。页面本身仍是只读 HTML,
|
|
574
|
+
// 里面的 fetch 照样被 localOnly 与两小时窗口挡着。
|
|
575
|
+
registerRoute({ kind: 'exact', path: '/api/relay/panel/debug', handler: localOnly('GET', async (_req, res) => servePanel(res), { allowDocumentNavigation: true }) })
|
|
530
576
|
// 桌面端画布页:整页吐同一份内联 HTML,并在 `</head>` 前补一句「这是桌面端」的标记 ——
|
|
531
577
|
// 画布据此把路由从 BrowserRouter 换成 HashRouter(它被挂在 `/api/relay/canvas` 这种非根
|
|
532
578
|
// 路径下,BrowserRouter 的 `/` 匹配不上会 404)。手机 WebView 那份不带这个标记,一个字不改。
|
|
@@ -647,8 +693,25 @@ function registerControlPlaneRoutes(deps) {
|
|
|
647
693
|
const source = preview && /^https?:\/\/[^\s]+$/i.test(preview.trim())
|
|
648
694
|
? { ...state, relayUrl: preview.trim().replace(/\/+$/, '') }
|
|
649
695
|
: state
|
|
650
|
-
const { payload
|
|
651
|
-
|
|
696
|
+
const { payload } = pairingPayload(source, lanPortInUse())
|
|
697
|
+
// 直连隧道:这一个是「不配中转也能连」的那条路。窗口是现开的一次性小节点(两分钟、
|
|
698
|
+
// 只放行当场那把钥匙),开不出来就照旧只给局域网与中转——所以它永远只是加一条路,
|
|
699
|
+
// 不会让原来那套配不上。
|
|
700
|
+
const tailcat = deps.tailcat && typeof deps.tailcat.openPairingWindow === 'function'
|
|
701
|
+
? await deps.tailcat.openPairingWindow(120).catch(() => null)
|
|
702
|
+
: null
|
|
703
|
+
if (tailcat) {
|
|
704
|
+
payload.tailcat = {
|
|
705
|
+
version: 1,
|
|
706
|
+
address: tailcat.address,
|
|
707
|
+
port: tailcat.port,
|
|
708
|
+
key: tailcat.key,
|
|
709
|
+
proof: tailcat.proof,
|
|
710
|
+
expiresAt: tailcat.expiresAt,
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
const text = pairingText(payload)
|
|
714
|
+
if (!payload.relayUrl && payload.lanUrls.length === 0 && !payload.tailcat) {
|
|
652
715
|
sendJson(res, 409, { ok: false, error: '没有可用的局域网或中转连接地址' })
|
|
653
716
|
return
|
|
654
717
|
}
|
|
@@ -669,6 +732,9 @@ function registerControlPlaneRoutes(deps) {
|
|
|
669
732
|
// 的 `lanPort` 不同,不说清楚会让人对着 18787 排查一个根本没在监听的端口。
|
|
670
733
|
lanPort: lanPortInUse(),
|
|
671
734
|
lanPortAdjusted: getLanPortAdjusted(),
|
|
735
|
+
// 手机拿到的那三样(地址 / 一次性私钥 / 证明串)——只在本机屏幕上出现,
|
|
736
|
+
// 与二维码同等敏感,所以面板以外的地方不要 log 它。
|
|
737
|
+
tailcat: tailcat ? { address: tailcat.address, port: tailcat.port, expiresAt: tailcat.expiresAt } : null,
|
|
672
738
|
svg,
|
|
673
739
|
})
|
|
674
740
|
} catch (error) {
|
|
@@ -1076,6 +1142,7 @@ const WEB_ACCESS_ROUTES = new Set([
|
|
|
1076
1142
|
'/api/relay/panel',
|
|
1077
1143
|
'/api/relay/panel/setup',
|
|
1078
1144
|
'/api/relay/panel/settings',
|
|
1145
|
+
'/api/relay/panel/debug',
|
|
1079
1146
|
'/api/relay/faults',
|
|
1080
1147
|
// 配对二维码:里面是能派生整条链路密钥的一次性票据,所以它只在两小时窗口开着时给网页看
|
|
1081
1148
|
// ——窗口由本机设置页亲手开,服务器部署没有「本机屏幕」可看,这条是它唯一的出口。
|
|
@@ -1085,6 +1152,10 @@ const WEB_ACCESS_ROUTES = new Set([
|
|
|
1085
1152
|
'/api/relay/onboarded',
|
|
1086
1153
|
'/api/relay/devices',
|
|
1087
1154
|
'/api/relay/devices/revoke',
|
|
1155
|
+
// 手机直连隧道(Tailcat)的查看与管理。
|
|
1156
|
+
'/api/relay/tailcat',
|
|
1157
|
+
'/api/relay/tailcat/enabled',
|
|
1158
|
+
'/api/relay/tailcat/revoke',
|
|
1088
1159
|
'/api/relay/revoke-all',
|
|
1089
1160
|
'/api/relay/config',
|
|
1090
1161
|
'/api/relay/check-port',
|
package/lib/routes/mobile-api.js
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
14
|
const { sendJson } = require('../http/routing.js');
|
|
15
|
+
const { readJsonBody } = require('../shared/http-util.js');
|
|
15
16
|
const { orderCandidates, withManualCandidate, withManualIpv6Candidate } = require('../relay/lan-network.cjs');
|
|
16
17
|
|
|
17
18
|
const sessions = require('../mobile/sessions.js');
|
|
@@ -58,8 +59,13 @@ const { credentialsEntry } = require('../shared/credentials-entry.js');
|
|
|
58
59
|
const { handleCredentialUpgrade } = require('../mobile/credential-upgrade.js');
|
|
59
60
|
const extensions = require('../mobile/extensions.js');
|
|
60
61
|
const extensionDev = require('../mobile/extension-dev.js');
|
|
62
|
+
const extensionCatalog = require('../mobile/extension-catalog.js');
|
|
61
63
|
const sessionDelivery = require('../mobile/session-delivery.js');
|
|
62
64
|
const { createPhoneRoutes } = require('../mobile/phone/routes.js');
|
|
65
|
+
// 公众号文章库(见 mobile/wechat.js):AI 在对话里写、手机上翻阅。
|
|
66
|
+
const wechat = require('../mobile/wechat.js');
|
|
67
|
+
// 悬浮宠物的包目录(见 mobile/pets.js):素材存在电脑上,手机连着哪台就看哪台。
|
|
68
|
+
const pets = require('../mobile/pets.js');
|
|
63
69
|
// 体检接口要自报身份:版本号只有一个来源,就是包自己的 package.json(与 relay/status.js 同)。
|
|
64
70
|
const pkg = require('../../package.json');
|
|
65
71
|
const desktopView = require('../desktop-view.cjs');
|
|
@@ -87,7 +93,7 @@ const API_VERSION = '1';
|
|
|
87
93
|
* 所以它走 route() 即包 guarded。
|
|
88
94
|
*/
|
|
89
95
|
function registerMobileRoutes(deps) {
|
|
90
|
-
const { registerRoute, registerUpgradeRoute, guarded, state, hostMetadata, getSidecarSnapshot, safeLanCandidates, safeIpv6Candidates, lanPortInUse, preview, core, pairingApproval, rotatePairing, terminalService, cliService, phoneService } = deps;
|
|
96
|
+
const { registerRoute, tailcat, registerUpgradeRoute, guarded, state, hostMetadata, getSidecarSnapshot, safeLanCandidates, safeIpv6Candidates, lanPortInUse, preview, core, pairingApproval, rotatePairing, terminalService, cliService, phoneService } = deps;
|
|
91
97
|
// CLI 会话(`cli-*`)在这张表上分派一次(见 mobile/cli/mobile.js):它们是 ACP 子进程、
|
|
92
98
|
// 不是宿主的会话,但手机上必须像普通会话一样可用 —— 列表 / 历史 / 发送 / 停止 / 流 /
|
|
93
99
|
// 审批 / 模型全走宿主那套端点。分派发生在 `route()` 里面,下面每条注册都还是原样一行,
|
|
@@ -198,6 +204,27 @@ function registerMobileRoutes(deps) {
|
|
|
198
204
|
// 这里曾写成 `(_ctx, _req, res)`,于是 `res` 是 undefined,而 `sendJson` 的 catch 会把
|
|
199
205
|
// TypeError 当成「连接已关闭」吞掉——既没报错也没回包,请求**永久挂住**;手机 5s 超时后
|
|
200
206
|
// 退回探活接口拿到 ok,报「能连上但不是 DSH 移动端服务」。纯文本的路由表测试抓不到这类错。
|
|
207
|
+
/**
|
|
208
|
+
* 直连隧道:一台**已经配过对**的手机把自己的公钥发上来,换回它该拨的地址。
|
|
209
|
+
*
|
|
210
|
+
* 这是「插件更新之后老设备不用重新扫码」的那条路:手机手上本来就有这台电脑的凭据,
|
|
211
|
+
* 只是不知道有隧道这回事。宿主把它的公钥加进白名单、把节点拉起来,再把地址回给它。
|
|
212
|
+
* 隧道没开(或者这台机器上没有助手)就回 503 + `tunnel-unavailable`,手机照旧走原来的通道。
|
|
213
|
+
*/
|
|
214
|
+
route('/api/mobile/v1/tunnel', async (_ctx, req, res) => {
|
|
215
|
+
if (req.method !== 'POST') { sendJson(res, 405, { ok: false }); return }
|
|
216
|
+
// 信封请求的明文在 `req.body`(守卫解开之后放那儿),原始流已经被守卫读掉了 —— 自己读流
|
|
217
|
+
// 只会拿到空串,公钥永远为空、一律 400,手机那边看到的却是一句笼统的失败。
|
|
218
|
+
const body = await readJsonBody(req, 64 * 1024)
|
|
219
|
+
const publicKey = typeof body?.publicKey === 'string' ? body.publicKey.trim() : ''
|
|
220
|
+
if (!/^nodekey:[0-9a-f]{64}$/.test(publicKey)) { sendJson(res, 400, { ok: false, error: { code: 'invalid-public-key' } }); return }
|
|
221
|
+
const entry = tailcat && typeof tailcat.enroll === 'function' ? await tailcat.enroll(publicKey).catch(() => null) : null
|
|
222
|
+
// 拿不到地址要回一个**非 2xx + 错误码**:手机那侧的 `unwrap` 见到 `ok:false` 会抛,和「请求
|
|
223
|
+
// 根本没送到」在同一条 catch 里混成一团,界面上就只剩一句笼统的失败(真机上正是这么卡的)。
|
|
224
|
+
if (!entry) { sendJson(res, 503, { ok: false, error: { code: 'tunnel-unavailable', message: '这台电脑现在给不出隧道地址' } }); return }
|
|
225
|
+
sendJson(res, 200, { ok: true, address: entry.address, port: entry.port })
|
|
226
|
+
})
|
|
227
|
+
|
|
201
228
|
registerRoute({ kind: 'exact', path: '/api/mobile/v1/health', handler: async (_req, res) => {
|
|
202
229
|
sendJson(res, 200, { ok: true, service: 'dsh-mobile-plugin', apiVersion: API_VERSION, version: pkg.version })
|
|
203
230
|
} })
|
|
@@ -563,6 +590,13 @@ function registerMobileRoutes(deps) {
|
|
|
563
590
|
route('/api/mobile/v1/extension/dev/packages', extensionDev.handleDevPackages)
|
|
564
591
|
route('/api/mobile/v1/extension/dev/file', extensionDev.handleDevFile)
|
|
565
592
|
|
|
593
|
+
// 随插件 npm 包发布的已审核扩展(见 mobile/extension-catalog.js):只读包内 extensions/
|
|
594
|
+
// 这一个固定目录,catalog.json 是审核清单,只有登记过的 slug 才下发。文件清单的 size 与
|
|
595
|
+
// sha256 由宿主扫目录现算,不信清单里的数字;取文件仍先 resolve 再判前缀,不接受任意路径。
|
|
596
|
+
// 能力面与开发包那两条同一档 —— 不暴露 shell / 系统设置 / 任意文件读写,也不给写。
|
|
597
|
+
route('/api/mobile/v1/extension/catalog', extensionCatalog.handleCatalog)
|
|
598
|
+
route('/api/mobile/v1/extension/catalog/file', extensionCatalog.handleCatalogFile)
|
|
599
|
+
|
|
566
600
|
// 分享文件的落地通道(App 把手机分享进来的文件分片传上来,见 mobile/uploads.js)。
|
|
567
601
|
// 手机端没有「把文件交给宿主」的路:图片能进 session.send 的 base64 内容块,其它格式
|
|
568
602
|
// 一律进不去。四条端点只做「收字节 → 落盘」:默认落到 `~/.dsh/mobile-inbox/<日期>/<文件名>`,
|
|
@@ -664,6 +698,25 @@ function registerMobileRoutes(deps) {
|
|
|
664
698
|
route('/api/mobile/v1/phone/unbind', phoneRoutes.unbind)
|
|
665
699
|
registerUpgradeRoute({ path: '/api/mobile/v1/phone/control', handler: phoneService.handleUpgrade })
|
|
666
700
|
}
|
|
701
|
+
|
|
702
|
+
// 公众号文章库(见 mobile/wechat.js):文章存在这台电脑上,手机只读。
|
|
703
|
+
//
|
|
704
|
+
// 能力面只多「读这一个固定目录」:编号形状卡死(`wx` + 8 位十六进制)、图片名不含斜杠,
|
|
705
|
+
// 路径全由宿主拼。写文章那条正常走 AI 的工具(`wechat_article_write`);这里那条 POST
|
|
706
|
+
// 是给以后「手机上改名 / 删除」留的,App 目前没有入口。
|
|
707
|
+
route('/api/mobile/v1/wechat/articles', wechat.handleArticleList)
|
|
708
|
+
route('/api/mobile/v1/wechat/article', wechat.handleArticleRead)
|
|
709
|
+
route('/api/mobile/v1/wechat/media', wechat.handleArticleMedia)
|
|
710
|
+
route('/api/mobile/v1/wechat/article/save', wechat.handleArticleWrite)
|
|
711
|
+
route('/api/mobile/v1/wechat/article/delete', wechat.handleArticleDelete)
|
|
712
|
+
|
|
713
|
+
// 悬浮宠物的包目录(见 mobile/pets.js):内置包随插件包走、只读,用户包在
|
|
714
|
+
// `$DSH_HOME/storages/mobile-plugin/pets/`。能力面只多「读这两个固定目录 + 删一个用户包」:
|
|
715
|
+
// id 与文件名都卡成不含斜杠的形状,取文件还要对上列表里那一份,客户端给不了路径。
|
|
716
|
+
// 建包走 AI 的工具(`pet_pack_write`);删是人的动作,所以只有那一条 POST。
|
|
717
|
+
route('/api/mobile/v1/pet/packs', pets.handlePackList)
|
|
718
|
+
route('/api/mobile/v1/pet/pack/file', pets.handlePackFile)
|
|
719
|
+
route('/api/mobile/v1/pet/pack/delete', pets.handlePackDelete)
|
|
667
720
|
}
|
|
668
721
|
|
|
669
722
|
module.exports = { registerMobileRoutes };
|
package/lib/session-cleanup.cjs
CHANGED
|
@@ -316,7 +316,7 @@ function restoreQueuedArchivedClearIds(ctx) {
|
|
|
316
316
|
return queued
|
|
317
317
|
}
|
|
318
318
|
|
|
319
|
-
async function pruneOrphanedArchivedSessions(ctx) {
|
|
319
|
+
async function pruneOrphanedArchivedSessions(ctx, skip) {
|
|
320
320
|
const workspaceList = ctx && ctx.apiProxy && ctx.apiProxy.workspace ? ctx.apiProxy.workspace.list : undefined
|
|
321
321
|
if (typeof workspaceList !== 'function') return 0
|
|
322
322
|
const rpcId = 'dsh-mobile-plugin-archive-prune'
|
|
@@ -325,7 +325,8 @@ async function pruneOrphanedArchivedSessions(ctx) {
|
|
|
325
325
|
const archived = value && Array.isArray(value.archivedSessionIds)
|
|
326
326
|
? value.archivedSessionIds.filter((id) => typeof id === 'string' && id)
|
|
327
327
|
: []
|
|
328
|
-
const
|
|
328
|
+
const skipped = skip instanceof Set ? skip : new Set(Array.isArray(skip) ? skip : [])
|
|
329
|
+
const orphans = (await archivedIdsWithoutLogs(archived)).filter((id) => !skipped.has(id))
|
|
329
330
|
if (orphans.length === 0) return 0
|
|
330
331
|
const failed = await forgetArchivedSessions(ctx, orphans, rpcId)
|
|
331
332
|
return orphans.length - failed.length
|
|
@@ -339,10 +340,12 @@ async function pruneOrphanedArchivedSessions(ctx) {
|
|
|
339
340
|
* 就是一次未捕获的 ENOENT,宿主直接退出。
|
|
340
341
|
* - **擦归档集合**:桌面端的会话列表是「连上宿主时拉的一份快照 + 之后的事件增量」,
|
|
341
342
|
* 进程活着的时候擦集合,宿主会把「归档集合变了」广播给桌面端,桌面端就把它快照里
|
|
342
|
-
*
|
|
343
|
-
*
|
|
344
|
-
*
|
|
345
|
-
*
|
|
343
|
+
* 那些其实已经没有日志、本来被归档藏着的行放回正常列表。所以只在启动时擦,而且**本次
|
|
344
|
+
* 刚删掉日志的 id 这一轮也先不擦**:宿主启动和删日志是并发的,桌面端的快照完全可能
|
|
345
|
+
* 在删完之前就取走、把这些行带在身上;这时擦集合照样会把它们放回正常列表(一次重启
|
|
346
|
+
* 之后它们又出现,再重启才消失)。留到下一次启动再擦,那时它们早已没有日志,任何新
|
|
347
|
+
* 快照里都不会再有这些行。留在集合里的死 id 依然不显示:「已归档」取的是会话列表与
|
|
348
|
+
* 归档集合的交集,没有日志就没有那一行。
|
|
346
349
|
*
|
|
347
350
|
* 用进程级标记而不是模块变量:代际热替换会重跑 apply,而桌面端那条快照挂在同一个进程上。
|
|
348
351
|
*/
|
|
@@ -352,9 +355,21 @@ const PRUNE_RETRY_MS = 500
|
|
|
352
355
|
async function pruneOrphanedArchivedSessionsOnce(ctx, log) {
|
|
353
356
|
if (globalThis[PRUNE_ONCE]) return
|
|
354
357
|
globalThis[PRUNE_ONCE] = true
|
|
358
|
+
// 本次启动前盘上就已经没有数据的 id:它们早就不在任何新快照里,这一轮可以直接擦。
|
|
359
|
+
const alreadyDataLess = new Set(sessionIdsWithoutAnyData(readPendingArchivedClearIds()))
|
|
360
|
+
let skip = []
|
|
355
361
|
// 删日志不碰宿主,先做:越早越好,别等宿主把 workspace 服务挂齐(那时桌面端可能已经连上)。
|
|
356
362
|
try {
|
|
357
363
|
const { deleted, kept } = await deleteQueuedArchivedSessions(log, ctx)
|
|
364
|
+
// 本次刚删掉日志的 id,这一轮先别擦归档集合,留到下一次启动再擦。
|
|
365
|
+
//
|
|
366
|
+
// 桌面端的会话列表是「启动时拉的一份快照 + 之后的事件增量」,宿主没有删除会话的接口,
|
|
367
|
+
// 插件在磁盘上删日志它看不见 —— 它快照里还留着这些行,本来靠「已归档」藏着。这时一擦
|
|
368
|
+
// 集合,宿主就广播「归档集合变了」,桌面端把这些其实已经没有日志的行放回正常列表,用户
|
|
369
|
+
// 看到的就是「重启一次它们又回来了」。留到下一次启动:那时它们早就没有日志,任何新快照
|
|
370
|
+
// 里都不会再有这些行,擦集合的广播也就放不出东西来。代价是这些空 id 在归档集合里多留
|
|
371
|
+
// 一次启动(「已归档」取会话列表与归档集合的交集,没有日志就看不见它)。
|
|
372
|
+
skip = deleted.filter((id) => !alreadyDataLess.has(id))
|
|
358
373
|
if (deleted.length > 0) log?.(`archived clear queue: deleted ${deleted.length} session logs`)
|
|
359
374
|
if (kept.length > 0) log?.(`archived clear queue: ${kept.length} session logs kept for the next start`)
|
|
360
375
|
} catch (error) {
|
|
@@ -365,7 +380,7 @@ async function pruneOrphanedArchivedSessionsOnce(ctx, log) {
|
|
|
365
380
|
// 的死 id 一直没人清(实测宿主重启过一次,315 个没有日志的 id 原样挂着)。
|
|
366
381
|
for (let attempt = 1; attempt <= PRUNE_ATTEMPTS; attempt += 1) {
|
|
367
382
|
try {
|
|
368
|
-
const pruned = await pruneOrphanedArchivedSessions(ctx)
|
|
383
|
+
const pruned = await pruneOrphanedArchivedSessions(ctx, skip)
|
|
369
384
|
if (pruned > 0) log?.(`archived session prune: dropped ${pruned} deleted session ids`)
|
|
370
385
|
return
|
|
371
386
|
} catch (error) {
|
|
@@ -26,6 +26,7 @@ const { lanUrls } = require('./relay/lan-network.cjs')
|
|
|
26
26
|
const core = require('./relay/core.js')
|
|
27
27
|
const { createReconnectScheduler } = require('./reconnect-scheduler.cjs')
|
|
28
28
|
const { isMirrorSnapshotFresh } = require('./mirror-snapshot.cjs')
|
|
29
|
+
const { snapshotAuthorized } = require('./relay/snapshot-auth.js')
|
|
29
30
|
// 凭据库在这个进程里**只读**(§3.7):写盘只有宿主主进程一家。签发与吊销都经 IPC 交给它,
|
|
30
31
|
// 本地这份按文件指纹重载 —— 两个进程各持一份内存副本、各自整份覆写,后写的赢,于是
|
|
31
32
|
// 「经中转配上的手机回局域网 401」「吊销被回滚」这两条必然出现。
|
|
@@ -242,6 +243,13 @@ function snapshotRead(info) {
|
|
|
242
243
|
// 镜像只在中转连着时才会有新版本推来(原因见 mirror-snapshot.cjs 的说明):冻结的快照必须
|
|
243
244
|
// 回源主进程,否则中转一断、局域网仍健康时,新建的会话/工作区在 App 里永远不出现。
|
|
244
245
|
if (!isMirrorSnapshotFresh(localSnapshot)) return null
|
|
246
|
+
// **鉴权**:这条捷径绕开主进程守卫直接回快照,必须自己验一遍凭据。否则只要知道实例号
|
|
247
|
+
// (印在配对二维码上),不带任何凭据也能经中转读到会话 / 工作区列表。验不过回 null 回源,
|
|
248
|
+
// 交给主进程守卫如实驳回(401);被吊销 / 已过期的凭据同样回落,吊销才不会在这条路上失效。
|
|
249
|
+
if (!snapshotAuthorized(info && info.headers, {
|
|
250
|
+
lookup: (presented) => credentials.lookup(presented, config.instanceId),
|
|
251
|
+
verifyLanToken: (presented) => core.verifyLanToken(config.lanSecret, presented, config.instanceId),
|
|
252
|
+
})) return null
|
|
245
253
|
let url
|
|
246
254
|
try { url = new URL(String((info && info.path) || ''), 'http://127.0.0.1') } catch { return null }
|
|
247
255
|
const pathname = url.pathname
|