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
@@ -40,8 +40,11 @@ const { sendJson, readJsonBody } = require('../shared/http-util');
40
40
  const CHUNK_BYTES = 256 * 1024;
41
41
  // 片大小的上限(原始字节):base64 后约 2.73MB,仍在下面 8MB 的请求体预算内。
42
42
  const CHUNK_MAX_BYTES = 2 * 1024 * 1024;
43
- /** 单片请求体上限:4MB base64 后约 5.33MB,留出信封与转义余量。 */
44
- const CHUNK_MAX_BODY = 8 * 1024 * 1024;
43
+ /**
44
+ * 单片请求体上限(**明文**字节):4MB base64 后约 5.33MB,再按 §8.3(9) 的 4/3 信封
45
+ * 放大到 11MB —— 按线上长度直接卡 8MB 等于悄悄砍掉四分之一的可用额度。
46
+ */
47
+ const CHUNK_MAX_BODY = Math.ceil(8 * 1024 * 1024 * 4 / 3);
45
48
  /** 文件名上限(按字符算,避开各文件系统的字节上限差异)。 */
46
49
  const NAME_MAX_CHARS = 120;
47
50
  /** 暂存残片的最长寿命:手机上传中断后没人来 abort 的清理依据。 */
@@ -49,6 +52,44 @@ const STAGING_TTL_MS = 24 * 60 * 60 * 1000;
49
52
  /** 分片必须是标准 base64:Node 的 Buffer.from 会静默丢弃非法字符,光靠解码判不出坏片。 */
50
53
  const BASE64_RE = /^[A-Za-z0-9+/]*={0,2}$/;
51
54
 
55
+ /**
56
+ * 刚落盘的上传:uploadId → { path, name, bytes, at }。
57
+ *
58
+ * 存在的理由:`session/send` 以前只认内联 base64 的图片块,一张 4MB 照片线上要
59
+ * 7MB 左右,而那条请求的预算只有 8 秒 —— 大图必超时。现在图片先走分片上传(每片都
60
+ * 落在预算内),再在 send 里用 uploadId 换字节。这张表就是那个「换」的凭据。
61
+ *
62
+ * 只在内存里、30 分钟过期、条数封顶:过期之后手机重传一次就行,不留长期文件索引。
63
+ */
64
+ const finishedUploads = new Map();
65
+ const FINISHED_TTL_MS = 30 * 60 * 1000;
66
+ const FINISHED_MAX = 64;
67
+
68
+ function rememberFinishedUpload(uploadId, record) {
69
+ if (!uploadId) return;
70
+ finishedUploads.set(uploadId, { ...record, at: Date.now() });
71
+ if (finishedUploads.size > FINISHED_MAX) {
72
+ const oldest = [...finishedUploads.entries()].sort((a, b) => a[1].at - b[1].at).slice(0, finishedUploads.size - FINISHED_MAX);
73
+ for (const [key] of oldest) finishedUploads.delete(key);
74
+ }
75
+ }
76
+
77
+ /**
78
+ * 按 uploadId 取刚落盘的上传。过期 / 不存在返回 null。
79
+ *
80
+ * @returns {{ path: string, name: string, bytes: number } | null}
81
+ */
82
+ function resolveFinishedUpload(uploadId) {
83
+ const id = String(uploadId || '');
84
+ const record = finishedUploads.get(id);
85
+ if (!record) return null;
86
+ if (Date.now() - record.at > FINISHED_TTL_MS) {
87
+ finishedUploads.delete(id);
88
+ return null;
89
+ }
90
+ return { path: record.path, name: record.name, bytes: record.bytes };
91
+ }
92
+
52
93
  /** 进行中的上传:uploadId → { name, stagingPath, received, bytes, expiresAt, expiryTimer }。 */
53
94
  const uploads = new Map();
54
95
 
@@ -240,12 +281,22 @@ async function handleUploadChunk(_ctx, req, res) {
240
281
  sendJson(res, 200, { ok: true, received: job.received });
241
282
  }
242
283
 
243
- /** POST /api/mobile/v1/upload/finish { uploadId } → { ok, path, name, bytes }。 */
284
+ /** POST /api/mobile/v1/upload/finish { uploadId } → { ok, path, name, bytes }。
285
+ *
286
+ * **同一 uploadId 再收一次是回放,不是新上传**:落盘(rename)已经做完、回包却在路上丢了
287
+ * 的时候,手机那边只能再收一次 —— 那一发必须拿回第一次的那份结果,否则它会把一次成功的
288
+ * 上传判成失败、让用户白传一遍整个文件。认不出的 uploadId 仍然 404(不能靠「新建一个空
289
+ * 上传」蒙过去)。
290
+ */
244
291
  async function handleUploadFinish(_ctx, req, res) {
245
292
  const body = await readJsonBody(req);
246
293
  const uploadId = String((body && body.uploadId) || '');
247
294
  const job = uploads.get(uploadId);
248
- if (!job) return fail(res, 404, 'unknown-upload', '上传会话不存在或已结束');
295
+ if (!job) {
296
+ const replay = resolveFinishedUpload(uploadId);
297
+ if (replay) return sendJson(res, 200, { ok: true, ...replay, uploadId });
298
+ return fail(res, 404, 'unknown-upload', '上传会话不存在或已结束');
299
+ }
249
300
  removeUpload(uploadId, job);
250
301
 
251
302
  const dir = path.join(inboxRoot(), today());
@@ -253,7 +304,9 @@ async function handleUploadFinish(_ctx, req, res) {
253
304
  await fsp.mkdir(dir, { recursive: true });
254
305
  const target = await uniquePath(dir, job.name);
255
306
  await fsp.rename(job.stagingPath, target);
256
- sendJson(res, 200, { ok: true, path: target, name: path.basename(target), bytes: job.bytes });
307
+ // 记住这一份:发送图片时用它把「分片上传的字节」换回内联块,见 resolveFinishedUpload。
308
+ rememberFinishedUpload(uploadId, { path: target, name: path.basename(target), bytes: job.bytes });
309
+ sendJson(res, 200, { ok: true, path: target, name: path.basename(target), bytes: job.bytes, uploadId });
257
310
  } catch (error) {
258
311
  try { await fsp.unlink(job.stagingPath); } catch { /* 已经不在了 */ }
259
312
  return fail(res, 500, 'inbox-unavailable', `落盘失败:${error.message}`);
@@ -272,6 +325,37 @@ async function handleUploadAbort(_ctx, req, res) {
272
325
  sendJson(res, 200, { ok: true });
273
326
  }
274
327
 
328
+ /** 一张图片的明文上限:超过就按「传太大」拒,而不是把宿主内存撑爆。 */
329
+ const MAX_IMAGE_UPLOAD_BYTES = 10 * 1024 * 1024;
330
+
331
+ const UPLOAD_EXPIRED_MESSAGE = '这张图片的上传已过期,请重新发送';
332
+
333
+ /**
334
+ * 把 content 里带 `uploadId` 的图片块补成内联 base64。
335
+ *
336
+ * 宿主的 `session.prompt` 只认 `{type:'image', mediaType, data}`,所以这一步要在宿主
337
+ * 那一跳之前完成:手机到宿主这一段只传 uploadId,字节早就在分片上传时落盘了。
338
+ *
339
+ * @returns {Promise<string|null>} 有块补不上就回一句给用户看的话,全补上了回 null。
340
+ */
341
+ async function inlineUploadedImageBlocks(content) {
342
+ for (const block of content) {
343
+ if (!block || block.type !== 'image') continue;
344
+ if (typeof block.data === 'string' && block.data) continue;
345
+ const record = resolveFinishedUpload(block.uploadId);
346
+ if (!record) return UPLOAD_EXPIRED_MESSAGE;
347
+ try {
348
+ const bytes = await fsp.readFile(record.path);
349
+ if (!bytes.length || bytes.length > MAX_IMAGE_UPLOAD_BYTES) return UPLOAD_EXPIRED_MESSAGE;
350
+ block.data = bytes.toString('base64');
351
+ if (!block.name) block.name = record.name;
352
+ } catch {
353
+ return UPLOAD_EXPIRED_MESSAGE;
354
+ }
355
+ }
356
+ return null;
357
+ }
358
+
275
359
  module.exports = {
276
360
  handleUploadBegin,
277
361
  handleUploadChunk,
@@ -284,4 +368,6 @@ module.exports = {
284
368
  STAGING_TTL_MS,
285
369
  CHUNK_BYTES,
286
370
  CHUNK_MAX_BYTES,
371
+ resolveFinishedUpload,
372
+ inlineUploadedImageBlocks,
287
373
  };
@@ -17,6 +17,9 @@
17
17
  const { sendJson, readJsonBody } = require('../shared/http-util');
18
18
  const { isFreshRequest } = require('../shared/fresh-request');
19
19
  const { stableRevision, unchangedListBody } = require('../shared/list-revision.js');
20
+ const { listSignal: sharedListSignal } = require('../shared/list-signal.js');
21
+ // 条件刷新要记的那几样按 ctx 隔离:这类模块级状态只许住在 session-cache.js。
22
+ const { workspaceShortcutStateOf } = require('./session-cache.js');
20
23
  // 会话列表缓存归 sessions.js 管。归档/移动会话会改变列表行本身,必须让那边立刻失效,
21
24
  // 否则手机端按完「归档会话」,那一行最长 10 秒还留在侧栏里,看着像没归档。
22
25
  const { invalidateSessionList } = require('./sessions.js');
@@ -51,17 +54,25 @@ async function attachGitMarkers(items, options) {
51
54
  if (!item || typeof item !== 'object') return item;
52
55
  const path = typeof item.path === 'string' ? item.path.trim() : '';
53
56
  if (!path) return item;
54
- return { ...item, git: await describeProjectGit(path, options) };
57
+ const marker = await describeProjectGit(path, options);
58
+ // 手机上只用到「是不是仓库」「仓库根在哪」(侧栏入口的可见性与跳转参数),分支
59
+ // 留在里面是因为它参与 revision、换分支确实该让镜像更新。**并列仓库清单不下发**:
60
+ // 一份最多二十条、每条带着仓库/子模块/worktree 的路径与身份,而 Git 页是自己现查的,
61
+ // 这份数组在列表响应里纯属白占字节。
62
+ return {
63
+ ...item,
64
+ git: { isRepo: marker.isRepo, root: marker.root, branch: marker.branch },
65
+ };
55
66
  }));
56
67
  }
57
68
 
58
69
  /**
59
70
  * 构建工作区快照 body(供移动端镜像同步推送复用)。
60
71
  *
61
- * `archivedSessionIds` 必须原样透传:宿主 `workspace.list` 一直在返回它,
62
- * 但此前这里只保留 `items`,把它整个丢掉。于是 App 点完「归档会话」拿不到任何
63
- * 归档状态,侧边栏那一行永远不消失——归档在移动端表现为一次没有反馈的空操作。
64
- * 它同时也是将来做「取消归档」入口的唯一数据来源。
72
+ * 归档集合(宿主 `workspace.list` 的 `archivedSessionIds`)**只参与 revision 计算,
73
+ * 不再下发**:App 从来没有读过这个字段(归档会话由 `/sessions` 直接剔除,取消归档走
74
+ * 单独的写接口),而它是一份可能几十 KB 的纯字符串数组 —— 每发一次完整工作区快照就白传
75
+ * 一次。留在 revision 里是为了「仅归档集合变化」也能判成新版本,镜像该更新还得更新。
65
76
  *
66
77
  * `options.fresh` 透传给仓库识别,跳过它那 15 秒的目录缓存(写后刷新 / 下拉刷新)。
67
78
  */
@@ -90,18 +101,20 @@ async function buildWorkspacesBody(ctx, options = {}) {
90
101
  return {
91
102
  ok: true,
92
103
  items: marked,
93
- archivedSessionIds,
94
104
  // 与 sessions 快照同款的新鲜度/空态契约(见 lib/mobile/sessions.js 的说明):
95
105
  // App 把 generatedAt + revision 存进快照用于展示「数据时间」,emptyConfirmed
96
106
  // 让「用户真的删光了工作区」不必再靠连续两次空去猜。
97
107
  generatedAt: Date.now(),
98
- // 归档集合也是工作区快照语义的一部分;仅它变化时也必须让镜像推送判为新版本。
99
- // 仓库标记也在里面:换分支是这份快照的一部分,变了就该推新版本。
108
+ // 归档集合也算进 revision:仅它变化时也必须让镜像推送判为新版本。
109
+ // 仓库标记只保留下发的那三格;并列仓库清单不进 revision、也不再随清单下发。
100
110
  revision: stableRevision({ items: marked, archivedSessionIds }),
101
111
  emptyConfirmed: marked.length === 0,
102
112
  };
103
113
  }
104
114
 
115
+ /** 短路状态的最长信任时间:只是兜底,工作区信号一涨就会被判失效。 */
116
+ const WORKSPACE_SHORTCUT_MAX_AGE_MS = 5 * 60 * 1000;
117
+
105
118
  /**
106
119
  * GET /api/mobile/v1/workspaces?rev=<上次的 revision>(token)
107
120
  *
@@ -117,7 +130,25 @@ async function handleWorkspaces(ctx, req, res) {
117
130
  const fresh = isFreshRequest(req.url);
118
131
  // 仓库识别也认这一个 `fresh`:写后刷新 / 下拉刷新必须看见刚新建的仓库,
119
132
  // 不能被识别那 15 秒的目录缓存挡回去。
133
+ //
134
+ // 构建前短路:这份响应里有 `archivedSessionIds`(一份几十 KB 的数组),每 15 秒重建
135
+ // 一次纯属白费。与 `/sessions` 同一条判据:客户端 rev 命中的那份没变、工作区信号没涨、
136
+ // 而且真有观众(推送调度器在跑,信号才可信)。
137
+ const cache = workspaceShortcutStateOf(ctx);
138
+ if (!fresh && rev && sharedListSignal.watching()) {
139
+ const current = sharedListSignal.workspaceSnapshot().revision;
140
+ if (cache.lastRevision === rev && cache.lastSignalRevision === current
141
+ && Date.now() - cache.lastBuiltAt <= WORKSPACE_SHORTCUT_MAX_AGE_MS) {
142
+ return sendJson(res, 200, { ok: true, unchanged: true, revision: rev, generatedAt: cache.lastGeneratedAt });
143
+ }
144
+ }
120
145
  const body = await buildWorkspacesBody(ctx, { fresh });
146
+ if (body && typeof body.revision === 'string' && body.revision) {
147
+ cache.lastRevision = body.revision;
148
+ cache.lastGeneratedAt = body.generatedAt;
149
+ cache.lastSignalRevision = sharedListSignal.workspaceSnapshot().revision;
150
+ cache.lastBuiltAt = Date.now();
151
+ }
121
152
  const unchanged = fresh ? null : unchangedListBody(body, rev);
122
153
  sendJson(res, 200, unchanged || body);
123
154
  }