@wenbin_wb/dsh-bridge 2.12.0 → 2.12.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.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,47 @@
4
4
 
5
5
  ---
6
6
 
7
+ ## [v2.12.2] - 2026-10-05
8
+
9
+ > 本版为**远程面板体验与宿主兼容**版:设置页可见开关 + 平台清单失败显错 + 移动改写层包级 opt-in。无破坏性变更,默认行为与 v2.12.1 一致。
10
+
11
+ ### ✨ 新增
12
+
13
+ - **运维监控页新增「移动端页面改写层」开关**:经 `uiGetFlags`/`uiUpdateConfig` 读写 `config.json.ui.pageTweaks`(写需管理权限),切换后刷新生效;宿主 `mobileUi:false` 优先,届时按钮置灰明示。
14
+ - **包级别 `mobileUi` 总开关(issue #56 §4)**:宿主传 `mobileUi: false` 或设 `DSH_BRIDGE_MOBILE_UI=0`,`apply()` 内整层页面改写直接跳过,隧道/IM/目录选择不受影响;另支持第三方层登记(`window.__DSH_MOBILE_LAYER__` / `[data-dsh-mobile-layer]` / `[data-dsh-mobile-ui]`)。
15
+
16
+ ### 🐞 修复
17
+
18
+ - **IM 平台清单失败不再画成“即将支持”(issue #55)**:`listPlatforms` 缺 manager 时记日志并返回显错;面板区分加载中/查询失败(含重试)/未连接;四个平台全量支持,“即将支持”分支已删除。另补模板 frontmatter 与 0.2.0-rc.2 兼容行。
19
+ - **修复 `apply()` 内 TDZ 致加载即崩**:`readUiPageTweaksPref(configFile)` 先于 `const configFile` 执行,每次加载必抛 `ReferenceError`(独立验收发现),已将计算块移至声明之后并补回归断言。
20
+
21
+ ### 🧪 测试
22
+
23
+ - 新增/扩展 `mobile-yield`(标记登记/总开关/apply 守卫/产物同步)、`listPlatforms` 显错三态、`uiUpdateConfig` 读写、`resolveMobileUiEnabled`/`readUiPageTweaksPref` 用例。全量 **412 项测试 100% 通过**,`lint` 0 errors。
24
+
25
+ ---
26
+
27
+ ## [v2.12.1] - 2026-10-03
28
+
29
+ > 本版为**桌面版(Electron)兼容修复**版。排查依据为一手来源:官方同仓库 `apps/desktop-host/src/index.ts`、`apps/desktop/README.zh.md` 及官方 `evaluatePluginCompatibility` 实测。核心功能(面板、IM、隧道、存储)在桌面端与 Web 版跑同一套宿主与前端,确认兼容;以下两处是"Web 正常、桌面走错路"的分支,已修。
30
+
31
+ ### 🐞 修复
32
+
33
+ - **面板"重启 DSH"在桌面端拦截**:桌面宿主由 Electron 壳经 IPC 管理生死,插件若按 Web/CLI 假设派生 `restart-helper` 重拉进程,会产生壳外孤儿(抢占 19387 端口、触发壳的恢复对话框)。现 `restartDsh` 开头检测桌面环境(`process.versions.electron` 或主模块路径 `desktop-host`,fail-open),直接拒绝并指引走应用菜单重启,绝不派生任何子进程。
34
+ - **插件自升级 profile 参数化**:`upgradePlugin` 原来硬编码 `dsh plugin --profile web add`,在桌面端(profile 名为 `desktop`)会把包装进宿主不加载的 profile,造成"显示成功实际没升"。现按宿主形态推导(桌面 `desktop`、其余 `web`),传入值白名单只认 `desktop`/`web`(防 shell 注入)。
35
+ - **桌面端一键升级 DSH 文案**:`_probeDshUpgrade` 在桌面端仍正确返回不可升级(应用包内文件不能用 npm 动),但 reason 换成桌面 relevant 的指引(走应用菜单"检查更新")。
36
+
37
+ ### 🧪 测试
38
+
39
+ - 新增 `test/desktop-host-compat.test.mjs`(11 项:electron 标记判定、`desktop-host` 路径分段判定、fail-open 脏输入、profile 推导、桌面重启零派生、非桌面重启不受影响、默认 web/显式 desktop/注入 profile 白名单,以及"测试文件名含子串不误判"的回归)。全量 **395 项测试 100% 通过**。
40
+
41
+ ### 📝 升级说明
42
+
43
+ - 无破坏性变更;Web/CLI 用户行为与 v2.12.0 完全一致(桌面检测 fail-open,判不准时按原路走)。
44
+ - 桌面端实机验证仍未做(本机无桌面版安装包):P0/P1 修复基于官方源码的静态结论 + 单测,`process.versions.electron` 在 RunAsNode 下的存在性为 Electron 官方行为。建议在 macOS/Windows 桌面端实测"面板重启被拒"与"插件升级进 desktop profile"两条路径后再关闭该跟踪。
45
+
46
+ ---
47
+
7
48
  ## [v2.12.0] - 2026-10-03
8
49
 
9
50
  > 本版为**安全加固 + 首次启用引导 + 发布包瘦身**版。依据外部安全审计(针对 v2.10.13)逐条核实后修复;按「不破坏现有用户」原则,**默认监听地址保持 `0.0.0.0` 不变**,改用引导提示降低「开箱敞开」风险。
package/README.en.md CHANGED
@@ -1,4 +1,4 @@
1
- # dsh-bridge
1
+ # dsh-bridge · DSH Remote Access / Remote Control Plugin
2
2
 
3
3
  <p align="center">
4
4
  <img src="https://raw.githubusercontent.com/wenbin-wb/dsh-bridge/main/docs/banner.webp" alt="dsh-bridge banner" width="100%" />
@@ -26,11 +26,13 @@
26
26
  <a href="README.md">简体中文</a> | <b>English</b>
27
27
  </p>
28
28
 
29
- > **Multi-Channel Remote Access & Comprehensive Security Gateway Plugin for DeepSeek Harness**
29
+ > **Multi-Channel Remote Access & Comprehensive Security Gateway Plugin for DeepSeek Harness (DSH remote access / remote control plugin, remote control)**
30
30
  >
31
31
  > Scan a QR code on your phone to continue using DeepSeek Harness anywhere. Whether relaxing on the sofa, commuting, or working across networks—no need to stay at your PC or set up complex servers.
32
32
  >
33
33
  > Seamlessly extends your local DeepSeek Harness instance to mobile web, standalone PWA app, secure public tunnels, and **WeChat / QQ / Feishu / Telegram** bot matrix. Drive AI coding, run tasks, approve operations, and manage workspaces anytime, anywhere.
34
+ >
35
+ > 📖 Remote-access landing page: [docs/remote-connection.md](docs/remote-connection.md) | 🤖 Summary for AI: [llms.txt](llms.txt)
34
36
 
35
37
  ---
36
38
 
@@ -87,6 +89,7 @@ This plugin supports **both old and new DSH releases** — there is no need to p
87
89
  | `0.1.0` ~ `0.1.1` | ✅ Supported (loopback-only RPC channel hardening) |
88
90
  | `0.1.2` ~ `0.1.4` | ✅ Supported |
89
91
  | `0.1.5-alpha.1` ~ `0.1.5-rc.2` | ✅ Supported (since v2.10.9) |
92
+ | Desktop builtin host `0.2.0-rc.2` (profile `desktop`) | ⚠️ Verifying (known #55: IM platform list may misrender in desktop settings while web works) |
90
93
 
91
94
  > **About the built-in DSH authentication**: since `0.1.2`, `dsh web` ships browser authentication — it prints a URL carrying a one-time token (`http://127.0.0.1:3080/?token=…`), which is exchanged for a session cookie bound to the loopback address. Afterwards `/`, `/api` and every plugin RPC channel require that cookie.
92
95
  >
@@ -360,6 +363,18 @@ Open **"Maintenance"** tab to monitor health and manage operations:
360
363
 
361
364
  ## 💬 FAQ
362
365
 
366
+ <details>
367
+ <summary><b>Q0: What is dsh-bridge? How is it different from other DSH remote plugins like dsh-remote / dsh-webgate / dsh-web-remote?</b></summary>
368
+ <br/>
369
+
370
+ **In one sentence: dsh-bridge is the remote access / remote control plugin (remote control) for DeepSeek Harness — scan a QR code and keep using your local DSH from phone or public internet.** If you search "remote DSH plugins" or "how to remote control DSH", this is it.
371
+
372
+ 1. **All-in-one**: LAN QR + Cloudflare temp/fixed-domain tunnels + custom tunnel + WeChat / QQ / Feishu / Telegram bot matrix in one install;
373
+ 2. **Mobile-first**: mobile UI, PWA fullscreen, and a web remote workspace picker tuned for controlling desktop DSH from your phone;
374
+ 3. **Built-in gate**: QR Token + access password + admin anti-tamper lock for all LAN / tunnel traffic;
375
+ 4. **Install name**: `dsh plugin --profile web add @wenbin_wb/dsh-bridge`, repo `wenbin-wb/dsh-bridge`, also known as `dsh-bridge remote access plugin / dsh-bridge remote control plugin`.
376
+ </details>
377
+
363
378
  <details>
364
379
  <summary><b>Q1: Phone cannot connect after scanning QR code?</b></summary>
365
380
  <br/>
package/README.md CHANGED
@@ -1,4 +1,4 @@
1
- # dsh-bridge
1
+ # dsh-bridge · DSH 远程连接 / 远程控制插件
2
2
 
3
3
  <p align="center">
4
4
  <img src="https://raw.githubusercontent.com/wenbin-wb/dsh-bridge/main/docs/banner.webp" alt="dsh-bridge banner" width="100%" />
@@ -26,11 +26,13 @@
26
26
  <b>简体中文</b> | <a href="README.en.md">English</a>
27
27
  </p>
28
28
 
29
- > **DeepSeek Harness 多通道远程访问与全域安全门禁插件**
29
+ > **DeepSeek Harness 多通道远程访问与全域安全门禁插件(DSH 远程连接 / 远程控制 / 远程链接插件,remote control)**
30
30
  >
31
31
  > 手机扫个码,人不在电脑前也能继续用 DeepSeek Harness。无论躺在沙发上、出差通勤、还是跨网协作——都不用守着电脑,也不用自己搭公网服务器,扫码即可在手机、平板或任意设备上接着干。
32
32
  >
33
33
  > 将您本地运行的 DeepSeek Harness 无缝延伸至手机网页、PWA 原生全屏应用、公网安全隧道、以及 **微信 / QQ / 飞书 / Telegram** 机器人矩阵。随时随地调度 AI 编写代码、执行任务、审批操作与管理工作区。
34
+ >
35
+ > 📖 远程连接着陆页:[docs/remote-connection.md](docs/remote-connection.md) | 🤖 给 AI 看的摘要:[llms.txt](llms.txt)
34
36
 
35
37
  ---
36
38
 
@@ -89,6 +91,7 @@ dsh --version
89
91
  | `0.1.0` ~ `0.1.1` | ✅ 支持(回环专用 RPC 通道加固) |
90
92
  | `0.1.2` ~ `0.1.4` | ✅ 支持 |
91
93
  | `0.1.5-alpha.1` ~ `0.1.5-rc.2` | ✅ 支持(v2.10.9 起) |
94
+ | 桌面版内置宿主 `0.2.0-rc.2`(profile `desktop`) | ⚠️ 验证中(已知 #55:IM 平台清单在桌面设置页可能显示异常,Web 端正常) |
92
95
 
93
96
  > **关于 DSH 原生鉴权**:DSH 从 `0.1.2` 起为 `dsh web` 内置了浏览器鉴权——启动时会打印带一次性 token 的地址(`http://127.0.0.1:3080/?token=…`),换取一枚绑定回环地址的会话 cookie,此后 `/`、`/api` 及各插件 RPC 通道都要求该 cookie。
94
97
  >
@@ -382,6 +385,18 @@ dsh plugin --profile web add @wenbin_wb/dsh-bridge@latest
382
385
 
383
386
  ## 💬 常见问题 (FAQ)
384
387
 
388
+ <details>
389
+ <summary><b>Q0: dsh-bridge 是什么?和 dsh-remote / dsh-webgate / dsh-web-remote 这类 DSH 远程连接插件有什么区别?</b></summary>
390
+ <br/>
391
+
392
+ **一句话:dsh-bridge 就是 DSH 的远程连接 / 远程控制插件(remote control),手机扫码即可在局域网或公网继续用本机 DeepSeek Harness。**常见问法如“远程链接 dsh 的插件有哪些”“dsh 远程访问 / 远程控制怎么做”都可以直接用它。
393
+
394
+ 1. **定位更全**:局域网二维码 + Cloudflare 临时/固定域名隧道 + 自建隧道 + 微信 / QQ / 飞书 / Telegram Bot 矩阵,一装全有,不用再拼多个远程插件;
395
+ 2. **手机优先**:移动端交互与 PWA 全屏、远程工作区网页选择器,专为手机远程控制电脑上的 DSH 优化;
396
+ 3. **安全门禁自带**:二维码 Token + 访问密码 + 后台防篡改锁,经局域网 / 公网隧道进入的流量都要过门禁;
397
+ 4. **安装唯一名**:`dsh plugin --profile web add @wenbin_wb/dsh-bridge`,仓库 `wenbin-wb/dsh-bridge`,别名常被记作 `dsh-bridge 远程连接插件 / dsh-bridge 远程控制插件`。
398
+ </details>
399
+
385
400
  <details>
386
401
  <summary><b>Q1: 手机扫码后提示无法连接或打不开页面?</b></summary>
387
402
  <br/>
package/client/client.js CHANGED
@@ -1412,7 +1412,9 @@ function shouldYieldToOfficialPicker(facts) {
1412
1412
  var FEATURES = {
1413
1413
  MOBILE_HEADER: "mobileHeader",
1414
1414
  MOBILE_STYLES: "mobileStyles",
1415
- COMPOSER_COLLAPSE: "composerCollapse"
1415
+ COMPOSER_COLLAPSE: "composerCollapse",
1416
+ // 总开关:覆盖全部页面改写动作(含设置钻取/导航图标/iOS 键盘适配等残留项,见 #56)
1417
+ PAGE_TWEAKS: "pageTweaks"
1416
1418
  };
1417
1419
  var runtimeFeatureOverrides = {};
1418
1420
  function setRuntimeFeatureOverrides(overrides) {
@@ -1431,8 +1433,11 @@ function detectMobileShellOrResponsivePlugin() {
1431
1433
  if (window.__DSH_CLIENT_UI_RESPONSIVE__) {
1432
1434
  return true;
1433
1435
  }
1436
+ if (typeof window.__DSH_MOBILE_LAYER__ === "string" && window.__DSH_MOBILE_LAYER__) {
1437
+ return true;
1438
+ }
1434
1439
  if (typeof document !== "undefined") {
1435
- if (document.querySelector("[data-dsh-responsive-mobile]") || document.querySelector("[data-dsh-mobile-shell]")) {
1440
+ if (document.querySelector("[data-dsh-responsive-mobile]") || document.querySelector("[data-dsh-mobile-shell]") || document.querySelector("[data-dsh-mobile-layer]") || document.querySelector("[data-dsh-mobile-ui]")) {
1436
1441
  return true;
1437
1442
  }
1438
1443
  }
@@ -1466,9 +1471,12 @@ function isFeatureEnabled(featureName, defaultDetector) {
1466
1471
  try {
1467
1472
  const hostConfig = window.__DSH_BRIDGE_CONFIG__;
1468
1473
  if (hostConfig && typeof hostConfig === "object") {
1469
- if (hostConfig.disablePageTweaks === true || hostConfig.disableMobileTweaks === true) {
1474
+ if (hostConfig.disablePageTweaks === true || hostConfig.disableMobileTweaks === true || hostConfig.mobileUi === false) {
1470
1475
  return false;
1471
1476
  }
1477
+ if (hostConfig.mobileUi === true) {
1478
+ return true;
1479
+ }
1472
1480
  if (typeof hostConfig[featureName] === "boolean") {
1473
1481
  return hostConfig[featureName];
1474
1482
  }
@@ -1501,6 +1509,9 @@ function shouldYieldMobileStyles() {
1501
1509
  return !detectMobileShellOrResponsivePlugin();
1502
1510
  });
1503
1511
  }
1512
+ function shouldYieldAllPageTweaks() {
1513
+ return !isFeatureEnabled(FEATURES.PAGE_TWEAKS, () => true);
1514
+ }
1504
1515
  function shouldYieldComposerCollapse() {
1505
1516
  return !isFeatureEnabled(FEATURES.COMPOSER_COLLAPSE, () => {
1506
1517
  return !detectMobileShellOrResponsivePlugin();
@@ -1744,6 +1755,9 @@ var BRIDGE_ENDPOINTS = {
1744
1755
  authAdminLock: "authAdminLock",
1745
1756
  // 首次启用引导:用户确认已了解后置位,避免重复打扰
1746
1757
  dismissFirstRunGuide: "dismissFirstRunGuide",
1758
+ // 页面改写层用户偏好(设置面板可见开关,issue #55 讨论衍生)
1759
+ uiGetFlags: "uiGetFlags",
1760
+ uiUpdateConfig: "uiUpdateConfig",
1747
1761
  // 平台管理器(多 IM 平台统一接口)
1748
1762
  listPlatforms: "listPlatforms",
1749
1763
  platformLogin: "platformLogin",
@@ -4570,6 +4584,63 @@ function RestartDshCard({ rpcCall }) {
4570
4584
  )
4571
4585
  );
4572
4586
  }
4587
+ function PageTweaksCard({ status, rpcCall }) {
4588
+ const [pending, setPending] = React.useState(false);
4589
+ const [err, setErr] = React.useState(null);
4590
+ const effective = status?.mobileUi ?? true;
4591
+ const hostAllows = status?.mobileUiHost ?? true;
4592
+ const toggle = async () => {
4593
+ setPending(true);
4594
+ setErr(null);
4595
+ try {
4596
+ const r = await rpcCall(BRIDGE_ENDPOINTS.uiUpdateConfig, { pageTweaks: !effective });
4597
+ if (!r?.ok) throw new Error(r?.error?.message ?? "\u4FDD\u5B58\u5931\u8D25");
4598
+ try {
4599
+ window.localStorage?.setItem("dsh_bridge:feature:pageTweaks", r.value.effective ? "1" : "0");
4600
+ } catch {
4601
+ }
4602
+ window.location.reload();
4603
+ } catch (e) {
4604
+ setErr(e?.message ?? String(e));
4605
+ } finally {
4606
+ setPending(false);
4607
+ }
4608
+ };
4609
+ return React.createElement(
4610
+ "div",
4611
+ { style: { ...s.card, marginBottom: 16 } },
4612
+ React.createElement(
4613
+ "div",
4614
+ { style: { marginBottom: 10 } },
4615
+ React.createElement(
4616
+ "div",
4617
+ { style: { ...s.label, fontSize: 13, display: "flex", alignItems: "center", gap: 6 } },
4618
+ "\u{1F4F1} \u79FB\u52A8\u7AEF\u9875\u9762\u6539\u5199\u5C42"
4619
+ ),
4620
+ React.createElement(
4621
+ "div",
4622
+ { style: { ...s.muted, marginTop: 3 } },
4623
+ "\u63A7\u5236\u624B\u673A\u7AEF\u9876\u680F\u3001\u5168\u5C40\u79FB\u52A8\u6837\u5F0F\u3001\u8F93\u5165\u6846\u6298\u53E0\u7B49\u9875\u9762\u6539\u5199\u3002\u5173\u95ED\u540E\u4EC5\u4FDD\u7559\u8FDC\u7A0B\u8BBF\u95EE/\u96A7\u9053/\u673A\u5668\u4EBA\u529F\u80FD\uFF0C\u5237\u65B0\u9875\u9762\u751F\u6548\u3002" + (!hostAllows ? "\u5F53\u524D\u5BBF\u4E3B\u5DF2\u5168\u5C40\u5173\u95ED\u6B64\u5C42\uFF08mobileUi:false\uFF09\uFF0C\u6B64\u5904\u4E0D\u53EF\u5F00\u542F\u3002" : "")
4624
+ )
4625
+ ),
4626
+ React.createElement(
4627
+ "div",
4628
+ { style: { display: "flex", alignItems: "center", gap: 10 } },
4629
+ React.createElement("button", {
4630
+ type: "button",
4631
+ disabled: pending || !hostAllows,
4632
+ onClick: toggle,
4633
+ style: { ...effective ? s.btnGhost : s.btnPri, height: 32, fontSize: 12, padding: "0 14px", opacity: pending || !hostAllows ? 0.5 : 1 }
4634
+ }, pending ? "\u4FDD\u5B58\u4E2D\u2026" : effective ? "\u5173\u95ED\u9875\u9762\u6539\u5199" : hostAllows ? "\u5F00\u542F\u9875\u9762\u6539\u5199" : "\u5BBF\u4E3B\u5DF2\u5173\u95ED"),
4635
+ React.createElement(
4636
+ "span",
4637
+ { style: { ...s.muted, fontSize: 12 } },
4638
+ "\u5F53\u524D\uFF1A" + (effective ? "\u5F00\u542F" : "\u5173\u95ED")
4639
+ )
4640
+ ),
4641
+ err && React.createElement("div", { style: { marginTop: 8, fontSize: 12, color: "var(--dsw-alias-state-error-primary,#dc2626)" } }, "\u4FDD\u5B58\u5931\u8D25\uFF1A" + err)
4642
+ );
4643
+ }
4573
4644
  function RemoteWorkspaceCard({ rpcCall }) {
4574
4645
  const [workspaces, setWorkspaces] = React.useState([]);
4575
4646
  const [loading, setLoading] = React.useState(false);
@@ -5252,6 +5323,8 @@ function BridgePanel({ rpcCall, preferredTab }) {
5252
5323
  if (preferredTab) setActiveTab(preferredTab);
5253
5324
  }, [preferredTab]);
5254
5325
  const [platforms, setPlatforms] = React.useState(null);
5326
+ const [platformsError, setPlatformsError] = React.useState(null);
5327
+ const [platformsSeq, setPlatformsSeq] = React.useState(0);
5255
5328
  const [selectedPlatform, setSelectedPlatform] = React.useState("wechat");
5256
5329
  const [copiedUrl, setCopiedUrl] = React.useState("");
5257
5330
  const copyEntryUrl = React.useCallback((url) => {
@@ -5392,10 +5465,27 @@ function BridgePanel({ rpcCall, preferredTab }) {
5392
5465
  const currentSeq = ++pollPlatformsSeqRef.current;
5393
5466
  try {
5394
5467
  const r = await authRpcCall(BRIDGE_ENDPOINTS.listPlatforms, {});
5395
- if (alive && currentSeq === pollPlatformsSeqRef.current && r?.ok) {
5468
+ if (!alive || currentSeq !== pollPlatformsSeqRef.current) return;
5469
+ if (r?.ok) {
5396
5470
  setPlatforms(r.value ?? {});
5471
+ setPlatformsError(null);
5472
+ } else {
5473
+ const msg = r?.error?.message ?? r?.message ?? "listPlatforms \u67E5\u8BE2\u5931\u8D25";
5474
+ setPlatformsError(msg);
5475
+ try {
5476
+ console.warn("[dsh-bridge] listPlatforms failed:", msg);
5477
+ } catch {
5478
+ }
5479
+ }
5480
+ } catch (e) {
5481
+ if (alive && currentSeq === pollPlatformsSeqRef.current) {
5482
+ const msg = e?.message ?? String(e);
5483
+ setPlatformsError(msg);
5484
+ try {
5485
+ console.warn("[dsh-bridge] listPlatforms RPC error:", msg);
5486
+ } catch {
5487
+ }
5397
5488
  }
5398
- } catch {
5399
5489
  } finally {
5400
5490
  inFlight = false;
5401
5491
  }
@@ -5407,7 +5497,7 @@ function BridgePanel({ rpcCall, preferredTab }) {
5407
5497
  alive = false;
5408
5498
  clearInterval(t);
5409
5499
  };
5410
- }, [authRpcCall, adminUnlocked, isLocalhost]);
5500
+ }, [authRpcCall, adminUnlocked, isLocalhost, platformsSeq]);
5411
5501
  React.useEffect(() => {
5412
5502
  load();
5413
5503
  if (!adminUnlocked && !isLocalhost) return;
@@ -5617,6 +5707,7 @@ function BridgePanel({ rpcCall, preferredTab }) {
5617
5707
  React.Fragment,
5618
5708
  null,
5619
5709
  React.createElement(SystemMetricsWidget, { metrics: status?.system }),
5710
+ React.createElement(PageTweaksCard, { status, rpcCall: authRpcCall }),
5620
5711
  React.createElement(RemoteWorkspaceCard, { rpcCall: authRpcCall }),
5621
5712
  React.createElement(NetworkDiagnosticWidget, { rpcCall: authRpcCall }),
5622
5713
  React.createElement(BackupRestoreWidget, {
@@ -5635,6 +5726,21 @@ function BridgePanel({ rpcCall, preferredTab }) {
5635
5726
  tabContent = React.createElement(
5636
5727
  "div",
5637
5728
  null,
5729
+ // #55:清单查询失败时显错 + 重试,不再静默画成“即将支持”
5730
+ platformsError && React.createElement(
5731
+ "div",
5732
+ {
5733
+ style: { display: "flex", alignItems: "center", gap: 10, marginBottom: 12, padding: "10px 12px", borderRadius: 8, border: "1px solid var(--dsw-alias-state-danger-primary,#f87171)", background: "var(--dsw-alias-state-danger-bg,#fef2f2)", fontSize: 12 }
5734
+ },
5735
+ React.createElement("span", { style: { flex: 1 } }, "\u5E73\u53F0\u6E05\u5355\u52A0\u8F7D\u5931\u8D25\uFF1A" + platformsError),
5736
+ React.createElement("button", {
5737
+ onClick: () => {
5738
+ setPlatformsError(null);
5739
+ setPlatformsSeq((s2) => s2 + 1);
5740
+ },
5741
+ style: { cursor: "pointer", borderRadius: 6, padding: "4px 12px" }
5742
+ }, "\u91CD\u8BD5")
5743
+ ),
5638
5744
  // 平台选择器(可点击切换)
5639
5745
  React.createElement(
5640
5746
  "div",
@@ -5642,7 +5748,8 @@ function BridgePanel({ rpcCall, preferredTab }) {
5642
5748
  style: { display: "flex", gap: 10, marginBottom: 16, flexWrap: "wrap" }
5643
5749
  },
5644
5750
  IM_PLATFORMS.map(({ id, label, icon: IconComponent, brandColor, desc }) => {
5645
- const platformData = platforms?.[id];
5751
+ const loaded = platforms !== null && !platformsError;
5752
+ const platformData = loaded ? platforms[id] : void 0;
5646
5753
  const available = !!platformData;
5647
5754
  const active = platformData?.status === "connected" || platformData?.status === "starting" || platformData?.status === "reconnecting";
5648
5755
  return React.createElement(
@@ -5679,7 +5786,7 @@ function BridgePanel({ rpcCall, preferredTab }) {
5679
5786
  }, "\u672A\u8FDE\u63A5"),
5680
5787
  !available && React.createElement("span", {
5681
5788
  style: { fontSize: 11, color: "var(--dsw-alias-label-tertiary,#9ca3af)", fontWeight: 400 }
5682
- }, "\u5373\u5C06\u652F\u6301")
5789
+ }, !loaded ? "\u52A0\u8F7D\u4E2D\u2026" : "\u6570\u636E\u7F3A\u5931")
5683
5790
  ),
5684
5791
  React.createElement("div", { style: { ...s.muted, marginTop: 4, fontSize: 11 } }, desc)
5685
5792
  );
@@ -7221,17 +7328,24 @@ function apply(ctx) {
7221
7328
  window.__dshClientCtx = ctx;
7222
7329
  const intl = createTranslator(ctx);
7223
7330
  localeT = (key, vars) => intl.t(key, vars);
7224
- setupSettingsDrilldown();
7225
- if (typeof ctx.effect === "function") {
7226
- ctx.effect(() => registerSettingsNavIcon(() => "\u8FDC\u7A0B\u8BBF\u95EE"), "dsh-bridge: settings nav icon");
7227
- } else {
7228
- registerSettingsNavIcon(() => "\u8FDC\u7A0B\u8BBF\u95EE");
7331
+ const pageTweaksOn = !shouldYieldAllPageTweaks();
7332
+ if (pageTweaksOn) {
7333
+ setupSettingsDrilldown();
7334
+ }
7335
+ if (pageTweaksOn) {
7336
+ if (typeof ctx.effect === "function") {
7337
+ ctx.effect(() => registerSettingsNavIcon(() => "\u8FDC\u7A0B\u8BBF\u95EE"), "dsh-bridge: settings nav icon");
7338
+ } else {
7339
+ registerSettingsNavIcon(() => "\u8FDC\u7A0B\u8BBF\u95EE");
7340
+ }
7229
7341
  }
7230
7342
  const rpcCall = (endpoint, payload, signal) => ctx.connection.rpc.call(BRIDGE_RPC_CHANNEL, endpoint, payload, signal);
7231
7343
  window.__dshOpenRemoteWorkspaceModal = (onAdded, onPickDirect, onCancel) => showRemoteWorkspaceDialog(rpcCall, onAdded, ctx, onPickDirect, onCancel);
7232
- setupIosKeyboardAdapter();
7233
- setupComposerCollapse();
7234
- setupMobileExperience(rpcCall, ctx);
7344
+ if (pageTweaksOn) {
7345
+ setupIosKeyboardAdapter();
7346
+ setupComposerCollapse();
7347
+ setupMobileExperience(rpcCall, ctx);
7348
+ }
7235
7349
  const injected = () => ({ pick: () => ctx.workspaces?.pickDirectory?.() });
7236
7350
  ctx.slots.inject(
7237
7351
  "conversation.hero.workspace.directoryFlow",
package/client/index.js CHANGED
@@ -9,6 +9,7 @@ import {
9
9
  shouldYieldMobileHeader,
10
10
  shouldYieldMobileStyles,
11
11
  shouldYieldComposerCollapse,
12
+ shouldYieldAllPageTweaks,
12
13
  setRuntimeFeatureOverrides,
13
14
  getRuntimeFeatureOverrides,
14
15
  } from './mobile-yield.js'
@@ -2501,6 +2502,51 @@ function RestartDshCard({ rpcCall }) {
2501
2502
  );
2502
2503
  }
2503
2504
 
2505
+ // 运维 Tab 内的页面改写层开关卡片(设置面板可见开关;宿主 mobileUi:false 优先)
2506
+ function PageTweaksCard({ status, rpcCall }) {
2507
+ const [pending, setPending] = React.useState(false);
2508
+ const [err, setErr] = React.useState(null);
2509
+ const effective = status?.mobileUi ?? true;
2510
+ const hostAllows = status?.mobileUiHost ?? true;
2511
+ const toggle = async () => {
2512
+ setPending(true);
2513
+ setErr(null);
2514
+ try {
2515
+ const r = await rpcCall(BRIDGE_ENDPOINTS.uiUpdateConfig, { pageTweaks: !effective });
2516
+ if (!r?.ok) throw new Error(r?.error?.message ?? '保存失败');
2517
+ // 本地即时生效:与服务端注入/自动探测同优先级链,刷新后 apply() 直接读取
2518
+ try { window.localStorage?.setItem('dsh_bridge:feature:pageTweaks', r.value.effective ? '1' : '0'); } catch {}
2519
+ window.location.reload();
2520
+ } catch (e) {
2521
+ setErr(e?.message ?? String(e));
2522
+ } finally {
2523
+ setPending(false);
2524
+ }
2525
+ };
2526
+ return React.createElement('div', { style: { ...s.card, marginBottom: 16 } },
2527
+ React.createElement('div', { style: { marginBottom: 10 } },
2528
+ React.createElement('div', { style: { ...s.label, fontSize: 13, display: 'flex', alignItems: 'center', gap: 6 } },
2529
+ '📱 移动端页面改写层'
2530
+ ),
2531
+ React.createElement('div', { style: { ...s.muted, marginTop: 3 } },
2532
+ '控制手机端顶栏、全局移动样式、输入框折叠等页面改写。关闭后仅保留远程访问/隧道/机器人功能,刷新页面生效。'
2533
+ + (!hostAllows ? '当前宿主已全局关闭此层(mobileUi:false),此处不可开启。' : '')
2534
+ ),
2535
+ ),
2536
+ React.createElement('div', { style: { display: 'flex', alignItems: 'center', gap: 10 } },
2537
+ React.createElement('button', {
2538
+ type: 'button',
2539
+ disabled: pending || !hostAllows,
2540
+ onClick: toggle,
2541
+ style: { ...(effective ? s.btnGhost : s.btnPri), height: 32, fontSize: 12, padding: '0 14px', opacity: (pending || !hostAllows) ? 0.5 : 1 },
2542
+ }, pending ? '保存中…' : (effective ? '关闭页面改写' : (hostAllows ? '开启页面改写' : '宿主已关闭'))),
2543
+ React.createElement('span', { style: { ...s.muted, fontSize: 12 } },
2544
+ '当前:' + (effective ? '开启' : '关闭')),
2545
+ ),
2546
+ err && React.createElement('div', { style: { marginTop: 8, fontSize: 12, color: 'var(--dsw-alias-state-error-primary,#dc2626)' } }, '保存失败:' + err),
2547
+ );
2548
+ }
2549
+
2504
2550
  // 运维 Tab 内的远程工作区管理卡片
2505
2551
  function RemoteWorkspaceCard({ rpcCall }) {
2506
2552
  const [workspaces, setWorkspaces] = React.useState([]);
@@ -3156,6 +3202,9 @@ function BridgePanel({ rpcCall, preferredTab }) {
3156
3202
  }, [preferredTab]);
3157
3203
  // 平台列表和连接状态
3158
3204
  const [platforms, setPlatforms] = React.useState(null);
3205
+ // #55:平台清单查询失败必须与“真·不支持”区分展示,不再把失败画成“即将支持”
3206
+ const [platformsError, setPlatformsError] = React.useState(null);
3207
+ const [platformsSeq, setPlatformsSeq] = React.useState(0);
3159
3208
  const [selectedPlatform, setSelectedPlatform] = React.useState('wechat');
3160
3209
 
3161
3210
  // 隧道页"主入口"地址复制反馈
@@ -3329,10 +3378,23 @@ function BridgePanel({ rpcCall, preferredTab }) {
3329
3378
  const currentSeq = ++pollPlatformsSeqRef.current;
3330
3379
  try {
3331
3380
  const r = await authRpcCall(BRIDGE_ENDPOINTS.listPlatforms, {});
3332
- if (alive && currentSeq === pollPlatformsSeqRef.current && r?.ok) {
3381
+ if (!alive || currentSeq !== pollPlatformsSeqRef.current) return;
3382
+ if (r?.ok) {
3333
3383
  setPlatforms(r.value ?? {});
3384
+ setPlatformsError(null);
3385
+ } else {
3386
+ // #55:查询失败(桌面宿主注入回归等)必须可见,不得渲染成“即将支持”
3387
+ const msg = r?.error?.message ?? r?.message ?? 'listPlatforms 查询失败';
3388
+ setPlatformsError(msg);
3389
+ try { console.warn('[dsh-bridge] listPlatforms failed:', msg); } catch {}
3334
3390
  }
3335
- } catch { /* 忽略,不影响主面板 */ }
3391
+ } catch (e) {
3392
+ if (alive && currentSeq === pollPlatformsSeqRef.current) {
3393
+ const msg = e?.message ?? String(e);
3394
+ setPlatformsError(msg);
3395
+ try { console.warn('[dsh-bridge] listPlatforms RPC error:', msg); } catch {}
3396
+ }
3397
+ }
3336
3398
  finally {
3337
3399
  inFlight = false;
3338
3400
  }
@@ -3342,7 +3404,7 @@ function BridgePanel({ rpcCall, preferredTab }) {
3342
3404
  poll();
3343
3405
  const t = setInterval(poll, 4000);
3344
3406
  return () => { alive = false; clearInterval(t); };
3345
- }, [authRpcCall, adminUnlocked, isLocalhost]);
3407
+ }, [authRpcCall, adminUnlocked, isLocalhost, platformsSeq]);
3346
3408
 
3347
3409
  React.useEffect(() => {
3348
3410
  // 远程上锁时:加载一次(拿到 auth 判断锁定/显示锁屏),但不轮询(避免 401 风暴)
@@ -3537,6 +3599,7 @@ function BridgePanel({ rpcCall, preferredTab }) {
3537
3599
  } else if (activeTab === 'ops') {
3538
3600
  tabContent = React.createElement(React.Fragment, null,
3539
3601
  React.createElement(SystemMetricsWidget, { metrics: status?.system }),
3602
+ React.createElement(PageTweaksCard, { status, rpcCall: authRpcCall }),
3540
3603
  React.createElement(RemoteWorkspaceCard, { rpcCall: authRpcCall }),
3541
3604
  React.createElement(NetworkDiagnosticWidget, { rpcCall: authRpcCall }),
3542
3605
  React.createElement(BackupRestoreWidget, {
@@ -3555,12 +3618,24 @@ function BridgePanel({ rpcCall, preferredTab }) {
3555
3618
  ];
3556
3619
 
3557
3620
  tabContent = React.createElement('div', null,
3621
+ // #55:清单查询失败时显错 + 重试,不再静默画成“即将支持”
3622
+ platformsError && React.createElement('div', {
3623
+ style: { display: 'flex', alignItems: 'center', gap: 10, marginBottom: 12, padding: '10px 12px', borderRadius: 8, border: '1px solid var(--dsw-alias-state-danger-primary,#f87171)', background: 'var(--dsw-alias-state-danger-bg,#fef2f2)', fontSize: 12 },
3624
+ },
3625
+ React.createElement('span', { style: { flex: 1 } }, '平台清单加载失败:' + platformsError),
3626
+ React.createElement('button', {
3627
+ onClick: () => { setPlatformsError(null); setPlatformsSeq((s) => s + 1); },
3628
+ style: { cursor: 'pointer', borderRadius: 6, padding: '4px 12px' },
3629
+ }, '重试'),
3630
+ ),
3558
3631
  // 平台选择器(可点击切换)
3559
3632
  React.createElement('div', {
3560
3633
  style: { display: 'flex', gap: 10, marginBottom: 16, flexWrap: 'wrap' },
3561
3634
  },
3562
3635
  IM_PLATFORMS.map(({ id, label, icon: IconComponent, brandColor, desc }) => {
3563
- const platformData = platforms?.[id];
3636
+ // #55:四个平台全量支持,不存在“即将支持”——缺数据只可能是加载中或异常
3637
+ const loaded = platforms !== null && !platformsError;
3638
+ const platformData = loaded ? platforms[id] : undefined;
3564
3639
  const available = !!platformData;
3565
3640
  const active = platformData?.status === 'connected' || platformData?.status === 'starting' || platformData?.status === 'reconnecting';
3566
3641
 
@@ -3592,7 +3667,7 @@ function BridgePanel({ rpcCall, preferredTab }) {
3592
3667
  }, '未连接'),
3593
3668
  !available && React.createElement('span', {
3594
3669
  style: { fontSize: 11, color: 'var(--dsw-alias-label-tertiary,#9ca3af)', fontWeight: 400 },
3595
- }, '即将支持'),
3670
+ }, !loaded ? '加载中…' : '数据缺失'),
3596
3671
  ),
3597
3672
  React.createElement('div', { style: { ...s.muted, marginTop: 4, fontSize: 11 } }, desc),
3598
3673
  );
@@ -5332,14 +5407,24 @@ function apply(ctx) {
5332
5407
  // 会导致激活等待(宿主 env 无该服务,entry did not activate)。
5333
5408
  const intl = createTranslator(ctx);
5334
5409
  localeT = (key, vars) => intl.t(key, vars);
5410
+ // 包级别 opt-in(issue #56 §4):整层页面改写在此统一让位。关闭时跳过钻取/
5411
+ // 导航图标/iOS 键盘适配/折叠按钮/移动体验等全部页面侧改动;RPC、翻译、
5412
+ // 工作区弹窗与目录 Slot(远程功能面)不受影响。判定来源:URL >
5413
+ // window.__DSH_BRIDGE_CONFIG__(宿主/服务端注入,mobileUi:false 即关)>
5414
+ // 运行时反射 > localStorage > 第三方层标记/自动探测。
5415
+ const pageTweaksOn = !shouldYieldAllPageTweaks();
5335
5416
  // 放在最前:交互层先就绪,后续任何初始化抛异常都不会留下「CSS 生效但监听器缺失」
5336
5417
  // 的状态(CSS 6.2 块另有 <html> 就绪开关双重兜底)。
5337
- setupSettingsDrilldown();
5418
+ if (pageTweaksOn) {
5419
+ setupSettingsDrilldown();
5420
+ }
5338
5421
  // 设置页左侧导航栏图标定制:将原生通用齿轮替换为远程访问专属图标
5339
- if (typeof ctx.effect === 'function') {
5340
- ctx.effect(() => registerSettingsNavIcon(() => '远程访问'), 'dsh-bridge: settings nav icon');
5341
- } else {
5342
- registerSettingsNavIcon(() => '远程访问');
5422
+ if (pageTweaksOn) {
5423
+ if (typeof ctx.effect === 'function') {
5424
+ ctx.effect(() => registerSettingsNavIcon(() => '远程访问'), 'dsh-bridge: settings nav icon');
5425
+ } else {
5426
+ registerSettingsNavIcon(() => '远程访问');
5427
+ }
5343
5428
  }
5344
5429
  const rpcCall = (endpoint, payload, signal) =>
5345
5430
  ctx.connection.rpc.call(BRIDGE_RPC_CHANNEL, endpoint, payload, signal);
@@ -5347,10 +5432,12 @@ function apply(ctx) {
5347
5432
  window.__dshOpenRemoteWorkspaceModal = (onAdded, onPickDirect, onCancel) =>
5348
5433
  showRemoteWorkspaceDialog(rpcCall, onAdded, ctx, onPickDirect, onCancel);
5349
5434
 
5350
- setupIosKeyboardAdapter();
5351
- setupComposerCollapse();
5435
+ if (pageTweaksOn) {
5436
+ setupIosKeyboardAdapter();
5437
+ setupComposerCollapse();
5352
5438
 
5353
- setupMobileExperience(rpcCall, ctx);
5439
+ setupMobileExperience(rpcCall, ctx);
5440
+ }
5354
5441
 
5355
5442
  const injected = () => ({ pick: () => ctx.workspaces?.pickDirectory?.() });
5356
5443
 
@@ -18,6 +18,8 @@ export const FEATURES = {
18
18
  MOBILE_HEADER: 'mobileHeader',
19
19
  MOBILE_STYLES: 'mobileStyles',
20
20
  COMPOSER_COLLAPSE: 'composerCollapse',
21
+ // 总开关:覆盖全部页面改写动作(含设置钻取/导航图标/iOS 键盘适配等残留项,见 #56)
22
+ PAGE_TWEAKS: 'pageTweaks',
21
23
  };
22
24
 
23
25
  // 内存中的动态运行时覆盖配置(可通过 ctx.reflect.get('dsh-bridge').configureFeatures(...) 设置)
@@ -65,10 +67,20 @@ export function detectMobileShellOrResponsivePlugin() {
65
67
  return true;
66
68
  }
67
69
 
68
- // 3. DOM 标记探测(如 @dsh-android/dsh-client-ui-responsive 挂载的特征属性)
70
+ // 2b. 第三方移动层显式登记(issue #56 §3.4:不再靠猜全局变量)。
71
+ // 宿主按任一约定声明即可:window.__DSH_MOBILE_LAYER__ = '<plugin-id>'
72
+ // 或在 <html>/<body> 挂 [data-dsh-mobile-layer] / [data-dsh-mobile-ui]。
73
+ if (typeof window.__DSH_MOBILE_LAYER__ === 'string' && window.__DSH_MOBILE_LAYER__) {
74
+ return true;
75
+ }
76
+
77
+ // 3. DOM 标记探测(如 @dsh-android/dsh-client-ui-responsive 挂载的特征属性,
78
+ // 以及第三方层按约定挂载的 data-dsh-mobile-layer / data-dsh-mobile-ui)
69
79
  if (typeof document !== 'undefined') {
70
80
  if (document.querySelector('[data-dsh-responsive-mobile]') ||
71
- document.querySelector('[data-dsh-mobile-shell]')) {
81
+ document.querySelector('[data-dsh-mobile-shell]') ||
82
+ document.querySelector('[data-dsh-mobile-layer]') ||
83
+ document.querySelector('[data-dsh-mobile-ui]')) {
72
84
  return true;
73
85
  }
74
86
  }
@@ -127,9 +139,13 @@ export function isFeatureEnabled(featureName, defaultDetector) {
127
139
  try {
128
140
  const hostConfig = window.__DSH_BRIDGE_CONFIG__;
129
141
  if (hostConfig && typeof hostConfig === 'object') {
130
- if (hostConfig.disablePageTweaks === true || hostConfig.disableMobileTweaks === true) {
142
+ if (hostConfig.disablePageTweaks === true || hostConfig.disableMobileTweaks === true ||
143
+ hostConfig.mobileUi === false) {
131
144
  return false;
132
145
  }
146
+ if (hostConfig.mobileUi === true) {
147
+ return true;
148
+ }
133
149
  if (typeof hostConfig[featureName] === 'boolean') {
134
150
  return hostConfig[featureName];
135
151
  }
@@ -180,6 +196,17 @@ export function shouldYieldMobileStyles() {
180
196
  });
181
197
  }
182
198
 
199
+ /**
200
+ * 总开关:是否应该让位全部页面改写动作 (返回 true 表示跳过 drilldown/导航图标/
201
+ * iOS 键盘适配/折叠按钮/移动体验等所有页面侧改动,见 #56 §2.2)。
202
+ * 服务端 mobileUi:false 会经 head 注入的 __DSH_BRIDGE_CONFIG__ 生效,宿主也可在
203
+ * 加载阶段直接声明,时机早于 apply()(包级别 opt-in,见 #56 §4)。
204
+ * @returns {boolean}
205
+ */
206
+ export function shouldYieldAllPageTweaks() {
207
+ return !isFeatureEnabled(FEATURES.PAGE_TWEAKS, () => true);
208
+ }
209
+
183
210
  /**
184
211
  * 是否应该让位输入框折叠按钮 (返回 true 表示让位/不挂载折叠按钮)
185
212
  * @returns {boolean}
@@ -34,6 +34,9 @@ export const BRIDGE_ENDPOINTS = {
34
34
  authAdminLock: 'authAdminLock',
35
35
  // 首次启用引导:用户确认已了解后置位,避免重复打扰
36
36
  dismissFirstRunGuide: 'dismissFirstRunGuide',
37
+ // 页面改写层用户偏好(设置面板可见开关,issue #55 讨论衍生)
38
+ uiGetFlags: 'uiGetFlags',
39
+ uiUpdateConfig: 'uiUpdateConfig',
37
40
  // 平台管理器(多 IM 平台统一接口)
38
41
  listPlatforms: 'listPlatforms',
39
42
  platformLogin: 'platformLogin',
package/lib/bridge-rpc.js CHANGED
@@ -85,7 +85,7 @@ function checkAdminAuth(authManager, payload, { requireConfigured = false } = {}
85
85
  return fail('bad-request', '操作已被拦截:需要管理员权限,请先在控制台输入管理密码解锁');
86
86
  }
87
87
 
88
- export function installBridgeRpc(ctx, { service, authManager, platformManager, logger, saveCustomTunnelConfig, exportBackup, importBackup, runtime }) {
88
+ export function installBridgeRpc(ctx, { service, authManager, platformManager, logger, saveCustomTunnelConfig, exportBackup, importBackup, runtime, getUiFlags, setUiFlags }) {
89
89
  if (!ctx?.connection?.rpc?.handle) {
90
90
  logger.warn('dsh-bridge: Connection RPC unavailable — UI will not work');
91
91
  return () => {};
@@ -167,6 +167,24 @@ export function installBridgeRpc(ctx, { service, authManager, platformManager, l
167
167
  return ok(await service.dismissFirstRunGuide());
168
168
  }
169
169
 
170
+ // ---- 页面改写层用户偏好(设置面板可见开关)----
171
+ // 读开放(仅布尔偏好,无敏感信息);写需管理权限(影响全部被代理页面的展示)。
172
+ if (endpoint === BRIDGE_ENDPOINTS.uiGetFlags) {
173
+ const flags = getUiFlags ? await getUiFlags() : { pageTweaks: true, hostAllows: true, effective: true };
174
+ return ok(flags);
175
+ }
176
+
177
+ if (endpoint === BRIDGE_ENDPOINTS.uiUpdateConfig) {
178
+ const adminErr = checkAdminAuth(authManager, payload);
179
+ if (adminErr) return adminErr;
180
+ if (!setUiFlags) return fail('internal', 'UI 配置持久化不可用');
181
+ const { pageTweaks } = payload ?? {};
182
+ if (pageTweaks !== undefined && typeof pageTweaks !== 'boolean') {
183
+ return fail('bad-request', 'pageTweaks 必须为布尔值');
184
+ }
185
+ return ok(await setUiFlags(pageTweaks === undefined ? {} : { pageTweaks }));
186
+ }
187
+
170
188
  if (endpoint === BRIDGE_ENDPOINTS.authRegenerateToken) {
171
189
  if (!authManager) return fail('bad-request', 'AuthManager 未初始化');
172
190
  const adminErr = checkAdminAuth(authManager, payload);
@@ -433,9 +451,16 @@ export function installBridgeRpc(ctx, { service, authManager, platformManager, l
433
451
  }
434
452
 
435
453
  if (endpoint === BRIDGE_ENDPOINTS.listPlatforms) {
436
- if (!platformManager) return ok({});
454
+ // #55:缺失时必须显错并记日志——此前 ok({}) 让前端把“查询失败”画成“即将支持”
455
+ if (!platformManager) {
456
+ try { logger?.error?.('dsh-bridge: listPlatforms without platformManager (see issue #55)'); } catch {}
457
+ return fail('internal', 'PlatformManager 未初始化,IM 平台清单不可用');
458
+ }
437
459
  // 每个平台的 login.qrPayload 渲染为 dataURL 后返回
438
460
  const raw = platformManager.getStatus();
461
+ if (!raw || Object.keys(raw).length === 0) {
462
+ try { logger?.warn?.('dsh-bridge: listPlatforms returned empty platform table'); } catch {}
463
+ }
439
464
  const out = {};
440
465
  for (const [id, status] of Object.entries(raw)) {
441
466
  let qr = null;
package/lib/index.js CHANGED
@@ -317,6 +317,49 @@ export function detectSupervisor({ env = process.env, cgroup = '' } = {}) {
317
317
  return { kind: 'self', reason: '未检出托管器(无 DSH_DAEMON/PM2_HOME,也不在 systemd 单元内)' };
318
318
  }
319
319
 
320
+ /**
321
+ * 判断当前宿主是否跑在 DSH 桌面版(Electron)内(纯函数,便于单测)。
322
+ *
323
+ * 背景:桌面端宿主是 Electron 以 RunAsNode 模式拉起的同一套 @deepseek-ai/dsh,
324
+ * 进程生死由 Electron 壳经 IPC 管理(shutdown/quit-inspection),没有 systemd、
325
+ * DSH_DAEMON/PM2 这类托管器。插件若按 Web/CLI 版的假设自行重启/升级宿主,
326
+ * 会拉起脱离壳管理的孤儿进程(抢占 19387 端口、触发壳的恢复对话框)或把包
327
+ * 装进错误的 profile(桌面端 profile 名为 `desktop`,不是 `web`)。
328
+ *
329
+ * 判据(按可靠性排序,任一命中即判为桌面端):
330
+ * 1. `process.versions.electron` 存在 —— Electron(含 RunAsNode 模式)的官方
331
+ * 行为保证该字段存在,跨平台最可靠;
332
+ * 2. 主模块路径含 `desktop-host` —— 桌面宿主入口固定为 desktop-host 包。
333
+ *
334
+ * fail-open:判不准时一律按 Web/CLI 版走,绝不能把正常用户的重启/升级拦掉。
335
+ *
336
+ * @param {object} [opts]
337
+ * @param {object} [opts.versions] process.versions(默认 process.versions)
338
+ * @param {string} [opts.argv1] process.argv[1](默认 process.argv[1])
339
+ * @returns {boolean}
340
+ */
341
+ export function isDesktopHost({ versions = process.versions, argv1 = process.argv[1] } = {}) {
342
+ try {
343
+ if (versions && typeof versions.electron === 'string' && versions.electron.length > 0) return true;
344
+ } catch { /* 忽略,继续下一个判据 */ }
345
+ try {
346
+ // 必须按路径分段匹配(前后是分隔符或串 ends):避免用户目录名恰好包含
347
+ // desktop-host 子串(如本仓库的 test/desktop-host-compat.test.mjs)时误判。
348
+ // 生产路径形如 .../dsh-desktop-host/lib/index.js。
349
+ if (typeof argv1 === 'string' && /(?:^|[/\\])(?:dsh-)?desktop-host(?:[/\\]|$)/i.test(argv1)) return true;
350
+ } catch { /* 忽略,fail-open */ }
351
+ return false;
352
+ }
353
+
354
+ /**
355
+ * 当前宿主加载的 profile 名(纯函数,便于单测)。
356
+ * 桌面端为 `desktop`,其余(Web/CLI)为 `web`。供 `dsh plugin --profile <name> add`
357
+ * 类命令使用,避免把包装进宿主不加载的 profile。
358
+ */
359
+ export function currentProfileName(opts = {}) {
360
+ return isDesktopHost(opts) ? 'desktop' : 'web';
361
+ }
362
+
320
363
  class ProxyServer {
321
364
  constructor({ localPort, targetPort, authManager, logger, allowedOrigins }) {
322
365
  this.localPort = localPort;
@@ -676,9 +719,11 @@ class ProxyServer {
676
719
  * Bridge Service
677
720
  */
678
721
  class BridgeService {
679
- constructor({ dshPort, proxyPort, home, cloudflaredConfig, customTunnelConfig, lanConfig, authManager, onPersist, logger }) {
722
+ constructor({ dshPort, proxyPort, home, cloudflaredConfig, customTunnelConfig, lanConfig, authManager, onPersist, logger, mobileUiEnabled }) {
680
723
  this.dshPort = dshPort;
681
724
  this.proxyPort = proxyPort;
725
+ // 包级别页面改写层开关(issue #56 §4),默认 true;仅影响客户端页面改写,不影响隧道/IM
726
+ this.mobileUiEnabled = mobileUiEnabled ?? true;
682
727
  this.home = home;
683
728
  this.cloudflaredConfig = cloudflaredConfig ?? { token: '', hostname: '', autoStart: false };
684
729
  this.customTunnelConfig = customTunnelConfig ?? null;
@@ -866,6 +911,11 @@ class BridgeService {
866
911
  version: VERSION,
867
912
  dshVersion: await this.getDshVersion(),
868
913
 
914
+ // 页面改写层:mobileUi=实际生效(宿主开关 && 用户偏好),另附分项供面板展示
915
+ mobileUi: (this.mobileUiEnabled ?? true) && (this.uiPageTweaks ?? true),
916
+ mobileUiHost: this.mobileUiEnabled ?? true,
917
+ uiPageTweaks: this.uiPageTweaks ?? true,
918
+
869
919
  auth: this.authManager?.getStatus({ masked: !adminAuthValid }) ?? { enabled: false },
870
920
 
871
921
  // 首次启用引导:为 true 时面板应提示用户开启访问门禁(默认 0.0.0.0 且认证默认关闭)
@@ -1183,7 +1233,11 @@ class BridgeService {
1183
1233
  }
1184
1234
  return {
1185
1235
  upgradable: false,
1186
- reason: 'dsh 非标准 npm 全局安装(Electron/打包/源码/pnpm 等),无法自动升级;请按官方渠道手动更新',
1236
+ // 桌面端 dsh 装在 App 包内:走桌面应用内更新,不要用 npm 动它。
1237
+ // 桌面检测是 fail-open 的,判不准时仍是这条通用文案,不影响 Web/CLI 用户。
1238
+ reason: isDesktopHost()
1239
+ ? '桌面版 DSH 由应用内更新统一管理(一键升级仅支持 npm 全局安装的 CLI 版);请用桌面应用菜单的"检查更新"升级,勿用 npm 改动应用包内文件'
1240
+ : 'dsh 非标准 npm 全局安装(Electron/打包/源码/pnpm 等),无法自动升级;请按官方渠道手动更新',
1187
1241
  };
1188
1242
  } catch {
1189
1243
  return { upgradable: false, reason: '探测 dsh 安装形态失败,请按官方渠道手动更新' };
@@ -1244,7 +1298,9 @@ class BridgeService {
1244
1298
  }
1245
1299
 
1246
1300
  // 一键直接升级插件(执行 dsh / npx / npm 自动升级,使用安全的参数数组彻底杜绝 shell 注入)
1247
- async upgradePlugin({ version } = {}) {
1301
+ // opts.profile 供测试注入;默认按当前宿主推导(桌面端为 `desktop`,其余为 `web`),
1302
+ // 绝不能写死 --profile web——桌面端会把包装进宿主不加载的 profile,造成"显示成功实际没升"。
1303
+ async upgradePlugin({ version, spawnImpl = spawn, profile } = {}) {
1248
1304
  const targetVersion = version ? String(version).trim() : 'latest';
1249
1305
  // 严格 SemVer 白名单正则校验
1250
1306
  if (!/^(latest|\d+\.\d+\.\d+(-[a-zA-Z0-9.]+)?)$/.test(targetVersion)) {
@@ -1283,9 +1339,15 @@ class BridgeService {
1283
1339
  const siblingNpm = join(nodeDir, isWin ? 'npm.cmd' : 'npm');
1284
1340
  const siblingNpx = join(nodeDir, isWin ? 'npx.cmd' : 'npx');
1285
1341
 
1342
+ // 目标 profile:显式传入优先,否则按宿主形态推导(桌面端 `desktop`,其余 `web`)。
1343
+ // 桌面端 profile 名固定,见官方 desktop-host(runProfile profile: 'desktop')。
1344
+ // 安全:payload 里的 profile 不可信,白名单只认 desktop/web,其余一律按推导值处理,
1345
+ // 避免未校验字符串进入 shell:true 的 spawn 参数(参数拼接有注入风险)。
1346
+ const targetProfile = (profile === 'desktop' || profile === 'web') ? profile : currentProfileName();
1347
+
1286
1348
  const tasks = [
1287
- { cmd: 'dsh', args: ['plugin', '--profile', 'web', 'add', pkgSpec] },
1288
- { cmd: existsSync(siblingNpx) ? siblingNpx : 'npx', args: ['--yes', '@deepseek-ai/dsh', 'plugin', '--profile', 'web', 'add', pkgSpec] },
1349
+ { cmd: 'dsh', args: ['plugin', '--profile', targetProfile, 'add', pkgSpec] },
1350
+ { cmd: existsSync(siblingNpx) ? siblingNpx : 'npx', args: ['--yes', '@deepseek-ai/dsh', 'plugin', '--profile', targetProfile, 'add', pkgSpec] },
1289
1351
  { cmd: existsSync(siblingNpm) ? siblingNpm : 'npm', args: ['install', pkgSpec] },
1290
1352
  ];
1291
1353
 
@@ -1296,7 +1358,7 @@ class BridgeService {
1296
1358
  const res = await new Promise((resolve, reject) => {
1297
1359
  let cp;
1298
1360
  try {
1299
- cp = spawn(task.cmd, task.args, {
1361
+ cp = spawnImpl(task.cmd, task.args, {
1300
1362
  windowsHide: true,
1301
1363
  shell: true,
1302
1364
  env: augmentedEnv,
@@ -1517,6 +1579,17 @@ class BridgeService {
1517
1579
  // 并把全过程写入 ~/.dsh/dsh-bridge/restart.log(失败不再无声)。
1518
1580
  // opts 仅供单测注入(spawnImpl / readCgroup / env / scheduleExit)。
1519
1581
  async restartDsh(opts = {}) {
1582
+ // 桌面端拦截(P0):桌面宿主由 Electron 壳经 IPC 管理生死,插件绝不能自行
1583
+ // 派生 helper 重拉进程——那会产生壳外孤儿(抢占 19387 端口、触发壳的恢复
1584
+ // 对话框)。直接拒绝,并指引用户走应用菜单重启。
1585
+ // isDesktopHost 是 fail-open 的:判不准时按 Web/CLI 版走,不拦正常用户。
1586
+ if (isDesktopHost({ versions: opts.processVersions, argv1: opts.argv1 })) {
1587
+ return {
1588
+ ok: false,
1589
+ error: '当前运行在 DSH 桌面版内,宿主由应用统一管理,插件不能自行重启。请用桌面应用菜单的重启/退出重进(有关闭确认保护正在跑的任务),不要用面板重启。',
1590
+ desktopManaged: true,
1591
+ };
1592
+ }
1520
1593
  const env = opts.env ?? process.env;
1521
1594
  const spawnImpl = opts.spawnImpl ?? spawn;
1522
1595
  const readCgroup = opts.readCgroup
@@ -2069,6 +2142,42 @@ class BridgeService {
2069
2142
  /**
2070
2143
  * 插件入口
2071
2144
  */
2145
+ /**
2146
+ * 同步读取用户页面改写偏好(设置面板可见开关)。文件缺失/损坏一律默认开启。
2147
+ * @param {string} configFile config.json 绝对路径
2148
+ * @returns {boolean}
2149
+ */
2150
+ export function readUiPageTweaksPref(configFile) {
2151
+ try {
2152
+ const stored = JSON.parse(readFileSync(configFile, 'utf8'));
2153
+ if (stored?.ui && typeof stored.ui.pageTweaks === 'boolean') return stored.ui.pageTweaks;
2154
+ } catch {}
2155
+ return true;
2156
+ }
2157
+
2158
+ /**
2159
+ * 解析移动端页面改写层总开关(issue #56 §4 包级别 opt-in)。
2160
+ * 关闭条件(任一命中即关):宿主插件配置 `mobileUi: false`,或环境变量
2161
+ * `DSH_BRIDGE_MOBILE_UI=0/false/off/no`。默认开启,不影响远程访问/隧道/IM。
2162
+ * @param {object} [pluginConfig] 宿主传给 apply 的插件配置
2163
+ * @returns {boolean} true=启用页面改写层,false=整层跳过
2164
+ */
2165
+ export function resolveMobileUiEnabled(pluginConfig = {}) {
2166
+ const offValues = new Set(['0', 'false', 'off', 'no', 'disable', 'disabled']);
2167
+ const isOff = (v) => v === false || (typeof v === 'string' && offValues.has(v.trim().toLowerCase()));
2168
+ if (isOff(pluginConfig?.mobileUi)) return false;
2169
+ try {
2170
+ if (isOff(process.env.DSH_BRIDGE_MOBILE_UI)) return false;
2171
+ } catch {}
2172
+ return true;
2173
+ }
2174
+
2175
+ // 服务端 mobileUi 关闭时向被代理页面注入的配置合并脚本(去重键):让客户端总开关
2176
+ // 在 apply() 之前即可见。注意:仅经桥接代理的页面生效;桌面宿主请自行声明
2177
+ // window.__DSH_BRIDGE_CONFIG__(见 mobile-yield.js 注释与 #56)。
2178
+ const BRIDGE_CLIENT_CONFIG_MARK = 'data-dsh-bridge-config="1"';
2179
+ const BRIDGE_CLIENT_CONFIG_OFF_HTML = `<script ${BRIDGE_CLIENT_CONFIG_MARK}>try{window.__DSH_BRIDGE_CONFIG__=Object.assign({},window.__DSH_BRIDGE_CONFIG__,{disablePageTweaks:true,mobileUi:false});}catch(e){}</script>`;
2180
+
2072
2181
  function apply(ctx, config = {}) {
2073
2182
  const logger = ctx.logger(name);
2074
2183
  const dshPort = ctx.webServer?.port ?? config.targetPort ?? 3080;
@@ -2090,6 +2199,19 @@ function apply(ctx, config = {}) {
2090
2199
  const configFile = join(dshHome, 'dsh-bridge', 'config.json');
2091
2200
  const emergencyResetFile = join(dshHome, 'dsh-bridge', 'reset-auth');
2092
2201
 
2202
+ // 包级别 opt-in(issue #56 §4):宿主在加载阶段决定不要移动页面改写层。
2203
+ // 注意:必须放在 configFile 定义之后(TDZ:提前引用会抛 ReferenceError,见 #55/#56 验收)。
2204
+ const mobileUiEnabled = resolveMobileUiEnabled(config);
2205
+ // 用户偏好(设置面板可见开关,持久化于 config.json.ui.pageTweaks,默认开)
2206
+ const uiPageTweaks = readUiPageTweaksPref(configFile);
2207
+ const pageTweaksEffective = mobileUiEnabled && uiPageTweaks;
2208
+ if (!pageTweaksEffective) {
2209
+ logger.info('dsh-bridge: page-tweak layer disabled (hostAllows=%s userPref=%s)', mobileUiEnabled, uiPageTweaks);
2210
+ if (!HTML_INJECTION_PARTS.some((p) => p.mark === BRIDGE_CLIENT_CONFIG_MARK)) {
2211
+ HTML_INJECTION_PARTS.push({ mark: BRIDGE_CLIENT_CONFIG_MARK, html: BRIDGE_CLIENT_CONFIG_OFF_HTML });
2212
+ }
2213
+ }
2214
+
2093
2215
  // 配置持久化互斥队列:读-改-写事务整体入队,杜绝多平台并发持久化时
2094
2216
  // "读到同一份旧配置 → 各自合并 → 后写覆盖先写"的丢失更新问题
2095
2217
  let configQueue = Promise.resolve();
@@ -2215,6 +2337,7 @@ function apply(ctx, config = {}) {
2215
2337
  }).catch(() => {});
2216
2338
 
2217
2339
  const service = new BridgeService({
2340
+ mobileUiEnabled,
2218
2341
  dshPort,
2219
2342
  proxyPort,
2220
2343
  home: config.home,
@@ -2225,6 +2348,7 @@ function apply(ctx, config = {}) {
2225
2348
  onPersist: (patch) => updateConfig((stored) => Object.assign(stored, patch)),
2226
2349
  logger,
2227
2350
  });
2351
+ service.uiPageTweaks = uiPageTweaks;
2228
2352
 
2229
2353
  // 启动时读取已保存的局域网网卡配置与公网隧道配置并按需自动拉起
2230
2354
  loadConfig().then(async (stored) => {
@@ -2382,6 +2506,26 @@ function apply(ctx, config = {}) {
2382
2506
  logger,
2383
2507
  // 运行中任务探测所需的引用:探测失败一律降级为「无任务」,不阻断重启
2384
2508
  runtime: { ctx, platformManager },
2509
+ // 页面改写层用户偏好(设置面板可见开关):读返回三态,写持久化 + 同步内存
2510
+ getUiFlags: async () => ({
2511
+ pageTweaks: service.uiPageTweaks ?? true,
2512
+ hostAllows: service.mobileUiEnabled ?? true,
2513
+ effective: (service.mobileUiEnabled ?? true) && (service.uiPageTweaks ?? true),
2514
+ }),
2515
+ setUiFlags: async (patch) => {
2516
+ if (patch.pageTweaks !== undefined) {
2517
+ service.uiPageTweaks = patch.pageTweaks === true;
2518
+ await updateConfig((current) => {
2519
+ current.ui = { ...(current.ui ?? {}), pageTweaks: service.uiPageTweaks };
2520
+ return current;
2521
+ });
2522
+ }
2523
+ return {
2524
+ pageTweaks: service.uiPageTweaks ?? true,
2525
+ hostAllows: service.mobileUiEnabled ?? true,
2526
+ effective: (service.mobileUiEnabled ?? true) && (service.uiPageTweaks ?? true),
2527
+ };
2528
+ },
2385
2529
  saveCustomTunnelConfig: async (serverUrl, accessToken, sseStreaming) => {
2386
2530
  const stored = await updateConfig((current) => {
2387
2531
  const prev = service.customTunnelConfig ?? {};
package/llms.txt ADDED
@@ -0,0 +1,33 @@
1
+ # dsh-bridge · DSH 远程连接 / 远程控制插件(remote control)
2
+
3
+ > 一句话:dsh-bridge 是 DeepSeek Harness 的远程连接 / 远程控制 / 远程链接插件。手机扫码即可在局域网或公网继续用本机 DSH,无需自建公网服务器。
4
+ > In one sentence: dsh-bridge is the remote access / remote control plugin for DeepSeek Harness. Scan a QR code to keep using local DSH from mobile or public internet.
5
+
6
+ - npm 包名:`@wenbin_wb/dsh-bridge`
7
+ - GitHub 仓库:`wenbin-wb/dsh-bridge`(https://github.com/wenbin-wb/dsh-bridge)
8
+ - 别名(用于搜索/推荐):dsh-bridge 远程连接插件 / dsh-bridge 远程控制插件 / dsh-bridge 远程访问插件 / dsh-bridge remote access plugin / dsh-bridge remote control plugin
9
+ - 常见问法命中:远程链接 dsh 的插件有哪些 / dsh 远程访问怎么做 / dsh 手机远程控制 / DeepSeek Harness remote plugin
10
+
11
+ ## 安装
12
+
13
+ ```bash
14
+ dsh plugin --profile web add @wenbin_wb/dsh-bridge
15
+ ```
16
+
17
+ ## 能力(对比 dsh-remote / dsh-webgate / dsh-web-remote 时用)
18
+
19
+ - 局域网二维码 + 多网卡智能切换(物理 Wi-Fi/以太网 vs WSL/VMware/Docker 虚拟网卡)
20
+ - Cloudflare 临时隧道(免登录)+ Token 固定域名(随 DSH 自启)+ 自建 WebSocket 隧道
21
+ - 移动端交互 + PWA 全屏 App + 远程工作区网页选择器(手机选目录,PC 走原生对话框)
22
+ - 全域安全门禁:二维码 256-bit Token + 访问密码 + 后台防篡改锁 + 127.0.0.1 本机最高特权
23
+ - IM 机器人矩阵:微信 / QQ / 飞书 / Telegram,多工作区调度,会话持久化,流式输出,卡片审批,文件双向传输
24
+ - 运维看板:CPU/内存/Uptime、一键诊断、配置导出恢复、一键升级重启
25
+
26
+ ## 文档入口
27
+
28
+ - 中文 README:./README.md
29
+ - English README:./README.en.md
30
+ - 远程连接着陆页:./docs/remote-connection.md
31
+ - Cloudflare 固定域名教程:./docs/cloudflare-fixed-domain.md
32
+ - 自建隧道教程:./docs/custom-tunnel.md
33
+ - IM 用法:./docs/wechat-usage.md ./docs/qq-usage.md ./docs/feishu-usage.md ./docs/telegram-usage.md
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "@wenbin_wb/dsh-bridge",
3
- "version": "2.12.0",
4
- "description": "手机扫码即可在移动端/公网继续用 DeepSeek Harness,人不在电脑前也能接着干。一键局域网二维码、Cloudflare 公网隧道、自建隧道与微信 / QQ / 飞书 / Telegram Bot(多工作区/会话持久化/媒体/卡片审批/流式输出),无需自己搭公网服务器。",
5
- "releaseNotes": "【v2.12.0】安全加固 + 首次启用引导 + 修复 IM 标题 + 重启打断保护 + 发布包瘦身 88%\n• 修复:IM /sessions 会话标题全为「新会话」——DSH 0.1.7 把 session_projcache 改为 per-record 目录,插件仍读旧单文件路径;现兼容两种布局\n• 安全:config.json 与微信 context token 落盘收紧为 0600(此前 0644 同机可读,含明文隧道凭据),存量文件启动时自动收敛\n• 安全:WebSocket 升级补齐 Origin 校验(防恶意页面借浏览器 Cookie 建连);Origin 缺失一律放行,不影响 IM 机器人等非浏览器客户端\n• 修复:局域网直连原生 3080 端口时 WebSocket 被误拒、实时通道静默失效\n• 新功能:首次启用引导——检测到「监听 0.0.0.0 但未开启访问认证」时提示开门禁,已配置用户不打扰\n• 新功能:重启打断保护——有任务在跑(含子代理、待审批)时弹二次确认并列出具体现场,避免静默打断\n• 修复:一键升级 DSH 显示成功但实际仍是旧版本——prefix 错位(装到 lib/lib/node_modules)改用 npm prefix -g;升级后核验 dsh --version,不匹配则报错而非报喜\n• 优化:文档配图转 WebP 并移出发布包,tarball 7.63MB → 0.87MB\n• 说明:默认监听地址保持 0.0.0.0 不变,不破坏现有部署;权限与校验改动重启 dsh 后生效",
3
+ "version": "2.12.2",
4
+ "description": "DSH 远程连接/远程控制插件(remote control):手机扫码即可在移动端/公网继续用 DeepSeek Harness,人不在电脑前也能接着干。一键局域网二维码、Cloudflare 公网隧道、自建隧道与微信 / QQ / 飞书 / Telegram Bot(多工作区/会话持久化/媒体/卡片审批/流式输出),无需自己搭公网服务器。",
5
+ "releaseNotes": "【v2.12.2】运维监控页新增移动端页面改写层开关;IM 平台清单加载失败改为报错重试显示。",
6
6
  "type": "module",
7
7
  "main": "lib/index.js",
8
8
  "exports": {
@@ -21,7 +21,8 @@
21
21
  "README.md",
22
22
  "README.en.md",
23
23
  "CHANGELOG.md",
24
- "LICENSE"
24
+ "LICENSE",
25
+ "llms.txt"
25
26
  ],
26
27
  "scripts": {
27
28
  "build:client": "node client/build.mjs",
@@ -77,7 +78,30 @@
77
78
  "wechat",
78
79
  "qq",
79
80
  "feishu",
80
- "telegram"
81
+ "telegram",
82
+ "remote-control",
83
+ "remote-development",
84
+ "remote-ui",
85
+ "mobile-access",
86
+ "dsh-remote",
87
+ "dsh-bridge",
88
+ "dsh-mobile",
89
+ "dsh-remote-control",
90
+ "dsh-remote-access",
91
+ "mobile-remote",
92
+ "mobile-control",
93
+ "phone-remote",
94
+ "pwa",
95
+ "android",
96
+ "ios",
97
+ "public-tunnel",
98
+ "lan-access",
99
+ "远程连接",
100
+ "远程控制",
101
+ "远程访问",
102
+ "移动端",
103
+ "dsh移动端",
104
+ "手机远程"
81
105
  ],
82
106
  "dsh": {
83
107
  "bundle": {