@wenbin_wb/dsh-bridge 2.10.15 → 2.11.0

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,28 @@
4
4
 
5
5
  ---
6
6
 
7
+ ## [v2.11.0] - 2026-09-28
8
+
9
+ > 本版为客户端架构升级与环境解耦:支持桌面端宿主(如 Tauri)将设置面板作为独立组件嵌入并动态管理设置页入口;同时为 Android 移动壳及第三方自适应插件引入环境自适应让位与多级特性开关,消除界面冲突与遮挡。
10
+
11
+ ### ✨ 新功能
12
+
13
+ - **客户端宿主接入面(对齐 #51)**:在客户端入口暴露 Cordis 反射服务 `ctx.provide('dsh-bridge', { version: 1, render, setSettingsVisible, settingsVisible, configureFeatures, getFeatures })`。桌面端宿主(如 DSH Desktop / Tauri)可调用 `render()` 将 `BridgePanel` 现构造嵌入自定义侧栏或扩展容器,支持 `preferredTab` 指定默认停靠页并保持动态响应;收编后可调用 `setSettingsVisible(false)` 动态撤下全局设置页中的重复条目。老宿主环境下平稳降级。
14
+ - **设置页左侧导航栏图标定制**:新增 `client/settings-nav-icon.js`,通过 CSS mask 将设置中心原生回退的通用小齿轮(gear)替换为「远程访问 / 手机无线连接」专属矢量图标,颜色自动跟随 `currentColor` 保持原生悬停与激活态一致。
15
+ - **移动端多级特性开关与排障支持(对齐 #52)**:新增 `client/mobile-yield.js`,支持通过 URL 查询参数(如 `?dsh_bridge_tweaks=0` 全局禁用、`?dsh_mobile_header=0` 仅禁用顶栏)、宿主全局配置(`window.__DSH_BRIDGE_CONFIG__`)、运行时反射服务或本地存储细粒度控制移动端 UI 特性。
16
+
17
+ ### 🐞 修复
18
+
19
+ - **Android 移动壳/已有自适应插件下的界面冲突与顶栏遮挡(#52)**:在 Android 原生 WebView 壳(如 `com.dsharnessmobile.shell`)或已加载移动自适应插件(如 `@dsh-android/dsh-client-ui-responsive`)的环境下,插件主动避让自建 body 级顶栏与全局覆盖样式,杜绝双重顶栏重叠、设置页错位与触控遮挡失效。
20
+
21
+ ### 🧪 测试
22
+
23
+ - 新增 `test/mobile-yield.test.mjs`(11 条)、`test/client-provide.test.mjs`(6 条)与 `test/settings-nav-icon.test.mjs`(3 条),全量 337 项测试 100% 通过。
24
+
25
+ > ⚠️ 生效说明:纯前端改动,刷新页面即可生效,无需重启 dsh。
26
+
27
+ ---
28
+
7
29
  ## [v2.10.15] - 2026-09-25
8
30
 
9
31
  > 本版为设置页关闭按钮的可用性微调:手机上「✕」更容易点中。
package/client/client.js CHANGED
@@ -1374,6 +1374,166 @@ function shouldYieldToOfficialPicker(facts) {
1374
1374
  return Boolean(facts?.local) && Boolean(facts?.officialPicker);
1375
1375
  }
1376
1376
 
1377
+ // client/mobile-yield.js
1378
+ var FEATURES = {
1379
+ MOBILE_HEADER: "mobileHeader",
1380
+ MOBILE_STYLES: "mobileStyles",
1381
+ COMPOSER_COLLAPSE: "composerCollapse"
1382
+ };
1383
+ var runtimeFeatureOverrides = {};
1384
+ function setRuntimeFeatureOverrides(overrides) {
1385
+ if (overrides && typeof overrides === "object") {
1386
+ runtimeFeatureOverrides = { ...runtimeFeatureOverrides, ...overrides };
1387
+ }
1388
+ }
1389
+ function getRuntimeFeatureOverrides() {
1390
+ return { ...runtimeFeatureOverrides };
1391
+ }
1392
+ function detectMobileShellOrResponsivePlugin() {
1393
+ if (typeof window === "undefined") return false;
1394
+ if (window.__DSH_MOBILE_SHELL__ || window.__TAURI__ || window.__DSH_DESKTOP__) {
1395
+ return true;
1396
+ }
1397
+ if (window.__DSH_CLIENT_UI_RESPONSIVE__) {
1398
+ return true;
1399
+ }
1400
+ if (typeof document !== "undefined") {
1401
+ if (document.querySelector("[data-dsh-responsive-mobile]") || document.querySelector("[data-dsh-mobile-shell]")) {
1402
+ return true;
1403
+ }
1404
+ }
1405
+ return false;
1406
+ }
1407
+ function isFeatureEnabled(featureName, defaultDetector) {
1408
+ if (typeof window === "undefined") {
1409
+ return typeof defaultDetector === "function" ? defaultDetector() : true;
1410
+ }
1411
+ try {
1412
+ if (window.location && window.location.search) {
1413
+ const params = new URLSearchParams(window.location.search);
1414
+ const tweaksVal = params.get("dsh_bridge_tweaks") ?? params.get("dsh_mobile_tweaks");
1415
+ if (tweaksVal === "0" || tweaksVal === "false" || tweaksVal === "off") {
1416
+ return false;
1417
+ }
1418
+ if (tweaksVal === "1" || tweaksVal === "true" || tweaksVal === "on") {
1419
+ return true;
1420
+ }
1421
+ const kebabName = featureName.replace(/[A-Z]/g, (m) => `_${m.toLowerCase()}`);
1422
+ const specificVal = params.get(`dsh_${kebabName}`) ?? params.get(`dsh_${featureName}`);
1423
+ if (specificVal === "0" || specificVal === "false" || specificVal === "off") {
1424
+ return false;
1425
+ }
1426
+ if (specificVal === "1" || specificVal === "true" || specificVal === "on") {
1427
+ return true;
1428
+ }
1429
+ }
1430
+ } catch {
1431
+ }
1432
+ try {
1433
+ const hostConfig = window.__DSH_BRIDGE_CONFIG__;
1434
+ if (hostConfig && typeof hostConfig === "object") {
1435
+ if (hostConfig.disablePageTweaks === true || hostConfig.disableMobileTweaks === true) {
1436
+ return false;
1437
+ }
1438
+ if (typeof hostConfig[featureName] === "boolean") {
1439
+ return hostConfig[featureName];
1440
+ }
1441
+ }
1442
+ } catch {
1443
+ }
1444
+ if (typeof runtimeFeatureOverrides[featureName] === "boolean") {
1445
+ return runtimeFeatureOverrides[featureName];
1446
+ }
1447
+ try {
1448
+ if (window.localStorage) {
1449
+ const stored = window.localStorage.getItem(`dsh_bridge:feature:${featureName}`);
1450
+ if (stored === "0" || stored === "false") return false;
1451
+ if (stored === "1" || stored === "true") return true;
1452
+ }
1453
+ } catch {
1454
+ }
1455
+ if (typeof defaultDetector === "function") {
1456
+ return defaultDetector();
1457
+ }
1458
+ return true;
1459
+ }
1460
+ function shouldYieldMobileHeader() {
1461
+ return !isFeatureEnabled(FEATURES.MOBILE_HEADER, () => {
1462
+ return !detectMobileShellOrResponsivePlugin();
1463
+ });
1464
+ }
1465
+ function shouldYieldMobileStyles() {
1466
+ return !isFeatureEnabled(FEATURES.MOBILE_STYLES, () => {
1467
+ return !detectMobileShellOrResponsivePlugin();
1468
+ });
1469
+ }
1470
+ function shouldYieldComposerCollapse() {
1471
+ return !isFeatureEnabled(FEATURES.COMPOSER_COLLAPSE, () => {
1472
+ return !detectMobileShellOrResponsivePlugin();
1473
+ });
1474
+ }
1475
+
1476
+ // client/settings-nav-icon.js
1477
+ var SETTINGS_NAV_MARKER = "data-dsh-bridge-settings-nav";
1478
+ var REMOTE_NAV_SVG = `<svg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'><rect width='10' height='16' x='3' y='4' rx='2'/><path d='M7 16h2'/><path d='M17 8a4 4 0 0 1 0 8'/><path d='M20 5a8 8 0 0 1 0 14'/></svg>`;
1479
+ function getNavIconCss(customSvg = REMOTE_NAV_SVG) {
1480
+ const maskUrl = `data:image/svg+xml,${encodeURIComponent(customSvg)}`;
1481
+ return `
1482
+ [${SETTINGS_NAV_MARKER}] > svg:first-child {
1483
+ display: none !important;
1484
+ }
1485
+ [${SETTINGS_NAV_MARKER}]::before {
1486
+ content: '';
1487
+ flex: none;
1488
+ width: 16px;
1489
+ height: 16px;
1490
+ background-color: currentColor;
1491
+ -webkit-mask: url("${maskUrl}") center / contain no-repeat;
1492
+ mask: url("${maskUrl}") center / contain no-repeat;
1493
+ }
1494
+ `;
1495
+ }
1496
+ function registerSettingsNavIcon(resolveLabel = () => "\u8FDC\u7A0B\u8BBF\u95EE") {
1497
+ if (typeof document === "undefined") return () => {
1498
+ };
1499
+ const styleId = "dsh-bridge-settings-nav-icon-style";
1500
+ if (!document.getElementById(styleId)) {
1501
+ const style = document.createElement("style");
1502
+ style.id = styleId;
1503
+ style.dataset.plugin = "@wenbin_wb/dsh-bridge";
1504
+ style.dataset.pluginCss = "@wenbin_wb/dsh-bridge/settings-nav-icon";
1505
+ style.textContent = getNavIconCss();
1506
+ document.head.appendChild(style);
1507
+ }
1508
+ let disposed = false;
1509
+ const sync = () => {
1510
+ if (disposed) return;
1511
+ const currentLabel = typeof resolveLabel === "function" ? resolveLabel().trim() : "\u8FDC\u7A0B\u8BBF\u95EE";
1512
+ const buttons = document.querySelectorAll('[role="dialog"] nav button, div[class*="VOzbGW_nav"] button');
1513
+ for (const button of buttons) {
1514
+ const text = button.textContent?.trim() || "";
1515
+ if (currentLabel.length > 0 && (text === currentLabel || text.includes(currentLabel))) {
1516
+ button.setAttribute(SETTINGS_NAV_MARKER, "");
1517
+ } else if (button.hasAttribute(SETTINGS_NAV_MARKER)) {
1518
+ button.removeAttribute(SETTINGS_NAV_MARKER);
1519
+ }
1520
+ }
1521
+ };
1522
+ sync();
1523
+ let observer = null;
1524
+ if (typeof MutationObserver !== "undefined") {
1525
+ observer = new MutationObserver(sync);
1526
+ observer.observe(document.body, { childList: true, subtree: true, characterData: true });
1527
+ }
1528
+ return () => {
1529
+ disposed = true;
1530
+ if (observer) observer.disconnect();
1531
+ document.querySelectorAll(`[${SETTINGS_NAV_MARKER}]`).forEach((el) => el.removeAttribute(SETTINGS_NAV_MARKER));
1532
+ const styleEl = document.getElementById(styleId);
1533
+ if (styleEl) styleEl.remove();
1534
+ };
1535
+ }
1536
+
1377
1537
  // lib/bridge-rpc-constants.js
1378
1538
  var BRIDGE_RPC_CHANNEL = "/dsh-bridge";
1379
1539
  var BRIDGE_ENDPOINTS = {
@@ -4860,10 +5020,13 @@ function TabBar({ active, onChange, dots }) {
4860
5020
  })
4861
5021
  );
4862
5022
  }
4863
- function BridgePanel({ rpcCall }) {
5023
+ function BridgePanel({ rpcCall, preferredTab }) {
4864
5024
  const [status, setStatus] = React.useState(null);
4865
5025
  const [err, setErr] = React.useState(null);
4866
- const [activeTab, setActiveTab] = React.useState("lan");
5026
+ const [activeTab, setActiveTab] = React.useState(preferredTab || "lan");
5027
+ React.useEffect(() => {
5028
+ if (preferredTab) setActiveTab(preferredTab);
5029
+ }, [preferredTab]);
4867
5030
  const [platforms, setPlatforms] = React.useState(null);
4868
5031
  const [selectedPlatform, setSelectedPlatform] = React.useState("wechat");
4869
5032
  const [copiedUrl, setCopiedUrl] = React.useState("");
@@ -5645,6 +5808,7 @@ function BridgePanel({ rpcCall }) {
5645
5808
  }
5646
5809
  function injectMobileStyles() {
5647
5810
  if (typeof document === "undefined") return;
5811
+ if (shouldYieldMobileStyles()) return;
5648
5812
  if (document.getElementById("dsh-bridge-mobile-styles")) return;
5649
5813
  const style = document.createElement("style");
5650
5814
  style.id = "dsh-bridge-mobile-styles";
@@ -5703,6 +5867,7 @@ function setupSettingsDrilldown() {
5703
5867
  function setupMobileExperience(rpcCall, ctx) {
5704
5868
  if (typeof document === "undefined" || typeof window === "undefined") return;
5705
5869
  injectMobileStyles();
5870
+ if (shouldYieldMobileHeader()) return;
5706
5871
  let header = document.querySelector(".dsh-mobile-app-header");
5707
5872
  let titleEl = document.querySelector(".dsh-mobile-header-title");
5708
5873
  if (!header) {
@@ -6659,8 +6824,9 @@ function setupIosKeyboardAdapter() {
6659
6824
  });
6660
6825
  }
6661
6826
  function setupComposerCollapse() {
6662
- if (typeof window === "undefined" || typeof document === "undefined") return;
6827
+ if (typeof window === "undefined" || typeof document === "undefined" || typeof MutationObserver === "undefined") return;
6663
6828
  if (window.innerWidth > MOBILE_MAX_WIDTH) return;
6829
+ if (shouldYieldComposerCollapse()) return;
6664
6830
  const LS_KEY = "dsh-composer-fold";
6665
6831
  let bar = null;
6666
6832
  let busy = false;
@@ -6793,6 +6959,11 @@ function setupComposerCollapse() {
6793
6959
  function apply(ctx) {
6794
6960
  window.__dshClientCtx = ctx;
6795
6961
  setupSettingsDrilldown();
6962
+ if (typeof ctx.effect === "function") {
6963
+ ctx.effect(() => registerSettingsNavIcon(() => "\u8FDC\u7A0B\u8BBF\u95EE"), "dsh-bridge: settings nav icon");
6964
+ } else {
6965
+ registerSettingsNavIcon(() => "\u8FDC\u7A0B\u8BBF\u95EE");
6966
+ }
6796
6967
  const rpcCall = (endpoint, payload, signal) => ctx.connection.rpc.call(BRIDGE_RPC_CHANNEL, endpoint, payload, signal);
6797
6968
  window.__dshOpenRemoteWorkspaceModal = (onAdded, onPickDirect, onCancel) => showRemoteWorkspaceDialog(rpcCall, onAdded, ctx, onPickDirect, onCancel);
6798
6969
  setupIosKeyboardAdapter();
@@ -6824,19 +6995,74 @@ function apply(ctx) {
6824
6995
  );
6825
6996
  })
6826
6997
  );
6827
- ctx.slots.inject(
6828
- "settings.section",
6829
- () => ctx.slots.register(
6830
- {
6831
- name: "settings.section",
6832
- id: "dsh-bridge",
6833
- order: 10,
6834
- label: () => "\u8FDC\u7A0B\u8BBF\u95EE",
6835
- inject: () => ({ rpcCall })
6836
- },
6837
- BridgePanel
6838
- )
6839
- );
6998
+ let settingsVisible = true;
6999
+ let sectionRegistration = null;
7000
+ const ensureSectionRegistered = () => {
7001
+ if (!settingsVisible || sectionRegistration) return;
7002
+ try {
7003
+ sectionRegistration = ctx.slots.register(
7004
+ {
7005
+ name: "settings.section",
7006
+ id: "dsh-bridge",
7007
+ order: 10,
7008
+ label: () => "\u8FDC\u7A0B\u8BBF\u95EE",
7009
+ inject: () => ({ rpcCall })
7010
+ },
7011
+ BridgePanel
7012
+ );
7013
+ } catch {
7014
+ }
7015
+ };
7016
+ const ensureSectionUnregistered = () => {
7017
+ if (!sectionRegistration) return;
7018
+ try {
7019
+ if (typeof sectionRegistration === "function") {
7020
+ sectionRegistration();
7021
+ } else if (typeof sectionRegistration.dispose === "function") {
7022
+ sectionRegistration.dispose();
7023
+ }
7024
+ } catch {
7025
+ }
7026
+ sectionRegistration = null;
7027
+ };
7028
+ const sectionGate = {
7029
+ visible: () => settingsVisible,
7030
+ setVisible: (visible) => {
7031
+ const next = !!visible;
7032
+ if (settingsVisible === next) return;
7033
+ settingsVisible = next;
7034
+ if (next) {
7035
+ ensureSectionRegistered();
7036
+ } else {
7037
+ ensureSectionUnregistered();
7038
+ }
7039
+ }
7040
+ };
7041
+ ctx.slots.inject("settings.section", () => {
7042
+ if (settingsVisible) {
7043
+ ensureSectionRegistered();
7044
+ }
7045
+ });
7046
+ const bridgeControl = {
7047
+ version: 1,
7048
+ /** 把插件自己的面板交给宿主渲染(返回 React element) */
7049
+ render: (props = {}) => {
7050
+ return React.createElement(BridgePanel, {
7051
+ rpcCall,
7052
+ preferredTab: props.preferredTab,
7053
+ ...props
7054
+ });
7055
+ },
7056
+ /** 宿主收编面板后,撤下设置页那条重复入口 */
7057
+ setSettingsVisible: (visible) => sectionGate.setVisible(visible),
7058
+ settingsVisible: () => sectionGate.visible(),
7059
+ /** 运行时动态特性开关配置 (Issue #52) */
7060
+ configureFeatures: (flags) => setRuntimeFeatureOverrides(flags),
7061
+ getFeatures: () => getRuntimeFeatureOverrides()
7062
+ };
7063
+ if (typeof ctx.provide === "function") {
7064
+ ctx.provide("dsh-bridge", bridgeControl);
7065
+ }
6840
7066
  }
6841
7067
 
6842
7068
  return module.exports;
package/client/index.js CHANGED
@@ -5,6 +5,14 @@ import {
5
5
  fetchLoopbackTokenOnce,
6
6
  } from './unlock-manager.js'
7
7
  import { hasOfficialDirectoryPicker, shouldYieldToOfficialPicker } from './picker-yield.js'
8
+ import {
9
+ shouldYieldMobileHeader,
10
+ shouldYieldMobileStyles,
11
+ shouldYieldComposerCollapse,
12
+ setRuntimeFeatureOverrides,
13
+ getRuntimeFeatureOverrides,
14
+ } from './mobile-yield.js'
15
+ import { registerSettingsNavIcon } from './settings-nav-icon.js'
8
16
  // dsh-bridge 客户端插件:设置页「远程访问」面板
9
17
 
10
18
  // 兼容非 HTTPS 环境(如手机局域网 HTTP 访问):为非安全上下文补齐 crypto.randomUUID
@@ -3081,10 +3089,13 @@ function TabBar({ active, onChange, dots }) {
3081
3089
 
3082
3090
  // ---- 主面板 ----
3083
3091
 
3084
- function BridgePanel({ rpcCall }) {
3092
+ function BridgePanel({ rpcCall, preferredTab }) {
3085
3093
  const [status, setStatus] = React.useState(null);
3086
3094
  const [err, setErr] = React.useState(null);
3087
- const [activeTab, setActiveTab] = React.useState('lan');
3095
+ const [activeTab, setActiveTab] = React.useState(preferredTab || 'lan');
3096
+ React.useEffect(() => {
3097
+ if (preferredTab) setActiveTab(preferredTab);
3098
+ }, [preferredTab]);
3088
3099
  // 平台列表和连接状态
3089
3100
  const [platforms, setPlatforms] = React.useState(null);
3090
3101
  const [selectedPlatform, setSelectedPlatform] = React.useState('wechat');
@@ -3819,6 +3830,7 @@ function BridgePanel({ rpcCall }) {
3819
3830
 
3820
3831
  function injectMobileStyles() {
3821
3832
  if (typeof document === 'undefined') return;
3833
+ if (shouldYieldMobileStyles()) return;
3822
3834
  if (document.getElementById('dsh-bridge-mobile-styles')) return;
3823
3835
 
3824
3836
  const style = document.createElement('style');
@@ -3915,6 +3927,7 @@ function setupSettingsDrilldown() {
3915
3927
  function setupMobileExperience(rpcCall, ctx) {
3916
3928
  if (typeof document === 'undefined' || typeof window === 'undefined') return;
3917
3929
  injectMobileStyles();
3930
+ if (shouldYieldMobileHeader()) return;
3918
3931
 
3919
3932
  // 1. 创建顶部 DeepSeek App 风格导航条 (Header: 左侧双横线,中间当前会话标题,右侧(+))
3920
3933
  let header = document.querySelector('.dsh-mobile-app-header');
@@ -5067,8 +5080,9 @@ function setupIosKeyboardAdapter() {
5067
5080
  // 中间消息可视区很小。折叠后 composer 隐藏、消息 viewArea 自动伸展全高,阅读区显著增大。
5068
5081
  // 折叠态记忆到 localStorage(dsh-composer-fold),跨会话保持用户偏好。
5069
5082
  function setupComposerCollapse() {
5070
- if (typeof window === 'undefined' || typeof document === 'undefined') return;
5083
+ if (typeof window === 'undefined' || typeof document === 'undefined' || typeof MutationObserver === 'undefined') return;
5071
5084
  if (window.innerWidth > MOBILE_MAX_WIDTH) return; // 仅移动端
5085
+ if (shouldYieldComposerCollapse()) return;
5072
5086
  const LS_KEY = 'dsh-composer-fold';
5073
5087
 
5074
5088
  let bar = null; // 折叠态底部"点击输入"细条
@@ -5197,6 +5211,12 @@ function apply(ctx) {
5197
5211
  // 放在最前:交互层先就绪,后续任何初始化抛异常都不会留下「CSS 生效但监听器缺失」
5198
5212
  // 的状态(CSS 6.2 块另有 <html> 就绪开关双重兜底)。
5199
5213
  setupSettingsDrilldown();
5214
+ // 设置页左侧导航栏图标定制:将原生通用齿轮替换为远程访问专属图标
5215
+ if (typeof ctx.effect === 'function') {
5216
+ ctx.effect(() => registerSettingsNavIcon(() => '远程访问'), 'dsh-bridge: settings nav icon');
5217
+ } else {
5218
+ registerSettingsNavIcon(() => '远程访问');
5219
+ }
5200
5220
  const rpcCall = (endpoint, payload, signal) =>
5201
5221
  ctx.connection.rpc.call(BRIDGE_RPC_CHANNEL, endpoint, payload, signal);
5202
5222
 
@@ -5245,18 +5265,80 @@ function apply(ctx) {
5245
5265
  }),
5246
5266
  );
5247
5267
 
5248
- ctx.slots.inject('settings.section', () =>
5249
- ctx.slots.register(
5250
- {
5251
- name: 'settings.section',
5252
- id: 'dsh-bridge',
5253
- order: 10,
5254
- label: () => '远程访问',
5255
- inject: () => ({ rpcCall }),
5256
- },
5257
- BridgePanel,
5258
- ),
5259
- );
5268
+ // 设置页条目动态门控与可见性管理 (Issue #51)
5269
+ let settingsVisible = true;
5270
+ let sectionRegistration = null;
5271
+
5272
+ const ensureSectionRegistered = () => {
5273
+ if (!settingsVisible || sectionRegistration) return;
5274
+ try {
5275
+ sectionRegistration = ctx.slots.register(
5276
+ {
5277
+ name: 'settings.section',
5278
+ id: 'dsh-bridge',
5279
+ order: 10,
5280
+ label: () => '远程访问',
5281
+ inject: () => ({ rpcCall }),
5282
+ },
5283
+ BridgePanel,
5284
+ );
5285
+ } catch {}
5286
+ };
5287
+
5288
+ const ensureSectionUnregistered = () => {
5289
+ if (!sectionRegistration) return;
5290
+ try {
5291
+ if (typeof sectionRegistration === 'function') {
5292
+ sectionRegistration();
5293
+ } else if (typeof sectionRegistration.dispose === 'function') {
5294
+ sectionRegistration.dispose();
5295
+ }
5296
+ } catch {}
5297
+ sectionRegistration = null;
5298
+ };
5299
+
5300
+ const sectionGate = {
5301
+ visible: () => settingsVisible,
5302
+ setVisible: (visible) => {
5303
+ const next = !!visible;
5304
+ if (settingsVisible === next) return;
5305
+ settingsVisible = next;
5306
+ if (next) {
5307
+ ensureSectionRegistered();
5308
+ } else {
5309
+ ensureSectionUnregistered();
5310
+ }
5311
+ },
5312
+ };
5313
+
5314
+ ctx.slots.inject('settings.section', () => {
5315
+ if (settingsVisible) {
5316
+ ensureSectionRegistered();
5317
+ }
5318
+ });
5319
+
5320
+ // 暴露客户端反射控制面 (Issue #51)
5321
+ const bridgeControl = {
5322
+ version: 1,
5323
+ /** 把插件自己的面板交给宿主渲染(返回 React element) */
5324
+ render: (props = {}) => {
5325
+ return React.createElement(BridgePanel, {
5326
+ rpcCall,
5327
+ preferredTab: props.preferredTab,
5328
+ ...props,
5329
+ });
5330
+ },
5331
+ /** 宿主收编面板后,撤下设置页那条重复入口 */
5332
+ setSettingsVisible: (visible) => sectionGate.setVisible(visible),
5333
+ settingsVisible: () => sectionGate.visible(),
5334
+ /** 运行时动态特性开关配置 (Issue #52) */
5335
+ configureFeatures: (flags) => setRuntimeFeatureOverrides(flags),
5336
+ getFeatures: () => getRuntimeFeatureOverrides(),
5337
+ };
5338
+
5339
+ if (typeof ctx.provide === 'function') {
5340
+ ctx.provide('dsh-bridge', bridgeControl);
5341
+ }
5260
5342
  }
5261
5343
 
5262
5344
  export { name, inject, apply };
@@ -0,0 +1,192 @@
1
+ // dsh-bridge 移动端体验与界面改动层的「让位与开关」机制 (Issue #52)
2
+ //
3
+ // 背景:
4
+ // dsh-bridge 为了在移动端网页访问(纯手机浏览器/局域网扫码)时提供类 App 的移动交互,
5
+ // 自建了 body 级导航条(.dsh-mobile-app-header)、输入框折叠按钮以及一套含 !important
6
+ // 的移动端全局覆盖样式。
7
+ //
8
+ // 但在以下环境,这一层自绘会引发严重冲突:
9
+ // 1. Android 原生 WebView 壳(如 com.dsharnessmobile.shell),壳层自身已有原生导航栏与手势;
10
+ // 2. 页面已安装专门的响应式插件(如 @dsh-android/dsh-client-ui-responsive),争抢同层 DOM;
11
+ // 3. 桌面端宿主(如 Tauri 桌面端 / DSH Desktop),无需任何移动端 DOM 侵入。
12
+ //
13
+ // 本模块提供「多级开关 + 自动特征探测让位」:
14
+ // 优先级:URL 参数 > 宿主全局注入配置 (__DSH_BRIDGE_CONFIG__) > 运行时动态配置 > 本地存储 > 自动探测兜底
15
+
16
+ /** 特性名称常量 */
17
+ export const FEATURES = {
18
+ MOBILE_HEADER: 'mobileHeader',
19
+ MOBILE_STYLES: 'mobileStyles',
20
+ COMPOSER_COLLAPSE: 'composerCollapse',
21
+ };
22
+
23
+ // 内存中的动态运行时覆盖配置(可通过 ctx.reflect.get('dsh-bridge').configureFeatures(...) 设置)
24
+ let runtimeFeatureOverrides = {};
25
+
26
+ /**
27
+ * 设置运行时特性覆盖(供 Cordis 反射服务调用)
28
+ * @param {Record<string, boolean>} overrides
29
+ */
30
+ export function setRuntimeFeatureOverrides(overrides) {
31
+ if (overrides && typeof overrides === 'object') {
32
+ runtimeFeatureOverrides = { ...runtimeFeatureOverrides, ...overrides };
33
+ }
34
+ }
35
+
36
+ /**
37
+ * 获取当前运行时特性状态
38
+ * @returns {Record<string, boolean>}
39
+ */
40
+ export function getRuntimeFeatureOverrides() {
41
+ return { ...runtimeFeatureOverrides };
42
+ }
43
+
44
+ /**
45
+ * 重置运行时特性覆盖(主要用于测试)
46
+ */
47
+ export function resetRuntimeFeatureOverrides() {
48
+ runtimeFeatureOverrides = {};
49
+ }
50
+
51
+ /**
52
+ * 探测是否处于已具备自适应/原生移动顶栏的环境中
53
+ * @returns {boolean}
54
+ */
55
+ export function detectMobileShellOrResponsivePlugin() {
56
+ if (typeof window === 'undefined') return false;
57
+
58
+ // 1. 移动端原生/混合壳显式标记
59
+ if (window.__DSH_MOBILE_SHELL__ || window.__TAURI__ || window.__DSH_DESKTOP__) {
60
+ return true;
61
+ }
62
+
63
+ // 2. 已加载的移动端自适应插件标记
64
+ if (window.__DSH_CLIENT_UI_RESPONSIVE__) {
65
+ return true;
66
+ }
67
+
68
+ // 3. DOM 标记探测(如 @dsh-android/dsh-client-ui-responsive 挂载的特征属性)
69
+ if (typeof document !== 'undefined') {
70
+ if (document.querySelector('[data-dsh-responsive-mobile]') ||
71
+ document.querySelector('[data-dsh-mobile-shell]')) {
72
+ return true;
73
+ }
74
+ }
75
+
76
+ return false;
77
+ }
78
+
79
+ /**
80
+ * 探测页面是否已有主顶栏(避免 body 级双顶栏叠加)
81
+ * @returns {boolean}
82
+ */
83
+ export function detectExistingHeader() {
84
+ if (typeof document === 'undefined') return false;
85
+ // 如果页面中已经有 #root 下的 header 或 banner 角色元素
86
+ return !!document.querySelector('#root header, [role="banner"]:not(.dsh-mobile-app-header)');
87
+ }
88
+
89
+ /**
90
+ * 解析特性开关(返回 true 表示启用,false 表示禁用/让位)
91
+ *
92
+ * @param {string} featureName 特性名称
93
+ * @param {() => boolean} defaultDetector 自动探测兜底函数
94
+ * @returns {boolean}
95
+ */
96
+ export function isFeatureEnabled(featureName, defaultDetector) {
97
+ if (typeof window === 'undefined') {
98
+ return typeof defaultDetector === 'function' ? defaultDetector() : true;
99
+ }
100
+
101
+ // 1. URL 应急排障参数(最高优先级,支持 ?dsh_bridge_tweaks=0 或 ?dsh_mobile=off 批量禁用)
102
+ try {
103
+ if (window.location && window.location.search) {
104
+ const params = new URLSearchParams(window.location.search);
105
+ // 总开关:禁用所有页面微调
106
+ const tweaksVal = params.get('dsh_bridge_tweaks') ?? params.get('dsh_mobile_tweaks');
107
+ if (tweaksVal === '0' || tweaksVal === 'false' || tweaksVal === 'off') {
108
+ return false;
109
+ }
110
+ if (tweaksVal === '1' || tweaksVal === 'true' || tweaksVal === 'on') {
111
+ return true;
112
+ }
113
+
114
+ // 单项开关:如 ?dsh_mobile_header=0, ?dsh_mobile_styles=0, ?dsh_composer_collapse=0
115
+ const kebabName = featureName.replace(/[A-Z]/g, (m) => `_${m.toLowerCase()}`);
116
+ const specificVal = params.get(`dsh_${kebabName}`) ?? params.get(`dsh_${featureName}`);
117
+ if (specificVal === '0' || specificVal === 'false' || specificVal === 'off') {
118
+ return false;
119
+ }
120
+ if (specificVal === '1' || specificVal === 'true' || specificVal === 'on') {
121
+ return true;
122
+ }
123
+ }
124
+ } catch {}
125
+
126
+ // 2. 宿主注入的全局配置对象 window.__DSH_BRIDGE_CONFIG__
127
+ try {
128
+ const hostConfig = window.__DSH_BRIDGE_CONFIG__;
129
+ if (hostConfig && typeof hostConfig === 'object') {
130
+ if (hostConfig.disablePageTweaks === true || hostConfig.disableMobileTweaks === true) {
131
+ return false;
132
+ }
133
+ if (typeof hostConfig[featureName] === 'boolean') {
134
+ return hostConfig[featureName];
135
+ }
136
+ }
137
+ } catch {}
138
+
139
+ // 3. 动态运行时配置(通过反射 API 设置)
140
+ if (typeof runtimeFeatureOverrides[featureName] === 'boolean') {
141
+ return runtimeFeatureOverrides[featureName];
142
+ }
143
+
144
+ // 4. 本地持久化偏好 (localStorage)
145
+ try {
146
+ if (window.localStorage) {
147
+ const stored = window.localStorage.getItem(`dsh_bridge:feature:${featureName}`);
148
+ if (stored === '0' || stored === 'false') return false;
149
+ if (stored === '1' || stored === 'true') return true;
150
+ }
151
+ } catch {}
152
+
153
+ // 5. 自动探测兜底
154
+ if (typeof defaultDetector === 'function') {
155
+ return defaultDetector();
156
+ }
157
+
158
+ return true;
159
+ }
160
+
161
+ /**
162
+ * 是否应该让位自绘顶栏 (返回 true 表示让位/不自建顶栏)
163
+ * @returns {boolean}
164
+ */
165
+ export function shouldYieldMobileHeader() {
166
+ return !isFeatureEnabled(FEATURES.MOBILE_HEADER, () => {
167
+ // 自动判定:如果是移动端壳层/已有自适应插件/页面已有顶栏,则主动让位
168
+ return !detectMobileShellOrResponsivePlugin();
169
+ });
170
+ }
171
+
172
+ /**
173
+ * 是否应该让位移动端全局样式 (返回 true 表示让位/不注入样式)
174
+ * @returns {boolean}
175
+ */
176
+ export function shouldYieldMobileStyles() {
177
+ return !isFeatureEnabled(FEATURES.MOBILE_STYLES, () => {
178
+ // 自动判定:如果已被外部响应式插件完全接管,则让位全局样式
179
+ return !detectMobileShellOrResponsivePlugin();
180
+ });
181
+ }
182
+
183
+ /**
184
+ * 是否应该让位输入框折叠按钮 (返回 true 表示让位/不挂载折叠按钮)
185
+ * @returns {boolean}
186
+ */
187
+ export function shouldYieldComposerCollapse() {
188
+ return !isFeatureEnabled(FEATURES.COMPOSER_COLLAPSE, () => {
189
+ // 自动判定:移动端壳层下输入框折叠按钮容易与原生交互冲突,让位
190
+ return !detectMobileShellOrResponsivePlugin();
191
+ });
192
+ }