@feiyang666/dsh-mobile-remote 1.5.4 → 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,29 @@
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
+
16
+ ## v1.6.1 (2026-08-23)
17
+
18
+ ### 修复 / Fixed
19
+
20
+ - **用量插件等宽表在移动端被压成逐字竖排(可靠兜底)**(`lib/index.js` MOBILE_CSS):`dsh-usage-plugin` 的「消耗表 / 消耗明细 / 每日统计」等 9–11 列宽表依赖其运行时注入的全局样式(`.dsh-usage-table{min-width:720px}` + ≤540px 折叠中间列)来避免列被压到极限宽度;若该样式因 CSP / 加载时序未生效,手机窄屏下每列会被压到约 1 字符宽、内容逐字竖排、无法阅读。本插件(dsh-mobile-remote)的样式是**随 index.html 一起由服务端下发、必定生效**,故在 `<style id="dsh-mobile-remote-css">` 中新增兜底:`html[data-dsh-mobile] .dsh-usage-table { min-width: 720px !important; }`,并在 ≤540px 时对带 `collapse-mobile` 的宽表隐藏第 4 列至倒数第 2 列、只保留前 3 个主标识列与最后合计列。这样即使旧版用量插件未更新或其样式表注入失败,移动端宽表也始终可横向滑动阅读、关键数据一屏看全。价格表等窄表(无 `collapse-mobile`)不折叠,保留横向滚动。
21
+
22
+ ## v1.6.0 (2026-08-23)
23
+
24
+ ### 新增 / New
25
+ - **SSE 实时推送端点**(`lib/index.js`):新增 `GET /__dsh_remote/events` 事件流端点(受远程密码门禁保护),设备心跳上报 / 远程开关切换 / 外网隧道启动停止等状态变化时向订阅者推送 `data: {"type":"changed","at":<时间戳>}`。桌面端数据中心等客户端订阅该流即可即时感知远程设备与隧道状态变化,无需轮询。无订阅者时零开销,插件独立使用不受影响。
26
+
3
27
  ## v1.5.4 (2026-08-23)
4
28
 
5
29
  ### 新增 / New
package/RELEASE_NOTES.md CHANGED
@@ -1,4 +1,4 @@
1
- # dsh-mobile-remote v1.5.4
1
+ # dsh-mobile-remote v1.7.0
2
2
 
3
3
  **DeepSeek Harness 移动端远程控制插件** —— 在 Web UI 设置页内置「远程控制」:连接二维码、一键开关、在线设备数,让手机通过局域网访问并操控电脑上的 DeepSeek Harness。移动端提供上传图片入口、底部抽屉式模型选择、发送状态提示等移动优先体验。
4
4
 
@@ -10,52 +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.5.4)
13
+ ## 本版更新(v1.7.0)
14
14
 
15
- ### 🐛 修复移动端无法选择图片(v1.5.0 起就存在的根因)
15
+ ### 🆕 中转服务器 WS 状态推送(长连接替代轮询)
16
16
 
17
- 图片类型校验正则写为 `/^image\//i`,但该代码经 JS **模板字符串**注入 `<script>`,模板字符串中 `\/` 属于非转义序列、反斜杠会被丢弃,注入后变成 `/^image//i`,被解析成「正则 `/^image/` 除以 `i.test(...)`」,运行时报 `TypeError: i.test is not a function`——从 v1.5.0 起手机端选图后输入框一直没有缩略图。本版改用 `indexOf('image/')` 判断图片类型,彻底绕开正则转义问题。
17
+ 外网隧道开启后,插件端改为通过 **WebSocket 长连接**(`/api/tunnel/ws?bindCode=…`)接收中转服务器的隧道状态实时推送,**替代原来每 5s 的 HTTP 轮询** `/api/tunnel/stats`,中转服务器请求压力降为 0(服务器资源集中用于 frp 隧道中转)。
18
18
 
19
- 同时配套修复:
20
- - 图片 intake 不再依赖 `new DataTransfer()`(iOS Safari 上 `files` 为空、`getAsFile()` 返回 null),改用普通 JS 对象模拟 `clipboardData` / `dataTransfer` 接口;
21
- - 模拟 `clipboardData` 补齐 DSH `onPaste` 会调用的 `getData()`,避免 React 事件处理器崩溃;
22
- - 隐藏文件选择器改为等 `document.body` 就绪后再挂载,避免部分 iOS WebView 拒绝 `.click()` 打开系统相册。
19
+ - **指数退避自动重连**:断线后 2s → 4s → … → 60s 封顶自动恢复;
20
+ - **心跳优先走 WS**:WS 已连上时心跳经长连接送达(零 HTTP 请求),未连上才回退 HTTP 兜底;
21
+ - **状态查询 60s 节流**:即使移动端每 5s 轮询本机 `/status`,也不会高频打中转服务器;前端优先读 WS 推送缓存;
22
+ - **ws 依赖缺失自动降级**:`ws` 未安装时 WS 客户端 no-op,心跳与状态查询回退 HTTP 低频兜底,**功能不中断**。
23
23
 
24
- ### 🆕 移动端「上传中 / 发送中」状态指示
24
+ > ⚠️ 本版新增运行时依赖 `ws`(`^8.18.0`)。通过 `dsh plugin --profile web add` 安装会自动带上;若使用离线 tgz 手动安装,请确保 `ws` 已安装(缺失时插件自动降级,不会崩溃)。
25
25
 
26
- 发送图片消息时,DSH 会先 `serializeImages`(读取图片转 base64,移动端大图可能持续数秒)再 `prompt`,期间会话列表不会立刻出现新消息,用户容易误以为「没发出去」。本版监听 composer textarea 的 `data-phase`,进入 `submitting` / `adjudicating` 阶段时在 composer 上方显示带 spinner 的浮层提示:
26
+ ## 历史更新(v1.6.x)
27
27
 
28
- - 有附件图片时显示「正在上传图片并发送…」;
29
- - 纯文本时显示「正在发送…」;
30
- - 发送完成(phase 恢复)后自动隐藏。
31
-
32
- ### 🐛 模型选择弹窗在移动端改为底部抽屉
33
-
34
- ModelSelect 菜单为绝对定位浮层(`right: 0`、宽 240px),窄屏下从右侧锚点向左展开,把 composer 左侧的 + / 上传图片等按钮盖住。本版将带 `aria-label` 的 `[role=menu]`(模型选择菜单)在移动端改为**底部抽屉(bottom sheet)**:贴底、左右留 8px、距底部 24px、宽度自适应屏幕、`max-height: 62vh` 内部滚动,不再遮挡左侧内容;权限选择菜单保持原有浮层 + 水平钳制。
35
-
36
- ## 历史更新(v1.5.0)
37
-
38
- ### 🆕 移动端上传图片(composer 加号旁的「上传图片」按钮)
39
-
40
- DSH 桌面端原生支持图片消息(粘贴 / 拖放,含格式 / 数量 / 大小预检查、缩略图 rail、随消息上传),但移动端没有「选择图片」的入口——工具行的 + 按钮只打开命令菜单。本版在**移动端 composer 工具行、+ 加号按钮旁**注入一个「上传图片」按钮:
41
-
42
- - 点击按钮,直接唤起手机**系统相册 / 文件选择器**(支持一次选多张);
43
- - 选中后把图片喂给 DSH **原生 intake 链路**(`paste` / `drop` 事件),格式 / 数量 / 大小预检查、缩略图预览、随消息上传全部复用官方实现;
44
- - **不依赖 DSH 内部 API**,harness 升级后只要 paste / drop 链路仍在即可正常工作;
45
- - 按钮禁用态跟随输入框(session 移除 / 锁定 / 提交中自动置灰);选完文件自动重置,可重复选同一张图;
46
- - 配套优化:缩略图 rail 删除按钮触控目标加大(18px → 24px),选图失败时自备极简提示气泡。
47
-
48
- > 仅移动端(`<1024px`)生效,桌面端界面不受任何影响。
49
-
50
- ### 🐛 修复启用远程控制后写坏 profile 的 cordis.patch.yml(服务启动即崩溃)
51
-
52
- 在设置页开启「远程控制」并重启服务后,dsh 启动即崩溃。**根因**:官方模板生成的 profile patch 文件内容是 `[]`(空数组占位),插件往文件末尾追加列表块时未先移除占位行,导致同一文件出现两个 YAML 文档且无 `---` 分隔符。**修复**:写入前过滤掉裸 `[]` 占位行(`setPatchEnabled` / `ensureTrustedHost`)。
53
-
54
- ### 🐛 修复客户端插件加载失败(client-modules: loaded without registering)
55
-
56
- client 侧 bundle 中 `__ModuleLoader__.load` 的 `id` 写成了无 scope 的裸包名,改为完整包名 `@feiyang666/dsh-mobile-remote`,与官方插件的 client bundle 写法一致。
57
-
58
- > 已安装旧版本的用户:请先卸载本插件(桌面端「插件管理」或 `dsh plugin --profile web remove @feiyang666/dsh-mobile-remote`),再重新安装本修复版。
28
+ - **v1.6.1**:用量插件等宽表移动端兜底(强制 720px + ≤540px 折叠中间列)
29
+ - **v1.6.0**:新增 `GET /__dsh_remote/events` SSE 实时推送端点(设备心跳 / 远程开关 / 外网隧道状态变化即时推送)
59
30
 
60
31
  ## 已知事项
61
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
@@ -59,6 +59,19 @@ const HEARTBEAT_TTL = 90_000
59
59
  /** 心跳清理定时器间隔(毫秒)。 */
60
60
  const HEARTBEAT_SWEEP = 30_000
61
61
 
62
+ // ---- SSE 实时推送 ----
63
+ // 设备心跳 / 远程开关 / 外网隧道状态变化时通知订阅者(订阅端点 /__dsh_remote/events)。
64
+ // 无订阅者时是 no-op,插件独立使用不受影响。
65
+ const sseClients = new Set()
66
+
67
+ function notifyChanged() {
68
+ if (sseClients.size === 0) return
69
+ const payload = 'data: {"type":"changed","at":' + Date.now() + '}\n\n'
70
+ for (const res of sseClients) {
71
+ try { res.write(payload) } catch (e) { sseClients.delete(res) }
72
+ }
73
+ }
74
+
62
75
  /**
63
76
  * 插件入口。webServer 服务就绪后被调用。
64
77
  * @param {import('@deepseek-ai/cordis').Context} ctx
@@ -104,6 +117,8 @@ export function apply(ctx) {
104
117
  ctx.webServer.register({ kind: 'exact', path: '/__dsh_remote/external/stop', handler: authGate(handleExternalStop(ctx, external)) }),
105
118
  // frpc 运行日志读取(供设置页展示外网隧道详细状态)
106
119
  ctx.webServer.register({ kind: 'exact', path: '/__dsh_remote/external/log', handler: authGate(handleExternalLog()) }),
120
+ // —— SSE 实时事件流:设备心跳 / 远程开关 / 外网隧道状态变化时推送 {"type":"changed"} ——
121
+ ctx.webServer.register({ kind: 'exact', path: '/__dsh_remote/events', handler: authGate(handleEvents()) }),
107
122
  // —— 远程访问密码门禁相关(auth 系列不套 authGate,需放行)——
108
123
  ctx.webServer.register({ kind: 'exact', path: '/__dsh_remote/auth-status', handler: handleAuthStatus() }),
109
124
  ctx.webServer.register({ kind: 'exact', path: '/__dsh_remote/auth', handler: handleAuth() }),
@@ -415,6 +430,21 @@ function authGate(handler) {
415
430
  }
416
431
  }
417
432
 
433
+ /** GET /__dsh_remote/events:SSE 实时事件流。设备心跳 / 远程开关 / 外网隧道状态变化时推送。 */
434
+ function handleEvents() {
435
+ return (req, res) => {
436
+ res.writeHead(200, {
437
+ 'Content-Type': 'text/event-stream; charset=utf-8',
438
+ 'Cache-Control': 'no-cache',
439
+ Connection: 'keep-alive',
440
+ 'X-Accel-Buffering': 'no',
441
+ })
442
+ res.write(':ok\n\n')
443
+ sseClients.add(res)
444
+ req.on('close', () => { sseClients.delete(res) })
445
+ }
446
+ }
447
+
418
448
  /** GET /__dsh_remote/auth-status:返回远程访问密码的启用状态与本次请求是否需鉴权。 */
419
449
  function handleAuthStatus() {
420
450
  return async (req, res) => {
@@ -572,11 +602,18 @@ self.addEventListener('fetch', function (event) {
572
602
 
573
603
  /**
574
604
  * 从中转服务器查询本成员隧道状态与统计;失败时返回 null(不影响整体 status)。
605
+ * 仅作 WS 推送不可用(ws 依赖缺失 / 旧后端)时的 HTTP 兜底。
575
606
  * 优先调用 /api/tunnel/stats(含 frps 实时流量/连接数等),接口不可用则回退到
576
- * /api/tunnel/status(旧版后端)。任一失败返回 null。
607
+ * /api/tunnel/status(旧版后端)。
608
+ * 注意:带 60s 节流缓存——即使前端每 5s 轮询本机 /status,也不会高频打中转服务器。
577
609
  */
610
+ let serverStatusCache = { at: 0, value: null }
578
611
  async function queryServerStatus(base, bindCode) {
579
612
  if (!bindCode) return null
613
+ const now = Date.now()
614
+ if (serverStatusCache.value && now - serverStatusCache.at < 60000) {
615
+ return serverStatusCache.value
616
+ }
580
617
  const server = String(base || DEFAULT_SERVER_BASE).replace(/\/$/, '')
581
618
  const call = (path) => fetch(`${server}${path}`, {
582
619
  headers: { Accept: 'application/json' },
@@ -589,8 +626,9 @@ async function queryServerStatus(base, bindCode) {
589
626
  }).catch(() => null)
590
627
  const q = `?bindCode=${encodeURIComponent(bindCode)}`
591
628
  const stats = await call(`/api/tunnel/stats${q}`)
592
- if (stats) return stats
593
- 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
594
632
  }
595
633
 
596
634
  /** 当前 profile 的 cordis.patch.yml 绝对路径。 */
@@ -700,10 +738,11 @@ function handleStatus(ctx, devices, external, tracker, dshCache) {
700
738
  })
701
739
  }
702
740
  deviceList.sort((a, b) => b.lastSeen - a.lastSeen)
703
- // 外网隧道在线时,向中转服务器查询成员真实状态(成员名 / frps 在线 / 最后心跳)
741
+ // 外网隧道在线时,优先读 WS 推送缓存(经长连接实时更新,零中转服务器请求);
742
+ // 仅当从未收到推送(ws 依赖缺失 / WS 未建立)时才回退 HTTP 低频查询(60s 节流)。
704
743
  let server = null
705
744
  if (extRunning && persisted) {
706
- server = await queryServerStatus(persisted.serverBase || undefined, persisted.bindCode)
745
+ server = external.serverStatus || await queryServerStatus(persisted.serverBase || undefined, persisted.bindCode)
707
746
  }
708
747
  json(res, 200, {
709
748
  host: ctx.webServer.host,
@@ -767,6 +806,7 @@ function handleToggle(ctx) {
767
806
  const enabled = body.enabled === true
768
807
  try {
769
808
  const result = setPatchEnabled(ctx, enabled)
809
+ notifyChanged() // 远程开关变化:通知 SSE 订阅者
770
810
  json(res, 200, {
771
811
  ok: true,
772
812
  enabled,
@@ -818,6 +858,7 @@ function handleHeartbeat(devices, tracker) {
818
858
  firstSeen: prev ? prev.firstSeen : now,
819
859
  lastSeen: now,
820
860
  })
861
+ notifyChanged() // 设备状态更新:通知 SSE 订阅者
821
862
  json(res, 200, { ok: true, deviceCount: devices.size })
822
863
  }
823
864
  }
@@ -864,9 +905,11 @@ function handleExternalStart(ctx, external) {
864
905
  try {
865
906
  const patchFile = patchFilePath(ctx)
866
907
  const result = await startExternal(external, body, patchFile, ctx.webServer.port || 3080)
908
+ notifyChanged() // 外网隧道启动:通知 SSE 订阅者
867
909
  json(res, 200, Object.assign({ ok: true }, result))
868
910
  } catch (error) {
869
911
  external.error = String(error?.message || error)
912
+ notifyChanged() // 启动失败也通知(前端可感知错误状态)
870
913
  json(res, 500, { ok: false, error: external.error })
871
914
  }
872
915
  }
@@ -876,6 +919,7 @@ function handleExternalStart(ctx, external) {
876
919
  function handleExternalStop(ctx, external) {
877
920
  return async (req, res) => {
878
921
  stopExternal(external)
922
+ notifyChanged() // 外网隧道停止:通知 SSE 订阅者
879
923
  json(res, 200, { ok: true, enabled: false })
880
924
  }
881
925
  }
@@ -1360,6 +1404,30 @@ html[data-dsh-mobile] [data-composer-card] [role='menu'][aria-label] {
1360
1404
  overflow: hidden !important;
1361
1405
  overscroll-behavior: contain !important;
1362
1406
  }
1407
+
1408
+ /* ----- 移动端插件宽表兜底:防止被压成逐字竖排 -----
1409
+ * dsh-usage-plugin 的「消耗表 / 消耗明细 / 每日统计」等 9–11 列宽表依赖其
1410
+ * 自身在运行时注入的全局样式(.dsh-usage-table{min-width:720px} + ≤540px
1411
+ * 折叠中间列)来避免列被压到极限宽度。若该样式因 CSP 或加载时序未生效,
1412
+ * 手机窄屏下每列会被压到约 1 字符宽、内容逐字竖排、数据无法阅读。
1413
+ * 本插件(dsh-mobile-remote)的样式是随 index.html 一起由服务端下发的,
1414
+ * 必定生效,因此作为可靠兜底:
1415
+ * - 宽表强制 min-width:720px,配合其外层 overflow-x 容器横向滑动,始终可读;
1416
+ * - ≤540px 时对带 collapse-mobile 的宽表隐藏第 4 列至倒数第 2 列,只保留
1417
+ * 前 3 个主标识列 + 最后合计列,手机上一屏即可看全关键数据;
1418
+ * - 价格表等窄表(无 collapse-mobile)不折叠,保留横向滚动。
1419
+ */
1420
+ html[data-dsh-mobile] .dsh-usage-table {
1421
+ min-width: 720px !important;
1422
+ }
1423
+ @media (max-width: 540px) {
1424
+ html[data-dsh-mobile] .dsh-usage-table.collapse-mobile tr > *:not(:nth-child(-n+3)):not(:last-child) {
1425
+ display: none !important;
1426
+ }
1427
+ html[data-dsh-mobile] .dsh-usage-table.collapse-mobile {
1428
+ min-width: 320px !important;
1429
+ }
1430
+ }
1363
1431
  `
1364
1432
 
1365
1433
  /* ------------------------------------------------------------------ */
@@ -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.5.4",
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": {