@feiyang666/dsh-mobile-remote 1.6.1 → 1.7.1-1
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 +22 -0
- package/RELEASE_NOTES.md +12 -14
- package/lib/client.js +54 -3
- package/lib/external.js +31 -7
- package/lib/index.js +40 -5
- package/lib/ws-client.js +152 -0
- package/package.json +6 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,27 @@
|
|
|
1
1
|
# 更新日志 / Changelog
|
|
2
2
|
|
|
3
|
+
## v1.7.1-1 (2026-08-29) — 预发布(beta,供前端测试)
|
|
4
|
+
|
|
5
|
+
### 新增 / New
|
|
6
|
+
- **外网访问面板显示数据通道状态**(`lib/index.js` + `lib/client.js`):`/status` 新增 `external.ws` 字段(`available` = ws 依赖是否可用 / `connected` = WS 长连接是否建立 / `source` = 当前数据来源 `ws` | `http` | `none` / `lastPushAt` = 最后推送时刻),设置页「外网访问」卡片新增「数据通道」行徽标,让用户一眼看清当前是 **WS 实时推送**(绿色)、**HTTP 兜底(退让模式)**(未安装 ws 依赖)、**WS 已连接等待推送**、**WS 已断开 / 重连中**(含最后推送时间)中的哪一种。
|
|
7
|
+
- **运行时长本地每秒刷新**(`lib/client.js`):`runtimeInfo()` 增加采集时刻 `at`,前端新增 `LiveDuration` 组件——基于「采样秒数 + 采集时刻」在浏览器本地每秒累加,让「运行时长 / 开机时长 / 隧道已运行」等秒级数字每秒跳动(秒表效果),不再依赖 5s 的 `/status` 轮询刷新(此前数字每 5s 才跳一次且跳幅不连续)。
|
|
8
|
+
|
|
9
|
+
### 变更 / Changed
|
|
10
|
+
- 无 API 破坏;旧版 host / 新版 client 混用时,缺少 `at` / `ws` 字段会自动降级为原行为(数字按轮询刷新、不显示数据通道行)。
|
|
11
|
+
|
|
12
|
+
## v1.7.0 (2026-08-28)
|
|
13
|
+
|
|
14
|
+
### 新增 / New
|
|
15
|
+
- **中转服务器 WS 状态推送客户端**(`lib/ws-client.js` + `lib/external.js`):外网隧道开启后,插件端改为通过 **WebSocket 长连接**(`/api/tunnel/ws?bindCode=…`)接收中转服务器的隧道状态实时推送,**替代原来每 5s 的 HTTP 轮询** `/api/tunnel/stats`,中转服务器请求压力降为 0(服务器资源集中用于 frp 隧道中转)。
|
|
16
|
+
- 指数退避自动重连(2s → 4s → … → 60s 封顶),断线自动恢复;
|
|
17
|
+
- `ws` 依赖缺失(如离线安装未带依赖)时自动降级:WS 客户端 no-op,心跳与状态查询回退 HTTP 低频兜底,**功能不中断**;
|
|
18
|
+
- 停止外网 / 插件卸载时主动断开,不再自动重连。
|
|
19
|
+
- **心跳优先经 WS 长连接发送**(`lib/external.js` `sendHeartbeat`):WS 已连上时心跳直接经长连接送达(零 HTTP 请求),未连上才回退 HTTP `/api/tunnel/heartbeat`。
|
|
20
|
+
- **状态查询 60s 节流缓存**(`lib/index.js` `queryServerStatus`):即使移动端前端每 5s 轮询本机 `/status`,也不会高频打中转服务器;前端 `/status` 优先读 WS 推送缓存,仅在从未收到推送(ws 缺失 / WS 未建立)时才回退 HTTP 低频查询。
|
|
21
|
+
|
|
22
|
+
### 变更 / Changed
|
|
23
|
+
- 新增运行时依赖 `ws`(`^8.18.0`);`npm run check` / `prepublishOnly` 增加 `lib/ws-client.js` 语法检查。
|
|
24
|
+
|
|
3
25
|
## v1.6.1 (2026-08-23)
|
|
4
26
|
|
|
5
27
|
### 修复 / Fixed
|
package/RELEASE_NOTES.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# dsh-mobile-remote v1.
|
|
1
|
+
# dsh-mobile-remote v1.7.0
|
|
2
2
|
|
|
3
3
|
**DeepSeek Harness 移动端远程控制插件** —— 在 Web UI 设置页内置「远程控制」:连接二维码、一键开关、在线设备数,让手机通过局域网访问并操控电脑上的 DeepSeek Harness。移动端提供上传图片入口、底部抽屉式模型选择、发送状态提示等移动优先体验。
|
|
4
4
|
|
|
@@ -10,25 +10,23 @@ dsh plugin --profile web add @feiyang666/dsh-mobile-remote
|
|
|
10
10
|
|
|
11
11
|
安装完成后**重启 dsh web 服务**,打开 `http://127.0.0.1:3080` → **设置 → 远程控制** 即可使用。
|
|
12
12
|
|
|
13
|
-
## 本版更新(v1.
|
|
13
|
+
## 本版更新(v1.7.0)
|
|
14
14
|
|
|
15
|
-
###
|
|
15
|
+
### 🆕 中转服务器 WS 状态推送(长连接替代轮询)
|
|
16
16
|
|
|
17
|
-
|
|
17
|
+
外网隧道开启后,插件端改为通过 **WebSocket 长连接**(`/api/tunnel/ws?bindCode=…`)接收中转服务器的隧道状态实时推送,**替代原来每 5s 的 HTTP 轮询** `/api/tunnel/stats`,中转服务器请求压力降为 0(服务器资源集中用于 frp 隧道中转)。
|
|
18
18
|
|
|
19
|
-
|
|
19
|
+
- **指数退避自动重连**:断线后 2s → 4s → … → 60s 封顶自动恢复;
|
|
20
|
+
- **心跳优先走 WS**:WS 已连上时心跳经长连接送达(零 HTTP 请求),未连上才回退 HTTP 兜底;
|
|
21
|
+
- **状态查询 60s 节流**:即使移动端每 5s 轮询本机 `/status`,也不会高频打中转服务器;前端优先读 WS 推送缓存;
|
|
22
|
+
- **ws 依赖缺失自动降级**:`ws` 未安装时 WS 客户端 no-op,心跳与状态查询回退 HTTP 低频兜底,**功能不中断**。
|
|
20
23
|
|
|
21
|
-
|
|
22
|
-
- ≤540px 时对带 `collapse-mobile` 的宽表隐藏第 4 列至倒数第 2 列,只保留前 3 个主标识列与最后合计列,关键数据一屏看全;
|
|
23
|
-
- 价格表等窄表(无 `collapse-mobile`)不折叠,保留横向滚动。
|
|
24
|
+
> ⚠️ 本版新增运行时依赖 `ws`(`^8.18.0`)。通过 `dsh plugin --profile web add` 安装会自动带上;若使用离线 tgz 手动安装,请确保 `ws` 已安装(缺失时插件自动降级,不会崩溃)。
|
|
24
25
|
|
|
25
|
-
|
|
26
|
+
## 历史更新(v1.6.x)
|
|
26
27
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
### 🆕 SSE 实时推送端点
|
|
30
|
-
|
|
31
|
-
新增 `GET /__dsh_remote/events` 事件流端点(受远程密码门禁保护),设备心跳上报 / 远程开关切换 / 外网隧道启动停止等状态变化时向订阅者推送 `data: {"type":"changed","at":<时间戳>}`。桌面端数据中心等客户端订阅该流即可**即时**感知远程设备与隧道状态变化,无需轮询。无订阅者时零开销,插件独立使用不受影响。
|
|
28
|
+
- **v1.6.1**:用量插件等宽表移动端兜底(强制 720px + ≤540px 折叠中间列)
|
|
29
|
+
- **v1.6.0**:新增 `GET /__dsh_remote/events` SSE 实时推送端点(设备心跳 / 远程开关 / 外网隧道状态变化即时推送)
|
|
32
30
|
|
|
33
31
|
## 已知事项
|
|
34
32
|
|
package/lib/client.js
CHANGED
|
@@ -225,6 +225,23 @@ window.__ModuleLoader__.load({
|
|
|
225
225
|
return ss + ' 秒';
|
|
226
226
|
}
|
|
227
227
|
|
|
228
|
+
// 实时时长组件:基于「一次采样的秒数 + 采集时刻」在浏览器本地每秒累加刷新,
|
|
229
|
+
// 让"运行时长 / 开机时长 / 隧道已运行"等秒级数字每秒跳动(秒表效果),
|
|
230
|
+
// 不再依赖 5s 的 /status HTTP 轮询(那会导致数字每 5s 才跳一次,且跳幅不连续)。
|
|
231
|
+
// 只要 host 在采集时带上 at 时间戳,两次 /status 之间也能精确走到下一秒。
|
|
232
|
+
function LiveDuration(props) {
|
|
233
|
+
var _n = useState(Date.now());
|
|
234
|
+
var now = _n[0];
|
|
235
|
+
var setNow = _n[1];
|
|
236
|
+
useEffect(function () {
|
|
237
|
+
var t = setInterval(function () { setNow(Date.now()); }, 1000);
|
|
238
|
+
return function () { clearInterval(t); };
|
|
239
|
+
}, []);
|
|
240
|
+
var at = props.at || now;
|
|
241
|
+
var sec = props.baseSec + Math.max(0, Math.floor((now - at) / 1000));
|
|
242
|
+
return fmtDuration(sec);
|
|
243
|
+
}
|
|
244
|
+
|
|
228
245
|
function fmtBytes(b) {
|
|
229
246
|
if (b === null || b === undefined) return '—';
|
|
230
247
|
var n = Number(b);
|
|
@@ -405,6 +422,32 @@ window.__ModuleLoader__.load({
|
|
|
405
422
|
// 后端探测到的错误(如鉴权失败 / token 不匹配),优先于请求时的临时错误
|
|
406
423
|
var extErrText = extErr || (extError && status && status.external && status.external.error ? status.external.error : null);
|
|
407
424
|
|
|
425
|
+
// ---- WS 状态推送通道(host 连接中转服务器的数据通道)----
|
|
426
|
+
// 让用户明确看到当前外网状态数据是经 WS 长连接实时推送,还是退让为 HTTP 低频轮询。
|
|
427
|
+
var wsInfo = status && status.external && status.external.ws ? status.external.ws : null;
|
|
428
|
+
var wsBadge = null;
|
|
429
|
+
var wsHint = null;
|
|
430
|
+
if (extStarted && wsInfo) {
|
|
431
|
+
if (wsInfo.connected && wsInfo.source === 'ws') {
|
|
432
|
+
wsBadge = el('span', { style: Object.assign({}, st.badge, st.badgeOn) }, 'WS 实时推送');
|
|
433
|
+
wsHint = '隧道状态经 WebSocket 长连接实时更新(零 HTTP 轮询)';
|
|
434
|
+
} else if (wsInfo.available === false) {
|
|
435
|
+
// 退让版本:ws 依赖缺失(如离线安装),WS 客户端 no-op,走 HTTP 低频兜底
|
|
436
|
+
wsBadge = el('span', { style: Object.assign({}, st.badge, st.badgeBusy) }, 'HTTP 兜底(退让模式)');
|
|
437
|
+
wsHint = '未安装 ws 依赖,已自动退让为 HTTP 低频查询(60s 节流),功能不受影响';
|
|
438
|
+
} else if (wsInfo.connected) {
|
|
439
|
+
wsBadge = el('span', { style: Object.assign({}, st.badge, st.badgeBusy) }, 'WS 已连接,等待推送');
|
|
440
|
+
wsHint = 'WS 长连接已建立,收到首条推送后自动切换为实时模式';
|
|
441
|
+
} else if (wsInfo.source === 'ws') {
|
|
442
|
+
wsBadge = el('span', { style: Object.assign({}, st.badge, st.badgeBusy) }, 'WS 已断开');
|
|
443
|
+
wsHint = 'WS 长连接已断开,正在自动重连;当前显示最近一次推送的缓存数据' +
|
|
444
|
+
(wsInfo.lastPushAt ? '(最后推送 ' + fmtAgo(wsInfo.lastPushAt) + ')' : '');
|
|
445
|
+
} else {
|
|
446
|
+
wsBadge = el('span', { style: Object.assign({}, st.badge, st.badgeBusy) }, 'WS 未连接');
|
|
447
|
+
wsHint = 'WS 长连接尚未建立(连接中 / 自动重连),期间数据走 HTTP 兜底';
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
408
451
|
function externalStart() {
|
|
409
452
|
if (extBusy || extConnecting) return;
|
|
410
453
|
if (!bindCode || !bindCode.trim()) {
|
|
@@ -730,8 +773,8 @@ window.__ModuleLoader__.load({
|
|
|
730
773
|
el('span', { style: Object.assign({}, st.badge, st.badgeOff) },
|
|
731
774
|
'PID ' + status.runtime.pid)),
|
|
732
775
|
el('div', { style: { display: 'flex', flexDirection: 'column', gap: 4 } }, kvList([
|
|
733
|
-
['运行时长',
|
|
734
|
-
['开机时长',
|
|
776
|
+
['运行时长', el(LiveDuration, { baseSec: status.runtime.uptime || 0, at: status.runtime.at })],
|
|
777
|
+
['开机时长', el(LiveDuration, { baseSec: status.runtime.sysUptime || 0, at: status.runtime.at })],
|
|
735
778
|
['Node.js', status.runtime.nodeVersion],
|
|
736
779
|
['主机名', status.runtime.hostname],
|
|
737
780
|
['系统', (status.runtime.platform || '—') + ' ' + (status.runtime.release || '') + ' (' + (status.runtime.arch || '—') + ')'],
|
|
@@ -857,8 +900,16 @@ window.__ModuleLoader__.load({
|
|
|
857
900
|
el('span', { style: st.meta },
|
|
858
901
|
'frpc 进程:' + (status.external.pid ? 'PID ' + status.external.pid : '—')),
|
|
859
902
|
status.external.startedAt
|
|
860
|
-
? el('span', { style: st.meta }, '已运行 '
|
|
903
|
+
? el('span', { style: st.meta }, '已运行 ',
|
|
904
|
+
el(LiveDuration, { baseSec: 0, at: status.external.startedAt }))
|
|
861
905
|
: null),
|
|
906
|
+
wsBadge
|
|
907
|
+
? el('div', { style: st.row },
|
|
908
|
+
el('span', { style: st.meta }, '数据通道'),
|
|
909
|
+
el('div', { style: { display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 2 } },
|
|
910
|
+
wsBadge,
|
|
911
|
+
wsHint ? el('span', { style: st.hint }, wsHint) : null))
|
|
912
|
+
: null,
|
|
862
913
|
el('div', { style: st.row },
|
|
863
914
|
el('span', { style: st.meta },
|
|
864
915
|
'绑定码:' + (status.external.bindCodeShort || '—')),
|
package/lib/external.js
CHANGED
|
@@ -6,7 +6,8 @@
|
|
|
6
6
|
// - 写 profile 的 connection trustedHosts,放行外网域名(通过 /api 信任围栏)
|
|
7
7
|
// - 心跳上报、状态查询、停止
|
|
8
8
|
//
|
|
9
|
-
//
|
|
9
|
+
// 运行时依赖:Node 内置模块 + `ws`(WebSocket 长连接接收中转服务状态推送,
|
|
10
|
+
// 替代高频轮询;ws 依赖缺失时自动回退 HTTP 低频查询)。frpc 二进制由中转服务提供或用户本地放置。
|
|
10
11
|
//
|
|
11
12
|
// 稳健性说明(针对"开启难/开了又关"问题):
|
|
12
13
|
// - 强制给 frpc.toml 追加 loginFailExit=false:连不上 frps 时持续重试,
|
|
@@ -22,6 +23,7 @@ import {
|
|
|
22
23
|
existsSync, readFileSync, writeFileSync, mkdirSync, openSync, closeSync, rmSync,
|
|
23
24
|
} from 'node:fs'
|
|
24
25
|
import { spawn } from 'node:child_process'
|
|
26
|
+
import { createWsStatusClient } from './ws-client.js'
|
|
25
27
|
|
|
26
28
|
/** 默认中转服务地址(自建 dsh-update-server,Nginx 反代 443→3200,对外无端口)。
|
|
27
29
|
* 可用 DSH_REMOTE_SERVER 覆盖。 */
|
|
@@ -480,6 +482,19 @@ async function startExternalInner(state, body, patchFile, defaultLocalPort) {
|
|
|
480
482
|
state.serverBase = base
|
|
481
483
|
state.localPort = Number((body && body.localPort) || defaultLocalPort)
|
|
482
484
|
savePersistedState(state)
|
|
485
|
+
// 建立 WS 长连接接收隧道状态推送(替代每 5s HTTP 轮询中转服务器);
|
|
486
|
+
// ws 依赖缺失(离线安装)时 wsClient.connect() 自动 no-op,心跳走 HTTP 兜底
|
|
487
|
+
state.serverStatus = null
|
|
488
|
+
if (state.wsClient) {
|
|
489
|
+
try { state.wsClient.disconnect() } catch { /* ignore */ }
|
|
490
|
+
}
|
|
491
|
+
state.wsClient = createWsStatusClient({
|
|
492
|
+
serverBase: base,
|
|
493
|
+
bindCode: state.bindCode,
|
|
494
|
+
onStatus: (data) => { state.serverStatus = data },
|
|
495
|
+
logger: null,
|
|
496
|
+
})
|
|
497
|
+
state.wsClient.connect()
|
|
483
498
|
// 隧道确认建立后启动持续心跳,让后端 last_seen 保持新鲜(管理端在线判定依赖它)
|
|
484
499
|
startKeepAlive(state)
|
|
485
500
|
}
|
|
@@ -568,6 +583,12 @@ async function startExternalInner(state, body, patchFile, defaultLocalPort) {
|
|
|
568
583
|
*/
|
|
569
584
|
function stopExternal(state, opts = {}) {
|
|
570
585
|
stopKeepAlive(state)
|
|
586
|
+
// 断开 WS 长连接(中转服务端据此判定离线)
|
|
587
|
+
if (state.wsClient) {
|
|
588
|
+
try { state.wsClient.disconnect() } catch { /* ignore */ }
|
|
589
|
+
state.wsClient = null
|
|
590
|
+
}
|
|
591
|
+
state.serverStatus = null
|
|
571
592
|
// 停止前上报离线,让管理端/成员端立即反映(后端支持 status 字段才生效)
|
|
572
593
|
if (!opts.keepState) {
|
|
573
594
|
state.status = 'offline'
|
|
@@ -612,20 +633,23 @@ function frpcLogTail(bytes = 6000) {
|
|
|
612
633
|
/** 持续心跳间隔(毫秒):后端据此维持 last_seen,并做离线超时判定。 */
|
|
613
634
|
const HEARTBEAT_INTERVAL = 60_000
|
|
614
635
|
|
|
615
|
-
/** 上报一次在线/离线心跳(尽力而为,失败静默)。
|
|
636
|
+
/** 上报一次在线/离线心跳(尽力而为,失败静默)。
|
|
637
|
+
* 优先经 WS 长连接发送(零 HTTP 请求);WS 不可用/未连上时回退 HTTP。 */
|
|
616
638
|
async function sendHeartbeat(state) {
|
|
617
639
|
const base = state.serverBase || process.env.DSH_REMOTE_SERVER || DEFAULT_SERVER_BASE
|
|
618
640
|
const bindCode = state.bindCode
|
|
619
641
|
if (!bindCode) return
|
|
642
|
+
const status = state.status === 'online' ? 'online' : 'offline'
|
|
643
|
+
// WS 已连接 → 直接经长连接发送,减轻中转服务器 HTTP 压力
|
|
644
|
+
if (state.wsClient && state.wsClient.isOpen() && state.wsClient.sendHeartbeat(status)) {
|
|
645
|
+
return
|
|
646
|
+
}
|
|
647
|
+
// WS 不可用 / 未连上 → HTTP 心跳兜底(旧后端 / ws 依赖缺失场景)
|
|
620
648
|
try {
|
|
621
649
|
await fetch(`${base.replace(/\/$/, '')}/api/tunnel/heartbeat`, {
|
|
622
650
|
method: 'POST',
|
|
623
651
|
headers: { 'Content-Type': 'application/json' },
|
|
624
|
-
body: JSON.stringify({
|
|
625
|
-
bindCode,
|
|
626
|
-
// offline 表示隧道已停止,让管理端立即标记离线(后端支持时才生效)
|
|
627
|
-
status: state.status === 'online' ? 'online' : 'offline',
|
|
628
|
-
}),
|
|
652
|
+
body: JSON.stringify({ bindCode, status }),
|
|
629
653
|
})
|
|
630
654
|
} catch { /* ignore */ }
|
|
631
655
|
}
|
package/lib/index.js
CHANGED
|
@@ -43,6 +43,7 @@ import {
|
|
|
43
43
|
hasPassword, verifyPassword, setPassword,
|
|
44
44
|
isEnabled, login, verifyToken, revokeToken, clearSessions,
|
|
45
45
|
} from './password.js'
|
|
46
|
+
import { wsAvailable } from './ws-client.js'
|
|
46
47
|
|
|
47
48
|
/** 插件名(loader 诊断用)。 */
|
|
48
49
|
export const name = 'dsh-mobile-remote'
|
|
@@ -214,11 +215,14 @@ function getLanInterfaces() {
|
|
|
214
215
|
|
|
215
216
|
/** 当前进程运行时信息(诊断用,设置页展示)。 */
|
|
216
217
|
function runtimeInfo() {
|
|
218
|
+
// 采集时刻:前端可据此在两次 /status 轮询之间本地累加,让"运行时长"每秒跳动。
|
|
219
|
+
const at = Date.now()
|
|
217
220
|
const mem = process.memoryUsage()
|
|
218
221
|
const cpu = cpus()
|
|
219
222
|
const load = loadavg()
|
|
220
223
|
const total = totalmem()
|
|
221
224
|
return {
|
|
225
|
+
at,
|
|
222
226
|
pid: process.pid,
|
|
223
227
|
hostname: hostname(),
|
|
224
228
|
platform: platform(),
|
|
@@ -602,11 +606,18 @@ self.addEventListener('fetch', function (event) {
|
|
|
602
606
|
|
|
603
607
|
/**
|
|
604
608
|
* 从中转服务器查询本成员隧道状态与统计;失败时返回 null(不影响整体 status)。
|
|
609
|
+
* 仅作 WS 推送不可用(ws 依赖缺失 / 旧后端)时的 HTTP 兜底。
|
|
605
610
|
* 优先调用 /api/tunnel/stats(含 frps 实时流量/连接数等),接口不可用则回退到
|
|
606
|
-
* /api/tunnel/status
|
|
611
|
+
* /api/tunnel/status(旧版后端)。
|
|
612
|
+
* 注意:带 60s 节流缓存——即使前端每 5s 轮询本机 /status,也不会高频打中转服务器。
|
|
607
613
|
*/
|
|
614
|
+
let serverStatusCache = { at: 0, value: null }
|
|
608
615
|
async function queryServerStatus(base, bindCode) {
|
|
609
616
|
if (!bindCode) return null
|
|
617
|
+
const now = Date.now()
|
|
618
|
+
if (serverStatusCache.value && now - serverStatusCache.at < 60000) {
|
|
619
|
+
return serverStatusCache.value
|
|
620
|
+
}
|
|
610
621
|
const server = String(base || DEFAULT_SERVER_BASE).replace(/\/$/, '')
|
|
611
622
|
const call = (path) => fetch(`${server}${path}`, {
|
|
612
623
|
headers: { Accept: 'application/json' },
|
|
@@ -619,8 +630,9 @@ async function queryServerStatus(base, bindCode) {
|
|
|
619
630
|
}).catch(() => null)
|
|
620
631
|
const q = `?bindCode=${encodeURIComponent(bindCode)}`
|
|
621
632
|
const stats = await call(`/api/tunnel/stats${q}`)
|
|
622
|
-
|
|
623
|
-
|
|
633
|
+
const data = stats || await call(`/api/tunnel/status${q}`)
|
|
634
|
+
serverStatusCache = { at: now, value: data }
|
|
635
|
+
return data
|
|
624
636
|
}
|
|
625
637
|
|
|
626
638
|
/** 当前 profile 的 cordis.patch.yml 绝对路径。 */
|
|
@@ -730,11 +742,21 @@ function handleStatus(ctx, devices, external, tracker, dshCache) {
|
|
|
730
742
|
})
|
|
731
743
|
}
|
|
732
744
|
deviceList.sort((a, b) => b.lastSeen - a.lastSeen)
|
|
733
|
-
//
|
|
745
|
+
// 外网隧道在线时,优先读 WS 推送缓存(经长连接实时更新,零中转服务器请求);
|
|
746
|
+
// 仅当从未收到推送(ws 依赖缺失 / WS 未建立)时才回退 HTTP 低频查询(60s 节流)。
|
|
734
747
|
let server = null
|
|
748
|
+
let serverSource = 'none'
|
|
735
749
|
if (extRunning && persisted) {
|
|
736
|
-
|
|
750
|
+
if (external.serverStatus) {
|
|
751
|
+
server = external.serverStatus
|
|
752
|
+
serverSource = 'ws'
|
|
753
|
+
} else {
|
|
754
|
+
server = await queryServerStatus(persisted.serverBase || undefined, persisted.bindCode)
|
|
755
|
+
serverSource = 'http'
|
|
756
|
+
}
|
|
737
757
|
}
|
|
758
|
+
// WS 长连接是否已建立(供前端判断当前处于"实时推送"还是"退让/重连")
|
|
759
|
+
const wsConnected = !!(external.wsClient && external.wsClient.isOpen())
|
|
738
760
|
json(res, 200, {
|
|
739
761
|
host: ctx.webServer.host,
|
|
740
762
|
port: ctx.webServer.port,
|
|
@@ -776,6 +798,19 @@ function handleStatus(ctx, devices, external, tracker, dshCache) {
|
|
|
776
798
|
: null,
|
|
777
799
|
// 中转服务器返回的成员状态(外网在线时才有)
|
|
778
800
|
server,
|
|
801
|
+
// WS 状态推送通道信息:让前端明确当前数据是 WS 实时推送还是 HTTP 兜底退让
|
|
802
|
+
ws: {
|
|
803
|
+
// ws 依赖是否可用(离线安装未带依赖时为 false,自动退让 HTTP)
|
|
804
|
+
available: wsAvailable(),
|
|
805
|
+
// WS 长连接是否已建立
|
|
806
|
+
connected: wsConnected,
|
|
807
|
+
// 当前 server 数据来源:'ws'(实时推送缓存)/ 'http'(兜底查询)/ 'none'
|
|
808
|
+
source: serverSource,
|
|
809
|
+
// 最近一次收到 WS 推送的时刻(未收到过为 null)
|
|
810
|
+
lastPushAt: external.serverStatus && external.serverStatus._at
|
|
811
|
+
? external.serverStatus._at
|
|
812
|
+
: null,
|
|
813
|
+
},
|
|
779
814
|
},
|
|
780
815
|
// 远程访问密码门禁状态(供设置面板展示)
|
|
781
816
|
auth: {
|
package/lib/ws-client.js
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
// dsh-mobile-remote — 中转服务器 WS 状态推送客户端(host 侧)。
|
|
2
|
+
//
|
|
3
|
+
// 目标:用 WebSocket 长连接接收中转服务器的隧道状态推送,替代插件端
|
|
4
|
+
// 每 5s 轮询 /api/tunnel/stats,把中转服务器的请求压力降为 0。
|
|
5
|
+
//
|
|
6
|
+
// 说明:
|
|
7
|
+
// - 本插件运行在 Electron 内置 Node(当前 20.x,无原生全局 WebSocket),
|
|
8
|
+
// 因此需要 `ws` 第三方库。若依赖缺失(如离线 tgz 安装未装依赖),
|
|
9
|
+
// wsAvailable() 返回 false,调用方自动回退 HTTP 低频查询,功能不中断。
|
|
10
|
+
// - 连接管理:指数退避自动重连(2s→4s→…→60s);心跳由 external.js 的
|
|
11
|
+
// keepAliveTimer 统一驱动(每 60s),WS 已连上时走 WS、否则走 HTTP。
|
|
12
|
+
// - 收到 tunnel:status 后更新本地缓存并回调 onStatus,前端 /status 直接读缓存。
|
|
13
|
+
|
|
14
|
+
import { createRequire } from 'node:module'
|
|
15
|
+
|
|
16
|
+
const require = createRequire(import.meta.url)
|
|
17
|
+
|
|
18
|
+
/** 尝试加载 ws 库(Node 20 无原生 WebSocket,需要它)。 */
|
|
19
|
+
let WsCtor = null
|
|
20
|
+
try {
|
|
21
|
+
WsCtor = require('ws')
|
|
22
|
+
} catch {
|
|
23
|
+
WsCtor = null
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** 当前环境是否可用 WebSocket(ws 依赖是否加载成功)。 */
|
|
27
|
+
export function wsAvailable() {
|
|
28
|
+
return !!WsCtor
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* 创建成员端 WS 状态推送客户端。
|
|
33
|
+
* @param {object} opts
|
|
34
|
+
* @param {string} opts.serverBase 中转服务基地址(https://… 或 http://…)
|
|
35
|
+
* @param {string} opts.bindCode 绑定码
|
|
36
|
+
* @param {(data: object) => void} [opts.onStatus] 收到 tunnel:status 时的回调
|
|
37
|
+
* @param {(msg: string, ...args: unknown[]) => void} [opts.logger] 日志(默认静默)
|
|
38
|
+
*/
|
|
39
|
+
export function createWsStatusClient({ serverBase, bindCode, onStatus, logger }) {
|
|
40
|
+
let ws = null
|
|
41
|
+
let manualClose = false
|
|
42
|
+
let retryTimer = null
|
|
43
|
+
let retryDelay = 2000
|
|
44
|
+
let lastStatus = null
|
|
45
|
+
const log = logger || (() => {})
|
|
46
|
+
|
|
47
|
+
function wsUrl() {
|
|
48
|
+
// http:// → ws://,https:// → wss://
|
|
49
|
+
const base = String(serverBase || '').replace(/^http/, 'ws').replace(/\/+$/, '')
|
|
50
|
+
return `${base}/api/tunnel/ws?bindCode=${encodeURIComponent(String(bindCode || ''))}`
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function isOpen() {
|
|
54
|
+
return !!(ws && ws.readyState === ws.OPEN)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function scheduleReconnect() {
|
|
58
|
+
if (manualClose || retryTimer) return
|
|
59
|
+
retryTimer = setTimeout(() => {
|
|
60
|
+
retryTimer = null
|
|
61
|
+
connect()
|
|
62
|
+
}, retryDelay)
|
|
63
|
+
if (retryTimer.unref) retryTimer.unref()
|
|
64
|
+
retryDelay = Math.min(retryDelay * 2, 60000)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function connect() {
|
|
68
|
+
if (!WsCtor) return
|
|
69
|
+
if (manualClose) return
|
|
70
|
+
if (ws && (ws.readyState === ws.OPEN || ws.readyState === ws.CONNECTING)) return
|
|
71
|
+
let client
|
|
72
|
+
try {
|
|
73
|
+
client = new WsCtor(wsUrl())
|
|
74
|
+
} catch (e) {
|
|
75
|
+
log('[ws-client] 连接失败:', String((e && e.message) || e))
|
|
76
|
+
scheduleReconnect()
|
|
77
|
+
return
|
|
78
|
+
}
|
|
79
|
+
ws = client
|
|
80
|
+
|
|
81
|
+
client.on('open', () => {
|
|
82
|
+
retryDelay = 2000
|
|
83
|
+
log('[ws-client] 已连接(bindCode=****)')
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
client.on('message', (buf) => {
|
|
87
|
+
try {
|
|
88
|
+
const msg = JSON.parse(buf.toString())
|
|
89
|
+
if (msg && msg.type === 'tunnel:status' && msg.data) {
|
|
90
|
+
msg.data._at = Date.now() // 缓存新鲜度标记
|
|
91
|
+
lastStatus = msg.data
|
|
92
|
+
try { onStatus && onStatus(msg.data) } catch (e) { /* ignore */ }
|
|
93
|
+
}
|
|
94
|
+
} catch (e) { /* ignore */ }
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
client.on('close', (code, reason) => {
|
|
98
|
+
log('[ws-client] 连接关闭 code=' + code, String(reason || ''))
|
|
99
|
+
ws = null
|
|
100
|
+
if (!manualClose) scheduleReconnect()
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
client.on('error', (err) => {
|
|
104
|
+
log('[ws-client] 错误:', String((err && err.message) || err))
|
|
105
|
+
try { client.terminate() } catch (e) { /* ignore */ }
|
|
106
|
+
})
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** 主动断开(停止外网 / 插件卸载时调用),之后不再自动重连。 */
|
|
110
|
+
function disconnect() {
|
|
111
|
+
manualClose = true
|
|
112
|
+
if (retryTimer) { clearTimeout(retryTimer); retryTimer = null }
|
|
113
|
+
if (ws) {
|
|
114
|
+
try { ws.close() } catch (e) { /* ignore */ }
|
|
115
|
+
try { ws.terminate() } catch (e) { /* ignore */ }
|
|
116
|
+
ws = null
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** 重置并重连(绑定码 / 中转地址变化后使用)。 */
|
|
121
|
+
function reconnect() {
|
|
122
|
+
manualClose = false
|
|
123
|
+
if (retryTimer) { clearTimeout(retryTimer); retryTimer = null }
|
|
124
|
+
retryDelay = 2000
|
|
125
|
+
if (ws) {
|
|
126
|
+
try { ws.terminate() } catch (e) { /* ignore */ }
|
|
127
|
+
ws = null
|
|
128
|
+
}
|
|
129
|
+
connect()
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* 经 WS 发送心跳(成功返回 true;未连接返回 false,调用方回退 HTTP)。
|
|
134
|
+
* @param {'online'|'offline'} [status]
|
|
135
|
+
*/
|
|
136
|
+
function sendHeartbeat(status) {
|
|
137
|
+
if (!isOpen()) return false
|
|
138
|
+
try {
|
|
139
|
+
ws.send(JSON.stringify({ type: 'heartbeat', status: status || 'online' }))
|
|
140
|
+
return true
|
|
141
|
+
} catch (e) {
|
|
142
|
+
return false
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** 最近一次收到的隧道状态(未收到过为 null)。 */
|
|
147
|
+
function getLastStatus() {
|
|
148
|
+
return lastStatus
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return { connect, disconnect, reconnect, isOpen, sendHeartbeat, getLastStatus }
|
|
152
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@feiyang666/dsh-mobile-remote",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.7.1-1",
|
|
4
4
|
"description": "DeepSeek Harness mobile remote-control plugin: LAN/external remote control (QR / on-off toggle / remote-access password gate), rich telemetry (expandable device details, system CPU/memory/load, DSH app state: version/sessions/workspaces/plugins/models), real external-tunnel status detection (frpc token / reconnect diagnostics), mobile-first UI polish (composer image upload entry via system picker, settings single-column layout) and faster remote loading.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"deepseek",
|
|
@@ -40,6 +40,9 @@
|
|
|
40
40
|
"engines": {
|
|
41
41
|
"node": ">=18"
|
|
42
42
|
},
|
|
43
|
+
"dependencies": {
|
|
44
|
+
"ws": "^8.18.0"
|
|
45
|
+
},
|
|
43
46
|
"dsh": {
|
|
44
47
|
"bundle": {
|
|
45
48
|
"patch": "./cordis.patch.yml"
|
|
@@ -62,8 +65,8 @@
|
|
|
62
65
|
],
|
|
63
66
|
"scripts": {
|
|
64
67
|
"pack:list": "npm pack --dry-run",
|
|
65
|
-
"check": "node --check lib/index.js && node --check lib/client.js && node --check lib/external.js && node --check lib/password.js && node --check lib/qrcode.js",
|
|
66
|
-
"prepublishOnly": "node --check lib/index.js && node --check lib/client.js && node --check lib/external.js && node --check lib/password.js && node --check lib/qrcode.js",
|
|
68
|
+
"check": "node --check lib/index.js && node --check lib/client.js && node --check lib/external.js && node --check lib/ws-client.js && node --check lib/password.js && node --check lib/qrcode.js",
|
|
69
|
+
"prepublishOnly": "node --check lib/index.js && node --check lib/client.js && node --check lib/external.js && node --check lib/ws-client.js && node --check lib/password.js && node --check lib/qrcode.js",
|
|
67
70
|
"pack": "npm pack"
|
|
68
71
|
},
|
|
69
72
|
"peerDependencies": {
|