@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 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.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/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
- ['运行时长', fmtDuration(status.runtime.uptime)],
734
- ['开机时长', fmtDuration(status.runtime.sysUptime)],
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 }, '已运行 ' + fmtDuration(Math.floor((Date.now() - status.external.startedAt) / 1000)))
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
- // 零运行时依赖:仅 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
@@ -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(旧版后端)。任一失败返回 null。
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
- if (stats) return stats
623
- return call(`/api/tunnel/status${q}`)
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
- // 外网隧道在线时,向中转服务器查询成员真实状态(成员名 / frps 在线 / 最后心跳)
745
+ // 外网隧道在线时,优先读 WS 推送缓存(经长连接实时更新,零中转服务器请求);
746
+ // 仅当从未收到推送(ws 依赖缺失 / WS 未建立)时才回退 HTTP 低频查询(60s 节流)。
734
747
  let server = null
748
+ let serverSource = 'none'
735
749
  if (extRunning && persisted) {
736
- server = await queryServerStatus(persisted.serverBase || undefined, persisted.bindCode)
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: {
@@ -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.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": {