dsh-mobile-plugin 0.5.24 → 0.5.26

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 (42) hide show
  1. package/README.md +6 -6
  2. package/knowledge/apps/alipay.md +30 -0
  3. package/knowledge/apps/android-settings.md +30 -0
  4. package/knowledge/apps/bilibili.md +30 -0
  5. package/knowledge/apps/boss-zhipin.md +32 -0
  6. package/knowledge/apps/douyin.md +32 -0
  7. package/knowledge/apps/taobao.md +33 -0
  8. package/knowledge/apps/wechat.md +33 -0
  9. package/knowledge/apps/weibo.md +31 -0
  10. package/knowledge/apps/xiaohongshu.md +29 -0
  11. package/knowledge/apps/zhihu.md +30 -0
  12. package/knowledge/sites/bilibili.com.md +27 -0
  13. package/knowledge/sites/github.com.md +27 -0
  14. package/knowledge/sites/x.com.md +28 -0
  15. package/knowledge/sites/xiaohongshu.com.md +27 -0
  16. package/knowledge/sites/youtube.com.md +28 -0
  17. package/knowledge/sites/zhihu.com.md +27 -0
  18. package/lib/impl.js +9 -0
  19. package/lib/mobile/cli/acp.js +3 -2
  20. package/lib/mobile/phone/confirm.js +189 -0
  21. package/lib/mobile/phone/index.js +21 -1
  22. package/lib/mobile/phone/irreversible.js +146 -0
  23. package/lib/mobile/phone/pacing.js +173 -0
  24. package/lib/mobile/phone/route-hint.js +164 -0
  25. package/lib/mobile/phone/routes-memory.js +133 -0
  26. package/lib/mobile/phone/routes.js +84 -0
  27. package/lib/mobile/phone/service.js +90 -5
  28. package/lib/mobile/phone/tools.js +1190 -64
  29. package/lib/mobile/phone-knowledge.js +301 -0
  30. package/lib/mobile/schedule.js +64 -7
  31. package/lib/mobile/sessions.js +40 -5
  32. package/lib/mobile/wechat.js +26 -1
  33. package/lib/p2p/rendezvous.cjs +106 -0
  34. package/lib/p2p/session.cjs +455 -0
  35. package/lib/p2p/wiring.cjs +93 -0
  36. package/lib/panel/index.html +10 -10
  37. package/lib/relay/status.js +2 -0
  38. package/lib/routes/control-plane.js +24 -1
  39. package/lib/routes/mobile-api.js +20 -1
  40. package/lib/shared/e2ee-request.js +27 -7
  41. package/lib/tailcat/host-process.cjs +37 -0
  42. package/package.json +4 -2
@@ -110,6 +110,8 @@ function createStatusView(deps) {
110
110
  // 直连隧道(tailcat):手机据此自己把通道加进来,**不需要重新扫码**。
111
111
  // 没开或起不来就是 null,老手机不认识这个字段也无害。
112
112
  tailcat: typeof deps.getTailcat === 'function' ? deps.getTailcat() : null,
113
+ // 点对点隧道那一格:手机轮询 `/status` 时据此知道这条通道在不在、通没通。
114
+ p2p: typeof deps.getP2P === 'function' ? deps.getP2P() : null,
113
115
  // 以本进程解析出的实际端口为准:sidecar 的快照里 `port` 才是最终 bind 成功的
114
116
  // 那个(它在 listen 之后才拿得到),而这里在探测阶段就已经知道了,两个值一致
115
117
  // 才不会让状态接口和二维码互相打架。
@@ -508,6 +508,13 @@ function registerControlPlaneRoutes(deps) {
508
508
  // 为什么白名单是「节点公钥」而不是设备 id:隧道那一层只认 WireGuard 公钥,和移动端
509
509
  // 凭据是两套东西 —— 吊销设备凭据并不会把它从隧道白名单里摘掉(见 A3-87)。所以这里
510
510
  // 单独给一个出口,否则「踢一台手机」在隧道这一层无法完成。
511
+ // 点对点隧道只有一格里要说的事:这条通道起没起、打通了没有、走的是谁。
512
+ // 没有白名单也没有总开关 —— 能连上的前提就是私钥对得上,比白名单更直接;开关那件事
513
+ // 交给手机侧的档位(`channelMode`),电脑这边不替人做决定。
514
+ registerRoute({ kind: 'exact', path: '/api/relay/p2p', handler: localOnly('GET', async (_req, res) => {
515
+ const state = deps.p2p && typeof deps.p2p.status === 'function' ? deps.p2p.status() : null
516
+ sendJson(res, 200, { ok: true, status: state })
517
+ }) })
511
518
  const tailcatSnapshot = () => {
512
519
  const state = deps.tailcat && typeof deps.tailcat.status === 'function' ? deps.tailcat.status() : null
513
520
  const allowed = deps.tailcat && typeof deps.tailcat.allowedClients === 'function' ? deps.tailcat.allowedClients() : []
@@ -702,8 +709,22 @@ function registerControlPlaneRoutes(deps) {
702
709
  expiresAt: tailcat.expiresAt,
703
710
  }
704
711
  }
712
+ // 点对点隧道:二维码多带一段「电脑的公钥 + 电脑自己探到的打洞候选」。两端各持一半密钥,
713
+ // 之后每次起来都用同一对,不需要窗口、也不需要临时钥匙 —— 与上面那条一次性节点不是一回事。
714
+ if (deps.p2p && typeof deps.p2p.exchange === 'function') {
715
+ const exchanged = deps.p2p.exchange(null)
716
+ if (exchanged && exchanged.ok) {
717
+ payload.p2p = {
718
+ version: 1,
719
+ peerKey: exchanged.publicKey,
720
+ port: exchanged.port,
721
+ candidates: Array.isArray(exchanged.candidates) ? exchanged.candidates : [],
722
+ localCandidates: Array.isArray(exchanged.localCandidates) ? exchanged.localCandidates : [],
723
+ }
724
+ }
725
+ }
705
726
  const text = pairingText(payload)
706
- if (!payload.relayUrl && payload.lanUrls.length === 0 && !payload.tailcat) {
727
+ if (!payload.relayUrl && payload.lanUrls.length === 0 && !payload.tailcat && !payload.p2p) {
707
728
  sendJson(res, 409, { ok: false, error: '没有可用的局域网或中转连接地址' })
708
729
  return
709
730
  }
@@ -1148,6 +1169,8 @@ const WEB_ACCESS_ROUTES = new Set([
1148
1169
  '/api/relay/tailcat',
1149
1170
  '/api/relay/tailcat/enabled',
1150
1171
  '/api/relay/tailcat/revoke',
1172
+ // 点对点隧道的现状(只有查看,没有开关也没有白名单:能连上的前提就是密钥对得上)。
1173
+ '/api/relay/p2p',
1151
1174
  '/api/relay/revoke-all',
1152
1175
  '/api/relay/config',
1153
1176
  '/api/relay/check-port',
@@ -13,6 +13,7 @@
13
13
 
14
14
  const { sendJson } = require('../http/routing.js');
15
15
  const { readJsonBody } = require('../shared/http-util.js');
16
+ const { createP2PRendezvousHandler } = require('../p2p/rendezvous.cjs');
16
17
  const { orderCandidates, withManualCandidate, withManualIpv6Candidate } = require('../relay/lan-network.cjs');
17
18
 
18
19
  const sessions = require('../mobile/sessions.js');
@@ -98,7 +99,7 @@ const API_VERSION = '1';
98
99
  * 所以它走 route() 即包 guarded。
99
100
  */
100
101
  function registerMobileRoutes(deps) {
101
- const { registerRoute, tailcat, registerUpgradeRoute, guarded, state, hostMetadata, getSidecarSnapshot, safeLanCandidates, safeIpv6Candidates, lanPortInUse, preview, core, pairingApproval, rotatePairing, terminalService, cliService, phoneService } = deps;
102
+ const { registerRoute, tailcat, p2p, registerUpgradeRoute, guarded, state, hostMetadata, getSidecarSnapshot, safeLanCandidates, safeIpv6Candidates, lanPortInUse, preview, core, pairingApproval, rotatePairing, terminalService, cliService, phoneService } = deps;
102
103
  // CLI 会话(`cli-*`)在这张表上分派一次(见 mobile/cli/mobile.js):它们是 ACP 子进程、
103
104
  // 不是宿主的会话,但手机上必须像普通会话一样可用 —— 列表 / 历史 / 发送 / 停止 / 流 /
104
105
  // 审批 / 模型全走宿主那套端点。分派发生在 `route()` 里面,下面每条注册都还是原样一行,
@@ -215,6 +216,16 @@ function registerMobileRoutes(deps) {
215
216
  remoteEnabled: Boolean(state.enabled),
216
217
  relayUrl: state.enabled ? (state.relayUrl || null) : null,
217
218
  ...hostMetadata(),
219
+ // 点对点隧道这一格。
220
+ //
221
+ // **这是 App 知道「这台电脑上这条通道到底有没有」的唯一来源**:早先它没挂在这条心跳上,
222
+ // 手机每 30 秒问一次、每次都只拿到上面那几格,于是「选择线路」里只能显示「未配置」、
223
+ // 状态一句也同步不过来 —— 而电脑面板那边明明已经显示「等待手机接入」了。两边说的
224
+ // 是同一件事,一边有数据一边没有,就是这里漏了一格。
225
+ //
226
+ // 形状与 `/api/relay/status` 的 `p2p` 完全一致(同一个 `getP2P()`),手机按该口的
227
+ // `available / enabled / ready / started / path` 说话,不自己猜。
228
+ p2p: p2p && typeof p2p.status === 'function' ? p2p.status() : null,
218
229
  })
219
230
  }) })
220
231
  // 地址体检:手机在**配对之前**就要问「这个地址上到底是谁」,那时它手上没有任何
@@ -360,6 +371,11 @@ function registerMobileRoutes(deps) {
360
371
  })
361
372
  })
362
373
 
374
+ // 点对点隧道的会合:手机把打洞候选报上来,这里把电脑那半边拉起来、并把电脑自己的
375
+ // 候选还回去。**没有这一步,两端谁也不知道对方的出口地址,这条通道一次都连不上**。
376
+ // 它走这条路而不是另架服务:这一发就打在我们已经通着的链路上,只传几十个字节。
377
+ route('/api/mobile/v1/p2p/rendezvous', createP2PRendezvousHandler({ p2p, lanPortInUse }))
378
+
363
379
  route('/api/mobile/v1/sessions', sessions.handleSessions)
364
380
  // 后台提醒服务专用:只回「在跑 / 刚完成 / 待回话 / 上一轮失败」这几格,
365
381
  // 带 `?rev=` 条件刷新。它要替掉原生那边每 8 秒一发的完整清单(那份还得为 20 个
@@ -731,6 +747,9 @@ function registerMobileRoutes(deps) {
731
747
  route('/api/mobile/v1/phone/status', phoneRoutes.status)
732
748
  route('/api/mobile/v1/phone/devices', phoneRoutes.devices)
733
749
  route('/api/mobile/v1/phone/unbind', phoneRoutes.unbind)
750
+ // 知识库清单与操作节奏:都只读 / 改设置,同样不发动作。节奏那条 GET 读档、POST 改档。
751
+ route('/api/mobile/v1/phone/knowledge', phoneRoutes.knowledge)
752
+ route('/api/mobile/v1/phone/pacing', phoneRoutes.pacing)
734
753
  registerUpgradeRoute({ path: '/api/mobile/v1/phone/control', handler: phoneService.handleUpgrade })
735
754
  }
736
755
 
@@ -19,12 +19,34 @@ const { ACCEPT_ENC_HEADER, compressPlain, decompressPlain } = require('../shared
19
19
 
20
20
  /** 重放窗口:手机时钟可能偏几分钟,5 分钟是给它留的余量,真正的防线是 nonce 表。 */
21
21
  const TS_WINDOW_MS = 5 * 60 * 1000;
22
- /** 每台设备保留最近多少条写请求的 nonce,过期 5 分钟后淘汰。 */
22
+ /**
23
+ * nonce 表的**软上限**,不是硬驱逐阈值。
24
+ *
25
+ * 超过它只是触发一次「淘汰过期项」的清理;清理完若仍高于这条线,宁可让表短暂超出,
26
+ * 也**不**为了压到上限删掉未过期项 —— 那些 nonce 还在 5 分钟窗口内,删掉它们等于让
27
+ * 同一个 nonce 再进一次,重放防护就漏了。窗口内的写请求量正常远不到这个数,删完过期项
28
+ * 后表会自然回落。
29
+ */
23
30
  const NONCE_TABLE_SIZE = 256;
24
31
 
25
32
  /** keyId → { nonce: ts } 的环形表。GET 不记:换通道重投会复用同一 nonce。 */
26
33
  const nonces = new Map();
27
34
 
35
+ /**
36
+ * 淘汰重放表里**真正过期**的 nonce(`now - stamp > TS_WINDOW_MS`)。
37
+ *
38
+ * 只按时间删,绝不按数量删:见 `NONCE_TABLE_SIZE` 的注释。抽成纯函数是为了能单独测
39
+ * 「窗口内一条都不许删」这条判据,不必通过整个信封流程。
40
+ *
41
+ * @param {Map<string, number>} table nonce → 请求时间戳(毫秒)
42
+ * @param {number} now 当前时间(毫秒)
43
+ */
44
+ function evictExpiredNonces(table, now) {
45
+ for (const [key, stamp] of table) {
46
+ if (now - stamp > TS_WINDOW_MS) table.delete(key);
47
+ }
48
+ }
49
+
28
50
  /**
29
51
  * 读一个请求头(大小写不敏感、数组取第一个)。
30
52
  *
@@ -137,12 +159,9 @@ function openRequest(req, deps, rawBody) {
137
159
  const table = seen || new Map();
138
160
  if (table.has(nonce)) return { ok: false, status: 409, code: 'e2ee-replay' };
139
161
  table.set(nonce, ts);
140
- // 过期淘汰 + 上限:表只增不减的话一台设备长期使用会把它撑大。
141
- if (table.size > NONCE_TABLE_SIZE) {
142
- for (const [key, stamp] of table) {
143
- if (Date.now() - stamp > TS_WINDOW_MS || table.size > NONCE_TABLE_SIZE) table.delete(key);
144
- }
145
- }
162
+ // 过期淘汰:表只增不减的话一台设备长期使用会把它撑大。只删真正过期的,
163
+ // 超出软上限也不硬删未过期项(见 `NONCE_TABLE_SIZE` 与 `evictExpiredNonces`)。
164
+ if (table.size > NONCE_TABLE_SIZE) evictExpiredNonces(table, Date.now());
146
165
  nonces.set(keyId, table);
147
166
  }
148
167
 
@@ -363,6 +382,7 @@ module.exports = {
363
382
  signedPath,
364
383
  signedQuery,
365
384
  readRawBody,
385
+ evictExpiredNonces,
366
386
  MAX_SEALED_BODY,
367
387
  TS_WINDOW_MS,
368
388
  NONCE_TABLE_SIZE,
@@ -39,6 +39,10 @@ const BACKOFF_MS = [1_000, 2_000, 5_000, 10_000, 30_000];
39
39
  /** 事件里允许出现的键,多一个少一个都当脏数据丢掉(stdout 是私有 IPC)。 */
40
40
  const EVENT_KEYS = {
41
41
  ready: ['address'],
42
+ p2pReady: ['url', 'candidates'],
43
+ p2pStopped: [],
44
+ p2pStatus: ['running', 'url', 'path', 'active', 'detail', 'candidates'],
45
+ p2pKeys: ['private', 'public', 'port'],
42
46
  pairing: ['address', 'port', 'key', 'proof', 'expiresAt'],
43
47
  paired: ['address', 'client'],
44
48
  pairingClosed: [],
@@ -235,6 +239,39 @@ function createTailcatHost(options) {
235
239
  ack(id, ok) {
236
240
  write({ command: 'ack', id, ok: Boolean(ok) });
237
241
  },
242
+ /**
243
+ * 拉起电脑侧的点对点隧道(P2P)。
244
+ *
245
+ * 与 `pair()` 那类「开会合窗口」不同:这条不需要窗口、也不要临时钥匙 —— 电脑的公钥
246
+ * 与手机的公钥在配对时就换过了,之后每次起来都用同一对。所以它只有「起来 / 停下 / 问现状」
247
+ * 三个动作。
248
+ */
249
+ startP2P(options) {
250
+ const list = (value) => (Array.isArray(value) ? value.filter(item => typeof item === 'string') : []);
251
+ write({
252
+ command: 'p2pStart',
253
+ p2pPrivateKey: String(options.privateKey || ''),
254
+ p2pPeerKey: String(options.peerKey || ''),
255
+ p2pPeerLocal: list(options.peerLocal),
256
+ p2pPeerPublic: list(options.peerPublic),
257
+ p2pListenPort: Number(options.listenPort) || 0,
258
+ p2pTargetPort: Number(options.targetPort) || 0,
259
+ });
260
+ },
261
+ stopP2P() {
262
+ write({ command: 'p2pStop' });
263
+ },
264
+ statusP2P() {
265
+ write({ command: 'p2pStatus' });
266
+ },
267
+ /** 生成一对新密钥(助手负责算,它才懂 WireGuard 的密钥格式)。 */
268
+ newKeysP2P() {
269
+ write({ command: 'p2pKeys' });
270
+ },
271
+ /** 测试缝:原样写一条命令。 */
272
+ writeCommand(value) {
273
+ write(value);
274
+ },
238
275
  async close() {
239
276
  if (closed) return;
240
277
  closed = true;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-mobile-plugin",
3
- "version": "0.5.24",
3
+ "version": "0.5.26",
4
4
  "description": "Mobile API (/api/mobile/v1) and relay client for the DSH mobile app — desktop profile build (dsh 0.2.x, peers resolved by the host)",
5
5
  "license": "MIT",
6
6
  "icon": "./icon.png",
@@ -43,6 +43,7 @@
43
43
  "locale/*.json",
44
44
  "icon.png",
45
45
  "assets/pets/**",
46
+ "knowledge/**/*.md",
46
47
  "extensions/**",
47
48
  "README.md"
48
49
  ],
@@ -58,7 +59,8 @@
58
59
  "dependencies": {
59
60
  "@agentclientprotocol/sdk": "2.4.0",
60
61
  "qrcode": "1.5.4",
61
- "ws": "8.18.0"
62
+ "ws": "8.18.0",
63
+ "zod": "4.6.5"
62
64
  },
63
65
  "optionalDependencies": {
64
66
  "dsh-mobile-tailcat-darwin-arm64": "1.0.0",