dsh-remote-mobile 1.4.2 → 1.5.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,24 @@ 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.5.0] - 2026-09-04
9
+
10
+ ### Compatibility (适配 dsh@0.1.2-rc.1 官方 Token 鉴权)
11
+ - **适配:透明接管官方底座临时 Token 鉴权,消除双重鉴权冗余**。DSH 0.1.2-rc.1 官方底座引入了进程级临时启动 Token(`?token=xxxx`)与全链路 RPC/WebSocket 401 阻断;装有本插件的用户在进程重启后,手机已配对的直连书签会遭遇官方 401 报错。现通过对底座 `connection` 服务(`authorizeIndex`、`requestRejection`、`browserAuth.isAuthenticated`)实施切面代理:凡通过本插件门禁(本机回环、Tailscale/LAN 免密直连、已扫码配对/长期密码认证)的请求直接放行,无需手动复制官方临时 Token,手机书签直连与局域网免密恢复丝滑体验;同时兼容保留官方原生合法 token 的换取逻辑。
12
+ - **优化:终端就绪输出专属卡片**。服务启动后在控制台打印美观的边框横幅,明确标注「官方临时 Token 鉴权已接管,PC/移动端访问无需携带官方token」,按类型列出本机回环、局域网、Tailscale 私网等可用直连地址,并附带设置页配置指引与项目文档链接。
13
+ - **优化:URL Token 提取解耦**。`extractTokenFromRequest` 优化参数提取,支持 `rm_token` 与 `auth_token`,避免官方 `token=` 干扰插件内部会话逻辑。
14
+
15
+ ---
16
+
17
+ ## [1.4.3] - 2026-09-03
18
+
19
+ ### Fixed (缺陷修复)
20
+ - **修复:样式覆写启用后提问弹窗被全屏黑纱遮罩盖住且宽度被强制撑满**。`preset-sidebar` 的 `[class*="_frame"]` 选择器用后缀匹配,同时命中三栏布局帧(`pI_x6G_frame`)与提问卡片(`Mbwy4a_frame`,其 class 名同样含 `_frame`)。提问卡片没有 `data-sidebar-collapsed` 属性,被 `:not(...)::after` 规则叠加了 `position:fixed; inset:0; z-index:899; background:rgb(0 0 0/24%)` 的全屏黑纱(表现为弹窗上面多了一层遮罩),同时被 `width:100vw !important` 强制撑满、覆盖了自身 `max-width:var(--dsh-chat-content-width)` 导致不自适应宽度。现用 `:has([class*="_sidebarCol"])` 把 `_frame` 相关规则精确锁定到三栏布局帧,提问卡片不再被误伤。
21
+ - **修复:提问弹窗左右边距过宽、选项字号偏大**。`preset-main` 新增提问卡片适配(以官方稳定属性 `data-question-key` 为锚点):左右内边距收窄到 `8px`(官方为 `calc(composer-side-clearance + 16px)`),选项标题字号 14px→13px、说明 14px→12.5px,行高同步收敛,标题微调 16px→15px,与对话正文紧凑排版保持一致。
22
+ - **修复:移动端侧边栏点击项目名称后抽屉被自动收起**。生态插件「点条目自动收回抽屉」增强会把条目内所有点击一律视为选中、下一帧程序化收起抽屉,导致选中高亮刚浮现,展开/收起箭头与新增/更多操作图标来不及操作抽屉就被关掉。现把防误收起守卫的记录范围从「条目内次级控件(按钮/箭头)」扩展到「条目任意部位(含项目名称主体)」,900ms 窗口内的程序化收起调用被吞掉,抽屉保持展开;真实点击收起按钮与点抽屉外暗处收起的官方行为不受影响。
23
+
24
+ ---
25
+
8
26
  ## [1.4.2] - 2026-08-26
9
27
 
10
28
  ### Features (新功能)
package/README.md CHANGED
@@ -22,7 +22,7 @@
22
22
  <a href="#config">必备配置</a> •
23
23
  <a href="#preview">界面预览</a> •
24
24
  <a href="#features">功能特性</a> •
25
- <a href="#install">安装方式</a> •
25
+ <a href="#install">安装与更新</a> •
26
26
  <a href="#faq">常见问题</a>
27
27
  </p>
28
28
 
@@ -69,7 +69,7 @@ dsh plugin --profile web add dsh-remote-mobile
69
69
  <span id="config"></span>
70
70
  ### 2. 前置配置(开放外部监听)
71
71
 
72
- 由于 DSH 默认仅监听 `127.0.0.1`,为了使 Tailscale 私网或局域网设备能够正常连通,请确保在 `~/.dsh/profiles/web/cordis.patch.yml` 中包含以下配置:
72
+ 由于 DSH 默认仅监听 `127.0.0.1`,为了使 Tailscale 私网或局域网设备能够正常连通,请确保在 `~/.dsh/profiles/web/cordis.patch.yml`(Windows 对应 `C:\Users\<用户名>\.dsh\profiles\web\cordis.patch.yml` 或在资源管理器直接输入 `%USERPROFILE%\.dsh`)中包含以下配置:
73
73
 
74
74
  ```yaml
75
75
  # 1. 允许 webserver 监听外部网络连接(必选)
@@ -88,7 +88,7 @@ dsh plugin --profile web add dsh-remote-mobile
88
88
  disabled: true
89
89
  ```
90
90
 
91
- > **💡 说明**:插件自身注册已由 DSH Bundle 体系全自动处理,**无需**在 `cordis.patch.yml` 中额外添加 `id: remote-mobile`。
91
+ > **💡 说明**:插件自身注册已由 DSH Bundle 体系全自动处理,**无需**在 `cordis.patch.yml` 中额外添加 `id: remote-mobile`。Windows 用户可按 `Win + R` 键输入 `%USERPROFILE%\.dsh\profiles\web` 快速直达配置目录。
92
92
 
93
93
  ---
94
94
 
@@ -176,7 +176,7 @@ DSH Web 界面在手机上仍有不少沿袭桌面端的样式问题,插件内
176
176
  ---
177
177
 
178
178
  <span id="install"></span>
179
- ## 📦 全部安装方式
179
+ ## 📦 安装与更新
180
180
 
181
181
  <details>
182
182
  <summary><b>展开查看全部 4 种安装方式</b></summary>
@@ -203,6 +203,7 @@ pnpm add dsh-remote-mobile
203
203
  ```
204
204
 
205
205
  ### 方式 4:本地源码开发与调试(软链接即时生效)
206
+
206
207
  ```bash
207
208
  # 1. 克隆代码至本地
208
209
  git clone https://github.com/IceApriler/dsh-remote-mobile.git
@@ -211,15 +212,45 @@ cd dsh-remote-mobile
211
212
  # 2. 安装依赖并编译打包
212
213
  npm install
213
214
  npm run build
214
-
215
- # 3. 建立软链接到 DSH 运行环境(开发修改后 npm run build 即时生效)
216
- rm -rf ~/.dsh/profiles/web/node_modules/dsh-remote-mobile
217
- ln -s $(pwd) ~/.dsh/profiles/web/node_modules/dsh-remote-mobile
218
215
  ```
219
216
 
217
+ **建立软链接到 DSH 运行环境(开发修改后 `npm run build` 即时生效):**
218
+
219
+ * **macOS / Linux**:
220
+ ```bash
221
+ rm -rf ~/.dsh/profiles/web/node_modules/dsh-remote-mobile
222
+ ln -s $(pwd) ~/.dsh/profiles/web/node_modules/dsh-remote-mobile
223
+ ```
224
+
225
+ * **Windows (PowerShell)**:
226
+ ```powershell
227
+ Remove-Item -Recurse -Force "$HOME\.dsh\profiles\web\node_modules\dsh-remote-mobile"
228
+ New-Item -ItemType Junction -Path "$HOME\.dsh\profiles\web\node_modules\dsh-remote-mobile" -Target (Get-Location)
229
+ ```
230
+
231
+ * **Windows (CMD)**:
232
+ ```cmd
233
+ rmdir /s /q %USERPROFILE%\.dsh\profiles\web\node_modules\dsh-remote-mobile
234
+ mklink /J %USERPROFILE%\.dsh\profiles\web\node_modules\dsh-remote-mobile %CD%
235
+ ```
236
+
220
237
  </details>
221
238
 
222
- ### 卸载插件
239
+ ### 🔄 更新插件
240
+
241
+ 在终端执行更新指令,更新后重启 DSH 即可生效:
242
+
243
+ ```bash
244
+ # 方式 1:通过 DSH 命令行一键更新(推荐)
245
+ dsh plugin --profile web update dsh-remote-mobile
246
+
247
+ # 方式 2:通过 Profile 目录包管理器更新
248
+ cd ~/.dsh/profiles/web && pnpm update dsh-remote-mobile
249
+ ```
250
+
251
+ > **💡 提示**:也可以在 Web 控制台 **设置 ⚙️ -> 插件 -> 插件管理** 中点击对应插件的更新按钮,或重新执行 `dsh plugin --profile web add dsh-remote-mobile@latest`。
252
+
253
+ ### 🗑️ 卸载插件
223
254
 
224
255
  ```bash
225
256
  dsh plugin --profile web remove dsh-remote-mobile
@@ -272,6 +303,8 @@ dsh-remote-mobile:
272
303
 
273
304
  ## 📂 本地文件存储位置
274
305
 
306
+ > **💡 路径提示**:在 macOS / Linux 下根目录为 `~/.dsh/`;在 Windows 下对应为 `C:\Users\<你的用户名>\.dsh\`(可在文件资源管理器地址栏直接输入 `%USERPROFILE%\.dsh` 直达)。
307
+
275
308
  | 文件路径 | 说明 | 安全级别 |
276
309
  |---|---|---|
277
310
  | `~/.dsh/settings.yaml` | 全局安全策略与免密开关配置 | 用户级读写 |
@@ -289,9 +322,14 @@ dsh-remote-mobile:
289
322
 
290
323
  **答**:这是 **3080 端口被其他进程占用**(绝大多数是上一个 `dsh web` 实例尚未完全退出,例如桌面快捷方式拉起的隐藏实例、或重启时新旧进程交叠),**与安装了哪个插件无关**。排查步骤:
291
324
 
292
- 1. 查找占用端口的进程:`lsof -nP -iTCP:3080`(Windows 用 `netstat -ano | findstr 3080`);
293
- 2. 结束旧实例(`kill <PID>` / 任务管理器),再重新启动 `dsh web`;
294
- 3. 注意:桌面快捷方式(dsh-desktop-launcher 生成的实例)与终端手动启动的实例不能同时运行。
325
+ 1. **查找占用端口的进程**:
326
+ * **macOS / Linux**:`lsof -nP -iTCP:3080`
327
+ * **Windows**:`netstat -ano | findstr 3080`(最后一列为 PID)
328
+ 2. **结束旧实例**:
329
+ * **macOS / Linux**:`kill <PID>`
330
+ * **Windows**:`taskkill /F /PID <PID>`(或在任务管理器中结束对应 Node 进程)
331
+ 3. 重新启动 `dsh web` 即可。
332
+ 4. **注意**:桌面快捷方式(dsh-desktop-launcher 生成的后台实例)与终端手动启动的实例不能同时运行。
295
333
 
296
334
  另:若日志显示的是 `service "remoteWebUiPairing" has been registered` 类错误,请升级本插件至最新版本——新版已内置通用共存保护,与其他远程接入类插件并存时可正常启动并自动让出服务,并在设置面板顶部提示冲突详情。
297
335
  </details>
@@ -299,10 +337,17 @@ dsh-remote-mobile:
299
337
  <details>
300
338
  <summary><b>Q1: 手机扫码后提示「连接被拒绝」或无法打开页面?</b></summary>
301
339
 
302
- **答**:请检查以下三项:
303
- 1. 确保电脑上的 `cordis.patch.yml` 中已配置 `host: '0.0.0.0'`,且 DSH 已经重启;
304
- 2. 局域网访问时,确保手机与电脑连接在同一个 Wi-Fi / 路由器下;
305
- 3. 如果开启了电脑操作系统自带的防火墙,请确保允许入站访问 `3080` 端口。
340
+ **答**:请按以下步骤逐一排查:
341
+ 1. **配置检查**:确保电脑上的 `cordis.patch.yml` 中已配置 `host: '0.0.0.0'`,且 DSH 已经重启;
342
+ 2. **同一局域网**:局域网访问时,确保手机与电脑连接在同一个 Wi-Fi / 路由器下,且未开启手机端的独立代理/VPN(避免流量绕过局域网);
343
+ 3. **Windows 防火墙排查(Windows 用户常见原因)**:
344
+ * 启动 DSH 时如弹出「Windows Defender 防火墙」警报,请务必勾选 **「专用网络」** 和 **「公用网络」** 并允许访问;
345
+ * 若错过弹窗或依然无法连通,可使用**管理员权限打开 PowerShell** 执行以下命令一键放行端口:
346
+ ```powershell
347
+ New-NetFirewallRule -DisplayName "DSH Web 3080" -Direction Inbound -LocalPort 3080 -Protocol TCP -Action Allow
348
+ ```
349
+ * **检查 Wi-Fi 网络类型**:在 Windows「设置 -> 网络和 Internet -> Wi-Fi」中,确保当前 Wi-Fi 网络配置文件类型已设为 **「专用网络」**(若为「公用网络」,Windows 防火墙会默认拦截局域网入站请求);
350
+ 4. **多网卡环境(WSL / 虚拟机)**:Windows 如安装了 WSL2 或 Hyper-V,可能会生成虚拟网卡 IP(如 `172.x.x.x`)。请在插件设置面板中确认使用的是物理无线网卡(WLAN / Wi-Fi)对应的局域网 IP 或 Tailscale 二维码。
306
351
  </details>
307
352
 
308
353
  <details>
package/README_EN.md CHANGED
@@ -22,7 +22,7 @@ English Documentation · [简体中文](./README.md)
22
22
  <a href="#config">Required Config</a> •
23
23
  <a href="#preview">UI Preview</a> •
24
24
  <a href="#features">Features</a> •
25
- <a href="#install">Installation</a> •
25
+ <a href="#install">Installation & Update</a> •
26
26
  <a href="#faq">FAQ</a>
27
27
  </p>
28
28
 
@@ -69,7 +69,7 @@ dsh plugin --profile web add dsh-remote-mobile
69
69
  <span id="config"></span>
70
70
  ### 2. Configuration (Open External Listening)
71
71
 
72
- Because DSH defaults to `127.0.0.1`, ensure your `~/.dsh/profiles/web/cordis.patch.yml` includes the following configuration to allow external connectivity:
72
+ Because DSH defaults to `127.0.0.1`, ensure your `~/.dsh/profiles/web/cordis.patch.yml` (on Windows: `C:\Users\<username>\.dsh\profiles\web\cordis.patch.yml` or enter `%USERPROFILE%\.dsh` in File Explorer) includes the following configuration to allow external connectivity:
73
73
 
74
74
  ```yaml
75
75
  # 1. Allow webserver to listen for external connections (Required)
@@ -89,7 +89,7 @@ Because DSH defaults to `127.0.0.1`, ensure your `~/.dsh/profiles/web/cordis.pat
89
89
  disabled: true
90
90
  ```
91
91
 
92
- > **💡 Note**: Plugin self-registration is automatically handled by the DSH Bundle system. You do **not** need to add `id: remote-mobile` manually.
92
+ > **💡 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
93
 
94
94
  ---
95
95
 
@@ -175,7 +175,7 @@ The DSH web UI still carries many desktop-oriented styles on phones. This plugin
175
175
  ---
176
176
 
177
177
  <span id="install"></span>
178
- ## 📦 All Installation Methods
178
+ ## 📦 Installation & Update
179
179
 
180
180
  <details>
181
181
  <summary><b>Expand to view all 4 installation methods</b></summary>
@@ -202,6 +202,7 @@ pnpm add dsh-remote-mobile
202
202
  ```
203
203
 
204
204
  ### Method 4: Local Source Development (Instant Symlink Sync)
205
+
205
206
  ```bash
206
207
  # 1. Clone source code
207
208
  git clone https://github.com/IceApriler/dsh-remote-mobile.git
@@ -210,15 +211,45 @@ cd dsh-remote-mobile
210
211
  # 2. Install dependencies & build
211
212
  npm install
212
213
  npm run build
213
-
214
- # 3. Create symlink to DSH runtime (changes take effect instantly on npm run build)
215
- rm -rf ~/.dsh/profiles/web/node_modules/dsh-remote-mobile
216
- ln -s $(pwd) ~/.dsh/profiles/web/node_modules/dsh-remote-mobile
217
214
  ```
218
215
 
216
+ **Create symlink to DSH runtime (changes take effect instantly on `npm run build`):**
217
+
218
+ * **macOS / Linux**:
219
+ ```bash
220
+ rm -rf ~/.dsh/profiles/web/node_modules/dsh-remote-mobile
221
+ ln -s $(pwd) ~/.dsh/profiles/web/node_modules/dsh-remote-mobile
222
+ ```
223
+
224
+ * **Windows (PowerShell)**:
225
+ ```powershell
226
+ Remove-Item -Recurse -Force "$HOME\.dsh\profiles\web\node_modules\dsh-remote-mobile"
227
+ New-Item -ItemType Junction -Path "$HOME\.dsh\profiles\web\node_modules\dsh-remote-mobile" -Target (Get-Location)
228
+ ```
229
+
230
+ * **Windows (CMD)**:
231
+ ```cmd
232
+ rmdir /s /q %USERPROFILE%\.dsh\profiles\web\node_modules\dsh-remote-mobile
233
+ mklink /J %USERPROFILE%\.dsh\profiles\web\node_modules\dsh-remote-mobile %CD%
234
+ ```
235
+
219
236
  </details>
220
237
 
221
- ### Uninstalling
238
+ ### 🔄 Updating Plugin
239
+
240
+ Run the update command in your terminal and restart DSH:
241
+
242
+ ```bash
243
+ # Method 1: DSH CLI One-Command Update (Recommended)
244
+ dsh plugin --profile web update dsh-remote-mobile
245
+
246
+ # Method 2: Update via Package Manager in Profile Directory
247
+ cd ~/.dsh/profiles/web && pnpm update dsh-remote-mobile
248
+ ```
249
+
250
+ > **💡 Tip**: You can also click the update button in the Web console under **Settings ⚙️ -> Plugins -> Plugin Management**, or run `dsh plugin --profile web add dsh-remote-mobile@latest`.
251
+
252
+ ### 🗑️ Uninstalling
222
253
 
223
254
  ```bash
224
255
  dsh plugin --profile web remove dsh-remote-mobile
@@ -271,6 +302,8 @@ Custom snippets (style mini-plugins) and their enabled states are persisted in `
271
302
 
272
303
  ## 📂 Local File Storage Locations
273
304
 
305
+ > **💡 Path Note**: On macOS / Linux, the base path is `~/.dsh/`; on Windows, it resolves to `C:\Users\<username>\.dsh\` (you can directly paste `%USERPROFILE%\.dsh` into the File Explorer address bar).
306
+
274
307
  | Path | Description | Security Level |
275
308
  |---|---|---|
276
309
  | `~/.dsh/settings.yaml` | Global security policies & bypass toggles | User R/W |
@@ -288,9 +321,14 @@ Custom snippets (style mini-plugins) and their enabled states are persisted in `
288
321
 
289
322
  **Answer**: This means **port 3080 is held by another process** (in most cases a previous `dsh web` instance has not fully exited — e.g. a hidden instance launched by a desktop shortcut, or overlapping old/new processes during restart). It is **unrelated to which plugins are installed**. Troubleshooting:
290
323
 
291
- 1. Find the process holding the port: `lsof -nP -iTCP:3080` (Windows: `netstat -ano | findstr 3080`);
292
- 2. Terminate the old instance (`kill <PID>` / Task Manager) and start `dsh web` again;
293
- 3. Note: an instance launched via the desktop shortcut (dsh-desktop-launcher) and one started manually in a terminal cannot run at the same time.
324
+ 1. **Find the process holding the port**:
325
+ * **macOS / Linux**: `lsof -nP -iTCP:3080`
326
+ * **Windows**: `netstat -ano | findstr 3080` (PID is in the rightmost column)
327
+ 2. **Terminate the old instance**:
328
+ * **macOS / Linux**: `kill <PID>`
329
+ * **Windows**: `taskkill /F /PID <PID>` (or kill the Node process in Task Manager)
330
+ 3. Start `dsh web` again.
331
+ 4. **Note**: An instance launched via the desktop shortcut (dsh-desktop-launcher) and one started manually in a terminal cannot run simultaneously.
294
332
 
295
333
  If your log instead shows `service "remoteWebUiPairing" has been registered`, please upgrade this plugin to the latest version — newer versions ship with generic coexistence protection and can start normally alongside other remote-access plugins by yielding the service automatically, with a banner explaining the details in the settings panel.
296
334
  </details>
@@ -298,10 +336,17 @@ If your log instead shows `service "remoteWebUiPairing" has been registered`, pl
298
336
  <details>
299
337
  <summary><b>Q1: Getting "Connection Refused" or cannot open page when scanning on mobile?</b></summary>
300
338
 
301
- **Answer**: Please verify the following 3 items:
302
- 1. Ensure `host: '0.0.0.0'` is properly configured in `cordis.patch.yml` and DSH has been restarted;
303
- 2. For LAN access, ensure your mobile phone and computer are connected to the exact same Wi-Fi router;
304
- 3. If OS firewall is active on the computer, ensure inbound traffic on port `3080` is allowed.
339
+ **Answer**: Please verify the following items step by step:
340
+ 1. **Configuration**: Ensure `host: '0.0.0.0'` is properly configured in `cordis.patch.yml` and DSH has been restarted;
341
+ 2. **Same Network**: For LAN access, ensure your mobile phone and computer are connected to the exact same Wi-Fi router, and disable standalone VPN/proxy apps on your phone;
342
+ 3. **Windows Defender Firewall (Common for Windows users)**:
343
+ * When prompted by the Windows Firewall popup on startup, ensure both **"Private networks"** and **"Public networks"** are checked before allowing access;
344
+ * If you missed the prompt or still cannot connect, run the following command in **PowerShell as Administrator** to allow inbound traffic on port 3080:
345
+ ```powershell
346
+ New-NetFirewallRule -DisplayName "DSH Web 3080" -Direction Inbound -LocalPort 3080 -Protocol TCP -Action Allow
347
+ ```
348
+ * **Check Wi-Fi Network Profile**: Under Windows "Settings -> Network & Internet -> Wi-Fi", make sure your current Wi-Fi profile is set to **"Private network"** (Windows "Public network" blocks inbound LAN connections by default);
349
+ 4. **Virtual / WSL Network Adapters**: If WSL2 or Hyper-V is installed on Windows, virtual network adapters (e.g. `172.x.x.x`) may be present. Make sure you use the physical WLAN / Wi-Fi LAN IP or the Tailscale QR code shown in the plugin settings panel.
305
350
  </details>
306
351
 
307
352
  <details>
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
  }
@@ -51,7 +51,7 @@ export declare const CLIPBOARD_POLYFILL_SNIPPET = "\n(function() {\n if (typeof
51
51
  * 注入到 index.html 的移动端悬浮入口拖拽与位置记忆脚本
52
52
  * 允许用户在移动端上下随心拖动左上角入口把手,避免遮挡聊天内容,并自动保存位置
53
53
  */
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\u6B21\u7EA7\u63A7\u4EF6\uFF08\u6309\u94AE / \u7BAD\u5934\u56FE\u6807\uFF09\u7684\u89E6\u6478\u65F6\u95F4\uFF0C\u5E76\u5305\u88C5\n * HTMLButtonElement.prototype.click\uFF1A\u77ED\u65F6\u95F4\u7A97\u53E3\u5185\u7684\u7A0B\u5E8F\u5316\u6536\u8D77\u8C03\u7528\u76F4\u63A5\u5FFD\u7565\uFF1B\n * \u7528\u6237\u771F\u5B9E\u70B9\u51FB\u6536\u8D77\u6309\u94AE\uFF08\u6D4F\u89C8\u5668\u5408\u6210\u4E8B\u4EF6\u4E0D\u8D70 prototype.click\uFF09\u4E0E\u70B9\u51FB\u6761\u76EE\u4E3B\u4F53\n * \uFF08\u975E\u6309\u94AE/\u7BAD\u5934\uFF09\u7684\u5B98\u65B9\u884C\u4E3A\u5747\u4E0D\u53D7\u5F71\u54CD\u3002 */\n if (!window.__dshRmAntiCollapseInstalled__) {\n window.__dshRmAntiCollapseInstalled__ = true;\n var lastSecondaryTapAt = 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 var isSecondaryControl =\n !!t.closest('button, [role=\"button\"]') ||\n !!t.closest('[class*=\"_arrow\"]');\n if (isSecondaryControl) lastSecondaryTapAt = 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() - lastSecondaryTapAt < 900) {\n return; // \u541E\u6389\u7D27\u968F\u6B21\u7EA7\u64CD\u4F5C\u56FE\u6807\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";
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";
55
55
  /**
56
56
  * 注入到 index.html 的移动端抽屉「点外部收起」脚本
57
57
  *
@@ -63,7 +63,7 @@ export declare const DRAGGABLE_NAV_SNIPPET = "\n(function() {\n if (typeof wind
63
63
  * 后缀稳定),不依赖 web-ui 注入的 data-pane / data-dsh-frame 属性,因此装/不装 web-ui
64
64
  * 两种环境行为一致。折叠状态沿用官方写入的 data-sidebar-collapsed 属性。
65
65
  */
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\u5185\u6B21\u7EA7\u64CD\u4F5C\u56FE\u6807\u300D\u540E\u7684\u8BEF\u6536\u8D77\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";
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";
67
67
  /**
68
68
  * 挂载底层 http.Server 门禁劫持与回环上下文虚拟化代理
69
69
  *
@@ -118,3 +118,22 @@ export declare const PLUGIN_MODULE_NAME = "dsh-remote-mobile";
118
118
  * @param store - 会话管理器实例
119
119
  */
120
120
  export declare function registerRemoteWebUiPairingBridge(ctx: any, store: SessionStore): void;
121
+ /**
122
+ * 判定一个请求是否已获得 dsh-remote-mobile 门禁授权(包括回环、免密直连与已认证会话)
123
+ */
124
+ export declare function isRequestAuthorizedByGate(req: any, store: SessionStore): boolean;
125
+ /**
126
+ * 透明接管 DSH 官方底座的 Token 鉴权服务 (connection.browserAuth & connection.requestRejection)
127
+ *
128
+ * 【设计职责】
129
+ * 官方新版在底座硬编码了临时进程 Token(dsh web: http://.../?token=xxxx)以及基于签名 Cookie 的
130
+ * 全链路 RPC/WebSocket 401 阻断。对于已安装本插件的用户而言,两层鉴权导致体验严重割裂且使手机书签失效。
131
+ * 本函数对底层 connection 服务实施透明短路代理:
132
+ * 1. 凡通过本插件门禁(本机、Tailscale/LAN 免密直连、已扫码配对/长期密码认证)的请求,底座鉴权全链路放行;
133
+ * 2. 彻底消除进程重启后移动端遭遇官方 401 的痛点,对外提供唯一定制化移动端门禁体系;
134
+ * 3. 未授权外部请求保持官方与插件原本拦截逻辑,且保留官方原生合法 token 的交换能力。
135
+ *
136
+ * @param connection - DSH 底座提供的 connection 服务实例
137
+ * @param store - 会话与配置管理器实例
138
+ */
139
+ 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 脚本代码片段
@@ -113,13 +115,14 @@ export const DRAGGABLE_NAV_SNIPPET = `
113
115
  * 侧边栏条目(role=treeitem)后,于下一帧程序化点击「收起侧边栏」按钮自动收回抽屉。
114
116
  * 该判定无法区分「选中条目回主区」与「点击条目内的次级操作图标」(更多操作 ⋯ /
115
117
  * 展开箭头等),导致后者抽屉被误关。
116
- * 这里记录最近一次对条目内次级控件(按钮 / 箭头图标)的触摸时间,并包装
117
- * HTMLButtonElement.prototype.click:短时间窗口内的程序化收起调用直接忽略;
118
- * 用户真实点击收起按钮(浏览器合成事件不走 prototype.click)与点击条目主体
119
- * (非按钮/箭头)的官方行为均不受影响。 */
118
+ * 这里记录最近一次对条目内任意部位(项目名称主体 / 次级按钮 / 箭头图标)的触摸时间,
119
+ * 并包装 HTMLButtonElement.prototype.click:短时间窗口内的程序化收起调用直接忽略。
120
+ * 覆盖条目主体是为了让选中高亮后浮现的展开/收起箭头与新增/更多操作图标有足够的
121
+ * 停留操作时间;用户真实点击收起按钮(浏览器合成事件不走 prototype.click)与
122
+ * 点击抽屉外暗处收起的官方行为均不受影响。 */
120
123
  if (!window.__dshRmAntiCollapseInstalled__) {
121
124
  window.__dshRmAntiCollapseInstalled__ = true;
122
- var lastSecondaryTapAt = 0;
125
+ var lastEntryTapAt = 0;
123
126
  document.addEventListener('pointerdown', function (e) {
124
127
  try {
125
128
  var t = e.target;
@@ -127,10 +130,7 @@ export const DRAGGABLE_NAV_SNIPPET = `
127
130
  if (!t.closest('[data-pane="sidebar"], [class*="_sidebarCol"]')) return;
128
131
  var entry = t.closest('[role="treeitem"], [data-dsh-part="sidebar-entry"]');
129
132
  if (!entry) return;
130
- var isSecondaryControl =
131
- !!t.closest('button, [role="button"]') ||
132
- !!t.closest('[class*="_arrow"]');
133
- if (isSecondaryControl) lastSecondaryTapAt = Date.now();
133
+ lastEntryTapAt = Date.now();
134
134
  } catch (err) {}
135
135
  }, { capture: true, passive: true });
136
136
 
@@ -140,8 +140,8 @@ export const DRAGGABLE_NAV_SNIPPET = `
140
140
  var isSidebarToggle =
141
141
  this.matches('[data-dsh-responsive-part="sidebar-toggle"]') ||
142
142
  /收起侧边栏|Collapse sidebar|Open sidebar/i.test(this.getAttribute('aria-label') || '');
143
- if (isSidebarToggle && Date.now() - lastSecondaryTapAt < 900) {
144
- return; // 吞掉紧随次级操作图标点击之后的程序化收起
143
+ if (isSidebarToggle && Date.now() - lastEntryTapAt < 900) {
144
+ return; // 吞掉紧随条目任意部位点击之后的程序化收起
145
145
  }
146
146
  } catch (err) {}
147
147
  return protoClick.apply(this, arguments);
@@ -280,7 +280,8 @@ export const MOBILE_DISMISS_SNIPPET = `
280
280
  }
281
281
  // 点击侧边栏条目(选中后回主区)→ 下一帧程序化收起。
282
282
  // 既有防误收起守卫(dsh-draggable-nav 包装 HTMLButtonElement.prototype.click
283
- // + 900ms 窗口)会拦截「点条目内次级操作图标」后的误收起,行为与 web-ui 一致。
283
+ // + 900ms 窗口)会拦截「点条目任意部位(含项目名称主体)」后的误收起,
284
+ // 保证选中高亮后浮现的展开/收起箭头与新增/更多操作图标可操作,行为与 web-ui 一致。
284
285
  if (target.closest('[class*="_toggle"]')) return;
285
286
  if (!target.closest('[role="treeitem"], [data-dsh-part="sidebar-entry"]')) return;
286
287
  if (raf !== 0) cancelAnimationFrame(raf);
@@ -700,3 +701,106 @@ export function registerRemoteWebUiPairingBridge(ctx, store) {
700
701
  };
701
702
  decide().catch(() => { });
702
703
  }
704
+ /**
705
+ * 恒定时间字符串比较,防止针对 secret token 的时序攻击
706
+ */
707
+ function safeStringEqual(a, b) {
708
+ if (typeof a !== 'string' || typeof b !== 'string')
709
+ return false;
710
+ try {
711
+ const bufA = Buffer.from(a, 'utf8');
712
+ const bufB = Buffer.from(b, 'utf8');
713
+ if (bufA.length !== bufB.length)
714
+ return false;
715
+ return timingSafeEqual(bufA, bufB);
716
+ }
717
+ catch {
718
+ return false;
719
+ }
720
+ }
721
+ /**
722
+ * 判定一个请求是否已获得 dsh-remote-mobile 门禁授权(包括回环、免密直连与已认证会话)
723
+ */
724
+ export function isRequestAuthorizedByGate(req, store) {
725
+ if (!req)
726
+ return false;
727
+ // 1. 本机 127.0.0.1 / ::1 回环无条件放行
728
+ if (isLoopbackRequest(req))
729
+ return true;
730
+ const clientIp = getClientIp(req);
731
+ const options = store.getOptions();
732
+ // 2. Tailscale 私网免密直连放行
733
+ if (options.allowTailscale && isTailscaleIp(clientIp))
734
+ return true;
735
+ // 3. 局域网 LAN 免密直连放行
736
+ if (options.allowLan && isLanIp(clientIp))
737
+ return true;
738
+ // 4. 检查是否携带有效的授权会话 Token (Cookie / Header / URL Token)
739
+ const token = store.extractTokenFromRequest(req);
740
+ if (token && store.validateToken(token, clientIp))
741
+ return true;
742
+ return false;
743
+ }
744
+ /**
745
+ * 透明接管 DSH 官方底座的 Token 鉴权服务 (connection.browserAuth & connection.requestRejection)
746
+ *
747
+ * 【设计职责】
748
+ * 官方新版在底座硬编码了临时进程 Token(dsh web: http://.../?token=xxxx)以及基于签名 Cookie 的
749
+ * 全链路 RPC/WebSocket 401 阻断。对于已安装本插件的用户而言,两层鉴权导致体验严重割裂且使手机书签失效。
750
+ * 本函数对底层 connection 服务实施透明短路代理:
751
+ * 1. 凡通过本插件门禁(本机、Tailscale/LAN 免密直连、已扫码配对/长期密码认证)的请求,底座鉴权全链路放行;
752
+ * 2. 彻底消除进程重启后移动端遭遇官方 401 的痛点,对外提供唯一定制化移动端门禁体系;
753
+ * 3. 未授权外部请求保持官方与插件原本拦截逻辑,且保留官方原生合法 token 的交换能力。
754
+ *
755
+ * @param connection - DSH 底座提供的 connection 服务实例
756
+ * @param store - 会话与配置管理器实例
757
+ */
758
+ export function takeOverConnectionAuth(connection, store) {
759
+ if (!connection || connection.__dsh_rm_auth_takeover__)
760
+ return;
761
+ connection.__dsh_rm_auth_takeover__ = true;
762
+ // 1. 接管底层统一的 browserAuth.isAuthenticated (供各类 RPC/WebSocket 与内部检查调用)
763
+ if (connection.browserAuth && typeof connection.browserAuth.isAuthenticated === 'function') {
764
+ const origIsAuthenticated = connection.browserAuth.isAuthenticated.bind(connection.browserAuth);
765
+ connection.browserAuth.isAuthenticated = (request) => {
766
+ if (isRequestAuthorizedByGate(request, store)) {
767
+ return true;
768
+ }
769
+ return origIsAuthenticated(request);
770
+ };
771
+ }
772
+ // 2. 接管 connection.requestRejection (底层拦截未授权的 RPC / WebSocket 请求)
773
+ if (typeof connection.requestRejection === 'function') {
774
+ const origRequestRejection = connection.requestRejection.bind(connection);
775
+ connection.requestRejection = (request) => {
776
+ if (isRequestAuthorizedByGate(request, store)) {
777
+ return undefined; // 明确放行,杜绝 401
778
+ }
779
+ return origRequestRejection(request);
780
+ };
781
+ }
782
+ // 3. 接管 connection.authorizeIndex (控制根路径 GET / 下发 index.html)
783
+ if (typeof connection.authorizeIndex === 'function') {
784
+ const origAuthorizeIndex = connection.authorizeIndex.bind(connection);
785
+ connection.authorizeIndex = (req, res) => {
786
+ if (isRequestAuthorizedByGate(req, store)) {
787
+ // 若携带官方进程 token 且与当前 launchToken 匹配,优先走官方换取官方 Cookie 与重定向
788
+ if (req.url && req.url.includes('token=')) {
789
+ try {
790
+ const url = new URL(req.url, 'http://dsh.invalid');
791
+ const tokens = url.searchParams.getAll('token');
792
+ if (tokens.length > 0 &&
793
+ connection.browserAuth?.launchToken &&
794
+ safeStringEqual(tokens[0], connection.browserAuth.launchToken)) {
795
+ return origAuthorizeIndex(req, res);
796
+ }
797
+ }
798
+ catch { }
799
+ }
800
+ // 已授权设备直接放行提供 HTML,跳过官方 401 阻断
801
+ return true;
802
+ }
803
+ return origAuthorizeIndex(req, res);
804
+ };
805
+ }
806
+ }
package/lib/client.js CHANGED
@@ -3464,8 +3464,8 @@ function StylesCard({ lang, showToast }) {
3464
3464
  }
3465
3465
 
3466
3466
  // src/client/version.js
3467
- var PLUGIN_VERSION = "v1.4.2";
3468
- var BUILD_TIME = "2026-08-26 23:31:01";
3467
+ var PLUGIN_VERSION = "v1.5.0";
3468
+ var BUILD_TIME = "2026-09-04 20:16:12";
3469
3469
 
3470
3470
  // src/client/components/MainSection.jsx
3471
3471
  var import_jsx_runtime9 = require("react/jsx-runtime");
package/lib/index.js CHANGED
@@ -15,8 +15,8 @@ import { SessionStore } from './auth/token.js';
15
15
  import { createGlobalAuthGate } from './auth/gate.js';
16
16
  import { createRoutes } from './routes/api.js';
17
17
  import { StyleSnippetStore } from './styles/style-snippets.js';
18
- import { getLocalTailscaleIp } from './auth/tailscale.js';
19
- import { patchHttpServerWithVirtualizer, mountIndexInjections, registerRemoteWebUiPairingBridge, } from './bridge/compat.js';
18
+ import { getLocalTailscaleIp, getLocalLanIp } from './auth/tailscale.js';
19
+ import { patchHttpServerWithVirtualizer, mountIndexInjections, registerRemoteWebUiPairingBridge, takeOverConnectionAuth, } from './bridge/compat.js';
20
20
  /** Cordis 插件名称标识 */
21
21
  export const name = 'dsh-remote-mobile';
22
22
  /** DSH 设置命名空间 */
@@ -98,18 +98,109 @@ export function apply(ctx, config = {}) {
98
98
  // 4. 向 Cordis 容器桥接注册 remoteWebUiPairing 服务(延迟裁决:与其他远程接入
99
99
  // 类插件共存时自动让出,保证启动不冲突,详见 compat.ts)
100
100
  registerRemoteWebUiPairingBridge(ctx, store);
101
- // 5. 延迟补丁与服务启动日志输出
102
- setTimeout(() => {
101
+ // 5. 透明接管 DSH 底座 connection 鉴权服务(短路官方 Token 401 阻断)
102
+ /**
103
+ * connection 鉴权接管采用三点冗余策略:
104
+ * 1) 插件 apply 时立即尝试 —— 覆盖 connection 先于本插件就绪的场景
105
+ * 2) ctx.inject 动态注入 —— 覆盖 connection 后于本插件就绪的场景
106
+ * 3) printReadyBanner 中兜底 —— 覆盖上述两者都未捕获到的极端时序
107
+ *
108
+ * takeOverConnectionAuth 内部通过 __dsh_rm_auth_takeover__ 标记保证幂等,
109
+ * 同一 connection 实例不会被重复接管。
110
+ */
111
+ const attachConnectionAuth = (conn) => {
112
+ if (conn) {
113
+ takeOverConnectionAuth(conn, store);
114
+ }
115
+ };
116
+ // 1) 优先通过 ctx.get 安全获取(未注入时不抛错返回 undefined)
117
+ try {
118
+ const conn = typeof ctx.get === 'function' ? ctx.get('connection') : undefined;
119
+ if (conn)
120
+ attachConnectionAuth(conn);
121
+ }
122
+ catch (e) {
123
+ ctx.logger?.debug?.('[dsh-remote-mobile] 尝试直接获取 connection 失败:', e);
124
+ }
125
+ // 2) 声明动态子注入,在 connection 服务就绪时接管
126
+ if (typeof ctx.inject === 'function') {
127
+ try {
128
+ ctx.inject(['connection'], (connCtx) => {
129
+ try {
130
+ // 统一通过 get 获取服务实例,避免属性访问与方法获取返回不同对象
131
+ const conn = typeof connCtx?.get === 'function' ? connCtx.get('connection') : connCtx?.connection;
132
+ if (conn)
133
+ attachConnectionAuth(conn);
134
+ }
135
+ catch (e) {
136
+ ctx.logger?.debug?.('[dsh-remote-mobile] inject 接收 connection 注入失败:', e);
137
+ }
138
+ });
139
+ }
140
+ catch (e) {
141
+ ctx.logger?.debug?.('[dsh-remote-mobile] 注册 connection 动态注入失败:', e);
142
+ }
143
+ }
144
+ // 6. 服务就绪时输出提示横幅与接入地址
145
+ let printed = false;
146
+ const printReadyBanner = () => {
147
+ if (printed)
148
+ return;
149
+ printed = true;
103
150
  if (ctx.webServer && ctx.webServer.server) {
104
151
  patchHttpServerWithVirtualizer(ctx.webServer.server, gateMiddleware, store, styleStore);
105
152
  }
153
+ try {
154
+ const conn = typeof ctx.get === 'function' ? ctx.get('connection') : undefined;
155
+ if (conn)
156
+ attachConnectionAuth(conn);
157
+ }
158
+ catch (e) {
159
+ ctx.logger?.debug?.('[dsh-remote-mobile] 就绪回调获取 connection 失败:', e);
160
+ }
106
161
  const tsIp = getLocalTailscaleIp();
162
+ const lanIp = getLocalLanIp();
163
+ const port = ctx.webServer?.port ?? 3080;
107
164
  const opts = store.getOptions();
165
+ const divider = '─'.repeat(72);
166
+ console.log(`\n┌${divider}`);
167
+ console.log(`│ [dsh-remote-mobile] 官方临时 Token 鉴权已接管,PC/移动端访问无需携带官方token`);
168
+ console.log(`│ 远程安全网关已就绪,支持手机浏览器访问:`);
169
+ console.log(`│ • 本机回环: http://127.0.0.1:${port}`);
170
+ if (lanIp && lanIp !== '127.0.0.1') {
171
+ console.log(`│ • 局域网: http://${lanIp}:${port} (同 Wi-Fi 局域网直连)`);
172
+ }
173
+ if (tsIp) {
174
+ console.log(`│ • 虚拟私网: http://${tsIp}:${port} (Tailscale 私网异地直连)`);
175
+ }
176
+ console.log(`│ 访问与配置说明:`);
177
+ console.log(`│ - 手机/远程设备打开对应地址即可直接访问`);
178
+ console.log(`│ - 在本机打开 Web 界面 ->「设置」->「远程与移动端」进行安全管理:`);
179
+ console.log(`│ 可开启免密直连(局域网/Tailscale)、查看 6 位临时配对码或设置长期密码`);
180
+ console.log(`│ - 项目文档: https://github.com/IceApriler/dsh-remote-mobile`);
181
+ console.log(`└${divider}\n`);
108
182
  ctx.logger?.info?.(`[dsh-remote-mobile] 移动端远程控制与安全门禁已激活 (上下文虚拟化桥接就绪,样式片段: ${styleStore.list().length} 个)`);
109
183
  if (tsIp) {
110
- ctx.logger?.info?.(`[dsh-remote-mobile] Tailscale 地址: http://${tsIp}:3080 (免密直连: ${opts.allowTailscale ? '已开启' : '已关闭'})`);
184
+ ctx.logger?.info?.(`[dsh-remote-mobile] Tailscale 地址: http://${tsIp}:${port} (免密直连: ${opts.allowTailscale ? '已开启' : '已关闭'})`);
111
185
  }
112
- }, 300);
186
+ };
187
+ // 1) 优先在 loader 插件树就绪后紧跟官方提示打印
188
+ try {
189
+ const loader = typeof ctx.get === 'function' ? ctx.get('loader') : undefined;
190
+ const settled = loader?.await?.();
191
+ if (settled && typeof settled.then === 'function') {
192
+ settled.then(() => {
193
+ setTimeout(printReadyBanner, 120);
194
+ }).catch((e) => {
195
+ ctx.logger?.debug?.('[dsh-remote-mobile] loader await 异常:', e);
196
+ });
197
+ }
198
+ }
199
+ catch (e) {
200
+ ctx.logger?.debug?.('[dsh-remote-mobile] 获取 loader 失败:', e);
201
+ }
202
+ // 2) 兜底定时器(避免无 loader 环境时不输出)
203
+ setTimeout(printReadyBanner, 1500);
113
204
  }
114
205
  // 导出所有子模块类型与核心工具
115
206
  export * from './auth/tailscale.js';
@@ -50,8 +50,11 @@ const PRESET_SIDEBAR_CSS = `
50
50
  /* 1. 最外层三栏布局:在移动端主工作区占满 100% 全宽,消除侧边栏强制占位。
51
51
  选择器全部基于官方 CSS Modules 稳定后缀(_frame/_sidebarCol/_centerCol,
52
52
  前缀 hash 随机、后缀稳定),不依赖 @linxin666/dsh-web-ui-all 注入的
53
- data-pane / data-dsh-frame 属性,安装与否均生效。 */
54
- [class*="_frame"] {
53
+ data-pane / data-dsh-frame 属性,安装与否均生效。
54
+ 注意:_frame 后缀同样命中「提问卡片」(Mbwy4a_frame 等非三栏布局元素),
55
+ 故用 :has([class*="_sidebarCol"]) 把规则精确锁定到真正承载三栏布局的
56
+ 外层 frame,避免提问弹窗被强制 100vw 撑满、被遮罩盖住。 */
57
+ [class*="_frame"]:has([class*="_sidebarCol"]) {
55
58
  grid-template-columns: 0px minmax(0px, 1fr) 0px !important;
56
59
  width: 100vw !important;
57
60
  max-width: 100vw !important;
@@ -59,7 +62,7 @@ const PRESET_SIDEBAR_CSS = `
59
62
  position: relative !important;
60
63
  }
61
64
 
62
- [class*="_centerCol"] {
65
+ [class*="_frame"]:has([class*="_sidebarCol"]) [class*="_centerCol"] {
63
66
  width: 100vw !important;
64
67
  max-width: 100vw !important;
65
68
  }
@@ -97,13 +100,15 @@ const PRESET_SIDEBAR_CSS = `
97
100
  拦截全部点击。压到 899:低于抽屉、仍高于正文(≤100),「点抽屉外暗处
98
101
  自动收回」的能力保留。
99
102
  - 装了 web-ui 时:它生成 [data-dsh-frame]:not([data-sidebar-collapsed])::after
100
- (z-index:1050),由下方 [class*="_frame"]::after 的 !important 统一压到 899。
101
- - 未装 web-ui 时:官方无遮罩,由下方 :not([data-sidebar-collapsed]) 规则补齐。 */
102
- [class*="_frame"]::after {
103
+ (z-index:1050),由下方 :has 锁定布局帧的 ::after 的 !important 统一压到 899。
104
+ - 未装 web-ui 时:官方无遮罩,由下方 :not([data-sidebar-collapsed]) 规则补齐。
105
+ 遮罩必须挂在三栏布局帧上(:has([class*="_sidebarCol"])),否则 _frame 后缀
106
+ 也会命中提问卡片等内层元素,把全屏黑纱盖到弹窗上面。 */
107
+ [class*="_frame"]:has([class*="_sidebarCol"])::after {
103
108
  z-index: 899 !important;
104
109
  }
105
110
 
106
- [class*="_frame"]:not([data-sidebar-collapsed])::after {
111
+ [class*="_frame"]:has([class*="_sidebarCol"]):not([data-sidebar-collapsed])::after {
107
112
  content: "";
108
113
  position: fixed;
109
114
  inset: 0;
@@ -566,6 +571,30 @@ const PRESET_MAIN_CSS = `
566
571
  max-height: 12px !important;
567
572
  flex: none !important;
568
573
  }
574
+
575
+ /* 6. 提问卡片(Mbwy4a_frame,data-question-key 为官方稳定属性锚点):
576
+ 收窄左右外边距(官方 padding: calc(composer-side-clearance + 16px) 偏大),
577
+ 选项文案字号跟随正文(正文 12.5px,选项标题 13px、说明 12.5px),
578
+ 行高同步收敛,保持卡片整体与对话紧凑排版一致。 */
579
+ [data-question-key] {
580
+ padding-left: 8px !important;
581
+ padding-right: 8px !important;
582
+ }
583
+
584
+ [data-question-key] [class*="_optionLabel"] {
585
+ font-size: 13px !important;
586
+ line-height: 20px !important;
587
+ }
588
+
589
+ [data-question-key] [class*="_description"] {
590
+ font-size: 12.5px !important;
591
+ line-height: 20px !important;
592
+ }
593
+
594
+ [data-question-key] [class*="_title"] {
595
+ font-size: 15px !important;
596
+ line-height: 21px !important;
597
+ }
569
598
  `;
570
599
  /**
571
600
  * 内置官方预设:按界面区域划分的三段预设。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-remote-mobile",
3
- "version": "1.4.2",
3
+ "version": "1.5.0",
4
4
  "description": "DeepSeek Harness 远程与移动端安全网关插件:零修改 DSH 底层代码安全开放局域网与 Tailscale 连接 | DeepSeek Harness (DSH) Remote & Mobile Security Guard: safely opens Tailscale/LAN with zero core modifications, QR scan auth, RSA encryption & brute-force defense.",
5
5
  "keywords": [
6
6
  "dsh",