dsh-mobile-plugin 0.5.21 → 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.
Files changed (38) hide show
  1. package/README.md +3 -12
  2. package/assets/pets/README.md +9 -0
  3. package/assets/pets/deepy-whale/attention.gif +0 -0
  4. package/assets/pets/deepy-whale/done.gif +0 -0
  5. package/assets/pets/deepy-whale/error.gif +0 -0
  6. package/assets/pets/deepy-whale/idle.gif +0 -0
  7. package/assets/pets/deepy-whale/pack.json +54 -0
  8. package/assets/pets/deepy-whale/sleeping.gif +0 -0
  9. package/assets/pets/deepy-whale/working-3.gif +0 -0
  10. package/assets/pets/deepy-whale/working.gif +0 -0
  11. package/extensions/README.md +64 -0
  12. package/extensions/catalog.json +1 -0
  13. package/lib/impl.js +21 -1
  14. package/lib/mobile/approvals.js +22 -2
  15. package/lib/mobile/cli/acp.js +34 -20
  16. package/lib/mobile/cli/mobile.js +8 -1
  17. package/lib/mobile/cli/service.js +151 -10
  18. package/lib/mobile/extension-catalog.js +244 -0
  19. package/lib/mobile/local-images.js +10 -5
  20. package/lib/mobile/pet-tools.js +215 -0
  21. package/lib/mobile/pets.js +559 -0
  22. package/lib/mobile/session-local-image.js +9 -1
  23. package/lib/mobile/stats-collect.cjs +15 -7
  24. package/lib/mobile/stats-ledger.cjs +44 -5
  25. package/lib/mobile/wechat-tools.js +257 -0
  26. package/lib/mobile/wechat.js +489 -0
  27. package/lib/panel/index.html +10 -10
  28. package/lib/relay/pairing-payload.cjs +13 -5
  29. package/lib/relay/snapshot-auth.js +82 -0
  30. package/lib/relay/status.js +3 -0
  31. package/lib/routes/control-plane.js +65 -3
  32. package/lib/routes/mobile-api.js +54 -1
  33. package/lib/sidecar-facade-worker.cjs +8 -0
  34. package/lib/tailcat/host-process.cjs +266 -0
  35. package/lib/tailcat/ingress.cjs +98 -0
  36. package/lib/tailcat/session.cjs +264 -0
  37. package/lib/tailcat/wiring.cjs +96 -0
  38. 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
- payload,
63
- text: `dshpair://v1?data=${encodeURIComponent(JSON.stringify(payload))}`,
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 };
@@ -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,7 +505,43 @@ 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 看宿主记的「配没配过」,自己决定先走引导还是直接进主页
@@ -655,8 +693,25 @@ function registerControlPlaneRoutes(deps) {
655
693
  const source = preview && /^https?:\/\/[^\s]+$/i.test(preview.trim())
656
694
  ? { ...state, relayUrl: preview.trim().replace(/\/+$/, '') }
657
695
  : state
658
- const { payload, text } = pairingPayload(source, lanPortInUse())
659
- if (!payload.relayUrl && payload.lanUrls.length === 0) {
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) {
660
715
  sendJson(res, 409, { ok: false, error: '没有可用的局域网或中转连接地址' })
661
716
  return
662
717
  }
@@ -677,6 +732,9 @@ function registerControlPlaneRoutes(deps) {
677
732
  // 的 `lanPort` 不同,不说清楚会让人对着 18787 排查一个根本没在监听的端口。
678
733
  lanPort: lanPortInUse(),
679
734
  lanPortAdjusted: getLanPortAdjusted(),
735
+ // 手机拿到的那三样(地址 / 一次性私钥 / 证明串)——只在本机屏幕上出现,
736
+ // 与二维码同等敏感,所以面板以外的地方不要 log 它。
737
+ tailcat: tailcat ? { address: tailcat.address, port: tailcat.port, expiresAt: tailcat.expiresAt } : null,
680
738
  svg,
681
739
  })
682
740
  } catch (error) {
@@ -1094,6 +1152,10 @@ const WEB_ACCESS_ROUTES = new Set([
1094
1152
  '/api/relay/onboarded',
1095
1153
  '/api/relay/devices',
1096
1154
  '/api/relay/devices/revoke',
1155
+ // 手机直连隧道(Tailcat)的查看与管理。
1156
+ '/api/relay/tailcat',
1157
+ '/api/relay/tailcat/enabled',
1158
+ '/api/relay/tailcat/revoke',
1097
1159
  '/api/relay/revoke-all',
1098
1160
  '/api/relay/config',
1099
1161
  '/api/relay/check-port',
@@ -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 };
@@ -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
@@ -0,0 +1,266 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * 手机直连隧道的电脑端助手进程:找得到就拉起、挂了重拉、退出收掉。
5
+ *
6
+ * 这个文件只管「进程」:助手在哪、怎么起、stdout 上那几行事件是什么意思。业务判断
7
+ * (白名单该是谁、配对窗口什么时候开、状态存到哪)留给调用方——它通过 onEvent 收事件,
8
+ * 通过 `persist` 事件里的 id 回执告诉助手「这笔改动存住了没有」。助手自己不留状态,
9
+ * 所以插件永远只有一份真相。
10
+ *
11
+ * 二进制从按平台的 npm 包装出来(`dsh-mobile-tailcat-<平台>`),装不上就没有这条通道,
12
+ * 其余功能一点不受影响——这也是不做运行时下载的理由:走的还是插件自己那条包管理器链路,
13
+ * 国内镜像、完整性校验、断点重试都是包管理器现成的。
14
+ *
15
+ * 助手是 Go 编的原生程序,Windows 上是 .exe;它只监听回环(由插件告诉它本机端口),
16
+ * 隧道里也只开一个固定端口。
17
+ */
18
+
19
+ const { spawn } = require('node:child_process');
20
+ const fs = require('node:fs');
21
+ const os = require('node:os');
22
+ const path = require('node:path');
23
+
24
+ /** 助手 IPC 版本,跟 tailcat-host/main.go 的 protoVersion 一起改。 */
25
+ const IPC_VERSION = 1;
26
+
27
+ /** 隧道里那个固定端口,与 tailcat-host/node.go 的 virtualPort 一致。 */
28
+ const TUNNEL_PORT = 48941;
29
+
30
+ /** 配对窗口那个端口,与 pairingPort 一致(手机侧知道它,但只需拨它一次)。 */
31
+ const PAIRING_PORT = 48942;
32
+
33
+ /** 一行 IPC 的上限,与助手的 lineLimit 一致。 */
34
+ const MAX_LINE = 64 * 1024;
35
+
36
+ /** 重启退避:起不来时别把机器打满。 */
37
+ const BACKOFF_MS = [1_000, 2_000, 5_000, 10_000, 30_000];
38
+
39
+ /** 事件里允许出现的键,多一个少一个都当脏数据丢掉(stdout 是私有 IPC)。 */
40
+ const EVENT_KEYS = {
41
+ ready: ['address'],
42
+ pairing: ['address', 'port', 'key', 'proof', 'expiresAt'],
43
+ paired: ['address', 'client'],
44
+ pairingClosed: [],
45
+ persist: ['id', 'state', 'allowedClients'],
46
+ error: ['code'],
47
+ };
48
+
49
+ const ADDRESS = /^tc[\x21-\x7e]{0,4095}$/;
50
+ const PRIVATE_KEY = /^privkey:[0-9a-f]{64}$/;
51
+ const CLIENT_KEY = /^nodekey:[0-9a-f]{64}$/;
52
+ const PROOF = /^[A-Za-z0-9_-]{43}$/;
53
+
54
+ /** 这台机器对应哪个平台包(与 tools/build-tailcat-helper.mjs 的 id 一致)。 */
55
+ function platformId(platform = process.platform, arch = process.arch) {
56
+ const os = platform === 'win32' ? 'win32' : platform;
57
+ const cpu = arch === 'x64' ? 'x64' : arch === 'arm64' ? 'arm64' : arch;
58
+ const id = `${os}-${cpu}`;
59
+ return ['darwin-arm64', 'darwin-x64', 'linux-x64', 'linux-arm64', 'win32-x64'].includes(id) ? id : undefined;
60
+ }
61
+
62
+ /**
63
+ * 找助手二进制:先按平台包解析(npm 装出来的正常形态),找不到就回 null——
64
+ * 调用方据此把这条通道标成「不可用」,不要抛错、不要影响插件启动。
65
+ */
66
+ function resolveBinary(options = {}) {
67
+ const platform = options.platformId || platformId(options.platform, options.arch);
68
+ if (!platform) return null;
69
+ const name = platform === 'win32-x64' ? 'dsh-tailcat-host.exe' : 'dsh-tailcat-host';
70
+ const pkg = `dsh-mobile-tailcat-${platform}`;
71
+
72
+ // 1. 平台包(可选依赖装出来的那份)。npm / pnpm 把可选依赖装在「插件自己这份 node_modules」里,
73
+ // 所以先按本模块自身所在位置解析(从本文件往上找,嵌套与 hoisted 布局都命中)——运行时的工作
74
+ // 目录(宿主可能是别处)跟安装目录不是一回事,只按工作目录找会在真实部署里找不到;再退回调用方
75
+ // 指定路径 / 当前工作目录(老行为,部署方把包放在工作目录时仍可用)。
76
+ for (const paths of [undefined, options.paths || [process.cwd()]]) {
77
+ try {
78
+ const manifest = require.resolve(`${pkg}/package.json`, paths ? { paths } : undefined);
79
+ const candidate = path.join(path.dirname(manifest), name);
80
+ if (fs.existsSync(candidate)) return candidate;
81
+ } catch {
82
+ /* 这一路没有:试下一路 */
83
+ }
84
+ }
85
+
86
+ // 2. 指定的路径(开发机、或者部署方自己放好的那一份)。
87
+ const override = process.env.DSHM_TAILCAT_HELPER;
88
+ if (typeof override === 'string' && override && fs.existsSync(override)) return override;
89
+
90
+ // 3. 本仓交叉编译的产物(开发与自测用)。
91
+ const dev = path.join(__dirname, '..', '..', '..', 'build', 'tailcat-runtime', platform, name);
92
+ if (fs.existsSync(dev)) return dev;
93
+ return null;
94
+ }
95
+
96
+ /**
97
+ * 拉起一个助手进程。
98
+ *
99
+ * options:
100
+ * localPort 插件本机 HTTP 端口(隧道里唯一的去处)
101
+ * name 这台电脑的名字(手机配对时显示)
102
+ * state 上次存下来的身份(字符串)或 null
103
+ * allowedClients 已经配过对的手机公钥
104
+ * onEvent(event) 事件回调,event 已去掉 event 字段之外的脏数据
105
+ * resolve 覆盖二进制查找(测试用)
106
+ */
107
+ function createTailcatHost(options) {
108
+ const binary = (options.resolve || resolveBinary)(options);
109
+ let child;
110
+ let buffer = '';
111
+ let closed = false;
112
+ let restarts = 0;
113
+ let backoffIndex = 0;
114
+ let restartTimer;
115
+ let ready = false;
116
+ let address = '';
117
+
118
+ const status = () => ({ available: Boolean(binary), running: Boolean(child), ready, address, restarts });
119
+
120
+ function write(value) {
121
+ if (!child || !child.stdin || !child.stdin.writable) return;
122
+ child.stdin.write(`${JSON.stringify(value)}\n`);
123
+ }
124
+
125
+ function handleLine(line) {
126
+ let value;
127
+ try {
128
+ value = JSON.parse(line);
129
+ } catch {
130
+ return; // 脏行直接丢,从不回显
131
+ }
132
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return;
133
+ const kind = value.event;
134
+ if (typeof kind !== 'string' || !EVENT_KEYS[kind]) return;
135
+ const keys = Object.keys(value);
136
+ const fields = EVENT_KEYS[kind];
137
+ if (keys.length !== fields.length + 1 || fields.some(field => !(field in value))) return;
138
+
139
+ if (kind === 'ready' && typeof value.address === 'string' && (value.address === '' || ADDRESS.test(value.address))) {
140
+ ready = true;
141
+ address = value.address;
142
+ backoffIndex = 0;
143
+ } else if (kind === 'pairing') {
144
+ if (!ADDRESS.test(String(value.address)) || value.port !== PAIRING_PORT) return;
145
+ if (!PRIVATE_KEY.test(String(value.key)) || !PROOF.test(String(value.proof))) return;
146
+ if (!Number.isSafeInteger(value.expiresAt)) return;
147
+ } else if (kind === 'paired') {
148
+ if (!CLIENT_KEY.test(String(value.client)) || !ADDRESS.test(String(value.address))) return;
149
+ address = value.address;
150
+ } else if (kind === 'persist') {
151
+ if (typeof value.id !== 'string' || value.id.length > 32) return;
152
+ if (!Array.isArray(value.allowedClients)) return;
153
+ } else if (kind === 'error') {
154
+ if (typeof value.code !== 'string' || value.code.length > 64) return;
155
+ ready = false;
156
+ }
157
+ options.onEvent({ ...value });
158
+ }
159
+
160
+ function scheduleRestart() {
161
+ if (closed || restartTimer) return;
162
+ const delay = BACKOFF_MS[Math.min(backoffIndex, BACKOFF_MS.length - 1)];
163
+ backoffIndex += 1;
164
+ restarts += 1;
165
+ restartTimer = setTimeout(() => {
166
+ restartTimer = undefined;
167
+ launch();
168
+ }, delay);
169
+ restartTimer.unref?.();
170
+ }
171
+
172
+ function launch() {
173
+ if (closed || !binary) return;
174
+ let proc;
175
+ try {
176
+ proc = spawn(binary, [], { stdio: ['pipe', 'pipe', 'ignore'], windowsHide: true });
177
+ } catch {
178
+ scheduleRestart();
179
+ return;
180
+ }
181
+ child = proc;
182
+ buffer = '';
183
+ ready = false;
184
+ proc.on('error', () => {});
185
+ proc.stdin?.on('error', () => {});
186
+ proc.stdout?.setEncoding('utf8');
187
+ proc.stdout?.on('data', chunk => {
188
+ if (child !== proc) return;
189
+ buffer += chunk;
190
+ for (let at = buffer.indexOf('\n'); at >= 0; at = buffer.indexOf('\n')) {
191
+ const line = buffer.slice(0, at);
192
+ buffer = buffer.slice(at + 1);
193
+ handleLine(line);
194
+ }
195
+ if (buffer.length > MAX_LINE) proc.kill();
196
+ });
197
+ proc.on('exit', () => {
198
+ if (child !== proc) return;
199
+ child = undefined;
200
+ ready = false;
201
+ address = '';
202
+ if (!closed) scheduleRestart();
203
+ });
204
+ write({
205
+ version: IPC_VERSION,
206
+ localPort: options.localPort,
207
+ name: String(options.name || os.hostname()).slice(0, 64),
208
+ state: options.state === undefined ? null : options.state,
209
+ allowedClients: Array.isArray(options.allowedClients) ? options.allowedClients : [],
210
+ });
211
+ }
212
+
213
+ return {
214
+ status,
215
+ start() {
216
+ if (closed || child) return;
217
+ launch();
218
+ },
219
+ /** 开一个配对窗口(助手自己管两分钟过期)。 */
220
+ pair(ttlSeconds) {
221
+ write({ command: 'pair', ttl: ttlSeconds });
222
+ },
223
+ cancelPairing() {
224
+ write({ command: 'cancelPairing' });
225
+ },
226
+ /** 换白名单(吊销手机就走这条)。 */
227
+ setAllowed(allowedClients) {
228
+ write({ command: 'setAllowed', allowedClients: allowedClients || [] });
229
+ },
230
+ /** 回执:state 存住了没有。 */
231
+ ack(id, ok) {
232
+ write({ command: 'ack', id, ok: Boolean(ok) });
233
+ },
234
+ async close() {
235
+ if (closed) return;
236
+ closed = true;
237
+ if (restartTimer) clearTimeout(restartTimer);
238
+ restartTimer = undefined;
239
+ const proc = child;
240
+ child = undefined;
241
+ if (!proc || proc.exitCode !== null) return;
242
+ // 收起 stdin 就是助手的停止信号;它无视才强杀。
243
+ await new Promise(done => {
244
+ const timer = setTimeout(() => {
245
+ proc.kill();
246
+ done();
247
+ }, 3_000);
248
+ timer.unref?.();
249
+ proc.once('exit', () => {
250
+ clearTimeout(timer);
251
+ done();
252
+ });
253
+ proc.stdin?.end();
254
+ });
255
+ },
256
+ };
257
+ }
258
+
259
+ module.exports = {
260
+ IPC_VERSION,
261
+ TUNNEL_PORT,
262
+ PAIRING_PORT,
263
+ platformId,
264
+ resolveBinary,
265
+ createTailcatHost,
266
+ };