@feiyang666/dsh-mobile-remote 1.6.1 → 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,18 @@
1
1
  # 更新日志 / Changelog
2
2
 
3
+ ## v1.7.0 (2026-08-28)
4
+
5
+ ### 新增 / New
6
+ - **中转服务器 WS 状态推送客户端**(`lib/ws-client.js` + `lib/external.js`):外网隧道开启后,插件端改为通过 **WebSocket 长连接**(`/api/tunnel/ws?bindCode=…`)接收中转服务器的隧道状态实时推送,**替代原来每 5s 的 HTTP 轮询** `/api/tunnel/stats`,中转服务器请求压力降为 0(服务器资源集中用于 frp 隧道中转)。
7
+ - 指数退避自动重连(2s → 4s → … → 60s 封顶),断线自动恢复;
8
+ - `ws` 依赖缺失(如离线安装未带依赖)时自动降级:WS 客户端 no-op,心跳与状态查询回退 HTTP 低频兜底,**功能不中断**;
9
+ - 停止外网 / 插件卸载时主动断开,不再自动重连。
10
+ - **心跳优先经 WS 长连接发送**(`lib/external.js` `sendHeartbeat`):WS 已连上时心跳直接经长连接送达(零 HTTP 请求),未连上才回退 HTTP `/api/tunnel/heartbeat`。
11
+ - **状态查询 60s 节流缓存**(`lib/index.js` `queryServerStatus`):即使移动端前端每 5s 轮询本机 `/status`,也不会高频打中转服务器;前端 `/status` 优先读 WS 推送缓存,仅在从未收到推送(ws 缺失 / WS 未建立)时才回退 HTTP 低频查询。
12
+
13
+ ### 变更 / Changed
14
+ - 新增运行时依赖 `ws`(`^8.18.0`);`npm run check` / `prepublishOnly` 增加 `lib/ws-client.js` 语法检查。
15
+
3
16
  ## v1.6.1 (2026-08-23)
4
17
 
5
18
  ### 修复 / Fixed
package/RELEASE_NOTES.md CHANGED
@@ -1,4 +1,4 @@
1
- # dsh-mobile-remote v1.6.1
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.6.1)
13
+ ## 本版更新(v1.7.0)
14
14
 
15
- ### 🐛 用量插件等宽表在移动端被压成逐字竖排(可靠兜底)
15
+ ### 🆕 中转服务器 WS 状态推送(长连接替代轮询)
16
16
 
17
- `dsh-usage-plugin` 的「消耗表 / 消耗明细 / 每日统计」等 9–11 列宽表依赖其运行时注入的全局样式(`.dsh-usage-table{min-width:720px}` + ≤540px 折叠中间列)来避免列被压到极限宽度;若该样式因 CSP / 加载时序未生效,手机窄屏下每列会被压到约 1 字符宽、内容逐字竖排、无法阅读。
17
+ 外网隧道开启后,插件端改为通过 **WebSocket 长连接**(`/api/tunnel/ws?bindCode=…`)接收中转服务器的隧道状态实时推送,**替代原来每 5s 的 HTTP 轮询** `/api/tunnel/stats`,中转服务器请求压力降为 0(服务器资源集中用于 frp 隧道中转)。
18
18
 
19
- 本插件(dsh-mobile-remote)的样式是**随 index.html 一起由服务端下发、必定生效**,故在 `<style id="dsh-mobile-remote-css">` 中新增兜底:
19
+ - **指数退避自动重连**:断线后 2s → 4s → … → 60s 封顶自动恢复;
20
+ - **心跳优先走 WS**:WS 已连上时心跳经长连接送达(零 HTTP 请求),未连上才回退 HTTP 兜底;
21
+ - **状态查询 60s 节流**:即使移动端每 5s 轮询本机 `/status`,也不会高频打中转服务器;前端优先读 WS 推送缓存;
22
+ - **ws 依赖缺失自动降级**:`ws` 未安装时 WS 客户端 no-op,心跳与状态查询回退 HTTP 低频兜底,**功能不中断**。
20
23
 
21
- - `html[data-dsh-mobile] .dsh-usage-table { min-width: 720px !important; }`,宽表强制保持 720px 宽;
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
- ## 历史更新(v1.6.0)
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/external.js CHANGED
@@ -6,7 +6,8 @@
6
6
  // - 写 profile 的 connection trustedHosts,放行外网域名(通过 /api 信任围栏)
7
7
  // - 心跳上报、状态查询、停止
8
8
  //
9
- // 零运行时依赖:仅 Node 内置模块。frpc 二进制由中转服务提供或用户本地放置。
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
@@ -602,11 +602,18 @@ self.addEventListener('fetch', function (event) {
602
602
 
603
603
  /**
604
604
  * 从中转服务器查询本成员隧道状态与统计;失败时返回 null(不影响整体 status)。
605
+ * 仅作 WS 推送不可用(ws 依赖缺失 / 旧后端)时的 HTTP 兜底。
605
606
  * 优先调用 /api/tunnel/stats(含 frps 实时流量/连接数等),接口不可用则回退到
606
- * /api/tunnel/status(旧版后端)。任一失败返回 null。
607
+ * /api/tunnel/status(旧版后端)。
608
+ * 注意:带 60s 节流缓存——即使前端每 5s 轮询本机 /status,也不会高频打中转服务器。
607
609
  */
610
+ let serverStatusCache = { at: 0, value: null }
608
611
  async function queryServerStatus(base, bindCode) {
609
612
  if (!bindCode) return null
613
+ const now = Date.now()
614
+ if (serverStatusCache.value && now - serverStatusCache.at < 60000) {
615
+ return serverStatusCache.value
616
+ }
610
617
  const server = String(base || DEFAULT_SERVER_BASE).replace(/\/$/, '')
611
618
  const call = (path) => fetch(`${server}${path}`, {
612
619
  headers: { Accept: 'application/json' },
@@ -619,8 +626,9 @@ async function queryServerStatus(base, bindCode) {
619
626
  }).catch(() => null)
620
627
  const q = `?bindCode=${encodeURIComponent(bindCode)}`
621
628
  const stats = await call(`/api/tunnel/stats${q}`)
622
- if (stats) return stats
623
- return call(`/api/tunnel/status${q}`)
629
+ const data = stats || await call(`/api/tunnel/status${q}`)
630
+ serverStatusCache = { at: now, value: data }
631
+ return data
624
632
  }
625
633
 
626
634
  /** 当前 profile 的 cordis.patch.yml 绝对路径。 */
@@ -730,10 +738,11 @@ function handleStatus(ctx, devices, external, tracker, dshCache) {
730
738
  })
731
739
  }
732
740
  deviceList.sort((a, b) => b.lastSeen - a.lastSeen)
733
- // 外网隧道在线时,向中转服务器查询成员真实状态(成员名 / frps 在线 / 最后心跳)
741
+ // 外网隧道在线时,优先读 WS 推送缓存(经长连接实时更新,零中转服务器请求);
742
+ // 仅当从未收到推送(ws 依赖缺失 / WS 未建立)时才回退 HTTP 低频查询(60s 节流)。
734
743
  let server = null
735
744
  if (extRunning && persisted) {
736
- server = await queryServerStatus(persisted.serverBase || undefined, persisted.bindCode)
745
+ server = external.serverStatus || await queryServerStatus(persisted.serverBase || undefined, persisted.bindCode)
737
746
  }
738
747
  json(res, 200, {
739
748
  host: ctx.webServer.host,
@@ -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.6.1",
3
+ "version": "1.7.0",
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": {