dsh-mobile-plugin 0.1.99 → 0.2.0
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 +14 -5
- package/dsh-mobile-credentials/lib/credentials.js +405 -84
- package/lib/client.js +31 -8
- package/lib/http/routing.js +165 -9
- package/lib/impl.js +79 -5
- package/lib/index.js +1 -1
- package/lib/mirror/pusher.cjs +35 -4
- package/lib/mobile/approvals.js +62 -15
- package/lib/mobile/auth.js +46 -26
- package/lib/mobile/credential-upgrade.js +129 -0
- package/lib/mobile/entry-window.js +32 -0
- package/lib/mobile/events.js +14 -4
- package/lib/mobile/extensions.js +279 -0
- package/lib/mobile/history-derive.js +159 -0
- package/lib/mobile/host-bridge/event-synthesis.js +40 -1
- package/lib/mobile/pairing.js +150 -64
- package/lib/mobile/plugin-self.js +187 -0
- package/lib/mobile/push-notify-route.js +37 -0
- package/lib/mobile/push-notify.js +84 -0
- package/lib/mobile/questions.js +75 -10
- package/lib/mobile/relay-control.js +534 -0
- package/lib/mobile/session-cache.js +19 -1
- package/lib/mobile/session-file.js +78 -4
- package/lib/mobile/session-list-shortcut.js +38 -0
- package/lib/mobile/session-model-options.js +3 -1
- package/lib/mobile/session-models.js +15 -3
- package/lib/mobile/session-send.js +21 -3
- package/lib/mobile/session-snapshot.js +12 -11
- package/lib/mobile/sessions.js +20 -0
- package/lib/mobile/stats-collect.cjs +55 -12
- package/lib/mobile/stats-ledger.cjs +140 -21
- package/lib/mobile/stream-registry.js +73 -0
- package/lib/mobile/stream.js +103 -18
- package/lib/mobile/uploads.js +79 -3
- package/lib/mobile/workspaces.js +39 -8
- package/lib/panel/index.html +11 -11
- package/lib/relay/core.js +25 -2
- package/lib/relay/lan-proxy.js +283 -122
- package/lib/relay/protocol.js +4 -2
- package/lib/relay/tunnel.js +20 -14
- package/lib/routes/control-plane.js +135 -9
- package/lib/routes/mobile-api.js +87 -7
- package/lib/shared/e2ee-pair.js +91 -0
- package/lib/shared/e2ee-plain.js +79 -0
- package/lib/shared/e2ee-request.js +331 -0
- package/lib/shared/e2ee.js +173 -0
- package/lib/shared/http-util.js +27 -4
- package/lib/shared/list-signal.js +16 -0
- package/lib/shared/pair-throttle.js +150 -0
- package/lib/shared/sse-write.js +16 -5
- package/lib/shared/via-header.js +65 -0
- package/lib/shell/runtime.js +147 -42
- package/lib/shell/self-check-after-swap.js +93 -0
- package/lib/shell/updater.js +36 -2
- package/lib/sidecar-facade-worker.cjs +70 -6
- package/package.json +3 -2
package/lib/mobile/uploads.js
CHANGED
|
@@ -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
|
-
/**
|
|
44
|
-
|
|
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
|
|
|
@@ -253,7 +294,9 @@ async function handleUploadFinish(_ctx, req, res) {
|
|
|
253
294
|
await fsp.mkdir(dir, { recursive: true });
|
|
254
295
|
const target = await uniquePath(dir, job.name);
|
|
255
296
|
await fsp.rename(job.stagingPath, target);
|
|
256
|
-
|
|
297
|
+
// 记住这一份:发送图片时用它把「分片上传的字节」换回内联块,见 resolveFinishedUpload。
|
|
298
|
+
rememberFinishedUpload(uploadId, { path: target, name: path.basename(target), bytes: job.bytes });
|
|
299
|
+
sendJson(res, 200, { ok: true, path: target, name: path.basename(target), bytes: job.bytes, uploadId });
|
|
257
300
|
} catch (error) {
|
|
258
301
|
try { await fsp.unlink(job.stagingPath); } catch { /* 已经不在了 */ }
|
|
259
302
|
return fail(res, 500, 'inbox-unavailable', `落盘失败:${error.message}`);
|
|
@@ -272,6 +315,37 @@ async function handleUploadAbort(_ctx, req, res) {
|
|
|
272
315
|
sendJson(res, 200, { ok: true });
|
|
273
316
|
}
|
|
274
317
|
|
|
318
|
+
/** 一张图片的明文上限:超过就按「传太大」拒,而不是把宿主内存撑爆。 */
|
|
319
|
+
const MAX_IMAGE_UPLOAD_BYTES = 10 * 1024 * 1024;
|
|
320
|
+
|
|
321
|
+
const UPLOAD_EXPIRED_MESSAGE = '这张图片的上传已过期,请重新发送';
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* 把 content 里带 `uploadId` 的图片块补成内联 base64。
|
|
325
|
+
*
|
|
326
|
+
* 宿主的 `session.prompt` 只认 `{type:'image', mediaType, data}`,所以这一步要在宿主
|
|
327
|
+
* 那一跳之前完成:手机到宿主这一段只传 uploadId,字节早就在分片上传时落盘了。
|
|
328
|
+
*
|
|
329
|
+
* @returns {Promise<string|null>} 有块补不上就回一句给用户看的话,全补上了回 null。
|
|
330
|
+
*/
|
|
331
|
+
async function inlineUploadedImageBlocks(content) {
|
|
332
|
+
for (const block of content) {
|
|
333
|
+
if (!block || block.type !== 'image') continue;
|
|
334
|
+
if (typeof block.data === 'string' && block.data) continue;
|
|
335
|
+
const record = resolveFinishedUpload(block.uploadId);
|
|
336
|
+
if (!record) return UPLOAD_EXPIRED_MESSAGE;
|
|
337
|
+
try {
|
|
338
|
+
const bytes = await fsp.readFile(record.path);
|
|
339
|
+
if (!bytes.length || bytes.length > MAX_IMAGE_UPLOAD_BYTES) return UPLOAD_EXPIRED_MESSAGE;
|
|
340
|
+
block.data = bytes.toString('base64');
|
|
341
|
+
if (!block.name) block.name = record.name;
|
|
342
|
+
} catch {
|
|
343
|
+
return UPLOAD_EXPIRED_MESSAGE;
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
return null;
|
|
347
|
+
}
|
|
348
|
+
|
|
275
349
|
module.exports = {
|
|
276
350
|
handleUploadBegin,
|
|
277
351
|
handleUploadChunk,
|
|
@@ -284,4 +358,6 @@ module.exports = {
|
|
|
284
358
|
STAGING_TTL_MS,
|
|
285
359
|
CHUNK_BYTES,
|
|
286
360
|
CHUNK_MAX_BYTES,
|
|
361
|
+
resolveFinishedUpload,
|
|
362
|
+
inlineUploadedImageBlocks,
|
|
287
363
|
};
|
package/lib/mobile/workspaces.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
*
|
|
62
|
-
*
|
|
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
|
}
|