dsh-remote-mobile 1.4.3 → 1.6.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
@@ -5,6 +5,49 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [1.6.0] - 2026-09-06
9
+
10
+ ### Mobile UX (解决 Issue #5 · 移动端体验全面重构与极窄屏排版加固)
11
+ - **解决 Issue #5:设置面板移动端改上下堆叠(preset-settings)**:针对窄屏下设置弹窗左右并排导致右侧文字被挤压成单字一列的痛点,彻底重构布局。通过 `[role="dialog"]:has(nav)` 精准定位带导航弹窗,取消 `530px` 定宽与 `zoom: 0.88` 微缩;导航栏置顶横排并支持横向滑动手势,内容区 100% 铺满并恢复纵向自然滚动,移除横滑提示;常规通用 Modal 不受任何影响。
12
+ - **解决 Issue #5:侧边栏抽屉自适应宽度与触控区友好化(preset-sidebar)**:
13
+ - 展开态抽屉宽度改为自适应 `width: min(85vw, 320px)`,告别 360px 窄屏或大屏手机沿用官方 280px 定宽的死板体验;
14
+ - 条目行高增大到 `min-height: 44px`、次级操作按钮增大到 `min-width/height: 36px`,符合移动端人机交互规范,大幅降低误触;
15
+ - **工作区与会话右侧操作按钮(更多操作 ⋯、新建会话 + 等)移动端常显**:彻底解决 PC 鼠标 hover 依赖在触屏上无法唤出操作按钮的痛点,结合标题容器自适应省略号截断防挤压;
16
+ - **精准区分会话选中与操作点击(闭环 Issue #5 抽屉收起取舍)**:点击会话条目(`_sessionRow`)主体直接程序化收起抽屉丝滑回到主聊天区;而点击条目操作按钮(`_rowActions` / `⋯` / `+`)或点击工作区行(`_projectRow` 展开/折叠)继续被防误收起守卫保护,抽屉保持展开;
17
+ - 折叠展开悬浮把手放大至 `44px`(圆角 12px),并通过 `top: max(12px, env(safe-area-inset-top))` 智能避让 iPhone 刘海屏、灵动岛与系统状态栏。
18
+ - **极窄屏弹窗与内边距紧凑化(解决 Issue #5 窄屏挤压)**:
19
+ - 设置弹窗宽度由 `94vw` 提升至 `96vw`,圆角微调至 `14px`,消除手机屏幕两侧过宽的无用灰边;收紧官方 `[role="dialog"] [class*="_content"]` 默认过厚(24px~32px)的内边距为 `10px 4px`,为内部卡片释放出 35px 以上的宝贵横向宽度;
20
+ - 将所有设置卡片(`DeviceListCard`、`NetworkCard`、`SecurityCard`、`SecretCard`、`ConfigCard`、`StylesCard`、`QrPairingCard`、`StorageCard`)的基础内边距由原本过宽的 `18px 20px` 统一调整为紧凑舒适的 `14px 14px`。
21
+ - **Tab 导航栏防文字折断与横滑支持(MainSection)**:给顶部页签按钮增加 `whiteSpace: nowrap` 与 `flexShrink: 0`,配合隐藏滚动条的弹性横向滑动(`overflow-x: auto`),彻底消除窄屏下“设备与安全”、“样式覆写”、“本地数据”等页签文字被挤压拆成两截的现象。
22
+ - **操作按钮防竖排挤压变形(SecurityCard 等)**:
23
+ - 修复安全审计卡片中「🗑️ 清空审计日志」等按钮因左侧标题不可压缩而缺少换行保护,导致文字被挤压成每行两字竖排的狭长细框的问题;全面为操作按钮配置 `whiteSpace: nowrap`、`flexShrink: 0` 与 `marginLeft: auto`;
24
+ - 容器统一重构为自适应 `flexWrap: wrap` 流式排版,宽屏下自然并排,窄屏下平滑换行靠右展示,按钮文字永远保持单行水平胶囊样式;
25
+ - 优化设备卡片(`DeviceListCard`)头部布局,说明文字通栏独立展示,不再与「一键注销」按钮争抢水平宽度。
26
+ - **设置页样式片段列表移动端响应式折行**:修复窄屏下操作按钮组与双端开关在同一行溢出卡片右边界的问题,移除强制不收缩限制,支持开关组与按钮组弹性自适应换行。
27
+ - **网络接入卡片移动端响应式流式排版(NetworkCard)**:修复窄屏下 Tailscale 与本地局域网(LAN)接入项的「标题/状态」与「免密直连开关」并排挤压导致图标与文字撕裂拆行的问题。重构为结构化自适应流:标题与状态徽标通栏舒展展示,免密开关在窄屏自适应换行靠右;访问链接与复制按钮收纳为底色胶囊输入条,彻底消除移动端排版错乱。
28
+
29
+
30
+ ### Stability & Lifecycle (生命周期与启动稳定性加固)
31
+ - **修复:底座 Patch 重载时的 inactive context 异常崩溃**。底座加载 `cordis.patch.yml`(如覆盖 `webserver` 监听 `0.0.0.0`)会触发服务重启与旧插件上下文注销(dispose);原就绪横幅延时函数在已注销的 context 下通过原生定时器触发并访问 `ctx.webServer`,触发 Cordis 容器断言抛出 `cannot get required service "webServer" in inactive context` 导致进程退出。现引入闭包 `disposed` 状态标志、监听 `ctx.on('dispose')` 自动清理定时器,并对服务访问与异步回调执行全流程异常隔离兜底,彻底保证各种极端重载与时序下的平稳运行。
32
+ - **修复:消除 Cordis 特权属性访问隔离拦截**。移除直接对未注入特权属性(如 `ctx.scope`、`ctx.setTimeout`)的读取,规避 Cordis 严格隔离模式下的 `cannot get property "timer"/"scope" without inject` 报错。
33
+
34
+ ### Compatibility (生态插件兼容与冲突防护)
35
+ - **新增:小鲸鱼悬浮按钮 (#dshRemoteWhale) 冲突防护**。针对第三方生态插件聚合打包时前端自适应逻辑在模块加载时自执行、容易与本插件产生移动端悬浮入口重复与手势交互冲突的问题:
36
+ - 新增 `FORCE_DESKTOP_SUPPRESSION_SNIPPET`,在 HTML `<head>` 最前置注入 `sessionStorage.setItem('dsh-remote-force-desktop', '1')`,触发其内置的桌面模式逻辑,平滑避免其移动端 DOM 重复挂载;
37
+ - 内置预设样式(`preset-sidebar`)补充 `#dshRemoteWhale` 与 `[data-dsh-plugin="remote-web-ui"]` 隐藏(`display: none !important`)规则,提供双重共存保障。
38
+ - **完善:文档新增单体插件安装指引**。说明聚合包打包机制对前端配置生效的影响,推荐按需单独安装单体功能插件,保持环境轻量清晰、互不干扰。
39
+
40
+ ---
41
+
42
+ ## [1.5.0] - 2026-09-04
43
+
44
+ ### Compatibility (适配 dsh@0.1.2-rc.1 官方 Token 鉴权)
45
+ - **适配:透明接管官方底座临时 Token 鉴权,消除双重鉴权冗余**。DSH 0.1.2-rc.1 官方底座引入了进程级临时启动 Token(`?token=xxxx`)与全链路 RPC/WebSocket 401 阻断;装有本插件的用户在进程重启后,手机已配对的直连书签会遭遇官方 401 报错。现通过对底座 `connection` 服务(`authorizeIndex`、`requestRejection`、`browserAuth.isAuthenticated`)实施切面代理:凡通过本插件门禁(本机回环、Tailscale/LAN 免密直连、已扫码配对/长期密码认证)的请求直接放行,无需手动复制官方临时 Token,手机书签直连与局域网免密恢复丝滑体验;同时兼容保留官方原生合法 token 的换取逻辑。
46
+ - **优化:终端就绪输出专属卡片**。服务启动后在控制台打印美观的边框横幅,明确标注「官方临时 Token 鉴权已接管,PC/移动端访问无需携带官方token」,按类型列出本机回环、局域网、Tailscale 私网等可用直连地址,并附带设置页配置指引与项目文档链接。
47
+ - **优化:URL Token 提取解耦**。`extractTokenFromRequest` 优化参数提取,支持 `rm_token` 与 `auth_token`,避免官方 `token=` 干扰插件内部会话逻辑。
48
+
49
+ ---
50
+
8
51
  ## [1.4.3] - 2026-09-03
9
52
 
10
53
  ### Fixed (缺陷修复)
package/README.md CHANGED
@@ -64,6 +64,9 @@ DSH 核心服务出于安全考虑默认仅监听本地回环地址(`127.0.0.1
64
64
  dsh plugin --profile web add dsh-remote-mobile
65
65
  ```
66
66
 
67
+ > [!IMPORTANT]
68
+ > **生态插件安装建议**:**不建议安装 `@linxin666/dsh-web-all` 全家桶**,更推荐**按需安装单体功能插件**。全家桶将多个前端模块合并打包,可能引发移动端悬浮入口重复与手势交互冲突。如需任务看板、宠物等生态功能,建议直接安装对应的单体包(详见下方说明)。
69
+
67
70
  ---
68
71
 
69
72
  <span id="config"></span>
@@ -90,6 +93,24 @@ dsh plugin --profile web add dsh-remote-mobile
90
93
 
91
94
  > **💡 说明**:插件自身注册已由 DSH Bundle 体系全自动处理,**无需**在 `cordis.patch.yml` 中额外添加 `id: remote-mobile`。Windows 用户可按 `Win + R` 键输入 `%USERPROFILE%\.dsh\profiles\web` 快速直达配置目录。
92
95
 
96
+ > [!WARNING]
97
+ > **⚠️ 插件兼容与共存说明:不建议安装聚合全家桶(`@linxin666/dsh-web-all`),更推荐「按需单独安装所需插件」**
98
+ >
99
+ > * **为什么不建议安装 `@linxin666/dsh-web-all` 全家桶?**
100
+ > 1. **避免功能冲突与重复入口**:该全家桶将多个子插件的前端逻辑编译打包在同一个 `client.js` 中,其移动端自适应逻辑在模块加载时会自执行。即使在 `cordis.patch.yml` 中配置了 `web-ui-remote-web-ui: disabled: true`,浏览器加载脚本后依然会渲染出小鲸鱼悬浮按钮(`#dshRemoteWhale`),导致两个插件的移动端入口与手势交互出现重复和冲突。
101
+ > 2. **体积更轻量、升级更解耦**:全家桶将多个功能捆绑在一起,按需安装单体插件可以保持环境精简,也便于未来各插件独立升级与维护。
102
+ >
103
+ > * **推荐的最佳实践(按需单独安装单体插件)**:
104
+ > 建议按需安装各自独立的功能单体包,例如:
105
+ > ```bash
106
+ > # 推荐方式:需要哪个装哪个,职责清晰、互不干扰
107
+ > dsh plugin --profile web add @linxin666/dsh-client-ui-task-board
108
+ > dsh plugin --profile web add @linxin666/dsh-client-ui-web-ui-settings
109
+ > dsh plugin --profile web add @linxin666/dsh-pet
110
+ > dsh plugin --profile web add @linxin666/dsh-ssh
111
+ > ```
112
+ > *(注:自 v1.6.0 起,`dsh-remote-mobile` 已内置冲突防护机制,会自动前置注入 `dsh-remote-force-desktop` 并协同 CSS 避免小鲸鱼悬浮按钮 `#dshRemoteWhale` 重复挂载,保障已安装全家桶环境下的共存体验;若追求更清爽的环境,按需安装单体包依然是最佳实践)*。
113
+
93
114
  ---
94
115
 
95
116
  ### 3. 启动并使用
@@ -162,7 +183,7 @@ dsh web --no-open
162
183
 
163
184
  DSH Web 界面在手机上仍有不少沿袭桌面端的样式问题,插件内置「移动端样式片段」模块,把移动端适配拆分为可独立启停的 CSS 片段:
164
185
 
165
- - **按界面区域划分的三段内置预设**:`preset-sidebar` 侧边栏抽屉导航(折叠 0 宽度、可拖拽悬浮把手)、`preset-settings` 设置面板适配(弹窗居中微缩、遮罩锁滚动)、`preset-main` 对话正文**高密度排版**(小字号 12.5px + 紧凑行距 + 收紧边距 → 每行展示更多内容;基于稳定 HTML 元素与 localName 后缀,不做布局缩放,无右侧留白),默认移动端启用、PC 关闭;
186
+ - **按界面区域划分的三段内置预设**:`preset-sidebar` 侧边栏抽屉导航(折叠 0 宽度、可拖拽悬浮把手、自适应抽屉宽度与 44px 触控友好区)、`preset-settings` 设置面板适配(上下堆叠自适应、顶部导航横滑、内容纵向自然滚览)、`preset-main` 对话正文**高密度排版**(小字号 12.5px + 紧凑行距 + 收紧边距 → 每行展示更多内容;基于稳定 HTML 元素与 localName 后缀,不做布局缩放,无右侧留白),默认移动端启用、PC 关闭;
166
187
  - **PC / 移动端分别启停(按视口宽度判定,与设备无关)**:每段预设和每个自定义片段都有独立的「🖥️ PC」「📱 移动端」两个开关;「移动端」= 窄视口(≤900px)生效——**PC 浏览器拉小窗口也会生效**,「PC」= 宽视口(>900px)生效,两端都开 = 全宽度生效;
167
188
  - **用户自定义(样式小插件)**:在 **设置 ⚙️ → 远程与移动端 → 🎨 移动端样式片段** 中粘贴自己的 CSS 即可新增片段,支持编辑/启停/删除/**一键复制**(每个片段「查看 CSS」旁都有「📋 复制样式」按钮),持久化于 `~/.dsh/remote-mobile/style-snippets.json`,保存后下一次页面加载即生效,无需重启;
168
189
  - **按 UA 打标记 + 按宽度生效**:样式按视口宽度档生效(见上),与设备 UA 无关;移动端 UA 请求额外给 `<html>` 打上 `data-dsh-mobile="1"` 标记作为作用域钩子,并注入可拖拽的展开把手脚本(全端注入,运行时仅在侧边栏折叠时生效)。
@@ -380,6 +401,15 @@ dsh-remote-mobile:
380
401
  3. 横幅可点击 ✕ 临时关闭;冲突解除后刷新页面即不再出现。
381
402
  </details>
382
403
 
404
+ <details>
405
+ <summary><b>Q6: 为什么建议按需安装单体功能插件,而不建议安装聚合全家桶(如 @linxin666/dsh-web-all)?</b></summary>
406
+
407
+ **答**:主要是基于**避免功能冲突**与**环境轻量解耦**的考量:
408
+ 1. **避免移动端入口重复与手势交互冲突**:全家桶插件会将十几个独立功能插件的前端代码编译打包在同一个 `client.js` 中,其内置的移动端自适应逻辑在模块加载时会自执行。即使在 `cordis.patch.yml` 中配置了 `web-ui-remote-web-ui: disabled: true`,浏览器加载脚本后仍可能渲染出小鲸鱼悬浮按钮(`#dshRemoteWhale`),导致两个插件的移动端悬浮入口重叠以及触控手势冲突。
409
+ 2. **轻量可控与独立升级**:按需单独安装所需插件(例如只需任务看板和宠物时,仅安装 `@linxin666/dsh-client-ui-task-board` 与 `@linxin666/dsh-pet`),既能保证环境轻盈,也便于各个插件独立升级、回滚与维护,避免依赖绑定。
410
+ *(注:自 v1.6.0 起,`dsh-remote-mobile` 已内置无缝兼容处理,通过前置参数与 CSS 协同规避 `#dshRemoteWhale` 重复挂载,保障已安装全家桶环境下的正常使用;若追求更简洁稳定的开发与运行环境,按需安装单体包仍是最佳实践)*。
411
+ </details>
412
+
383
413
  ---
384
414
 
385
415
  ## 📄 开源许可证
package/README_EN.md CHANGED
@@ -64,6 +64,9 @@ Run the DSH official plugin management command in your terminal (Recommended):
64
64
  dsh plugin --profile web add dsh-remote-mobile
65
65
  ```
66
66
 
67
+ > [!IMPORTANT]
68
+ > **Ecosystem Plugin Advice**: **Installing `@linxin666/dsh-web-all` (all-in-one package) is not recommended**. We suggest **installing individual standalone plugins on demand**. The all-in-one package bundles multiple frontend modules together, which may result in duplicated mobile floating buttons and conflicting gesture handlers. If you need task boards, pet widgets, or other tools, install their standalone packages directly (see details below).
69
+
67
70
  ---
68
71
 
69
72
  <span id="config"></span>
@@ -91,6 +94,24 @@ Because DSH defaults to `127.0.0.1`, ensure your `~/.dsh/profiles/web/cordis.pat
91
94
 
92
95
  > **💡 Note**: Plugin self-registration is automatically handled by the DSH Bundle system. You do **not** need to add `id: remote-mobile` manually. Windows users can press `Win + R` and enter `%USERPROFILE%\.dsh\profiles\web` to quickly jump to the configuration folder.
93
96
 
97
+ > [!WARNING]
98
+ > **⚠️ Plugin Compatibility & Coexistence Note: Prefer installing standalone plugins over the all-in-one bundle (`@linxin666/dsh-web-all`)**
99
+ >
100
+ > * **Why do we recommend avoiding `@linxin666/dsh-web-all`?**
101
+ > 1. **Avoid duplicated entries and gesture conflicts**: The all-in-one package compiles the frontend logic of multiple plugins into a single `client.js`. Its mobile adaptation logic executes upon script load. Even if you set `web-ui-remote-web-ui: disabled: true` in `cordis.patch.yml`, the browser may still render the floating whale button (`#dshRemoteWhale`), leading to duplicated mobile entry points and conflicting touch gestures.
102
+ > 2. **Lightweight and independently upgradable**: Installing standalone packages keeps the environment minimal and lets you upgrade, rollback, or configure each component independently.
103
+ >
104
+ > * **Recommended Best Practice (Install Standalone Plugins)**:
105
+ > Install only the specific standalone packages you actually need, for example:
106
+ > ```bash
107
+ > # Recommended: clear responsibilities, decoupled, fully controllable
108
+ > dsh plugin --profile web add @linxin666/dsh-client-ui-task-board
109
+ > dsh plugin --profile web add @linxin666/dsh-client-ui-web-ui-settings
110
+ > dsh plugin --profile web add @linxin666/dsh-pet
111
+ > dsh plugin --profile web add @linxin666/dsh-ssh
112
+ > ```
113
+ > *(Note: Starting from v1.6.0, `dsh-remote-mobile` includes automated compatibility handling that prepends `dsh-remote-force-desktop` to sessionStorage and coordinates with CSS to prevent the `#dshRemoteWhale` button from mounting twice. For the cleanest setup, installing standalone packages remains the best practice)*.
114
+
94
115
  ---
95
116
 
96
117
  ### 3. Launch & Pair
@@ -161,7 +182,7 @@ Open the DSH Web Console in your browser, navigate to **Settings ⚙️ -> Remot
161
182
 
162
183
  The DSH web UI still carries many desktop-oriented styles on phones. This plugin ships a built-in **Mobile Style Snippets** module that splits mobile adaptation into toggleable CSS snippets:
163
184
 
164
- - **Three area-based built-in presets**: `preset-sidebar` (collapsed sidebar becomes a 0-width drawer with a draggable floating toggle), `preset-settings` (centered/scaled settings dialog with locked overlay scroll), `preset-main` (dense conversation typography with scrollable code blocks). Mobile on / PC off by default;
185
+ - **Three area-based built-in presets**: `preset-sidebar` (collapsed sidebar becomes a 0-width drawer with draggable floating toggle, adaptive width, and touch-friendly 44px tap targets), `preset-settings` (vertically stacked layout with horizontally scrollable nav tabs and natural vertical scrolling), `preset-main` (dense conversation typography with scrollable code blocks). Mobile on / PC off by default;
165
186
  - **Separate PC / Mobile toggles (viewport-width based, device-independent)**: every preset and custom snippet has independent PC and Mobile switches; "Mobile" = applies at narrow viewports (≤900px) — **a PC browser with a narrow window gets it too**; "PC" = applies at wide viewports (>900px); both on = applies at all widths;
166
187
  - **Custom snippets (your own mini-plugins)**: paste your CSS in **Settings ⚙️ → Remote & Mobile → 🎨 Mobile Style Snippets** to add a snippet, with edit/toggle/delete/**one-click copy** support (every snippet has a "📋 Copy CSS" button next to "View CSS"). Persisted to `~/.dsh/remote-mobile/style-snippets.json`; changes apply on the next page load without restart;
167
188
  - **UA-based marker + width-based styling**: styles apply by viewport-width band (see above), independent of device UA; mobile UA requests additionally add `data-dsh-mobile="1"` to `<html>` as a scope hook, and the draggable toggle script is injected for all clients (it only activates while the sidebar is collapsed).
@@ -379,6 +400,15 @@ If your log instead shows `service "remoteWebUiPairing" has been registered`, pl
379
400
  **Answer**: **Supported.** The plugin uses request isolation so that mobile devices get the same workspace management and execution capabilities as the desktop Web interface.
380
401
  </details>
381
402
 
403
+ <details>
404
+ <summary><b>Q6: Why is installing standalone plugins recommended over monolithic all-in-one packages (e.g. @linxin666/dsh-web-all)?</b></summary>
405
+
406
+ **Answer**: This is primarily based on **avoiding functional conflicts** and **keeping the environment lightweight and decoupled**:
407
+ 1. **Avoid duplicated mobile entries and touch gesture conflicts**: All-in-one packages compile the frontend logic of dozens of sub-plugins into a single `client.js`. Its mobile adaptation logic executes upon script load. Even if you set `web-ui-remote-web-ui: disabled: true` in `cordis.patch.yml`, the browser may still render the floating whale button (`#dshRemoteWhale`), causing duplicated mobile floating entry points and touch gesture interference.
408
+ 2. **Lightweight and independently upgradable**: Installing standalone packages on demand (e.g. installing only `@linxin666/dsh-client-ui-task-board` and `@linxin666/dsh-pet` when needed) maintains a minimal dependency footprint and allows independent upgrading, rollback, and maintenance.
409
+ *(Note: As of v1.6.0, `dsh-remote-mobile` provides built-in compatibility handling to prevent `#dshRemoteWhale` from mounting repeatedly, ensuring smooth coexistence when an all-in-one package is present; for the cleanest environment, installing standalone plugins remains the recommended practice)*.
410
+ </details>
411
+
382
412
  ---
383
413
 
384
414
  ## 📄 License
package/lib/auth/token.js CHANGED
@@ -657,10 +657,10 @@ export class SessionStore extends EventEmitter {
657
657
  const authHeader = req.headers.authorization || '';
658
658
  if (authHeader.startsWith('Bearer '))
659
659
  return authHeader.slice(7).trim();
660
- if (req.url && req.url.includes('token=')) {
660
+ if (req.url && (req.url.includes('rm_token=') || req.url.includes('auth_token='))) {
661
661
  try {
662
662
  const u = new URL(req.url, 'http://localhost');
663
- const t = u.searchParams.get('token');
663
+ const t = u.searchParams.get('rm_token') || u.searchParams.get('auth_token');
664
664
  if (t)
665
665
  return t;
666
666
  }
@@ -47,11 +47,17 @@ export declare const CRYPTO_POLYFILL_SNIPPET = "\n(function() {\n if (typeof wi
47
47
  * 原生可用时不做任何替换。
48
48
  */
49
49
  export declare const CLIPBOARD_POLYFILL_SNIPPET = "\n(function() {\n if (typeof navigator === \"undefined\") return;\n try {\n if (navigator.clipboard && typeof navigator.clipboard.writeText === \"function\") return;\n var writeText = function(text) {\n try {\n var ta = document.createElement('textarea');\n ta.value = String(text == null ? '' : text);\n ta.setAttribute('readonly', '');\n ta.style.position = 'fixed';\n ta.style.top = '-9999px';\n ta.style.left = '-9999px';\n document.body.appendChild(ta);\n ta.select();\n var ok = document.execCommand('copy');\n document.body.removeChild(ta);\n return ok ? Promise.resolve() : Promise.reject(new Error('copy failed'));\n } catch (err) {\n return Promise.reject(err);\n }\n };\n var readText = function() {\n return Promise.reject(new Error('clipboard readText is not supported in insecure context'));\n };\n try {\n if (!navigator.clipboard) navigator.clipboard = {};\n if (typeof navigator.clipboard.writeText !== \"function\") navigator.clipboard.writeText = writeText;\n if (typeof navigator.clipboard.readText !== \"function\") navigator.clipboard.readText = readText;\n } catch (err) {\n try {\n Object.defineProperty(navigator, 'clipboard', {\n value: { writeText: writeText, readText: readText },\n configurable: true,\n });\n } catch (err2) {}\n }\n } catch (err) {}\n})();\n";
50
+ /**
51
+ * 注入到 index.html <head> 最前列的生态插件 remote-web-ui 移动端冲突防护脚本
52
+ * 写入 sessionStorage 'dsh-remote-force-desktop' = '1',触发对方内置的强制桌面跳过机制,
53
+ * 避免生态插件重复渲染小鲸鱼按钮 (#dshRemoteWhale) 以及引发手势拦截冲突
54
+ */
55
+ export declare const FORCE_DESKTOP_SUPPRESSION_SNIPPET = "\n(function() {\n try {\n if (typeof window !== \"undefined\" && window.sessionStorage) {\n window.sessionStorage.setItem('dsh-remote-force-desktop', '1');\n }\n } catch (e) {}\n})();\n";
50
56
  /**
51
57
  * 注入到 index.html 的移动端悬浮入口拖拽与位置记忆脚本
52
58
  * 允许用户在移动端上下随心拖动左上角入口把手,避免遮挡聊天内容,并自动保存位置
53
59
  */
54
- export declare const DRAGGABLE_NAV_SNIPPET = "\n(function() {\n if (typeof window === \"undefined\" || typeof document === \"undefined\") return;\n\n /* ===== \u9632\u8BEF\u6536\u8D77\u5B88\u536B =====\n * \u7B2C\u4E09\u65B9\u751F\u6001\u63D2\u4EF6\uFF08\u5982 @linxin666/dsh-web-ui-all \u7684\u79FB\u52A8\u7AEF\u62BD\u5C49\u589E\u5F3A\uFF09\u4F1A\u5728\u7528\u6237\u70B9\u51FB\n * \u4FA7\u8FB9\u680F\u6761\u76EE\uFF08role=treeitem\uFF09\u540E\uFF0C\u4E8E\u4E0B\u4E00\u5E27\u7A0B\u5E8F\u5316\u70B9\u51FB\u300C\u6536\u8D77\u4FA7\u8FB9\u680F\u300D\u6309\u94AE\u81EA\u52A8\u6536\u56DE\u62BD\u5C49\u3002\n * \u8BE5\u5224\u5B9A\u65E0\u6CD5\u533A\u5206\u300C\u9009\u4E2D\u6761\u76EE\u56DE\u4E3B\u533A\u300D\u4E0E\u300C\u70B9\u51FB\u6761\u76EE\u5185\u7684\u6B21\u7EA7\u64CD\u4F5C\u56FE\u6807\u300D\uFF08\u66F4\u591A\u64CD\u4F5C \u22EF /\n * \u5C55\u5F00\u7BAD\u5934\u7B49\uFF09\uFF0C\u5BFC\u81F4\u540E\u8005\u62BD\u5C49\u88AB\u8BEF\u5173\u3002\n * \u8FD9\u91CC\u8BB0\u5F55\u6700\u8FD1\u4E00\u6B21\u5BF9\u6761\u76EE\u5185\u4EFB\u610F\u90E8\u4F4D\uFF08\u9879\u76EE\u540D\u79F0\u4E3B\u4F53 / \u6B21\u7EA7\u6309\u94AE / \u7BAD\u5934\u56FE\u6807\uFF09\u7684\u89E6\u6478\u65F6\u95F4\uFF0C\n * \u5E76\u5305\u88C5 HTMLButtonElement.prototype.click\uFF1A\u77ED\u65F6\u95F4\u7A97\u53E3\u5185\u7684\u7A0B\u5E8F\u5316\u6536\u8D77\u8C03\u7528\u76F4\u63A5\u5FFD\u7565\u3002\n * \u8986\u76D6\u6761\u76EE\u4E3B\u4F53\u662F\u4E3A\u4E86\u8BA9\u9009\u4E2D\u9AD8\u4EAE\u540E\u6D6E\u73B0\u7684\u5C55\u5F00/\u6536\u8D77\u7BAD\u5934\u4E0E\u65B0\u589E/\u66F4\u591A\u64CD\u4F5C\u56FE\u6807\u6709\u8DB3\u591F\u7684\n * \u505C\u7559\u64CD\u4F5C\u65F6\u95F4\uFF1B\u7528\u6237\u771F\u5B9E\u70B9\u51FB\u6536\u8D77\u6309\u94AE\uFF08\u6D4F\u89C8\u5668\u5408\u6210\u4E8B\u4EF6\u4E0D\u8D70 prototype.click\uFF09\u4E0E\n * \u70B9\u51FB\u62BD\u5C49\u5916\u6697\u5904\u6536\u8D77\u7684\u5B98\u65B9\u884C\u4E3A\u5747\u4E0D\u53D7\u5F71\u54CD\u3002 */\n if (!window.__dshRmAntiCollapseInstalled__) {\n window.__dshRmAntiCollapseInstalled__ = true;\n var lastEntryTapAt = 0;\n document.addEventListener('pointerdown', function (e) {\n try {\n var t = e.target;\n if (!t || !t.closest) return;\n if (!t.closest('[data-pane=\"sidebar\"], [class*=\"_sidebarCol\"]')) return;\n var entry = t.closest('[role=\"treeitem\"], [data-dsh-part=\"sidebar-entry\"]');\n if (!entry) return;\n lastEntryTapAt = Date.now();\n } catch (err) {}\n }, { capture: true, passive: true });\n\n var protoClick = HTMLButtonElement.prototype.click;\n HTMLButtonElement.prototype.click = function () {\n try {\n var isSidebarToggle =\n this.matches('[data-dsh-responsive-part=\"sidebar-toggle\"]') ||\n /\u6536\u8D77\u4FA7\u8FB9\u680F|Collapse sidebar|Open sidebar/i.test(this.getAttribute('aria-label') || '');\n if (isSidebarToggle && Date.now() - lastEntryTapAt < 900) {\n return; // \u541E\u6389\u7D27\u968F\u6761\u76EE\u4EFB\u610F\u90E8\u4F4D\u70B9\u51FB\u4E4B\u540E\u7684\u7A0B\u5E8F\u5316\u6536\u8D77\n }\n } catch (err) {}\n return protoClick.apply(this, arguments);\n };\n }\n\n function initDraggableNav() {\n var logoRow = document.querySelector('[data-pane=\"sidebar\"] [class*=\"_logoRow\"], [class*=\"_sidebarCol\"] [class*=\"_logoRow\"]');\n if (!logoRow || logoRow.__dsh_drag_init__) return;\n logoRow.__dsh_drag_init__ = true;\n\n var savedTop = localStorage.getItem('dsh_mobile_nav_top');\n if (savedTop && !isNaN(Number(savedTop))) {\n var topVal = Math.max(10, Math.min(window.innerHeight - 60, Number(savedTop)));\n logoRow.style.setProperty('top', topVal + 'px', 'important');\n }\n\n var startY = 0;\n var initialTop = 0;\n var isDragging = false;\n var hasMoved = false;\n\n function onTouchStart(e) {\n var sidebar = document.querySelector('[data-pane=\"sidebar\"], [class*=\"_sidebarCol\"]');\n if (sidebar && !sidebar.querySelector('[class*=\"_collapsed\"]')) return;\n\n var touch = e.touches ? e.touches[0] : e;\n startY = touch.clientY;\n var rect = logoRow.getBoundingClientRect();\n initialTop = rect.top;\n isDragging = true;\n hasMoved = false;\n }\n\n function onTouchMove(e) {\n if (!isDragging) return;\n var touch = e.touches ? e.touches[0] : e;\n var deltaY = touch.clientY - startY;\n\n if (!hasMoved && Math.abs(deltaY) > 5) {\n hasMoved = true;\n }\n\n if (hasMoved) {\n if (e.cancelable) e.preventDefault();\n var newTop = initialTop + deltaY;\n var minTop = 10;\n var maxTop = window.innerHeight - 56;\n newTop = Math.max(minTop, Math.min(maxTop, newTop));\n logoRow.style.setProperty('top', newTop + 'px', 'important');\n }\n }\n\n function onTouchEnd(e) {\n if (!isDragging) return;\n isDragging = false;\n if (hasMoved) {\n e.preventDefault();\n e.stopPropagation();\n var currentRect = logoRow.getBoundingClientRect();\n try {\n localStorage.setItem('dsh_mobile_nav_top', String(Math.round(currentRect.top)));\n } catch (err) {}\n }\n }\n\n logoRow.addEventListener('touchstart', onTouchStart, { passive: true });\n window.addEventListener('touchmove', onTouchMove, { passive: false });\n window.addEventListener('touchend', onTouchEnd, { capture: true });\n\n logoRow.addEventListener('mousedown', onTouchStart);\n window.addEventListener('mousemove', onTouchMove);\n window.addEventListener('mouseup', onTouchEnd, { capture: true });\n }\n\n if (document.readyState === 'loading') {\n document.addEventListener('DOMContentLoaded', initDraggableNav);\n } else {\n initDraggableNav();\n }\n setInterval(initDraggableNav, 800);\n})();\n";
60
+ export declare const DRAGGABLE_NAV_SNIPPET = "\n(function() {\n if (typeof window === \"undefined\" || typeof document === \"undefined\") return;\n\n /* ===== \u9632\u8BEF\u6536\u8D77\u5B88\u536B =====\n * \u7B2C\u4E09\u65B9\u751F\u6001\u63D2\u4EF6\uFF08\u5982 @linxin666/dsh-web-ui-all \u7684\u79FB\u52A8\u7AEF\u62BD\u5C49\u589E\u5F3A\uFF09\u4F1A\u5728\u7528\u6237\u70B9\u51FB\n * \u4FA7\u8FB9\u680F\u6761\u76EE\uFF08role=treeitem\uFF09\u540E\uFF0C\u4E8E\u4E0B\u4E00\u5E27\u7A0B\u5E8F\u5316\u70B9\u51FB\u300C\u6536\u8D77\u4FA7\u8FB9\u680F\u300D\u6309\u94AE\u81EA\u52A8\u6536\u56DE\u62BD\u5C49\u3002\n * \u8BE5\u5224\u5B9A\u65E0\u6CD5\u533A\u5206\u300C\u9009\u4E2D\u6761\u76EE\u56DE\u4E3B\u533A\u300D\u4E0E\u300C\u70B9\u51FB\u6761\u76EE\u5185\u7684\u6B21\u7EA7\u64CD\u4F5C\u56FE\u6807\u300D\uFF08\u66F4\u591A\u64CD\u4F5C \u22EF /\n * \u5C55\u5F00\u7BAD\u5934\u7B49\uFF09\uFF0C\u5BFC\u81F4\u540E\u8005\u62BD\u5C49\u88AB\u8BEF\u5173\u3002\n * \u8FD9\u91CC\u8BB0\u5F55\u6700\u8FD1\u4E00\u6B21\u5BF9\u6761\u76EE\u5185\u4EFB\u610F\u90E8\u4F4D\uFF08\u9879\u76EE\u540D\u79F0\u4E3B\u4F53 / \u6B21\u7EA7\u6309\u94AE / \u7BAD\u5934\u56FE\u6807\uFF09\u7684\u89E6\u6478\u65F6\u95F4\uFF0C\n * \u5E76\u5305\u88C5 HTMLButtonElement.prototype.click\uFF1A\u77ED\u65F6\u95F4\u7A97\u53E3\u5185\u7684\u7A0B\u5E8F\u5316\u6536\u8D77\u8C03\u7528\u76F4\u63A5\u5FFD\u7565\u3002\n * \u8986\u76D6\u6761\u76EE\u4E3B\u4F53\u662F\u4E3A\u4E86\u8BA9\u9009\u4E2D\u9AD8\u4EAE\u540E\u6D6E\u73B0\u7684\u5C55\u5F00/\u6536\u8D77\u7BAD\u5934\u4E0E\u65B0\u589E/\u66F4\u591A\u64CD\u4F5C\u56FE\u6807\u6709\u8DB3\u591F\u7684\n * \u505C\u7559\u64CD\u4F5C\u65F6\u95F4\uFF1B\u7528\u6237\u771F\u5B9E\u70B9\u51FB\u6536\u8D77\u6309\u94AE\uFF08\u6D4F\u89C8\u5668\u5408\u6210\u4E8B\u4EF6\u4E0D\u8D70 prototype.click\uFF09\u4E0E\n * \u70B9\u51FB\u62BD\u5C49\u5916\u6697\u5904\u6536\u8D77\u7684\u5B98\u65B9\u884C\u4E3A\u5747\u4E0D\u53D7\u5F71\u54CD\u3002 */\n if (!window.__dshRmAntiCollapseInstalled__) {\n window.__dshRmAntiCollapseInstalled__ = true;\n var lastEntryTapAt = 0;\n document.addEventListener('pointerdown', function (e) {\n try {\n var t = e.target;\n if (!t || !t.closest) return;\n if (!t.closest('[data-pane=\"sidebar\"], [class*=\"_sidebarCol\"]')) return;\n\n // \u4F1A\u8BDD\u6761\u76EE\uFF08_sessionRow\uFF09\u9664\u64CD\u4F5C\u6309\u94AE\uFF08_rowActions/button\u7B49\uFF09\u4EE5\u5916\u7684\u4E3B\u4F53\u70B9\u51FB\uFF1A\n // \u7528\u6237\u610F\u5728\u8FDB\u5165/\u5207\u6362\u4F1A\u8BDD\u5E76\u67E5\u770B\u6B63\u6587\uFF0C\u4E0D\u62E6\u622A\u7A0B\u5E8F\u5316\u6536\u8D77\uFF08\u653E\u884C\u81EA\u52A8\u56DE\u4E3B\u533A\uFF09\n var isSessionRow = t.closest('[class*=\"_sessionRow\"]');\n var isActionOrToggle = t.closest('[class*=\"_rowActions\"], [class*=\"_chevron\"], [class*=\"_arrow\"], button, [role=\"button\"]');\n if (isSessionRow && !isActionOrToggle) {\n lastEntryTapAt = 0;\n return;\n }\n\n var entry = t.closest('[role=\"treeitem\"], [data-dsh-part=\"sidebar-entry\"]');\n if (!entry) return;\n lastEntryTapAt = Date.now();\n } catch (err) {}\n }, { capture: true, passive: true });\n\n var protoClick = HTMLButtonElement.prototype.click;\n HTMLButtonElement.prototype.click = function () {\n try {\n var isSidebarToggle =\n this.matches('[data-dsh-responsive-part=\"sidebar-toggle\"]') ||\n /\u6536\u8D77\u4FA7\u8FB9\u680F|Collapse sidebar|Open sidebar/i.test(this.getAttribute('aria-label') || '');\n if (isSidebarToggle && Date.now() - lastEntryTapAt < 900) {\n return; // \u541E\u6389\u7D27\u968F\u5DE5\u4F5C\u533A/\u64CD\u4F5C\u63A7\u4EF6\u70B9\u51FB\u4E4B\u540E\u7684\u7A0B\u5E8F\u5316\u6536\u8D77\n }\n } catch (err) {}\n return protoClick.apply(this, arguments);\n };\n }\n\n function initDraggableNav() {\n var logoRow = document.querySelector('[data-pane=\"sidebar\"] [class*=\"_logoRow\"], [class*=\"_sidebarCol\"] [class*=\"_logoRow\"]');\n if (!logoRow || logoRow.__dsh_drag_init__) return;\n logoRow.__dsh_drag_init__ = true;\n\n var savedTop = localStorage.getItem('dsh_mobile_nav_top');\n if (savedTop && !isNaN(Number(savedTop))) {\n var topVal = Math.max(10, Math.min(window.innerHeight - 60, Number(savedTop)));\n logoRow.style.setProperty('top', topVal + 'px', 'important');\n }\n\n var startY = 0;\n var initialTop = 0;\n var isDragging = false;\n var hasMoved = false;\n\n function onTouchStart(e) {\n var sidebar = document.querySelector('[data-pane=\"sidebar\"], [class*=\"_sidebarCol\"]');\n if (sidebar && !sidebar.querySelector('[class*=\"_collapsed\"]')) return;\n\n var touch = e.touches ? e.touches[0] : e;\n startY = touch.clientY;\n var rect = logoRow.getBoundingClientRect();\n initialTop = rect.top;\n isDragging = true;\n hasMoved = false;\n }\n\n function onTouchMove(e) {\n if (!isDragging) return;\n var touch = e.touches ? e.touches[0] : e;\n var deltaY = touch.clientY - startY;\n\n if (!hasMoved && Math.abs(deltaY) > 5) {\n hasMoved = true;\n }\n\n if (hasMoved) {\n if (e.cancelable) e.preventDefault();\n var newTop = initialTop + deltaY;\n var minTop = 10;\n var maxTop = window.innerHeight - 56;\n newTop = Math.max(minTop, Math.min(maxTop, newTop));\n logoRow.style.setProperty('top', newTop + 'px', 'important');\n }\n }\n\n function onTouchEnd(e) {\n if (!isDragging) return;\n isDragging = false;\n if (hasMoved) {\n e.preventDefault();\n e.stopPropagation();\n var currentRect = logoRow.getBoundingClientRect();\n try {\n localStorage.setItem('dsh_mobile_nav_top', String(Math.round(currentRect.top)));\n } catch (err) {}\n }\n }\n\n logoRow.addEventListener('touchstart', onTouchStart, { passive: true });\n window.addEventListener('touchmove', onTouchMove, { passive: false });\n window.addEventListener('touchend', onTouchEnd, { capture: true });\n\n logoRow.addEventListener('mousedown', onTouchStart);\n window.addEventListener('mousemove', onTouchMove);\n window.addEventListener('mouseup', onTouchEnd, { capture: true });\n }\n\n if (document.readyState === 'loading') {\n document.addEventListener('DOMContentLoaded', initDraggableNav);\n } else {\n initDraggableNav();\n }\n setInterval(initDraggableNav, 800);\n})();\n";
55
61
  /**
56
62
  * 注入到 index.html 的移动端抽屉「点外部收起」脚本
57
63
  *
@@ -63,7 +69,7 @@ export declare const DRAGGABLE_NAV_SNIPPET = "\n(function() {\n if (typeof wind
63
69
  * 后缀稳定),不依赖 web-ui 注入的 data-pane / data-dsh-frame 属性,因此装/不装 web-ui
64
70
  * 两种环境行为一致。折叠状态沿用官方写入的 data-sidebar-collapsed 属性。
65
71
  */
66
- export declare const MOBILE_DISMISS_SNIPPET = "\n(function() {\n if (typeof window === \"undefined\" || typeof document === \"undefined\") return;\n\n var frameSelector = '[class*=\"_frame\"]';\n var sidebarSelector = '[class*=\"_sidebarCol\"]';\n\n function webUiOwnsDrawer() {\n return !!document.querySelector('style[data-dsh-compat=\"responsive\"]');\n }\n\n function findToggle(frame) {\n return frame.querySelector('[class*=\"_toggle\"]') ||\n Array.prototype.filter.call(frame.querySelectorAll('button'), function (b) {\n return /\u6536\u8D77\u4FA7\u8FB9\u680F|Collapse sidebar|Open sidebar/i.test(b.getAttribute('aria-label') || '');\n })[0] || null;\n }\n\n function install(frame) {\n if (frame.__dshRmDismissInstalled__) return;\n frame.__dshRmDismissInstalled__ = true;\n var raf = 0;\n\n frame.addEventListener('click', function (event) {\n try {\n // web-ui \u53EF\u80FD\u8FD0\u884C\u4E2D\u624D\u6CE8\u5165 responsive style\uFF1A\u6BCF\u6B21\u4E8B\u4EF6\u90FD\u590D\u67E5\uFF0C\u52A8\u6001\u8BA9\u4F4D\n if (webUiOwnsDrawer()) return;\n var target = event.target;\n if (!target || !(target instanceof Element)) return;\n if (typeof window.matchMedia !== 'function' || !window.matchMedia('(max-width: 900px)').matches) return;\n var sidebar = frame.querySelector(sidebarSelector);\n var toggle = findToggle(frame);\n if (!sidebar || !toggle) return;\n\n // \u62BD\u5C49\u5C55\u5F00\u65F6\u70B9\u51FB\u6B63\u6587\uFF08sidebar \u5916\uFF09\u2192 \u89E6\u53D1\u5B98\u65B9\u6536\u8D77\u6309\u94AE\uFF0C\u5E76\u62E6\u622A\u88AB\u541E\u6389\u7684\u70B9\u51FB\uFF1B\n // \u627E\u4E0D\u5230\u6536\u8D77\u6309\u94AE\u65F6\u4E0A\u9762\u5DF2 return\uFF0C\u7EDD\u4E0D\u8BEF\u62E6\u6B63\u6587\u4EA4\u4E92\n if (!frame.hasAttribute('data-sidebar-collapsed') && !sidebar.contains(target)) {\n event.preventDefault();\n event.stopPropagation();\n toggle.click();\n return;\n }\n // \u70B9\u51FB\u4FA7\u8FB9\u680F\u6761\u76EE\uFF08\u9009\u4E2D\u540E\u56DE\u4E3B\u533A\uFF09\u2192 \u4E0B\u4E00\u5E27\u7A0B\u5E8F\u5316\u6536\u8D77\u3002\n // \u65E2\u6709\u9632\u8BEF\u6536\u8D77\u5B88\u536B\uFF08dsh-draggable-nav \u5305\u88C5 HTMLButtonElement.prototype.click\n // + 900ms \u7A97\u53E3\uFF09\u4F1A\u62E6\u622A\u300C\u70B9\u6761\u76EE\u4EFB\u610F\u90E8\u4F4D\uFF08\u542B\u9879\u76EE\u540D\u79F0\u4E3B\u4F53\uFF09\u300D\u540E\u7684\u8BEF\u6536\u8D77\uFF0C\n // \u4FDD\u8BC1\u9009\u4E2D\u9AD8\u4EAE\u540E\u6D6E\u73B0\u7684\u5C55\u5F00/\u6536\u8D77\u7BAD\u5934\u4E0E\u65B0\u589E/\u66F4\u591A\u64CD\u4F5C\u56FE\u6807\u53EF\u64CD\u4F5C\uFF0C\u884C\u4E3A\u4E0E web-ui \u4E00\u81F4\u3002\n if (target.closest('[class*=\"_toggle\"]')) return;\n if (!target.closest('[role=\"treeitem\"], [data-dsh-part=\"sidebar-entry\"]')) return;\n if (raf !== 0) cancelAnimationFrame(raf);\n raf = requestAnimationFrame(function () {\n raf = 0;\n if (!frame.hasAttribute('data-sidebar-collapsed')) toggle.click();\n });\n } catch (err) {}\n }, true);\n }\n\n function ensure() {\n if (webUiOwnsDrawer()) return;\n var frame = document.querySelector(frameSelector);\n if (frame) install(frame);\n }\n\n if (document.readyState === 'loading') {\n document.addEventListener('DOMContentLoaded', ensure);\n } else {\n ensure();\n }\n setInterval(ensure, 800);\n})();\n";
72
+ export declare const MOBILE_DISMISS_SNIPPET = "\n(function() {\n if (typeof window === \"undefined\" || typeof document === \"undefined\") return;\n\n var frameSelector = '[class*=\"_frame\"]';\n var sidebarSelector = '[class*=\"_sidebarCol\"]';\n\n function webUiOwnsDrawer() {\n return !!document.querySelector('style[data-dsh-compat=\"responsive\"]');\n }\n\n function findToggle(frame) {\n return frame.querySelector('[class*=\"_toggle\"]') ||\n Array.prototype.filter.call(frame.querySelectorAll('button'), function (b) {\n return /\u6536\u8D77\u4FA7\u8FB9\u680F|Collapse sidebar|Open sidebar/i.test(b.getAttribute('aria-label') || '');\n })[0] || null;\n }\n\n function install(frame) {\n if (frame.__dshRmDismissInstalled__) return;\n frame.__dshRmDismissInstalled__ = true;\n var raf = 0;\n\n frame.addEventListener('click', function (event) {\n try {\n // web-ui \u53EF\u80FD\u8FD0\u884C\u4E2D\u624D\u6CE8\u5165 responsive style\uFF1A\u6BCF\u6B21\u4E8B\u4EF6\u90FD\u590D\u67E5\uFF0C\u52A8\u6001\u8BA9\u4F4D\n if (webUiOwnsDrawer()) return;\n var target = event.target;\n if (!target || !(target instanceof Element)) return;\n if (typeof window.matchMedia !== 'function' || !window.matchMedia('(max-width: 900px)').matches) return;\n var sidebar = frame.querySelector(sidebarSelector);\n var toggle = findToggle(frame);\n if (!sidebar || !toggle) return;\n\n // \u62BD\u5C49\u5C55\u5F00\u65F6\u70B9\u51FB\u6B63\u6587\uFF08sidebar \u5916\uFF09\u2192 \u89E6\u53D1\u5B98\u65B9\u6536\u8D77\u6309\u94AE\uFF0C\u5E76\u62E6\u622A\u88AB\u541E\u6389\u7684\u70B9\u51FB\uFF1B\n // \u627E\u4E0D\u5230\u6536\u8D77\u6309\u94AE\u65F6\u4E0A\u9762\u5DF2 return\uFF0C\u7EDD\u4E0D\u8BEF\u62E6\u6B63\u6587\u4EA4\u4E92\n if (!frame.hasAttribute('data-sidebar-collapsed') && !sidebar.contains(target)) {\n event.preventDefault();\n event.stopPropagation();\n toggle.click();\n return;\n }\n // \u70B9\u51FB\u4FA7\u8FB9\u680F\u5185\u6B21\u7EA7\u64CD\u4F5C\u56FE\u6807\uFF08\u66F4\u591A\u64CD\u4F5C \u22EF / \u5C55\u5F00\u7BAD\u5934\u7B49\uFF09\u3001\u5DE5\u4F5C\u533A\u884C\uFF08_projectRow\uFF09\u6216\u539F\u751F\u5207\u6362\u6309\u94AE \u2192 \u4E0D\u6536\u8D77\u62BD\u5C49\n if (target.closest('[class*=\"_toggle\"], [class*=\"_rowActions\"], [class*=\"_projectRow\"], [class*=\"_chevron\"], [class*=\"_arrow\"], button, [role=\"button\"]')) return;\n // \u70B9\u51FB\u4F1A\u8BDD\u6761\u76EE\uFF08_sessionRow\uFF09\u4E3B\u4F53\uFF08\u9009\u4E2D\u4F1A\u8BDD\u540E\u56DE\u4E3B\u533A\uFF09\u2192 \u4E0B\u4E00\u5E27\u7A0B\u5E8F\u5316\u6536\u8D77\n if (!target.closest('[class*=\"_sessionRow\"], [role=\"treeitem\"], [data-dsh-part=\"sidebar-entry\"]')) return;\n if (raf !== 0) cancelAnimationFrame(raf);\n raf = requestAnimationFrame(function () {\n raf = 0;\n if (!frame.hasAttribute('data-sidebar-collapsed')) toggle.click();\n });\n } catch (err) {}\n }, true);\n }\n\n function ensure() {\n if (webUiOwnsDrawer()) return;\n var frame = document.querySelector(frameSelector);\n if (frame) install(frame);\n }\n\n if (document.readyState === 'loading') {\n document.addEventListener('DOMContentLoaded', ensure);\n } else {\n ensure();\n }\n setInterval(ensure, 800);\n})();\n";
67
73
  /**
68
74
  * 挂载底层 http.Server 门禁劫持与回环上下文虚拟化代理
69
75
  *
@@ -118,3 +124,22 @@ export declare const PLUGIN_MODULE_NAME = "dsh-remote-mobile";
118
124
  * @param store - 会话管理器实例
119
125
  */
120
126
  export declare function registerRemoteWebUiPairingBridge(ctx: any, store: SessionStore): void;
127
+ /**
128
+ * 判定一个请求是否已获得 dsh-remote-mobile 门禁授权(包括回环、免密直连与已认证会话)
129
+ */
130
+ export declare function isRequestAuthorizedByGate(req: any, store: SessionStore): boolean;
131
+ /**
132
+ * 透明接管 DSH 官方底座的 Token 鉴权服务 (connection.browserAuth & connection.requestRejection)
133
+ *
134
+ * 【设计职责】
135
+ * 官方新版在底座硬编码了临时进程 Token(dsh web: http://.../?token=xxxx)以及基于签名 Cookie 的
136
+ * 全链路 RPC/WebSocket 401 阻断。对于已安装本插件的用户而言,两层鉴权导致体验严重割裂且使手机书签失效。
137
+ * 本函数对底层 connection 服务实施透明短路代理:
138
+ * 1. 凡通过本插件门禁(本机、Tailscale/LAN 免密直连、已扫码配对/长期密码认证)的请求,底座鉴权全链路放行;
139
+ * 2. 彻底消除进程重启后移动端遭遇官方 401 的痛点,对外提供唯一定制化移动端门禁体系;
140
+ * 3. 未授权外部请求保持官方与插件原本拦截逻辑,且保留官方原生合法 token 的交换能力。
141
+ *
142
+ * @param connection - DSH 底座提供的 connection 服务实例
143
+ * @param store - 会话与配置管理器实例
144
+ */
145
+ export declare function takeOverConnectionAuth(connection: any, store: SessionStore): void;
@@ -30,7 +30,9 @@
30
30
  * - 判定基于 Cordis 公开运行时结构(服务占用 + fiber 运行态),不针对特定插件;
31
31
  * - 保证与其他远程接入类插件共存时必定可以正常启动(不再触发整树回滚致命错误)。
32
32
  */
33
+ import { timingSafeEqual } from 'node:crypto';
33
34
  import { getClientIp, isTailscaleIp, isLanIp } from '../auth/tailscale.js';
35
+ import { isLoopbackRequest } from '../auth/gate.js';
34
36
  import { isMobileUserAgent, applyDataMobileAttr, buildMobileStyleTag, } from '../styles/style-snippets.js';
35
37
  /**
36
38
  * 注入到 index.html <head> 最前列的 Polyfill 脚本代码片段
@@ -100,6 +102,20 @@ export const CLIPBOARD_POLYFILL_SNIPPET = `
100
102
  } catch (err) {}
101
103
  })();
102
104
  `;
105
+ /**
106
+ * 注入到 index.html <head> 最前列的生态插件 remote-web-ui 移动端冲突防护脚本
107
+ * 写入 sessionStorage 'dsh-remote-force-desktop' = '1',触发对方内置的强制桌面跳过机制,
108
+ * 避免生态插件重复渲染小鲸鱼按钮 (#dshRemoteWhale) 以及引发手势拦截冲突
109
+ */
110
+ export const FORCE_DESKTOP_SUPPRESSION_SNIPPET = `
111
+ (function() {
112
+ try {
113
+ if (typeof window !== "undefined" && window.sessionStorage) {
114
+ window.sessionStorage.setItem('dsh-remote-force-desktop', '1');
115
+ }
116
+ } catch (e) {}
117
+ })();
118
+ `;
103
119
  /**
104
120
  * 注入到 index.html 的移动端悬浮入口拖拽与位置记忆脚本
105
121
  * 允许用户在移动端上下随心拖动左上角入口把手,避免遮挡聊天内容,并自动保存位置
@@ -126,6 +142,16 @@ export const DRAGGABLE_NAV_SNIPPET = `
126
142
  var t = e.target;
127
143
  if (!t || !t.closest) return;
128
144
  if (!t.closest('[data-pane="sidebar"], [class*="_sidebarCol"]')) return;
145
+
146
+ // 会话条目(_sessionRow)除操作按钮(_rowActions/button等)以外的主体点击:
147
+ // 用户意在进入/切换会话并查看正文,不拦截程序化收起(放行自动回主区)
148
+ var isSessionRow = t.closest('[class*="_sessionRow"]');
149
+ var isActionOrToggle = t.closest('[class*="_rowActions"], [class*="_chevron"], [class*="_arrow"], button, [role="button"]');
150
+ if (isSessionRow && !isActionOrToggle) {
151
+ lastEntryTapAt = 0;
152
+ return;
153
+ }
154
+
129
155
  var entry = t.closest('[role="treeitem"], [data-dsh-part="sidebar-entry"]');
130
156
  if (!entry) return;
131
157
  lastEntryTapAt = Date.now();
@@ -139,7 +165,7 @@ export const DRAGGABLE_NAV_SNIPPET = `
139
165
  this.matches('[data-dsh-responsive-part="sidebar-toggle"]') ||
140
166
  /收起侧边栏|Collapse sidebar|Open sidebar/i.test(this.getAttribute('aria-label') || '');
141
167
  if (isSidebarToggle && Date.now() - lastEntryTapAt < 900) {
142
- return; // 吞掉紧随条目任意部位点击之后的程序化收起
168
+ return; // 吞掉紧随工作区/操作控件点击之后的程序化收起
143
169
  }
144
170
  } catch (err) {}
145
171
  return protoClick.apply(this, arguments);
@@ -276,12 +302,10 @@ export const MOBILE_DISMISS_SNIPPET = `
276
302
  toggle.click();
277
303
  return;
278
304
  }
279
- // 点击侧边栏条目(选中后回主区)→ 下一帧程序化收起。
280
- // 既有防误收起守卫(dsh-draggable-nav 包装 HTMLButtonElement.prototype.click
281
- // + 900ms 窗口)会拦截「点条目任意部位(含项目名称主体)」后的误收起,
282
- // 保证选中高亮后浮现的展开/收起箭头与新增/更多操作图标可操作,行为与 web-ui 一致。
283
- if (target.closest('[class*="_toggle"]')) return;
284
- if (!target.closest('[role="treeitem"], [data-dsh-part="sidebar-entry"]')) return;
305
+ // 点击侧边栏内次级操作图标(更多操作 ⋯ / 展开箭头等)、工作区行(_projectRow)或原生切换按钮 → 不收起抽屉
306
+ if (target.closest('[class*="_toggle"], [class*="_rowActions"], [class*="_projectRow"], [class*="_chevron"], [class*="_arrow"], button, [role="button"]')) return;
307
+ // 点击会话条目(_sessionRow)主体(选中会话后回主区)→ 下一帧程序化收起
308
+ if (!target.closest('[class*="_sessionRow"], [role="treeitem"], [data-dsh-part="sidebar-entry"]')) return;
285
309
  if (raf !== 0) cancelAnimationFrame(raf);
286
310
  raf = requestAnimationFrame(function () {
287
311
  raf = 0;
@@ -364,8 +388,9 @@ export function patchHttpServerWithVirtualizer(server, gateMiddleware, _store, s
364
388
  // web-ui 在场时完全交给它处理,未装时复刻等价行为
365
389
  html = html.replace('<head>', `<head><script id="dsh-mobile-dismiss">${MOBILE_DISMISS_SNIPPET}</script>`);
366
390
  html = html.replace('<head>', `<head><script id="dsh-crypto-polyfill">${CRYPTO_POLYFILL_SNIPPET}</script>`);
367
- // Clipboard Polyfill 最后 replace → 位于 <head> 最前,先于官方 bundle 生效
368
391
  html = html.replace('<head>', `<head><script id="dsh-clipboard-polyfill">${CLIPBOARD_POLYFILL_SNIPPET}</script>`);
392
+ // 冲突防护脚本后 replace → 位于 <head> 最前,优先写入 sessionStorage 避免小鲸鱼重复挂载
393
+ html = html.replace('<head>', `<head><script id="dsh-force-desktop-suppression">${FORCE_DESKTOP_SUPPRESSION_SNIPPET}</script>`);
369
394
  chunk = html;
370
395
  injected = true;
371
396
  }
@@ -380,8 +405,8 @@ export function patchHttpServerWithVirtualizer(server, gateMiddleware, _store, s
380
405
  str = str.replace('<head>', `<head><script id="dsh-draggable-nav">${DRAGGABLE_NAV_SNIPPET}</script>`);
381
406
  str = str.replace('<head>', `<head><script id="dsh-mobile-dismiss">${MOBILE_DISMISS_SNIPPET}</script>`);
382
407
  str = str.replace('<head>', `<head><script id="dsh-crypto-polyfill">${CRYPTO_POLYFILL_SNIPPET}</script>`);
383
- // Clipboard Polyfill 最后 replace → 位于 <head> 最前,先于官方 bundle 生效
384
408
  str = str.replace('<head>', `<head><script id="dsh-clipboard-polyfill">${CLIPBOARD_POLYFILL_SNIPPET}</script>`);
409
+ str = str.replace('<head>', `<head><script id="dsh-force-desktop-suppression">${FORCE_DESKTOP_SUPPRESSION_SNIPPET}</script>`);
385
410
  chunk = Buffer.from(str, 'utf8');
386
411
  injected = true;
387
412
  }
@@ -477,8 +502,9 @@ export function mountIndexInjections(ctx) {
477
502
  if (html.includes('<head>')) {
478
503
  return html
479
504
  .replace('<head>', `<head><script id="dsh-crypto-polyfill">${CRYPTO_POLYFILL_SNIPPET}</script>`)
480
- // Clipboard Polyfill 后 replace → 位于 <head> 最前,先于官方 bundle 生效
481
- .replace('<head>', `<head><script id="dsh-clipboard-polyfill">${CLIPBOARD_POLYFILL_SNIPPET}</script>`);
505
+ .replace('<head>', `<head><script id="dsh-clipboard-polyfill">${CLIPBOARD_POLYFILL_SNIPPET}</script>`)
506
+ // 冲突防护脚本后 replace → 位于 <head> 最前,优先写入 sessionStorage 避免小鲸鱼重复挂载
507
+ .replace('<head>', `<head><script id="dsh-force-desktop-suppression">${FORCE_DESKTOP_SUPPRESSION_SNIPPET}</script>`);
482
508
  }
483
509
  return html;
484
510
  });
@@ -495,6 +521,11 @@ export function mountIndexInjections(ctx) {
495
521
  placement: 'head',
496
522
  text: CLIPBOARD_POLYFILL_SNIPPET,
497
523
  });
524
+ table.push({
525
+ kind: 'script',
526
+ placement: 'head',
527
+ text: FORCE_DESKTOP_SUPPRESSION_SNIPPET,
528
+ });
498
529
  }
499
530
  });
500
531
  }
@@ -699,3 +730,106 @@ export function registerRemoteWebUiPairingBridge(ctx, store) {
699
730
  };
700
731
  decide().catch(() => { });
701
732
  }
733
+ /**
734
+ * 恒定时间字符串比较,防止针对 secret token 的时序攻击
735
+ */
736
+ function safeStringEqual(a, b) {
737
+ if (typeof a !== 'string' || typeof b !== 'string')
738
+ return false;
739
+ try {
740
+ const bufA = Buffer.from(a, 'utf8');
741
+ const bufB = Buffer.from(b, 'utf8');
742
+ if (bufA.length !== bufB.length)
743
+ return false;
744
+ return timingSafeEqual(bufA, bufB);
745
+ }
746
+ catch {
747
+ return false;
748
+ }
749
+ }
750
+ /**
751
+ * 判定一个请求是否已获得 dsh-remote-mobile 门禁授权(包括回环、免密直连与已认证会话)
752
+ */
753
+ export function isRequestAuthorizedByGate(req, store) {
754
+ if (!req)
755
+ return false;
756
+ // 1. 本机 127.0.0.1 / ::1 回环无条件放行
757
+ if (isLoopbackRequest(req))
758
+ return true;
759
+ const clientIp = getClientIp(req);
760
+ const options = store.getOptions();
761
+ // 2. Tailscale 私网免密直连放行
762
+ if (options.allowTailscale && isTailscaleIp(clientIp))
763
+ return true;
764
+ // 3. 局域网 LAN 免密直连放行
765
+ if (options.allowLan && isLanIp(clientIp))
766
+ return true;
767
+ // 4. 检查是否携带有效的授权会话 Token (Cookie / Header / URL Token)
768
+ const token = store.extractTokenFromRequest(req);
769
+ if (token && store.validateToken(token, clientIp))
770
+ return true;
771
+ return false;
772
+ }
773
+ /**
774
+ * 透明接管 DSH 官方底座的 Token 鉴权服务 (connection.browserAuth & connection.requestRejection)
775
+ *
776
+ * 【设计职责】
777
+ * 官方新版在底座硬编码了临时进程 Token(dsh web: http://.../?token=xxxx)以及基于签名 Cookie 的
778
+ * 全链路 RPC/WebSocket 401 阻断。对于已安装本插件的用户而言,两层鉴权导致体验严重割裂且使手机书签失效。
779
+ * 本函数对底层 connection 服务实施透明短路代理:
780
+ * 1. 凡通过本插件门禁(本机、Tailscale/LAN 免密直连、已扫码配对/长期密码认证)的请求,底座鉴权全链路放行;
781
+ * 2. 彻底消除进程重启后移动端遭遇官方 401 的痛点,对外提供唯一定制化移动端门禁体系;
782
+ * 3. 未授权外部请求保持官方与插件原本拦截逻辑,且保留官方原生合法 token 的交换能力。
783
+ *
784
+ * @param connection - DSH 底座提供的 connection 服务实例
785
+ * @param store - 会话与配置管理器实例
786
+ */
787
+ export function takeOverConnectionAuth(connection, store) {
788
+ if (!connection || connection.__dsh_rm_auth_takeover__)
789
+ return;
790
+ connection.__dsh_rm_auth_takeover__ = true;
791
+ // 1. 接管底层统一的 browserAuth.isAuthenticated (供各类 RPC/WebSocket 与内部检查调用)
792
+ if (connection.browserAuth && typeof connection.browserAuth.isAuthenticated === 'function') {
793
+ const origIsAuthenticated = connection.browserAuth.isAuthenticated.bind(connection.browserAuth);
794
+ connection.browserAuth.isAuthenticated = (request) => {
795
+ if (isRequestAuthorizedByGate(request, store)) {
796
+ return true;
797
+ }
798
+ return origIsAuthenticated(request);
799
+ };
800
+ }
801
+ // 2. 接管 connection.requestRejection (底层拦截未授权的 RPC / WebSocket 请求)
802
+ if (typeof connection.requestRejection === 'function') {
803
+ const origRequestRejection = connection.requestRejection.bind(connection);
804
+ connection.requestRejection = (request) => {
805
+ if (isRequestAuthorizedByGate(request, store)) {
806
+ return undefined; // 明确放行,杜绝 401
807
+ }
808
+ return origRequestRejection(request);
809
+ };
810
+ }
811
+ // 3. 接管 connection.authorizeIndex (控制根路径 GET / 下发 index.html)
812
+ if (typeof connection.authorizeIndex === 'function') {
813
+ const origAuthorizeIndex = connection.authorizeIndex.bind(connection);
814
+ connection.authorizeIndex = (req, res) => {
815
+ if (isRequestAuthorizedByGate(req, store)) {
816
+ // 若携带官方进程 token 且与当前 launchToken 匹配,优先走官方换取官方 Cookie 与重定向
817
+ if (req.url && req.url.includes('token=')) {
818
+ try {
819
+ const url = new URL(req.url, 'http://dsh.invalid');
820
+ const tokens = url.searchParams.getAll('token');
821
+ if (tokens.length > 0 &&
822
+ connection.browserAuth?.launchToken &&
823
+ safeStringEqual(tokens[0], connection.browserAuth.launchToken)) {
824
+ return origAuthorizeIndex(req, res);
825
+ }
826
+ }
827
+ catch { }
828
+ }
829
+ // 已授权设备直接放行提供 HTML,跳过官方 401 阻断
830
+ return true;
831
+ }
832
+ return origAuthorizeIndex(req, res);
833
+ };
834
+ }
835
+ }