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.
- package/apk/dsh-remote.apk +0 -0
- package/gateway.cjs +36 -3
- package/package.json +1 -1
- package/public/app.js +119 -8
- package/public/desktop/desktop.html +2 -0
- package/public/desktop/desktop.js +132 -8
- package/public/index.html +2 -0
- package/public/update.json +8 -4
- package/public/version.json +1 -1
package/apk/dsh-remote.apk
CHANGED
|
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
|
-
|
|
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.
|
|
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 (
|
|
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
|
-
//
|
|
768
|
-
|
|
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') + ' · ' +
|
|
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 =
|
|
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 => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[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 (
|
|
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
|
-
|
|
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', {
|
|
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 =
|
|
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 =
|
|
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…',
|
package/public/update.json
CHANGED
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version": "0.6.
|
|
2
|
+
"version": "0.6.5",
|
|
3
3
|
"apkUrl": "dsh-remote.apk",
|
|
4
|
-
"sha256": "
|
|
5
|
-
"releasedAt": "2026-08-
|
|
6
|
-
"notes": "
|
|
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);新增测试通知按钮。"
|
package/public/version.json
CHANGED