@mrrisega/dsh-remote 0.6.7-beta.2 → 0.6.7-beta.4

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.
@@ -91,8 +91,16 @@ class El {
91
91
  get isConnected() { return true; }
92
92
  }
93
93
 
94
- /** 搭一个最小 dsh web 环境:frame(带官方 data 属性)内含 sidebar/center 两列 + 可选官方 toggle。 */
95
- function harness({ collapsedAttr = undefined, detailsAttr = undefined, sidebarRect = null, width = 390, withToggle = true } = {}) {
94
+ /**
95
+ * 搭一个最小 dsh web 环境:frame(带官方 data 属性)内含 sidebar/center 两列 + 可选官方 toggle。
96
+ *
97
+ * `variant: "rc2"` 复刻**官方 0.1.5-rc.2 起**的 DOM:第三列由 `details` 改名 `rightbar`
98
+ * (`data-details-collapsed` → `data-rightbar-collapsed`、`pI_x6G_detailsCol` → `pI_x6G_rightbarCol`),
99
+ * 且 `overlayLayer`(官方 shell.overlay 宿主,`inset:0` 全屏、`data-shell-overlay`)留在 frame 末尾、
100
+ * 折叠时**没有拖拽把手**——这正是 2026-09-19 线上"整屏遮罩"的现场结构。
101
+ */
102
+ function harness({ collapsedAttr = undefined, detailsAttr = undefined, sidebarRect = null, width = 390, withToggle = true,
103
+ variant = "rc1", rightbarAttr = undefined, overlayRect = null, rightbarRect = null } = {}) {
96
104
  const html = new El("html");
97
105
  // HOSTISH 门禁会读 documentElement.outerHTML 找官方特征(__ModuleLoader__ / DeepSeek Harness)
98
106
  html.outerHTML = '<html><head><script>window.__ModuleLoader__ = { mode: "queue" }</script>'
@@ -107,6 +115,17 @@ function harness({ collapsedAttr = undefined, detailsAttr = undefined, sidebarRe
107
115
  if (sidebarRect) sidebar._rect = sidebarRect;
108
116
  frame.appendChild(sidebar);
109
117
  frame.appendChild(center);
118
+ let rightbar = null;
119
+ let overlay = null;
120
+ if (variant === "rc2") {
121
+ if (rightbarAttr !== undefined) frame.setAttribute("data-rightbar-collapsed", rightbarAttr);
122
+ rightbar = new El("div", { class: "pI_x6G_rightbarCol" });
123
+ if (rightbarRect) rightbar._rect = rightbarRect;
124
+ frame.appendChild(rightbar);
125
+ overlay = new El("div", { class: "pI_x6G_overlayLayer", "data-shell-overlay": "" });
126
+ if (overlayRect) overlay._rect = overlayRect;
127
+ frame.appendChild(overlay);
128
+ }
110
129
  const toggle = new El("button");
111
130
  toggle.setAttribute("aria-label", "打开侧边栏");
112
131
 
@@ -127,7 +146,7 @@ function harness({ collapsedAttr = undefined, detailsAttr = undefined, sidebarRe
127
146
  addEventListener() {},
128
147
  querySelector(sel) {
129
148
  const s2 = String(sel);
130
- if (s2.includes(".pI_x6G_frame") || s2.includes("[data-sidebar-collapsed], [data-details-collapsed]") || s2.includes("[data-shell-overlay]")) return frame;
149
+ if (s2.includes(".pI_x6G_frame") || /\[data-(sidebar|details|rightbar)-collapsed\]/.test(s2) || s2.includes("[data-shell-overlay]")) return frame;
131
150
  if (s2.includes("aria-label=")) return withToggle ? toggle : null;
132
151
  if (s2.includes(".hHd-Xa_toggle")) return withToggle ? toggle : null;
133
152
  if (s2.includes("hHd-Xa")) return sidebar; // 子列识别用(真实页面里这是侧栏)
@@ -154,12 +173,15 @@ function harness({ collapsedAttr = undefined, detailsAttr = undefined, sidebarRe
154
173
  MutationObserver: class { observe() {} disconnect() {} },
155
174
  ResizeObserver: class { observe() {} disconnect() {} },
156
175
  };
176
+ // 真实浏览器里 window.location 必然存在(注入层用 location.assign/href 跳转),假 DOM 也要有
177
+ const loc = { href: "https://n.risegao.cn:13443/remote/dev-x/", host: "n.risegao.cn", pathname: "/remote/dev-x/", origin: "https://n.risegao.cn:13443" };
178
+ win.location = loc;
157
179
  const ctx = vm.createContext({
158
180
  window: win, document: doc, console, setTimeout: win.setTimeout, clearTimeout: win.clearTimeout,
159
181
  setInterval: () => 0, clearInterval() {}, MutationObserver: win.MutationObserver,
160
182
  ResizeObserver: win.ResizeObserver, requestAnimationFrame: win.requestAnimationFrame,
161
183
  navigator: { userAgent: "iPhone Safari", maxTouchPoints: 5, platform: "iPhone" },
162
- location: { href: "https://n.risegao.cn:13443/app/", host: "n.risegao.cn" },
184
+ location: loc,
163
185
  localStorage: win.localStorage, URL, Date, Math, JSON, Object, Array, String, Number, RegExp, Boolean, Promise, Error, isNaN, parseInt, parseFloat,
164
186
  HTMLElement: El, // 脚本用 `c instanceof HTMLElement` 过滤 frame 的子列
165
187
  });
@@ -170,7 +192,7 @@ function harness({ collapsedAttr = undefined, detailsAttr = undefined, sidebarRe
170
192
  ctx.console = dbgConsole;
171
193
  ctx.globalThis = ctx;
172
194
  ctx.self = win;
173
- return { ctx, html, body, frame, sidebar, toggle, win };
195
+ return { ctx, html, body, frame, sidebar, rightbar, overlay, toggle, win };
174
196
  }
175
197
 
176
198
  const ADAPTER_DEBUG = process.env.DSH_ADAPTER_DEBUG === "1";
@@ -260,48 +282,83 @@ test("运行时:能拿到官方 toggle 时,点遮罩仍走官方 toggle(
260
282
  assert.ok(env.toggle._clicked >= 1, "应点击官方 toggle");
261
283
  });
262
284
 
263
- // ── 字号档位(2026-09-16 需求:4 档滑块、仅手机端生效) ──────────────────────
285
+ // ── 远程控制悬浮按钮(2026-09-19:字号 + 加密小锁 + 返回设备列表,三合一) ──────
286
+ //
287
+ // 用户要求:「把字体的菜单和加密小锁的菜单合并成一个小悬浮按钮;点击后可以弹菜单调节字体,
288
+ // 也可以返回设备列表,让用户主动返回回去;手机端要有,电脑端也可以有。」
264
289
 
265
- const findFsBtn = (env) => env.body.children.find((c) => c._cls.has("dsh-ma-fsbtn"));
266
- const findFsPanel = (env) => env.body.children.find((c) => c._cls.has("dsh-ma-fspanel"));
290
+ const findFab = (env) => env.body.children.find((c) => c._cls.has("dsh-ma-fab"));
291
+ const findMenu = (env) => env.body.children.find((c) => c._cls.has("dsh-ma-menu"));
267
292
 
268
- test("字号:菜单/字号按钮与面板都应创建(面板默认收起)", () => {
293
+ test("悬浮菜单:按钮与菜单都创建,菜单默认收起", () => {
269
294
  const env = runAdapter(harness({}));
270
- const btn = findFsBtn(env);
271
- const panel = findFsPanel(env);
272
- assert.ok(btn, "应有字号按钮");
273
- assert.ok(panel, "应有字号面板");
274
- assert.equal(panel.hidden, true, "面板默认收起(点按钮才展开)");
275
- assert.ok((panel.querySelector ? panel : {}).querySelector === undefined || true);
295
+ const fab = findFab(env);
296
+ const menu = findMenu(env);
297
+ assert.ok(fab, "应有悬浮按钮");
298
+ assert.ok(menu, "应有菜单面板");
299
+ assert.equal(menu.hidden, true, "菜单默认收起(点按钮才展开)");
276
300
  });
277
301
 
278
- test("字号:默认档 = 最小(不加缩放属性,保持当前大小)", () => {
302
+ test("悬浮菜单:点按钮开合菜单", () => {
279
303
  const env = runAdapter(harness({}));
280
- assert.equal(env.html.getAttribute("data-dsh-ma-fs"), null, "默认不应带缩放属性");
281
- assert.equal(env.win.localStorage.getItem("dsh-ma-font-scale"), null);
304
+ const fab = findFab(env);
305
+ const menu = findMenu(env);
306
+ fab.dispatch("click");
307
+ assert.equal(menu.hidden, false, "点按钮应展开菜单");
308
+ fab.dispatch("click");
309
+ assert.equal(menu.hidden, true, "再点应收起");
282
310
  });
283
311
 
284
- test("字号:滑块改档 → 属性/正文变量/持久化都生效", () => {
312
+ test("悬浮菜单:字号档位仍生效(属性/正文变量/持久化)", () => {
285
313
  const env = runAdapter(harness({}));
286
- const panel = findFsPanel(env);
287
- const range = panel && panel.querySelector && panel.querySelector('input[type="range"]');
288
- assert.ok(range, "面板里应有 range 滑块");
289
- // 打到「大」(level 2)
314
+ const menu = findMenu(env);
315
+ const range = menu.querySelector('input[type="range"]');
316
+ assert.ok(range, "菜单里应有字号滑块");
290
317
  range.value = "2";
291
318
  range.dispatch("input");
292
319
  assert.equal(env.html.getAttribute("data-dsh-ma-fs"), "2", "应写入缩放属性(CSS 据此放大字号)");
293
- assert.equal(env.win.localStorage.getItem("dsh-ma-font-scale"), "2", "应持久化到 localStorage");
294
- const contentVar = env.html.style["--dsh-content-font-size"] || env.html._cssVars && env.html._cssVars["--dsh-content-font-size"];
295
- assert.ok(contentVar, "应同步官方正文变量 --dsh-content-font-size(聊天正文才会变大)");
320
+ assert.equal(env.win.localStorage.getItem("dsh-ma-font-scale"), "2", "应持久化");
321
+ const contentVar = (env.html._cssVars || {})["--dsh-content-font-size"];
322
+ assert.ok(contentVar, "应同步官方正文变量 --dsh-content-font-size");
323
+ range.value = "0";
324
+ range.dispatch("input");
325
+ assert.equal(env.html.getAttribute("data-dsh-ma-fs"), null, "回到「最小」应移除缩放属性");
296
326
  });
297
327
 
298
- test("字号:恢复「最小」时移除缩放属性(回到当前大小)", () => {
328
+ test("悬浮菜单:必须有「返回设备列表」,且回到 /app/(用户在镜像页的唯一主动退路)", () => {
299
329
  const env = runAdapter(harness({}));
300
- const panel = findFsPanel(env);
301
- const range = panel.querySelector('input[type="range"]');
302
- range.value = "3"; range.dispatch("input");
303
- assert.equal(env.html.getAttribute("data-dsh-ma-fs"), "3");
304
- range.value = "0"; range.dispatch("input");
305
- assert.equal(env.html.getAttribute("data-dsh-ma-fs"), null, "level 0 = 最小 → 不应留缩放属性");
306
- assert.equal(env.win.localStorage.getItem("dsh-ma-font-scale"), "0");
330
+ const menu = findMenu(env);
331
+ const back = menu.querySelector(".dsh-ma-menu-back");
332
+ assert.ok(back, "菜单里必须有返回设备列表按钮");
333
+ back.dispatch("click");
334
+ assert.equal(env.ctx.location.href, "/app/", "点击后应跳到 APP 外壳(绝对路径,不能依赖当前镜像路径)");
335
+ });
336
+
337
+ test("悬浮菜单:E2EE 状态可合并(shim 在场时隐藏独立药丸并读它的状态)", () => {
338
+ const env = harness({});
339
+ env.win.__dshE2eeBadge = { state: () => ({ mode: "warn", text: "未加密(刷新后需重新解锁)" }) };
340
+ runAdapter(env);
341
+ assert.equal(env.html.getAttribute("data-dsh-ma-merged-e2ee"), "1",
342
+ "shim 在场时应挂牌合并(CSS 据此隐藏官方那颗独立药丸)");
343
+ });
344
+
345
+ test("多端适配:悬浮按钮与字号样式的 CSS 必须在 @media(max-width:820px) 之外(电脑端也要有)", () => {
346
+ const { html } = injectMobileAdapter("<!doctype html><html><head></head><body></body></html>");
347
+ const css = /<style id="[^"]*-css"[^>]*>([\s\S]*?)<\/style>/.exec(html)[1];
348
+ // 只保留 @media 之外的规则(做一次括号配平剥离),再断言关键选择器还在
349
+ let out = "", i = 0, depth = 0, inMedia = false;
350
+ while (i < css.length) {
351
+ if (css.startsWith("@media", i)) {
352
+ const open = css.indexOf("{", i);
353
+ let d = 1, j = open + 1;
354
+ while (j < css.length && d > 0) { if (css[j] === "{") d++; else if (css[j] === "}") d--; j++; }
355
+ i = j;
356
+ continue;
357
+ }
358
+ out += css[i++];
359
+ }
360
+ assert.match(out, /button\.dsh-ma-fab\s*\{/, "悬浮按钮样式必须在媒体查询之外(电脑端可见)");
361
+ assert.match(out, /div\.dsh-ma-menu\s*\{/, "菜单样式必须在媒体查询之外");
362
+ assert.match(out, /html\[data-dsh-ma-fs="3"\]/, "字号档位样式也应在媒体查询之外(电脑端同样生效)");
363
+ assert.match(out, /z-index:\s*40[01]/, "悬浮按钮/菜单层级要高于遮罩(290),遮罩异常时仍是自救出口");
307
364
  });
package/dsh-setup.mjs CHANGED
@@ -257,6 +257,35 @@ const SERVICE_PATH = "/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/
257
257
  const INSTALL_SOURCE = String(process.env.DSH_BRIDGE_INSTALL_SOURCE || "").trim() || "npx";
258
258
  const INSTALL_VERSION = String(process.env.DSH_BRIDGE_INSTALL_VERSION || "").trim() || pkgVersion();
259
259
 
260
+ /**
261
+ * 把文件权限收紧到「只有本人可读写」。
262
+ *
263
+ * ⚠️ Windows 上 `{ mode: 0o600 }` 是**空操作**(Windows 用 ACL,不是 POSIX mode 位):
264
+ * 实测 .dsh-config.json 拿到的是用户目录的默认**继承 ACL**(AreAccessRulesProtected=False),
265
+ * 也就是说代码里那句 0600 对 Windows 用户完全没效果 —— 而文件里是**明文账号密码**。
266
+ * 风险不在"同机他人可读"(默认 ACL 下其实读不到),而在**任何按文件复制的场景**
267
+ * (备份/同步盘/杀软上报/崩溃转储/用户发给作者的支持包)都会连钥匙一起被带走,
268
+ * 而文档里的"0600 请妥善保护"会让用户误以为 Windows 上已有这层保护。
269
+ * 这里显式断开继承、只授予当前用户;失败只警告一次,绝不影响主流程。
270
+ */
271
+ let hardenWarned = false;
272
+ function hardenFile(file) {
273
+ try { fs.chmodSync(file, 0o600); } catch { /* POSIX 上失败不致命 */ }
274
+ if (process.platform !== "win32") return;
275
+ const who = [process.env.USERDOMAIN, process.env.USERNAME].filter(Boolean).join("\\");
276
+ if (!who) return;
277
+ let r;
278
+ try {
279
+ r = spawnSync("icacls", [file, "/inheritance:r", "/grant:r", `${who}:F`],
280
+ { windowsHide: true, encoding: "utf8", timeout: 8000 });
281
+ } catch (e) { r = { status: -1, stderr: e.message }; }
282
+ if (r.status !== 0 && !hardenWarned) {
283
+ hardenWarned = true;
284
+ console.warn(`⚠️ 收紧文件权限失败(${who}):${String(r.stderr || "").trim() || `icacls 退出码 ${r.status}`}`);
285
+ console.warn(" 配置文件可能仍可被其它账户/备份工具读取,请自行确认其存放位置。");
286
+ }
287
+ }
288
+
260
289
  // ---------- 配置读写 ----------
261
290
  function loadConfig() {
262
291
  try { return JSON.parse(fs.readFileSync(CONFIG_PATH, "utf8")); }
@@ -265,6 +294,7 @@ function loadConfig() {
265
294
  function saveConfig(cfg) {
266
295
  fs.mkdirSync(CONFIG_DIR, { recursive: true });
267
296
  fs.writeFileSync(CONFIG_PATH, JSON.stringify(cfg, null, 2), { mode: 0o600 });
297
+ hardenFile(CONFIG_PATH); // Windows 上 mode 是空操作 → 必须显式收紧 ACL(内含明文账号密码)
268
298
  }
269
299
 
270
300
  // ---------- 公共配置(从服务端取域名,服务商可随时更换) ----------
@@ -754,7 +784,7 @@ await delay(1000);
754
784
  try { process.kill(PLAN.pid); } catch (e) {}
755
785
  const deadline = Date.now() + 30000;
756
786
  while (Date.now() < deadline && alive(PLAN.pid)) await delay(200);
757
- if (alive(PLAN.pid)) spawnSync("taskkill", ["/PID", String(PLAN.pid), "/T", "/F"], { windowsHide: true });
787
+ if (alive(PLAN.pid)) spawnSync("taskkill", ["/PID", String(PLAN.pid), "/T", "/F"], { windowsHide: true, timeout: 8000 });
758
788
  let out = "ignore";
759
789
  try { out = openSync(PLAN.log, "a"); } catch (e) {}
760
790
  const child = spawn(PLAN.argv[0], PLAN.argv.slice(1), { detached: true, stdio: ["ignore", out, out], windowsHide: true });
@@ -833,11 +863,37 @@ dsh-remote:独立的本地设置页已移除。
833
863
  `);
834
864
  }
835
865
 
866
+ /**
867
+ * 账号指纹:账号 / 模式 / 设备身份任一变化都意味着"必须用新凭据重新登记"。
868
+ * 为什么需要:bridge 的 DSH_BRIDGE_PHONE/PASSWORD 是**进程启动时**从环境变量固化的,
869
+ * 配置改了并不会影响已在跑的进程 —— 这是"切换账号后设备仍留在旧账号"的根因之一。
870
+ */
871
+ function accountFingerprint(cfg) {
872
+ const c = cfg || {};
873
+ return [c.local_key ? "local" : "saas", c.phone || c.email || "", String(c.device_id || ""), String(c.local_key || "")].join("|");
874
+ }
875
+
876
+ /**
877
+ * 服务端登录限流的"最早可重试时刻"(bridge 在收到 429 时落盘)。
878
+ * 为什么需要:限流窗口是 15 分钟,而 watcher 每 10 秒就会重新拉起 bridge ——
879
+ * 不读这个文件就是 90 次空撞(日志刷屏、还可能拖长窗口)。它只是**退避提示**,绝不阻止正常启动。
880
+ */
881
+ const LOGIN_RATELIMIT_FILE = ".dsh-login-ratelimited";
882
+ function loginRateLimitUntil() {
883
+ const f = path.join(CONFIG_DIR, LOGIN_RATELIMIT_FILE);
884
+ try {
885
+ const until = Number(String(fs.readFileSync(f, "utf8")).trim());
886
+ if (Number.isFinite(until) && until > Date.now()) return until;
887
+ fs.rmSync(f, { force: true }); // 已过期:清掉,恢复正常启动
888
+ } catch { /* 无文件 = 未限流 */ }
889
+ return 0;
890
+ }
891
+
836
892
  // ---------- run:前台跑 bridge(带配置 + 自启动 watcher) ----------
837
893
  async function runBridge() {
838
- // Windows 去重:登录任务(Task Scheduler)与插件半的自愈都可能拉起 watcher,
839
- // 两个 bridge 会抢同一个设备登记 → 这里以 pid 文件为准,已有守护在跑就直接退出。
840
- if (IS_WIN) {
894
+ // 去重(所有平台):自启动服务、登录任务、插件半的脱离进程兜底都可能拉起 watcher,
895
+ // 两个 bridge 会抢同一个设备登记 → 以 pid 文件为准,已有守护在跑就直接退出。
896
+ {
841
897
  const other = readPidFile(WATCHER_PID_FILE);
842
898
  if (other && other !== process.pid && pidAlive(other)) {
843
899
  console.log(`[dsh-remote] 已有一个 bridge 守护在运行(pid=${other}),本进程直接退出,避免重复实例。`);
@@ -846,6 +902,10 @@ async function runBridge() {
846
902
  }
847
903
  let cfg = loadConfig();
848
904
  let warnedNoLogin = false;
905
+ /** 当前 bridge 子进程使用的账号指纹(账号/模式/设备身份任一变化 → 重启子进程)。 */
906
+ let bridgeAccountFp = "";
907
+ /** 限流提示只打一次,避免刷屏。 */
908
+ let warnedRateLimit = false;
849
909
 
850
910
  // watcher:检测 dsh web(127.0.0.1:3080)存活,存活才启动 bridge
851
911
  const checkUpstream = () => new Promise((resolve) => {
@@ -887,6 +947,28 @@ async function runBridge() {
887
947
  }
888
948
  const apiUrl = saas ? (cfg.api_url || DEFAULT_API) : "";
889
949
 
950
+ // 账号/模式/设备身份变了 → 必须重启 bridge(它的凭据是启动时从环境变量固化的)。
951
+ // 没有这一条时:面板切了账号、旧 bridge 却继续用旧账号跑,新账号的设备列表里看不到这台机器
952
+ //(2026-09-19 实测事故)。
953
+ const fp = accountFingerprint(cfg);
954
+ if (!childStopped(bridgeProc) && bridgeAccountFp && bridgeAccountFp !== fp) {
955
+ console.log("[dsh-remote] 账号配置已变化 → 重启 bridge 让新账号生效...");
956
+ try { bridgeProc.kill(); } catch { /* 已退出 */ }
957
+ bridgeAccountFp = "";
958
+ return; // 下一轮(10s 内)用新配置重新拉起
959
+ }
960
+
961
+ // 服务端登录限流窗口内不反复拉起 bridge(凭据没错,改了也没用)
962
+ const rlUntil = loginRateLimitUntil();
963
+ if (rlUntil > Date.now()) {
964
+ if (!warnedRateLimit) {
965
+ warnedRateLimit = true;
966
+ console.log(`[dsh-remote] 登录被服务端限流,约 ${Math.ceil((rlUntil - Date.now()) / 1000)} 秒后自动重试(凭据无需修改)。`);
967
+ }
968
+ return;
969
+ }
970
+ warnedRateLimit = false;
971
+
890
972
  const alive = await checkUpstream();
891
973
  if (alive && childStopped(bridgeProc)) {
892
974
  starting = true;
@@ -914,7 +996,8 @@ async function runBridge() {
914
996
  });
915
997
  // Windows:落 pid 文件,供插件半发现进程(Windows 没有 pgrep/ps)。
916
998
  // 插件半启动 bridge 走的就是本函数,所以这里写一次两边都覆盖到。
917
- if (IS_WIN) writePidFile(BRIDGE_PID_FILE, bridgeProc.pid);
999
+ writePidFile(BRIDGE_PID_FILE, bridgeProc.pid); // 供插件半发现进程(Windows 无 pgrep;POSIX 用于脱离进程兜底的去重)
1000
+ bridgeAccountFp = fp; // 记下这个子进程用的是哪套凭据,配置一变就重启它
918
1001
  setTimeout(() => { starting = false; }, 5000);
919
1002
  } else if (!alive && bridgeProc && bridgeProc.exitCode === null) {
920
1003
  console.log("[dsh-remote] dsh web 离线,停止 bridge...");
@@ -922,8 +1005,8 @@ async function runBridge() {
922
1005
  }
923
1006
  };
924
1007
 
925
- // Windows:watcher 自身的 pid 也要落盘(插件半据此判断"守护在跑"并避免重复拉起)
926
- if (IS_WIN) {
1008
+ // watcher 自身的 pid 也要落盘(插件半据此判断"守护在跑"并避免重复拉起)
1009
+ {
927
1010
  writePidFile(WATCHER_PID_FILE, process.pid);
928
1011
  const cleanup = () => { removePidFile(WATCHER_PID_FILE); removePidFile(BRIDGE_PID_FILE); };
929
1012
  process.on("exit", cleanup);
@@ -1328,6 +1411,48 @@ function validatePatchBase(text) {
1328
1411
  return { ok: true, cleaned, insertedIds: ids };
1329
1412
  }
1330
1413
 
1414
+ /**
1415
+ * patch 文本是否是「合法的顶层 YAML 数组文档」。
1416
+ *
1417
+ * 为什么必须判:dsh 侧解析 cordis.patch.yml 时**要求顶层是数组**,而
1418
+ * **纯注释文档的 YAML 解析结果是 `null`(不是空数组)**,于是 dsh 直接启动失败:
1419
+ * `Error: dsh: overlay …/cordis.patch.yml must be a top-level YAML array of loader patch entries`
1420
+ * 官方空 profile 模板(dsh-app-boot)正是「注释 + `[]`」——`[]` 这个占位符不能省。
1421
+ * 现场(2026-09-18 Windows 用户实测):卸载把插件条目摘掉后只剩顶部注释,dsh web 再也起不来。
1422
+ */
1423
+ /** 顶层(无缩进)非注释行 = YAML 的结构行;缩进行属于上一条目。 */
1424
+ function patchStructuralLines(text) {
1425
+ return String(text ?? "")
1426
+ .split(/\r?\n/)
1427
+ .map((l) => l.replace(/\s+$/, ""))
1428
+ .filter((l) => l.trim() !== "" && !/^\s*#/.test(l) && !/^[ \t]/.test(l));
1429
+ }
1430
+
1431
+ /** 是否是合法的顶层数组文档(`[]` 占位,或若干 `-` 开头的顶层条目)。 */
1432
+ function isPatchArrayDocument(text) {
1433
+ const roots = patchStructuralLines(text);
1434
+ return roots.length > 0 && roots.every((l) => l === "[]" || l.startsWith("-"));
1435
+ }
1436
+
1437
+ /** 只有注释/空行 → YAML 解析成 null(不是空数组),必须补 `[]` 占位,否则 dsh 起不来。 */
1438
+ function needsArrayPlaceholder(text) {
1439
+ return patchStructuralLines(text).length === 0;
1440
+ }
1441
+
1442
+ /**
1443
+ * 写回 patch 文档,并**保证结果仍是合法顶层数组**:条目被删光时补回 `[]` 占位。
1444
+ * 卸载、摘 include、回滚都必须走这里 —— 任何一条写路径漏了这步,用户就会拿到
1445
+ * 一个「dsh web 起不来且面板也进不去」的 profile(只能靠 CLI 自救)。
1446
+ */
1447
+ function writePatchDocument(patchFile, text) {
1448
+ const body = String(text ?? "").replace(/\s+$/, "") + "\n";
1449
+ // ⚠️ 只在「一个结构行都不剩」时补 —— 不能因为"看起来不像数组"就乱补,
1450
+ // 那会把本来好的文件写坏(把 `[]` 追加到已有 insert 块后面 → YAML 直接报错)。
1451
+ const fixed = needsArrayPlaceholder(body) ? `${body}[]\n` : body;
1452
+ writePatchAtomic(patchFile, fixed);
1453
+ return fixed;
1454
+ }
1455
+
1331
1456
  /** 安全写回 patch(原子:先写同目录临时文件再 rename,避免中途被打断留下半截文件)。 */
1332
1457
  function writePatchAtomic(patchFile, text) {
1333
1458
  const tmp = `${patchFile}.dsh-remote.tmp`;
@@ -1384,7 +1509,7 @@ function ensurePatchActivation(patchFile, pluginLocalDir, pkgFile, opts = {}) {
1384
1509
  const merged2 = `${base2.cleaned ? base2.cleaned + "\n" : ""}\n${want}\n`;
1385
1510
  const chk = parsePatchInsertIds(merged2);
1386
1511
  if (!chk.badLines.length && chk.ids.filter((id) => id === PLUGIN_ID).length === 1) {
1387
- writePatchAtomic(patchFile, merged2);
1512
+ writePatchDocument(patchFile, merged2);
1388
1513
  console.log(`✅ 已同步激活行的 relayDir → ${wantDir}`);
1389
1514
  return { ok: true, changed: true, basePatch: original };
1390
1515
  }
@@ -1407,18 +1532,18 @@ function ensurePatchActivation(patchFile, pluginLocalDir, pkgFile, opts = {}) {
1407
1532
  if (check.badLines.length || check.ids.filter((id) => id === PLUGIN_ID).length !== 1) {
1408
1533
  return { ok: false, reason: "合并后的 patch 校验未通过(未做改动)", changed: false, basePatch: original };
1409
1534
  }
1410
- writePatchAtomic(patchFile, merged);
1535
+ writePatchDocument(patchFile, merged);
1411
1536
  const after = fs.readFileSync(patchFile, "utf8");
1412
1537
  const verify = parsePatchInsertIds(after);
1413
1538
  if (verify.badLines.length || verify.ids.filter((id) => id === PLUGIN_ID).length !== 1) {
1414
- writePatchAtomic(patchFile, original); // 回滚
1539
+ writePatchDocument(patchFile, original); // 回滚
1415
1540
  return { ok: false, reason: "写入后校验失败,已回滚", changed: false, basePatch: original };
1416
1541
  }
1417
1542
  removeBundleEntry(pkgFile); // 单一激活点:bundles 里不能再有本插件
1418
1543
  // 双保险:上面那次移除若没生效(并发写 package.json / 解析失败),**回滚本次 patch 行**,
1419
1544
  // 宁可退回 bundles 形态(需重启)也绝不留"两处激活"→ dsh web 启动会 duplicate id 崩掉。
1420
1545
  if (pkgBundles().length) {
1421
- writePatchAtomic(patchFile, original);
1546
+ writePatchDocument(patchFile, original);
1422
1547
  return { ok: false, reason: "bundles 条目未能移除,已回滚 patch 行以免重复激活", changed: false, bundleOnly: true, basePatch: original };
1423
1548
  }
1424
1549
  return { ok: true, changed: true, basePatch: original };
@@ -1428,7 +1553,7 @@ function ensurePatchActivation(patchFile, pluginLocalDir, pkgFile, opts = {}) {
1428
1553
  function stripIncludeEntries(patchFile, patch, reason) {
1429
1554
  const next = stripPluginEntries(patch);
1430
1555
  if (next === patch) return false;
1431
- writePatchAtomic(patchFile, next);
1556
+ writePatchDocument(patchFile, next);
1432
1557
  console.log(`✅ 已移除 ${patchFile} 中的冗余 include(${reason};激活点必须唯一,避免重复 ID 崩溃)`);
1433
1558
  return true;
1434
1559
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mrrisega/dsh-remote",
3
- "version": "0.6.7-beta.2",
3
+ "version": "0.6.7-beta.4",
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",
@@ -340,8 +340,35 @@ window.__ModuleLoader__.load({
340
340
  }
341
341
 
342
342
  // ── 宿主 API ────────────────────────────────────────────────────────────
343
- function api(path, options) {
344
- return fetch(path, options).then(function (res) {
343
+ /**
344
+ * 默认请求超时(0.6.7-beta.3 新增)。
345
+ * 背景(用户实测):node 半一旦被同步子进程调用钉死(卸载时 spawnSync 卡在 schtasks 上),
346
+ * 请求**永远不会返回**,面板就对着一个永不结束的 spinner —— 用户只能猜。
347
+ * 现在超时后给出明确报错(并提示"这一步可能已经在后台完成了"),而不是无限转圈。
348
+ * 30s 足够覆盖最慢的正常路径(relay 6s + Windows 进程扫描 8s)。
349
+ */
350
+ var API_TIMEOUT_MS = 30000;
351
+ function api(path, options, timeoutMs) {
352
+ var opts = options || {};
353
+ var timer = null;
354
+ try {
355
+ if (typeof AbortController === "function") {
356
+ var ctrl = new AbortController();
357
+ var ms = typeof timeoutMs === "number" && timeoutMs > 0 ? timeoutMs : API_TIMEOUT_MS;
358
+ timer = setTimeout(function () { try { ctrl.abort(); } catch (e) { /* 忽略 */ } }, ms);
359
+ opts = Object.assign({}, opts, { signal: ctrl.signal });
360
+ }
361
+ } catch (e) { /* 无 AbortController 的旧环境:退化为无超时(不阻断使用) */ }
362
+ var done = function () { if (timer) { clearTimeout(timer); timer = null; } };
363
+ return fetch(path, opts).catch(function (e) {
364
+ // AbortError → 换成用户能看懂的话(并说明服务端可能已经做完了)
365
+ if (e && (e.name === "AbortError" || /abort/i.test(String(e.message || "")))) {
366
+ var te = new Error("请求超时(面板等不到本机响应)。这一步可能已经在后台完成了 —— 请刷新页面查看;若 dsh web 无响应,重启一次 dsh web 即可。");
367
+ te.timeout = true;
368
+ throw te;
369
+ }
370
+ throw e;
371
+ }).finally(done).then(function (res) {
345
372
  return res.text().then(function (text) {
346
373
  var body = null;
347
374
  try { body = JSON.parse(text); } catch (e) { body = null; }
@@ -358,6 +385,10 @@ window.__ModuleLoader__.load({
358
385
  var post = function (path, data) {
359
386
  return api(path, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(data || {}) });
360
387
  };
388
+ /** 带自定义超时的 POST(用于卸载这类"可能拖住"的操作)。 */
389
+ var postWithTimeout = function (path, data, timeoutMs) {
390
+ return api(path, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(data || {}) }, timeoutMs);
391
+ };
361
392
 
362
393
  // ── 交流群(二维码由图层面板上传统一配置,node 半经公开配置下发) ─────────
363
394
  // 设置面板「加入交流群」按钮与用户反馈页都展示同一张码;未配置(qrcode 为空)→ 不展示任何入口。
@@ -1066,7 +1097,9 @@ window.__ModuleLoader__.load({
1066
1097
  if (!armed) { setArmed(true); return; }
1067
1098
  setUnBusy(true);
1068
1099
  setSelfMsg(null);
1069
- post("/dsh-remote/self/uninstall", {}).then(function (b) {
1100
+ // 卸载:服务端现在"先卸插件、先回响应、慢活交子进程",正常应在 1 秒内返回;
1101
+ // 给 20s 上界,超时也要给用户一句能照做的话(而不是永久转圈)。
1102
+ postWithTimeout("/dsh-remote/self/uninstall", {}, 20000).then(function (b) {
1070
1103
  if (b && b.ok) {
1071
1104
  setArmed(false);
1072
1105
  // 优先展示服务端 detail(含 bridge 自启动/配置目录的逐项清理结果与重启提示);
@@ -1079,7 +1112,7 @@ window.__ModuleLoader__.load({
1079
1112
  }
1080
1113
  }).catch(function (e) {
1081
1114
  setArmed(false);
1082
- setSelfMsg({ kind: "err", text: "卸载失败:" + e.message });
1115
+ setSelfMsg({ kind: e && e.timeout ? "warn" : "err", text: (e && e.timeout ? "" : "卸载失败:") + e.message });
1083
1116
  }).finally(function () { setUnBusy(false); });
1084
1117
  };
1085
1118
 
@@ -1694,6 +1727,25 @@ window.__ModuleLoader__.load({
1694
1727
  // ---------- 登录(密码/短信) ----------
1695
1728
  var doLogin = function () {
1696
1729
  if (!phone.trim() || !pass) { setMsg("err", "请填写手机号与密码"); return; }
1730
+ /**
1731
+ * 账号落盘(POST /dsh-remote/config)后的统一提示。
1732
+ *
1733
+ * 为什么单独抽出来:这次"换账号后设备不出现"的事故就是**静默失败**造成的 ——
1734
+ * /config 会顺带重启 bridge(让新账号生效),但其返回的 bridgeRestart 前端从来不看,
1735
+ * 于是"服务没重启成功"和"一切正常"在界面上长得一模一样。
1736
+ */
1737
+ function applyAccountSaved(cfg, okText) {
1738
+ setSt(cfg);
1739
+ var br = cfg && cfg.bridgeRestart;
1740
+ if (br && br.ok === false && br.status !== "running" && br.status !== "provisioning" && br.status !== "skipped") {
1741
+ setMsg("warn", okText + ";但后台服务(bridge)未能自动重启:"
1742
+ + ((br.detail || br.status) || "未知原因")
1743
+ + " —— 新账号可能收不到这台设备,请点下方「重启服务」或重启一次 dsh web。");
1744
+ return;
1745
+ }
1746
+ setMsg("ok", okText);
1747
+ }
1748
+
1697
1749
  if (!lcap || !lcap.id || !lcapTxt.trim()) { setMsg("err", "请输入图中验证码(点击图片可刷新)"); if (!lcap) loadCaptcha("login"); return; }
1698
1750
  var prevPhone = (st && st.config && st.config.phone) || "";
1699
1751
  setBusy("login");
@@ -1704,8 +1756,8 @@ window.__ModuleLoader__.load({
1704
1756
  // 切换连接账号(手机号与之前不同):清除旧账号留下的反馈线程凭据
1705
1757
  if (prevPhone !== phone.trim()) fbClearThreads();
1706
1758
  setPwdOpen(false); // 登录成功(可能从「忘记密码」返回)→ 收起重置表单
1707
- setSt(cfg); setPass(""); setLcapTxt(""); setLcap(null);
1708
- setMsg("ok", "✅ 登录成功,账号已保存");
1759
+ setPass(""); setLcapTxt(""); setLcap(null);
1760
+ applyAccountSaved(cfg, "✅ 登录成功,账号已保存");
1709
1761
  });
1710
1762
  }
1711
1763
  var relayBody = body.body || {};
@@ -1758,9 +1810,8 @@ window.__ModuleLoader__.load({
1758
1810
  if (body.ok || body.status === 201 || (body.body && body.body.token)) {
1759
1811
  return post("/dsh-remote/config", { phone: rphone.trim(), password: rpass }).then(function (cfg) {
1760
1812
  setPwdOpen(false);
1761
- setSt(cfg);
1762
1813
  setRphone(""); setRpass(""); setRpass2(""); setRsms(""); setRcap(null); setRcapTxt(""); setRInvite("");
1763
- setMsg("ok", "✅ 注册成功,已自动登录");
1814
+ applyAccountSaved(cfg, "✅ 注册成功,已自动登录");
1764
1815
  });
1765
1816
  }
1766
1817
  var b = body.body || {};