@mrrisega/dsh-remote 0.6.17 → 0.6.18-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 (33) hide show
  1. package/clients/dsh-remote/e2ee-shim-script.js +203 -11
  2. package/clients/dsh-remote/mobile-adapter.mjs +404 -29
  3. package/clients/dsh-remote/test/e2ee-shim.test.mjs +43 -1
  4. package/clients/dsh-remote/test/mobile-adapter-guards.test.mjs +145 -4
  5. package/clients/dsh-remote/test/mobile-adapter-runtime.test.mjs +95 -6
  6. package/clients/dsh-remote/wechat-runtime.mjs +25 -2
  7. package/docs/telemetry.md +7 -1
  8. package/dsh-setup.mjs +152 -3
  9. package/package.json +1 -1
  10. package/packages/dsh-remote-web/lib/client.js +754 -39
  11. package/packages/dsh-remote-web/lib/index.js +279 -6
  12. package/packages/dsh-remote-web/package.json +1 -1
  13. package/packages/dsh-remote-web/runtime/clients/dsh-remote/e2ee-shim-script.js +203 -11
  14. package/packages/dsh-remote-web/runtime/clients/dsh-remote/mobile-adapter.mjs +404 -29
  15. package/packages/dsh-remote-web/runtime/clients/dsh-remote/wechat-runtime.mjs +25 -2
  16. package/packages/dsh-remote-web/runtime/dsh-setup.mjs +152 -3
  17. package/packages/dsh-remote-web/test/account-switch-device.test.mjs +20 -2
  18. package/packages/dsh-remote-web/test/announcements-proxy.test.mjs +310 -0
  19. package/packages/dsh-remote-web/test/announcements-ui.test.mjs +524 -0
  20. package/packages/dsh-remote-web/test/connect-stuck-visibility.test.mjs +4 -1
  21. package/packages/dsh-remote-web/test/diag-report.test.mjs +4 -1
  22. package/packages/dsh-remote-web/test/harness-restart.test.mjs +5 -1
  23. package/packages/dsh-remote-web/test/installer-cli.test.mjs +1 -1
  24. package/packages/dsh-remote-web/test/launchd-domain.test.mjs +26 -0
  25. package/packages/dsh-remote-web/test/picker-pin.test.mjs +32 -2
  26. package/packages/dsh-remote-web/test/plugin-link.test.mjs +3 -1
  27. package/packages/dsh-remote-web/test/plugin-manifest.test.mjs +2 -2
  28. package/packages/dsh-remote-web/test/runtime-bundle.test.mjs +4 -1
  29. package/packages/dsh-remote-web/test/setup-output.test.mjs +22 -0
  30. package/packages/dsh-remote-web/test/telemetry.test.mjs +29 -10
  31. package/packages/dsh-remote-web/test/uninstall-safety.test.mjs +3 -3
  32. package/packages/dsh-remote-web/test/wechat-bot-ui.test.mjs +8 -3
  33. package/packages/dsh-remote-web/test/windows-compat.test.mjs +7 -4
@@ -2938,6 +2938,88 @@ async function proxyFeedback(relayDir, req, res, pathname) {
2938
2938
  }
2939
2939
  }
2940
2940
 
2941
+ /**
2942
+ * 把 `/dsh-remote/announcements*` 代理到云端公告 API(契约冻结见
2943
+ * dsh-relay-enterprise/docs/announcements-contract.md §3):
2944
+ * · GET /dsh-remote/announcements → 上游 GET {api}/api/announcements?client=desktop[&install_id=…]
2945
+ * · POST /dsh-remote/announcements/click → 上游 POST {api}/api/announcements/click {id[, install_id]}
2946
+ *
2947
+ * 三条刻意的设计(都是"服务端契约之外"的客户端纪律,写在这里免得后人"顺手优化"掉):
2948
+ *
2949
+ * ① **身份维度二选一,绝不混用**:能拿到账号 JWT 就只带 `Authorization: Bearer`(服务端按 user 维度去重,
2950
+ * 同一个人手机看过电脑就不再弹);拿不到才退回 `install_id`(装机维度)。
2951
+ * 两者**同时**带上会让唯一索引 (announcement_id, user_id, install_id) 把"同一人两台机器"
2952
+ * 算成两行 —— UV 直接翻倍。所以下面是有 Bearer 就不带 install_id,反之亦然。
2953
+ * ② **install_id 复用本机既有的 device_id,绝不新造 ID**:服务端那个维度就是"这台电脑",
2954
+ * 再生成一个 ID 只会让同一台机器在统计里变成两台(且新 ID 与反馈/设备列表的口径对不上)。
2955
+ * 注意 `cfg.device_id` 是**本机设备标识**,与遥测的 `.telemetry-install-id` 不是一回事:
2956
+ * 后者在 DSH_REMOTE_TELEMETRY=0 时根本不生成,公告不该被遥测开关牵连。
2957
+ * ③ **失败一律软着陆**:上游 404(老服务端)/ 不可达 / 超时都只回一个 JSON,**绝不抛**。
2958
+ * 浏览器半对这一切的既定反应是 fail closed(不显示入口、不弹窗),所以这里透传状态码即可,
2959
+ * 既不用把它改写成 200(那会让"接口通不通"这件事查不出来),也不会把面板打挂。
2960
+ */
2961
+ async function proxyAnnouncements(relayDir, req, res, pathname) {
2962
+ const method = String(req.method || "GET").toUpperCase();
2963
+ try {
2964
+ const cfg = loadConfig(relayDir);
2965
+ const api = String(cfg.api_url || DEFAULT_API).replace(/\/+$/, "");
2966
+ const suffix = pathname.replace(/^\/dsh-remote\/announcements/, "") || "/";
2967
+ const isClick = method === "POST" && suffix === "/click";
2968
+ // 未知子路径:老/新浏览器半都可能拼错,直接 404(不抛、不代理到奇怪的上游路径)
2969
+ if (!(isClick || (method === "GET" && (suffix === "/" || suffix === "")))) {
2970
+ return sendJson(res, 404, { ok: false, error: "unknown announcements route" });
2971
+ }
2972
+
2973
+ const headers = { "x-dsh-client": `dsh-remote-web/${PLUGIN_VERSION}` };
2974
+ // 浏览器已带的 Bearer 优先透传(与 proxyFeedback 同口径:那是请求方自己的身份);
2975
+ // 否则由 node 半附当前账号 JWT —— 只有"确实配了账号"才值得为此跑一次 device-login。
2976
+ const auth = req.headers.authorization;
2977
+ let bearer = "";
2978
+ if (auth && /^Bearer\s+/i.test(auth)) bearer = String(auth);
2979
+ else if (cfg.local_key || (cfg.phone && cfg.password)) {
2980
+ const t = await feedbackAuthToken(relayDir).catch(() => "");
2981
+ if (t) bearer = `Bearer ${t}`;
2982
+ }
2983
+ if (bearer) headers.authorization = bearer;
2984
+
2985
+ let url;
2986
+ let init = { method, headers };
2987
+ if (isClick) {
2988
+ url = `${api}/api/announcements/click`;
2989
+ let payload = {};
2990
+ try {
2991
+ const buf = await readBodyBuffer(req);
2992
+ const parsed = JSON.parse(buf.toString("utf8") || "{}");
2993
+ if (parsed && typeof parsed === "object") payload = parsed;
2994
+ } catch { payload = {}; } // 请求体读不到/不是 JSON → 上游按无 id 处理(404),不在这里抛
2995
+ // 无账号维度时补 install_id,保证点击也能记到"这台电脑"名下(有 Bearer 时**不补**,见上面 ① )
2996
+ if (!bearer) {
2997
+ const installId = String(cfg.device_id || "").trim();
2998
+ if (installId && !payload.install_id) payload.install_id = installId;
2999
+ }
3000
+ init = { ...init, headers: { ...headers, "content-type": "application/json" }, body: JSON.stringify(payload) };
3001
+ } else {
3002
+ url = `${api}/api/announcements?client=desktop`;
3003
+ if (!bearer) {
3004
+ const installId = String(cfg.device_id || "").trim();
3005
+ if (installId) url += `&install_id=${encodeURIComponent(installId)}`;
3006
+ }
3007
+ }
3008
+
3009
+ const r = await fetch(url, { ...init, signal: AbortSignal.timeout(8000) });
3010
+ if (r.status === 401) fbTokenCache = { token: "", exp: 0 }; // token 失效 → 下次请求重取
3011
+ const text = await r.text();
3012
+ res.writeHead(r.status, {
3013
+ "content-type": r.headers.get("content-type") || "application/json; charset=utf-8",
3014
+ "cache-control": "no-store",
3015
+ });
3016
+ res.end(text);
3017
+ } catch (e) {
3018
+ // 网络不可达 / 超时 / JSON 异常:一律 502 + 人话,绝不把异常抛回 webServer(那会变成 500 堆栈)
3019
+ return sendJson(res, 502, { ok: false, error: `公告服务不可达: ${e.message}` });
3020
+ }
3021
+ }
3022
+
2941
3023
  // ---------- 自管理:版本 / 在线更新 / 彻底卸载(面板内“版本与更新”卡片) ----------
2942
3024
 
2943
3025
  /** 插件 id / 包名(2026-09 由 dsh-remote-ui 更名)。 */
@@ -3263,6 +3345,8 @@ async function runOnlineUpdate(relayDir) {
3263
3345
  // 既不把内测装机降级到稳定版,也不让它错过已发布的正式版。
3264
3346
  const target = await resolveUpdateTarget();
3265
3347
  const spec = `@mrrisega/dsh-remote@${target.tag}`;
3348
+ // 新一轮开始 → 先把上一轮的日志归档(见 rotateUpdateLog 注释:无界增长 + 面板黑框拉回旧尾巴)
3349
+ rotateUpdateLog(relayDir);
3266
3350
  appendLogLine(relayDir, UPDATE_LOG,
3267
3351
  `[update] 开始在线更新 ${spec}(通道 ${UPDATE_TAG};${target.why})(${new Date().toISOString()})`);
3268
3352
  telemetryRecord(relayDir, "update_started"); // 匿名遥测:在线更新开始
@@ -3343,6 +3427,8 @@ async function runOnlineUpdate(relayDir) {
3343
3427
  telemetryRecord(relayDir, "update_failed", { fail_code: fc });
3344
3428
  } else {
3345
3429
  updateFailures.delete(String(relayDir));
3430
+ // 更新成功 → 落一条「必须重启 dsh web」的强提醒(见 run() 成功分支的同款注释)
3431
+ markRestartPending(relayDir, "restart", "插件已更新,重启 dsh web 后生效");
3346
3432
  }
3347
3433
  });
3348
3434
  child.unref();
@@ -3417,6 +3503,19 @@ async function runOnlineUpdate(relayDir) {
3417
3503
  telemetryRecord(relayDir, "update_failed", { fail_code: fc });
3418
3504
  } else {
3419
3505
  updateFailures.delete(String(relayDir)); // 成功即清掉旧失败
3506
+ // 【2026-09-26】更新成功 → 写一条 kind="restart" 的待重启标记。
3507
+ //
3508
+ // 修的是"文案与状态机不一致":版本卡一直写「请重启 dsh web 后生效」(client.js 的
3509
+ // `updated` 分支),但**没有任何一处**在成功路径上写过待重启状态 —— 于是那句文案是个
3510
+ // 无人兑现的承诺,用户在页面上也看不到任何"要动一下"的强提示。
3511
+ //
3512
+ // kind 用 "restart" 而**不是**既有的 "refresh":两者是**不同的动作**
3513
+ // · refresh = 插件文件在运行中被改写,刷新浏览器即可(客户端半新的,node 半也是新的)
3514
+ // · restart = 在线更新刚换了插件包,需要重启 dsh web 才载入新的 node 半
3515
+ // 客户端按 kind 一一对应地换文案与主按钮(见 client.js 的 restartAlert),
3516
+ // 混用一个 kind 就会再次出现"文案说刷新、状态其实是重启"。
3517
+ // settleRestartState() 靠 bootId 自动结清 —— 用户真的重启后这条提示自己消失,不需要额外清理。
3518
+ markRestartPending(relayDir, "restart", "插件已更新,重启 dsh web 后生效");
3420
3519
  }
3421
3520
  });
3422
3521
  child.unref();
@@ -3445,6 +3544,28 @@ function tailOf(filePath, lines = 24) {
3445
3544
  } catch { return ""; }
3446
3545
  }
3447
3546
 
3547
+ /**
3548
+ * 新一轮在线更新**开始前**轮转 `.dsh-update.log`:旧内容归档成 `.dsh-update.log.1`(只留一份)。
3549
+ *
3550
+ * 为什么必须在"开始"时做,而不是"成功"时清空:
3551
+ * ① 这份日志是 `openSync(..., "a")` **只 append 从不截断**的 —— 一轮轮更新下来会无界增长,
3552
+ * 违反工作区 AGENTS.md「日志必须有轮转」;而失败现场恰恰是最需要保留的那一份,
3553
+ * 所以在成功时删反而会把证据毁掉。归档旧的一轮、新的一轮另起一页才是对的分界。
3554
+ * ② 面板的「更新日志」黑框读的就是这个文件的尾部(tailOf):不轮转的话,**刷新页面还会把
3555
+ * 上一次的尾巴拉回来**,用户看到的"更新黑框永不消失"有一半责任在这里。
3556
+ * 客户端那半已经按 running 状态 gate 住了(见 client.js 的 updShowLog),这一条是宿主侧的根治。
3557
+ * 轮转失败(权限/占用)不致命:退化成继续 append 的旧行为,绝不影响更新本身。
3558
+ */
3559
+ function rotateUpdateLog(relayDir) {
3560
+ try {
3561
+ const file = join(relayDir, UPDATE_LOG);
3562
+ if (!existsSync(file)) return;
3563
+ const prev = `${file}.1`;
3564
+ try { rmSync(prev, { force: true }); } catch { /* 上一份归档删不掉就覆盖写,继续 */ }
3565
+ renameSync(file, prev);
3566
+ } catch { /* 轮转失败:静默,退化为 append */ }
3567
+ }
3568
+
3448
3569
  /**
3449
3570
  * 判断 patch 文本是否是「合法的顶层 YAML 数组文档」。
3450
3571
  *
@@ -4574,6 +4695,11 @@ const TELEMETRY_EVENT_NAMES = new Set([
4574
4695
  "tunnel_disconnected", "first_remote_ok", "plugin_loaded", "panel_opened", "harness_restart",
4575
4696
  "update_started", "update_failed",
4576
4697
  "wechat_bound", "wechat_unbound",
4698
+ // 2026-09-26(926 迭代)新增:手机镜像页**就绪耗时**。这一条是"慢加载"唯一的量化来源 ——
4699
+ // 在此之前只能靠用户体感反馈。字段 ms(耗时)/ at(客户端事件时刻)/ outcome(ok|slow)
4700
+ // 由路由 /dsh-remote/telemetry/mirror-load 校验后带入(见 telemetryEventOf)。
4701
+ // 与服务端 TELEMETRY_EVENTS 必须逐字对齐(少一个 = 服务端静默丢弃)。
4702
+ "mirror_load",
4577
4703
  ]);
4578
4704
  /**
4579
4705
  * fail_code 白名单 —— **必须与服务端 TELEMETRY_FAIL_CODES 完全一致**。
@@ -4856,6 +4982,17 @@ function telemetryEventOf(name, extra = {}) {
4856
4982
  const code = String(extra.fail_code || "");
4857
4983
  ev.fail_code = TELEMETRY_FAIL_CODES.has(code) ? code : "unknown"; // 白名单外 → unknown
4858
4984
  }
4985
+ // mirror_load:唯一带**数值**的事件。契约 §2 的两列语义:
4986
+ // · at = 客户端事件发生时刻(epoch ms)→ 服务端 at_ms
4987
+ // · ms = 耗时毫秒 → 服务端 value_ms
4988
+ // at 允许缺省(0 = 没给)→ 保留上面那个 Date.now()(= 服务端收包时刻的近似),
4989
+ // 但**绝不把 0 发出去**:0 在分析里会被读成"1970 年",比"没有值"更糟。
4990
+ // ms 同样只在有限数时写入(越界/非法由路由先挡掉,这里是第二道防线)。
4991
+ if (name === "mirror_load") {
4992
+ if (Number.isFinite(Number(extra.at)) && Number(extra.at) > 0) ev.at = Math.round(Number(extra.at));
4993
+ if (Number.isFinite(Number(extra.ms))) ev.ms = Math.max(0, Math.min(600000, Math.round(Number(extra.ms))));
4994
+ ev.outcome = extra.outcome === "slow" ? "slow" : "ok";
4995
+ }
4859
4996
  return ev;
4860
4997
  }
4861
4998
 
@@ -5708,6 +5845,36 @@ function registerRoutes(ctx, relayDir) {
5708
5845
  sendJson(res, 200, { ok: true });
5709
5846
  },
5710
5847
  },
5848
+ // 镜像页就绪耗时(D1 在 clients/dsh-remote/mobile-adapter.mjs 里量,经此入队):
5849
+ // body = { ms: 耗时毫秒, at: 事件时刻 epoch ms, outcome: "ok"|"slow" }
5850
+ // 契约(926 迭代 §2):事件名固定 mirror_load,ms 钳制 [0, 600000],outcome ∈ {ok, slow}。
5851
+ //
5852
+ // 🔒 入参校验失败**静默丢弃**但仍回 200 {ok:true}:这条上报是手机镜像页加载路径上的
5853
+ // fire-and-forget,任何 4xx 都可能被页面当成"功能坏了"而冒泡给用户;统计丢一条无所谓,
5854
+ // 打扰用户不可接受。校验放在**路由里**(而不是 telemetryRecord 里)是因为遥测入队
5855
+ // 必须保持"只认白名单、绝不抛"的既有语义,业务校验不属于那一层。
5856
+ {
5857
+ method: "POST",
5858
+ path: "/dsh-remote/telemetry/mirror-load",
5859
+ handler: async (req, res) => {
5860
+ try {
5861
+ const buf = await readBodyBuffer(req);
5862
+ let body = null;
5863
+ try { body = JSON.parse(buf.toString("utf8") || "{}"); } catch { body = null; }
5864
+ const ms = Number(body && body.ms);
5865
+ if (body && Number.isFinite(ms) && ms >= 0 && ms <= 600000) {
5866
+ const atRaw = Number(body.at);
5867
+ const at = Number.isFinite(atRaw) && atRaw > 0 ? Math.round(atRaw) : 0;
5868
+ telemetryRecord(relayDir, "mirror_load", {
5869
+ ms: Math.round(ms),
5870
+ at,
5871
+ outcome: body.outcome === "slow" ? "slow" : "ok",
5872
+ });
5873
+ }
5874
+ } catch { /* 体读不到/超限:静默 */ }
5875
+ sendJson(res, 200, { ok: true });
5876
+ },
5877
+ },
5711
5878
  // 手动重试(error 阶段的「重试」按钮):清掉退避计数立刻再走一遍闭环。
5712
5879
  {
5713
5880
  method: "POST",
@@ -6192,6 +6359,19 @@ function registerRoutes(ctx, relayDir) {
6192
6359
  await proxyFeedback(relayDir, req, res, url.pathname + url.search);
6193
6360
  },
6194
6361
  },
6362
+ // 📣 公告(面板「未读公告」入口 + 电脑端弹窗):
6363
+ // 声明成 prefix:true + 自行判别子路径,与 feedback 同一形态 ——
6364
+ // `/dsh-remote/announcements`(列表)与 `/dsh-remote/announcements/click`(点击/已读上报)
6365
+ // 是一对,拆成两条 exact 会在以后加子接口(比如详情)时又要动路由表。
6366
+ {
6367
+ method: "ALL",
6368
+ path: "/dsh-remote/announcements",
6369
+ prefix: true,
6370
+ handler: async (req, res) => {
6371
+ const url = new URL(req.url ?? "/", "http://x");
6372
+ await proxyAnnouncements(relayDir, req, res, url.pathname);
6373
+ },
6374
+ },
6195
6375
  ];
6196
6376
 
6197
6377
  const disposers = [];
@@ -6389,6 +6569,101 @@ async function pinBrowseDirectoryPicker(ctx, { attempts = 6, gapMs = 700 } = {})
6389
6569
  return pickerPinState;
6390
6570
  }
6391
6571
 
6572
+ /** 不持有进程的 sleep(未 ref:宿主随时可以正常退出,不被我们的定时器拖住)。 */
6573
+ function sleepUnref(ms) {
6574
+ return new Promise((resolve) => {
6575
+ const t = setTimeout(resolve, ms);
6576
+ if (t && typeof t.unref === "function") t.unref();
6577
+ });
6578
+ }
6579
+
6580
+ /* ── 延后接线的闸门参数(全部只为"别在宿主启动期动 Loader",不是功能参数) ───────────── */
6581
+ const PICKER_PIN_READY_WAIT_MS = 120_000; // 等宿主 HTTP 服务 listen 的上限
6582
+ const PICKER_PIN_SETTLE_MS = 8_000; // listen 之后再静置,避开首轮 client-module compose
6583
+ const PICKER_PIN_PROBE_WAIT_MS = 30_000; // 等服务挂出来的上限(只等,不换)
6584
+ const PICKER_PIN_PROBE_GAP_MS = 3_000; // 上面那段轮询间隔
6585
+
6586
+ /**
6587
+ * 目录选择器的**延后**接线 —— 这是 2026-09-26 启动事故的直接修复。
6588
+ *
6589
+ * 🔴 事故:旧实现在 `apply()` 里**立刻**调 `pinBrowseDirectoryPicker(ctx)`(最多 6 次 × 700ms 轮询)。
6590
+ * `loader.remove()` / `loader.create()` 会触发 dsh 的 `internal/plugin` 事件,而
6591
+ * `@deepseek-ai/dsh-client-modules` 收到事件就**重新 compose 整张前端插件图**
6592
+ * (`ClientModuleRegistry.flush() → buildCombo() → newlineCount()/identitySectionMap()`)。
6593
+ * 启动期本来就在装载几十个插件,此时再改 Loader = 昂贵的重复组合。真机 CPU profile 与实测都
6594
+ * 指向这里:把 `DSH_REMOTE_NATIVE_PICKER=1` 写进 launchd plist 后 dsh web 立刻恢复正常启动
6595
+ * (3080 监听、401 约 2ms、单实例);不设时出现过"进程活着、3 分钟不监听、CPU 一直转、
6596
+ * 日志不更新"的假死。
6597
+ *
6598
+ * 所以现在(与旧实现的四点区别):
6599
+ * ① **等宿主把 HTTP 服务 listen 起来**(`boundWebPort() > 0`)—— 这是"宿主已启动"最可靠的信号;
6600
+ * `apply()` 时它还没 listen 完,旧实现正是在"插件图还在装配"的窗口里动 Loader,最坏;
6601
+ * ② listen 之后再**静置** `PICKER_PIN_SETTLE_MS`,让首轮 compose 先跑完;
6602
+ * ③ 之后只在"服务还认不出"时短轮询;**一旦认出 native 就换,且只换这一次**(换失败也不再来回折腾);
6603
+ * ④ 认不出/超时/出错 → **静默保留原生选择器**(= 原始行为),绝不影响宿主启动。
6604
+ *
6605
+ * @returns {() => void} 取消函数(供 `ctx.effect` 做清理)
6606
+ */
6607
+ function schedulePickerPin(ctx) {
6608
+ let cancelled = false;
6609
+ const gone = () => cancelled;
6610
+
6611
+ const waitUntil = async (cond, timeoutMs, stepMs) => {
6612
+ const t0 = Date.now();
6613
+ for (;;) {
6614
+ if (gone()) return false;
6615
+ let ok = false;
6616
+ try { ok = !!cond(); } catch { ok = false; }
6617
+ if (ok) return true;
6618
+ if (Date.now() - t0 >= timeoutMs) return false;
6619
+ await sleepUnref(stepMs);
6620
+ }
6621
+ };
6622
+
6623
+ void (async () => {
6624
+ try {
6625
+ if (String(process.env.DSH_REMOTE_NATIVE_PICKER || "") === "1") {
6626
+ pickerPinState = { kind: directoryPickerKind(ctx), action: "skipped", detail: "DSH_REMOTE_NATIVE_PICKER=1,保留系统原生对话框" };
6627
+ return;
6628
+ }
6629
+ // ① 等宿主 listen(apply 时还没 listen 完)
6630
+ const up = await waitUntil(() => boundWebPort() > 0, PICKER_PIN_READY_WAIT_MS, 1000);
6631
+ if (gone()) return;
6632
+ if (!up) {
6633
+ pickerPinState = { kind: "", action: "skipped", detail: "宿主监听端口未在等待窗口内就绪,本次不干预目录选择器(保留原生)" };
6634
+ ctx.logger?.info?.("dsh-remote-web: 宿主监听端口未就绪,跳过目录选择器切换(不影响启动)");
6635
+ return;
6636
+ }
6637
+ // ② 静置,避开首轮 client-module compose
6638
+ await sleepUnref(PICKER_PIN_SETTLE_MS);
6639
+ if (gone()) return;
6640
+ // ③ 等服务挂出来(**只等不换**)
6641
+ await waitUntil(() => directoryPickerKind(ctx) !== "", PICKER_PIN_PROBE_WAIT_MS, PICKER_PIN_PROBE_GAP_MS);
6642
+ if (gone()) return;
6643
+ const kind = directoryPickerKind(ctx);
6644
+ if (!kind) {
6645
+ pickerPinState = { kind: "", action: "skipped", detail: "窗口内未确认到选择器服务,保留原生(下次重启再试)" };
6646
+ return;
6647
+ }
6648
+ if (kind === "browse") {
6649
+ pickerPinState = { kind, action: "none", detail: "已经是浏览器内选择器" };
6650
+ return;
6651
+ }
6652
+ if (kind !== "native") {
6653
+ pickerPinState = { kind, action: "skipped", detail: "选择器服务当前是 " + kind + ",不认识,保留原样" };
6654
+ return;
6655
+ }
6656
+ // ④ 只换一次(attempts:1 → 内部不会再轮询)
6657
+ await pinBrowseDirectoryPicker(ctx, { attempts: 1, gapMs: 0 });
6658
+ } catch (e) {
6659
+ // 目录选择器是**锦上添花**:任何异常都必须静默,绝不把宿主拖下水
6660
+ try { ctx.logger?.warn?.("dsh-remote-web: 目录选择器延后切换异常(已忽略):" + (e && e.message ? e.message : e)); } catch { /* ignore */ }
6661
+ }
6662
+ })();
6663
+
6664
+ return () => { cancelled = true; };
6665
+ }
6666
+
6392
6667
  export function apply(ctx, config = {}) {
6393
6668
  const relayDir = config.relayDir || process.env.DSH_RELAY_DIR || DEFAULT_RELAY_DIR;
6394
6669
  // 记住宿主 ctx:上游地址要问 dsh web 自己的监听端口(见 upstreamUrl)。
@@ -6429,11 +6704,9 @@ export function apply(ctx, config = {}) {
6429
6704
  // 0.1.2-rc.1+ 浏览器会话代持:换取 Harness 会话 Cookie 供 bridge 上游携带(手机点设备不再 401 白页)
6430
6705
  ctx.effect(() => scheduleHarnessMint(ctx, relayDir), "dsh-remote-web: harness browser-session mint");
6431
6706
  // 目录选择器:远程控制下必须用浏览器内实现(否则手机点「添加工作区」像没反应) —— 见上方长注释
6432
- ctx.effect(() => {
6433
- let alive = true;
6434
- void pinBrowseDirectoryPicker(ctx).catch(() => { /* 绝不影响宿主 */ });
6435
- return () => { alive = false; void alive; };
6436
- }, "dsh-remote-web: pin browser directory picker");
6707
+ // ⚠️ **必须延后**:绝不能在 apply() 里直接切 Loader —— 会把宿主启动拖成假死(2026-09-26 事故,
6708
+ // 见 schedulePickerPin 上方长注释)。schedulePickerPin 自己等"宿主已 listen + 静置"再动手。
6709
+ ctx.effect(() => schedulePickerPin(ctx), "dsh-remote-web: pin browser directory picker");
6437
6710
  // 插件市场一键全功能:缺桌面运行环境则自动安装,登录后自动拉起 bridge(不依赖用户跑 npx)
6438
6711
  ctx.effect(() => scheduleRuntime(relayDir), "dsh-remote-web: runtime self-provision");
6439
6712
  // 匿名遥测:插件停摆(卸载/重载)时停掉发送心跳;磁盘队列留给下次装载补发。
@@ -6455,6 +6728,6 @@ export function apply(ctx, config = {}) {
6455
6728
  // test hooks:cordis 只读 name/inject/apply,这些导出只给用例(见 test/picker-pin.test.mjs)。
6456
6729
  // 为什么不放进 apply 内部直接测:apply 是"装载即副作用"的同步函数,选择器固定是异步且带重试窗口的,
6457
6730
  // 单独导出才能把「**先摘 auto、后建 browse**」这个顺序契约钉死。
6458
- export { pinBrowseDirectoryPicker as __pinBrowseDirectoryPicker, pickerPinState as __pickerPinState, directoryPickerKind as __directoryPickerKind, PICKER_AUTO_ENTRY_ID as __PICKER_AUTO_ENTRY_ID, PICKER_AUTO_PACKAGE as __PICKER_AUTO_PACKAGE, PICKER_BROWSE_PACKAGES as __PICKER_BROWSE_PACKAGES,
6731
+ export { pinBrowseDirectoryPicker as __pinBrowseDirectoryPicker, schedulePickerPin as __schedulePickerPin, pickerPinState as __pickerPinState, directoryPickerKind as __directoryPickerKind, PICKER_AUTO_ENTRY_ID as __PICKER_AUTO_ENTRY_ID, PICKER_AUTO_PACKAGE as __PICKER_AUTO_PACKAGE, PICKER_BROWSE_PACKAGES as __PICKER_BROWSE_PACKAGES,
6459
6732
  // 日志去重(同一条失败不刷屏)也要能被单测钉住 —— 它属于「诊断可读性」,见 test/linux-bridge.test.mjs
6460
6733
  noteAutostartFailure as __noteAutostartFailure, clearAutostartFailure as __clearAutostartFailure };
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-remote-web",
3
- "version": "0.6.17",
3
+ "version": "0.6.18-beta.2",
4
4
  "description": "公网远程控制 DeepSeek Harness(dsh web):安装即得专属加密地址,人在外面也能用手机访问电脑上的 dsh——无需同一局域网/WiFi、无需公网 IP 与内网穿透,全程加密;手机端 100% 还原电脑体验(对话/工具/审批/设置)。技术用户可选自建服务,流量走自己的服务器。Remote control DeepSeek Harness (dsh web) from anywhere over the public internet — install, get an encrypted URL, use it from your phone on any network (dual-half cordis plugin: Settings panel + same-origin /dsh-remote routes). (2026-09 由 dsh-remote-ui 更名 / renamed from dsh-remote-ui; dsh-remote 的 dsh web 插件半,不是纯 UI/皮肤插件)",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",