dsh-pocket 2.10.5 → 2.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.en.md CHANGED
@@ -6,6 +6,8 @@
6
6
 
7
7
  <p align="center"><a href="README.en.md">English</a> | <a href="README.md">中文</a></p>
8
8
 
9
+ <p align="center"><a href="https://trendshift.io/repositories/166736?utm_source=trendshift-badge&amp;utm_medium=badge&amp;utm_campaign=badge-trendshift-166736" target="_blank" rel="noopener noreferrer"><img src="https://trendshift.io/api/badge/trendshift/repositories/166736/daily?language=JavaScript" alt="shaobeichen%2Fdsh-pocket | Trendshift" width="250" height="55"/></a></p>
10
+
9
11
  <p align="center">
10
12
  <a href="https://www.npmjs.com/package/dsh-pocket"><img alt="npm" src="https://img.shields.io/npm/v/dsh-pocket?color=4d6bfe&label=npm"></a>
11
13
  <a href="https://www.npmjs.com/package/dsh-pocket"><img alt="downloads" src="https://img.shields.io/npm/dm/dsh-pocket?color=4d6bfe"></a>
@@ -39,21 +41,22 @@ What it looks like — the phone shows the exact same UI as your computer, live:
39
41
 
40
42
  ## ✨ Features
41
43
 
42
- | Feature | Description |
43
- |---|---|
44
- | 📶 LAN QR access | Works out of the box: Settings → Phone access — scan the LAN QR on the same Wi-Fi (auto-detects the LAN IP; **under WSL it picks the Windows host's physical NIC IP**) |
45
- | 🚪 LAN switch | **Turn LAN access off/on with one click** in Settings (a confirmation dialog shows each time): off kills the LAN QR code and link instantly; public access is unaffected |
46
- | 🌐 Public QR (from anywhere) | Click "Enable anywhere" → cloudflared tunnel → scan the public QR over 4G / any network |
47
- | 🏷️ Fixed public hostname | Optional "**Named tunnel**" mode: paste a Cloudflare Tunnel Token + your own domain — the public address stays **fixed across restarts** (see below) |
48
- | 🔐 Access PIN | Public links require an **8-character PIN** (rotated on every tunnel start by default; **customizable to a fixed PIN** — custom PINs are not rotated); LAN has its own separate **8-character PIN** (on by default; switchable off in Settings — then LAN scans connect directly) |
49
- | 🔑 Custom PINs | Both the public and LAN PINs can be **set to your own fixed 8-character PIN (letters and digits) in Settings** (custom PINs are never auto-rotated) |
50
- | 🧘 Session persistence | Enter the PIN once and you're set for a long time (login is tied to the computer's dsh web process: as long as it stays up, the phone won't ask again; **after a dsh web restart/update, enter it once more**) |
51
- | ⚡ Real-time sync | Streaming output passes through WebSocket untouched — what the computer renders, the phone renders live; fully interactive both ways; built-in WS heartbeat keep-alive (defeats silent NAT/battery link drops with auto-reconnect) |
52
- | 📱 Mobile-adaptive layout | Narrow screens get a drawer layout automatically (ported from dsh-web-mobile, MIT): sidebar drawer, full-width conversation, safe-area insets, touch optimizations |
53
- | 📁 File browser | The mobile "Files" entries need a host-side explorer panel (a dsh-web-ui component); on stock DSH without it the entries are auto-hidden instead of doing nothing |
54
- | 🗜️ Transfer compression | Large JSON responses are gzip/brotli'd on the fly (17MB session history → ~1MB; brotli quality 6: fast and bandwidth-friendly) — faster loads, less mobile data |
55
- | 🔁 Tunnel auto-restore | After a DSH restart the previously-running public tunnel comes back automatically |
56
- | 🧩 Zero-dependency install | One npm package, one settings tab — no core/adapter split, no account, no server |
44
+ | Feature | Description |
45
+ | ---------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
46
+ | 📶 LAN QR access | Works out of the box: Settings → Phone access — scan the LAN QR on the same Wi-Fi (auto-detects the LAN IP; **under WSL it picks the Windows host's physical NIC IP**) |
47
+ | 🚪 LAN switch | **Turn LAN access off/on with one click** in Settings (a confirmation dialog shows each time): off kills the LAN QR code and link instantly; public access is unaffected |
48
+ | 🌐 Public QR (from anywhere) | Click "Enable anywhere" → cloudflared tunnel → scan the public QR over 4G / any network |
49
+ | 🏷️ Fixed public hostname | Optional "**Named tunnel**" mode: paste a Cloudflare Tunnel Token + your own domain — the public address stays **fixed across restarts** (see below) |
50
+ | 🔐 Access PIN | Public links use an **8-digit random PIN** by default (rotated on every tunnel start; **customizable to a fixed PIN** — custom PINs are not rotated); LAN has its own separate **8-digit random PIN** (on by default; switchable off in Settings — then LAN scans connect directly) |
51
+ | 🔑 Custom PINs | Both the public and LAN PINs can be **set to a fixed 8–64-character PIN using letters and digits in Settings** (custom PINs are never auto-rotated) |
52
+ | 🧘 Session persistence | Enter the PIN once and you're set for a long time (login is tied to the computer's dsh web process: as long as it stays up, the phone won't ask again; **after a dsh web restart/update, enter it once more**) |
53
+ | ⚡ Real-time sync | Streaming output passes through WebSocket untouched — what the computer renders, the phone renders live; fully interactive both ways; built-in WS heartbeat keep-alive (defeats silent NAT/battery link drops with auto-reconnect) |
54
+ | 📱 Mobile-adaptive layout | Narrow screens get a drawer layout automatically (ported from dsh-web-mobile, MIT): sidebar drawer, full-width conversation, safe-area insets, touch optimizations |
55
+ | 🧭 Optional right sidebar | Shows the native right-sidebar entry on mobile; disable it for a compact phone header or keep it available alongside the terminal dock on an unfolded display |
56
+ | 📁 File browser | The mobile "Files" entries need a host-side explorer panel (a dsh-web-ui component); on stock DSH without it the entries are auto-hidden instead of doing nothing |
57
+ | 🗜️ Transfer compression | Large JSON responses are gzip/brotli'd on the fly (17MB session history → ~1MB; brotli quality 6: fast and bandwidth-friendly) — faster loads, less mobile data |
58
+ | 🔁 Tunnel auto-restore | After a DSH restart the previously-running public tunnel comes back automatically |
59
+ | 🧩 Zero-dependency install | One npm package, one settings tab — no core/adapter split, no account, no server |
57
60
 
58
61
  ## 🚀 Usage
59
62
 
@@ -80,7 +83,7 @@ npx @deepseek-ai/dsh web
80
83
 
81
84
  ### LAN (same Wi-Fi)
82
85
 
83
- Settings → **Phone access** → scan the "📶 LAN" QR code → enter the **LAN PIN** (shown in the LAN block; hit **Refresh** to roll a new one, or **Customize** to set your own fixed 8 digits) → the phone opens the exact same DSH, in real time.
86
+ Settings → **Phone access** → scan the "📶 LAN" QR code → enter the **LAN PIN** (shown in the LAN block; hit **Refresh** to roll a new one, or **Customize** to set an 8–64-character alphanumeric PIN) → the phone opens the exact same DSH, in real time.
84
87
 
85
88
  > The "**LAN access**" switch is **on by default** and can be **turned off/on with one click** (a confirmation dialog shows each time). Off kills the LAN QR code and link instantly (phones can't open them); **public access is unaffected**. Tap "On" to restore it.
86
89
  >
@@ -92,7 +95,7 @@ Settings → **Phone access** → scan the "📶 LAN" QR code → enter the **LA
92
95
 
93
96
  ### Public (from anywhere)
94
97
 
95
- On the same page click "**Enable anywhere**" → **a security disclaimer pops up every time — check "I understand and agree" to proceed** (on a corporate/classified network, confirm compliance first) → wait for the tunnel (first run downloads cloudflared; macOS/Linux use the Tsinghua mirror, seconds) → scan the "🌐 Public" QR code → the phone opens the link and **enters the 8-character PIN** (shown in the settings page's public section; **rotated on every tunnel start by default**, or **Customize** it to a fixed PIN — letters and digits — that is never rotated) → works from outside (4G / office network).
98
+ On the same page click "**Enable anywhere**" → **a security disclaimer pops up every time — check "I understand and agree" to proceed** (on a corporate/classified network, confirm compliance first) → wait for the tunnel (first run downloads cloudflared; macOS/Linux use the Tsinghua mirror, seconds) → scan the "🌐 Public" QR code → the phone opens the link and **enters the access PIN** (shown in the settings page's public section; the default is an **8-digit random PIN rotated on every tunnel start**, or use **Customize** for a fixed 8–64-character alphanumeric PIN that is never rotated) → works from outside (4G / office network).
96
99
 
97
100
  > Upgrading: `dsh plugin --profile web update dsh-pocket --latest -w` (`--latest` is required across major versions — a `^0.x` range won't auto-jump to 1.x).
98
101
 
@@ -111,7 +114,7 @@ Note: in named-tunnel mode the public PIN is **not auto-rotated** (the address i
111
114
 
112
115
  - **DSH can execute code on your computer.** **LAN** QR/URL plus its own **8-character PIN** is the key (PIN **on by default**, switchable off — then LAN scans connect directly, same-network devices only) — **never share the LAN QR, URL or PIN**.
113
116
  - **Read and accept the security disclaimer before enabling public access** (the dialog shows on every enable; the server enforces it, so it can't be bypassed): public = exposing a code-executing DSH to the internet — use a strong PIN, turn it off when done, never on classified networks.
114
- - **Public** access is protected by an **8-character PIN**: the link is random, the PIN rotates on every tunnel start by default, and old links die instantly — even a leaked link can't get in. **A custom PIN is never auto-rotated** (your value stays stable; custom PINs may use letters and digits).
117
+ - **Public** access uses an **8-digit random PIN** by default: the link is random, the PIN rotates on every tunnel start, and old links die instantly — even a leaked link can't get in. **A custom PIN may contain 8–64 letters and digits and is never auto-rotated**.
115
118
  - Phone login state is tied to the computer's dsh web process: **no re-entry while dsh web stays up; one re-entry after a restart/update**.
116
119
  - **Login rate limiting** (anti brute-force): **5** consecutive wrong PINs from the same IP lock it for **60s**; a global failure threshold briefly locks everyone (blocks distributed IP-rotation scans); a successful login resets the counter.
117
120
  - The public URL is randomly assigned by cloudflared and **changes on every restart** (old links die automatically — a natural key rotation); in **named-tunnel fixed-hostname** mode the address stays and the PIN is not auto-rotated — manage it with a custom PIN.
@@ -126,18 +129,18 @@ Note: in named-tunnel mode the public PIN is **not auto-rotated** (the address i
126
129
 
127
130
  ## 🩹 Troubleshooting (traps users step on)
128
131
 
129
- | Symptom | Cause & fix |
130
- |---|---|
131
- | `dsh: command not found` / "DSH is not defined" | dsh CLI missing: `npm install -g @deepseek-ai/dsh`, or prefix commands with `npx @deepseek-ai/dsh` |
132
- | `ERR_PNPM_ADDING_TO_ROOT` | pnpm 9 workspace-root restriction: append `-w` (`--workspace-root`) to install/update commands |
133
- | Nothing changed after install/update | **You must restart `dsh web`**; the running process still loads the old code |
134
- | `listen EADDRINUSE ... :3081` | A stale dsh-pocket process holds the port: macOS/Linux `lsof -ti :3081 \| xargs kill -9`; Windows `netstat -ano \| findstr :3081` (find the LISTENING PID) → `taskkill /PID <PID> /F`, then retry |
135
- | Want a different port (issue #70) | Plugin mode: write `"proxyPort": 3082` into `$DSH_HOME/dsh-pocket/settings.json` and restart `dsh web`. CLI mode: `dsh-pocket --port 3082`. If the port is taken you'll get `EADDRINUSE` — kill the old process or pick another one |
136
- | Issue a temporary PIN to a guest | Not available: the temporary access PIN feature (issue #69) was removed in 2.6.x (it crashed on revoke). To share access, send the main PIN or a `?token=<main PIN>` link, then hit "Refresh" in Settings once the guest is done |
137
- | cloudflared install fails on a remote Linux server (issue #45) | If all CDN sources (GitHub / ghproxy / gh.ddlc / gh-proxy) are unreachable on a remote Linux host, install `cloudflared` yourself (e.g. `apt install cloudflared`, `dnf install cloudflared`, or download the tgz and unpack it), then add `"cloudflaredPath": "/path/to/cloudflared"` into `$DSH_HOME/dsh-pocket/settings.json` and restart `dsh web`. The plugin will then use that binary directly and skip the auto-download |
138
- | Version stuck below 1.x | `^0.x` ranges never jump to 1.x: update with `--latest` (`dsh plugin --profile web update dsh-pocket --latest -w`) |
139
- | Public `error 1033` | See "Public tunnel troubleshooting" below — usually a local proxy/VPN (Clash etc. TUN mode) killing the tunnel |
140
- | After "Restart dsh web", the page says the process is running in the background | The new process from in-page self-restart is a detached background process (not attached to your terminal) — that's the standard way to apply updates in-page; stop it: macOS/Linux `lsof -ti :3080 \| xargs kill -9`; Windows `netstat -ano \| findstr :3080` → `taskkill /PID <PID> /F` (logs under `$DSH_HOME` as `dsh-pocket-restart-*.log`) |
132
+ | Symptom | Cause & fix |
133
+ | ------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
134
+ | `dsh: command not found` / "DSH is not defined" | dsh CLI missing: `npm install -g @deepseek-ai/dsh`, or prefix commands with `npx @deepseek-ai/dsh` |
135
+ | `ERR_PNPM_ADDING_TO_ROOT` | pnpm 9 workspace-root restriction: append `-w` (`--workspace-root`) to install/update commands |
136
+ | Nothing changed after install/update | **You must restart `dsh web`**; the running process still loads the old code |
137
+ | `listen EADDRINUSE ... :3081` | A stale dsh-pocket process holds the port: macOS/Linux `lsof -ti :3081 \| xargs kill -9`; Windows `netstat -ano \| findstr :3081` (find the LISTENING PID) → `taskkill /PID <PID> /F`, then retry |
138
+ | Want a different port (issue #70) | Plugin mode: write `"proxyPort": 3082` into `$DSH_HOME/dsh-pocket/settings.json` and restart `dsh web`. CLI mode: `dsh-pocket --port 3082`. If the port is taken you'll get `EADDRINUSE` — kill the old process or pick another one |
139
+ | Issue a temporary PIN to a guest | Not available: the temporary access PIN feature (issue #69) was removed in 2.6.x (it crashed on revoke). To share access, send the main PIN or a `?token=<main PIN>` link, then hit "Refresh" in Settings once the guest is done |
140
+ | cloudflared install fails on a remote Linux server (issue #45) | If all CDN sources (GitHub / ghproxy / gh.ddlc / gh-proxy) are unreachable on a remote Linux host, install `cloudflared` yourself (e.g. `apt install cloudflared`, `dnf install cloudflared`, or download the tgz and unpack it), then add `"cloudflaredPath": "/path/to/cloudflared"` into `$DSH_HOME/dsh-pocket/settings.json` and restart `dsh web`. The plugin will then use that binary directly and skip the auto-download |
141
+ | Version stuck below 1.x | `^0.x` ranges never jump to 1.x: update with `--latest` (`dsh plugin --profile web update dsh-pocket --latest -w`) |
142
+ | Public `error 1033` | See "Public tunnel troubleshooting" below — usually a local proxy/VPN (Clash etc. TUN mode) killing the tunnel |
143
+ | After "Restart dsh web", the page says the process is running in the background | The new process from in-page self-restart is a detached background process (not attached to your terminal) — that's the standard way to apply updates in-page; stop it: macOS/Linux `lsof -ti :3080 \| xargs kill -9`; Windows `netstat -ano \| findstr :3080` → `taskkill /PID <PID> /F` (logs under `$DSH_HOME` as `dsh-pocket-restart-*.log`) |
141
144
 
142
145
  ## ⚠️ Public tunnel troubleshooting (read first)
143
146
 
@@ -166,9 +169,11 @@ Such tools take over all traffic and often cut cloudflared's tunnel-edge connect
166
169
  **Other causes**: corporate firewalls / campus networks blocking outbound — ask IT to allow it, or use a hotspot.
167
170
 
168
171
  **First run: "Downloading cloudflared" fails or hangs**:
172
+
169
173
  - **macOS/Linux**: the plugin first downloads from the **Tsinghua mirror** (measured ~3MB/s, done in seconds); falls back to official GitHub + acceleration mirrors if it fails.
170
174
  - **Windows**: no Tsinghua mirror (Homebrew doesn't support Windows) — downloads the ~50MB exe from GitHub directly; **single-threaded, so it's slower — that's expected**, wait a few minutes, or use a proxy.
171
175
  - If all sources fail, the settings page shows a hint. Alternatives (any one):
176
+
172
177
  1. Install the `cloudflared` command and retry (the plugin then uses the PATH binary, no download):
173
178
  - macOS: `brew install cloudflared`; Linux: `sudo apt install cloudflared` or from the official site
174
179
  - Windows: `winget install cloudflared` or from the official site
@@ -178,16 +183,16 @@ Such tools take over all traffic and often cut cloudflared's tunnel-edge connect
178
183
 
179
184
  ## 🗂 Architecture (single package)
180
185
 
181
- | File | Purpose |
182
- |---|---|
183
- | `lib/index.js` | Plugin entry: auto-start proxy + register RPC + access-PIN management (public: 8 digits rotated per tunnel start; LAN: separate 8 digits, manually refreshable / switchable) + LAN access switch + DSH Desktop detection |
184
- | `lib/settings.mjs` | Settings persistence: LAN access switch (on by default) + LAN-PIN switch (on by default) stored in `$DSH_HOME/dsh-pocket/settings.json` |
185
- | `lib/service.mjs` | Service: proxy lifecycle (port auto-fallback), public tunnel (auto-restore), status snapshot (with QR data URLs) |
186
- | `lib/proxy.mjs` | Header-rewriting reverse proxy: Host/Origin → loopback, HTTP + WebSocket passthrough + polyfill injection + gzip/brotli compression + per-host token auth (public always; LAN per switch) + blocks LAN Hosts when LAN is off |
187
- | `lib/tunnel.mjs` | cloudflared: multi-mirror download (Tsinghua first) / adaptive parallel / start / parse public URL (HTTP/2) |
188
- | `lib/web-rpc.js` | Loopback RPC: `status` / `tunnel.start` / `tunnel.stop` / `lan.setEnabled` / `version` / `update` / `restart` |
189
- | `client/` | "Phone access" settings tab + mobile adaptation (dsh-web-mobile port) |
190
- | `bin/dsh-pocket.mjs` | CLI: LAN/public modes, prints URL + QR |
186
+ | File | Purpose |
187
+ | -------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
188
+ | `lib/index.js` | Plugin entry: auto-start proxy + register RPC + access-PIN management (public: 8 digits rotated per tunnel start; LAN: separate 8 digits, manually refreshable / switchable) + LAN access switch + DSH Desktop detection |
189
+ | `lib/settings.mjs` | Settings persistence: LAN access switch (on by default) + LAN-PIN switch (on by default) stored in `$DSH_HOME/dsh-pocket/settings.json` |
190
+ | `lib/service.mjs` | Service: proxy lifecycle (port auto-fallback), public tunnel (auto-restore), status snapshot (with QR data URLs) |
191
+ | `lib/proxy.mjs` | Header-rewriting reverse proxy: Host/Origin → loopback, HTTP + WebSocket passthrough + polyfill injection + gzip/brotli compression + per-host token auth (public always; LAN per switch) + blocks LAN Hosts when LAN is off |
192
+ | `lib/tunnel.mjs` | cloudflared: multi-mirror download (Tsinghua first) / adaptive parallel / start / parse public URL (HTTP/2) |
193
+ | `lib/web-rpc.js` | Loopback RPC: `status` / `tunnel.start` / `tunnel.stop` / `lan.setEnabled` / `version` / `update` / `restart` |
194
+ | `client/` | "Phone access" settings tab + mobile adaptation (dsh-web-mobile port) |
195
+ | `bin/dsh-pocket.mjs` | CLI: LAN/public modes, prints URL + QR |
191
196
 
192
197
  ## 🛠 Development
193
198
 
package/README.md CHANGED
@@ -6,6 +6,8 @@
6
6
 
7
7
  <p align="center"><a href="README.en.md">English</a> | <a href="README.md">中文</a></p>
8
8
 
9
+ <p align="center"><a href="https://trendshift.io/repositories/166736?utm_source=trendshift-badge&amp;utm_medium=badge&amp;utm_campaign=badge-trendshift-166736" target="_blank" rel="noopener noreferrer"><img src="https://trendshift.io/api/badge/trendshift/repositories/166736/daily?language=JavaScript" alt="shaobeichen%2Fdsh-pocket | Trendshift" width="250" height="55"/></a></p>
10
+
9
11
  <p align="center">
10
12
  <a href="https://www.npmjs.com/package/dsh-pocket"><img alt="npm" src="https://img.shields.io/npm/v/dsh-pocket?color=4d6bfe&label=npm"></a>
11
13
  <a href="https://www.npmjs.com/package/dsh-pocket"><img alt="downloads" src="https://img.shields.io/npm/dm/dsh-pocket?color=4d6bfe"></a>
@@ -39,21 +41,22 @@ DSH Pocket 就是干这个的:**装上它,手机扫个码,就能实时看
39
41
 
40
42
  ## ✨ 特性
41
43
 
42
- | 特性 | 说明 |
43
- |---|---|
44
- | 📶 局域网扫码 | 装好即用:设置 → 手机访问,打开就有局域网二维码,手机连同一 WiFi 扫码即开(自动识别本机局域网 IP,**WSL 环境自动取 Windows 物理网卡 IP**) |
45
- | 🚪 局域网开关 | 设置页可**一键关闭/开启局域网访问**(切换时弹窗提醒):关闭后局域网二维码/链接立即失效,仅公网可用 |
46
- | 🌐 公网扫码(人在外面) | 点「开启公网访问」→ cloudflared 隧道 → 出公网二维码,4G/任何网络都能访问 |
47
- | 🏷️ 公网固定域名 | 可选「**命名隧道**」模式:填 Cloudflare Tunnel Token + 自己的域名,公网地址**固定不变**(重启不再变;见下方说明) |
48
- | 🔐 访问密码 | 公网链接需输入 **8 位密码**(默认每次开启公网自动换新;**可自定义固定密码**——自定义后不再换新);局域网有独立 **8 位密码**(默认开启,设置页可**一键关闭**——关闭后局域网扫码直连) |
49
- | 🔑 自定义密码 | 公网/局域网密码都可在设置页**设成自己固定的 8 位密码(英文字母大小写或数字)**(自定义后公网不再自动换新) |
50
- | 🧘 会话保持 | 手机输一次密码后**长期免输**(登录状态绑定电脑上的 dsh web 进程:只要它不重启,手机不用再输;**dsh web 重启/更新后需重新输入一次**) |
51
- | ⚡ 实时同步 | 流式输出走 WebSocket 全透传——**电脑上在输出,手机上同步在滚**,可双向操作;内置心跳保活(防路由器 NAT/省电机制静默断链,断线自动重连) |
52
- | 📱 移动端适配 | 窄屏自动变抽屉布局(移植 dsh-web-mobile,MIT):侧栏抽屉、会话全宽、状态栏安全区、触控优化 |
53
- | 📁 文件浏览 | 移动端「文件浏览」入口需要宿主提供 explorer 面板(dsh-web-ui 组件);官方 DSH 未内置时入口自动隐藏,不会出现"点了没反应" |
54
- | 🗜️ 传输压缩 | 大 JSON 响应自动 gzip/brotli(长会话 17MB → ~1MB,brotli 质量 6:快且省流量),手机加载更快、更省流量 |
55
- | 🔁 隧道自动恢复 | DSH 重启后自动重新拉起之前开着的公网隧道,无需手动重开 |
56
- | 🧩 零依赖安装 | 一个 npm 包、一个设置页,没有核心/适配器要分开装;无需账号、无需服务器 |
44
+ | 特性 | 说明 |
45
+ | ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
46
+ | 📶 局域网扫码 | 装好即用:设置 → 手机访问,打开就有局域网二维码,手机连同一 WiFi 扫码即开(自动识别本机局域网 IP,**WSL 环境自动取 Windows 物理网卡 IP**) |
47
+ | 🚪 局域网开关 | 设置页可**一键关闭/开启局域网访问**(切换时弹窗提醒):关闭后局域网二维码/链接立即失效,仅公网可用 |
48
+ | 🌐 公网扫码(人在外面) | 点「开启公网访问」→ cloudflared 隧道 → 出公网二维码,4G/任何网络都能访问 |
49
+ | 🏷️ 公网固定域名 | 可选「**命名隧道**」模式:填 Cloudflare Tunnel Token + 自己的域名,公网地址**固定不变**(重启不再变;见下方说明) |
50
+ | 🔐 访问密码 | 公网链接默认使用 **8 位随机密码**(每次开启公网自动换新;**可自定义固定密码**——自定义后不再换新);局域网有独立的默认 **8 位随机密码**(默认开启,设置页可**一键关闭**——关闭后局域网扫码直连) |
51
+ | 🔑 自定义密码 | 公网/局域网密码都可在设置页**设成自己固定的 8–64 位密码(英文字母大小写或数字)**(自定义后公网不再自动换新) |
52
+ | 🧘 会话保持 | 手机输一次密码后**长期免输**(登录状态绑定电脑上的 dsh web 进程:只要它不重启,手机不用再输;**dsh web 重启/更新后需重新输入一次**) |
53
+ | ⚡ 实时同步 | 流式输出走 WebSocket 全透传——**电脑上在输出,手机上同步在滚**,可双向操作;内置心跳保活(防路由器 NAT/省电机制静默断链,断线自动重连) |
54
+ | 📱 移动端适配 | 窄屏自动变抽屉布局(移植 dsh-web-mobile,MIT):侧栏抽屉、会话全宽、状态栏安全区、触控优化 |
55
+ | 🧭 可选右边栏 | 手机端显示原生右边栏入口;普通手机可在设置中关闭以保持紧凑,折叠屏展开后可更方便地同时使用终端底栏和右边栏 |
56
+ | 📁 文件浏览 | 移动端「文件浏览」入口需要宿主提供 explorer 面板(dsh-web-ui 组件);官方 DSH 未内置时入口自动隐藏,不会出现"点了没反应" |
57
+ | 🗜️ 传输压缩 | 大 JSON 响应自动 gzip/brotli(长会话 17MB → ~1MB,brotli 质量 6:快且省流量),手机加载更快、更省流量 |
58
+ | 🔁 隧道自动恢复 | DSH 重启后自动重新拉起之前开着的公网隧道,无需手动重开 |
59
+ | 🧩 零依赖安装 | 一个 npm 包、一个设置页,没有核心/适配器要分开装;无需账号、无需服务器 |
57
60
 
58
61
  ## 🚀 怎么用
59
62
 
@@ -80,7 +83,7 @@ npx @deepseek-ai/dsh web
80
83
 
81
84
  ### 局域网(同一 WiFi)
82
85
 
83
- 设置 → **手机访问** → 手机扫「📶 局域网」二维码 → 打开链接**输入局域网密码**(显示在设置页局域网区块,点「刷新」可换新,或点「自定义」设成自己固定的 8 位密码——英文字母大小写或数字)→ 打开的就是电脑上的 DSH,实时同步。
86
+ 设置 → **手机访问** → 手机扫「📶 局域网」二维码 → 打开链接**输入局域网密码**(显示在设置页局域网区块,点「刷新」可换新,或点「自定义」设成自己固定的 8–64 位密码——英文字母大小写或数字)→ 打开的就是电脑上的 DSH,实时同步。
84
87
 
85
88
  > 「**局域网访问**」开关默认**开**:可一键**关闭/开启**(切换时弹窗提醒)——关闭后局域网二维码/链接立即失效(手机打不开),**公网不受影响**;想恢复时再点「开」即可。
86
89
  >
@@ -92,7 +95,7 @@ npx @deepseek-ai/dsh web
92
95
 
93
96
  ### 公网(人在外面)
94
97
 
95
- 同一页点「**开启公网访问**」→ **每次都会先弹出安全免责声明**,勾选「我已知情」后才能开启(公司/涉密网络请先确认合规)→ 等隧道建立(首次会下载 cloudflared,macOS/Linux 走清华镜像秒下)→ 手机扫「🌐 公网」二维码 → 打开链接**输入 8 位访问密码**(密码显示在设置页公网区块,默认**每次开启公网变新**,也可点「自定义」设成固定密码——英文字母大小写或数字,自定义后不再换新)→ 人在外面(4G/公司网)也能访问。
98
+ 同一页点「**开启公网访问**」→ **每次都会先弹出安全免责声明**,勾选「我已知情」后才能开启(公司/涉密网络请先确认合规)→ 等隧道建立(首次会下载 cloudflared,macOS/Linux 走清华镜像秒下)→ 手机扫「🌐 公网」二维码 → 打开链接**输入访问密码**(密码显示在设置页公网区块,默认是**每次开启公网变新的 8 位随机密码**,也可点「自定义」设成 8–64 位固定密码——英文字母大小写或数字,自定义后不再换新)→ 人在外面(4G/公司网)也能访问。
96
99
 
97
100
  > 更新到新版本:`dsh plugin --profile web update dsh-pocket --latest -w`(跨大版本时 `--latest` 是必须的,`^0.x` 范围不会自动升到 1.x)。
98
101
 
@@ -111,7 +114,7 @@ npx @deepseek-ai/dsh web
111
114
 
112
115
  - **DSH 能执行你电脑上的代码**。**局域网**二维码/URL 配上独立 **8 位密码**才是钥匙(密码**默认开启**,可关——关闭后局域网扫码直连,仅同一网络设备可访问),**请勿把局域网二维码、URL 或密码发给别人**
113
116
  - **开启公网访问前必须阅读并勾选免责声明**(每次开启都会弹框;服务端强制校验,无法绕过):公网 = 把能执行代码的 DSH 暴露到互联网,请使用强密码、用完即关、涉密网络勿用
114
- - **公网**有 **8 位密码**保护:链接随机分配、默认每次开启换新密码、旧链接立即作废——泄露了也进不来,改密码/重开即可作废;**自定义密码后不再自动换新**(你设的值即稳定密码,可为英文字母大小写或数字)
117
+ - **公网**默认有 **8 位随机密码**保护:链接随机分配、默认每次开启换新密码、旧链接立即作废——泄露了也进不来,改密码/重开即可作废;**自定义密码可设为 8–64 位英文字母或数字,且不再自动换新**
115
118
  - 手机登录状态与电脑上的 dsh web 进程绑定:**电脑 dsh web 一直开着就不用重复输入;重启/更新后需重新输入一次**
116
119
  - **登录限速**(防暴力破解):同一 IP 连续输错 **5 次**锁定 **60 秒**;全局失败超阈值时短暂全锁(防换 IP 分布式扫描);输对密码后计数清零
117
120
  - 公网 URL 由 cloudflared 随机分配,**每次重启会变化**(旧链接自动失效,相当于天然轮换);**命名隧道固定域名**模式下地址不变、密码不自动轮换,请配合自定义密码管理
@@ -127,18 +130,18 @@ npx @deepseek-ai/dsh web
127
130
 
128
131
  ## 🩹 常见问题(别踩的坑)
129
132
 
130
- | 现象 | 原因与解决 |
131
- |---|---|
132
- | `dsh: command not found` / 提示 DSH 未定义 | dsh CLI 没装:`npm install -g @deepseek-ai/dsh`,或命令前加 `npx @deepseek-ai/dsh` |
133
- | `ERR_PNPM_ADDING_TO_ROOT` | pnpm 9 对 workspace 根的限制:安装/更新命令**末尾加 `-w`**(`--workspace-root`) |
134
- | 装完/更新了但界面没变化 | **必须重启 `dsh web`** 才生效;运行中的进程仍加载旧代码 |
135
- | `listen EADDRINUSE ... :3081` | 旧 dsh-pocket 进程还占着端口:macOS/Linux `lsof -ti :3081 \| xargs kill -9`;Windows `netstat -ano \| findstr :3081`(找 LISTENING 的 PID)→ `taskkill /PID <PID> /F`,后重试 |
136
- | 想换端口(issue #70) | 插件模式:在 `$DSH_HOME/dsh-pocket/settings.json` 写 `"proxyPort": 3082` 后重启 `dsh web`。CLI 模式:`dsh-pocket --port 3082`。端口被占会报 `EADDRINUSE`,杀掉旧进程或换一个端口 |
137
- | 想给访客一个临时密码 | 暂不支持:issue #69 的「临时访问 PIN」功能已在 2.6.x 移除(撤销时会崩)。现在分享访问:把主密码或 `?token=<主密码>` 链接发给对方,用完在设置页点「刷新」换掉即可 |
133
+ | 现象 | 原因与解决 |
134
+ | ------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
135
+ | `dsh: command not found` / 提示 DSH 未定义 | dsh CLI 没装:`npm install -g @deepseek-ai/dsh`,或命令前加 `npx @deepseek-ai/dsh` |
136
+ | `ERR_PNPM_ADDING_TO_ROOT` | pnpm 9 对 workspace 根的限制:安装/更新命令**末尾加 `-w`**(`--workspace-root`) |
137
+ | 装完/更新了但界面没变化 | **必须重启 `dsh web`** 才生效;运行中的进程仍加载旧代码 |
138
+ | `listen EADDRINUSE ... :3081` | 旧 dsh-pocket 进程还占着端口:macOS/Linux `lsof -ti :3081 \| xargs kill -9`;Windows `netstat -ano \| findstr :3081`(找 LISTENING 的 PID)→ `taskkill /PID <PID> /F`,后重试 |
139
+ | 想换端口(issue #70) | 插件模式:在 `$DSH_HOME/dsh-pocket/settings.json` 写 `"proxyPort": 3082` 后重启 `dsh web`。CLI 模式:`dsh-pocket --port 3082`。端口被占会报 `EADDRINUSE`,杀掉旧进程或换一个端口 |
140
+ | 想给访客一个临时密码 | 暂不支持:issue #69 的「临时访问 PIN」功能已在 2.6.x 移除(撤销时会崩)。现在分享访问:把主密码或 `?token=<主密码>` 链接发给对方,用完在设置页点「刷新」换掉即可 |
138
141
  | Linux 服务器装不上 cloudflared(issue #45) | 远程 Linux 国内/企业网下所有 CDN 源(GitHub/ghproxy/gh.ddlc/gh-proxy)都连不上时:在服务器上手动装 `cloudflared`(如 `apt install cloudflared`、`dnf install cloudflared`、或下载 tgz 解压到任意目录),然后在 `$DSH_HOME/dsh-pocket/settings.json` 加 `"cloudflaredPath": "/path/to/cloudflared"`,重启 `dsh web` 后插件直接调用它,**不再走自动下载** |
139
- | 版本停在 0.x 升不上去 | `^0.x` 范围不允许升到 1.x:更新用 `--latest`(`dsh plugin --profile web update dsh-pocket --latest -w`) |
140
- | 公网 `error 1033` | 见下方「公网隧道常见问题」——多半是本机代理/VPN(Clash 等 TUN 模式)掐断了隧道 |
141
- | 点「重启 dsh web」后页面提示进程在后台运行 | 自重启的新进程是 detached 后台进程(不挂终端),是页内更新的标准做法;停止它:macOS/Linux `lsof -ti :3080 \| xargs kill -9`;Windows `netstat -ano \| findstr :3080` → `taskkill /PID <PID> /F`(日志在 `$DSH_HOME` 下 `dsh-pocket-restart-*.log`) |
142
+ | 版本停在 0.x 升不上去 | `^0.x` 范围不允许升到 1.x:更新用 `--latest`(`dsh plugin --profile web update dsh-pocket --latest -w`) |
143
+ | 公网 `error 1033` | 见下方「公网隧道常见问题」——多半是本机代理/VPN(Clash 等 TUN 模式)掐断了隧道 |
144
+ | 点「重启 dsh web」后页面提示进程在后台运行 | 自重启的新进程是 detached 后台进程(不挂终端),是页内更新的标准做法;停止它:macOS/Linux `lsof -ti :3080 \| xargs kill -9`;Windows `netstat -ano \| findstr :3080` → `taskkill /PID <PID> /F`(日志在 `$DSH_HOME` 下 `dsh-pocket-restart-*.log`) |
142
145
 
143
146
  ## ⚠️ 公网隧道常见问题(必读)
144
147
 
@@ -168,9 +171,11 @@ npx @deepseek-ai/dsh web
168
171
  **其他可能**:企业防火墙/校园网拦截出站;此时请让 IT 放行或改用热点。
169
172
 
170
173
  **首次开启时「下载 cloudflared」失败/卡住**:
174
+
171
175
  - **macOS/Linux**:优先走**清华镜像**(实测 ~3MB/s,几秒下完);失败自动回退官方 GitHub + 加速源。
172
176
  - **Windows**:无清华镜像(Homebrew 不支持 Windows),走官方直连下载(约 50MB,**单线程会慢,属正常**,耐心等几分钟;也可挂代理加速)。
173
177
  - 全部失败时设置页会给出提示。备选方案(任选其一):
178
+
174
179
  1. 手动装好命令行 cloudflared 后重试(装好后 dsh-pocket 直接用 PATH 里的,不再下载):
175
180
  - macOS:`brew install cloudflared`;Linux:`sudo apt install cloudflared` 或官网下载
176
181
  - Windows:`winget install cloudflared` 或官网下载
@@ -180,16 +185,16 @@ npx @deepseek-ai/dsh web
180
185
 
181
186
  ## 🗂 架构(单包)
182
187
 
183
- | 文件 | 说明 |
184
- |---|---|
185
- | `lib/index.js` | 插件入口:自动起代理 + 注册 RPC + 访问密码管理(公网 8 位每次开启变新;局域网独立 8 位可手动刷新/开关)+ 局域网访问总开关 + 桌面端环境适配 |
186
- | `lib/settings.mjs` | 设置持久化:局域网访问总开关(默认开启)+ 局域网密码开关(默认开启)存 `$DSH_HOME/dsh-pocket/settings.json` |
187
- | `lib/service.mjs` | 服务:代理生命周期(端口自适应)、公网隧道(自动恢复)、状态快照(含二维码) |
188
- | `lib/proxy.mjs` | 改头反向代理:Host/Origin → loopback,HTTP + WebSocket 透传 + polyfill 注入 + gzip/brotli 压缩 + 按 Host 区分的访问令牌认证(公网必验;局域网按开关)+ 局域网关闭时拦截局域网 Host |
189
- | `lib/tunnel.mjs` | cloudflared:多镜像源下载(清华优先)/自适应多线程/启动/解析公网 URL(HTTP/2) |
190
- | `lib/web-rpc.js` | loopback RPC:`status` / `tunnel.start` / `tunnel.stop` / `lan.setEnabled` / `version` / `update` / `restart` |
191
- | `client/` | 设置页「手机访问」+ 移动端适配(dsh-web-mobile 移植) |
192
- | `bin/dsh-pocket.mjs` | CLI:局域网/公网模式,打印 URL + 二维码 |
188
+ | 文件 | 说明 |
189
+ | -------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
190
+ | `lib/index.js` | 插件入口:自动起代理 + 注册 RPC + 访问密码管理(公网 8 位每次开启变新;局域网独立 8 位可手动刷新/开关)+ 局域网访问总开关 + 桌面端环境适配 |
191
+ | `lib/settings.mjs` | 设置持久化:局域网访问总开关(默认开启)+ 局域网密码开关(默认开启)存 `$DSH_HOME/dsh-pocket/settings.json` |
192
+ | `lib/service.mjs` | 服务:代理生命周期(端口自适应)、公网隧道(自动恢复)、状态快照(含二维码) |
193
+ | `lib/proxy.mjs` | 改头反向代理:Host/Origin → loopback,HTTP + WebSocket 透传 + polyfill 注入 + gzip/brotli 压缩 + 按 Host 区分的访问令牌认证(公网必验;局域网按开关)+ 局域网关闭时拦截局域网 Host |
194
+ | `lib/tunnel.mjs` | cloudflared:多镜像源下载(清华优先)/自适应多线程/启动/解析公网 URL(HTTP/2) |
195
+ | `lib/web-rpc.js` | loopback RPC:`status` / `tunnel.start` / `tunnel.stop` / `lan.setEnabled` / `version` / `update` / `restart` |
196
+ | `client/` | 设置页「手机访问」+ 移动端适配(dsh-web-mobile 移植) |
197
+ | `bin/dsh-pocket.mjs` | CLI:局域网/公网模式,打印 URL + 二维码 |
193
198
 
194
199
  ## 🛠 开发
195
200
 
package/client/api.js CHANGED
@@ -1,5 +1,7 @@
1
1
  // dsh-pocket 设置页签 RPC 契约(client 与 host 共享)
2
2
  export const POCKET_RPC_CHANNEL = '/dsh-pocket';
3
+ export const MOBILE_RIGHTBAR_ATTRIBUTE = 'data-dsh-pocket-mobile-rightbar';
4
+ export const MOBILE_RIGHTBAR_EVENT = 'dsh-pocket:mobile-rightbar';
3
5
 
4
6
  export const POCKET_ENDPOINTS = Object.freeze({
5
7
  status: 'pocket.status',
@@ -13,6 +15,7 @@ export const POCKET_ENDPOINTS = Object.freeze({
13
15
  lanAuthSetEnabled: 'lanAuth.setEnabled',
14
16
  lanSetOverride: 'lan.setOverride',
15
17
  lanSetEnabled: 'lan.setEnabled',
18
+ mobileRightbarSetEnabled: 'mobile.rightbar.setEnabled',
16
19
  pinSetCustom: 'pin.setCustom',
17
20
  pocketReset: 'pocket.reset',
18
21
  // 移动端「复制文件内容」(issue #17):手机经此 RPC 让主机读取文件正文,
package/client/client.js CHANGED
@@ -41,6 +41,8 @@ var import_react2 = require("react");
41
41
 
42
42
  // client/api.js
43
43
  var POCKET_RPC_CHANNEL = "/dsh-pocket";
44
+ var MOBILE_RIGHTBAR_ATTRIBUTE = "data-dsh-pocket-mobile-rightbar";
45
+ var MOBILE_RIGHTBAR_EVENT = "dsh-pocket:mobile-rightbar";
44
46
  var POCKET_ENDPOINTS = Object.freeze({
45
47
  status: "pocket.status",
46
48
  tunnelStart: "tunnel.start",
@@ -53,6 +55,7 @@ var POCKET_ENDPOINTS = Object.freeze({
53
55
  lanAuthSetEnabled: "lanAuth.setEnabled",
54
56
  lanSetOverride: "lan.setOverride",
55
57
  lanSetEnabled: "lan.setEnabled",
58
+ mobileRightbarSetEnabled: "mobile.rightbar.setEnabled",
56
59
  pinSetCustom: "pin.setCustom",
57
60
  pocketReset: "pocket.reset",
58
61
  // 移动端「复制文件内容」(issue #17):手机经此 RPC 让主机读取文件正文,
@@ -962,12 +965,8 @@ var MOBILE_CSS = `
962
965
  }
963
966
 
964
967
  /* --- Session header on mobile ---
965
- Layout goal: [toggle] [session title] [mode badge] in a row, with the
966
- Session log capsule removed from the header (relocated to the drawer
967
- footer). Stable structural hooks only:
968
- [data-phase] header the session header element
969
- header > :first-child titleRow (titleCluster + utilities)
970
- header > :first-child > :last-child headerUtilities (Session log seat) */
968
+ Layout goal: [toggle] [session title] [mode badge] in a row. The optional
969
+ rightbar entry uses the shell's stable header-corner hook. */
971
970
  [data-phase] header {
972
971
  padding: 8px 12px 0 !important;
973
972
  }
@@ -1004,11 +1003,14 @@ var MOBILE_CSS = `
1004
1003
  top: 12px !important;
1005
1004
  z-index: 2 !important;
1006
1005
  }
1007
- /* Session log download: gone from the header row on mobile (the utilities
1008
- seat holds only the session-log-export capsule). */
1009
- [data-phase] header > :first-child > :last-child {
1006
+ /* The native rightbar entry is visible by default. Users who prefer the
1007
+ compact header can turn it off in Pocket settings. */
1008
+ body[data-dsh-pocket-mobile-rightbar="off"] [data-conversation-header-corner] {
1010
1009
  display: none !important;
1011
1010
  }
1011
+ body:not([data-dsh-pocket-mobile-rightbar="off"]) [data-mobile-nav="files"] {
1012
+ right: 44px !important;
1013
+ }
1012
1014
 
1013
1015
  /* --- Settings dialog on mobile ---
1014
1016
  Desktop: 800px two-column flex (188px nav + content). Mobile: a
@@ -1400,9 +1402,15 @@ var MOBILE_CSS = `
1400
1402
  targeted). Layout: ONE fixed-height (28px) flex strip that scrolls
1401
1403
  horizontally \u2014 the full metrics stream stays reachable by swiping,
1402
1404
  the row never grows vertically, no ellipsis or fade, 12px gaps
1403
- between metric groups, a 2px scrollbar as the swipe affordance. */
1405
+ between metric groups, a 2px scrollbar as the swipe affordance.
1406
+
1407
+ Opt-out: the strip is scoped under body:not([data-pocket-stats="off"])
1408
+ so a skin/operator plugin can legally reclaim this 28px band by
1409
+ setting document.body.dataset.pocketStats = 'off' (avoids the
1410
+ !important weight war that let two footers print on top of each
1411
+ other \u2014 issue #165). Default behavior is unchanged. */
1404
1412
 
1405
- [data-mobile-nav="stats"] {
1413
+ body:not([data-pocket-stats="off"]) [data-mobile-nav="stats"] {
1406
1414
  display: flex !important;
1407
1415
  flex-flow: row nowrap !important;
1408
1416
  align-items: center !important;
@@ -1424,17 +1432,17 @@ var MOBILE_CSS = `
1424
1432
  line-height: 20px !important;
1425
1433
  font-size: 12px !important;
1426
1434
  }
1427
- [data-mobile-nav="stats"]::-webkit-scrollbar {
1435
+ body:not([data-pocket-stats="off"]) [data-mobile-nav="stats"]::-webkit-scrollbar {
1428
1436
  height: 2px !important;
1429
1437
  }
1430
- [data-mobile-nav="stats"]::-webkit-scrollbar-thumb {
1438
+ body:not([data-pocket-stats="off"]) [data-mobile-nav="stats"]::-webkit-scrollbar-thumb {
1431
1439
  background: var(--dsw-alias-label-tertiary, rgba(0, 0, 0, .3)) !important;
1432
1440
  border-radius: 2px !important;
1433
1441
  }
1434
- [data-mobile-nav="stats"]::-webkit-scrollbar-track {
1442
+ body:not([data-pocket-stats="off"]) [data-mobile-nav="stats"]::-webkit-scrollbar-track {
1435
1443
  background: transparent !important;
1436
1444
  }
1437
- [data-mobile-nav="stats"] > * {
1445
+ body:not([data-pocket-stats="off"]) [data-mobile-nav="stats"] > * {
1438
1446
  display: flex !important;
1439
1447
  flex: 0 0 auto !important;
1440
1448
  flex-flow: row nowrap !important;
@@ -1446,10 +1454,10 @@ var MOBILE_CSS = `
1446
1454
  margin-right: 12px !important;
1447
1455
  padding: 0 !important;
1448
1456
  }
1449
- [data-mobile-nav="stats"] > *:last-child {
1457
+ body:not([data-pocket-stats="off"]) [data-mobile-nav="stats"] > *:last-child {
1450
1458
  margin-right: 0 !important;
1451
1459
  }
1452
- [data-mobile-nav="stats"] * {
1460
+ body:not([data-pocket-stats="off"]) [data-mobile-nav="stats"] * {
1453
1461
  white-space: nowrap !important;
1454
1462
  }
1455
1463
 
@@ -1560,7 +1568,7 @@ var MOBILE_CSS = `
1560
1568
  `;
1561
1569
 
1562
1570
  // client/mobile/locales.ts
1563
- var NS = "mobileNav";
1571
+ var NS = "pocketMobileNav";
1564
1572
  var zh = {
1565
1573
  "open": "\u6253\u5F00\u76EE\u5F55",
1566
1574
  "close": "\u6536\u8D77\u76EE\u5F55",
@@ -1615,6 +1623,30 @@ function mobileApply(ctx) {
1615
1623
  } };
1616
1624
  }
1617
1625
  ctx.effect(() => ctx.locale.register(NS, { zh, en }), "dsh-mobile-nav: dictionaries");
1626
+ ctx.effect(() => {
1627
+ let active = true;
1628
+ const applyEnabled = (enabled) => {
1629
+ document.body?.setAttribute(MOBILE_RIGHTBAR_ATTRIBUTE, enabled ? "on" : "off");
1630
+ };
1631
+ const onChange = (event) => {
1632
+ applyEnabled(event.detail?.enabled === true);
1633
+ };
1634
+ const load = async () => {
1635
+ try {
1636
+ const result = await ctx.connection.rpc.call(POCKET_RPC_CHANNEL, POCKET_ENDPOINTS.status, {});
1637
+ if (active) applyEnabled(result?.ok === true ? result.value?.mobileRightbarEnabled !== false : true);
1638
+ } catch {
1639
+ if (active) applyEnabled(true);
1640
+ }
1641
+ };
1642
+ window.addEventListener(MOBILE_RIGHTBAR_EVENT, onChange);
1643
+ void load();
1644
+ return () => {
1645
+ active = false;
1646
+ window.removeEventListener(MOBILE_RIGHTBAR_EVENT, onChange);
1647
+ document.body?.removeAttribute(MOBILE_RIGHTBAR_ATTRIBUTE);
1648
+ };
1649
+ }, "dsh-mobile-nav: optional right sidebar");
1618
1650
  ctx.effect(() => {
1619
1651
  const tag = document.createElement("style");
1620
1652
  tag.dataset.plugin = "@dsh-external/dsh-mobile-nav";
@@ -1796,6 +1828,33 @@ function mobileApply(ctx) {
1796
1828
  );
1797
1829
  return startFileGuard(readFile);
1798
1830
  }, "dsh-mobile-nav: file open guard + copy button + hide add-workspace (issue #17)");
1831
+ ctx.effect(() => {
1832
+ if (!narrow.matches) return () => {
1833
+ };
1834
+ const PHRASES = ["\u52A0\u8F7D\u63D0\u4F9B\u65B9\u76EE\u5F55\u5931\u8D25", "Settings are unavailable in this browser"];
1835
+ const NOTICE = "\u624B\u673A\u4E0A\u4E0D\u652F\u6301\u6A21\u578B\u8BBE\u7F6E\uFF0C\u8BF7\u53BB\u7535\u8111\u7AEF\u4FEE\u6539\u8BBE\u7F6E";
1836
+ const findDeepest = (el) => {
1837
+ let deepest = el;
1838
+ for (const child of el.querySelectorAll("*")) {
1839
+ if (PHRASES.some((p) => (child.textContent ?? "").includes(p))) deepest = child;
1840
+ }
1841
+ return deepest;
1842
+ };
1843
+ const patch = () => {
1844
+ for (const el of document.querySelectorAll("body *")) {
1845
+ const t = el.textContent ?? "";
1846
+ if (!PHRASES.some((p) => t.includes(p))) continue;
1847
+ if (el.dataset?.dshpModelNotice === "1") continue;
1848
+ const target = findDeepest(el);
1849
+ target.textContent = NOTICE;
1850
+ target.dataset.dshpModelNotice = "1";
1851
+ }
1852
+ };
1853
+ const observer = new MutationObserver(patch);
1854
+ observer.observe(document.body, { childList: true, subtree: true, characterData: true });
1855
+ patch();
1856
+ return () => observer.disconnect();
1857
+ }, "dsh-mobile-nav: replace model-settings load error with mobile hint");
1799
1858
  ctx.slots.inject("conversation.session.header.actions", () => ctx.slots.register({
1800
1859
  name: "conversation.session.header.actions",
1801
1860
  id: "mobile-nav-toggle",
@@ -1862,7 +1921,7 @@ var zh2 = {
1862
1921
  "resetGo": "\u6062\u590D",
1863
1922
  "resetIntro": "\u8BBE\u7F6E\u641E\u51FA\u95EE\u9898\u65F6\u7684\u4E34\u65F6\u515C\u5E95\uFF1A\u6E05\u7A7A\u672C\u673A\u914D\u7F6E\u5E76\u91CD\u8BBE\u968F\u673A\u5BC6\u7801\uFF08DSH \u7684\u4F1A\u8BDD\u3001\u6A21\u578B\u3001\u63D2\u4EF6\u914D\u7F6E\u4E0D\u53D7\u5F71\u54CD\uFF09",
1864
1923
  "resetTitle": "\u26A0\uFE0F \u786E\u8BA4\u6062\u590D\u51FA\u5382\u8BBE\u7F6E\uFF1F",
1865
- "resetBody": "\u5C06\u6E05\u7A7A\u5E76\u6062\u590D\u9ED8\u8BA4\uFF1A\n\u2460 \u5F00\u5173\uFF1A\u5C40\u57DF\u7F51\u8BBF\u95EE=\u5F00\u3001\u8BBF\u95EE\u5BC6\u7801=\u5F00\u3001\u5C40\u57DF\u7F51\u5730\u5740=\u81EA\u52A8\n\u2461 \u516C\u7F51\uFF1A\u6A21\u5F0F\u56DE\u5230\u968F\u673A\u57DF\u540D\uFF0C\u6E05\u7A7A Tunnel Token \u4E0E\u56FA\u5B9A\u57DF\u540D\uFF0C\u5E76\u5173\u95ED\u6B63\u5728\u8FD0\u884C\u7684\u516C\u7F51\n\u2462 \u5BC6\u7801\uFF1A\u516C\u7F51\u548C\u5C40\u57DF\u7F51\u90FD\u6362\u6210\u65B0\u7684\u968F\u673A 8 \u4F4D\u5BC6\u7801\uFF08\u65E7\u5BC6\u7801\u7ACB\u5373\u4F5C\u5E9F\uFF0C\u624B\u673A\u9700\u91CD\u65B0\u8F93\u5165\uFF09\n\nDSH \u81EA\u8EAB\u7684\u4F1A\u8BDD\u3001\u6A21\u578B\u3001\u63D2\u4EF6\u914D\u7F6E\u4E0D\u53D7\u5F71\u54CD\uFF1B\u6B64\u64CD\u4F5C\u4E0D\u53EF\u64A4\u9500\u3002",
1924
+ "resetBody": "\u5C06\u6E05\u7A7A\u5E76\u6062\u590D\u9ED8\u8BA4\uFF1A\n\u2460 \u5F00\u5173\uFF1A\u5C40\u57DF\u7F51\u8BBF\u95EE=\u5F00\u3001\u8BBF\u95EE\u5BC6\u7801=\u5F00\u3001\u624B\u673A\u7AEF\u53F3\u8FB9\u680F=\u5F00\u3001\u5C40\u57DF\u7F51\u5730\u5740=\u81EA\u52A8\n\u2461 \u516C\u7F51\uFF1A\u6A21\u5F0F\u56DE\u5230\u968F\u673A\u57DF\u540D\uFF0C\u6E05\u7A7A Tunnel Token \u4E0E\u56FA\u5B9A\u57DF\u540D\uFF0C\u5E76\u5173\u95ED\u6B63\u5728\u8FD0\u884C\u7684\u516C\u7F51\n\u2462 \u5BC6\u7801\uFF1A\u516C\u7F51\u548C\u5C40\u57DF\u7F51\u90FD\u6362\u6210\u65B0\u7684\u968F\u673A 8 \u4F4D\u5BC6\u7801\uFF08\u65E7\u5BC6\u7801\u7ACB\u5373\u4F5C\u5E9F\uFF0C\u624B\u673A\u9700\u91CD\u65B0\u8F93\u5165\uFF09\n\nDSH \u81EA\u8EAB\u7684\u4F1A\u8BDD\u3001\u6A21\u578B\u3001\u63D2\u4EF6\u914D\u7F6E\u4E0D\u53D7\u5F71\u54CD\uFF1B\u6B64\u64CD\u4F5C\u4E0D\u53EF\u64A4\u9500\u3002",
1866
1925
  "resetConfirm": "\u786E\u8BA4\u6062\u590D",
1867
1926
  "resetDone": "\u2705 \u5DF2\u6062\u590D\u51FA\u5382\u8BBE\u7F6E\uFF1A\u8BBE\u7F6E\u5DF2\u6E05\u7A7A\uFF0C\u5BC6\u7801\u5DF2\u6362\u65B0\uFF08\u624B\u673A\u9700\u91CD\u65B0\u8F93\u5165\uFF09",
1868
1927
  "resetFailed": "\u274C \u6062\u590D\u5931\u8D25\uFF0C\u8BF7\u91CD\u8BD5",
@@ -1884,13 +1943,15 @@ var zh2 = {
1884
1943
  "lanPinCustomValue": "\u{1F510} \u8BBF\u95EE\u5BC6\u7801\uFF1A{pin}\uFF08\u81EA\u5B9A\u4E49\uFF1B\u624B\u673A\u6253\u5F00\u9700\u8F93\u5165\uFF09",
1885
1944
  "refresh": "\u5237\u65B0",
1886
1945
  "customize": "\u81EA\u5B9A\u4E49",
1887
- "customizing": "\u65B0\u5BC6\u7801\uFF088 \u4F4D\uFF0C\u82F1\u6587\u5B57\u6BCD\u6216\u6570\u5B57\uFF09\uFF1A",
1946
+ "customizing": "\u65B0\u5BC6\u7801\uFF088\u201364 \u4F4D\uFF0C\u82F1\u6587\u5B57\u6BCD\u6216\u6570\u5B57\uFF09\uFF1A",
1888
1947
  "save": "\u4FDD\u5B58",
1889
1948
  "cancel": "\u53D6\u6D88",
1890
- "pinInvalid": "\u5BC6\u7801\u5FC5\u987B\u662F 8 \u4F4D\u82F1\u6587\u5B57\u6BCD\u6216\u6570\u5B57",
1949
+ "pinInvalid": "\u5BC6\u7801\u5FC5\u987B\u662F 8\u201364 \u4F4D\u82F1\u6587\u5B57\u6BCD\u6216\u6570\u5B57",
1891
1950
  "pinCustomHint": "\u81EA\u5B9A\u4E49\u540E\u5F00\u542F\u516C\u7F51\u4E0D\u518D\u81EA\u52A8\u6362\u65B0",
1892
1951
  "lanPinOff": "\u{1F513} \u5BC6\u7801\u5DF2\u5173\u95ED\uFF1A\u626B\u7801\u76F4\u8FDE\uFF0C\u65E0\u9700\u5BC6\u7801\uFF08\u4EC5\u540C\u4E00\u5C40\u57DF\u7F51\u8BBE\u5907\u53EF\u8BBF\u95EE\uFF1B\u516C\u7F51\u4ECD\u8981\u5BC6\u7801\uFF09",
1893
1952
  "lanStarting": "\u4EE3\u7406\u672A\u5C31\u7EEA\u2026",
1953
+ "mobileRightbar": "\u624B\u673A\u7AEF\u53F3\u8FB9\u680F",
1954
+ "mobileRightbarHint": "\u663E\u793A\u539F\u751F\u53F3\u8FB9\u680F\u5165\u53E3\uFF1B\u666E\u901A\u624B\u673A\u53EF\u6309\u9700\u5173\u95ED\uFF0C\u6298\u53E0\u5C4F\u5C55\u5F00\u540E\u4F7F\u7528\u66F4\u65B9\u4FBF",
1894
1955
  "wanTitle": "\u{1F310} \u516C\u7F51\uFF08\u4EBA\u5728\u5916\u9762\uFF09",
1895
1956
  "wanHint": "\u4EFB\u4F55\u7F51\u7EDC\u626B\u7801\u5373\u7528\uFF08URL \u6BCF\u6B21\u91CD\u542F\u81EA\u52A8\u6362\u65B0\uFF09",
1896
1957
  "wanPin": "\u{1F510} \u8BBF\u95EE\u5BC6\u7801\uFF1A{pin}\uFF08\u6BCF\u6B21\u5F00\u542F\u516C\u7F51\u53D8\u65B0\uFF1B\u624B\u673A\u6253\u5F00\u94FE\u63A5\u9700\u8F93\u5165\u6B64\u5BC6\u7801\uFF09",
@@ -1958,7 +2019,7 @@ var en2 = {
1958
2019
  "resetGo": "Reset",
1959
2020
  "resetIntro": "Temporary fallback when settings break: clear local config and re-roll random PINs (DSH sessions, models and plugin config are untouched)",
1960
2021
  "resetTitle": "\u26A0\uFE0F Confirm factory reset?",
1961
- "resetBody": "This clears and restores defaults:\n\u2460 Switches: LAN access on, access PIN on, LAN address auto\n\u2461 Public: mode back to random URL, Tunnel Token and fixed domain cleared, and any running tunnel is stopped\n\u2462 PINs: both public and LAN become new random 8-character PINs (old ones stop working; the phone must re-enter)\n\nYour DSH sessions, models and plugin config are untouched. This cannot be undone.",
2022
+ "resetBody": "This clears and restores defaults:\n\u2460 Switches: LAN access on, access PIN on, mobile right sidebar on, LAN address auto\n\u2461 Public: mode back to random URL, Tunnel Token and fixed domain cleared, and any running tunnel is stopped\n\u2462 PINs: both public and LAN become new random 8-character PINs (old ones stop working; the phone must re-enter)\n\nYour DSH sessions, models and plugin config are untouched. This cannot be undone.",
1962
2023
  "resetConfirm": "Reset",
1963
2024
  "resetDone": "\u2705 Factory reset done: settings cleared and PINs re-rolled (re-enter the PIN on your phone)",
1964
2025
  "resetFailed": "\u274C Reset failed \u2014 please retry",
@@ -1980,13 +2041,15 @@ var en2 = {
1980
2041
  "lanPinCustomValue": "\u{1F510} PIN: {pin} (custom; required on the phone)",
1981
2042
  "refresh": "Refresh",
1982
2043
  "customize": "Customize",
1983
- "customizing": "New PIN (8 chars, letters/digits): ",
2044
+ "customizing": "New PIN (8\u201364 chars, letters/digits): ",
1984
2045
  "save": "Save",
1985
2046
  "cancel": "Cancel",
1986
- "pinInvalid": "PIN must be exactly 8 characters (letters and digits only)",
2047
+ "pinInvalid": "PIN must be 8\u201364 characters (letters and digits only)",
1987
2048
  "pinCustomHint": "custom PINs are not rotated on tunnel start",
1988
2049
  "lanPinOff": "\u{1F513} PIN off \u2014 scan & go, no PIN (LAN devices only; public still requires PIN)",
1989
2050
  "lanStarting": "Proxy starting\u2026",
2051
+ "mobileRightbar": "Mobile right sidebar",
2052
+ "mobileRightbarHint": "Show the native right-sidebar entry; disable it for a compact phone header or keep it on for an unfolded display",
1990
2053
  "wanTitle": "\u{1F310} Anywhere (public)",
1991
2054
  "wanHint": "Scan from any network (the URL changes on every restart)",
1992
2055
  "wanPin": "\u{1F510} PIN: {pin} (changes each time the tunnel is enabled; required on the phone)",
@@ -2045,6 +2108,11 @@ var styles = {
2045
2108
  qr: { width: 220, height: 220, borderRadius: 10, border: "1px solid var(--dsw-alias-border-l2,#e5e7eb)", margin: "8px 0" },
2046
2109
  warn: { color: "var(--dsw-alias-state-warn-primary,#b45309)", fontSize: 12, lineHeight: 1.5 }
2047
2110
  };
2111
+ function applyMobileRightbarSetting(enabled) {
2112
+ const on = enabled !== false;
2113
+ document.body?.setAttribute(MOBILE_RIGHTBAR_ATTRIBUTE, on ? "on" : "off");
2114
+ window.dispatchEvent(new CustomEvent(MOBILE_RIGHTBAR_EVENT, { detail: { enabled: on } }));
2115
+ }
2048
2116
  function PocketSettingsTab({ rpcCall, t }) {
2049
2117
  const [status, setStatus] = (0, import_react2.useState)(null);
2050
2118
  const [busy, setBusy] = (0, import_react2.useState)(false);
@@ -2068,6 +2136,7 @@ function PocketSettingsTab({ rpcCall, t }) {
2068
2136
  try {
2069
2137
  const s = await call(POCKET_ENDPOINTS.status, {});
2070
2138
  setStatus(s);
2139
+ applyMobileRightbarSetting(s.mobileRightbarEnabled);
2071
2140
  setTunnelState(s.tunnelState ?? null);
2072
2141
  if (s.desktop) setIsDesktop(true);
2073
2142
  if (s.restartNotice) {
@@ -2214,7 +2283,9 @@ function PocketSettingsTab({ rpcCall, t }) {
2214
2283
  setBusy(true);
2215
2284
  setError(null);
2216
2285
  try {
2217
- setStatus(await call(POCKET_ENDPOINTS.pocketReset, { confirm: true }));
2286
+ const next = await call(POCKET_ENDPOINTS.pocketReset, { confirm: true });
2287
+ setStatus(next);
2288
+ applyMobileRightbarSetting(next.mobileRightbarEnabled);
2218
2289
  setTunnelCfg(null);
2219
2290
  setCustomPin(null);
2220
2291
  setAdvOpen(false);
@@ -2240,6 +2311,16 @@ function PocketSettingsTab({ rpcCall, t }) {
2240
2311
  } catch {
2241
2312
  }
2242
2313
  };
2314
+ const setMobileRightbar = async (on) => {
2315
+ try {
2316
+ const r = await call(POCKET_ENDPOINTS.mobileRightbarSetEnabled, { on });
2317
+ const enabled = r.mobileRightbarEnabled === true;
2318
+ setStatus((s) => ({ ...s, mobileRightbarEnabled: enabled }));
2319
+ applyMobileRightbarSetting(enabled);
2320
+ } catch (err) {
2321
+ setError(err.message);
2322
+ }
2323
+ };
2243
2324
  const [lanToggleOpen, setLanToggleOpen] = (0, import_react2.useState)(null);
2244
2325
  const requestLanToggle = (on) => setLanToggleOpen(on);
2245
2326
  const confirmLanToggle = async () => {
@@ -2283,7 +2364,8 @@ function PocketSettingsTab({ rpcCall, t }) {
2283
2364
  (0, import_react2.createElement)("input", {
2284
2365
  style: { width: 130, margin: "0 6px", padding: "4px 8px", fontSize: 14, letterSpacing: 1, textAlign: "center", border: "1px solid var(--dsw-alias-border-l2,#d1d5db)", borderRadius: 6, outline: "none" },
2285
2366
  type: "password",
2286
- maxLength: 8,
2367
+ minLength: 8,
2368
+ maxLength: 64,
2287
2369
  value: customPin?.value ?? "",
2288
2370
  autoFocus: true,
2289
2371
  onChange: (e) => setCustomPin((c) => ({ ...c, value: e.target.value.replace(/[^a-zA-Z0-9]/g, ""), err: null })),
@@ -2583,6 +2665,15 @@ function PocketSettingsTab({ rpcCall, t }) {
2583
2665
  ) : null
2584
2666
  ) : null
2585
2667
  ),
2668
+ (0, import_react2.createElement)(
2669
+ "div",
2670
+ { style: styles.block },
2671
+ row(
2672
+ t("mobileRightbar"),
2673
+ Switch(status?.mobileRightbarEnabled !== false, () => setMobileRightbar(status?.mobileRightbarEnabled === false)),
2674
+ (0, import_react2.createElement)("div", { style: { ...styles.muted, marginTop: 6 } }, t("mobileRightbarHint"))
2675
+ )
2676
+ ),
2586
2677
  error ? (0, import_react2.createElement)("div", { style: { color: "var(--dsw-alias-state-error-primary,#dc2626)", fontSize: 12, marginTop: 8 } }, `\u274C ${errText(error)}`) : null,
2587
2678
  // 恢复出厂设置:设置出问题时的临时兜底(最底部,避免误触)
2588
2679
  (0, import_react2.createElement)(
package/client/index.jsx CHANGED
@@ -9,7 +9,7 @@
9
9
 
10
10
  import { createElement as h, useEffect, useRef, useState } from 'react';
11
11
 
12
- import { POCKET_RPC_CHANNEL, POCKET_ENDPOINTS, redactStatus, compareVersions } from './api.js';
12
+ import { POCKET_RPC_CHANNEL, POCKET_ENDPOINTS, MOBILE_RIGHTBAR_ATTRIBUTE, MOBILE_RIGHTBAR_EVENT, redactStatus, compareVersions } from './api.js';
13
13
  import { mobileApply } from './mobile/mobile-apply.tsx';
14
14
  import { NS as POCKET_NS, zh as POCKET_ZH, en as POCKET_EN } from './pocket-locales.js';
15
15
 
@@ -44,6 +44,12 @@ const styles = {
44
44
  warn: { color: 'var(--dsw-alias-state-warn-primary,#b45309)', fontSize: 12, lineHeight: 1.5 },
45
45
  };
46
46
 
47
+ function applyMobileRightbarSetting(enabled) {
48
+ const on = enabled !== false;
49
+ document.body?.setAttribute(MOBILE_RIGHTBAR_ATTRIBUTE, on ? 'on' : 'off');
50
+ window.dispatchEvent(new CustomEvent(MOBILE_RIGHTBAR_EVENT, { detail: { enabled: on } }));
51
+ }
52
+
47
53
  function PocketSettingsTab({ rpcCall, t }) {
48
54
  const [status, setStatus] = useState(null);
49
55
  const [busy, setBusy] = useState(false);
@@ -71,6 +77,7 @@ function PocketSettingsTab({ rpcCall, t }) {
71
77
  try {
72
78
  const s = await call(POCKET_ENDPOINTS.status, {});
73
79
  setStatus(s);
80
+ applyMobileRightbarSetting(s.mobileRightbarEnabled);
74
81
  setTunnelState(s.tunnelState ?? null);
75
82
  if (s.desktop) setIsDesktop(true);
76
83
  if (s.restartNotice) {
@@ -228,7 +235,9 @@ function PocketSettingsTab({ rpcCall, t }) {
228
235
  setBusy(true);
229
236
  setError(null);
230
237
  try {
231
- setStatus(await call(POCKET_ENDPOINTS.pocketReset, { confirm: true }));
238
+ const next = await call(POCKET_ENDPOINTS.pocketReset, { confirm: true });
239
+ setStatus(next);
240
+ applyMobileRightbarSetting(next.mobileRightbarEnabled);
232
241
  setTunnelCfg(null);
233
242
  setCustomPin(null);
234
243
  setAdvOpen(false);
@@ -257,6 +266,17 @@ function PocketSettingsTab({ rpcCall, t }) {
257
266
  } catch { /* 忽略 */ }
258
267
  };
259
268
 
269
+ const setMobileRightbar = async (on) => {
270
+ try {
271
+ const r = await call(POCKET_ENDPOINTS.mobileRightbarSetEnabled, { on });
272
+ const enabled = r.mobileRightbarEnabled === true;
273
+ setStatus((s) => ({ ...s, mobileRightbarEnabled: enabled }));
274
+ applyMobileRightbarSetting(enabled);
275
+ } catch (err) {
276
+ setError(err.message);
277
+ }
278
+ };
279
+
260
280
  // 局域网访问总开关:关闭后局域网扫码/链接直接失效(公网不受影响)。
261
281
  // 切换前弹窗确认(弹窗提醒);服务端用 setLanEnabled 持久化,代理按 Host 实时拦截。
262
282
  const [lanToggleOpen, setLanToggleOpen] = useState(null); // null | true | false(目标 on 状态)
@@ -282,7 +302,7 @@ function PocketSettingsTab({ rpcCall, t }) {
282
302
  }
283
303
  };
284
304
 
285
- // 自定义访问密码(issue #33):公网/局域网各自设固定 8 位密码(英文字母大小写或数字);自定义后公网不再自动轮换。
305
+ // 自定义访问密码(issue #33):公网/局域网各自设固定 8–64 位密码(英文字母大小写或数字);自定义后公网不再自动轮换。
286
306
  // customPin: { which: 'public'|'lan', value, err } | null —— 正在输入自定义密码的区块
287
307
  const [customPin, setCustomPin] = useState(null);
288
308
  const saveCustomPin = async (which) => {
@@ -306,7 +326,8 @@ function PocketSettingsTab({ rpcCall, t }) {
306
326
  h('input', {
307
327
  style: { width: 130, margin: '0 6px', padding: '4px 8px', fontSize: 14, letterSpacing: 1, textAlign: 'center', border: '1px solid var(--dsw-alias-border-l2,#d1d5db)', borderRadius: 6, outline: 'none' },
308
328
  type: 'password',
309
- maxLength: 8,
329
+ minLength: 8,
330
+ maxLength: 64,
310
331
  value: customPin?.value ?? '',
311
332
  autoFocus: true,
312
333
  onChange: (e) => setCustomPin((c) => ({ ...c, value: e.target.value.replace(/[^a-zA-Z0-9]/g, ''), err: null })),
@@ -555,6 +576,14 @@ function PocketSettingsTab({ rpcCall, t }) {
555
576
  : null,
556
577
  ),
557
578
 
579
+ h('div', { style: styles.block },
580
+ row(
581
+ t('mobileRightbar'),
582
+ Switch(status?.mobileRightbarEnabled !== false, () => setMobileRightbar(status?.mobileRightbarEnabled === false)),
583
+ h('div', { style: { ...styles.muted, marginTop: 6 } }, t('mobileRightbarHint')),
584
+ ),
585
+ ),
586
+
558
587
  error ? h('div', { style: { color: 'var(--dsw-alias-state-error-primary,#dc2626)', fontSize: 12, marginTop: 8 } }, `❌ ${errText(error)}`) : null,
559
588
 
560
589
  // 恢复出厂设置:设置出问题时的临时兜底(最底部,避免误触)
@@ -1,5 +1,5 @@
1
- /** `mobileNav` namespace dictionaries: drawer controls. */
2
- export const NS = 'mobileNav'
1
+ /** `pocketMobileNav` namespace dictionaries: drawer controls. */
2
+ export const NS = 'pocketMobileNav'
3
3
 
4
4
  /** Simplified Chinese dictionary (the key-set source of truth). */
5
5
  export const zh = {
@@ -19,5 +19,5 @@ export const en: Record<MobileNavKey, string> = {
19
19
  'files': 'Files',
20
20
  }
21
21
 
22
- /** Key domain of the `mobileNav` namespace (zh is the source of truth). */
22
+ /** Key domain of the `pocketMobileNav` namespace (zh is the source of truth). */
23
23
  export type MobileNavKey = keyof typeof zh
@@ -5,7 +5,7 @@ import { MobileNavOverlay } from './MobileNavOverlay.tsx'
5
5
  import { MobileDrawerFooter } from './MobileDrawerFooter.tsx'
6
6
  import { startFileGuard } from './fileGuard.ts'
7
7
  import { MOBILE_CSS } from './mobile.css.ts'
8
- import { POCKET_RPC_CHANNEL, POCKET_ENDPOINTS } from '../api.js'
8
+ import { POCKET_RPC_CHANNEL, POCKET_ENDPOINTS, MOBILE_RIGHTBAR_ATTRIBUTE, MOBILE_RIGHTBAR_EVENT } from '../api.js'
9
9
  import { NS, en, zh } from './locales.ts'
10
10
  import type { MobileNavKey } from './locales.ts'
11
11
  import { resolveLayout, persistLayoutFromUrl } from './layout-mode.mjs'
@@ -13,7 +13,7 @@ import { resolveLayout, persistLayoutFromUrl } from './layout-mode.mjs'
13
13
  declare module '@deepseek-ai/dsh-client-ui-slots' {
14
14
  interface LocaleNamespaceMap {
15
15
  /** Directory-drawer controls copy. */
16
- 'mobileNav': MobileNavKey
16
+ 'pocketMobileNav': MobileNavKey
17
17
  }
18
18
  }
19
19
 
@@ -44,6 +44,34 @@ export function mobileApply(ctx): void {
44
44
 
45
45
  ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'dsh-mobile-nav: dictionaries')
46
46
 
47
+ ctx.effect(() => {
48
+ let active = true
49
+ const applyEnabled = (enabled: boolean): void => {
50
+ document.body?.setAttribute(MOBILE_RIGHTBAR_ATTRIBUTE, enabled ? 'on' : 'off')
51
+ }
52
+ const onChange = (event: Event): void => {
53
+ applyEnabled((event as CustomEvent<{ enabled?: boolean }>).detail?.enabled === true)
54
+ }
55
+ const load = async (): Promise<void> => {
56
+ try {
57
+ const result = await ctx.connection.rpc.call(POCKET_RPC_CHANNEL, POCKET_ENDPOINTS.status, {}) as {
58
+ ok?: boolean
59
+ value?: { mobileRightbarEnabled?: boolean }
60
+ }
61
+ if (active) applyEnabled(result?.ok === true ? result.value?.mobileRightbarEnabled !== false : true)
62
+ } catch {
63
+ if (active) applyEnabled(true)
64
+ }
65
+ }
66
+ window.addEventListener(MOBILE_RIGHTBAR_EVENT, onChange)
67
+ void load()
68
+ return () => {
69
+ active = false
70
+ window.removeEventListener(MOBILE_RIGHTBAR_EVENT, onChange)
71
+ document.body?.removeAttribute(MOBILE_RIGHTBAR_ATTRIBUTE)
72
+ }
73
+ }, 'dsh-mobile-nav: optional right sidebar')
74
+
47
75
  ctx.effect(() => {
48
76
  const tag = document.createElement('style')
49
77
  tag.dataset.plugin = '@dsh-external/dsh-mobile-nav'
@@ -299,6 +327,38 @@ export function mobileApply(ctx): void {
299
327
  return startFileGuard(readFile)
300
328
  }, 'dsh-mobile-nav: file open guard + copy button + hide add-workspace (issue #17)')
301
329
 
330
+ // 手机上模型 / 提供方设置加载失败:上游 dsh-web 会渲染「加载提供方目录失败 /
331
+ // Settings are unavailable in this browser」。这条文案不是 dsh-pocket 的,但手机侧
332
+ // 本就不支持改模型设置,原报错只会吓到用户。窄屏下用 MutationObserver 就地把该报错
333
+ // 文本替换成「去电脑端修改」的引导提示(仅手机,桌面端不受影响)。
334
+ ctx.effect(() => {
335
+ if (!narrow.matches) return () => {}
336
+ const PHRASES = ['加载提供方目录失败', 'Settings are unavailable in this browser']
337
+ const NOTICE = '手机上不支持模型设置,请去电脑端修改设置'
338
+ // 取包含报错文案的最深节点,避免把外层大容器整块清掉。
339
+ const findDeepest = (el: Element): Element => {
340
+ let deepest = el
341
+ for (const child of el.querySelectorAll('*')) {
342
+ if (PHRASES.some((p) => (child.textContent ?? '').includes(p))) deepest = child
343
+ }
344
+ return deepest
345
+ }
346
+ const patch = (): void => {
347
+ for (const el of document.querySelectorAll('body *')) {
348
+ const t = el.textContent ?? ''
349
+ if (!PHRASES.some((p) => t.includes(p))) continue
350
+ if ((el as HTMLElement).dataset?.dshpModelNotice === '1') continue
351
+ const target = findDeepest(el)
352
+ target.textContent = NOTICE
353
+ ;(target as HTMLElement).dataset.dshpModelNotice = '1'
354
+ }
355
+ }
356
+ const observer = new MutationObserver(patch)
357
+ observer.observe(document.body, { childList: true, subtree: true, characterData: true })
358
+ patch()
359
+ return () => observer.disconnect()
360
+ }, 'dsh-mobile-nav: replace model-settings load error with mobile hint')
361
+
302
362
 
303
363
  ctx.slots.inject('conversation.session.header.actions', () => ctx.slots.register({
304
364
  name: 'conversation.session.header.actions',
@@ -407,12 +407,8 @@ export const MOBILE_CSS = `
407
407
  }
408
408
 
409
409
  /* --- Session header on mobile ---
410
- Layout goal: [toggle] [session title] [mode badge] in a row, with the
411
- Session log capsule removed from the header (relocated to the drawer
412
- footer). Stable structural hooks only:
413
- [data-phase] header the session header element
414
- header > :first-child titleRow (titleCluster + utilities)
415
- header > :first-child > :last-child headerUtilities (Session log seat) */
410
+ Layout goal: [toggle] [session title] [mode badge] in a row. The optional
411
+ rightbar entry uses the shell's stable header-corner hook. */
416
412
  [data-phase] header {
417
413
  padding: 8px 12px 0 !important;
418
414
  }
@@ -449,11 +445,14 @@ export const MOBILE_CSS = `
449
445
  top: 12px !important;
450
446
  z-index: 2 !important;
451
447
  }
452
- /* Session log download: gone from the header row on mobile (the utilities
453
- seat holds only the session-log-export capsule). */
454
- [data-phase] header > :first-child > :last-child {
448
+ /* The native rightbar entry is visible by default. Users who prefer the
449
+ compact header can turn it off in Pocket settings. */
450
+ body[data-dsh-pocket-mobile-rightbar="off"] [data-conversation-header-corner] {
455
451
  display: none !important;
456
452
  }
453
+ body:not([data-dsh-pocket-mobile-rightbar="off"]) [data-mobile-nav="files"] {
454
+ right: 44px !important;
455
+ }
457
456
 
458
457
  /* --- Settings dialog on mobile ---
459
458
  Desktop: 800px two-column flex (188px nav + content). Mobile: a
@@ -845,9 +844,15 @@ export const MOBILE_CSS = `
845
844
  targeted). Layout: ONE fixed-height (28px) flex strip that scrolls
846
845
  horizontally — the full metrics stream stays reachable by swiping,
847
846
  the row never grows vertically, no ellipsis or fade, 12px gaps
848
- between metric groups, a 2px scrollbar as the swipe affordance. */
847
+ between metric groups, a 2px scrollbar as the swipe affordance.
848
+
849
+ Opt-out: the strip is scoped under body:not([data-pocket-stats="off"])
850
+ so a skin/operator plugin can legally reclaim this 28px band by
851
+ setting document.body.dataset.pocketStats = 'off' (avoids the
852
+ !important weight war that let two footers print on top of each
853
+ other — issue #165). Default behavior is unchanged. */
849
854
 
850
- [data-mobile-nav="stats"] {
855
+ body:not([data-pocket-stats="off"]) [data-mobile-nav="stats"] {
851
856
  display: flex !important;
852
857
  flex-flow: row nowrap !important;
853
858
  align-items: center !important;
@@ -869,17 +874,17 @@ export const MOBILE_CSS = `
869
874
  line-height: 20px !important;
870
875
  font-size: 12px !important;
871
876
  }
872
- [data-mobile-nav="stats"]::-webkit-scrollbar {
877
+ body:not([data-pocket-stats="off"]) [data-mobile-nav="stats"]::-webkit-scrollbar {
873
878
  height: 2px !important;
874
879
  }
875
- [data-mobile-nav="stats"]::-webkit-scrollbar-thumb {
880
+ body:not([data-pocket-stats="off"]) [data-mobile-nav="stats"]::-webkit-scrollbar-thumb {
876
881
  background: var(--dsw-alias-label-tertiary, rgba(0, 0, 0, .3)) !important;
877
882
  border-radius: 2px !important;
878
883
  }
879
- [data-mobile-nav="stats"]::-webkit-scrollbar-track {
884
+ body:not([data-pocket-stats="off"]) [data-mobile-nav="stats"]::-webkit-scrollbar-track {
880
885
  background: transparent !important;
881
886
  }
882
- [data-mobile-nav="stats"] > * {
887
+ body:not([data-pocket-stats="off"]) [data-mobile-nav="stats"] > * {
883
888
  display: flex !important;
884
889
  flex: 0 0 auto !important;
885
890
  flex-flow: row nowrap !important;
@@ -891,10 +896,10 @@ export const MOBILE_CSS = `
891
896
  margin-right: 12px !important;
892
897
  padding: 0 !important;
893
898
  }
894
- [data-mobile-nav="stats"] > *:last-child {
899
+ body:not([data-pocket-stats="off"]) [data-mobile-nav="stats"] > *:last-child {
895
900
  margin-right: 0 !important;
896
901
  }
897
- [data-mobile-nav="stats"] * {
902
+ body:not([data-pocket-stats="off"]) [data-mobile-nav="stats"] * {
898
903
  white-space: nowrap !important;
899
904
  }
900
905
 
@@ -37,7 +37,7 @@ export const zh = {
37
37
  'resetGo': '恢复',
38
38
  'resetIntro': '设置搞出问题时的临时兜底:清空本机配置并重设随机密码(DSH 的会话、模型、插件配置不受影响)',
39
39
  'resetTitle': '⚠️ 确认恢复出厂设置?',
40
- 'resetBody': '将清空并恢复默认:\n① 开关:局域网访问=开、访问密码=开、局域网地址=自动\n② 公网:模式回到随机域名,清空 Tunnel Token 与固定域名,并关闭正在运行的公网\n③ 密码:公网和局域网都换成新的随机 8 位密码(旧密码立即作废,手机需重新输入)\n\nDSH 自身的会话、模型、插件配置不受影响;此操作不可撤销。',
40
+ 'resetBody': '将清空并恢复默认:\n① 开关:局域网访问=开、访问密码=开、手机端右边栏=开、局域网地址=自动\n② 公网:模式回到随机域名,清空 Tunnel Token 与固定域名,并关闭正在运行的公网\n③ 密码:公网和局域网都换成新的随机 8 位密码(旧密码立即作废,手机需重新输入)\n\nDSH 自身的会话、模型、插件配置不受影响;此操作不可撤销。',
41
41
  'resetConfirm': '确认恢复',
42
42
  'resetDone': '✅ 已恢复出厂设置:设置已清空,密码已换新(手机需重新输入)',
43
43
  'resetFailed': '❌ 恢复失败,请重试',
@@ -59,13 +59,15 @@ export const zh = {
59
59
  'lanPinCustomValue': '🔐 访问密码:{pin}(自定义;手机打开需输入)',
60
60
  'refresh': '刷新',
61
61
  'customize': '自定义',
62
- 'customizing': '新密码(8 位,英文字母或数字):',
62
+ 'customizing': '新密码(8–64 位,英文字母或数字):',
63
63
  'save': '保存',
64
64
  'cancel': '取消',
65
- 'pinInvalid': '密码必须是 8 位英文字母或数字',
65
+ 'pinInvalid': '密码必须是 8–64 位英文字母或数字',
66
66
  'pinCustomHint': '自定义后开启公网不再自动换新',
67
67
  'lanPinOff': '🔓 密码已关闭:扫码直连,无需密码(仅同一局域网设备可访问;公网仍要密码)',
68
68
  'lanStarting': '代理未就绪…',
69
+ 'mobileRightbar': '手机端右边栏',
70
+ 'mobileRightbarHint': '显示原生右边栏入口;普通手机可按需关闭,折叠屏展开后使用更方便',
69
71
  'wanTitle': '🌐 公网(人在外面)',
70
72
  'wanHint': '任何网络扫码即用(URL 每次重启自动换新)',
71
73
  'wanPin': '🔐 访问密码:{pin}(每次开启公网变新;手机打开链接需输入此密码)',
@@ -135,7 +137,7 @@ export const en = {
135
137
  'resetGo': 'Reset',
136
138
  'resetIntro': 'Temporary fallback when settings break: clear local config and re-roll random PINs (DSH sessions, models and plugin config are untouched)',
137
139
  'resetTitle': '⚠️ Confirm factory reset?',
138
- 'resetBody': 'This clears and restores defaults:\n① Switches: LAN access on, access PIN on, LAN address auto\n② Public: mode back to random URL, Tunnel Token and fixed domain cleared, and any running tunnel is stopped\n③ PINs: both public and LAN become new random 8-character PINs (old ones stop working; the phone must re-enter)\n\nYour DSH sessions, models and plugin config are untouched. This cannot be undone.',
140
+ 'resetBody': 'This clears and restores defaults:\n① Switches: LAN access on, access PIN on, mobile right sidebar on, LAN address auto\n② Public: mode back to random URL, Tunnel Token and fixed domain cleared, and any running tunnel is stopped\n③ PINs: both public and LAN become new random 8-character PINs (old ones stop working; the phone must re-enter)\n\nYour DSH sessions, models and plugin config are untouched. This cannot be undone.',
139
141
  'resetConfirm': 'Reset',
140
142
  'resetDone': '✅ Factory reset done: settings cleared and PINs re-rolled (re-enter the PIN on your phone)',
141
143
  'resetFailed': '❌ Reset failed — please retry',
@@ -157,13 +159,15 @@ export const en = {
157
159
  'lanPinCustomValue': '🔐 PIN: {pin} (custom; required on the phone)',
158
160
  'refresh': 'Refresh',
159
161
  'customize': 'Customize',
160
- 'customizing': 'New PIN (8 chars, letters/digits): ',
162
+ 'customizing': 'New PIN (8–64 chars, letters/digits): ',
161
163
  'save': 'Save',
162
164
  'cancel': 'Cancel',
163
- 'pinInvalid': 'PIN must be exactly 8 characters (letters and digits only)',
165
+ 'pinInvalid': 'PIN must be 8–64 characters (letters and digits only)',
164
166
  'pinCustomHint': 'custom PINs are not rotated on tunnel start',
165
167
  'lanPinOff': '🔓 PIN off — scan & go, no PIN (LAN devices only; public still requires PIN)',
166
168
  'lanStarting': 'Proxy starting…',
169
+ 'mobileRightbar': 'Mobile right sidebar',
170
+ 'mobileRightbarHint': 'Show the native right-sidebar entry; disable it for a compact phone header or keep it on for an unfolded display',
167
171
  'wanTitle': '🌐 Anywhere (public)',
168
172
  'wanHint': 'Scan from any network (the URL changes on every restart)',
169
173
  'wanPin': '🔐 PIN: {pin} (changes each time the tunnel is enabled; required on the phone)',
package/lib/index.js CHANGED
@@ -21,7 +21,7 @@ import { createPocketService } from './service.mjs';
21
21
  import { installPocketRpc } from './web-rpc.js';
22
22
  import { restartHost } from './restart.js';
23
23
  import { advancedNoticeScript, DEFAULT_INJECT, classifyHost } from './proxy.mjs';
24
- import { lanEnabled, setLanEnabled, lanAuthEnabled, setLanAuthEnabled, lanIpOverride, setLanIpOverride, pinCustom, setPinCustom, tunnelMode, setTunnelMode, tunnelToken, setTunnelToken, tunnelHostname, setTunnelHostname, resetSettings, proxyPort, cloudflaredPath } from './settings.mjs';
24
+ import { lanEnabled, setLanEnabled, lanAuthEnabled, setLanAuthEnabled, mobileRightbarEnabled, setMobileRightbarEnabled, lanIpOverride, setLanIpOverride, pinCustom, setPinCustom, tunnelMode, setTunnelMode, tunnelToken, setTunnelToken, tunnelHostname, setTunnelHostname, resetSettings, proxyPort, cloudflaredPath } from './settings.mjs';
25
25
 
26
26
  const name = 'dsh-pocket';
27
27
  const inject = ['connection', 'webServer'];
@@ -49,8 +49,8 @@ const loadedVersion = currentVersion();
49
49
  // 局域网密码(token-lan):默认手动刷新(设置页按钮);自定义后刷新会换回随机值。
50
50
  // 会话保持(issue #33):登录 cookie 绑定进程级 sessionKey(见 apply)——
51
51
  // dsh web 重启/更新后 sessionKey 变化 → 手机需重新输入。
52
- // 密码规则:8 位英文字母(大小写)或数字(自动生成的为 8 位数字,自定义可为字母+数字)。
53
- const PIN_RE = /^[a-zA-Z0-9]{8}$/;
52
+ // 默认 PIN 为 8 位数字;自定义 PIN 可使用 8–64 位英文字母(大小写)或数字。
53
+ const PIN_RE = /^[a-zA-Z0-9]{8,64}$/;
54
54
  function writePinToFile(p, fresh) {
55
55
  try {
56
56
  mkdirSync(dirname(p), { recursive: true });
@@ -136,13 +136,13 @@ export function isLanOverrideHost(host) {
136
136
  return override.length > 0 && hostNameOnly(host) === override;
137
137
  }
138
138
  /**
139
- * 用户自定义访问密码(issue #33):公网/局域网各自设一个固定的 8 位密码(英文字母大小写或数字)。
139
+ * 用户自定义访问密码(issue #33):公网/局域网各自设置固定的 8–64 位密码(英文字母大小写或数字)。
140
140
  * 自定义后公网开启时不再自动轮换(rotateAccessToken 见上)。
141
- * 非法输入(非 8 位、含字母数字以外字符)抛错,由 RPC 层转成错误响应。
141
+ * 非法输入(少于 8 位、超过 64 位或含字母数字以外字符)抛错,由 RPC 层转成错误响应。
142
142
  */
143
143
  export function setCustomPin(which, value) {
144
144
  const v = String(value ?? '').trim();
145
- if (!PIN_RE.test(v)) throw new Error('密码必须是 8 位英文字母或数字 | PIN must be exactly 8 characters (letters and digits only)');
145
+ if (!PIN_RE.test(v)) throw new Error('密码必须是 8–64 位英文字母或数字 | PIN must be 8–64 characters (letters and digits only)');
146
146
  if (which === 'public') {
147
147
  writePinToFile(tokenPath(), v);
148
148
  setPinCustom('public', true);
@@ -287,7 +287,7 @@ export function apply(ctx, config = {}, internals = {}) {
287
287
  // 端口(issue #70):插件模式优先级 settings.proxyPort > cordis patch config.port > 3081。
288
288
  // 3081 被占时 createPocketService 内部 EADDRINUSE 抛错,按 DSH 插件启动失败处理(用户改端口重启即可)。
289
289
  port: internals.port ?? config.port ?? (proxyPort() || 3081),
290
- home: internals.home,
290
+ home: internals.home ?? process.env.DSH_HOME ?? join(homedir(), '.dsh'),
291
291
  internals,
292
292
  getLanIpOverride: () => lanIpOverride(),
293
293
  getLanEnabled: () => lanEnabled(),
@@ -357,6 +357,8 @@ export function apply(ctx, config = {}, internals = {}) {
357
357
  setLanAuthEnabled: (on) => setLanAuthEnabled(on),
358
358
  getLanEnabled: () => lanEnabled(),
359
359
  setLanEnabled: (on) => setLanEnabled(on),
360
+ getMobileRightbarEnabled: () => mobileRightbarEnabled(),
361
+ setMobileRightbarEnabled: (on) => setMobileRightbarEnabled(on),
360
362
  getLanIpOverride: () => lanIpOverride(),
361
363
  setLanIpOverride: (ip) => setLanIpOverride(ip),
362
364
  getPinCustom: (which) => pinCustom(which),
package/lib/proxy.mjs CHANGED
@@ -216,10 +216,10 @@ button{width:100%;padding:10px;font-size:15px;background:#4f6ef7;color:#fff;bord
216
216
  .err{color:#dc2626;font-size:12px;margin-bottom:10px;min-height:16px}
217
217
  </style></head><body><div class="card">
218
218
  <h1>🔐 DSH Pocket</h1>
219
- <p>${where}受访问密码保护,请输入 8 位密码(英文字母或数字) | ${whereEn} is password-protected — enter the 8-character PIN (letters/digits)</p>
219
+ <p>${where}受访问密码保护,请输入访问密码 | ${whereEn} is password-protected — enter the access PIN</p>
220
220
  <div class="err">${errMsg}</div>
221
221
  <form method="post" action="/pocket-login">
222
- <input name="token" type="password" maxlength="8" autocomplete="one-time-code" autofocus required>
222
+ <input name="token" type="password" minlength="8" maxlength="64" autocomplete="one-time-code" autofocus required>
223
223
  <button type="submit">进入 | Enter</button>
224
224
  </form>
225
225
  </div></body></html>`;
@@ -757,6 +757,18 @@ export function createPocketProxy({ port = 3081, host = '0.0.0.0', upstream = DE
757
757
  let body = '';
758
758
  req.on('data', (c) => { body += c; if (body.length > 1024) req.destroy(); });
759
759
  req.on('end', () => {
760
+ // 请求体读取期间其他并发请求可能已触发锁定,比较密码前必须再次检查。
761
+ // 否则攻击者可预先挂起大量 POST,再同时结束请求体,批量穿透限速。
762
+ const finalRl = limiter?.status(ip) ?? { locked: false, retryAfter: 0 };
763
+ if (finalRl.locked) {
764
+ res.writeHead(429, {
765
+ 'content-type': 'text/html; charset=utf-8',
766
+ 'cache-control': 'no-store',
767
+ 'retry-after': String(finalRl.retryAfter),
768
+ });
769
+ res.end(loginPageHtml('locked', isPublic, finalRl.retryAfter));
770
+ return;
771
+ }
760
772
  const submitted = String(new URLSearchParams(body).get('token') ?? '');
761
773
  // 与 cookie / ?token= 两条通道保持一致:常量时间比较 + 主 PIN 与替代令牌
762
774
  // (临时 PIN,issue #69 钩子)任一命中即放行。原先这里是 `submitted === token`
package/lib/settings.mjs CHANGED
@@ -3,6 +3,7 @@
3
3
  // 当前项:
4
4
  // - lanEnabled 局域网访问总开关(默认开启):关闭后局域网扫码/链接直接失效(代理拒绝局域网 Host)
5
5
  // - lanAuthEnabled 局域网访问密码开关(issue #24),默认开启
6
+ // - mobileRightbarEnabled 手机端右边栏入口(默认开启)
6
7
  // - publicPinCustom 公网密码是否用户自定义(issue #33),自定义后不自动轮换
7
8
  // - lanPinCustom 局域网密码是否用户自定义(issue #33)
8
9
  // - tunnelMode 公网隧道模式(issue #66):'quick'(默认,随机 trycloudflare.com)| 'named'(固定域名)
@@ -64,6 +65,19 @@ export function setLanAuthEnabled(on) {
64
65
  return s.lanAuthEnabled;
65
66
  }
66
67
 
68
+ /** 手机端右边栏入口:默认开启,可按需关闭以保持更紧凑的标题栏。 */
69
+ export function mobileRightbarEnabled() {
70
+ return readSettings().mobileRightbarEnabled !== false;
71
+ }
72
+
73
+ /** 设置手机端右边栏入口,返回新状态(持久化)。 */
74
+ export function setMobileRightbarEnabled(on) {
75
+ const s = readSettings();
76
+ s.mobileRightbarEnabled = !!on;
77
+ writeSettings(s);
78
+ return s.mobileRightbarEnabled;
79
+ }
80
+
67
81
  /** 局域网地址手动覆盖:默认空字符串 = 自动选择。 */
68
82
  export function lanIpOverride() {
69
83
  return readSettings().lanIpOverride ?? '';
@@ -83,7 +97,7 @@ export function setLanIpOverride(value) {
83
97
  }
84
98
 
85
99
  // ---------- 访问密码「自定义」标记(issue #33) ----------
86
- // 用户可把公网/局域网密码设成自己固定的 8 位密码(英文字母大小写或数字;自定义后不再自动轮换)。
100
+ // 用户可把公网/局域网密码设成自己固定的 8–64 位密码(英文字母大小写或数字;自定义后不再自动轮换)。
87
101
  // 标记存 settings.json:publicPinCustom / lanPinCustom。
88
102
  const PIN_CUSTOM_KEYS = { public: 'publicPinCustom', lan: 'lanPinCustom' };
89
103
 
@@ -106,8 +120,8 @@ export function setPinCustom(which, on) {
106
120
 
107
121
  // ---------- 恢复出厂设置 ----------
108
122
  // 设置出问题时的临时兜底:删掉 settings.json 即回到出厂默认(文件缺失 = 默认开启,
109
- // 于是局域网访问开、访问密码开、局域网地址自动、公网模式随机)。DSH 自身的会话、
110
- // 模型、插件配置都在 $DSH_HOME 的其他目录,不受影响。
123
+ // 于是局域网访问开、访问密码开、手机端右边栏开、局域网地址自动、公网模式随机)。
124
+ // DSH 自身的会话、模型、插件配置都在 $DSH_HOME 的其他目录,不受影响。
111
125
 
112
126
  /** 删除本机设置文件(不存在也算成功);返回 true 表示已清空。 */
113
127
  export function resetSettings() {
package/lib/web-rpc.js CHANGED
@@ -267,7 +267,7 @@ export function killHint(port) {
267
267
  * 优先直接挂到本插件 inject 的 webServer 上(dsh v0.1.5-alpha.1+ 兼容路径),
268
268
  * 不可用时回退 ctx.connection.rpc.handle(旧版 dsh 兼容路径)。
269
269
  * wire 协议两条路径完全一致:client-request → handler → server-response。 */
270
- export function installPocketRpc(ctx, { service, log = console, desktop = false, runUpdate = null, restart = null, restartNotice = null, getToken = null, getLanToken = null, refreshLanToken = null, getLanAuthEnabled = null, setLanAuthEnabled = null, getLanEnabled = null, setLanEnabled = null, getLanIpOverride = null, setLanIpOverride = null, getPinCustom = null, setCustomPin = null, getTunnelConfig = null, setTunnelConfig = null, resetPocket = null }) {
270
+ export function installPocketRpc(ctx, { service, log = console, desktop = false, runUpdate = null, restart = null, restartNotice = null, getToken = null, getLanToken = null, refreshLanToken = null, getLanAuthEnabled = null, setLanAuthEnabled = null, getLanEnabled = null, setLanEnabled = null, getMobileRightbarEnabled = null, setMobileRightbarEnabled = null, getLanIpOverride = null, setLanIpOverride = null, getPinCustom = null, setCustomPin = null, getTunnelConfig = null, setTunnelConfig = null, resetPocket = null }) {
271
271
  const pocketRpcHandler = async (endpoint, payload = {}, signal) => {
272
272
  if (signal?.aborted) return fail('cancelled', 'The request was cancelled.');
273
273
 
@@ -285,6 +285,7 @@ export function installPocketRpc(ctx, { service, log = console, desktop = false,
285
285
  lanToken: getLanToken?.() ?? null,
286
286
  lanAuthEnabled: getLanAuthEnabled?.() ?? true,
287
287
  lanEnabled: getLanEnabled?.() ?? true,
288
+ mobileRightbarEnabled: getMobileRightbarEnabled?.() ?? true,
288
289
  publicPinCustom: getPinCustom?.('public') ?? false,
289
290
  lanPinCustom: getPinCustom?.('lan') ?? false,
290
291
  tunnelConfig: getTunnelConfig?.() ?? { mode: 'quick', hostname: '', tokenSet: false },
@@ -310,6 +311,11 @@ export function installPocketRpc(ctx, { service, log = console, desktop = false,
310
311
  if (enabled === undefined) return fail('bad-request', '局域网访问开关不可用 | LAN access switch unavailable');
311
312
  return ok({ lanEnabled: enabled });
312
313
  }
314
+ if (endpoint === POCKET_ENDPOINTS.mobileRightbarSetEnabled) {
315
+ const enabled = setMobileRightbarEnabled?.(payload?.on === true);
316
+ if (enabled === undefined) return fail('bad-request', '手机端右边栏开关不可用 | mobile right sidebar switch unavailable');
317
+ return ok({ mobileRightbarEnabled: enabled });
318
+ }
313
319
  if (endpoint === POCKET_ENDPOINTS.lanSetOverride) {
314
320
  // 返回完整 status:前端 setStatus(await call(...)) 直接替换 status 对象,
315
321
  // 若只返回 { lanIpOverride } 会丢掉 accessToken/lanToken/tunnelUrl 等字段,
package/package.json CHANGED
@@ -24,7 +24,8 @@
24
24
  "scripts": {
25
25
  "build:client": "node client/build.mjs",
26
26
  "release": "semantic-release",
27
- "test": "node --test --test-timeout=30000 \"test/*.test.js\""
27
+ "test": "node --test --test-timeout=30000 \"test/*.test.js\"",
28
+ "test:local": "node --test --test-timeout=300000 \"test/local/*.test.js\""
28
29
  },
29
30
  "dependencies": {
30
31
  "qrcode": "^1.5.4",
@@ -80,5 +81,5 @@
80
81
  "access": "public",
81
82
  "registry": "https://registry.npmjs.org/"
82
83
  },
83
- "version": "2.10.5"
84
+ "version": "2.11.0"
84
85
  }