@mrrisega/dsh-remote 0.6.0-beta.11 → 0.6.0-beta.2

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 (25) hide show
  1. package/README.md +0 -30
  2. package/clients/dsh-remote/dsh-bridge.mjs +5 -305
  3. package/package.json +2 -2
  4. package/packages/dsh-remote-web/lib/client.js +178 -467
  5. package/packages/dsh-remote-web/lib/index.js +4 -115
  6. package/packages/dsh-remote-web/test/access-key-sessions.test.mjs +11 -81
  7. package/packages/dsh-remote-web/test/account-switch.test.mjs +1 -1
  8. package/packages/dsh-remote-web/test/remote-access-ui.test.mjs +2 -174
  9. package/packages/dsh-remote-web/test/settings-entry.test.mjs +5 -55
  10. package/packages/dsh-remote-web/test/uninstall-runtime.test.mjs +2 -2
  11. package/clients/dsh-remote/e2ee-client.mjs +0 -711
  12. package/clients/dsh-remote/e2ee-shim-script.js +0 -697
  13. package/clients/dsh-remote/e2ee-shim.mjs +0 -158
  14. package/clients/dsh-remote/mobile-adapter.mjs +0 -541
  15. package/clients/dsh-remote/test/bridge-html-inject.test.mjs +0 -110
  16. package/clients/dsh-remote/test/e2ee-bridge.test.mjs +0 -630
  17. package/clients/dsh-remote/test/e2ee-client.test.mjs +0 -365
  18. package/clients/dsh-remote/test/e2ee-shim.test.mjs +0 -507
  19. package/clients/dsh-remote/test/e2ee-wecrypto-parity.test.mjs +0 -200
  20. package/clients/dsh-remote/test/lib-e2ee-shim.mjs +0 -59
  21. package/clients/dsh-remote/test/lib-native-e2ee.mjs +0 -92
  22. package/clients/dsh-remote/test/mobile-adapter.test.mjs +0 -124
  23. package/packages/dsh-remote-web/test/e2ee-state.test.mjs +0 -141
  24. package/packages/dsh-remote-web/test/feedback-account-mine.test.mjs +0 -70
  25. package/packages/dsh-remote-web/test/password-reset.test.mjs +0 -429
@@ -4,8 +4,7 @@
4
4
  // - 读写配置目录下 .dsh-config.json(0600)
5
5
  // - 查询/启停 bridge(launchctl,plist 缺失时自动生成,逻辑与 dsh-setup.mjs 一致)
6
6
  // - 代理 relay API(captcha / register / login / public-config),直连、不走系统代理;
7
- // 另代理企业端一次性访问密钥 / 授权设备(/api/auth-key、/api/mobile-sessions、…/revoke、
8
- // DELETE …/:id、POST …/purge,Bearer)供面板「📱 远程访问」卡使用
7
+ // 另代理企业端一次性访问密钥 / 授权设备(/api/auth-key、/api/mobile-sessions、…/revoke,Bearer)供面板「📱 远程访问」卡使用
9
8
  // - 自管理 self*(版本可见 / 新版检测 / 一键在线更新 / 彻底卸载):插件市场没有更新卸载按钮,
10
9
  // 面板内即官方管理入口;更新=后台 npx 按 dist-tag(默认 latest,DSH_UPDATE_TAG 可切 beta/alpha)(幂等补齐运行环境并重启 bridge);
11
10
  // 彻底卸载=profile 插件清理(uninstallSelf)+ 运行时清理(uninstallRuntime:停 bridge 自启动 /
@@ -763,13 +762,13 @@ async function proxyCreateAccessKey(relayDir, res) {
763
762
  }
764
763
 
765
764
  /**
766
- * GET /dsh-remote/mobile-sessions → 已授权设备列表(企业端 GET /api/mobile-sessions,Bearer)。
765
+ * GET /dsh-remote/mobile-sessions → 已授权设备列表(企业端 POST /api/mobile-sessions,Bearer)。
767
766
  */
768
767
  async function proxyMobileSessions(relayDir, res) {
769
768
  const token = await relayToken(relayDir).catch(() => "");
770
769
  if (!token) return sendJson(res, 401, notLoggedInJson());
771
770
  const r = await relayFetch(relayDir, "/api/mobile-sessions", {
772
- method: "GET",
771
+ method: "POST",
773
772
  headers: { authorization: `Bearer ${token}` },
774
773
  });
775
774
  const d = flattenRelayBody(r);
@@ -803,78 +802,8 @@ async function proxyRevokeMobileSession(relayDir, req, res) {
803
802
  return sendJson(res, 200, { ok: true, relayStatus: (r && r.status) || 200 });
804
803
  }
805
804
 
806
- /**
807
- * DELETE /dsh-remote/mobile-sessions/delete(body {id})→ 删除本机该设备的授权记录
808
- * (企业端 DELETE /api/mobile-sessions/:id,Bearer:本人整行删除并拉黑 jti)。
809
- */
810
- async function proxyDeleteMobileSession(relayDir, req, res) {
811
- const body = await readJsonBody(req);
812
- if (body.__parseError) return sendJson(res, 400, { ok: false, error: "JSON 解析失败" });
813
- const id = String(body.id ?? "").trim();
814
- if (!id) return sendJson(res, 400, { ok: false, error: "缺少参数 id(会话 ID)" });
815
- const token = await relayToken(relayDir).catch(() => "");
816
- if (!token) return sendJson(res, 401, notLoggedInJson());
817
- const r = await relayFetch(relayDir, `/api/mobile-sessions/${encodeURIComponent(id)}`, {
818
- method: "DELETE",
819
- headers: { authorization: `Bearer ${token}` },
820
- });
821
- const d = flattenRelayBody(r);
822
- const ok = !!(r.ok && d.ok !== false);
823
- if (!ok) {
824
- return sendJson(res, (r && r.status) || 502, { ok: false, error: relayErrorMessage(r), relayStatus: (r && r.status) || 0 });
825
- }
826
- return sendJson(res, 200, { ok: true, relayStatus: (r && r.status) || 200 });
827
- }
828
-
829
- /**
830
- * POST /dsh-remote/mobile-sessions/purge → 清理本人所有已解绑(revoked)记录
831
- * (企业端 POST /api/mobile-sessions/purge,Bearer)。
832
- */
833
- async function proxyPurgeMobileSessions(relayDir, res) {
834
- const token = await relayToken(relayDir).catch(() => "");
835
- if (!token) return sendJson(res, 401, notLoggedInJson());
836
- const r = await relayFetch(relayDir, "/api/mobile-sessions/purge", {
837
- method: "POST",
838
- headers: { authorization: `Bearer ${token}` },
839
- });
840
- const d = flattenRelayBody(r);
841
- const ok = !!(r.ok && d.ok !== false);
842
- if (!ok) {
843
- return sendJson(res, (r && r.status) || 502, { ok: false, error: relayErrorMessage(r), relayStatus: (r && r.status) || 0 });
844
- }
845
- const removed = Number.isInteger(d.removed) ? d.removed : null;
846
- const payload = { ok: true, relayStatus: (r && r.status) || 200 };
847
- if (removed !== null) payload.removed = removed;
848
- return sendJson(res, 200, payload);
849
- }
850
-
851
805
  // ---------- 综合状态 ----------
852
806
 
853
- /**
854
- * 读取 bridge 写入的 E2EE 开关状态(<relayDir>/.e2ee-state.json,写入方见
855
- * clients/dsh-remote/e2ee-client.mjs 的 writeE2eeStateFile:{enabled, reason, profile, epoch, caps, at})。
856
- * 归一化后只透出固定展示字段(enabled/reason/profile/epoch/caps),at 等调试字段不外泄;
857
- * 文件缺失/损坏一律视为「未启用(明文回退)」——面板据此显示普通安全连接。
858
- */
859
- export function readE2eeStateFile(relayDir) {
860
- const none = () => ({ enabled: false, reason: "no_state_file", profile: "", epoch: 0, caps: [] });
861
- try {
862
- const f = join(relayDir, ".e2ee-state.json");
863
- if (!existsSync(f)) return none();
864
- const s = JSON.parse(readFileSync(f, "utf8"));
865
- if (!s || typeof s !== "object") return none();
866
- return {
867
- enabled: s.enabled === true,
868
- reason: typeof s.reason === "string" && s.reason ? s.reason : "",
869
- profile: typeof s.profile === "string" ? s.profile : "",
870
- epoch: Number.isInteger(s.epoch) && s.epoch >= 0 ? s.epoch : 0,
871
- caps: Array.isArray(s.caps) ? s.caps.filter((c) => typeof c === "string") : [],
872
- };
873
- } catch {
874
- return none();
875
- }
876
- }
877
-
878
807
  async function composeStatus(relayDir) {
879
808
  const cfg = loadConfig(relayDir);
880
809
  const launchd = launchdStatus();
@@ -909,9 +838,6 @@ async function composeStatus(relayDir) {
909
838
  manual,
910
839
  running: launchd.running || manual.bridge.length > 0,
911
840
  bindError,
912
- // E2EE 开关状态(bridge 写 .e2ee-state.json;文件缺失 = 未启用明文):
913
- // {enabled, reason, profile, epoch, caps},供面板「📱 远程访问」卡展示加密状态。
914
- e2ee: readE2eeStateFile(relayDir),
915
841
  },
916
842
  host: hostname(),
917
843
  };
@@ -1020,7 +946,7 @@ const PLUGIN_ID = "dsh-remote-web";
1020
946
  const PLUGIN_LEGACY_IDS = ["dsh-remote-ui"];
1021
947
  const PLUGIN_ALL_IDS = [PLUGIN_ID, ...PLUGIN_LEGACY_IDS];
1022
948
  /** 插件自身发布版本(与 dsh-remote 根包同步递增)。 */
1023
- const PLUGIN_VERSION = "0.6.0-beta.11";
949
+ const PLUGIN_VERSION = "0.6.0-beta.2";
1024
950
  const UPDATE_LOG = ".dsh-update.log";
1025
951
  const UPDATE_MARKER = ".dsh-update-running";
1026
952
 
@@ -1296,22 +1222,6 @@ function registerRoutes(ctx, relayDir) {
1296
1222
  await proxyRevokeMobileSession(relayDir, req, res);
1297
1223
  },
1298
1224
  },
1299
- // 删除已授权设备记录(整行删除并拉黑 jti;企业端 DELETE /api/mobile-sessions/:id,Bearer)
1300
- {
1301
- method: "DELETE",
1302
- path: "/dsh-remote/mobile-sessions/delete",
1303
- handler: async (req, res) => {
1304
- await proxyDeleteMobileSession(relayDir, req, res);
1305
- },
1306
- },
1307
- // 清理本人全部已解绑(revoked)记录(企业端 POST /api/mobile-sessions/purge,Bearer)
1308
- {
1309
- method: "POST",
1310
- path: "/dsh-remote/mobile-sessions/purge",
1311
- handler: async (_req, res) => {
1312
- await proxyPurgeMobileSessions(relayDir, res);
1313
- },
1314
- },
1315
1225
  {
1316
1226
  method: "POST",
1317
1227
  path: "/dsh-remote/config",
@@ -1391,27 +1301,6 @@ function registerRoutes(ctx, relayDir) {
1391
1301
  sendJson(res, r.status || 502, { ok: r.ok, status: r.status, body: r.body });
1392
1302
  },
1393
1303
  },
1394
- {
1395
- method: "POST",
1396
- path: "/dsh-remote/password/reset",
1397
- handler: async (req, res) => {
1398
- // 公开 POST /api/password/reset(无需 Bearer):短信验证码重置密码。
1399
- // 成功 → 企业端使该账号全部授权设备/会话失效(含 E2EE 派生口令)。
1400
- const body = await readJsonBody(req);
1401
- if (body.__parseError) return sendJson(res, 400, { ok: false, error: "JSON 解析失败" });
1402
- const phone = String(body.phone ?? "").trim();
1403
- const smsCode = String(body.sms_code ?? "").trim();
1404
- const newPassword = String(body.new_password ?? body.password ?? "");
1405
- if (!phone || !smsCode || !newPassword) return sendJson(res, 400, { ok: false, error: "手机号、短信验证码与新密码必填" });
1406
- const r = await relayFetch(relayDir, "/api/password/reset", {
1407
- method: "POST",
1408
- headers: { "content-type": "application/json" },
1409
- body: JSON.stringify({ phone, sms_code: smsCode, new_password: newPassword }),
1410
- });
1411
- // 透传企业端 ok/error(成功 200 {ok:true};失败保留 status 与错误体)
1412
- sendJson(res, r.status || 502, { ok: r.ok, status: r.status, body: r.body });
1413
- },
1414
- },
1415
1304
  {
1416
1305
  method: "GET",
1417
1306
  path: "/dsh-remote/captcha",
@@ -1,7 +1,5 @@
1
1
  // 插件 node 半新增代理回归:/dsh-remote/access-key | mobile-sessions | mobile-sessions/revoke
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)
2
+ // (企业端 E1 契约:POST /api/auth-key、POST /api/mobile-sessions、POST /api/mobile-sessions/:id/revoke,Bearer device-login JWT)
5
3
  // 覆盖:Bearer 透传、字段扁平化、未登录 401、qr 缺失容错、上游错误透传。
6
4
  import assert from "node:assert/strict";
7
5
  import http from "node:http";
@@ -32,7 +30,7 @@ function startFakeRelay(opts = {}) {
32
30
  if (!opts.noQr) body.qr_data_url = QR;
33
31
  return send(200, body);
34
32
  }
35
- if ((req.method === "GET" || req.method === "POST") && url.pathname === "/api/mobile-sessions") {
33
+ if (req.method === "POST" && url.pathname === "/api/mobile-sessions") {
36
34
  return send(200, {
37
35
  ok: true,
38
36
  sessions: [
@@ -43,9 +41,6 @@ function startFakeRelay(opts = {}) {
43
41
  }
44
42
  if (req.method === "POST" && url.pathname === "/api/mobile-sessions/ms_1/revoke") return send(200, { ok: true });
45
43
  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 });
49
44
  send(404, { error: { code: "not_found" } });
50
45
  });
51
46
  return new Promise((resolve) => srv.listen(0, "127.0.0.1", () => resolve({ srv, seen, port: srv.address().port })));
@@ -122,7 +117,7 @@ test("mobile-sessions 路由:列表透传,Bearer 已带上", async () => {
122
117
  assert.equal(r.sessions[0].label, "iPhone 15");
123
118
  assert.equal(r.sessions[0].os, "iOS");
124
119
  const up = relay.seen.find((s) => s.path === "/api/mobile-sessions");
125
- assert.equal(up.method, "GET"); // 列表为 GET;POST 仅用于 auth-key/revoke
120
+ assert.equal(up.method, "POST");
126
121
  assert.equal(up.authorization, "Bearer jwt-abc");
127
122
  } finally {
128
123
  host.close();
@@ -160,66 +155,7 @@ test("mobile-sessions/revoke 路由:body {id} 转发到 /api/mobile-sessions/:
160
155
  }
161
156
  });
162
157
 
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 () => {
158
+ test("未登录(无账号配置)→ 三条路由统一 401 提示登录,不请求企业端", async () => {
223
159
  const relay = await startFakeRelay();
224
160
  const tempDir = await mkdtemp(path.join(os.tmpdir(), "dsh-aks-401-"));
225
161
  await writeFile(path.join(tempDir, ".dsh-config.json"), JSON.stringify({ api_url: `http://127.0.0.1:${relay.port}` }));
@@ -243,19 +179,13 @@ test("未登录(无账号配置)→ 五条路由统一 401 提示登录,
243
179
  assert.equal((await (await fetch(`${base}${p}`, { method }))).status, 401, `${p} 应返回 401`);
244
180
  assert.match(String(r.error), /尚未登录/);
245
181
  }
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
- }
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), /尚未登录/);
259
189
  assert.ok(!relay.seen.some((s) => s.path.startsWith("/api/auth-key") || s.path.startsWith("/api/mobile-sessions")), "未登录不应请求企业端");
260
190
  } finally {
261
191
  host.close();
@@ -6,7 +6,7 @@ import path from "node:path";
6
6
  import test from "node:test";
7
7
  import { apply } from "../lib/index.js";
8
8
 
9
- test("切换账号时轮换设备身份并重启 bridge", { skip: process.platform !== "darwin" }, async () => {
9
+ test("切换账号时轮换设备身份并重启 bridge", async () => {
10
10
  const tempDir = await mkdtemp(path.join(os.tmpdir(), "dsh-account-switch-"));
11
11
  const fakeHome = path.join(tempDir, "home");
12
12
  const fakeBin = path.join(tempDir, "bin");
@@ -22,12 +22,6 @@ 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
-
31
25
  /** 文本子节点包含子串(用于动态拼接文案断言)。 */
32
26
  function textHas(tree, substr) {
33
27
  return !!find(tree, (node) => (node.children || []).some((c) => typeof c === "string" && c.includes(substr)));
@@ -114,19 +108,11 @@ function loadPlugin(opts = {}) {
114
108
  }
115
109
  if (path === "/dsh-remote/mobile-sessions") {
116
110
  sessionsCalls++;
117
- // 默认:首次给列表,后续刷新给空(模拟已全部清理);测试可传 opts.sessions 固定列表
118
- const list = opts.sessions !== undefined ? opts.sessions : (sessionsCalls === 1 ? SESSIONS_1 : []);
119
- return response(200, { ok: true, sessions: list });
111
+ return response(200, { ok: true, sessions: sessionsCalls === 1 ? SESSIONS_1 : [] }); // 取消配对后的刷新 → 空列表
120
112
  }
121
113
  if (path === "/dsh-remote/mobile-sessions/revoke") {
122
114
  return response(200, { ok: true });
123
115
  }
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
- }
130
116
  if (path === "/dsh-remote/account") return response(200, { ok: true, account: { phone: "13800000000", plan: "free", plan_source: "plan", invite_code: "ABC12345" } });
131
117
  return response(200, { ok: true });
132
118
  },
@@ -186,7 +172,7 @@ test("📱 远程访问卡:bridge 在线 → 绿点文案;登录态点「生
186
172
  assert.ok(qr && qr.props?.alt === "远程访问二维码", "应渲染服务端返回的二维码 <img>");
187
173
  // 到期倒计时/有效至文案
188
174
  assert.ok(textHas(tree, "有效至") && textHas(tree, "剩余"), "应显示「有效至 HH:MM:SS / 剩余 xx:xx」倒计时");
189
- assert.ok(textHas(tree, "用一次即失效"), "应说明一次性/30 分钟语义(精简一句)");
175
+ assert.ok(textHas(tree, "访问一次后失效"), "应说明一次性/30 分钟语义");
190
176
  });
191
177
 
192
178
  test("已授权设备:展开列表(label/os/browser/时间)→ 取消配对二次确认 → 成功提示并刷新", async () => {
@@ -230,76 +216,6 @@ test("已授权设备:展开列表(label/os/browser/时间)→ 取消配
230
216
  assert.ok(textHas(tree, "暂无已授权设备(手机扫码后出现)"), "刷新后空态文案应出现");
231
217
  });
232
218
 
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
-
303
219
  test("升级/续费按钮:点击 → GET /dsh-remote/access-key → window.open(url)(带登录态打开)", async () => {
304
220
  const plugin = loadPlugin();
305
221
  plugin.states[0] = { config: { phone: "13800000000", deviceId: "dev-x" }, service: { running: true } };
@@ -334,92 +250,4 @@ test("二维码缺失容错 + 未登录引导文案(源码级约束)", () =>
334
250
  assert.match(SOURCE, /已连接(可远程访问)/);
335
251
  assert.match(SOURCE, /带登录态/);
336
252
  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/);
425
253
  });
@@ -1,6 +1,6 @@
1
1
  // dsh-remote-web 浏览器半回归(2026-09 前名 dsh-remote-ui):面板入口迁移进「设置」页 settings.section 官方扩展点。
2
- // 覆盖:栏目注册(id/order/label)、侧边栏入口与浮动面板移除(不再向官方侧栏注入任何按钮/点击机器)、
3
- // 账号区无「切换账号」、「关于 dsh-remote」说明卡片、退出登录/切换连接账号清理反馈线程凭据、首次安装红点引导。
2
+ // 覆盖:栏目注册(id/order/label)、侧边栏入口与浮动面板移除、账号区无「切换账号」、
3
+ // 「关于 dsh-remote」说明卡片、退出登录/切换连接账号清理反馈线程凭据、首次安装红点引导。
4
4
  import assert from "node:assert/strict";
5
5
  import { readFileSync } from "node:fs";
6
6
  import test from "node:test";
@@ -87,7 +87,6 @@ function loadPlugin(opts = {}) {
87
87
  });
88
88
 
89
89
  const allCreated = [];
90
- const intervals = [];
91
90
  const doc = {
92
91
  createElement(tag) { const el = makeEl(tag); allCreated.push(el); return el; },
93
92
  head: makeEl("head"),
@@ -96,10 +95,7 @@ function loadPlugin(opts = {}) {
96
95
  const cls = sel.charAt(0) === "." ? sel.slice(1) : "";
97
96
  return allCreated.find((el) => el.className === cls) || null;
98
97
  },
99
- querySelectorAll(sel) {
100
- if (sel === "button") return opts.navCells || [];
101
- return [];
102
- },
98
+ querySelectorAll(sel) { return sel === "button" ? (opts.navCells || []) : []; },
103
99
  };
104
100
 
105
101
  let lastObserver = null;
@@ -133,7 +129,7 @@ function loadPlugin(opts = {}) {
133
129
  return response(200, { body: { ok: true } });
134
130
  },
135
131
  navigator: { clipboard: { writeText: async () => {} } },
136
- setInterval(cb, ms) { intervals.push({ cb, ms }); return intervals.length; },
132
+ setInterval() { return 1; },
137
133
  clearInterval() {},
138
134
  setTimeout() { return 1; },
139
135
  Set,
@@ -156,7 +152,6 @@ function loadPlugin(opts = {}) {
156
152
 
157
153
  return {
158
154
  registered, metas, injects, requests, removed, states, localStorage,
159
- intervals,
160
155
  lastObserver: () => lastObserver,
161
156
  renderSection() { hook = 0; return registered.get("dsh-remote")({ close() {} }); },
162
157
  };
@@ -208,7 +203,7 @@ test("登录态账号区:无「切换账号」,有「退出登录」,头
208
203
  "📱 远程访问:用手机或另一台电脑的浏览器,随时随地使用同一份 dsh web——人在哪都能用(免公网 IP、免内网穿透);官方托管中继,4G/5G 即用,也可自建服务。",
209
204
  "🛠 电脑端一键安装:bridge 与「远程访问」面板一次到位——云端/自建切换、账号登录、bridge 启停、一次性扫码访问、已授权设备管理、意见反馈都在这里。",
210
205
  "🔒 安全与通道:HTTP / WebSocket 全量透传,一次性访问密钥认证,面板实时显示设备与已授权设备列表;服务端可配置流量配额。",
211
- "🛡 端到端加密:手机↔电脑之间的消息内容用「你的账号密码派生密钥」端到端加密——密钥与密码不落服务端(仅存校验值),中继只可见路径/大小/时间(详见 README「安全与隐私」)。",
206
+ "🛡 端到端流量保护:可选对通道做端到端加密保护,传输全程不暴露本机公网 IP(详见项目 README「安全」说明)。",
212
207
  ];
213
208
  for (const p of points) {
214
209
  assert.ok(find(tree, (n) => n.children?.includes(p)), `说明卡片应含要点: ${p.slice(0, 12)}…`);
@@ -282,51 +277,6 @@ test("红点已看过(localStorage 有 key)时不注入,重启 DSH Web 不
282
277
  assert.equal(navCell.children.length, 0, "已看过时不应再注入红点");
283
278
  });
284
279
 
285
- test("红点只挂在设置页「远程访问」栏目导航(navCell):普通按钮即使文本含「远程访问」也不注入,不遮挡官方入口", () => {
286
- const navCells = [];
287
- const officialCell = makeEl("button");
288
- officialCell.className = "VOzbGW_navCell"; // shell hashed 类名(含 navCell 子串)=设置页栏目导航
289
- officialCell.textContent = "📱 远程访问";
290
- const plainBtn = makeEl("button");
291
- plainBtn.className = "some-other-btn"; // 非 navCell(官方侧栏/内容区按钮):即使含「远程访问」也不得注入
292
- plainBtn.textContent = "远程访问";
293
- const plugin = loadPlugin({ navCells });
294
-
295
- navCells.push(officialCell, plainBtn);
296
- plugin.lastObserver().cb([]);
297
-
298
- assert.equal(officialCell.children.length, 1, "官方设置页「远程访问」栏目按钮应注入红点");
299
- assert.equal(officialCell.children[0].className, "dru-reddot");
300
- assert.equal(plainBtn.children.length, 0, "非栏目导航按钮不得注入红点(不遮挡官方按钮)");
301
- });
302
-
303
- // ---------- 入口收敛:不再向官方侧栏注入任何按钮 / 导航点击机器 ----------
304
- // 用户从官方「设置」进入 → 点 settings.section「远程访问」栏目(官方扩展点,天然不冲突),
305
- // 官方「设置」按钮保持原样、不被遮挡。旧 Bug A 侧栏注入实现(容器限定点击流 + 独立按钮 +
306
- // 红点 + openRemoteSettings/__dshRemoteNav)已整段移除,红点只保留在设置页栏目导航 navCell 上。
307
-
308
- test("源码约束:侧栏注入按钮/导航点击机器已移除,官方「设置」不被抢、红点仅保留在设置页栏目导航", () => {
309
- // 不再存在:侧栏 DOM 注入 / 导航容器点击 / 暴露给宿主与自动化的 open 入口
310
- assert.doesNotMatch(SOURCE, /injectSidebarRemoteEntry/);
311
- assert.doesNotMatch(SOURCE, /dru-nav-remote/);
312
- assert.doesNotMatch(SOURCE, /dsh-remote-nav-seen/);
313
- assert.doesNotMatch(SOURCE, /openRemoteSettings/);
314
- assert.doesNotMatch(SOURCE, /clickNavToken/);
315
- assert.doesNotMatch(SOURCE, /remoteSectionVisible/);
316
- assert.doesNotMatch(SOURCE, /__dshRemoteNav/);
317
- assert.doesNotMatch(SOURCE, /data-dru-remote/);
318
- assert.doesNotMatch(SOURCE, /NAV_ENTRY_ID/);
319
- assert.doesNotMatch(SOURCE, /NAV_CONTAINER_SEL/);
320
- assert.doesNotMatch(SOURCE, /NAV_TEXT_MAX/);
321
- assert.doesNotMatch(SOURCE, /dru-fab/);
322
- // 官方按钮不被克隆 / 包装 / 挪位(克隆曾继承官方事件委托与热区 → Bug A)
323
- assert.doesNotMatch(SOURCE, /cloneNode/);
324
- // 唯一保留的 DOM 注入 = 设置页栏目导航红点(.dru-reddot,navCell 右上角,pointer-events:none)
325
- assert.match(SOURCE, /dru-reddot/);
326
- assert.match(SOURCE, /navCell/);
327
- assert.match(SOURCE, /dsh-remote-seen-dot/);
328
- });
329
-
330
280
  test("源码约束:无侧边栏入口/浮动面板/切换账号;命名统一为「远程访问」;新增访问密钥/设备路由", () => {
331
281
  // 只断言“代码形态”不存在(注释里允许出现说明文字)
332
282
  assert.doesNotMatch(SOURCE, /slots\.inject\("sidebar\.footer\.action"/);
@@ -77,7 +77,7 @@ async function plantInstallation(tempHome, opts = {}) {
77
77
  return { relayDir, profile };
78
78
  }
79
79
 
80
- test("彻底卸载:配置目录整目录清空(含账号/密钥/固化运行时),不误伤 profile 其它条目与家目录其它数据", { skip: process.platform !== "darwin" }, async () => {
80
+ test("彻底卸载:配置目录整目录清空(含账号/密钥/固化运行时),不误伤 profile 其它条目与家目录其它数据", async () => {
81
81
  const tempHome = await mkdtemp(path.join(os.tmpdir(), "dsh-ui-rt-"));
82
82
  const prevHome = process.env.HOME;
83
83
  process.env.HOME = tempHome;
@@ -220,7 +220,7 @@ test("DSH_RELAY_SKIP_SERVICE 开关:置位时即便存在自启动 plist 也
220
220
  }
221
221
  });
222
222
 
223
- test("自然跳过(无开关):HOME 有 plist 但服务未运行 → 删除 plist 但不发 launchctl bootout(PATH 假命令接管)", { skip: process.platform !== "darwin" }, async () => {
223
+ test("自然跳过(无开关):HOME 有 plist 但服务未运行 → 删除 plist 但不发 launchctl bootout(PATH 假命令接管)", async () => {
224
224
  const tempHome = await mkdtemp(path.join(os.tmpdir(), "dsh-ui-rt-natural-"));
225
225
  const prevHome = process.env.HOME;
226
226
  const prevPath = process.env.PATH;