@feiyang666/dsh-mobile-remote 1.9.1 → 2.0.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 +37 -0
- package/README.md +3 -2
- package/README.zh.md +6 -8
- package/RELEASE_NOTES.md +23 -33
- package/cordis.patch.yml +4 -1
- package/lib/client.js +42 -5
- package/lib/external.js +97 -8
- package/lib/index.js +143 -956
- package/lib/lanproxy.js +40 -10
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,42 @@
|
|
|
1
1
|
# 更新日志 / Changelog
|
|
2
2
|
|
|
3
|
+
## v2.0.0 (2026-11-07)
|
|
4
|
+
|
|
5
|
+
> 适配 **dsh 0.2.2-rc.1**。这一版修掉两个真实故障,并把自研移动端适配全部删除。
|
|
6
|
+
|
|
7
|
+
### 修复 / Fixed
|
|
8
|
+
|
|
9
|
+
- **局域网能访问但面板显示「未开启」、没有二维码**:dsh 用 `--host <网卡IP>` 启动时只监听那块
|
|
10
|
+
网卡(`127.0.0.1` 上没有任何监听),旧版旁挂听众去绑同一个 `<网卡IP>:<端口>` 必然
|
|
11
|
+
`EADDRINUSE`,于是 `running=false`。新增 `bindInfo()` 区分两种形态:
|
|
12
|
+
`direct`(宿主自己绑网卡)直接视为「局域网已可达」,只补其余网卡并跳过宿主已占用的地址。
|
|
13
|
+
- **外网域名 502 Bad Gateway**:frpc 配置里的 `localIP = "127.0.0.1"` 指向没人监听的端口。
|
|
14
|
+
现在 `localIP` 跟随宿主真实监听地址;并新增**外网入口 30s HTTP 可达性探测**,
|
|
15
|
+
502 / 连不上会直接在设置页标红并显示 frpc 实际转发地址(进程活着不再等于「在线」)。
|
|
16
|
+
|
|
17
|
+
### 移除 / Removed(官方已自带移动端适配)
|
|
18
|
+
|
|
19
|
+
- 移动端 CSS 全部删除(composer 换行、选择器限宽、iOS 16px 防缩放、Tooltip 抑制、
|
|
20
|
+
设置页单栏、菜单水平钳制、侧栏抽屉样式)。
|
|
21
|
+
- 「上传图片」按钮与图片 intake(模拟 paste/drop)删除 —— 官方 `+ → 文件` 与粘贴/拖放已覆盖。
|
|
22
|
+
- 自研 Toast、composer 发送状态浮层、侧栏悬浮按钮 / 覆盖式抽屉、设置弹窗结构打标。
|
|
23
|
+
- 保留设备心跳脚本(设置页「已连接设备」)、`crypto.randomUUID` 内网兜底(在门禁脚本内)、
|
|
24
|
+
远程访问密码门禁。
|
|
25
|
+
|
|
26
|
+
### 变更 / Changed
|
|
27
|
+
|
|
28
|
+
- `lib/lanproxy.js`:`start(hosts, port, { upstreamHost, skipHosts })`;`status()` 增加 `upstream`。
|
|
29
|
+
- `lib/index.js`:`/status` 增加 `direct`、`lan.upstream`、`external.localHost`、`external.probe`。
|
|
30
|
+
- `lib/client.js`:直连形态开关置灰 + 「局域网直连 · 由 dsh 提供」说明卡;外网卡片展示探测结果。
|
|
31
|
+
- `lib/external.js`:`startExternal(..., localHost)`、`autoRestoreExternal(..., localHost)`;
|
|
32
|
+
导出 `finalizeFrpcConfig` 供测试。
|
|
33
|
+
|
|
34
|
+
### 测试 / Tests
|
|
35
|
+
|
|
36
|
+
- 新增 `tests/bind-aware.test.mjs`(4 项):上游可定制、`skipHosts` 不重复绑、
|
|
37
|
+
全部地址被占用时不误报失败、frpc `localIP` 跟随宿主。
|
|
38
|
+
- `tests/patch.test.mjs` 43 项断言仍全部通过。
|
|
39
|
+
|
|
3
40
|
## v1.9.1 (2026-10-24)
|
|
4
41
|
|
|
5
42
|
### 修复 / Fixed
|
package/README.md
CHANGED
|
@@ -188,8 +188,9 @@ So for users, **installing is one command** — no YAML editing, no manual file
|
|
|
188
188
|
- **Toggle**: `POST /__dsh_remote/toggle` → writes/removes a `webserver` override block in the profile's `cordis.patch.yml` → dsh's `watchUserPatches` (Cordis HMR) hot-reloads the webserver entry to re-listen.
|
|
189
189
|
- **Device count**: mobile-injected JS heartbeats every 30s; the host keeps an active-device map (90s TTL). Each heartbeat now reports full device metadata (screen / viewport / DPR / network / current page / language / platform), shown in the expandable device rows of the settings panel.
|
|
190
190
|
- **QR code**: `GET /__dsh_remote/qr?url=...` returns SVG.
|
|
191
|
-
- **
|
|
192
|
-
-
|
|
191
|
+
- **No mobile UI patches any more** (v2.0.0): `tapIndex` only injects a ~40-line device-heartbeat script (`CLIENT_JS`). dsh 0.2.2+ already ships the mobile layout (drawer sidebar below 768px, auto-collapse at 1024px, no focus stealing on mobile UA, iOS keyboard fix, PWA), so the old mobile CSS, sidebar drawer, "Upload image" button, toast and sending indicator were removed — duplicated adaptation only fights the official implementation.
|
|
192
|
+
- **`crypto.randomUUID` fallback for insecure contexts** (kept in `GATE_JS`): DSH clients call `crypto.randomUUID()` on the wire; it only exists on HTTPS or localhost, so LAN `http://` access used to fail with `settings are unavailable in this browser`. The injected head script provides an RFC 4122 v4 shim backed by `getRandomValues`.
|
|
193
|
+
- **Tunnel reachability probe** (v2.0.0): frpc being alive only means it logged into frps. Every 30s the host now issues an HTTP probe against the public URL, so a 502 (wrong local forward address/port) is surfaced in the panel together with the local address frpc actually forwards to.
|
|
193
194
|
|
|
194
195
|
---
|
|
195
196
|
|
package/README.zh.md
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
[GitHub](https://github.com/feiyang-dev/dsh-mobile-remote) · [npm](https://www.npmjs.com/package/@feiyang666/dsh-mobile-remote) · MIT License
|
|
8
8
|
|
|
9
|
-
**由开发者制作的 DeepSeek Harness 插件** —— 在 Web UI
|
|
9
|
+
**由开发者制作的 DeepSeek Harness 插件** —— 在 Web UI 设置页内置「远程控制」:连接二维码、一键开关、在线设备数,让手机通过局域网/外网访问并操控电脑上的 DeepSeek Harness(移动端界面由官方 dsh 自带,插件不再注入 UI 补丁)。
|
|
10
10
|
|
|
11
11
|

|
|
12
12
|

|
|
@@ -20,16 +20,15 @@
|
|
|
20
20
|
|
|
21
21
|
`dsh-mobile-remote` 是 DeepSeek Harness 生态的**移动端远程控制插件**(DSH plugin,Host + Client 双面一体包)。装好后在 WebUI **设置 → 远程控制** 出现一个管理面板:
|
|
22
22
|
|
|
23
|
-
-
|
|
24
|
-
- **移动端侧栏默认收起**(v1.9.0):窄屏下把侧栏轨道压到 0,内容区吃满整屏;左上角悬浮按钮点开为**覆盖式抽屉**(不挤压中栏),点遮罩 / Esc / 选中会话自动收起;
|
|
23
|
+
- **不再自带移动端 UI 补丁**(v2.0.0):官方 dsh 0.2.2 起已内置移动端适配(<768px 侧栏浮层抽屉 + 遮罩、1024px 自动塌缩、移动 UA 不抢焦点、iOS 软键盘修正、PWA),插件只保留一个设备心跳脚本;旧的移动端 CSS、侧栏抽屉、「上传图片」按钮、Toast 与发送状态浮层**全部移除**,界面完全交回官方实现;
|
|
25
24
|
- **连接二维码**:自动生成 `http://<局域网IP>:<端口>` 的二维码,手机相机 / 浏览器扫码即连;
|
|
26
25
|
- **裸地址直达,自动登录**(v1.8.0):直接访问裸地址 `http://192.168.x.x:3080`(不带任何 token)即可自动进入——服务端自动完成新版 dsh 的浏览器会话认证(`≥0.1.2-rc.1`),无需复制带 `?token=` 的长链接;老版 dsh 自动回退,行为不变;
|
|
27
|
-
- **开启 / 关闭开关**(
|
|
26
|
+
- **开启 / 关闭开关**(v2.0.0 适配两种绑定形态):dsh 只听回环时,在局域网网卡上**旁挂一个转发听众**(`<局域网IP>:<端口>` → `127.0.0.1:<端口>`);dsh 自己就用 `--host <网卡IP>` 启动时,直接以那块网卡为上游、只补其余网卡(同地址不再重复绑,避免 `EADDRINUSE` 导致「明明能访问却显示未开启」)。dsh 的监听地址与端口**完全不动**——本机窗口、桌面端、会话都不会被打断,开关**即时生效、无需重启、不写配置**;
|
|
28
27
|
- **当前连接的设备数量**:移动端心跳上报,实时统计在线设备数;
|
|
29
28
|
- **远程访问密码门禁**(v1.2.0+):管理员本机设置密码,外网隧道访问进入页面需先输密码,本机/局域网不受影响;
|
|
30
29
|
- **远程访问提速**(v1.2.0+):frpc 连接池调大,缓解外网访问首屏转圈慢;
|
|
31
30
|
- **更丰富的数据展示**(v1.4.0):设备列表可点击展开完整上报信息(系统 / 浏览器 / 屏幕 / 视口 / DPR / 设备内存 / CPU 核数 / 电池 / 网络 / 当前页面 / 首次连接 / 心跳次数);「运行时信息」卡(运行时长 / 开机时长 / Node 版本 / PID / CPU 型号与核数 / 系统负载 / 内存使用率 / RSS / 堆内存);**「DSH 状态」卡**(dsh 版本 / 会话总数 / 工作区列表 / 已安装插件 / 模型提供方,后台缓存);「网络接口」卡(网卡名 / IP / 掩码 / MAC);外网隧道详情(域名 / 端口 / frpc 版本 / PID / 运行时长 / 脱敏绑定码 / 日志查看);
|
|
32
|
-
- **外网隧道真实状态**(v1.4.1
|
|
31
|
+
- **外网隧道真实状态**(v1.4.1+ / v2.0.0 增强):面板探测 frpc 日志确认隧道真正建立,断线重连降级「连接中」,token 鉴权失败显示红色「出错」+ 精确原因(如 `token in login doesn't match`)+ 一键重试;**v2.0.0 起每 30 秒再对外网入口做一次 HTTP 探测**——frpc 进程活着只代表连上了 frps,本机转发地址/端口不对时中转侧对外只会回 502,现在这种「假在线」会直接在面板上标红,并显示 frpc 实际使用的本地转发地址;
|
|
33
32
|
- 局域网地址列表、手机访问地址、配置位置一目了然。
|
|
34
33
|
|
|
35
34
|
**核心卖点:纯命令行用户无需安装桌面端。** 官方 CLI 出于安全拒绝 `--host 0.0.0.0`;而 dsh **0.2.1-rc.3 起连 patch 层也不再接受通配地址**(`host: '0.0.0.0'` 会在配置校验阶段直接让进程退出),改绑具体网卡又会让本机回环监听消失(桌面端窗口白屏)。所以 v1.9.0 改用**旁挂 LAN 听众**:dsh 保持只听回环,插件在局域网网卡上再监听一份并转发过去——手机与电脑仍共享同一个后端进程与同一份 `~/.dsh` 数据,**会话列表、历史记录、工作区、设置全部天然双向同步,无需任何数据复制**。
|
|
@@ -185,9 +184,8 @@ dsh --profile web --patch remote-control.patch.yml
|
|
|
185
184
|
- **开关**:`POST /__dsh_remote/toggle` → 在局域网网卡上开启 / 关闭旁挂听众。请求被转发到 `127.0.0.1:<端口>`,并把 `Host` / `Origin` 改写成回环 authority(dsh 的信任围栏对回环一律放行),因此**不需要改 dsh 任何配置、也不需要重启**。
|
|
186
185
|
- **设备数**:移动端注入 JS 每 30s 心跳上报,host 维护活跃设备表(90s 过期)。每次心跳会上报完整设备元数据(屏幕 / 视口 / DPR / 网络 / 当前页面 / 语言 / 平台),设置面板的设备行可展开查看。
|
|
187
186
|
- **二维码**:`GET /__dsh_remote/qr?url=...` 返回 SVG。
|
|
188
|
-
-
|
|
189
|
-
-
|
|
190
|
-
- **移动端设置页适配**(v1.4.2+):原生设置弹窗在手机上自动变为全屏单栏——导航栏变顶部横向滚动标签、内容区占满剩余空间;同时抑制触屏上常驻的原生 Tooltip 气泡(如「停止 / 开始 / 关闭菜单栏」提示文字)。通过结构标记识别设置弹窗,**不依赖 harness 内部哈希类名**,harness 升级后无需修改插件即可直接适配。**v1.4.3** 修复该单栏布局——面板原本仍是横向 `flex`,内容列被压成 0 宽度(内容空白)且无法上下滚动;现强制 `flex-direction: column` + `min-height: 0`,设置选项区在手机上可正常滚动。并新增**内网(非安全上下文)`crypto.randomUUID` 兜底**:当前 DSH 客户端在连接时直接调用 `crypto.randomUUID()`,该 Web Crypto API 仅在 HTTPS / localhost 存在,内网 http 直连会报 `crypto.randomUUID is not a function` / `settings are unavailable in this browser`;插件注入的头部脚本用 `getRandomValues` 补一个 RFC 4122 v4 实现,内网访问 / 扫码连接恢复正常。
|
|
187
|
+
- **前端只注入心跳脚本**(v2.0.0 起):`tapIndex` 只插一个约 40 行的设备心跳脚本(`lib/index.js` 的 `CLIENT_JS`),**不再注入任何移动端 CSS / 交互补丁**——官方 dsh 0.2.2 已自带窄屏抽屉、软键盘修正等适配,重复适配只会与官方改动打架;
|
|
188
|
+
- **内网(非安全上下文)`crypto.randomUUID` 兜底**(保留,见 `GATE_JS`):当前 DSH 客户端连接时直接调用 `crypto.randomUUID()`,该 Web Crypto API 仅在 HTTPS / localhost 存在,内网 http 直连会报 `crypto.randomUUID is not a function` / `settings are unavailable in this browser`;门禁脚本用 `getRandomValues` 补一个 RFC 4122 v4 实现,内网访问 / 扫码连接正常。
|
|
191
189
|
|
|
192
190
|
---
|
|
193
191
|
|
package/RELEASE_NOTES.md
CHANGED
|
@@ -1,46 +1,36 @@
|
|
|
1
|
-
# dsh-mobile-remote
|
|
1
|
+
# dsh-mobile-remote v2.0.0
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
> 功能与 v1.9.0 完全一致,无代码变更。
|
|
3
|
+
**DeepSeek Harness 远程访问插件** —— 在 Web UI 设置页内置「远程控制」:连接二维码、一键开关、在线设备数,让手机通过局域网 / 外网访问并操控电脑上的 DeepSeek Harness。移动端界面从 v2.0.0 起**完全交给官方 dsh**(0.2.2 已自带)。
|
|
5
4
|
|
|
6
|
-
|
|
5
|
+
## 本版更新(v2.0.0)
|
|
7
6
|
|
|
8
|
-
|
|
7
|
+
一句话:**适配「dsh 自己就绑在网卡上」的启动方式,并把外网 502 从"假在线"里揪出来;同时删掉所有自研移动端 UI 补丁。**
|
|
9
8
|
|
|
10
|
-
|
|
9
|
+
### 修掉的两个真实故障
|
|
11
10
|
|
|
12
|
-
|
|
11
|
+
| 症状 | 根因 | 修复 |
|
|
12
|
+
| --- | --- | --- |
|
|
13
|
+
| 局域网明明能访问,插件却显示**未开启**、**不显示二维码** | dsh 用 `--host 192.168.x.x` 启动时只监听那块网卡,`127.0.0.1` 上没有任何监听。旧版旁挂听众去绑**同一个** `<网卡IP>:<端口>` → `EADDRINUSE` → `running=false` | 新增 `bindInfo()`:`direct` 形态直接认定「局域网已可达」,只补其余网卡并跳过宿主已占用的地址;二维码/地址列表把宿主绑定地址排第一 |
|
|
14
|
+
| 外网域名打开就是 **502 Bad Gateway**,面板却显示「在线」 | frpc 配置里的 `localIP = "127.0.0.1"` 转发到一个没人监听的端口,frps 与 nginx 侧只能回 502 | frpc 的 `localIP` 跟随宿主真实监听地址(非回环时改成网卡 IP);并新增**外网入口 30s HTTP 可达性探测**,502 直接在面板标红 |
|
|
13
15
|
|
|
14
|
-
|
|
15
|
-
| --- | --- |
|
|
16
|
-
| 开启开关后,**电脑上的窗口直接打不开/白屏** | 旧版把 `webserver.host` 改成 `0.0.0.0`。dsh 0.2.1-rc.3 起只接受具体网卡 IP,`0.0.0.0` 会让进程启动即退出;即使是具体 IP,改绑后 `127.0.0.1` 监听消失,桌面端固定连回环 → 白屏 |
|
|
17
|
-
| **官方桌面端**里开关点了完全没反应 | 旧版按 `DSH_PROFILE` 环境变量定位 profile,而官方桌面端**不设该变量**(只把 profile 目录作为 argv 传参)→ patch 写进了 `profiles/web`,实际运行的是 `profiles/desktop` |
|
|
18
|
-
| 写完 patch 后 **dsh 起不来**,报 `unknown tag !<tag:yaml.org,2002:js>` | `!!js` 在该 YAML schema 里只能作用于**标量**;`trustedHosts: !!js [...]` 会被当成「流式序列 + 标量标签」 |
|
|
19
|
-
| 端口莫名变了、二维码/链接失效 | 官方桌面端用 `--port 0` 由系统分配端口,旧 patch 里重述 `port` 会让重载后的端口漂移 |
|
|
16
|
+
### 移动端适配全部移除
|
|
20
17
|
|
|
21
|
-
|
|
18
|
+
官方 dsh 0.2.2 起自带移动端适配(`<768px` 侧栏浮层抽屉 + 遮罩、`1024px` 自动塌缩、移动 UA 不抢焦点、iOS 软键盘光标修正、PWA 清单),继续注入自己的 CSS 只会和官方改动互相打架:
|
|
22
19
|
|
|
23
|
-
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
-
|
|
27
|
-
因此局域网访问**不再需要** `trustedHosts`,也不会再出现「页面能打开、`/api` 全 403」。
|
|
28
|
-
- **patch 只写 `connection.trustedHosts`**(外网隧道经 frp 直连真实监听时才需要),
|
|
29
|
-
用 `!!js "[...ctx.webStartup.trustedHosts, '域名']"` 合并启动参数,绝不覆盖 `webserver`。
|
|
30
|
-
- **profile 路径校正**:优先 `profileContext.patchPath` —— CLI、官方桌面端、第三方壳全部适用。
|
|
31
|
-
- **旧配置自动迁移**:清理历史遗留的 `webserver` 覆盖与 `ctx.webRuntime.trustedHosts` 块,
|
|
32
|
-
迁出的域名并入受管 trustedHosts;若旧绑定正在生效,会在 HMR watcher 就绪后再改写,把监听拉回回环。
|
|
20
|
+
- 删除全部移动端 CSS(composer 换行 / 选择器限宽 / 16px 防缩放 / Tooltip 抑制 / 设置页单栏 / 菜单钳制 / 抽屉样式);
|
|
21
|
+
- 删除「上传图片」按钮与模拟 paste/drop 的图片 intake 逻辑(官方 `+ → 文件` 与粘贴/拖放链路已有);
|
|
22
|
+
- 删除自研 Toast、发送状态浮层、侧栏悬浮按钮与抽屉、设置弹窗结构打标;
|
|
23
|
+
- 保留:设备心跳脚本(设置页「已连接设备」需要)、`crypto.randomUUID` 内网兜底(在门禁脚本里)、远程访问密码门禁。
|
|
33
24
|
|
|
34
|
-
###
|
|
25
|
+
### 其它
|
|
35
26
|
|
|
36
|
-
-
|
|
37
|
-
|
|
38
|
-
-
|
|
39
|
-
|
|
40
|
-
- 端口变化时局域网听众 15s 内自适应重绑;`remoteEnabled` 持久化,重启自动恢复。
|
|
27
|
+
- `lib/lanproxy.js`:`start(hosts, port, { upstreamHost, skipHosts })`,转发上游可定制、已占用地址自动跳过;`status()` 增加 `upstream`。
|
|
28
|
+
- `lib/index.js`:`/status` 新增 `direct`(宿主直连形态)与 `lan.upstream`;`toggle` 在直连形态返回 `direct: true` 并说明需要改启动参数才能关闭;`external` 新增 `localHost` / `probe`。
|
|
29
|
+
- `lib/client.js`:直连形态下开关置灰并显示「局域网直连 · 由 dsh 提供」说明卡;外网卡片展示入口探测结果。
|
|
30
|
+
- 版本号提升到 **2.0.0**(移除移动端 UI 属于行为变更)。
|
|
41
31
|
|
|
42
32
|
### 验证
|
|
43
33
|
|
|
44
|
-
- `tests/patch.test.mjs`:43
|
|
45
|
-
-
|
|
46
|
-
|
|
34
|
+
- `node --test tests/patch.test.mjs`:43 项断言通过。
|
|
35
|
+
- 新增 `tests/bind-aware.test.mjs`:4 项断言通过(上游可定制、`skipHosts` 不重复绑、全部被占用时不误报失败、frpc `localIP` 跟随宿主)。
|
|
36
|
+
- 实测环境:Windows + dsh 0.2.2(`--host 192.168.3.119 --port 3080`)。修复前:`127.0.0.1:3080` = `ECONNREFUSED`,`/status` 返回 `remoteEnabled:false`,公网域名 nginx 502;修复后 `direct` 形态下 `remoteEnabled:true`、二维码正常,frpc `localIP` 改为宿主网卡地址。
|
package/cordis.patch.yml
CHANGED
|
@@ -8,7 +8,10 @@
|
|
|
8
8
|
#
|
|
9
9
|
# 插件是 host + client 双面插件:
|
|
10
10
|
# - host 侧(lib/index.js,inject [webServer]):
|
|
11
|
-
# *
|
|
11
|
+
# * 局域网:宿主只绑回环时旁挂 LAN 听众;宿主用 --host <网卡IP> 启动时直接复用它;
|
|
12
|
+
# * 外网:经中转服务器 + frpc 隧道,并对公网入口做 30s 可达性探测(暴露 502);
|
|
13
|
+
# * 通过 ctx.webServer.tapIndex 仅注入一个设备心跳脚本(不含任何移动端 UI 补丁,
|
|
14
|
+
# 官方 dsh 0.2.2 起自带移动端适配);
|
|
12
15
|
# * 通过 ctx.webServer.register 提供 /__dsh_remote/* API
|
|
13
16
|
# (status / toggle / heartbeat / qr),支持设置页远程控制开关、二维码、设备数。
|
|
14
17
|
# - client 侧(lib/client.js,package.json 的 dsh.client 声明):
|
package/lib/client.js
CHANGED
|
@@ -332,21 +332,26 @@ window.__ModuleLoader__.load({
|
|
|
332
332
|
|
|
333
333
|
// 局域网状态徽标
|
|
334
334
|
var on = !!(status && status.remoteEnabled);
|
|
335
|
+
// dsh 自己就用 `--host <网卡IP>` 绑在网卡上(官方桌面端/第三方壳的启动方式):
|
|
336
|
+
// 这时局域网直连由 dsh 提供,插件的旁挂听众既不需要也无法绑同一地址。
|
|
337
|
+
var direct = !!(status && status.direct && status.direct.enabled);
|
|
335
338
|
var badge = el('span', { style: Object.assign({}, st.badge, on ? st.badgeOn : st.badgeOff) },
|
|
336
|
-
on ? '已开启' : '未开启');
|
|
339
|
+
on ? (direct ? '已开启(直连)' : '已开启') : '未开启');
|
|
337
340
|
|
|
338
|
-
//
|
|
341
|
+
// 局域网开关(直连形态下不可切换:那份监听属于 dsh 进程本身)
|
|
339
342
|
var sw = el('button', {
|
|
340
343
|
type: 'button',
|
|
341
344
|
role: 'switch',
|
|
342
345
|
'aria-checked': String(on),
|
|
343
|
-
'aria-label': on ? '关闭远程控制' : '开启远程控制',
|
|
346
|
+
'aria-label': direct ? '局域网直连由 dsh 启动参数提供' : (on ? '关闭远程控制' : '开启远程控制'),
|
|
344
347
|
onClick: toggle,
|
|
345
|
-
disabled: busy || !status,
|
|
346
|
-
|
|
348
|
+
disabled: busy || !status || direct,
|
|
349
|
+
title: direct ? 'dsh 以 --host 启动了网卡监听,插件无法关闭;请修改启动参数' : null,
|
|
350
|
+
style: Object.assign({}, st.switch, on ? st.switchOn : null, direct ? { opacity: .65, cursor: 'default' } : null),
|
|
347
351
|
}, el('span', { style: Object.assign({}, st.knob, on ? st.knobOn : null) }));
|
|
348
352
|
|
|
349
353
|
// 局域网二维码(裸地址:插件已在服务端接管 / 自动完成认证,无需带 token)
|
|
354
|
+
// lanAddresses 由 host 排序:直连形态下宿主自己的绑定地址排第一。
|
|
350
355
|
var bareLanUrl = (status && status.lanAddresses && status.lanAddresses.length && status.port)
|
|
351
356
|
? ('http://' + status.lanAddresses[0] + ':' + status.port)
|
|
352
357
|
: (status && status.url || null);
|
|
@@ -519,6 +524,20 @@ window.__ModuleLoader__.load({
|
|
|
519
524
|
: (extError ? '外网隧道异常,请查看上方错误信息' : '开启外网访问后,这里显示外网二维码'));
|
|
520
525
|
}
|
|
521
526
|
|
|
527
|
+
// 外网入口真实可达性探测:frpc「在线」只代表连上了 frps,
|
|
528
|
+
// 本地服务不可达时中转侧对外一律 502 —— 必须把这一层单独暴露出来。
|
|
529
|
+
var extProbe = status && status.external ? status.external.probe : null;
|
|
530
|
+
var extProbeEl = null;
|
|
531
|
+
if (extStarted && extProbe) {
|
|
532
|
+
var probeBad = extProbe.ok !== true;
|
|
533
|
+
extProbeEl = el('p', { style: probeBad ? Object.assign({}, st.hint, { color: '#c0272b' }) : st.hint },
|
|
534
|
+
probeBad
|
|
535
|
+
? '外网入口探测失败:' + (extProbe.error || ('HTTP ' + extProbe.status)) +
|
|
536
|
+
'(frpc 本地转发 ' + ((status.external && status.external.localHost) || '127.0.0.1') +
|
|
537
|
+
':' + (status.port || '') + ')'
|
|
538
|
+
: '外网入口可达(HTTP ' + extProbe.status + ';每 30 秒自动复检)');
|
|
539
|
+
}
|
|
540
|
+
|
|
522
541
|
// 说明卡片(未开启时展示新版工作原理)
|
|
523
542
|
var cliHint = null;
|
|
524
543
|
if (status && !status.remoteEnabled) {
|
|
@@ -534,6 +553,20 @@ window.__ModuleLoader__.load({
|
|
|
534
553
|
);
|
|
535
554
|
}
|
|
536
555
|
|
|
556
|
+
// 直连说明(dsh 用 --host <网卡IP> 启动时)
|
|
557
|
+
var directHint = null;
|
|
558
|
+
if (status && direct) {
|
|
559
|
+
directHint = el('div', { style: st.card },
|
|
560
|
+
el('div', { style: st.row },
|
|
561
|
+
el('p', { style: st.title }, '局域网直连'),
|
|
562
|
+
el('span', { style: Object.assign({}, st.badge, st.badgeOn) }, '由 dsh 提供')),
|
|
563
|
+
el('p', { style: st.sub },
|
|
564
|
+
'dsh 启动时带了 --host ' + ((status.direct && status.direct.host) || status.bindHost || '') +
|
|
565
|
+
',本身就监听在这块网卡上,手机可以直接连接,不需要插件再旁挂听众。'),
|
|
566
|
+
el('p', { style: st.hint },
|
|
567
|
+
'因此这里的开关不可用:要彻底关闭局域网访问,需去掉 dsh 启动参数里的 --host(或改成 127.0.0.1)后重启。'));
|
|
568
|
+
}
|
|
569
|
+
|
|
537
570
|
// ---- 远程访问密码管理 ----
|
|
538
571
|
var authOn = !!(status && status.auth && status.auth.enabled);
|
|
539
572
|
var authRequiredHere = !!(status && status.auth && status.auth.requiredHere);
|
|
@@ -1010,6 +1043,8 @@ window.__ModuleLoader__.load({
|
|
|
1010
1043
|
|
|
1011
1044
|
extQr,
|
|
1012
1045
|
|
|
1046
|
+
extProbeEl,
|
|
1047
|
+
|
|
1013
1048
|
extErrText
|
|
1014
1049
|
? el('div', { style: Object.assign({}, st.card, { borderColor: 'rgba(200,40,40,.35)' }) },
|
|
1015
1050
|
el('p', { style: Object.assign({}, st.hint, { color: '#c0272b' }) }, '外网访问失败:' + extErrText),
|
|
@@ -1021,6 +1056,8 @@ window.__ModuleLoader__.load({
|
|
|
1021
1056
|
el('button', { type: 'button', onClick: refresh, style: st.btn }, '刷新状态')))
|
|
1022
1057
|
: null),
|
|
1023
1058
|
|
|
1059
|
+
directHint,
|
|
1060
|
+
|
|
1024
1061
|
cliHint,
|
|
1025
1062
|
|
|
1026
1063
|
error
|
package/lib/external.js
CHANGED
|
@@ -124,9 +124,10 @@ const POOL_COUNT_RE = /^\s*(transport\.)?poolCount\s*=.*(\r?\n|$)/gm
|
|
|
124
124
|
* 连接复用 tcpMux 自动管理;服务端可能仍在下发该字段,本地一律剥离,
|
|
125
125
|
* 否则 frpc 解析配置失败、启动即退出)。
|
|
126
126
|
* @param {string} cfg - 后端返回的原始 frpc 配置
|
|
127
|
+
* @param {string} [localHost] - frpc 应当转发到的本地地址(宿主真实监听地址)
|
|
127
128
|
* @returns {string} 修正后的配置
|
|
128
129
|
*/
|
|
129
|
-
function finalizeFrpcConfig(cfg) {
|
|
130
|
+
function finalizeFrpcConfig(cfg, localHost) {
|
|
130
131
|
let text = String(cfg || '')
|
|
131
132
|
// 3) 先剥离 client 端已不支持的 poolCount 字段(放最前,保证后续注入干净)
|
|
132
133
|
text = text.replace(POOL_COUNT_RE, '')
|
|
@@ -140,7 +141,19 @@ function finalizeFrpcConfig(cfg) {
|
|
|
140
141
|
}
|
|
141
142
|
text = text.replace(/^(\s*serverAddr\s*=\s*)[^\n]+/m, `$1"${override.trim()}"`)
|
|
142
143
|
}
|
|
143
|
-
// 2)
|
|
144
|
+
// 2.5) localIP 覆写:宿主如果只绑了某块网卡(官方桌面端/第三方壳的 `--host 192.168.x.x`),
|
|
145
|
+
// 127.0.0.1 上没有任何监听,frpc 转发过去就是 connection refused →
|
|
146
|
+
// 中转服务器/nginx 对外只能是 502 Bad Gateway。这里把代理的 localIP 改成宿主真实地址。
|
|
147
|
+
const target = String(localHost || '127.0.0.1').trim() || '127.0.0.1'
|
|
148
|
+
if (target !== '127.0.0.1') {
|
|
149
|
+
if (/^\s*localIP\s*=/m.test(text)) {
|
|
150
|
+
text = text.replace(/^(\s*localIP\s*=\s*)[^\n]*/gm, `$1"${target}"`)
|
|
151
|
+
} else {
|
|
152
|
+
// 没有 localIP 行时补在每条 localPort 之前(与 frp 配置顺序无关,读者更直观)
|
|
153
|
+
text = text.replace(/^(\s*localPort\s*=\s*)/gm, `localIP = "${target}"\n$1`)
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
// 3) loginFailExit 必须放顶层(第一个 [[proxies]] 之前),否则 frpc 解析报错
|
|
144
157
|
if (!/loginFailExit\s*=/.test(text)) {
|
|
145
158
|
const injectLine = '# 由 dsh-mobile-remote 注入:连不上 frps 时持续重试不退出\nloginFailExit = false\n'
|
|
146
159
|
const proxiesIdx = text.indexOf('[[proxies]]')
|
|
@@ -351,7 +364,7 @@ async function ensureFrpcBinary(base) {
|
|
|
351
364
|
* 启动外网隧道。做了稳健性处理,避免"反复点/开了又关"。
|
|
352
365
|
* @returns {Promise<{ok:boolean, url?:string, status?:string, error?:string, message?:string, log?:string}>}
|
|
353
366
|
*/
|
|
354
|
-
async function startExternal(state, body, patchFile, defaultLocalPort) {
|
|
367
|
+
async function startExternal(state, body, patchFile, defaultLocalPort, localHost) {
|
|
355
368
|
// 已运行则直接返回,避免重复点重复启动;
|
|
356
369
|
// 但状态为 error(如鉴权失败)时允许重试:kill 旧进程并重新绑定。
|
|
357
370
|
if (state.proc && state.proc.exitCode === null && state.status !== 'error') {
|
|
@@ -363,8 +376,9 @@ async function startExternal(state, body, patchFile, defaultLocalPort) {
|
|
|
363
376
|
|
|
364
377
|
state.status = 'connecting'
|
|
365
378
|
state.error = null
|
|
379
|
+
state.localHost = String(localHost || '127.0.0.1')
|
|
366
380
|
try {
|
|
367
|
-
return await startExternalInner(state, body, patchFile, defaultLocalPort)
|
|
381
|
+
return await startExternalInner(state, body, patchFile, defaultLocalPort, state.localHost)
|
|
368
382
|
} catch (error) {
|
|
369
383
|
// 启动失败必须复位状态,否则前端会一直卡在"连接中"且无法关闭
|
|
370
384
|
state.status = 'error'
|
|
@@ -373,7 +387,7 @@ async function startExternal(state, body, patchFile, defaultLocalPort) {
|
|
|
373
387
|
}
|
|
374
388
|
}
|
|
375
389
|
|
|
376
|
-
async function startExternalInner(state, body, patchFile, defaultLocalPort) {
|
|
390
|
+
async function startExternalInner(state, body, patchFile, defaultLocalPort, localHost) {
|
|
377
391
|
// 端口优先级:调用方显式指定 > 当前真实监听端口。
|
|
378
392
|
// 不再让过期的持久化 localPort 覆盖当前端口(否则 frpc 会转发到无人监听的端口)。
|
|
379
393
|
const localPort = Number((body && body.localPort) || defaultLocalPort || 0)
|
|
@@ -401,7 +415,7 @@ async function startExternalInner(state, body, patchFile, defaultLocalPort) {
|
|
|
401
415
|
|
|
402
416
|
// 2) 写 frpc.toml(含 loginFailExit=false 与回环地址覆写)
|
|
403
417
|
const cfgPath = frpcConfigPath()
|
|
404
|
-
const frpcConfig = finalizeFrpcConfig(data.frpcConfig)
|
|
418
|
+
const frpcConfig = finalizeFrpcConfig(data.frpcConfig, localHost)
|
|
405
419
|
// 防线:确认代理确实指向当前监听端口。错配时 frpc 会连不上本地服务,
|
|
406
420
|
// 表现为外网域名 502 Bad Gateway(日志:connect to local service ... refused)。
|
|
407
421
|
if (localPort) {
|
|
@@ -489,6 +503,9 @@ async function startExternalInner(state, body, patchFile, defaultLocalPort) {
|
|
|
489
503
|
state.wsClient.connect()
|
|
490
504
|
// 隧道确认建立后启动持续心跳,让后端 last_seen 保持新鲜(管理端在线判定依赖它)
|
|
491
505
|
startKeepAlive(state)
|
|
506
|
+
// 同时开始探测外网入口的真实可达性:frpc 进程活着并不代表 nginx/中转侧能拿到
|
|
507
|
+
// 200 —— 本地服务没起来(端口/绑定地址不对)时对外就是 502,必须自己暴露出来。
|
|
508
|
+
startUrlProbe(state)
|
|
492
509
|
}
|
|
493
510
|
|
|
494
511
|
const probe = () => {
|
|
@@ -575,6 +592,7 @@ async function startExternalInner(state, body, patchFile, defaultLocalPort) {
|
|
|
575
592
|
*/
|
|
576
593
|
function stopExternal(state, opts = {}) {
|
|
577
594
|
stopKeepAlive(state)
|
|
595
|
+
stopUrlProbe(state)
|
|
578
596
|
// 断开 WS 长连接(中转服务端据此判定离线)
|
|
579
597
|
if (state.wsClient) {
|
|
580
598
|
try { state.wsClient.disconnect() } catch { /* ignore */ }
|
|
@@ -601,7 +619,7 @@ function stopExternal(state, opts = {}) {
|
|
|
601
619
|
* 重新绑定并拉起 frpc,免去每次手动输入验证码与中转服务地址。
|
|
602
620
|
* 失败时把状态置为 error 并保留持久化,供用户手动处理或下次重启重试。
|
|
603
621
|
*/
|
|
604
|
-
async function autoRestoreExternal(state, patchFile, defaultLocalPort) {
|
|
622
|
+
async function autoRestoreExternal(state, patchFile, defaultLocalPort, localHost) {
|
|
605
623
|
const saved = loadPersistedState()
|
|
606
624
|
if (!saved) return
|
|
607
625
|
try {
|
|
@@ -620,7 +638,7 @@ async function autoRestoreExternal(state, patchFile, defaultLocalPort) {
|
|
|
620
638
|
if (stale && live && stale !== live) {
|
|
621
639
|
state.lastLog = `检测到端口变化(持久化 ${stale} → 当前 ${live}),外网隧道已改用当前端口`
|
|
622
640
|
}
|
|
623
|
-
await startExternal(state, body, patchFile, live || stale)
|
|
641
|
+
await startExternal(state, body, patchFile, live || stale, localHost)
|
|
624
642
|
} catch (e) {
|
|
625
643
|
state.status = 'error'
|
|
626
644
|
state.error = `自动恢复外网访问失败:${String((e && e.message) || e)}`
|
|
@@ -709,6 +727,71 @@ function probeTunnelHealth(state) {
|
|
|
709
727
|
return verdict
|
|
710
728
|
}
|
|
711
729
|
|
|
730
|
+
/* ------------------------------------------------------------------ */
|
|
731
|
+
/* 外网入口可达性探测 */
|
|
732
|
+
/* ------------------------------------------------------------------ */
|
|
733
|
+
|
|
734
|
+
/** 探测结果缓存:同一 URL 30 秒内不重复请求(status 每 5s 轮询一次)。 */
|
|
735
|
+
let urlProbeCache = { url: '', at: 0, value: null }
|
|
736
|
+
|
|
737
|
+
/**
|
|
738
|
+
* 用 HTTP GET 探测外网入口的真实状态。
|
|
739
|
+
* frpc 进程活着只代表「已经连上 frps」,不代表中转服务器能把请求送到本机:
|
|
740
|
+
* 本机端口没人监听 / 绑定地址不对时,nginx 对外一律回 502 —— 这里把它探测出来。
|
|
741
|
+
* @param {string} url - 外网访问地址(https://xxx)
|
|
742
|
+
* @returns {Promise<{ok: boolean, status: number, at: number, error?: string}|null>}
|
|
743
|
+
*/
|
|
744
|
+
async function probeExternalUrl(url) {
|
|
745
|
+
if (!url) return null
|
|
746
|
+
const now = Date.now()
|
|
747
|
+
if (urlProbeCache.url === url && now - urlProbeCache.at < 30_000) return urlProbeCache.value
|
|
748
|
+
let value
|
|
749
|
+
try {
|
|
750
|
+
const res = await fetch(url, {
|
|
751
|
+
method: 'GET',
|
|
752
|
+
redirect: 'follow',
|
|
753
|
+
headers: { 'user-agent': 'dsh-mobile-remote/healthcheck' },
|
|
754
|
+
signal: AbortSignal.timeout(8000),
|
|
755
|
+
})
|
|
756
|
+
// 只用状态码判断可达性,正文直接丢弃(避免把整个前端页面读进内存)
|
|
757
|
+
try { await res.arrayBuffer() } catch { /* ignore */ }
|
|
758
|
+
value = {
|
|
759
|
+
ok: res.status >= 200 && res.status < 400,
|
|
760
|
+
status: res.status,
|
|
761
|
+
at: Date.now(),
|
|
762
|
+
error: res.status === 502
|
|
763
|
+
? '中转服务器返回 502:frps 已连上,但本机端口没有响应(转发地址/端口不对,或 dsh 未监听该地址)'
|
|
764
|
+
: null,
|
|
765
|
+
}
|
|
766
|
+
} catch (error) {
|
|
767
|
+
value = { ok: false, status: 0, at: Date.now(), error: String((error && error.message) || error) }
|
|
768
|
+
}
|
|
769
|
+
urlProbeCache = { url, at: now, value }
|
|
770
|
+
return value
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
/** 启动外网入口可达性轮询(30s 一次,随隧道停止而停止)。 */
|
|
774
|
+
function startUrlProbe(state) {
|
|
775
|
+
if (state.probeTimer) return
|
|
776
|
+
const tick = async () => {
|
|
777
|
+
if (!state.url) return
|
|
778
|
+
const value = await probeExternalUrl(state.url)
|
|
779
|
+
if (value) state.probe = value
|
|
780
|
+
}
|
|
781
|
+
void tick()
|
|
782
|
+
state.probeTimer = setInterval(() => { void tick() }, 30_000)
|
|
783
|
+
if (state.probeTimer.unref) state.probeTimer.unref()
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
/** 停止外网入口探测并清空缓存结果。 */
|
|
787
|
+
function stopUrlProbe(state) {
|
|
788
|
+
if (state.probeTimer) {
|
|
789
|
+
clearInterval(state.probeTimer)
|
|
790
|
+
state.probeTimer = null
|
|
791
|
+
}
|
|
792
|
+
state.probe = null
|
|
793
|
+
}
|
|
794
|
+
|
|
712
795
|
/** 当前外网状态摘要(供 /status 使用)。 */
|
|
713
796
|
function externalView(state) {
|
|
714
797
|
const running = !!state.proc && state.proc.exitCode === null
|
|
@@ -737,6 +820,10 @@ function externalView(state) {
|
|
|
737
820
|
pid: running && state.proc ? state.proc.pid : null,
|
|
738
821
|
startedAt: state.startedAt || null,
|
|
739
822
|
lastLog: state.lastLog || null,
|
|
823
|
+
// frpc 实际转发的本地地址(非回环时是宿主的网卡 IP)
|
|
824
|
+
localHost: state.localHost || null,
|
|
825
|
+
// 外网入口真实可达性:{ ok, status, at, error }
|
|
826
|
+
probe: state.probe || null,
|
|
740
827
|
error,
|
|
741
828
|
}
|
|
742
829
|
}
|
|
@@ -746,6 +833,8 @@ export {
|
|
|
746
833
|
externalDir,
|
|
747
834
|
frpcBinaryPath,
|
|
748
835
|
frpcConfigPath,
|
|
836
|
+
// 供单元测试直接断言配置改写(localIP / loginFailExit / poolCount)
|
|
837
|
+
finalizeFrpcConfig,
|
|
749
838
|
startExternal,
|
|
750
839
|
stopExternal,
|
|
751
840
|
externalView,
|