dsh-remote-plugin 0.6.4 → 0.6.5

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.
Binary file
package/gateway.cjs CHANGED
@@ -43,6 +43,7 @@ const ROOT = __dirname
43
43
  const PUBLIC_DIR = path.join(ROOT, 'public')
44
44
  const PORT = Number(process.env.PORT) || 8787
45
45
  const HOST = process.env.HOST || '0.0.0.0'
46
+ const WS_IDLE_MS = Number(process.env.GATEWAY_WS_IDLE_MS) || 60000
46
47
  const UPSTREAM = new URL(process.env.DSH_UPSTREAM || 'http://127.0.0.1:3080')
47
48
  const TOKEN_FILE = process.env.TOKEN_FILE || path.join(os.homedir(), '.dsh-remote', 'token')
48
49
  const NOTES_FILE = process.env.DSH_REMOTE_NOTES || path.join(os.homedir(), '.dsh-remote', 'device-notes.json')
@@ -1778,11 +1779,43 @@ server.on('upgrade', (req, socket, head) => {
1778
1779
  upSocket.setNoDelay(true)
1779
1780
  upSocket.pipe(socket)
1780
1781
  socket.pipe(upSocket)
1781
- const close = () => { upSocket.destroy(); socket.destroy() }
1782
+ // 双向 idle 检测: 任一侧 60s 无数据即视为死连接, 同时销毁两侧
1783
+ let upIdle = null
1784
+ let clientIdle = null
1785
+ const clearIdle = () => {
1786
+ clearTimeout(upIdle)
1787
+ clearTimeout(clientIdle)
1788
+ upIdle = null
1789
+ clientIdle = null
1790
+ }
1791
+ const destroyBoth = () => {
1792
+ clearIdle()
1793
+ upSocket.destroy()
1794
+ socket.destroy()
1795
+ }
1796
+ const close = () => {
1797
+ clearIdle()
1798
+ upSocket.destroy()
1799
+ socket.destroy()
1800
+ }
1801
+ const touchUp = () => {
1802
+ clearTimeout(upIdle)
1803
+ upIdle = setTimeout(destroyBoth, WS_IDLE_MS)
1804
+ upIdle?.unref?.()
1805
+ }
1806
+ const touchClient = () => {
1807
+ clearTimeout(clientIdle)
1808
+ clientIdle = setTimeout(destroyBoth, WS_IDLE_MS)
1809
+ clientIdle?.unref?.()
1810
+ }
1811
+ upSocket.on('data', touchUp)
1812
+ socket.on('data', touchClient)
1813
+ touchUp()
1814
+ touchClient()
1782
1815
  upSocket.on('error', close)
1783
1816
  socket.on('error', close)
1784
- upSocket.on('close', () => socket.end())
1785
- socket.on('close', () => upSocket.end())
1817
+ upSocket.on('close', () => { clearIdle(); if (!socket.destroyed) socket.end() })
1818
+ socket.on('close', () => { clearIdle(); if (!upSocket.destroyed) upSocket.end() })
1786
1819
  })
1787
1820
 
1788
1821
  upstreamReq.on('error', () => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-remote-plugin",
3
- "version": "0.6.4",
3
+ "version": "0.6.5",
4
4
  "description": "DSH Remote 官方 bundle 插件:DSH 左侧原生边栏入口 + 右侧抽屉内嵌管理控制台;内置网关随 DSH 自动启停(systemd 独立单元),抽屉直显令牌与设备监控;网关提供 /fs/* 文件传输端点(列表/断点下载/上传),配合 Android App 远程操控会话/审批/提问/goal 与文件互传(多服务器测速切换、聊天记录离线缓存)。",
5
5
  "type": "module",
6
6
  "main": "./index.mjs",
package/public/app.js CHANGED
@@ -709,16 +709,54 @@ const streams = {}
709
709
  state.streamsOk = { mux: false, host: false }
710
710
  let pollTimer = null
711
711
  let wsRetryTimer = null
712
+ let connTickTimer = null
713
+ let reconnectInfo = null
714
+
715
+ function clearStreamTimers(ws) {
716
+ if (!ws) return
717
+ clearInterval(ws._hbTimer)
718
+ clearInterval(ws._staleTimer)
719
+ ws._hbTimer = null
720
+ ws._staleTimer = null
721
+ }
722
+
723
+ function clearConnTick() {
724
+ clearInterval(connTickTimer)
725
+ connTickTimer = null
726
+ }
727
+
728
+ function startConnTick() {
729
+ if (connTickTimer) return
730
+ connTickTimer = setInterval(() => {
731
+ updateConn()
732
+ if (!reconnectInfo || state.streamMode === 'poll' || !navigator.onLine ||
733
+ (streams.mux?.readyState === WebSocket.OPEN && streams.host?.readyState === WebSocket.OPEN)) {
734
+ clearConnTick()
735
+ }
736
+ }, 1000)
737
+ }
738
+
739
+ function setReconnect(delay) {
740
+ reconnectInfo = { at: Date.now() + delay }
741
+ startConnTick()
742
+ updateConn()
743
+ }
744
+
745
+ function clearReconnect() {
746
+ reconnectInfo = null
747
+ clearConnTick()
748
+ }
712
749
 
713
750
  function openStreams() {
714
751
  if (!state.token) return
715
752
  if (state.streamMode === 'poll') stopPolling()
716
753
  state.streamMode = 'ws'
754
+ clearReconnect()
717
755
  openStream('mux', onMuxFrame, true)
718
756
  openStream('host', onHostFrame, false)
719
757
  }
720
758
 
721
- function openStream(kind, handler, refreshOnOpen) {
759
+ function openStream(kind, handler, refreshOnOpen, isRestore) {
722
760
  if (!state.token) return
723
761
  let base
724
762
  if (state.server) {
@@ -731,11 +769,28 @@ function openStream(kind, handler, refreshOnOpen) {
731
769
  const ws = new WebSocket(`${base}/api/events.${kind}?token=${encodeURIComponent(state.token)}&client=${clientMark}`)
732
770
  try { streams[kind]?.close() } catch {}
733
771
  streams[kind] = ws
772
+ ws._attempt = 0
773
+ ws._lastMsgAt = 0
774
+ ws._isRestore = !!isRestore
734
775
  ws.onopen = () => {
735
776
  state.streamsOk[kind] = true
736
777
  state.errCount = 0
778
+ ws._attempt = 0
779
+ ws._lastMsgAt = Date.now()
780
+ clearStreamTimers(ws)
781
+ // 应用层心跳: 25s 发纯文本 ping, 防 NAT/WiFi 切换后的 WS 半开假活
782
+ ws._hbTimer = setInterval(() => {
783
+ try { if (ws.readyState === WebSocket.OPEN) ws.send('ping') } catch {}
784
+ }, 25000)
785
+ // 60s 没有任何消息(含 pong/业务帧)就主动断开, 触发指数退避重连
786
+ ws._staleTimer = setInterval(() => {
787
+ if (ws.readyState === WebSocket.OPEN && Date.now() - (ws._lastMsgAt || 0) > 60000) {
788
+ try { ws.close() } catch {}
789
+ }
790
+ }, 10000)
737
791
  // 重连成功:切回 WS 并停止轮询
738
792
  if (state.streamMode === 'poll') { stopPolling(); state.streamMode = 'ws' }
793
+ if (streams.mux?.readyState === WebSocket.OPEN && streams.host?.readyState === WebSocket.OPEN) clearReconnect()
739
794
  updateConn()
740
795
  // mux 每次(重)连接都会重放“仍待处理”的审批/提问基线:
741
796
  // 先清空旧列表, 避免“桌面已自定义回答, 手机漏收 question/resolved”后永久残留。
@@ -747,6 +802,7 @@ function openStream(kind, handler, refreshOnOpen) {
747
802
  if (refreshOnOpen) refreshAll()
748
803
  }
749
804
  ws.onmessage = (msg) => {
805
+ ws._lastMsgAt = Date.now()
750
806
  state.streamsOk[kind] = true
751
807
  state.errCount = 0
752
808
  updateConn()
@@ -756,16 +812,34 @@ function openStream(kind, handler, refreshOnOpen) {
756
812
  } catch {}
757
813
  }
758
814
  ws.onclose = () => {
815
+ clearStreamTimers(ws)
759
816
  state.streamsOk[kind] = false
760
817
  state.errCount++
761
818
  updateConn()
762
- if (state.streamMode === 'poll') return
819
+ if (!navigator.onLine) { clearReconnect(); return }
820
+ if (state.streamMode === 'poll') {
821
+ // 降级轮询期间: 恢复尝试失败也用退避, 避免 30s 固定间隔内空转
822
+ if (ws._isRestore && streams[kind] === ws) {
823
+ const attempt = ws._attempt || 0
824
+ ws._attempt = attempt + 1
825
+ const baseDelay = Math.min(1200 * Math.pow(2, attempt), 30000)
826
+ const delay = Math.round(baseDelay * (0.8 + Math.random() * 0.4))
827
+ setTimeout(() => openStream(kind, handler, refreshOnOpen, true), delay)
828
+ }
829
+ return
830
+ }
763
831
  // 连续失败 3 次 -> 降级为轮询
764
832
  if (state.errCount >= 3) { enterPollMode(); return }
765
833
  // 多服务器: 连续掉线若干次就重测速, 自动换到当前可达的最快地址
766
834
  if (state.servers.length && state.errCount % 5 === 0) setTimeout(() => selectFastestServer({ silent: true }), 300)
767
- // 无条件重连; 页面被挂起时定时器暂停, visibilitychange 会再触发一次
768
- if (streams[kind] === ws) setTimeout(() => openStream(kind, handler, refreshOnOpen), 1200)
835
+ // 指数退避 + 20% 抖动: min(1200 * 2^attempt, 30000)
836
+ const attempt = ws._attempt || 0
837
+ ws._attempt = attempt + 1
838
+ const baseDelay = Math.min(1200 * Math.pow(2, attempt), 30000)
839
+ const delay = Math.round(baseDelay * (0.8 + Math.random() * 0.4))
840
+ setReconnect(delay)
841
+ // 页面被挂起时定时器暂停, visibilitychange 会再触发一次
842
+ if (streams[kind] === ws) setTimeout(() => openStream(kind, handler, refreshOnOpen), delay)
769
843
  }
770
844
  ws.onerror = () => { try { ws.close() } catch {} }
771
845
  }
@@ -838,8 +912,8 @@ async function pollKind(kind) {
838
912
  function tryRestoreWs() {
839
913
  if (state.streamMode !== 'poll' || !state.token) return
840
914
  // 轮询继续跑,等 WS 真正 onopen 后再切回,避免重连窗口丢事件
841
- openStream('mux', onMuxFrame, true)
842
- openStream('host', onHostFrame, false)
915
+ openStream('mux', onMuxFrame, true, true)
916
+ openStream('host', onHostFrame, false, true)
843
917
  }
844
918
 
845
919
  /* 回前台恢复: 强制重排修复 MIUI WebView 后台切回时 sticky 顶栏不绘制的问题 */
@@ -876,6 +950,23 @@ setInterval(() => {
876
950
  setInterval(() => {
877
951
  if (document.visibilityState === 'visible' && state.servers.length) selectFastestServer({ silent: true })
878
952
  }, 300000)
953
+
954
+ /* 网络感知: 离线立刻关 WS + 显示离线, 在线立即重连 */
955
+ window.addEventListener('offline', () => {
956
+ clearReconnect()
957
+ try { streams.mux?.close() } catch {}
958
+ try { streams.host?.close() } catch {}
959
+ if (state.streamMode === 'poll') stopPolling()
960
+ updateConn()
961
+ })
962
+ window.addEventListener('online', () => {
963
+ if (!state.token) { updateConn(); return }
964
+ state.errCount = 0
965
+ clearReconnect()
966
+ openStreams()
967
+ updateConn()
968
+ })
969
+
879
970
  function onMuxFrame(full) {
880
971
  const f = full.payload
881
972
  if (!f) return
@@ -2727,16 +2818,36 @@ function updateConn() {
2727
2818
  const ms = state.serverLatency[state.server]
2728
2819
  const curGroup = cur ? cur.group : state.activeGroup
2729
2820
  const curLabel = cur ? (cur.note || cur.url) : (state.server || t('speed.origin'))
2821
+ const titleBase = t('conn.titleGroup', { group: curGroup, url: curLabel, ms: Number.isFinite(ms) ? ms + 'ms' : '—' })
2822
+ if (!navigator.onLine) {
2823
+ el.textContent = t('conn.offline')
2824
+ el.className = 'topbar-btn conn-badge off'
2825
+ el.title = titleBase
2826
+ return
2827
+ }
2730
2828
  if (state.streamMode === 'poll') {
2731
2829
  el.textContent = t('conn.poll')
2732
2830
  el.className = 'topbar-btn conn-badge off'
2733
- el.title = t('conn.pollTitle') + ' · ' + t('conn.titleGroup', { group: curGroup, url: curLabel, ms: Number.isFinite(ms) ? ms + 'ms' : '—' })
2831
+ el.title = t('conn.pollTitle') + ' · ' + titleBase
2734
2832
  return
2735
2833
  }
2736
2834
  const ok = !!state.streamsOk?.mux
2835
+ if (!ok && reconnectInfo) {
2836
+ const remain = Math.max(0, Math.ceil((reconnectInfo.at - Date.now()) / 1000))
2837
+ el.textContent = remain > 0 ? t('conn.reconnectIn', { n: remain }) : t('conn.reconnecting')
2838
+ el.className = 'topbar-btn conn-badge off'
2839
+ el.title = t('conn.reconnecting') + ' · ' + titleBase
2840
+ return
2841
+ }
2842
+ if (!ok && state.errCount > 0) {
2843
+ el.textContent = t('conn.failed')
2844
+ el.className = 'topbar-btn conn-badge off'
2845
+ el.title = titleBase
2846
+ return
2847
+ }
2737
2848
  el.textContent = ok ? t('conn.on') : t('conn.off')
2738
2849
  el.className = 'topbar-btn conn-badge ' + (ok ? 'on' : 'off')
2739
- el.title = t('conn.titleGroup', { group: curGroup, url: curLabel, ms: Number.isFinite(ms) ? ms + 'ms' : '—' })
2850
+ el.title = titleBase
2740
2851
  }
2741
2852
 
2742
2853
  function autosize(el) {
@@ -354,6 +354,7 @@
354
354
  'ds.feedbackRateLimited': '提交太频繁,请 5 分钟后再试', 'ds.feedbackSubmitFailed': '提交失败:{msg}', 'ds.feedbackNetworkError': '网络错误',
355
355
  'ds.questionTitle': '回答问题', 'ds.ignore': '忽略', 'ds.submit': '提交',
356
356
  'ds.connOn': '已连接', 'ds.connOff': '未连接', 'ds.connIng': '连接中',
357
+ 'ds.connReconnecting': '重连中…', 'ds.connReconnectIn': '重连中 {n}s', 'ds.connOffline': '离线', 'ds.connFailed': '连接失败',
357
358
  'ds.connPollTitle': '当前网络不支持实时推送,已降级为轮询(延迟数秒)',
358
359
  'ds.currentServer': '{group} · {url}', 'ds.origin': '当前页面',
359
360
  'ds.toastSent': '已发送', 'ds.toastCopied': '令牌已复制', 'ds.toastAuth': '令牌无效',
@@ -448,6 +449,7 @@
448
449
  'ds.feedbackRateLimited': 'Too frequent, try again in 5 minutes', 'ds.feedbackSubmitFailed': 'Submit failed: {msg}', 'ds.feedbackNetworkError': 'Network error',
449
450
  'ds.questionTitle': 'Answer question', 'ds.ignore': 'Ignore', 'ds.submit': 'Submit',
450
451
  'ds.connOn': 'Connected', 'ds.connOff': 'Offline', 'ds.connIng': 'Connecting',
452
+ 'ds.connReconnecting': 'Reconnecting…', 'ds.connReconnectIn': 'Reconnecting {n}s', 'ds.connOffline': 'Offline', 'ds.connFailed': 'Connection failed',
451
453
  'ds.connPollTitle': 'Realtime push is unavailable on this network; degraded to polling (a few seconds delay)',
452
454
  'ds.currentServer': '{group} · {url}', 'ds.origin': 'this page',
453
455
  'ds.toastSent': 'Sent', 'ds.toastCopied': 'Token copied', 'ds.toastAuth': 'Invalid token',
@@ -66,6 +66,8 @@ const state = {
66
66
  const streams = {}
67
67
  let pollTimer = null
68
68
  let wsRetryTimer = null
69
+ let connTickTimer = null
70
+ let reconnectInfo = null
69
71
 
70
72
  function esc(s) { return String(s ?? '').replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c])) }
71
73
  function short(id) { return '…' + String(id).slice(-8) }
@@ -803,14 +805,50 @@ function deleteGroup(name) {
803
805
  }
804
806
 
805
807
  /* ---------------- 事件流 (WebSocket + 轮询降级) ---------------- */
808
+ function clearStreamTimers(ws) {
809
+ if (!ws) return
810
+ clearInterval(ws._hbTimer)
811
+ clearInterval(ws._staleTimer)
812
+ ws._hbTimer = null
813
+ ws._staleTimer = null
814
+ }
815
+
816
+ function clearConnTick() {
817
+ clearInterval(connTickTimer)
818
+ connTickTimer = null
819
+ }
820
+
821
+ function startConnTick() {
822
+ if (connTickTimer) return
823
+ connTickTimer = setInterval(() => {
824
+ updateConn()
825
+ if (!reconnectInfo || state.streamMode === 'poll' || !navigator.onLine ||
826
+ (streams.mux?.readyState === WebSocket.OPEN && streams.host?.readyState === WebSocket.OPEN)) {
827
+ clearConnTick()
828
+ }
829
+ }, 1000)
830
+ }
831
+
832
+ function setReconnect(delay) {
833
+ reconnectInfo = { at: Date.now() + delay }
834
+ startConnTick()
835
+ updateConn()
836
+ }
837
+
838
+ function clearReconnect() {
839
+ reconnectInfo = null
840
+ clearConnTick()
841
+ }
842
+
806
843
  function openStreams() {
807
844
  if (!state.token) return
808
845
  if (state.streamMode === 'poll') stopPolling()
809
846
  state.streamMode = 'ws'
847
+ clearReconnect()
810
848
  openStream('mux', onMuxFrame, true)
811
849
  openStream('host', onHostFrame, false)
812
850
  }
813
- function openStream(kind, handler, refreshOnOpen) {
851
+ function openStream(kind, handler, refreshOnOpen, isRestore) {
814
852
  if (!state.token) return
815
853
  let base
816
854
  if (state.server) base = state.server.replace(/^http/, 'ws')
@@ -818,28 +856,64 @@ function openStream(kind, handler, refreshOnOpen) {
818
856
  const ws = new WebSocket(`${base}/api/events.${kind}?token=${encodeURIComponent(state.token)}&client=web`)
819
857
  try { streams[kind]?.close() } catch {}
820
858
  streams[kind] = ws
859
+ ws._attempt = 0
860
+ ws._lastMsgAt = 0
861
+ ws._isRestore = !!isRestore
821
862
  ws.onopen = () => {
822
863
  state.streamsOk[kind] = true
823
864
  state.errCount = 0
865
+ ws._attempt = 0
866
+ ws._lastMsgAt = Date.now()
867
+ clearStreamTimers(ws)
868
+ // 应用层心跳: 25s 发纯文本 ping, 防 NAT/WiFi 切换后的 WS 半开假活
869
+ ws._hbTimer = setInterval(() => {
870
+ try { if (ws.readyState === WebSocket.OPEN) ws.send('ping') } catch {}
871
+ }, 25000)
872
+ // 60s 没有任何消息(含 pong/业务帧)就主动断开, 触发指数退避重连
873
+ ws._staleTimer = setInterval(() => {
874
+ if (ws.readyState === WebSocket.OPEN && Date.now() - (ws._lastMsgAt || 0) > 60000) {
875
+ try { ws.close() } catch {}
876
+ }
877
+ }, 10000)
824
878
  if (state.streamMode === 'poll') { stopPolling(); state.streamMode = 'ws' }
879
+ if (streams.mux?.readyState === WebSocket.OPEN && streams.host?.readyState === WebSocket.OPEN) clearReconnect()
825
880
  updateConn()
826
881
  if (kind === 'mux') { state.approvals = []; state.questions = []; renderNotifStack() }
827
882
  if (refreshOnOpen) refreshSessions()
828
883
  }
829
884
  ws.onmessage = (msg) => {
885
+ ws._lastMsgAt = Date.now()
830
886
  state.streamsOk[kind] = true
831
887
  state.errCount = 0
832
888
  updateConn()
833
889
  try { handler(JSON.parse(msg.data)) } catch {}
834
890
  }
835
891
  ws.onclose = () => {
892
+ clearStreamTimers(ws)
836
893
  state.streamsOk[kind] = false
837
894
  state.errCount++
838
895
  updateConn()
839
- if (state.streamMode === 'poll') return
896
+ if (!navigator.onLine) { clearReconnect(); return }
897
+ if (state.streamMode === 'poll') {
898
+ // 降级轮询期间: 恢复尝试失败也用退避, 避免 30s 固定间隔内空转
899
+ if (ws._isRestore && streams[kind] === ws) {
900
+ const attempt = ws._attempt || 0
901
+ ws._attempt = attempt + 1
902
+ const baseDelay = Math.min(1200 * Math.pow(2, attempt), 30000)
903
+ const delay = Math.round(baseDelay * (0.8 + Math.random() * 0.4))
904
+ setTimeout(() => openStream(kind, handler, refreshOnOpen, true), delay)
905
+ }
906
+ return
907
+ }
840
908
  if (state.errCount >= 3) { enterPollMode(); return }
841
909
  if (state.servers.length && state.errCount % 5 === 0) setTimeout(() => selectFastestServer({ silent: true }), 300)
842
- if (streams[kind] === ws) setTimeout(() => openStream(kind, handler, refreshOnOpen), 1200)
910
+ // 指数退避 + 20% 抖动: min(1200 * 2^attempt, 30000)
911
+ const attempt = ws._attempt || 0
912
+ ws._attempt = attempt + 1
913
+ const baseDelay = Math.min(1200 * Math.pow(2, attempt), 30000)
914
+ const delay = Math.round(baseDelay * (0.8 + Math.random() * 0.4))
915
+ setReconnect(delay)
916
+ if (streams[kind] === ws) setTimeout(() => openStream(kind, handler, refreshOnOpen), delay)
843
917
  }
844
918
  ws.onerror = () => { try { ws.close() } catch {} }
845
919
  }
@@ -911,9 +985,32 @@ async function pollKind(kind) {
911
985
  function tryRestoreWs() {
912
986
  if (state.streamMode !== 'poll' || !state.token) return
913
987
  // 轮询继续跑,等 WS 真正 onopen 后再切回,避免重连窗口丢事件
914
- openStream('mux', onMuxFrame, true)
915
- openStream('host', onHostFrame, false)
988
+ openStream('mux', onMuxFrame, true, true)
989
+ openStream('host', onHostFrame, false, true)
916
990
  }
991
+
992
+ /* 网络感知: 离线立刻关 WS + 显示离线, 在线立即重连 */
993
+ window.addEventListener('offline', () => {
994
+ clearReconnect()
995
+ try { streams.mux?.close() } catch {}
996
+ try { streams.host?.close() } catch {}
997
+ if (state.streamMode === 'poll') stopPolling()
998
+ updateConn()
999
+ })
1000
+ window.addEventListener('online', () => {
1001
+ if (!state.token) { updateConn(); return }
1002
+ state.errCount = 0
1003
+ clearReconnect()
1004
+ openStreams()
1005
+ updateConn()
1006
+ })
1007
+ document.addEventListener('visibilitychange', () => {
1008
+ if (document.visibilityState === 'visible' && state.token &&
1009
+ (streams.mux?.readyState !== WebSocket.OPEN || streams.host?.readyState !== WebSocket.OPEN)) {
1010
+ openStreams()
1011
+ }
1012
+ })
1013
+
917
1014
  function onMuxFrame(full) {
918
1015
  const f = full.payload
919
1016
  if (!f) return
@@ -1230,7 +1327,11 @@ async function sendMessage() {
1230
1327
  if (!text || !state.current) return
1231
1328
  if (await runSlashCommand(text)) { input.value = ''; return }
1232
1329
  input.value = ''
1233
- const v = await safeRpc('session.prompt', { sessionId: state.current, text }, '')
1330
+ const v = await safeRpc('session.prompt', {
1331
+ sessionId: state.current,
1332
+ mode: 'queue',
1333
+ content: [{ type: 'text', text }]
1334
+ }, '')
1234
1335
  if (v) toast(t('ds.toastSent'), 'ok')
1235
1336
  }
1236
1337
 
@@ -1473,19 +1574,42 @@ function updateConn() {
1473
1574
  const cur = state.servers.find(s => s.url === state.server)
1474
1575
  const group = cur ? cur.group : state.activeGroup
1475
1576
  const label = cur ? (cur.note || cur.url) : (state.server || t('ds.origin'))
1577
+ const serverText = t('ds.currentServer', { group, url: label })
1578
+ if (!navigator.onLine) {
1579
+ el.textContent = t('ds.connOffline')
1580
+ el.className = 'ds-conn off'
1581
+ el.title = serverText
1582
+ $('server-badge').textContent = serverText
1583
+ return
1584
+ }
1476
1585
  if (state.streamMode === 'poll') {
1477
1586
  el.textContent = '●'
1478
1587
  el.className = 'ds-conn off'
1479
1588
  el.title = t('ds.connPollTitle')
1480
- $('server-badge').textContent = t('ds.currentServer', { group, url: label })
1589
+ $('server-badge').textContent = serverText
1481
1590
  return
1482
1591
  }
1483
1592
  const any = Object.values(state.streamsOk).some(Boolean)
1484
1593
  const all = state.streamsOk.mux && state.streamsOk.host
1594
+ if (!all && reconnectInfo) {
1595
+ const remain = Math.max(0, Math.ceil((reconnectInfo.at - Date.now()) / 1000))
1596
+ el.textContent = remain > 0 ? t('ds.connReconnectIn', { n: remain }) : t('ds.connReconnecting')
1597
+ el.className = 'ds-conn ing'
1598
+ el.title = t('ds.connReconnecting') + ' · ' + serverText
1599
+ $('server-badge').textContent = serverText
1600
+ return
1601
+ }
1602
+ if (!all && state.errCount > 0 && !any) {
1603
+ el.textContent = t('ds.connFailed')
1604
+ el.className = 'ds-conn off'
1605
+ el.title = serverText
1606
+ $('server-badge').textContent = serverText
1607
+ return
1608
+ }
1485
1609
  el.textContent = '●'
1486
1610
  el.className = 'ds-conn ' + (all ? 'on' : any ? 'ing' : '')
1487
1611
  el.title = all ? t('ds.connOn') : any ? t('ds.connIng') : t('ds.connOff')
1488
- $('server-badge').textContent = t('ds.currentServer', { group, url: label })
1612
+ $('server-badge').textContent = serverText
1489
1613
  }
1490
1614
 
1491
1615
  /* ---------------- 初始化 ---------------- */
package/public/index.html CHANGED
@@ -491,6 +491,7 @@
491
491
  zh: {
492
492
  'a11y.hostAdmin': '主机管理', 'a11y.refresh': '刷新', 'a11y.more': '更多操作', 'a11y.moreTitle': '指令/权限/模型',
493
493
  'conn.on': '已连接', 'conn.off': '未连接', 'conn.reconnecting': '连接中断,正在重连…',
494
+ 'conn.reconnectIn': '重连中 {n}s', 'conn.offline': '离线', 'conn.failed': '连接失败',
494
495
  'conn.poll': '轮询', 'conn.pollTitle': '当前网络不支持实时推送,已降级为轮询(延迟数秒)',
495
496
  'conn.titleGroup': '{group} · {url}({ms})',
496
497
  'common.refreshing': '刷新中…',
@@ -672,6 +673,7 @@
672
673
  en: {
673
674
  'a11y.hostAdmin': 'Host admin', 'a11y.refresh': 'Refresh', 'a11y.more': 'More actions', 'a11y.moreTitle': 'Commands / Permissions / Models',
674
675
  'conn.on': 'Connected', 'conn.off': 'Offline', 'conn.reconnecting': 'Connection lost, reconnecting…',
676
+ 'conn.reconnectIn': 'Reconnecting {n}s', 'conn.offline': 'Offline', 'conn.failed': 'Connection failed',
675
677
  'conn.poll': 'Polling', 'conn.pollTitle': 'Realtime push is unavailable on this network; degraded to polling (a few seconds delay)',
676
678
  'conn.titleGroup': '{group} · {url} ({ms})',
677
679
  'common.refreshing': 'Refreshing…',
@@ -1,10 +1,14 @@
1
1
  {
2
- "version": "0.6.4",
2
+ "version": "0.6.5",
3
3
  "apkUrl": "dsh-remote.apk",
4
- "sha256": "7e9a67c36a77e13556034758b5e9b4d950b3e183d9a9bbfa0219f077a5c06ab2",
5
- "releasedAt": "2026-08-19T11:39:19.543Z",
6
- "notes": "扫码配对改为显式双按钮(拍照/相册),修复小米等 ROM 选择器错乱;会话历史加载超时放宽至 45 秒,失败可重试;网关端口自定义(插件管理页可改,环境变量仍优先)+ 启动前端口占用检测;峰谷计费提醒改前台服务驱动,后台按时必达;消息 Markdown 渲染(标题/代码块/列表/链接);修复 DSH 重启后网关上游端口不刷新(Issue #1);新增测试通知按钮。",
4
+ "sha256": "132da02e3b5cb2b348f71cdc88590430fdc88abe6ee764788f658372c239e317",
5
+ "releasedAt": "2026-08-19T12:12:05.411Z",
6
+ "notes": "连接稳定性全面优化:25 秒心跳保活(WiFi 切换 / NAT 超时不再假活);60 秒无消息自动重连,指数退避重连(1s 30s 上限 + 抖动);网关死连接自动清理(60 秒无数据双向销毁);断网自动检测与恢复(离线 / 重连中倒计时 / 连接失败状态可见);降级轮询恢复优化。",
7
7
  "history": [
8
+ {
9
+ "version": "0.6.5",
10
+ "notes": "连接稳定性全面优化:25 秒心跳保活(WiFi 切换 / NAT 超时不再假活);60 秒无消息自动重连,指数退避重连(1s → 30s 上限 + 抖动);网关死连接自动清理(60 秒无数据双向销毁);断网自动检测与恢复(离线 / 重连中倒计时 / 连接失败状态可见);降级轮询恢复优化。"
11
+ },
8
12
  {
9
13
  "version": "0.6.4",
10
14
  "notes": "扫码配对改为显式双按钮(拍照/相册),修复小米等 ROM 选择器错乱;会话历史加载超时放宽至 45 秒,失败可重试;网关端口自定义(插件管理页可改,环境变量仍优先)+ 启动前端口占用检测;峰谷计费提醒改前台服务驱动,后台按时必达;消息 Markdown 渲染(标题/代码块/列表/链接);修复 DSH 重启后网关上游端口不刷新(Issue #1);新增测试通知按钮。"
@@ -1,3 +1,3 @@
1
1
  {
2
- "version": "0.6.4"
2
+ "version": "0.6.5"
3
3
  }