@mrrisega/dsh-remote 0.6.0-beta.8 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mrrisega/dsh-remote",
3
- "version": "0.6.0-beta.8",
3
+ "version": "0.6.0-beta.9",
4
4
  "description": "手机远程控制 DeepSeek Harness · Remote control DeepSeek Harness (dsh web) from any phone browser — 100% 全功能 App 级体验:发消息、看工具执行、审批权限、改设置、管凭据,含特权操作,免内网穿透。一条命令安装 npx @mrrisega/dsh-remote。Mobile remote control for DSH, self-host or SaaS, no server needed on LAN.",
5
5
  "keywords": [
6
6
  "deepseek-harness",
@@ -159,55 +159,131 @@ window.__ModuleLoader__.load({
159
159
  // ── 侧边栏「远程访问」快捷入口(挂到官方设置按钮上方一行,用 dsh 自身的挂载位) ──
160
160
  // 不用 fixed 悬浮层(会遮挡官方按钮);改为在左侧主菜单的「设置」按钮上方克隆一行同款导航项:
161
161
  // 点击 = 打开 设置页 → 「远程访问」栏目;首次点击前该入口右上角带小红点(localStorage 一次)。
162
+ // 导航命中策略(兼容宿主差异):候选 = [class*=navCell] / [role=tab] / [role=menuitem] /
163
+ // [aria-label](纯图标项常只有 aria-label 可读);文本优先取 aria-label,再取 textContent,
164
+ // 因此 emoji/图标前缀(如 🖥/📱)不影响子串命中。命中「远程访问」栏目项后打 data-dru-remote
165
+ // 标记(同一设置页生命周期内二次命中直接走标记,无需重扫)。始终排除本插件注入的侧栏项
166
+ // (id=dru-nav-remote),避免点到自己造成递归。
162
167
  var NAV_SEEN_KEY = "dsh-remote-nav-seen";
163
- function clickTextNav(text, excludeId) {
168
+ var NAV_ENTRY_ID = "dru-nav-remote";
169
+ var NAV_MARK = "data-dru-remote";
170
+ var NAV_CAND_SEL = '[class*="navCell"], [role="tab"], [role="menuitem"], [aria-label]';
171
+ var NAV_ANY_SEL = 'button, a, [role="button"], [class*="nav"] button, [class*="sidebar"] a';
172
+
173
+ /** 元素导航文本:优先 aria-label(纯图标导航常见),再拼 textContent;去空白差异。 */
174
+ function navTextOf(el) {
175
+ var t = "";
176
+ try {
177
+ var al = el.getAttribute && el.getAttribute("aria-label");
178
+ if (al) t += " " + al;
179
+ } catch (e) {}
180
+ try { if (el.textContent) t += " " + el.textContent; } catch (e2) {}
181
+ return t.replace(/\s+/g, " ").trim();
182
+ }
183
+ /** 候选是否“可点导航项”:按钮/链接/tab/menuitem/navCell(排除无交互的装饰容器)。 */
184
+ function isNavClickable(el) {
185
+ try {
186
+ var tag = String(el.tagName || "").toLowerCase();
187
+ if (tag === "button" || tag === "a") return true;
188
+ var role = el.getAttribute && el.getAttribute("role") || "";
189
+ if (role === "tab" || role === "menuitem" || role === "button" || role === "link") return true;
190
+ return String(el.className || "").indexOf("navCell") !== -1;
191
+ } catch (e) { return false; }
192
+ }
193
+ /** 在候选列表里找文本含 token 的导航项并返回(命中「远程访问」时打标记)。 */
194
+ function pickNavHit(nodes, token, excludeId, preferMarked) {
164
195
  try {
165
- var nodes = document.querySelectorAll('button, [role="tab"], [role="menuitem"], [class*="navCell"], [class*="nav"], [class*="sidebar"] a, a');
166
196
  for (var i = 0; i < nodes.length; i++) {
167
197
  var el = nodes[i];
168
- if (excludeId && el.id === excludeId) continue;
169
- var t = (el.textContent || "").trim();
170
- // 含匹配(标签常带 emoji 前缀,如“📱 远程访问”);排除自身入口防递归
171
- if (t.indexOf(text) !== -1) {
172
- try { el.click(); return true; } catch (e) { /* 尝试下一个 */ }
173
- }
198
+ if (excludeId && el.id === excludeId) continue; // 排除注入项自身,防递归
199
+ if (!isNavClickable(el)) continue;
200
+ var marked = !!el.getAttribute && el.getAttribute(NAV_MARK) === "1";
201
+ if (preferMarked && !marked) continue;
202
+ if (!preferMarked && marked) continue;
203
+ if (navTextOf(el).indexOf(token) === -1) continue;
204
+ if (token === "远程访问") { try { el.setAttribute(NAV_MARK, "1"); } catch (e) {} }
205
+ return el;
174
206
  }
175
207
  } catch (e) { /* 忽略 */ }
208
+ return null;
209
+ }
210
+ /**
211
+ * 点击“文本含 token”的导航项:① 已打 data-dru-remote 标记的(远程栏目)优先;
212
+ * ② [class*=navCell]/[role=tab]/[role=menuitem]/[aria-label] 语义候选;
213
+ * ③ 任意按钮/链接兜底。全部失败返回 false(调用方决定是否退化进「设置」)。
214
+ */
215
+ function clickNavToken(token, excludeId) {
216
+ try {
217
+ if (token === "远程访问") {
218
+ var all = document.querySelectorAll(NAV_CAND_SEL + ", " + NAV_ANY_SEL);
219
+ var hit = pickNavHit(all, token, excludeId, true);
220
+ if (hit) { try { hit.click(); } catch (e) {} return true; }
221
+ }
222
+ var sem = document.querySelectorAll(NAV_CAND_SEL);
223
+ var hit2 = pickNavHit(sem, token, excludeId, false);
224
+ if (hit2) { try { hit2.click(); } catch (e) {} return true; }
225
+ var any = document.querySelectorAll(NAV_ANY_SEL);
226
+ var hit3 = pickNavHit(any, token, excludeId, false);
227
+ if (hit3) { try { hit3.click(); } catch (e) {} return true; }
228
+ } catch (e) { /* 忽略 */ }
176
229
  return false;
177
230
  }
231
+ /** 「远程访问」栏目内容区是否已在 DOM(设置页已选中该栏目)。 */
232
+ function remoteSectionVisible() {
233
+ try {
234
+ var el = document.querySelector(".dru-settings-section");
235
+ return !!el && (!document.body || document.body.contains(el));
236
+ } catch (e) { return false; }
237
+ }
238
+ /**
239
+ * 打开设置页「远程访问」栏目(侧栏注入项/面板内“去设置”等共用入口):
240
+ * 已在栏目内容区 → 不动;否则先找栏目项直接点;点不到 → 点「设置」进设置页,
241
+ * 再轮询(栏目项异步挂载)补点「远程访问」。全程排除注入项自身防递归。
242
+ */
178
243
  function openRemoteSettings() {
179
- if (clickTextNav("远程访问", "dru-nav-remote")) return; // 已在栏目内/直接点到(排除自身)
180
- if (clickTextNav("设置")) { /* 进入设置页后再轮询远程访问栏目 */ }
244
+ if (remoteSectionVisible()) return;
245
+ if (clickNavToken("远程访问", NAV_ENTRY_ID)) return;
246
+ clickNavToken("设置", NAV_ENTRY_ID); // 进设置页(导航文字可能不同名/纯图标,交给命中器)
181
247
  var tries = 0;
182
248
  var iv = setInterval(function () {
183
- if (clickTextNav("远程访问", "dru-nav-remote")) { clearInterval(iv); return; }
249
+ if (remoteSectionVisible() || clickNavToken("远程访问", NAV_ENTRY_ID)) { clearInterval(iv); return; }
184
250
  if (++tries > 40) clearInterval(iv);
185
251
  }, 150);
186
252
  if (typeof iv.unref === "function") iv.unref();
187
253
  }
254
+ // 暴露给宿主/自动化(同一入口,避免重复实现;测试沙箱经此驱动点击流)
255
+ try { window.__dshRemoteNav = { open: openRemoteSettings }; } catch (e) {}
256
+
188
257
  function injectSidebarRemoteEntry() {
189
258
  try {
190
- if (document.getElementById("dru-nav-remote")) return;
259
+ if (document.getElementById(NAV_ENTRY_ID)) return;
191
260
  if (!document.body) { setTimeout(injectSidebarRemoteEntry, 300); return; }
192
261
  var tries = 0;
193
262
  var iv = setInterval(function () {
194
263
  try {
195
- if (document.getElementById("dru-nav-remote")) { clearInterval(iv); return; }
196
- // 只在「设置页已打开/未打开都能出现」的左侧主导航找「设置」按钮;
197
- // 取文本以“设置”开头且最可能是菜单项(避免命中标题/弹层里的“设置”文字)
198
- var nodes = document.querySelectorAll('button, [role="menuitem"], a, [class*="navCell"]');
264
+ if (document.getElementById(NAV_ENTRY_ID)) { clearInterval(iv); return; }
265
+ // 在左侧主导航找「设置」按钮:语义候选优先,文本开头为“设置”或 aria-label 恰为“设置”
266
+ // (兼容文字不同名/带 emoji 图标的情况:能读到的标识仍是“设置”)。
267
+ var nodes = document.querySelectorAll(NAV_CAND_SEL + ", " + NAV_ANY_SEL);
199
268
  var settingsBtn = null;
200
269
  for (var i = 0; i < nodes.length; i++) {
201
270
  var el = nodes[i];
271
+ if (el.id === NAV_ENTRY_ID) continue;
272
+ if (!isNavClickable(el)) continue;
273
+ var label = (el.getAttribute && el.getAttribute("aria-label")) || "";
202
274
  var t = (el.textContent || "").trim();
203
- if (t.indexOf("设置") === 0 || t.indexOf("设置 ") === 0 || t === "设置") { settingsBtn = el; break; }
275
+ var hit = label === "设置" || label.indexOf("设置") === 0 ||
276
+ t === "设置" || t.indexOf("设置") === 0 || t.indexOf("设置 ") === 0;
277
+ if (!hit) continue;
278
+ settingsBtn = el;
279
+ break;
204
280
  }
205
281
  if (!settingsBtn || !settingsBtn.parentNode) {
206
282
  if (++tries > 80) clearInterval(iv);
207
283
  return;
208
284
  }
209
285
  var entry = settingsBtn.cloneNode(false);
210
- entry.id = "dru-nav-remote";
286
+ entry.id = NAV_ENTRY_ID;
211
287
  entry.removeAttribute("data-view");
212
288
  entry.removeAttribute("href");
213
289
  entry.textContent = "";
@@ -983,7 +1059,7 @@ window.__ModuleLoader__.load({
983
1059
  selfMsg ? h("div", { className: "dru-msg dru-msg-" + selfMsg.kind }, selfMsg.text) : null,
984
1060
  h("div", { className: "dru-hint", style: { marginTop: 8 } },
985
1061
  armed ? "⚠ 再次点击后即开始彻底卸载:① 移除 dsh web 配置中的插件引用与本地文件;② 停止并移除 bridge 自启动服务(macOS com.dshremote.bridge / Linux dsh-bridge)并结束残留进程;③ 清空本地配置目录(~/.dsh-remote:账号、设备密钥、固化运行时等)。此操作不可撤销,如需再次使用请在插件市场重新安装。" :
986
- "插件市场没有更新/卸载按钮(dsh 官方市场暂不提供),本卡片即官方管理入口:检测新版、一键在线更新、彻底卸载都在这里完成。")
1062
+ "检测新版、一键在线更新、彻底卸载都在本卡片完成。")
987
1063
  );
988
1064
  }
989
1065
 
@@ -992,7 +1068,7 @@ window.__ModuleLoader__.load({
992
1068
  // 见 clients/dsh-remote/e2ee-client.mjs),node 半随 /dsh-remote/status 以 service.e2ee 下发;
993
1069
  // 此处只做“可读文案”映射(协议 docs/e2ee-protocol.md §2.3/§7.3)。
994
1070
  var E2EE_DISABLED_COPY = {
995
- server_disabled: "等待服务端开启 E2EE(灰度中,当前为加密准备)",
1071
+ server_disabled: "端到端加密暂不可用(当前为普通安全连接 HTTPS)",
996
1072
  params_unreachable: "当前为普通安全连接(HTTPS)",
997
1073
  disabled_by_config: "当前为普通安全连接(HTTPS)",
998
1074
  derive_failed: "账号密码已变更,需在「🔑 账号」重新登录后恢复端到端加密(当前为普通安全连接(HTTPS))",
@@ -1054,7 +1130,9 @@ window.__ModuleLoader__.load({
1054
1130
  var devOpenArr = useState(false); var devOpen = devOpenArr[0]; var setDevOpen = devOpenArr[1];
1055
1131
  var devBusyArr = useState(""); var devBusy = devBusyArr[0]; var setDevBusy = devBusyArr[1];
1056
1132
  var devMsgArr = useState(null); var devMsg = devMsgArr[0]; var setDevMsg = devMsgArr[1];
1057
- var armedDevArr = useState(null); var armedDev = armedDevArr[0]; var setArmedDev = armedDevArr[1]; // 待二次确认的 session id
1133
+ var armedDevArr = useState(null); var armedDev = armedDevArr[0]; var setArmedDev = armedDevArr[1]; // 待二次确认的 session id(取消配对)
1134
+ var armedDelArr = useState(null); var armedDel = armedDelArr[0]; var setArmedDel = armedDelArr[1]; // 待二次确认的 session id(删除记录)
1135
+ var purgeArmedArr = useState(false); var purgeArmed = purgeArmedArr[0]; var setPurgeArmed = purgeArmedArr[1]; // 清理已解绑二次确认
1058
1136
 
1059
1137
  var refresh = useCallback(function () {
1060
1138
  setBusy("status");
@@ -1198,33 +1276,79 @@ window.__ModuleLoader__.load({
1198
1276
  setDevMsg({ kind: "err", text: "加载已授权设备失败:" + e.message });
1199
1277
  }).finally(function () { setDevBusy(""); });
1200
1278
  }
1279
+ /** 操作(取消配对/删除记录/清理已解绑)成功后静默重拉列表,覆盖行内状态。 */
1280
+ function refreshDeviceList() {
1281
+ api("/dsh-remote/mobile-sessions").then(function (b) {
1282
+ if (b && b.ok) setDevSessions(Array.isArray(b.sessions) ? b.sessions : []);
1283
+ }).catch(function () {});
1284
+ }
1201
1285
 
1202
1286
  var toggleDevices = function () {
1203
1287
  var next = !devOpen;
1204
1288
  setDevOpen(next);
1205
1289
  if (next && devSessions === null && devBusy === "") loadDevices();
1206
- if (!next) setArmedDev(null);
1290
+ if (!next) { setArmedDev(null); setArmedDel(null); setPurgeArmed(false); }
1207
1291
  };
1208
1292
 
1209
1293
  /** 取消配对:先点一次进入确认态,再点一次才 POST revoke(同 SelfManageCard 二次确认风格)。 */
1210
1294
  var doRevokeDevice = function (id) {
1211
1295
  if (!id) return;
1212
- if (armedDev !== id) { setArmedDev(id); return; }
1296
+ if (armedDev !== id) { setArmedDev(id); setArmedDel(null); setPurgeArmed(false); return; }
1213
1297
  setDevBusy("revoke:" + id);
1214
1298
  post("/dsh-remote/mobile-sessions/revoke", { id: id }).then(function (b) {
1215
1299
  if (!b || !b.ok) throw new Error((b && (b.error || (b.body && b.body.error))) || "取消失败");
1216
1300
  setArmedDev(null);
1217
1301
  setDevMsg({ kind: "ok", text: "已取消,对方需重新扫码/登录" });
1218
- // 刷新列表(成功即重拉,行内状态随后由列表覆盖)
1219
- api("/dsh-remote/mobile-sessions").then(function (lb) {
1220
- if (lb && lb.ok) setDevSessions(Array.isArray(lb.sessions) ? lb.sessions : []);
1221
- }).catch(function () {});
1302
+ refreshDeviceList();
1222
1303
  }).catch(function (e) {
1223
1304
  setArmedDev(null);
1224
1305
  setDevMsg({ kind: "err", text: "取消配对失败:" + e.message });
1225
1306
  }).finally(function () { setDevBusy(""); });
1226
1307
  };
1227
1308
 
1309
+ /**
1310
+ * 删除设备记录:任意行(含已取消/历史)都可用,整行删除并拉黑 jti——
1311
+ * DELETE /dsh-remote/mobile-sessions/delete(body {id} → 企业端 DELETE /api/mobile-sessions/:id)。
1312
+ * 先点一次进入确认态,再点一次才发请求。
1313
+ */
1314
+ var doDeleteDevice = function (id) {
1315
+ if (!id) return;
1316
+ if (armedDel !== id) { setArmedDel(id); setArmedDev(null); setPurgeArmed(false); return; }
1317
+ setDevBusy("delete:" + id);
1318
+ api("/dsh-remote/mobile-sessions/delete", {
1319
+ method: "DELETE",
1320
+ headers: { "content-type": "application/json" },
1321
+ body: JSON.stringify({ id: id })
1322
+ }).then(function (b) {
1323
+ if (!b || !b.ok) throw new Error((b && (b.error || (b.body && b.body.error))) || "删除失败");
1324
+ setArmedDel(null);
1325
+ setDevMsg({ kind: "ok", text: "已删除该设备的记录" });
1326
+ refreshDeviceList();
1327
+ }).catch(function (e) {
1328
+ setArmedDel(null);
1329
+ setDevMsg({ kind: "err", text: "删除记录失败:" + e.message });
1330
+ }).finally(function () { setDevBusy(""); });
1331
+ };
1332
+
1333
+ /**
1334
+ * 清理已解绑:删除本人全部 revoked 行——POST /dsh-remote/mobile-sessions/purge(企业端 purge)。
1335
+ * 先点一次进入确认态,再点一次才发请求;成功后刷新列表并提示清理条数。
1336
+ */
1337
+ var doPurgeDevices = function () {
1338
+ if (!purgeArmed) { setPurgeArmed(true); setArmedDev(null); setArmedDel(null); return; }
1339
+ setDevBusy("purge");
1340
+ post("/dsh-remote/mobile-sessions/purge", {}).then(function (b) {
1341
+ if (!b || !b.ok) throw new Error((b && (b.error || (b.body && b.body.error))) || "清理失败");
1342
+ setPurgeArmed(false);
1343
+ var n = b.removed != null ? Number(b.removed) : 0;
1344
+ setDevMsg({ kind: "ok", text: n > 0 ? ("已清理 " + n + " 条已解绑记录") : "已清理全部已解绑记录" });
1345
+ refreshDeviceList();
1346
+ }).catch(function (e) {
1347
+ setPurgeArmed(false);
1348
+ setDevMsg({ kind: "err", text: "清理失败:" + e.message });
1349
+ }).finally(function () { setDevBusy(""); });
1350
+ };
1351
+
1228
1352
  /** 升级/续费带登录态打开:取一次性访问 url,改写为 /app/promo?auth=… 后在手机端进入续费页。 */
1229
1353
  var openUpgradeAuth = function () {
1230
1354
  setBusy("upgrade");
@@ -1501,8 +1625,8 @@ window.__ModuleLoader__.load({
1501
1625
  : serviceRunning ? "已连接(可远程访问)" : "等待设备连接";
1502
1626
  var dotCls = "dru-dot " + (loggedInSaaS && serviceRunning ? "dru-dot-on" : "dru-dot-off");
1503
1627
  // Phase-5:端到端加密(E2EE)状态行 —— 未登录/旧 host 未下发 e2ee 一律不渲染
1504
- // (桌面宽屏与手机镜像共用同一面板:纯文字状态行、不弹层不打扰);启用=绿点绿字,
1505
- // 未启用=灰字 + 原因映射(server_disabled 等待灰度开启 / 其余回退普通 HTTPS)。
1628
+ // (桌面宽屏与手机镜像共用同一面板:纯文字状态行、不弹层不打扰);启用=绿点绿字(🔒已启用),
1629
+ // 未启用=灰字 + 中性原因文案(回退普通 HTTPS 连接,不宣称“灰度等待”)。
1506
1630
  function renderE2eeBadge() {
1507
1631
  if (!loggedInSaaS) return null;
1508
1632
  var e = describeE2ee(st && st.service && st.service.e2ee);
@@ -1533,15 +1657,15 @@ window.__ModuleLoader__.load({
1533
1657
  ),
1534
1658
  h("div", { className: "dru-access-col" },
1535
1659
  h("div", { className: "dru-key-note" },
1536
- "扫码即进入远程访问;每次生成的链接 30 分钟有效、访问一次后失效,停留栏目期间会自动更新。"),
1660
+ "扫码即进入,30 分钟有效、用一次即失效。"),
1537
1661
  h("div", { className: "dru-actions", style: { marginTop: 2 } },
1538
1662
  h("button", { type: "button", className: "dru-btn dru-btn-primary", disabled: akeyBusy, onClick: openKeyUrl }, "直接打开"),
1539
1663
  h("button", { type: "button", className: "dru-btn dru-btn-ghost", disabled: akeyBusy, onClick: loadAccessKey }, akeyBusy ? "生成中…" : "刷新二维码/访问链接")
1540
1664
  ),
1541
1665
  h("div", { className: "dru-hint", style: { marginTop: 4 } },
1542
1666
  !serviceRunning
1543
- ? "本机 bridge 未运行:请先在下方「🖥 Bridge 服务」卡片启动,手机/另一台电脑才能连入本机。"
1544
- : "手机上打开链接点「进入」即可像在本机一样使用 dsh web。")
1667
+ ? "本机 bridge 未运行:先在下方「🖥 Bridge 服务」卡启动。"
1668
+ : "打开链接/扫码进入即登录态;同设备重复扫码只更新授权,不新增设备。")
1545
1669
  )
1546
1670
  )
1547
1671
  ]) : h("div", null, [
@@ -1557,7 +1681,7 @@ window.__ModuleLoader__.load({
1557
1681
  ]);
1558
1682
  }
1559
1683
 
1560
- // ---------- 📲 已授权设备卡(展开列表 + 二次确认取消配对) ----------
1684
+ // ---------- 📲 已授权设备卡(展开列表 + 行内 取消配对/删除记录 + 底部 清理已解绑) ----------
1561
1685
  function deviceLabel(s) {
1562
1686
  if (s && s.label) return String(s.label);
1563
1687
  var parts = [];
@@ -1578,10 +1702,12 @@ window.__ModuleLoader__.load({
1578
1702
  if (devSessions.length === 0) {
1579
1703
  return h("div", { className: "dru-fb-empty" }, "暂无已授权设备(手机扫码后出现)");
1580
1704
  }
1705
+ var revokedCount = devSessions.filter(function (s) { return !!(s && s.revoked_at); }).length;
1581
1706
  return h("div", null, [
1582
1707
  devSessions.map(function (s) {
1708
+ var id = s && s.id;
1583
1709
  var revoked = !!(s && s.revoked_at);
1584
- return h("div", { key: s && s.id, className: "dru-dev" },
1710
+ return h("div", { key: id, className: "dru-dev" },
1585
1711
  h("div", { className: "dru-dev-top" },
1586
1712
  h("span", { className: "dru-dev-name" }, deviceLabel(s)),
1587
1713
  deviceMeta(s) ? h("span", { className: "dru-dev-meta" }, deviceMeta(s)) : null,
@@ -1592,18 +1718,34 @@ window.__ModuleLoader__.load({
1592
1718
  (s && s.last_seen_at ? " · 最近活跃 " + fmtDT(s.last_seen_at) : "") +
1593
1719
  (revoked ? " · 取消于 " + fmtDT(s.revoked_at) : "")
1594
1720
  ),
1595
- revoked ? null : h("div", { className: "dru-actions", style: { marginTop: 8 } },
1596
- h("button", {
1721
+ h("div", { className: "dru-actions", style: { marginTop: 8 } },
1722
+ revoked ? null : h("button", {
1597
1723
  type: "button",
1598
1724
  className: "dru-btn dru-btn-danger",
1599
1725
  disabled: devBusy !== "",
1600
- onClick: function () { doRevokeDevice(s && s.id); }
1601
- }, devBusy === "revoke:" + (s && s.id) ? "取消中…" : armedDev === (s && s.id) ? "⚠ 再点一次确认取消配对" : "取消配对")
1726
+ onClick: function () { doRevokeDevice(id); }
1727
+ }, devBusy === "revoke:" + id ? "取消中…" : armedDev === id ? "⚠ 再点一次确认取消配对" : "取消配对"),
1728
+ h("button", {
1729
+ type: "button",
1730
+ className: "dru-btn dru-btn-ghost",
1731
+ style: { color: "#cf222e", borderColor: "#cf222e" },
1732
+ disabled: devBusy !== "",
1733
+ onClick: function () { doDeleteDevice(id); }
1734
+ }, devBusy === "delete:" + id ? "删除中…" : armedDel === id ? "⚠ 再点一次确认删除记录" : "删除记录")
1602
1735
  )
1603
1736
  );
1604
1737
  }),
1605
1738
  h("div", { className: "dru-hint", style: { marginTop: 4 } },
1606
- "取消配对后,对方需重新扫码/登录才能再次远程访问本机。")
1739
+ "取消配对后,对方需重新扫码/登录才能再次远程访问本机。"),
1740
+ h("div", { className: "dru-actions", style: { marginTop: 8, borderTop: "1px dashed #eaeef2", paddingTop: 8 } },
1741
+ h("button", {
1742
+ type: "button",
1743
+ className: "dru-btn dru-btn-ghost",
1744
+ disabled: devBusy !== "" || revokedCount === 0,
1745
+ onClick: doPurgeDevices,
1746
+ title: revokedCount > 0 ? ("清理 " + revokedCount + " 条已解绑记录") : "没有已解绑记录"
1747
+ }, devBusy === "purge" ? "清理中…" : purgeArmed ? "⚠ 再点一次确认清理已解绑" : "清理已解绑" + (revokedCount > 0 ? "(" + revokedCount + ")" : ""))
1748
+ )
1607
1749
  ]);
1608
1750
  }
1609
1751
  function renderDevicesCard() {
@@ -1727,7 +1869,7 @@ window.__ModuleLoader__.load({
1727
1869
  h("div", { className: "dru-hint", style: { marginBottom: 6 } }, "📱 远程访问:用手机或另一台电脑的浏览器,随时随地使用同一份 dsh web——人在哪都能用(免公网 IP、免内网穿透);官方托管中继,4G/5G 即用,也可自建服务。"),
1728
1870
  h("div", { className: "dru-hint", style: { marginBottom: 6 } }, "🛠 电脑端一键安装:bridge 与「远程访问」面板一次到位——云端/自建切换、账号登录、bridge 启停、一次性扫码访问、已授权设备管理、意见反馈都在这里。"),
1729
1871
  h("div", { className: "dru-hint", style: { marginBottom: 6 } }, "🔒 安全与通道:HTTP / WebSocket 全量透传,一次性访问密钥认证,面板实时显示设备与已授权设备列表;服务端可配置流量配额。"),
1730
- h("div", { className: "dru-hint" }, "🛡 端到端加密(灰度开启中):服务端开启后,手机↔电脑之间的消息内容用「你的账号密码派生密钥」端到端加密——密钥与密码不落服务端(仅存校验值),中继只可见路径/大小/时间(详见 README「安全与隐私」)。")
1872
+ h("div", { className: "dru-hint" }, "🛡 端到端加密:手机↔电脑之间的消息内容用「你的账号密码派生密钥」端到端加密——密钥与密码不落服务端(仅存校验值),中继只可见路径/大小/时间(详见 README「安全与隐私」)。")
1731
1873
  ]),
1732
1874
  // 版本与更新(自管理:检测新版 / 一键在线更新 / 彻底卸载)
1733
1875
  h(SelfManageCard, null),
@@ -4,7 +4,8 @@
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,Bearer)供面板「📱 远程访问」卡使用
7
+ // 另代理企业端一次性访问密钥 / 授权设备(/api/auth-key、/api/mobile-sessions、…/revoke
8
+ // DELETE …/:id、POST …/purge,Bearer)供面板「📱 远程访问」卡使用
8
9
  // - 自管理 self*(版本可见 / 新版检测 / 一键在线更新 / 彻底卸载):插件市场没有更新卸载按钮,
9
10
  // 面板内即官方管理入口;更新=后台 npx 按 dist-tag(默认 latest,DSH_UPDATE_TAG 可切 beta/alpha)(幂等补齐运行环境并重启 bridge);
10
11
  // 彻底卸载=profile 插件清理(uninstallSelf)+ 运行时清理(uninstallRuntime:停 bridge 自启动 /
@@ -802,6 +803,51 @@ async function proxyRevokeMobileSession(relayDir, req, res) {
802
803
  return sendJson(res, 200, { ok: true, relayStatus: (r && r.status) || 200 });
803
804
  }
804
805
 
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
+
805
851
  // ---------- 综合状态 ----------
806
852
 
807
853
  /**
@@ -974,7 +1020,7 @@ const PLUGIN_ID = "dsh-remote-web";
974
1020
  const PLUGIN_LEGACY_IDS = ["dsh-remote-ui"];
975
1021
  const PLUGIN_ALL_IDS = [PLUGIN_ID, ...PLUGIN_LEGACY_IDS];
976
1022
  /** 插件自身发布版本(与 dsh-remote 根包同步递增)。 */
977
- const PLUGIN_VERSION = "0.6.0-beta.8";
1023
+ const PLUGIN_VERSION = "0.6.0-beta.9";
978
1024
  const UPDATE_LOG = ".dsh-update.log";
979
1025
  const UPDATE_MARKER = ".dsh-update-running";
980
1026
 
@@ -1250,6 +1296,22 @@ function registerRoutes(ctx, relayDir) {
1250
1296
  await proxyRevokeMobileSession(relayDir, req, res);
1251
1297
  },
1252
1298
  },
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
+ },
1253
1315
  {
1254
1316
  method: "POST",
1255
1317
  path: "/dsh-remote/config",
@@ -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();
@@ -122,7 +122,7 @@ test("composeStatus:.e2ee-state.json 启用 → /dsh-remote/status 的 service
122
122
  enabled: true, reason: "ok", profile: "pbkdf2-sha256-600k", epoch: 2, caps: ["e2ee-v2"],
123
123
  });
124
124
 
125
- // 2) bridge 未启用(服务端灰度关)→ 原样上报 reason,供面板映射可读文案
125
+ // 2) bridge 未启用(服务端关闭/不支持)→ 原样上报 reason,供面板映射可读文案
126
126
  await writeFile(stateFile, JSON.stringify({ enabled: false, reason: "server_disabled", profile: "", epoch: 0, caps: [] }));
127
127
  status = await (await fetch(`${boot.base}/dsh-remote/status`)).json();
128
128
  assert.equal(status.service.e2ee.enabled, false);
@@ -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,6 +334,18 @@ 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/);
253
349
  });
254
350
 
255
351
  // ---------- 端到端加密(E2EE,Phase-5)状态徽标 ----------
@@ -282,9 +378,9 @@ test("E2EE 徽标:已启用 → “🔒 端到端加密已启用(手机解
282
378
  assert.ok(textHas(tree, "手机解锁后生效"), "应提示“手机解锁后生效”(bridge 就绪、手机解锁后方生效)");
283
379
  });
284
380
 
285
- test("E2EE 徽标:未启用原因映射可读文案(灰度等待 / 参数不可达 / 本地关闭 / 改密 / 未知兜底)", () => {
381
+ test("E2EE 徽标:未启用原因映射可读文案(服务端关闭 / 参数不可达 / 本地关闭 / 改密 / 未知兜底)", () => {
286
382
  const cases = [
287
- [E2EE_STATE.serverDisabled, "等待服务端开启 E2EE"],
383
+ [E2EE_STATE.serverDisabled, "端到端加密暂不可用(当前为普通安全连接 HTTPS)"],
288
384
  [E2EE_STATE.paramsUnreachable, "当前为普通安全连接(HTTPS)"],
289
385
  [E2EE_STATE.localDisabled, "当前为普通安全连接(HTTPS)"],
290
386
  [E2EE_STATE.deriveFailed, "账号密码已变更"],
@@ -316,8 +412,9 @@ test("E2EE 徽标:未登录 / host 未下发 e2ee → 不打扰(不渲染状
316
412
  test("E2EE 徽标(源码级约束):client 含徽标字段/文案与 reason 映射表", () => {
317
413
  assert.match(SOURCE, /service\.e2ee/);
318
414
  assert.match(SOURCE, /\.e2ee-state\.json/);
319
- assert.match(SOURCE, /端到端加密已启用(手机解锁后生效)/);
320
- assert.match(SOURCE, /等待服务端开启 E2EE(灰度中,当前为加密准备)/);
415
+ assert.match(SOURCE, /🔒 端到端加密已启用(手机解锁后生效)/);
416
+ assert.match(SOURCE, /端到端加密暂不可用(当前为普通安全连接 HTTPS)/);
417
+ assert.doesNotMatch(SOURCE, /等待服务端开启 E2EE/); // 已正式开启,不再出现“灰度等待”措辞
321
418
  assert.match(SOURCE, /当前为普通安全连接(HTTPS)/);
322
419
  assert.match(SOURCE, /server_disabled/);
323
420
  assert.match(SOURCE, /params_unreachable/);
@@ -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
- "🛡 端到端加密(灰度开启中):服务端开启后,手机↔电脑之间的消息内容用「你的账号密码派生密钥」端到端加密——密钥与密码不落服务端(仅存校验值),中继只可见路径/大小/时间(详见 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\(\);/);