dsh-mobile-plugin 0.1.71 → 0.1.73
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/lib/client.js +2 -2
- package/lib/mobile/archive-clean.js +51 -0
- package/lib/mobile/credentials.js +8 -3
- package/lib/mobile/host-bridge/endpoints.js +1 -0
- package/lib/mobile/sessions.js +20 -7
- package/lib/mobile/workspaces.js +12 -0
- package/lib/panel/index.html +1 -1
- package/lib/relay/lan-proxy.js +4 -1
- package/lib/relay/pairing-policy.js +15 -1
- package/lib/relay/tunnel.js +7 -2
- package/lib/routes/control-plane.js +3 -1
- package/lib/routes/mobile-api.js +5 -0
- package/lib/session-cleanup.cjs +66 -10
- package/lib/sidecar-facade-worker.cjs +6 -2
- package/package.json +1 -1
package/lib/client.js
CHANGED
|
@@ -514,7 +514,7 @@ window.__ModuleLoader__.load({
|
|
|
514
514
|
webAccessOff: '公网网页管理已关闭',
|
|
515
515
|
webAccessOpen: '开放网页管理 2 小时',
|
|
516
516
|
webAccessClose: '立即关闭网页管理',
|
|
517
|
-
webAccessHint: '默认关闭;开启后 2
|
|
517
|
+
webAccessHint: '默认关闭;开启后 2 小时自动关闭,访问不会延长时间。关闭后公网不可访问,本机和局域网仍可访问;不影响手机 App。只可从本机 DSH 设置页开关。',
|
|
518
518
|
webAccessUnavailable: '正在读取本机访问状态…',
|
|
519
519
|
webAccessError: '更新网页访问状态失败',
|
|
520
520
|
},
|
|
@@ -531,7 +531,7 @@ window.__ModuleLoader__.load({
|
|
|
531
531
|
webAccessOff: 'Public web management is closed',
|
|
532
532
|
webAccessOpen: 'Open web management for 2 hours',
|
|
533
533
|
webAccessClose: 'Close web management now',
|
|
534
|
-
webAccessHint: 'Closed by default; opening lasts 2 hours and visits do not extend it. Mobile app access is unaffected. Only this local DSH settings page can toggle it.',
|
|
534
|
+
webAccessHint: 'Closed by default; opening lasts 2 hours and visits do not extend it. When closed, public access is blocked while this machine and its LAN can still access the page. Mobile app access is unaffected. Only this local DSH settings page can toggle it.',
|
|
535
535
|
webAccessUnavailable: 'Reading local access status…',
|
|
536
536
|
webAccessError: 'Failed to update web access status',
|
|
537
537
|
},
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 移动端「清理所有已归档的对话」:按宿主归档集合确定清理范围,并交由落地函数删除。
|
|
5
|
+
*
|
|
6
|
+
* 以 workspace.list 的 archivedSessionIds 为清理范围,不用可能过时的列表状态缩小范围;
|
|
7
|
+
* 请求必须显式带 confirm: true —— 磁盘删除不可恢复,手机端「少点一次」不值得把它变成滑一下就没了的动作。
|
|
8
|
+
*
|
|
9
|
+
* 删除动作不重写:读日志 header.id → 摘归档集合 → 删会话目录 + 投影缓存这套只在 session-cleanup.cjs
|
|
10
|
+
* 里存在(见地图 A3-17),这里只是把它的触发面从 agent 工具扩到移动端路由。
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const { sendJson, readJsonBody } = require('../shared/http-util');
|
|
14
|
+
const { withIdempotency, requestIdOf } = require('./idempotency.js');
|
|
15
|
+
const { canonicalDeviceKey } = require('./session-cache.js');
|
|
16
|
+
const { invalidateSessionList } = require('./sessions.js');
|
|
17
|
+
const { unwrap } = require('./envelope.js');
|
|
18
|
+
const { deleteSessionData, forgetArchivedSessions } = require('../session-cleanup.cjs');
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* POST /api/mobile/v1/workspace/clear-archived(token)
|
|
22
|
+
* body: { confirm: true } → { ok: true, deleted: N }。confirm 不为 true 一律 400。
|
|
23
|
+
*/
|
|
24
|
+
async function handleClearArchived(ctx, req, res) {
|
|
25
|
+
const body = await readJsonBody(req, 64 * 1024);
|
|
26
|
+
if (!body || body.confirm !== true) {
|
|
27
|
+
sendJson(res, 400, { ok: false, error: { code: 'confirm-required', message: '清理已归档会话不可恢复,请求需要带 confirm: true' } });
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
const outcome = await withIdempotency(ctx, canonicalDeviceKey(req), requestIdOf(req, body), async () => {
|
|
31
|
+
// 归档集合是清理范围的唯一依据;列表摘要里的 running / blank 可能过时,也不应缩小「全部归档」的范围。
|
|
32
|
+
const workspaceResponse = unwrap(await ctx.apiProxy.workspace.list({ rpcId: 'mobile', method: 'workspace.list', payload: {} }));
|
|
33
|
+
if (workspaceResponse.error) {
|
|
34
|
+
return { status: 400, body: { ok: false, error: { ...workspaceResponse.error, message: `workspace.list 失败:${workspaceResponse.error.message || workspaceResponse.error.code || '未知错误'}` } } };
|
|
35
|
+
}
|
|
36
|
+
const archivedIds = Array.isArray(workspaceResponse.value?.archivedSessionIds) ? workspaceResponse.value.archivedSessionIds : [];
|
|
37
|
+
const selected = archivedIds.filter((id) => typeof id === 'string' && id);
|
|
38
|
+
if (selected.length === 0) return { status: 200, body: { ok: true, deleted: 0, archivedSessionIds: archivedIds } };
|
|
39
|
+
// 先把它从宿主归档集合里摘掉,再删日志:宿主不会自己修剪那份集合,删完还留着
|
|
40
|
+
// 那些 id 就是「已归档」里一排点不开的死行。
|
|
41
|
+
await forgetArchivedSessions(ctx, selected, 'mobile');
|
|
42
|
+
const removed = await deleteSessionData(selected);
|
|
43
|
+
// 删的是列表里可见的行:手机端正开着列表,不立刻失效最长 10 秒还能点到死行。
|
|
44
|
+
invalidateSessionList(ctx);
|
|
45
|
+
const cleared = new Set(selected);
|
|
46
|
+
return { status: 200, body: { ok: true, deleted: removed.length, archivedSessionIds: archivedIds.filter((id) => !cleared.has(id)) } };
|
|
47
|
+
});
|
|
48
|
+
sendJson(res, outcome.status, outcome.body);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
module.exports = { handleClearArchived };
|
|
@@ -15,6 +15,13 @@
|
|
|
15
15
|
* 密钥值只在这条路上出现一次(手机 → 宿主)。宿主从不回传值:describe 只给
|
|
16
16
|
* {configured, source?, writable},所以这里的响应里也永远不会有 value 字段。
|
|
17
17
|
*
|
|
18
|
+
* 宿主 describe **回的就是一张平表**:键是引用名、值是上面那个视图
|
|
19
|
+
* (`dsh-api-settings-controller/lib/types/credentials.js` 的
|
|
20
|
+
* `Object.fromEntries(branded.map(...))`,0.2.0 的网关与旧 apiProxy 两条路都打到
|
|
21
|
+
* 同一个 Remote 命名空间)。曾经在这里多拆一层 `value.credentials` —— 那一层从来
|
|
22
|
+
* 不存在,于是每个引用都落成「未配置」,设置页永远显示「密钥未设置」,而测试假体
|
|
23
|
+
* 照抄了同一个错形状,绿的。读了宿主源码才看出来。
|
|
24
|
+
*
|
|
18
25
|
* 「能不能写」由宿主的 credentials 服务说了算:如果某个引用是启动环境变量提供的,
|
|
19
26
|
* 宿主会回 writable:false,set 则回 credential-rejected("supplied read-only by the
|
|
20
27
|
* launching environment")。这里不自己判断,一律如实透传,让 App 显示宿主的原话。
|
|
@@ -76,9 +83,7 @@ async function handleCredentialsDescribe(ctx, req, res) {
|
|
|
76
83
|
}));
|
|
77
84
|
if (error) return sendJson(res, 400, { ok: false, error });
|
|
78
85
|
|
|
79
|
-
const source = value && typeof value
|
|
80
|
-
? value.credentials
|
|
81
|
-
: {};
|
|
86
|
+
const source = value && typeof value === 'object' && !Array.isArray(value) ? value : {};
|
|
82
87
|
const credentials = {};
|
|
83
88
|
for (const ref of refs) {
|
|
84
89
|
const view = normalizeCredentialView(source[ref]);
|
|
@@ -83,6 +83,7 @@ const ENDPOINTS = {
|
|
|
83
83
|
'workspace.unarchiveSession': inv('workspace', 'unarchiveSession'),
|
|
84
84
|
'workspace.pinSession': inv('workspace', 'pinSession'),
|
|
85
85
|
'workspace.unpinSession': inv('workspace', 'unpinSession'),
|
|
86
|
+
'workspace.insertBefore': inv('workspace', 'insertBefore'),
|
|
86
87
|
'workspace.insertSessionBefore': inv('workspace', 'insertSessionBefore'),
|
|
87
88
|
|
|
88
89
|
// host.* → directoryPicker.*(宿主服务 browse 时),否则回落到插件自带的 browse 原语。
|
package/lib/mobile/sessions.js
CHANGED
|
@@ -151,7 +151,16 @@ async function handleSessions(ctx, req, res) {
|
|
|
151
151
|
// 是同一批会话的**另一个子集**,不能与默认视图共用这份 10 秒缓存——否则 App 点完
|
|
152
152
|
// 「筛选已归档会话」后,最长 10 秒内拿到的还是那份不含归档行的默认列表。
|
|
153
153
|
// 与 fresh 的区别:它只绕开这层缓存,不需要作废在途构建,也不 bump 朝代号(没有写入)。
|
|
154
|
-
const
|
|
154
|
+
const url = new URL(req.url, 'http://x');
|
|
155
|
+
const archivedOnly = url.searchParams.get('archived') === '1';
|
|
156
|
+
// 只有新客户端显式带分页参数时才切片;旧客户端不带参数仍拿到完整归档列表。
|
|
157
|
+
const hasPaging = archivedOnly && (url.searchParams.has('offset') || url.searchParams.has('limit'));
|
|
158
|
+
const parsedOffset = Number(url.searchParams.get('offset') ?? 0);
|
|
159
|
+
const parsedLimit = Number(url.searchParams.get('limit') ?? 50);
|
|
160
|
+
const page = hasPaging ? {
|
|
161
|
+
offset: Number.isFinite(parsedOffset) ? Math.max(0, Math.floor(parsedOffset)) : 0,
|
|
162
|
+
limit: Number.isFinite(parsedLimit) ? Math.max(1, Math.min(200, Math.floor(parsedLimit))) : 50,
|
|
163
|
+
} : null;
|
|
155
164
|
const bypassCache = wantFresh || archivedOnly;
|
|
156
165
|
if (wantFresh) {
|
|
157
166
|
// 顺手作废更早发起的构建:它们手里的数据是写入之前的,若在新结果之后才落地会把旧列表又写回缓存。
|
|
@@ -172,7 +181,7 @@ async function handleSessions(ctx, req, res) {
|
|
|
172
181
|
// 但「丢掉」挡不住**已经在飞**的那次构建:它手里是变更之前的数据,落地后会在缓存里
|
|
173
182
|
// 再活 10 秒。用缓存自带的朝代号兜住(见 session-cache.js 的 dropSessionListCache)。
|
|
174
183
|
const epoch = cache.droppedAt || 0;
|
|
175
|
-
const promise = buildSessionsResponse(ctx, deviceKey, generation, wantFresh, archivedOnly);
|
|
184
|
+
const promise = buildSessionsResponse(ctx, deviceKey, generation, wantFresh, archivedOnly, page);
|
|
176
185
|
cache.promise = promise;
|
|
177
186
|
try {
|
|
178
187
|
const response = await promise;
|
|
@@ -194,7 +203,7 @@ async function handleSessions(ctx, req, res) {
|
|
|
194
203
|
}
|
|
195
204
|
}
|
|
196
205
|
|
|
197
|
-
async function buildSessionsResponse(ctx, deviceKey = 'legacy', generation = completionGenerationOf(ctx, deviceKey), fresh = false, archivedOnly = false) {
|
|
206
|
+
async function buildSessionsResponse(ctx, deviceKey = 'legacy', generation = completionGenerationOf(ctx, deviceKey), fresh = false, archivedOnly = false, page = null) {
|
|
198
207
|
const { value, error } = unwrap(await callSessions(ctx, 'list', {}, 'mobile', undefined, { fresh }));
|
|
199
208
|
if (error) return { status: 400, body: { ok: false, error } };
|
|
200
209
|
// 形状不对 = 没取到,不是「你一个会话都没有」。旧实现 `(value.items || [])` 把
|
|
@@ -255,9 +264,11 @@ async function buildSessionsResponse(ctx, deviceKey = 'legacy', generation = com
|
|
|
255
264
|
};
|
|
256
265
|
return { summary: s, base };
|
|
257
266
|
});
|
|
267
|
+
// 有分页时只为当前页补预览,避免每翻一页都给不可见会话重复读历史。
|
|
268
|
+
const pageRows = page ? rows.slice(page.offset, page.offset + page.limit) : rows;
|
|
258
269
|
// 预览子集:运行中优先(完成边沿确认不能缺),其次最近活跃;数量受
|
|
259
270
|
// SESSIONS_PREVIEW_LIMIT 限制,避免会话多时每次列表请求都全量读历史日志。
|
|
260
|
-
const previewCandidates =
|
|
271
|
+
const previewCandidates = pageRows
|
|
261
272
|
.slice()
|
|
262
273
|
.sort((a, b) =>
|
|
263
274
|
(b.summary.running ? 1 : 0) - (a.summary.running ? 1 : 0)
|
|
@@ -285,22 +296,24 @@ async function buildSessionsResponse(ctx, deviceKey = 'legacy', generation = com
|
|
|
285
296
|
// 预览失败不影响标题与行数据。
|
|
286
297
|
}
|
|
287
298
|
}));
|
|
288
|
-
const
|
|
299
|
+
const allItems = rows.map((r) => r.base);
|
|
300
|
+
const items = page ? allItems.slice(page.offset, page.offset + page.limit) : allItems;
|
|
289
301
|
const generatedAt = Date.now();
|
|
290
302
|
return {
|
|
291
303
|
status: 200,
|
|
292
304
|
body: {
|
|
293
305
|
ok: true,
|
|
294
306
|
items,
|
|
307
|
+
hasMore: page ? page.offset + items.length < allItems.length : false,
|
|
295
308
|
// 数据新鲜度:App 把这两个字段连同快照一起存下来,就能把「这份数据是谁在什么
|
|
296
309
|
// 时候给的」显示给用户,而不是只给一个「刚刚同步过」的客户端时间。revision 是
|
|
297
310
|
// 内容指纹(跨插件重启稳定),App 也用它判断一次变更信号是否真的带来了新内容。
|
|
298
311
|
generatedAt,
|
|
299
|
-
revision: listRevision(
|
|
312
|
+
revision: listRevision(allItems),
|
|
300
313
|
// 显式空态:形状降级在上面的 500 分支里已被挡掉,能走到这里说明宿主回了一份
|
|
301
314
|
// 合法清单 —— 「空」在这一点上是可断言的契约,不再需要 App 连续两次空去猜。
|
|
302
315
|
// 下游(空清单守卫 / 中转缓存 / App 的空态判定)据此放行真实的「清空」。
|
|
303
|
-
emptyConfirmed:
|
|
316
|
+
emptyConfirmed: allItems.length === 0,
|
|
304
317
|
},
|
|
305
318
|
};
|
|
306
319
|
}
|
package/lib/mobile/workspaces.js
CHANGED
|
@@ -145,6 +145,17 @@ async function handleDeleteWorkspace(ctx, req, res) {
|
|
|
145
145
|
* POST /api/mobile/v1/workspace/move-session(token){ workspaceId, sessionId, beforeSessionId? }
|
|
146
146
|
* 透传宿主 workspace.insertSessionBefore,使移动端和桌面端共享工作区排序。
|
|
147
147
|
*/
|
|
148
|
+
async function handleMoveWorkspace(ctx, req, res) {
|
|
149
|
+
const body = await readJsonBody(req, 64 * 1024);
|
|
150
|
+
const workspaceId = body && typeof body.workspaceId === 'string' ? body.workspaceId.trim() : '';
|
|
151
|
+
const beforeWorkspaceId = body && typeof body.beforeWorkspaceId === 'string' ? body.beforeWorkspaceId.trim() : '';
|
|
152
|
+
if (!workspaceId) return sendJson(res, 400, { ok: false, error: { code: 'bad-request', message: '需要 workspaceId' } });
|
|
153
|
+
const payload = { workspaceId, ...(beforeWorkspaceId ? { beforeWorkspaceId } : {}) };
|
|
154
|
+
const { value, error } = unwrap(await callWorkspace(ctx, 'insertBefore', payload));
|
|
155
|
+
if (error) return sendJson(res, 400, { ok: false, error });
|
|
156
|
+
sendJson(res, 200, { ok: true, workspaceIds: value && value.workspaceIds });
|
|
157
|
+
}
|
|
158
|
+
|
|
148
159
|
async function handleMoveSession(ctx, req, res) {
|
|
149
160
|
const body = await readJsonBody(req, 64 * 1024);
|
|
150
161
|
const workspaceId = body && typeof body.workspaceId === 'string' ? body.workspaceId.trim() : '';
|
|
@@ -255,6 +266,7 @@ async function handleUnpinSession(ctx, req, res) {
|
|
|
255
266
|
|
|
256
267
|
module.exports = {
|
|
257
268
|
buildWorkspacesBody,
|
|
269
|
+
handleMoveWorkspace,
|
|
258
270
|
handleWorkspaces,
|
|
259
271
|
handleListDirectory,
|
|
260
272
|
handleCreateDirectory,
|