dsh-mobile-plugin 0.1.100 → 0.2.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.
Files changed (59) hide show
  1. package/README.md +14 -5
  2. package/dsh-mobile-credentials/lib/credentials.js +405 -84
  3. package/lib/client.js +31 -8
  4. package/lib/http/routing.js +165 -9
  5. package/lib/impl.js +88 -9
  6. package/lib/index.js +3 -2
  7. package/lib/mirror/pusher.cjs +35 -4
  8. package/lib/mobile/approvals.js +62 -15
  9. package/lib/mobile/auth.js +46 -26
  10. package/lib/mobile/credential-upgrade.js +137 -0
  11. package/lib/mobile/entry-window.js +32 -0
  12. package/lib/mobile/events.js +14 -4
  13. package/lib/mobile/extensions.js +279 -0
  14. package/lib/mobile/history-derive.js +209 -11
  15. package/lib/mobile/host-bridge/event-synthesis.js +61 -10
  16. package/lib/mobile/pairing.js +150 -64
  17. package/lib/mobile/plugin-self.js +187 -0
  18. package/lib/mobile/push-notify-route.js +37 -0
  19. package/lib/mobile/push-notify.js +84 -0
  20. package/lib/mobile/questions.js +75 -10
  21. package/lib/mobile/relay-control.js +547 -0
  22. package/lib/mobile/session-cache.js +60 -2
  23. package/lib/mobile/session-file.js +133 -34
  24. package/lib/mobile/session-list-shortcut.js +38 -0
  25. package/lib/mobile/session-model-options.js +3 -1
  26. package/lib/mobile/session-models.js +15 -3
  27. package/lib/mobile/session-send.js +30 -27
  28. package/lib/mobile/session-snapshot.js +12 -12
  29. package/lib/mobile/sessions.js +20 -0
  30. package/lib/mobile/stats-collect.cjs +55 -12
  31. package/lib/mobile/stats-ledger.cjs +140 -21
  32. package/lib/mobile/stream-registry.js +73 -0
  33. package/lib/mobile/stream.js +382 -31
  34. package/lib/mobile/subagents.js +11 -2
  35. package/lib/mobile/uploads.js +91 -5
  36. package/lib/mobile/workspaces.js +39 -8
  37. package/lib/panel/index.html +11 -11
  38. package/lib/relay/core.js +28 -2
  39. package/lib/relay/lan-network.cjs +47 -0
  40. package/lib/relay/lan-proxy.js +283 -122
  41. package/lib/relay/protocol.js +4 -2
  42. package/lib/relay/tunnel.js +20 -14
  43. package/lib/routes/control-plane.js +139 -9
  44. package/lib/routes/mobile-api.js +92 -9
  45. package/lib/shared/e2ee-pair.js +91 -0
  46. package/lib/shared/e2ee-plain.js +79 -0
  47. package/lib/shared/e2ee-request.js +331 -0
  48. package/lib/shared/e2ee.js +173 -0
  49. package/lib/shared/http-util.js +27 -4
  50. package/lib/shared/list-signal.js +16 -0
  51. package/lib/shared/pair-throttle.js +150 -0
  52. package/lib/shared/sse-write.js +16 -5
  53. package/lib/shared/via-header.js +65 -0
  54. package/lib/shell/runtime.js +147 -42
  55. package/lib/shell/self-check-after-swap.js +93 -0
  56. package/lib/shell/updater.js +44 -3
  57. package/lib/sidecar-facade-worker.cjs +70 -6
  58. package/lib/update-install.cjs +13 -5
  59. package/package.json +3 -2
@@ -9,9 +9,16 @@
9
9
  * 按老地址 POST `/pair`、请求被中转原样转给宿主、宿主没有这条路由,只能回 405。
10
10
  *
11
11
  * 与局域网那条(`lib/relay/lan-proxy.js` 的 `/__lan_pair`)是**同一件事的两个入口**:
12
- * 都是「配对码 → 签发凭据」,差别只在网络路径。凭据本身两条路签发的是同一种 HMAC
12
+ * 都是「配对码 / 票据 → 签发凭据」,差别只在网络路径。凭据本身两条路签发的是同一种 HMAC
13
13
  * (都用宿主 lanSecret 签名),所以经中转配对拿到的 token 可以直接用于局域网请求。
14
14
  *
15
+ * 请求体**两种形状都认**:
16
+ * 老形状 `{ instanceId?, code, device }` —— 旧 App,明文,一字不改地照旧处理;
17
+ * 新形状 `{ instanceId, sealed }` —— 协议 v2,`sealed` 是用二维码票据派生的
18
+ * 密钥封的,解出来是设备主密钥与设备信息。
19
+ * 新形状里**没有配对码**:票据本身就是资格证明。回包同样是密文 —— 里面是 token / cookie /
20
+ * 设备凭据,明文回包等于让中转顺手拿到这台设备未来一年的通行证。
21
+ *
15
22
  * 故意**不**在这里做的事:
16
23
  * - 不问宿主再确认一次。宿主的配对策略(pairingPolicy)已经决定了它接不接受新设备,
17
24
  * `deny` 时中转根本连不上;而这条路由本身就跑在宿主进程里。
@@ -21,44 +28,34 @@
21
28
 
22
29
  const { sendJson } = require('../shared/http-util');
23
30
  const { safeEqual } = require('../shared/dshtoken');
31
+ const { createPairThrottle, PAIR_RATE_NORMAL } = require('../shared/pair-throttle');
32
+ const { openSealedPairRequest, sealPairResponse } = require('../shared/e2ee-pair');
24
33
 
25
- /** 与局域网那条同口径:连错 5 次锁 60 秒。 */
26
- const PAIR_MAX_FAILS = 5;
27
- const PAIR_LOCK_MS = 60_000;
28
-
29
- /** ip -> { fails, lockedUntil }。放在模块作用域,两种入口共用同一份额度。 */
30
- const pairLocks = new Map();
31
-
32
- function isPairLocked(ip) {
33
- const entry = pairLocks.get(ip);
34
- return !!entry && entry.lockedUntil > Date.now();
35
- }
36
-
37
- function recordPairFail(ip) {
38
- const entry = pairLocks.get(ip) || { fails: 0, lockedUntil: 0 };
39
- entry.fails += 1;
40
- if (entry.fails >= PAIR_MAX_FAILS) {
41
- entry.lockedUntil = Date.now() + PAIR_LOCK_MS;
42
- entry.fails = 0;
43
- }
44
- pairLocks.set(ip, entry);
45
- }
34
+ /**
35
+ * 一条入口一份闸门(见 `shared/pair-throttle.js` 头部的取舍说明)。
36
+ *
37
+ * 原来这里是「按来源分桶、错五次锁一分钟」,而这条链路上没有可靠的来源:经中转进来的
38
+ * 请求对端永远是本机回环,客户端自带的来源头又能覆盖它。于是那套机制两头都错 ——
39
+ * 陌生人连错五次把所有人一起锁死,换一个来源头就绕过。现在改成不看来源的全局令牌桶。
40
+ */
41
+ const throttle = createPairThrottle();
46
42
 
47
- function clearPairFails(ip) {
48
- pairLocks.delete(ip);
49
- }
43
+ /** 配对请求的线上上限:明文 16KB 按信封 4/3 放大(§8.3(9))。超了就按畸形请求处理。 */
44
+ const PAIR_MAX_BODY = Math.ceil(16 * 1024 * 4 / 3);
50
45
 
51
- /** 测试用:清空限流计数。 */
46
+ /** 测试用:把闸门重置成满桶。 */
52
47
  function resetPairLocks() {
53
- pairLocks.clear();
48
+ throttle.reset();
54
49
  }
55
50
 
56
- /** 请求方 IP。中转会把真实来源放进 x-real-ip(与 nginx 同一约定)。 */
51
+ /**
52
+ * 请求方地址,**只用于审计日志与设备记录**,不参与任何限流判定。
53
+ *
54
+ * 不再读 `x-real-ip`:那个值决定不了任何事,留着只会让日志和凭据记录说谎。局域网那条
55
+ * 用的是连接对端(手机上真实的局域网地址,还有用);经中转那条对端就是本机,本来没有
56
+ * 信息量,记本机也不算错。
57
+ */
57
58
  function clientIp(req) {
58
- const headers = (req && req.headers) || {};
59
- const raw = headers['x-real-ip'];
60
- const header = Array.isArray(raw) ? raw[0] : raw;
61
- if (typeof header === 'string' && header.trim()) return header.trim();
62
59
  return (req && req.socket && req.socket.remoteAddress) || '';
63
60
  }
64
61
 
@@ -94,8 +91,9 @@ function readBody(req) {
94
91
  req.setEncoding('utf8');
95
92
  req.on('data', (chunk) => {
96
93
  raw += chunk;
97
- if (raw.length > 16 * 1024) {
98
- raw = raw.slice(0, 16 * 1024);
94
+ // 配对请求的明文上限仍是 16KB;线上多 4/3 是信封那层(§8.3(9))。
95
+ if (raw.length > PAIR_MAX_BODY) {
96
+ raw = raw.slice(0, PAIR_MAX_BODY);
99
97
  req.destroy();
100
98
  finish();
101
99
  }
@@ -121,14 +119,26 @@ function readBody(req) {
121
119
  */
122
120
  function createPairHandler(deps) {
123
121
  const { state, core, credentials, pairingApproval, lanCandidates, ipv6Candidates, lanPort, log } = deps;
122
+ /**
123
+ * 配对成功之后换掉配对码与票据(§5.1)。
124
+ *
125
+ * **两件必须同一个动作**:只换码不换票据,那张截图里的票据依旧能配一次;只换票据不换码,
126
+ * 旧码在旧形状那条路上依旧能配。所以这里是一个回调,由装配层一次做完(写状态 + 同步
127
+ * 旁路),而不是在这里分两步。
128
+ *
129
+ * 传 null 表示不换(测试或旧装配方)。换不动不该让已经签发的配对失败 —— 用户手上已经
130
+ * 拿到凭据了,回滚反而更糟;记一行日志就够。
131
+ */
132
+ const rotatePairing = typeof deps.rotatePairing === 'function' ? deps.rotatePairing : null;
124
133
 
125
134
  // ⚠️ 签名必须是 `(req, res)` **两个**参数:宿主 webServer 就是 `handler(req, res)` 调的。
126
135
  // 写成 `(_ctx, req, res)` 会把 res 挤成 undefined,而 sendJson 的 catch 会把那个
127
136
  // TypeError 当成「连接已关闭」吞掉——既没报错也没回包,请求永久挂住(/health 踩过)。
128
137
  return async function handlePair(req, res) {
129
- const ip = clientIp(req);
130
-
131
- if (isPairLocked(ip)) {
138
+ // 闸门:不看来源,满了直接 429 + retry-after,不排队也不硬锁。
139
+ const gate = throttle.take();
140
+ if (!gate.allowed) {
141
+ res.setHeader?.('retry-after', String(gate.retryAfterSec));
132
142
  sendJson(res, 429, { ok: false, error: '尝试过于频繁,请稍后再试' });
133
143
  return;
134
144
  }
@@ -139,33 +149,80 @@ function createPairHandler(deps) {
139
149
  return;
140
150
  }
141
151
 
142
- const code = typeof body.code === 'string' ? body.code.trim() : '';
143
- if (!/^\d{6}$/.test(code)) {
144
- sendJson(res, 400, { ok: false, error: '配对码需为 6 位数字' });
152
+ const ip = clientIp(req);
153
+ const instanceId = String(state.instanceId || '');
154
+
155
+ // ── 形状判定 ──────────────────────────────────────────────────
156
+ //
157
+ // 有 `sealed` 就是协议 v2;有 `code` 就是旧 App 的老形状,**一字不改地照旧走**;
158
+ // 两样都没有就是畸形请求。两条路只在「怎么拿到设备信息」上不同,签发凭据之后完全汇合。
159
+ const sealed = typeof body.sealed === 'string' ? body.sealed.trim() : '';
160
+ const isSealedShape = sealed !== '';
161
+
162
+ // ── 可猜形状的收口(§10.4(d))─────────────────────────────────
163
+ //
164
+ // 新 App 已经没有手输这条路了:二维码里带着票据,扫到就走 `sealed`。所以「认得协议、
165
+ // 却还带着六位码」只可能是一张过期的旧二维码(截图、面板重开之前的那份)。这个形状
166
+ // 的码是逐位可试的,没有必要继续为它开门 —— 直接让它重扫当前那张。
167
+ //
168
+ // **判据是请求里带没带 `proto`,不是版本号**:老 App 没有协议概念、不带这个字段,
169
+ // 照旧能配;而版本号在 OTA 里并不一定变(热更换的是 JS,App 版本还是那个),拿它
170
+ // 当判据会把「就是不给更新的人」和「已经更新的人」一起判错。
171
+ if (!isSealedShape && body.proto !== undefined) {
172
+ sendJson(res, 400, {
173
+ ok: false,
174
+ code: 'legacy-pairing-retired',
175
+ error: '这张二维码已过期,请重新扫一下电脑面板上当前的二维码',
176
+ });
145
177
  return;
146
178
  }
147
179
 
148
- const expected = String(state.pairingCode || '');
149
- if (!expected || !safeEqual(code, expected)) {
150
- recordPairFail(ip);
151
- pairDenied(res);
152
- return;
180
+ let supplied;
181
+ let issuedExtra;
182
+ let pairKey = null;
183
+ if (isSealedShape) {
184
+ const opened = openSealedPairRequest({
185
+ ticket: state.pairingTicket,
186
+ instanceId,
187
+ sealed,
188
+ claimedInstanceId: typeof body.instanceId === 'string' ? body.instanceId : '',
189
+ });
190
+ if (!opened.ok) {
191
+ // 解不开就是「配对失败」,不回细节 —— 与「配对码错」同形,不给探测空间。
192
+ throttle.noteFail();
193
+ pairDenied(res);
194
+ return;
195
+ }
196
+ supplied = opened.device;
197
+ issuedExtra = { deviceKey: opened.deviceKey, keyId: opened.keyId };
198
+ pairKey = opened.pairKey;
199
+ } else {
200
+ const code = typeof body.code === 'string' ? body.code.trim() : '';
201
+ if (!/^\d{6}$/.test(code)) {
202
+ sendJson(res, 400, { ok: false, error: '配对码需为 6 位数字' });
203
+ return;
204
+ }
205
+ const expected = String(state.pairingCode || '');
206
+ if (!expected || !safeEqual(code, expected)) {
207
+ throttle.noteFail();
208
+ pairDenied(res);
209
+ return;
210
+ }
211
+ supplied = body.device && typeof body.device === 'object' ? body.device : {};
153
212
  }
154
213
 
155
- // 配对码对了,还要宿主自己的策略点头:'deny' 就是明确不收新设备。
156
- const decision = pairingApproval.evaluate(state.pairingPolicy, body.device);
214
+ // 还要宿主自己的策略点头:'deny' 就是明确不收新设备。
215
+ const decision = pairingApproval.evaluate(state.pairingPolicy, supplied);
157
216
  if (!decision.approved) {
158
- recordPairFail(ip);
217
+ // 被拒的尝试同样要记数,否则猜对码但被策略拒的一方可以无限重试。
218
+ throttle.noteFail();
159
219
  pairDenied(res);
160
220
  return;
161
221
  }
162
222
 
163
- // **配对码正确时不清限流计数**:被拒的尝试同样要记数,否则猜对码但被策略拒的一方
164
- // 可以无限重试。只有真正放行才清零。
165
- clearPairFails(ip);
223
+ // 只有真正放行才清零并回满档。
224
+ throttle.noteSuccess();
166
225
 
167
- const instanceId = String(state.instanceId || '');
168
- const supplied = body.device && typeof body.device === 'object' ? body.device : {};
169
226
  const device = {
170
227
  ...Object.fromEntries(Object.entries(supplied).filter(([, value]) => value !== undefined)),
171
228
  ip,
@@ -173,19 +230,30 @@ function createPairHandler(deps) {
173
230
 
174
231
  let issued;
175
232
  try {
176
- issued = credentials.issue(instanceId, device, '');
233
+ issued = credentials.issue(instanceId, device, '', undefined, issuedExtra);
177
234
  } catch (error) {
178
235
  log('pair issue failed:', error && error.message);
179
236
  sendJson(res, 500, { ok: false, error: 'internal error' });
180
237
  return;
181
238
  }
182
239
 
183
- // 两样都要给,缺一样 App 就停在「配对响应缺少 token/cookie」:
184
- // - `token` 是 **bearer**(`Authorization: Bearer …`),365 天;
185
- // - `cookie` 是**中转路由**用的那张,30 天。
186
- // 两者的 TTL 刻意不同,别合成一个常量(见 relay/core.js 的 MOBILE_BEARER_TTL_SEC 注释)。
187
- const token = core.signLanToken(state.lanSecret, instanceId, core.MOBILE_BEARER_TTL_SEC);
188
- const cookieValue = core.signLanToken(state.lanSecret, instanceId, core.MOBILE_TOKEN_TTL_SEC);
240
+ // 成功签发之后立刻换码 + 换票据:谁截了二维码,那张图从这一刻起就没有用了。
241
+ if (rotatePairing) {
242
+ try {
243
+ rotatePairing();
244
+ } catch (error) {
245
+ log('pair rotate failed:', error && error.message);
246
+ }
247
+ }
248
+
249
+ // 三样给的是**同一枚**(§3.2 的 T1 口径):`token`(`Authorization: Bearer`)、
250
+ // `cookie`(中转路由用的那张)与 `deviceCredential` 全等于刚签发的每设备凭据。
251
+ //
252
+ // 不再另签一张全局 HMAC Bearer:那张有效期一年、不带设备身份,发出去就吊销不掉 ——
253
+ // 而「单台吊销」正是 T1 存在的理由。一枚凭据既是通行证、又能在库里查到。
254
+ // 老 App 把整串当不透明串存起来,三段形状没变,它不需要改。
255
+ const token = issued.credential;
256
+ const cookieValue = issued.credential;
189
257
  const hostPlatform = pairingApproval.hostPlatform().platform;
190
258
  // 宿主自己那批直连候选随配对一起回给手机:手输公网地址(域名 / 公网 IP)配对时没有
191
259
  // 二维码,这是手机拿到内网地址的唯一机会。回家后那个公网地址若打不通(路由器不支持
@@ -199,8 +267,11 @@ function createPairHandler(deps) {
199
267
  const v6Candidates = state.lanEnabled && typeof ipv6Candidates === 'function'
200
268
  ? ipv6Candidates(port).map((candidate) => candidate.url).filter(Boolean)
201
269
  : []
202
- sendJson(res, 200, {
270
+ const payload = {
203
271
  ok: true,
272
+ // 协议版本回给手机:它据此把 `MobileSession.proto` 落盘,之后每条请求按它决定发不发
273
+ // 信封。**握手时定死**,不逐请求协商(协商就等于给中转一个降级开关)。
274
+ proto: isSealedShape ? 2 : 1,
204
275
  instanceId,
205
276
  token,
206
277
  // cookie 同时放进 body:RN 的 fetch 读不到 Set-Cookie,App 得自己带上。
@@ -208,6 +279,9 @@ function createPairHandler(deps) {
208
279
  deviceId: issued.deviceId,
209
280
  deviceCredential: issued.credential,
210
281
  credentialExpiresAt: issued.expiresAt,
282
+ // 手机拿它对一次时钟(§8.3(7)):时钟偏太多会被当成重放,而用户完全不知道
283
+ // 「这个 App 连不上」的原因是手机时间不准。
284
+ serverTime: Date.now(),
211
285
  // 宿主自己的系统标识,不是手机的。键名**必须是 `hostPlatform`**:App 落盘的就是它
212
286
  //(app/src/services/pairApi.ts 读 `body.hostPlatform` 存进 `MobileSession.hostPlatform`),
213
287
  // 局域网那条(lan-proxy.js)给的也是它。`hostPlatform()` 回的是 `{platform}` 对象,
@@ -215,7 +289,20 @@ function createPairHandler(deps) {
215
289
  ...(hostPlatform ? { hostPlatform } : {}),
216
290
  ...(directCandidates.length ? { lanUrls: directCandidates } : {}),
217
291
  ...(v6Candidates.length ? { ipv6Candidates: v6Candidates } : {}),
218
- });
292
+ };
293
+
294
+ // 新协议的回包必须加密:里面有 token / cookie / deviceCredential,明文回包等于让
295
+ // 中转顺手拿到这台设备未来一年的通行证。
296
+ if (pairKey) {
297
+ try {
298
+ sendJson(res, 200, sealPairResponse(pairKey, instanceId, payload));
299
+ } catch (error) {
300
+ log('pair seal failed:', error && error.message);
301
+ sendJson(res, 500, { ok: false, error: 'internal error' });
302
+ }
303
+ return;
304
+ }
305
+ sendJson(res, 200, payload);
219
306
  };
220
307
  }
221
308
 
@@ -223,6 +310,5 @@ module.exports = {
223
310
  createPairHandler,
224
311
  resetPairLocks,
225
312
  clientIp,
226
- PAIR_MAX_FAILS,
227
- PAIR_LOCK_MS,
313
+ openSealedPairRequest,
228
314
  };
@@ -0,0 +1,187 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * 移动端「这台设备上的移动端插件」自我管理:版本、来源、运行状态、更新与回滚结果。
5
+ *
6
+ * ## 范围:只管**我们自己这一个插件**
7
+ *
8
+ * 手机管的是 `dsh-mobile-plugin` 自己在这台电脑上的版本 —— 不是电脑上装的全部插件
9
+ * (那是桌面「设置 → 插件 / 插件市场」的事,App 这边不碰)。理由很直接:动别人的插件会
10
+ * 把承载 App 连接的那条链一起带走,而手机没有在电脑前的那双手。
11
+ *
12
+ * ## 与 `/plugin/update/*` 那三条的关系
13
+ *
14
+ * 那三条是上一版给「应用更新」页做的,只有 `status / check / apply`。这里补的是同一件事
15
+ * 在**设备页**里需要的另外几格:来源与版本要不要并成一行、更新完**有没有自动退回去**、
16
+ * 以及「这一台能不能更新」为什么不给按钮的原因。判定仍然只有一份真源(外壳的
17
+ * `dshMobilePluginRuntime`),这里只做投影与瘦身,不自己算。
18
+ *
19
+ * ## 回滚是宿主自己做的,不是手机按的
20
+ *
21
+ * 装坏了 App 就连不上这台设备 —— 那时候手机上任何「回滚」按钮都发不出去。所以外壳在
22
+ * 装之前备份正在跑的那一代、装完自检(见 `lib/shell/self-check-after-swap.js`),
23
+ * 不过就自己退回去。手机端只做两件事:
24
+ * · 更新后断了就按现有逻辑重连(`usePluginUpdate` 的轮询本身);
25
+ * · 连上之后读 `rolledBack`,如实显示「上次装失败了,已自动退回 X」。
26
+ *
27
+ * `rolledBack` 由外壳落进 `lastApply`,这里只认它、**不重新判**:判据写在会真去医院的那
28
+ * 一头,手机端各写一套只会出现两套口径。
29
+ */
30
+
31
+ const { sendJson, readJsonBody } = require('../shared/http-util');
32
+
33
+ /** 一次更新结果的合法状态(外壳给的 `state` 只有这几种取值,别的一律当未知)。 */
34
+ const APPLY_STATES = new Set([
35
+ 'succeeded',
36
+ 'failed',
37
+ 'installed-not-swapped',
38
+ 'skipped',
39
+ 'rolled-back',
40
+ 'rolled-back-partial',
41
+ ]);
42
+
43
+ /** 来源枚举:与 `update-install.cjs` 的 `declaredSource` 同一份。 */
44
+ const SOURCES = new Set(['npm', 'linked', 'file', 'workspace']);
45
+
46
+ /** 外壳服务;没有它(无头部署 / 实现被直接挂载)就是「这台没有升级能力」。 */
47
+ function runtimeOf(ctx) {
48
+ try {
49
+ return typeof ctx?.get === 'function' ? ctx.get('dshMobilePluginRuntime') || null : null;
50
+ } catch {
51
+ return null;
52
+ }
53
+ }
54
+
55
+ function textOrNull(value, max = 200) {
56
+ if (typeof value !== 'string') return null;
57
+ const text = value.trim();
58
+ return text ? text.slice(0, max) : null;
59
+ }
60
+
61
+ /**
62
+ * 上一轮更新的账:只挑前端要用的几格。
63
+ *
64
+ * 刻意**不带** `check`(注册表原始响应)与 `command`(本机装法命令行):都不是 App 该显示
65
+ * 的东西,后端那条路由背了同样的规矩。
66
+ */
67
+ function applyView(value) {
68
+ if (!value || typeof value !== 'object') return null;
69
+ const failure = value.failure && typeof value.failure === 'object'
70
+ ? {
71
+ code: textOrNull(value.failure.code, 64),
72
+ message: typeof value.failure.message === 'string' ? value.failure.message : '',
73
+ }
74
+ : null;
75
+ return {
76
+ installed: value.installed === true,
77
+ skipped: value.skipped === true,
78
+ state: typeof value.state === 'string' && APPLY_STATES.has(value.state) ? value.state : null,
79
+ beforeVersion: textOrNull(value.beforeVersion, 64),
80
+ installedVersion: textOrNull(value.installedVersion, 64),
81
+ swapped: value.swapped === true,
82
+ retryable: value.retryable === true,
83
+ /**
84
+ * 装上了、但新版装起来之后自检没过,外壳自己退回了上一代。
85
+ *
86
+ * 这是「回滚」在手机端唯一的表达:它不是一颗按钮,是一条已经发生过的事实。
87
+ */
88
+ rolledBack: value.rolledBack === true,
89
+ /** 退回之后跑着的是哪个版本(外壳只在真的退成功时给)。 */
90
+ runningAfterRollback: textOrNull(value.runningAfterRollback, 64),
91
+ failure,
92
+ };
93
+ }
94
+
95
+ /**
96
+ * 这一台的插件现场。
97
+ *
98
+ * `supported` = 有没有可就地换代的外壳。没有外壳时「装了也没法立刻生效」,界面上要说明
99
+ * 清(无头部署 / 源码挂载的机器就是这种)。
100
+ */
101
+ function snapshot(ctx) {
102
+ const runtime = runtimeOf(ctx);
103
+ if (!runtime || typeof runtime.status !== 'function') {
104
+ return {
105
+ supported: false,
106
+ runningVersion: null,
107
+ installedVersion: null,
108
+ source: null,
109
+ appliedAt: null,
110
+ lastApply: null,
111
+ shellChanged: false,
112
+ autoUpdate: false,
113
+ autoIntervalMs: null,
114
+ };
115
+ }
116
+ let raw = {};
117
+ try {
118
+ raw = runtime.status() || {};
119
+ } catch {
120
+ raw = {};
121
+ }
122
+ const source = textOrNull(raw.source, 32);
123
+ return {
124
+ supported: typeof runtime.apply === 'function',
125
+ runningVersion: textOrNull(raw.version, 64),
126
+ installedVersion: textOrNull(raw.installedVersion, 64),
127
+ source: source && SOURCES.has(source) ? source : (source || null),
128
+ appliedAt: textOrNull(raw.appliedAt, 64),
129
+ lastApply: applyView(raw.lastApply),
130
+ /** 这个版本连外壳一起改了:换得掉实现,外壳那部分要等下次启动 DSH。 */
131
+ shellChanged: raw.shellChanged === true,
132
+ autoUpdate: raw.autoUpdate === true,
133
+ autoIntervalMs: typeof raw.autoIntervalMs === 'number' && Number.isFinite(raw.autoIntervalMs) ? raw.autoIntervalMs : null,
134
+ };
135
+ }
136
+
137
+ /** `GET /api/mobile/v1/plugin/self` */
138
+ async function handlePluginSelf(ctx, _req, res) {
139
+ sendJson(res, 200, { ok: true, plugin: snapshot(ctx) });
140
+ }
141
+
142
+ /**
143
+ * `POST /api/mobile/v1/plugin/self/auto` —— 自动更新开关(每 10 分钟自己查一次)。
144
+ *
145
+ * 只收布尔:这个开关意味着「没人看着也换掉正在跑的代码」,它必须是一个明确的选择。
146
+ * 给不出外壳(`setAutoUpdate` 不在)时回 409,不假装已经打开 —— 开了却什么都不做的开关
147
+ * 比没有更糟。
148
+ */
149
+ async function handlePluginAuto(ctx, req, res) {
150
+ const runtime = runtimeOf(ctx);
151
+ if (!runtime || typeof runtime.setAutoUpdate !== 'function') {
152
+ return sendJson(res, 409, {
153
+ ok: false,
154
+ error: {
155
+ code: 'plugin_auto_unavailable',
156
+ message: '这台宿主没有可就地换代的插件外壳,自动更新开不了;请在电脑上更新后重启 DSH',
157
+ },
158
+ });
159
+ }
160
+ let body = {};
161
+ try {
162
+ body = (await readJsonBody(req, 4 * 1024)) || {};
163
+ } catch {
164
+ body = {};
165
+ }
166
+ if (typeof body.enabled !== 'boolean') {
167
+ return sendJson(res, 400, { ok: false, error: { code: 'bad-request', message: 'enabled 必须是布尔值' } });
168
+ }
169
+ try {
170
+ const enabled = runtime.setAutoUpdate(body.enabled);
171
+ return sendJson(res, 200, { ok: true, plugin: { ...snapshot(ctx), autoUpdate: enabled === true } });
172
+ } catch (error) {
173
+ return sendJson(res, 500, {
174
+ ok: false,
175
+ error: { code: 'write-failed', message: `开关没写进去:${error?.message || error}` },
176
+ });
177
+ }
178
+ }
179
+
180
+ module.exports = {
181
+ handlePluginSelf,
182
+ handlePluginAuto,
183
+ snapshot,
184
+ applyView,
185
+ APPLY_STATES,
186
+ SOURCES,
187
+ };
@@ -0,0 +1,37 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * POST /api/mobile/v1/push-token —— iOS 推送令牌上报。
5
+ *
6
+ * App 拿到 APNs 令牌(应用启动时向系统注册)就打到这里;宿主核对完设备身份
7
+ * (guarded 已做),经隧道帧 `pushRegister` 转交中转落表。中转没配 APNs 密钥时
8
+ * 那头静默忽略——这条路由照常回 ok:「推没推成」不是 App 要操心的事,它没有
9
+ * 可以据此改变行为的下一步。
10
+ *
11
+ * 隧道不在时也回 ok:令牌上报是幂等的,App 下次启动会再报,不值得为它排队重试。
12
+ */
13
+
14
+ const { sendJson, readJsonBody } = require('../shared/http-util');
15
+
16
+ /**
17
+ * @param {object} deps 装配注入。`pushNotify` 是 `mobile/push-notify.js` 的出口
18
+ * (缺了就只回 ok——推送没接线时令牌上报不该 500)。
19
+ */
20
+ function createPushTokenHandler(deps) {
21
+ const pushNotify = deps.pushNotify || null;
22
+ return function handlePushToken(_ctx, req, res) {
23
+ void readJsonBody(req).then((body) => {
24
+ const deviceId = typeof body?.deviceId === 'string' ? body.deviceId.trim() : '';
25
+ const token = typeof body?.token === 'string' ? body.token.trim() : '';
26
+ const platform = typeof body?.platform === 'string' ? body.platform.trim() : 'ios';
27
+ if (!deviceId || !token) {
28
+ sendJson(res, 400, { ok: false, error: 'deviceId and token are required' });
29
+ return;
30
+ }
31
+ if (pushNotify) pushNotify.registerToken(deviceId, token, platform);
32
+ sendJson(res, 200, { ok: true });
33
+ }, () => sendJson(res, 400, { ok: false, error: 'invalid json' }));
34
+ };
35
+ }
36
+
37
+ module.exports = { createPushTokenHandler };
@@ -0,0 +1,84 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * iOS 推送的宿主侧出口:完成 / 失败 / 待回话发生时,经隧道发一帧给中转,
5
+ * 由中转调 APNs 送到苹果。中转没配密钥时那头静默忽略,这里照发不误——
6
+ * 「推没推成」不是宿主的账,宿主只负责「该推的时候说一声」。
7
+ *
8
+ * 走隧道而不是直接 HTTP 打中转:隧道连接已带准入校验(x-dsh-license),复用它
9
+ * 就不用在中转上再开一条要鉴权的 HTTP 面。隧道不在时这次提醒就放弃——App 端
10
+ * 自己的本地通知通道会在它回到前台 / 后台刷新时补上,推送只是增强。
11
+ */
12
+
13
+ const FRAME_TTL_MS = 10_000;
14
+
15
+ /**
16
+ * @param {object} deps
17
+ * @param {() => object|null} deps.getSidecar 取 sidecar facade 的取值器(它有 pushSync 通道)。
18
+ * @param {() => boolean} deps.tunnelConnected 隧道在不在。
19
+ * @param {() => string} [deps.instanceId] 实例标识,帧里带上便于中转日志定位。
20
+ */
21
+ function createPushNotifier(deps) {
22
+ const getSidecar = typeof deps.getSidecar === 'function' ? deps.getSidecar : () => null;
23
+ const tunnelConnected = typeof deps.tunnelConnected === 'function' ? deps.tunnelConnected : () => false;
24
+ const instanceId = typeof deps.instanceId === 'function' ? deps.instanceId : () => '';
25
+
26
+ /** 最近一次已发送的(会话, 轮次, 结局)指纹:同一次收尾会被事件流与列表折叠两条路
27
+ * 各观察到一次,中转那边没有去重账,宿主必须自己只放一帧过去。 */
28
+ let lastSent = null;
29
+ let lastSentAt = 0;
30
+
31
+ /**
32
+ * 报告一轮收尾。`fact` 是 session-last-turn 的 `lastTurnOf` 形状
33
+ * ({ turn, endedAt, status, ... }),只有 completed / failed 两种值得推。
34
+ */
35
+ function notifyTurnEnded(sessionId, fact, title) {
36
+ if (!sessionId || !fact || typeof fact.turn !== 'number') return;
37
+ if (fact.status !== 'completed' && fact.status !== 'failed') return;
38
+ const fingerprint = `${sessionId}:${fact.turn}:${fact.status}`;
39
+ const now = Date.now();
40
+ if (fingerprint === lastSent && now - lastSentAt < FRAME_TTL_MS) return;
41
+ lastSent = fingerprint;
42
+ lastSentAt = now;
43
+ const body = fact.status === 'failed'
44
+ ? `出错了${fact.message ? `:${String(fact.message).slice(0, 60)}` : ''}`
45
+ : '回复已完成';
46
+ void send('push', {
47
+ kind: 'push',
48
+ pushFrame: 'send',
49
+ deviceId: '',
50
+ // deviceId 留空 = 这台实例上全部已登记设备都推。completion 的按设备记账
51
+ // 在 App 自己手里(本机已读水位),中转只存「实例 → 令牌」一级。
52
+ title: title || '任务提醒',
53
+ body,
54
+ sessionId,
55
+ kind: fact.status === 'failed' ? 'failed' : 'completed',
56
+ instanceId: instanceId(),
57
+ });
58
+ }
59
+
60
+ /** App 侧上报的推送令牌转交给中转(登记 / 删除)。 */
61
+ function registerToken(deviceId, token, platform) {
62
+ return send('push', {
63
+ kind: 'push',
64
+ pushFrame: 'register',
65
+ deviceId,
66
+ ...(token ? { token, platform: platform || 'ios' } : { action: 'delete' }),
67
+ instanceId: instanceId(),
68
+ });
69
+ }
70
+
71
+ function send(kind, payload) {
72
+ if (!tunnelConnected()) return Promise.resolve(false);
73
+ const sidecar = getSidecar();
74
+ if (!sidecar) return Promise.resolve(false);
75
+ return sidecar.pushSync(payload).then(
76
+ () => true,
77
+ () => false,
78
+ );
79
+ }
80
+
81
+ return { notifyTurnEnded, registerToken };
82
+ }
83
+
84
+ module.exports = { createPushNotifier };