@mrrisega/dsh-remote 0.6.0-beta.7 → 0.6.0-beta.9

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.
@@ -1,5 +1,7 @@
1
1
  // 插件 node 半新增代理回归:/dsh-remote/access-key | mobile-sessions | mobile-sessions/revoke
2
- // (企业端 E1 契约:POST /api/auth-key、POST /api/mobile-sessions、POST /api/mobile-sessions/:id/revoke,Bearer device-login JWT)
2
+ // | mobile-sessions/delete | mobile-sessions/purge
3
+ // (企业端 E1 契约:POST /api/auth-key、GET /api/mobile-sessions、POST /api/mobile-sessions/:id/revoke、
4
+ // DELETE /api/mobile-sessions/:id、POST /api/mobile-sessions/purge,Bearer device-login JWT)
3
5
  // 覆盖:Bearer 透传、字段扁平化、未登录 401、qr 缺失容错、上游错误透传。
4
6
  import assert from "node:assert/strict";
5
7
  import http from "node:http";
@@ -41,6 +43,9 @@ function startFakeRelay(opts = {}) {
41
43
  }
42
44
  if (req.method === "POST" && url.pathname === "/api/mobile-sessions/ms_1/revoke") return send(200, { ok: true });
43
45
  if (req.method === "POST" && url.pathname === "/api/mobile-sessions/ghost/revoke") return send(404, { error: { message: "not_found" } });
46
+ if (req.method === "DELETE" && url.pathname === "/api/mobile-sessions/ms_1") return send(200, { ok: true });
47
+ if (req.method === "DELETE" && url.pathname === "/api/mobile-sessions/ghost") return send(404, { error: { message: "not_found" } });
48
+ if (req.method === "POST" && url.pathname === "/api/mobile-sessions/purge") return send(200, { ok: true, removed: 2 });
44
49
  send(404, { error: { code: "not_found" } });
45
50
  });
46
51
  return new Promise((resolve) => srv.listen(0, "127.0.0.1", () => resolve({ srv, seen, port: srv.address().port })));
@@ -155,7 +160,66 @@ test("mobile-sessions/revoke 路由:body {id} 转发到 /api/mobile-sessions/:
155
160
  }
156
161
  });
157
162
 
158
- test("未登录(无账号配置)→ 三条路由统一 401 提示登录,不请求企业端", async () => {
163
+ test("mobile-sessions/delete 路由:DELETE body {id} 转发到企业端 DELETE /api/mobile-sessions/:id(拉黑 jti)", async () => {
164
+ const relay = await startFakeRelay();
165
+ const { host, base, tempDir } = await bootRelay(relay.port);
166
+ try {
167
+ const r = await (await fetch(`${base}/dsh-remote/mobile-sessions/delete`, {
168
+ method: "DELETE",
169
+ headers: { "content-type": "application/json" },
170
+ body: JSON.stringify({ id: "ms_1" }),
171
+ })).json();
172
+ assert.equal(r.ok, true);
173
+ const up = relay.seen.find((s) => s.path === "/api/mobile-sessions/ms_1" && s.method === "DELETE");
174
+ assert.ok(up, "应转发到企业端 DELETE /api/mobile-sessions/ms_1");
175
+ assert.equal(up.authorization, "Bearer jwt-abc");
176
+
177
+ // 缺 id → 400;不存在的会话 → 透传 404 文案
178
+ const bad = await (await fetch(`${base}/dsh-remote/mobile-sessions/delete`, {
179
+ method: "DELETE",
180
+ headers: { "content-type": "application/json" },
181
+ body: JSON.stringify({}),
182
+ })).json();
183
+ assert.equal(bad.ok, false);
184
+ assert.match(String(bad.error), /id/);
185
+
186
+ const ghost = await (await fetch(`${base}/dsh-remote/mobile-sessions/delete`, {
187
+ method: "DELETE",
188
+ headers: { "content-type": "application/json" },
189
+ body: JSON.stringify({ id: "ghost" }),
190
+ })).json();
191
+ assert.equal(ghost.ok, false);
192
+ assert.match(String(ghost.error), /not_found/);
193
+ } finally {
194
+ host.close();
195
+ relay.srv.close();
196
+ await rm(tempDir, { recursive: true, force: true });
197
+ }
198
+ });
199
+
200
+ test("mobile-sessions/purge 路由:POST → 企业端 POST /api/mobile-sessions/purge,removed 透传", async () => {
201
+ const relay = await startFakeRelay();
202
+ const { host, base, tempDir } = await bootRelay(relay.port);
203
+ try {
204
+ const r = await (await fetch(`${base}/dsh-remote/mobile-sessions/purge`, {
205
+ method: "POST",
206
+ headers: { "content-type": "application/json" },
207
+ body: JSON.stringify({}),
208
+ })).json();
209
+ assert.equal(r.ok, true);
210
+ assert.equal(r.removed, 2, "企业端返回的 removed 清理条数应透传");
211
+ const up = relay.seen.find((s) => s.path === "/api/mobile-sessions/purge");
212
+ assert.ok(up, "应转发到 /api/mobile-sessions/purge");
213
+ assert.equal(up.method, "POST");
214
+ assert.equal(up.authorization, "Bearer jwt-abc");
215
+ } finally {
216
+ host.close();
217
+ relay.srv.close();
218
+ await rm(tempDir, { recursive: true, force: true });
219
+ }
220
+ });
221
+
222
+ test("未登录(无账号配置)→ 五条路由统一 401 提示登录,不请求企业端", async () => {
159
223
  const relay = await startFakeRelay();
160
224
  const tempDir = await mkdtemp(path.join(os.tmpdir(), "dsh-aks-401-"));
161
225
  await writeFile(path.join(tempDir, ".dsh-config.json"), JSON.stringify({ api_url: `http://127.0.0.1:${relay.port}` }));
@@ -179,13 +243,19 @@ test("未登录(无账号配置)→ 三条路由统一 401 提示登录,
179
243
  assert.equal((await (await fetch(`${base}${p}`, { method }))).status, 401, `${p} 应返回 401`);
180
244
  assert.match(String(r.error), /尚未登录/);
181
245
  }
182
- const rev = await (await fetch(`${base}/dsh-remote/mobile-sessions/revoke`, {
183
- method: "POST",
184
- headers: { "content-type": "application/json" },
185
- body: JSON.stringify({ id: "ms_1" }),
186
- })).json();
187
- assert.equal(rev.ok, false);
188
- assert.match(String(rev.error), /尚未登录/);
246
+ for (const [method, p] of [
247
+ ["POST", "/dsh-remote/mobile-sessions/revoke"],
248
+ ["DELETE", "/dsh-remote/mobile-sessions/delete"],
249
+ ["POST", "/dsh-remote/mobile-sessions/purge"],
250
+ ]) {
251
+ const r = await (await fetch(`${base}${p}`, {
252
+ method,
253
+ headers: { "content-type": "application/json" },
254
+ body: JSON.stringify({ id: "ms_1" }),
255
+ })).json();
256
+ assert.equal(r.ok, false);
257
+ assert.match(String(r.error), /尚未登录/);
258
+ }
189
259
  assert.ok(!relay.seen.some((s) => s.path.startsWith("/api/auth-key") || s.path.startsWith("/api/mobile-sessions")), "未登录不应请求企业端");
190
260
  } finally {
191
261
  host.close();
@@ -0,0 +1,141 @@
1
+ // 插件 node 半 E2EE 状态读取(Phase-5):composeStatus 读取 relayDir/.e2ee-state.json →
2
+ // /dsh-remote/status 的 service.e2ee = {enabled, reason, profile, epoch, caps};文件缺失/损坏=未启用明文。
3
+ // 覆盖:readE2eeStateFile 归一化单测 + 真实 apply 路由集成(写状态文件 → status 带回;删文件 → 默认明文)。
4
+ import assert from "node:assert/strict";
5
+ import http from "node:http";
6
+ import { chmod, mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
7
+ import os from "node:os";
8
+ import path from "node:path";
9
+ import test from "node:test";
10
+ import { apply, readE2eeStateFile } from "../lib/index.js";
11
+
12
+ const E2EE_DEFAULT = { enabled: false, reason: "no_state_file", profile: "", epoch: 0, caps: [] };
13
+
14
+ test("readE2eeStateFile:缺失/损坏 → 默认明文;有效状态归一化(at 等调试字段不外泄)", async () => {
15
+ const tempDir = await mkdtemp(path.join(os.tmpdir(), "dsh-e2ee-read-"));
16
+ try {
17
+ // 1) 文件缺失 → 默认(未启用明文)
18
+ assert.deepEqual(readE2eeStateFile(tempDir), E2EE_DEFAULT);
19
+
20
+ // 2) 损坏 JSON / 非对象 → 默认
21
+ await writeFile(path.join(tempDir, ".e2ee-state.json"), "{oops", "utf8");
22
+ assert.deepEqual(readE2eeStateFile(tempDir), E2EE_DEFAULT);
23
+ await writeFile(path.join(tempDir, ".e2ee-state.json"), "42", "utf8");
24
+ assert.deepEqual(readE2eeStateFile(tempDir), E2EE_DEFAULT);
25
+
26
+ // 3) 有效状态:只透出固定展示字段,enabled/caps 等按布尔/字符串严格归一化
27
+ await writeFile(path.join(tempDir, ".e2ee-state.json"), JSON.stringify({
28
+ enabled: true,
29
+ reason: "ok",
30
+ profile: "pbkdf2-sha256-600k",
31
+ epoch: 3,
32
+ caps: ["e2ee-v2", 7, null, "e2ee-http"],
33
+ at: Date.now(), // 调试字段
34
+ }), "utf8");
35
+ assert.deepEqual(readE2eeStateFile(tempDir), {
36
+ enabled: true,
37
+ reason: "ok",
38
+ profile: "pbkdf2-sha256-600k",
39
+ epoch: 3,
40
+ caps: ["e2ee-v2", "e2ee-http"],
41
+ });
42
+
43
+ // 4) 畸形字段兜底:enabled 非布尔→false;reason 非字符串→空;epoch 负数/字符串→0
44
+ await writeFile(path.join(tempDir, ".e2ee-state.json"), JSON.stringify({
45
+ enabled: "yes",
46
+ reason: 123,
47
+ profile: null,
48
+ epoch: -1,
49
+ caps: "e2ee-v2",
50
+ }), "utf8");
51
+ assert.deepEqual(readE2eeStateFile(tempDir), {
52
+ enabled: false,
53
+ reason: "",
54
+ profile: "",
55
+ epoch: 0,
56
+ caps: [],
57
+ });
58
+ } finally {
59
+ await rm(tempDir, { recursive: true, force: true });
60
+ }
61
+ });
62
+
63
+ /** 假账号 API:只回 public-config(composeStatus 取 app_url 用),其余 404。 */
64
+ function startFakeRelay() {
65
+ const srv = http.createServer((req, res) => {
66
+ const url = new URL(req.url, "http://x");
67
+ res.writeHead(200, { "content-type": "application/json" });
68
+ if (url.pathname === "/api/public-config") {
69
+ res.end(JSON.stringify({ app_url: "https://app.test/app/", api_url: `http://127.0.0.1:${srv.address().port}/relay-api` }));
70
+ return;
71
+ }
72
+ res.end(JSON.stringify({ error: { code: "not_found" } }));
73
+ });
74
+ return new Promise((resolve) => srv.listen(0, "127.0.0.1", () => resolve(srv)));
75
+ }
76
+
77
+ /** 装载插件路由 + 假 launchctl/pgrep/ps(避免触碰真实系统服务),返回 host http server 与 base。 */
78
+ async function bootPlugin(relayDir) {
79
+ const fakeBin = path.join(relayDir, "fakebin");
80
+ await mkdir(fakeBin, { recursive: true });
81
+ const noop = "#!/bin/sh\nexit 0\n";
82
+ for (const name of ["launchctl", "pgrep", "ps"]) {
83
+ const p = path.join(fakeBin, name);
84
+ await writeFile(p, noop);
85
+ await chmod(p, 0o755);
86
+ }
87
+ const oldPath = process.env.PATH;
88
+ process.env.PATH = `${fakeBin}:${oldPath}`;
89
+
90
+ const routes = new Map();
91
+ apply({
92
+ webServer: { register(route) { routes.set(route.path, route.handler); return () => {}; } },
93
+ effect(register) { return register(); },
94
+ logger: { info() {}, warn() {} },
95
+ }, { relayDir });
96
+ const host = http.createServer((req, res) => {
97
+ const handler = routes.get(new URL(req.url, "http://x").pathname);
98
+ (handler || ((_r, rs) => { rs.writeHead(404); rs.end(); }))(req, res);
99
+ });
100
+ await new Promise((resolve) => host.listen(0, "127.0.0.1", resolve));
101
+ return { host, base: `http://127.0.0.1:${host.address().port}`, fakeBin, oldPath };
102
+ }
103
+
104
+ test("composeStatus:.e2ee-state.json 启用 → /dsh-remote/status 的 service.e2ee;删除 → 默认明文", async () => {
105
+ const relay = await startFakeRelay();
106
+ const tempDir = await mkdtemp(path.join(os.tmpdir(), "dsh-e2ee-status-"));
107
+ const stateFile = path.join(tempDir, ".e2ee-state.json");
108
+ await writeFile(path.join(tempDir, ".dsh-config.json"), JSON.stringify({
109
+ phone: "13800000000",
110
+ device_id: "dev-e2ee",
111
+ api_url: `http://127.0.0.1:${relay.address().port}/relay-api`,
112
+ }));
113
+ const boot = await bootPlugin(tempDir);
114
+ try {
115
+ // 1) bridge 已启用 E2EE(写状态文件)→ status 携带归一化 service.e2ee
116
+ await writeFile(stateFile, JSON.stringify({
117
+ enabled: true, reason: "ok", profile: "pbkdf2-sha256-600k", epoch: 2, caps: ["e2ee-v2"], at: Date.now(),
118
+ }));
119
+ let status = await (await fetch(`${boot.base}/dsh-remote/status`)).json();
120
+ assert.equal(status.ok, true);
121
+ assert.deepEqual(status.service.e2ee, {
122
+ enabled: true, reason: "ok", profile: "pbkdf2-sha256-600k", epoch: 2, caps: ["e2ee-v2"],
123
+ });
124
+
125
+ // 2) bridge 未启用(服务端关闭/不支持)→ 原样上报 reason,供面板映射可读文案
126
+ await writeFile(stateFile, JSON.stringify({ enabled: false, reason: "server_disabled", profile: "", epoch: 0, caps: [] }));
127
+ status = await (await fetch(`${boot.base}/dsh-remote/status`)).json();
128
+ assert.equal(status.service.e2ee.enabled, false);
129
+ assert.equal(status.service.e2ee.reason, "server_disabled");
130
+
131
+ // 3) 文件缺失 = 未启用明文(默认兜底)
132
+ await rm(stateFile, { force: true });
133
+ status = await (await fetch(`${boot.base}/dsh-remote/status`)).json();
134
+ assert.deepEqual(status.service.e2ee, E2EE_DEFAULT);
135
+ } finally {
136
+ boot.host.close();
137
+ relay.close();
138
+ process.env.PATH = boot.oldPath;
139
+ await rm(tempDir, { recursive: true, force: true });
140
+ }
141
+ });
@@ -22,6 +22,12 @@ function find(tree, predicate) {
22
22
  return match;
23
23
  }
24
24
 
25
+ function findAll(tree, predicate) {
26
+ const out = [];
27
+ walk(tree, (node) => { if (predicate(node)) out.push(node); });
28
+ return out;
29
+ }
30
+
25
31
  /** 文本子节点包含子串(用于动态拼接文案断言)。 */
26
32
  function textHas(tree, substr) {
27
33
  return !!find(tree, (node) => (node.children || []).some((c) => typeof c === "string" && c.includes(substr)));
@@ -108,11 +114,19 @@ function loadPlugin(opts = {}) {
108
114
  }
109
115
  if (path === "/dsh-remote/mobile-sessions") {
110
116
  sessionsCalls++;
111
- return response(200, { ok: true, sessions: sessionsCalls === 1 ? SESSIONS_1 : [] }); // 取消配对后的刷新 → 空列表
117
+ // 默认:首次给列表,后续刷新给空(模拟已全部清理);测试可传 opts.sessions 固定列表
118
+ const list = opts.sessions !== undefined ? opts.sessions : (sessionsCalls === 1 ? SESSIONS_1 : []);
119
+ return response(200, { ok: true, sessions: list });
112
120
  }
113
121
  if (path === "/dsh-remote/mobile-sessions/revoke") {
114
122
  return response(200, { ok: true });
115
123
  }
124
+ if (path === "/dsh-remote/mobile-sessions/delete") {
125
+ return response(200, { ok: true });
126
+ }
127
+ if (path === "/dsh-remote/mobile-sessions/purge") {
128
+ return response(200, { ok: true, removed: 1 });
129
+ }
116
130
  if (path === "/dsh-remote/account") return response(200, { ok: true, account: { phone: "13800000000", plan: "free", plan_source: "plan", invite_code: "ABC12345" } });
117
131
  return response(200, { ok: true });
118
132
  },
@@ -172,7 +186,7 @@ test("📱 远程访问卡:bridge 在线 → 绿点文案;登录态点「生
172
186
  assert.ok(qr && qr.props?.alt === "远程访问二维码", "应渲染服务端返回的二维码 <img>");
173
187
  // 到期倒计时/有效至文案
174
188
  assert.ok(textHas(tree, "有效至") && textHas(tree, "剩余"), "应显示「有效至 HH:MM:SS / 剩余 xx:xx」倒计时");
175
- assert.ok(textHas(tree, "访问一次后失效"), "应说明一次性/30 分钟语义");
189
+ assert.ok(textHas(tree, "用一次即失效"), "应说明一次性/30 分钟语义(精简一句)");
176
190
  });
177
191
 
178
192
  test("已授权设备:展开列表(label/os/browser/时间)→ 取消配对二次确认 → 成功提示并刷新", async () => {
@@ -216,6 +230,76 @@ test("已授权设备:展开列表(label/os/browser/时间)→ 取消配
216
230
  assert.ok(textHas(tree, "暂无已授权设备(手机扫码后出现)"), "刷新后空态文案应出现");
217
231
  });
218
232
 
233
+ test("已授权设备行内操作:活跃行有 取消配对+删除记录,已取消行有 删除记录;删除记录二次确认 → DELETE delete {id}", async () => {
234
+ const plugin = loadPlugin({ sessions: SESSIONS_1 }); // 固定列表:操作后重拉仍保留,便于断言行内按钮
235
+ plugin.states[0] = { config: { phone: "13800000000", deviceId: "dev-x" }, service: { running: true } };
236
+ let tree = plugin.render();
237
+ const openBtn = find(tree, (n) => typeof n.props?.onClick === "function" && (n.children || []).some((c) => typeof c === "string" && c.includes("已授权设备")));
238
+ openBtn.props.onClick();
239
+ await flush();
240
+ await flush();
241
+ tree = plugin.render();
242
+
243
+ // 行内按钮:活跃行(ms_1)应有「取消配对」+「删除记录」;已取消行(ms_2)只应有「删除记录」
244
+ const delBtns = findAll(tree, (n) => typeof n.props?.onClick === "function" && (n.children || []).some((c) => typeof c === "string" && c.includes("删除记录")));
245
+ assert.ok(delBtns.length >= 2, "每行(含已取消/历史)都应有「删除记录」按钮");
246
+ assert.ok(find(tree, (n) => typeof n.props?.onClick === "function" && (n.children || []).some((c) => typeof c === "string" && c.includes("取消配对"))),
247
+ "活跃行应有「取消配对」按钮");
248
+ // 底部有「清理已解绑」入口(卡片底部)
249
+ assert.ok(textHas(tree, "清理已解绑"), "卡片底部应提供「清理已解绑」");
250
+
251
+ // 删除 ms_1:第一次点击进入确认态(不请求)
252
+ delBtns[0].props.onClick();
253
+ tree = plugin.render();
254
+ assert.ok(textHas(tree, "再点一次确认删除记录"), "删除记录需二次确认");
255
+ assert.ok(!plugin.requests.some((r) => r.path === "/dsh-remote/mobile-sessions/delete"), "首次点击不应发 DELETE");
256
+
257
+ // 第二次点击 → DELETE /dsh-remote/mobile-sessions/delete {id} → 提示 + 刷新列表
258
+ const listBefore = plugin.requests.filter((r) => r.path === "/dsh-remote/mobile-sessions").length;
259
+ find(tree, (n) => typeof n.props?.onClick === "function" && (n.children || []).some((c) => typeof c === "string" && c.includes("再点一次确认删除记录"))).props.onClick();
260
+ await flush();
261
+ await flush();
262
+ await flush();
263
+ const del = plugin.requests.find((r) => r.path === "/dsh-remote/mobile-sessions/delete");
264
+ assert.ok(del, "确认后应 DELETE /dsh-remote/mobile-sessions/delete");
265
+ assert.equal(del.method, "DELETE");
266
+ assert.deepEqual(del.body, { id: "ms_1" }, "行内删除应带上该行 session id");
267
+ assert.ok(plugin.requests.filter((r) => r.path === "/dsh-remote/mobile-sessions").length > listBefore, "删除后应重拉设备列表");
268
+ tree = plugin.render();
269
+ assert.ok(textHas(tree, "已删除该设备的记录"), "删除成功应有可读提示");
270
+ });
271
+
272
+ test("已授权设备:卡片底部「清理已解绑」→ 二次确认 → POST purge → 提示条数 + 刷新列表", async () => {
273
+ const plugin = loadPlugin({ sessions: SESSIONS_1 });
274
+ plugin.states[0] = { config: { phone: "13800000000", deviceId: "dev-x" }, service: { running: true } };
275
+ let tree = plugin.render();
276
+ const openBtn = find(tree, (n) => typeof n.props?.onClick === "function" && (n.children || []).some((c) => typeof c === "string" && c.includes("已授权设备")));
277
+ openBtn.props.onClick();
278
+ await flush();
279
+ await flush();
280
+ tree = plugin.render();
281
+
282
+ // 底部 purge 按钮:带 1 条已解绑计数
283
+ const purgeBtn = find(tree, (n) => typeof n.props?.onClick === "function" && (n.children || []).some((c) => typeof c === "string" && c.includes("清理已解绑")));
284
+ assert.ok(purgeBtn, "卡片底部应有「清理已解绑」按钮");
285
+ purgeBtn.props.onClick();
286
+ tree = plugin.render();
287
+ assert.ok(textHas(tree, "再点一次确认清理已解绑"), "清理已解绑需二次确认");
288
+ assert.ok(!plugin.requests.some((r) => r.path === "/dsh-remote/mobile-sessions/purge"), "首次点击不应发请求");
289
+
290
+ const listBefore = plugin.requests.filter((r) => r.path === "/dsh-remote/mobile-sessions").length;
291
+ find(tree, (n) => typeof n.props?.onClick === "function" && (n.children || []).some((c) => typeof c === "string" && c.includes("再点一次确认清理已解绑"))).props.onClick();
292
+ await flush();
293
+ await flush();
294
+ await flush();
295
+ const purge = plugin.requests.find((r) => r.path === "/dsh-remote/mobile-sessions/purge");
296
+ assert.ok(purge, "确认后应 POST /dsh-remote/mobile-sessions/purge");
297
+ assert.equal(purge.method, "POST");
298
+ assert.ok(plugin.requests.filter((r) => r.path === "/dsh-remote/mobile-sessions").length > listBefore, "清理后应重拉设备列表");
299
+ tree = plugin.render();
300
+ assert.ok(textHas(tree, "已清理 1 条已解绑记录"), "应提示清理条数(可读)");
301
+ });
302
+
219
303
  test("升级/续费按钮:点击 → GET /dsh-remote/access-key → window.open(url)(带登录态打开)", async () => {
220
304
  const plugin = loadPlugin();
221
305
  plugin.states[0] = { config: { phone: "13800000000", deviceId: "dev-x" }, service: { running: true } };
@@ -250,4 +334,92 @@ test("二维码缺失容错 + 未登录引导文案(源码级约束)", () =>
250
334
  assert.match(SOURCE, /已连接(可远程访问)/);
251
335
  assert.match(SOURCE, /带登录态/);
252
336
  assert.match(SOURCE, /通过手机或另一台电脑远程使用同一份 dsh web/);
337
+ // 已授权设备管理(delete/purge 代理路由与按钮)
338
+ assert.match(SOURCE, /dsh-remote\/mobile-sessions\/delete/);
339
+ assert.match(SOURCE, /dsh-remote\/mobile-sessions\/purge/);
340
+ assert.match(SOURCE, /删除记录/);
341
+ assert.match(SOURCE, /清理已解绑/);
342
+ assert.match(SOURCE, /再点一次确认删除记录/);
343
+ assert.match(SOURCE, /再点一次确认清理已解绑/);
344
+ // 文案精简:二维码说明压成一句、右侧长段压成一句
345
+ assert.match(SOURCE, /扫码即进入,30 分钟有效、用一次即失效。/);
346
+ assert.match(SOURCE, /打开链接\/扫码进入即登录态;同设备重复扫码只更新授权,不新增设备。/);
347
+ assert.doesNotMatch(SOURCE, /每次生成的链接 30 分钟有效、访问一次后失效/);
348
+ assert.doesNotMatch(SOURCE, /手机上打开链接点「进入」即可像在本机一样使用 dsh web/);
349
+ });
350
+
351
+ // ---------- 端到端加密(E2EE,Phase-5)状态徽标 ----------
352
+
353
+ const E2EE_STATE = {
354
+ enabled: { enabled: true, reason: "ok", profile: "pbkdf2-sha256-600k", epoch: 1, caps: ["e2ee-v2"] },
355
+ serverDisabled: { enabled: false, reason: "server_disabled", profile: "", epoch: 0, caps: [] },
356
+ paramsUnreachable: { enabled: false, reason: "params_unreachable", profile: "", epoch: 0, caps: [] },
357
+ localDisabled: { enabled: false, reason: "disabled_by_config", profile: "", epoch: 0, caps: [] },
358
+ deriveFailed: { enabled: false, reason: "derive_failed", profile: "", epoch: 0, caps: [] },
359
+ unknownReason: { enabled: false, reason: "brand_new_reason_x", profile: "", epoch: 0, caps: [] },
360
+ };
361
+
362
+ function stateWith(service) {
363
+ return { config: { phone: "13800000000", deviceId: "dev-x", mode: "saas" }, service, remoteUrl: "https://app.test/" };
364
+ }
365
+
366
+ function e2eeLine(tree) {
367
+ return find(tree, (n) => n.props && typeof n.props.className === "string" && n.props.className.indexOf("dru-e2ee-line") === 0);
368
+ }
369
+
370
+ test("E2EE 徽标:已启用 → “🔒 端到端加密已启用(手机解锁后生效)”(service.e2ee 字段驱动)", () => {
371
+ const plugin = loadPlugin();
372
+ plugin.states[0] = stateWith({ running: true, e2ee: E2EE_STATE.enabled });
373
+ const tree = plugin.render();
374
+ const line = e2eeLine(tree);
375
+ assert.ok(line, "启用态应在「📱 远程访问」卡渲染加密状态行");
376
+ assert.match(String(line.props.className), /ok/, "启用态状态行应为绿色(ok)标记");
377
+ assert.ok(textHas(tree, "🔒 端到端加密已启用"), "应显示“端到端加密已启用”徽标文案");
378
+ assert.ok(textHas(tree, "手机解锁后生效"), "应提示“手机解锁后生效”(bridge 就绪、手机解锁后方生效)");
379
+ });
380
+
381
+ test("E2EE 徽标:未启用原因映射可读文案(服务端关闭 / 参数不可达 / 本地关闭 / 改密 / 未知兜底)", () => {
382
+ const cases = [
383
+ [E2EE_STATE.serverDisabled, "端到端加密暂不可用(当前为普通安全连接 HTTPS)"],
384
+ [E2EE_STATE.paramsUnreachable, "当前为普通安全连接(HTTPS)"],
385
+ [E2EE_STATE.localDisabled, "当前为普通安全连接(HTTPS)"],
386
+ [E2EE_STATE.deriveFailed, "账号密码已变更"],
387
+ [E2EE_STATE.unknownReason, "当前为普通安全连接(HTTPS)"], // 未知 reason → 兜底
388
+ ];
389
+ for (const [e2ee, text] of cases) {
390
+ const plugin = loadPlugin();
391
+ plugin.states[0] = stateWith({ running: true, e2ee });
392
+ const tree = plugin.render();
393
+ assert.ok(textHas(tree, text), `reason=${e2ee.reason} 应映射为可读文案: ${text}`);
394
+ const line = e2eeLine(tree);
395
+ assert.ok(line && !/ ok/.test(String(line.props.className)), `reason=${e2ee.reason} 非启用态不应带 ok 标记`);
396
+ }
397
+ });
398
+
399
+ test("E2EE 徽标:未登录 / host 未下发 e2ee → 不打扰(不渲染状态行)", () => {
400
+ // 未登录(config 无 phone)即便 bridge 上报 enabled 也不打扰
401
+ const anon = loadPlugin();
402
+ anon.states[0] = { config: { deviceId: "dev-x", mode: "saas" }, service: { running: true, e2ee: E2EE_STATE.enabled } };
403
+ assert.equal(e2eeLine(anon.render()), undefined, "未登录不应渲染 E2EE 状态行");
404
+ assert.ok(!textHas(anon.render(), "手机解锁后生效"), "未登录不应出现加密徽标文案");
405
+
406
+ // 已登录但 service 无 e2ee(旧 host / 未下发)→ 不渲染
407
+ const oldHost = loadPlugin();
408
+ oldHost.states[0] = stateWith({ running: true });
409
+ assert.equal(e2eeLine(oldHost.render()), undefined, "未下发 service.e2ee 时不应渲染状态行");
410
+ });
411
+
412
+ test("E2EE 徽标(源码级约束):client 含徽标字段/文案与 reason 映射表", () => {
413
+ assert.match(SOURCE, /service\.e2ee/);
414
+ assert.match(SOURCE, /\.e2ee-state\.json/);
415
+ assert.match(SOURCE, /🔒 端到端加密已启用(手机解锁后生效)/);
416
+ assert.match(SOURCE, /端到端加密暂不可用(当前为普通安全连接 HTTPS)/);
417
+ assert.doesNotMatch(SOURCE, /等待服务端开启 E2EE/); // 已正式开启,不再出现“灰度等待”措辞
418
+ assert.match(SOURCE, /当前为普通安全连接(HTTPS)/);
419
+ assert.match(SOURCE, /server_disabled/);
420
+ assert.match(SOURCE, /params_unreachable/);
421
+ assert.match(SOURCE, /disabled_by_config/);
422
+ assert.match(SOURCE, /derive_failed/);
423
+ assert.match(SOURCE, /describeE2ee/);
424
+ assert.match(SOURCE, /dru-e2ee-line/);
253
425
  });
@@ -87,6 +87,7 @@ function loadPlugin(opts = {}) {
87
87
  });
88
88
 
89
89
  const allCreated = [];
90
+ const intervals = [];
90
91
  const doc = {
91
92
  createElement(tag) { const el = makeEl(tag); allCreated.push(el); return el; },
92
93
  head: makeEl("head"),
@@ -95,7 +96,11 @@ function loadPlugin(opts = {}) {
95
96
  const cls = sel.charAt(0) === "." ? sel.slice(1) : "";
96
97
  return allCreated.find((el) => el.className === cls) || null;
97
98
  },
98
- querySelectorAll(sel) { return sel === "button" ? (opts.navCells || []) : []; },
99
+ querySelectorAll(sel) {
100
+ if (sel === "button") return opts.navCells || [];
101
+ if (typeof opts.navQuery === "function") return opts.navQuery(sel);
102
+ return [];
103
+ },
99
104
  };
100
105
 
101
106
  let lastObserver = null;
@@ -129,7 +134,7 @@ function loadPlugin(opts = {}) {
129
134
  return response(200, { body: { ok: true } });
130
135
  },
131
136
  navigator: { clipboard: { writeText: async () => {} } },
132
- setInterval() { return 1; },
137
+ setInterval(cb, ms) { intervals.push({ cb, ms }); return intervals.length; },
133
138
  clearInterval() {},
134
139
  setTimeout() { return 1; },
135
140
  Set,
@@ -152,6 +157,8 @@ function loadPlugin(opts = {}) {
152
157
 
153
158
  return {
154
159
  registered, metas, injects, requests, removed, states, localStorage,
160
+ intervals,
161
+ navWindow: sandbox.window,
155
162
  lastObserver: () => lastObserver,
156
163
  renderSection() { hook = 0; return registered.get("dsh-remote")({ close() {} }); },
157
164
  };
@@ -203,7 +210,7 @@ test("登录态账号区:无「切换账号」,有「退出登录」,头
203
210
  "📱 远程访问:用手机或另一台电脑的浏览器,随时随地使用同一份 dsh web——人在哪都能用(免公网 IP、免内网穿透);官方托管中继,4G/5G 即用,也可自建服务。",
204
211
  "🛠 电脑端一键安装:bridge 与「远程访问」面板一次到位——云端/自建切换、账号登录、bridge 启停、一次性扫码访问、已授权设备管理、意见反馈都在这里。",
205
212
  "🔒 安全与通道:HTTP / WebSocket 全量透传,一次性访问密钥认证,面板实时显示设备与已授权设备列表;服务端可配置流量配额。",
206
- "🛡 端到端流量保护:可选对通道做端到端加密保护,传输全程不暴露本机公网 IP(详见项目 README「安全」说明)。",
213
+ "🛡 端到端加密:手机↔电脑之间的消息内容用「你的账号密码派生密钥」端到端加密——密钥与密码不落服务端(仅存校验值),中继只可见路径/大小/时间(详见 README「安全与隐私」)。",
207
214
  ];
208
215
  for (const p of points) {
209
216
  assert.ok(find(tree, (n) => n.children?.includes(p)), `说明卡片应含要点: ${p.slice(0, 12)}…`);
@@ -277,6 +284,61 @@ test("红点已看过(localStorage 有 key)时不注入,重启 DSH Web 不
277
284
  assert.equal(navCell.children.length, 0, "已看过时不应再注入红点");
278
285
  });
279
286
 
287
+ // ---------- 侧栏入口「远程访问」点击流(openRemoteSettings) ----------
288
+
289
+ /** 简易导航节点:带 aria-label/text/class/role 的“按钮”,记录点击并支持 data-dru-remote 标记。 */
290
+ function navNode({ id = "", text = "", aria = "", cls = "", role = "", tag = "button" } = {}) {
291
+ const node = {
292
+ id,
293
+ className: cls,
294
+ textContent: text,
295
+ tagName: tag,
296
+ _attrs: {},
297
+ _clicked: 0,
298
+ getAttribute(k) { return k in this._attrs ? this._attrs[k] : null; },
299
+ setAttribute(k, v) { this._attrs[k] = String(v); },
300
+ click() { this._clicked++; },
301
+ };
302
+ if (aria) node._attrs["aria-label"] = aria;
303
+ if (role) node._attrs.role = role;
304
+ return node;
305
+ }
306
+
307
+ test("侧栏入口点击流:远程栏目项已存在(navCell/aria-label,含 emoji 前缀)→ 直接命中栏目,不点注入项自身", () => {
308
+ const injected = navNode({ id: "dru-nav-remote", text: "📱 远程访问", cls: "navCell-clone" }); // 注入的侧栏项
309
+ const settingsNav = navNode({ text: "设置", cls: "navCell-set" });
310
+ const remoteCell = navNode({ aria: "远程访问", cls: "VOzbGW_navCell", tag: "button" }); // 纯图标+aria-label 的栏目项
311
+ const nodes = [injected, settingsNav, remoteCell];
312
+ const plugin = loadPlugin({ navQuery: () => nodes });
313
+ plugin.navWindow.__dshRemoteNav.open();
314
+ assert.equal(remoteCell._clicked, 1, "应命中「远程访问」栏目项(aria-label 亦可读)");
315
+ assert.equal(remoteCell.getAttribute("data-dru-remote"), "1", "命中后应打 data-dru-remote 标记(加速二次命中)");
316
+ assert.equal(injected._clicked, 0, "不得点击注入的侧栏项自身(防递归)");
317
+ assert.equal(settingsNav._clicked, 0, "栏目已存在时无需退化点「设置」");
318
+ assert.equal(plugin.intervals.filter((x) => x.ms === 150).length, 0, "直接命中后不应启动轮询");
319
+ });
320
+
321
+ test("侧栏入口点击流:栏目项尚未挂载 → 先点「设置」,轮询到点后再补点「远程访问」(全程不点注入项)", () => {
322
+ const injected = navNode({ id: "dru-nav-remote", text: "📱 远程访问", cls: "navCell-clone" });
323
+ const settingsNav = navNode({ text: "设置", cls: "navCell-set" });
324
+ const remoteCell = navNode({ text: "📱 远程访问", cls: "VOzbGW_navCell" }); // 设置页打开后才挂载
325
+ const nodes = [injected, settingsNav]; // 初始只有注入项 + 「设置」
326
+ const plugin = loadPlugin({ navQuery: () => nodes });
327
+ plugin.navWindow.__dshRemoteNav.open();
328
+
329
+ assert.equal(settingsNav._clicked, 1, "栏目不可达时应点「设置」进入设置页");
330
+ assert.equal(injected._clicked, 0, "不得点击注入的侧栏项自身");
331
+ const poll = plugin.intervals.find((x) => x.ms === 150);
332
+ assert.ok(poll, "应启动 150ms 轮询等待栏目挂载");
333
+
334
+ nodes.push(remoteCell); // 设置页已打开,栏目项挂载
335
+ poll.cb();
336
+ assert.equal(remoteCell._clicked, 1, "轮询到点后应补点「远程访问」栏目");
337
+ assert.equal(remoteCell.getAttribute("data-dru-remote"), "1", "栏目命中后应打标记");
338
+ assert.equal(settingsNav._clicked, 1, "「设置」只应点一次");
339
+ assert.equal(injected._clicked, 0, "轮询过程也不得点注入项自身");
340
+ });
341
+
280
342
  test("源码约束:无侧边栏入口/浮动面板/切换账号;命名统一为「远程访问」;新增访问密钥/设备路由", () => {
281
343
  // 只断言“代码形态”不存在(注释里允许出现说明文字)
282
344
  assert.doesNotMatch(SOURCE, /slots\.inject\("sidebar\.footer\.action"/);
@@ -294,6 +356,13 @@ test("源码约束:无侧边栏入口/浮动面板/切换账号;命名统一
294
356
  assert.match(SOURCE, /dsh-remote\/access-key/);
295
357
  assert.match(SOURCE, /dsh-remote\/mobile-sessions/);
296
358
  assert.match(SOURCE, /order: 30/);
359
+ // 侧栏入口导航加固:aria-label/role 扫描、data-dru-remote 标记、排除注入项防递归、进设置后轮询
360
+ assert.match(SOURCE, /data-dru-remote/);
361
+ assert.match(SOURCE, /dru-nav-remote/);
362
+ assert.match(SOURCE, /aria-label/);
363
+ assert.match(SOURCE, /remoteSectionVisible/);
364
+ assert.match(SOURCE, /openRemoteSettings/);
365
+ assert.match(SOURCE, /__dshRemoteNav/);
297
366
  // 清理时机:退出登录成功回调内、登录账号变化时、切换自建服务时
298
367
  assert.match(SOURCE, /post\("\/dsh-remote\/logout"\)\.then\(function \(body\) \{[\s\S]*?fbClearThreads\(\);/);
299
368
  assert.match(SOURCE, /if \(prevPhone !== phone\.trim\(\)\) fbClearThreads\(\);/);