dsh-remote-plugin 0.6.8 → 0.6.9
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-stats.cjs +26 -11
- package/gateway.cjs +494 -87
- package/index.mjs +12 -2
- package/package.json +1 -1
- package/public/app.js +147 -59
- package/public/desktop/desktop.js +144 -56
- package/public/update.json +8 -4
- package/public/version.json +1 -1
package/index.mjs
CHANGED
|
@@ -151,7 +151,12 @@ function runExit(cmd, args) {
|
|
|
151
151
|
const GATEWAY_ENV_KEYS = [
|
|
152
152
|
'TOKEN', 'TOKEN_FILE', 'DSH_REMOTE_TOKEN', 'DSH_REMOTE_FS_ROOT', 'DSH_REMOTE_FS_MAX_UPLOAD',
|
|
153
153
|
'DSH_REMOTE_NOTES', 'DSH_REMOTE_WORKBENCH', 'DSH_REMOTE_DSH_SERVICE', 'DSH_REMOTE_FEEDBACK_URL',
|
|
154
|
-
'UPDATE_CHECK_URL', 'UPDATE_INTERVAL_MS', 'UPDATE_PROXY', '
|
|
154
|
+
'UPDATE_CHECK_URL', 'UPDATE_INTERVAL_MS', 'UPDATE_PROXY', 'DSH_HEALTH_PATH',
|
|
155
|
+
'GATEWAY_WS_IDLE_MS', 'GATEWAY_WS_PING_MS', 'GATEWAY_WS_PONG_TIMEOUT_MS',
|
|
156
|
+
'GATEWAY_WS_UPGRADE_TIMEOUT_MS', 'GATEWAY_UPSTREAM_TIMEOUT_MS', 'GATEWAY_EVENT_BUFFER_MAX',
|
|
157
|
+
'GATEWAY_WS_TICKET_TTL_MS',
|
|
158
|
+
'GATEWAY_HTTP_REQUEST_TIMEOUT_MS', 'GATEWAY_HTTP_HEADERS_TIMEOUT_MS', 'GATEWAY_HTTP_KEEPALIVE_TIMEOUT_MS',
|
|
159
|
+
'DSH_REMOTE_CORS_ORIGINS',
|
|
155
160
|
'HTTP_PROXY', 'HTTPS_PROXY', 'ALL_PROXY', 'NO_PROXY',
|
|
156
161
|
'http_proxy', 'https_proxy', 'all_proxy', 'no_proxy'
|
|
157
162
|
]
|
|
@@ -194,6 +199,9 @@ async function gatewayRunning() {
|
|
|
194
199
|
version: typeof data.version === 'string' ? data.version : '',
|
|
195
200
|
upstream: typeof data.upstream === 'string' ? data.upstream : '',
|
|
196
201
|
upstreamOk: data.upstreamOk === true,
|
|
202
|
+
upstreamReachable: data.upstreamReachable !== false,
|
|
203
|
+
upstreamStatus: Number(data.upstreamStatus) || 0,
|
|
204
|
+
upstreamProbe: typeof data.upstreamProbe === 'string' ? data.upstreamProbe : '',
|
|
197
205
|
}
|
|
198
206
|
} catch {
|
|
199
207
|
return { running: false }
|
|
@@ -344,7 +352,9 @@ function ensureGateway() {
|
|
|
344
352
|
const oldUpstream = health.upstream || ''
|
|
345
353
|
const oldVersion = health.version || '?'
|
|
346
354
|
const versionMismatch = oldVersion !== version
|
|
347
|
-
|
|
355
|
+
// 上游暂时不可达不应重启网关: VPN/DSH 重启/短暂网络抖动时,
|
|
356
|
+
// 重启只能制造额外断线,网关应保持运行并通过 /health 暴露 degraded 状态。
|
|
357
|
+
if (versionMismatch || (oldUpstream && oldUpstream !== upstream) || (!oldUpstream && upstream)) {
|
|
348
358
|
logGateway(`网关需刷新: 版本 ${oldVersion} -> ${version}, 上游 ${oldUpstream || '?'} -> ${upstream}`)
|
|
349
359
|
await killGateway(health)
|
|
350
360
|
for (let i = 0; i < 10; i++) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-remote-plugin",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.9",
|
|
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
|
@@ -11,6 +11,16 @@ const LS = {
|
|
|
11
11
|
set(k, v) { try { localStorage.setItem(k, v) } catch {} },
|
|
12
12
|
del(k) { try { localStorage.removeItem(k) } catch {} }
|
|
13
13
|
}
|
|
14
|
+
const CLIENT_ID = (() => {
|
|
15
|
+
try {
|
|
16
|
+
let id = sessionStorage.getItem('dshRemoteClientId')
|
|
17
|
+
if (!id) {
|
|
18
|
+
id = (globalThis.crypto?.randomUUID?.() || `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`)
|
|
19
|
+
sessionStorage.setItem('dshRemoteClientId', id)
|
|
20
|
+
}
|
|
21
|
+
return id
|
|
22
|
+
} catch { return '' }
|
|
23
|
+
})()
|
|
14
24
|
|
|
15
25
|
/* 离线缓存: 会话列表 + 每会话聊天记录。只在网络失败时兜底展示, 不会替代线上数据。 */
|
|
16
26
|
const CACHE = {
|
|
@@ -37,6 +47,7 @@ function writeHistoryCache(cache) {
|
|
|
37
47
|
|
|
38
48
|
const state = {
|
|
39
49
|
token: '',
|
|
50
|
+
wsTicket: { token: '', server: '', value: '', expiresAt: 0 },
|
|
40
51
|
server: '', // 当前生效的网关地址, 空 = 同源(浏览器模式)
|
|
41
52
|
servers: [], // 服务器列表: [{id,url,note,group}]
|
|
42
53
|
groups: ['默认'], // 组名列表(顺序保留)
|
|
@@ -59,6 +70,10 @@ const state = {
|
|
|
59
70
|
jobs: {}, // sessionId -> jobs
|
|
60
71
|
history: emptyHistory(),
|
|
61
72
|
errCount: 0,
|
|
73
|
+
streamInfo: {
|
|
74
|
+
mux: { status: 'idle', lastOpenAt: 0, lastCloseAt: 0, lastCloseCode: 0, lastCloseReason: '' },
|
|
75
|
+
host: { status: 'idle', lastOpenAt: 0, lastCloseAt: 0, lastCloseCode: 0, lastCloseReason: '' },
|
|
76
|
+
},
|
|
62
77
|
streamMode: 'ws', // 'ws' | 'poll'
|
|
63
78
|
pollSeq: { mux: 0, host: 0 },
|
|
64
79
|
refreshTimer: null,
|
|
@@ -191,6 +206,31 @@ function apiUrl(path) {
|
|
|
191
206
|
return (state.server || '') + path
|
|
192
207
|
}
|
|
193
208
|
|
|
209
|
+
let wsTicketPromise = null
|
|
210
|
+
async function getWsTicket() {
|
|
211
|
+
const now = Date.now()
|
|
212
|
+
if (state.wsTicket.token === state.token && state.wsTicket.server === state.server &&
|
|
213
|
+
state.wsTicket.value && state.wsTicket.expiresAt > now + 15000) return state.wsTicket.value
|
|
214
|
+
if (wsTicketPromise) return wsTicketPromise
|
|
215
|
+
const token = state.token
|
|
216
|
+
const server = state.server
|
|
217
|
+
wsTicketPromise = (async () => {
|
|
218
|
+
const res = await fetch(apiUrl('/api/ws-ticket'), {
|
|
219
|
+
method: 'POST',
|
|
220
|
+
headers: {
|
|
221
|
+
authorization: 'Bearer ' + token,
|
|
222
|
+
'x-dsh-remote-client': CAP?.isNativePlatform?.() ? 'app' : 'web',
|
|
223
|
+
}
|
|
224
|
+
})
|
|
225
|
+
if (!res.ok) throw new Error('ws ticket HTTP ' + res.status)
|
|
226
|
+
const data = await res.json()
|
|
227
|
+
if (!data?.ticket || !Number(data.expiresAt)) throw new Error('invalid ws ticket')
|
|
228
|
+
state.wsTicket = { token, server, value: data.ticket, expiresAt: Number(data.expiresAt) }
|
|
229
|
+
return data.ticket
|
|
230
|
+
})()
|
|
231
|
+
try { return await wsTicketPromise } finally { wsTicketPromise = null }
|
|
232
|
+
}
|
|
233
|
+
|
|
194
234
|
function updateBase() {
|
|
195
235
|
const configured = String(state.server || '').replace(/\/+$/, '')
|
|
196
236
|
if (configured) return configured
|
|
@@ -738,6 +778,10 @@ function deleteGroup(name) {
|
|
|
738
778
|
/* ---------------- 事件流 (WebSocket + 轮询降级) ---------------- */
|
|
739
779
|
const streams = {}
|
|
740
780
|
state.streamsOk = { mux: false, host: false }
|
|
781
|
+
const streamMeta = {
|
|
782
|
+
mux: { generation: 0, attempt: 0, failures: 0, retryTimer: null },
|
|
783
|
+
host: { generation: 0, attempt: 0, failures: 0, retryTimer: null },
|
|
784
|
+
}
|
|
741
785
|
let pollTimer = null
|
|
742
786
|
let wsRetryTimer = null
|
|
743
787
|
let connTickTimer = null
|
|
@@ -745,10 +789,40 @@ let reconnectInfo = null
|
|
|
745
789
|
|
|
746
790
|
function clearStreamTimers(ws) {
|
|
747
791
|
if (!ws) return
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
792
|
+
if (ws._retryTimer) clearTimeout(ws._retryTimer)
|
|
793
|
+
ws._retryTimer = null
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
function streamIsCurrent(kind, ws, generation) {
|
|
797
|
+
return streams[kind] === ws && streamMeta[kind].generation === generation
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
function aggregateStreamFailures() {
|
|
801
|
+
state.errCount = Math.max(streamMeta.mux.failures, streamMeta.host.failures)
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
function markStreamInfo(kind, patch) {
|
|
805
|
+
state.streamInfo[kind] = { ...state.streamInfo[kind], ...patch }
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
function allStreamsOpen() {
|
|
809
|
+
return streams.mux?.readyState === WebSocket.OPEN && streams.host?.readyState === WebSocket.OPEN
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
function clearStreamRetry(kind) {
|
|
813
|
+
const meta = streamMeta[kind]
|
|
814
|
+
if (meta.retryTimer) clearTimeout(meta.retryTimer)
|
|
815
|
+
meta.retryTimer = null
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
function closeStream(kind) {
|
|
819
|
+
const meta = streamMeta[kind]
|
|
820
|
+
clearStreamRetry(kind)
|
|
821
|
+
meta.generation++
|
|
822
|
+
const ws = streams[kind]
|
|
823
|
+
streams[kind] = null
|
|
824
|
+
state.streamsOk[kind] = false
|
|
825
|
+
try { ws?.close() } catch {}
|
|
752
826
|
}
|
|
753
827
|
|
|
754
828
|
function clearConnTick() {
|
|
@@ -780,15 +854,23 @@ function clearReconnect() {
|
|
|
780
854
|
|
|
781
855
|
function openStreams() {
|
|
782
856
|
if (!state.token) return
|
|
783
|
-
if (state.streamMode
|
|
784
|
-
state.streamMode = 'ws'
|
|
785
|
-
clearReconnect()
|
|
857
|
+
if (state.streamMode !== 'poll') state.streamMode = 'ws'
|
|
786
858
|
openStream('mux', onMuxFrame, true)
|
|
787
859
|
openStream('host', onHostFrame, false)
|
|
788
860
|
}
|
|
789
861
|
|
|
790
|
-
function openStream(kind, handler, refreshOnOpen, isRestore) {
|
|
862
|
+
function openStream(kind, handler, refreshOnOpen, isRestore, ticket = null) {
|
|
791
863
|
if (!state.token) return
|
|
864
|
+
if (ticket === null) {
|
|
865
|
+
const token = state.token
|
|
866
|
+
void getWsTicket().then((value) => {
|
|
867
|
+
if (state.token === token) openStream(kind, handler, refreshOnOpen, isRestore, value)
|
|
868
|
+
}).catch(() => {
|
|
869
|
+
// 兼容旧网关/插件副本: ticket 接口不可用时临时回退旧 token 握手。
|
|
870
|
+
if (state.token === token) openStream(kind, handler, refreshOnOpen, isRestore, '')
|
|
871
|
+
})
|
|
872
|
+
return
|
|
873
|
+
}
|
|
792
874
|
let base
|
|
793
875
|
if (state.server) {
|
|
794
876
|
base = state.server.replace(/^http/, 'ws')
|
|
@@ -797,31 +879,40 @@ function openStream(kind, handler, refreshOnOpen, isRestore) {
|
|
|
797
879
|
base = `${proto}//${location.host}`
|
|
798
880
|
}
|
|
799
881
|
const clientMark = CAP?.isNativePlatform?.() ? 'app' : 'web'
|
|
800
|
-
const
|
|
801
|
-
|
|
882
|
+
const auth = ticket ? `ticket=${encodeURIComponent(ticket)}` : `token=${encodeURIComponent(state.token)}`
|
|
883
|
+
const clientId = CLIENT_ID ? `&clientId=${encodeURIComponent(CLIENT_ID)}` : ''
|
|
884
|
+
const streamUrl = `${base}/api/events.${kind}?${auth}&client=${clientMark}${clientId}`
|
|
885
|
+
const current = streams[kind]
|
|
886
|
+
if (current?._streamUrl === streamUrl &&
|
|
887
|
+
(current.readyState === WebSocket.OPEN || current.readyState === WebSocket.CONNECTING)) return
|
|
888
|
+
if (current && current._streamUrl !== streamUrl) {
|
|
889
|
+
streamMeta[kind].attempt = 0
|
|
890
|
+
streamMeta[kind].failures = 0
|
|
891
|
+
aggregateStreamFailures()
|
|
892
|
+
}
|
|
893
|
+
closeStream(kind)
|
|
894
|
+
const meta = streamMeta[kind]
|
|
895
|
+
const generation = meta.generation
|
|
896
|
+
const ws = new WebSocket(streamUrl)
|
|
802
897
|
streams[kind] = ws
|
|
803
|
-
ws.
|
|
804
|
-
ws.
|
|
898
|
+
ws._streamUrl = streamUrl
|
|
899
|
+
ws._generation = generation
|
|
805
900
|
ws._isRestore = !!isRestore
|
|
806
901
|
ws.onopen = () => {
|
|
902
|
+
if (!streamIsCurrent(kind, ws, generation)) return
|
|
807
903
|
state.streamsOk[kind] = true
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
904
|
+
markStreamInfo(kind, { status: 'open', lastOpenAt: Date.now(), lastCloseCode: 0, lastCloseReason: '' })
|
|
905
|
+
meta.attempt = 0
|
|
906
|
+
meta.failures = 0
|
|
907
|
+
aggregateStreamFailures()
|
|
811
908
|
clearStreamTimers(ws)
|
|
812
|
-
//
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
try { ws.close() } catch {}
|
|
820
|
-
}
|
|
821
|
-
}, 10000)
|
|
822
|
-
// 重连成功:切回 WS 并停止轮询
|
|
823
|
-
if (state.streamMode === 'poll') { stopPolling(); state.streamMode = 'ws' }
|
|
824
|
-
if (streams.mux?.readyState === WebSocket.OPEN && streams.host?.readyState === WebSocket.OPEN) clearReconnect()
|
|
909
|
+
// DSH mux/host 是只下行 WebSocket, 浏览器不能发送应用层 ping。
|
|
910
|
+
// 网关负责 RFC6455 Ping/Pong, 前端只监听业务帧和 close 事件。
|
|
911
|
+
if (state.streamMode === 'poll' && allStreamsOpen()) {
|
|
912
|
+
stopPolling()
|
|
913
|
+
state.streamMode = 'ws'
|
|
914
|
+
}
|
|
915
|
+
if (allStreamsOpen()) clearReconnect()
|
|
825
916
|
updateConn()
|
|
826
917
|
// mux 每次(重)连接都会重放“仍待处理”的审批/提问基线:
|
|
827
918
|
// 先清空旧列表, 避免“桌面已自定义回答, 手机漏收 question/resolved”后永久残留。
|
|
@@ -833,9 +924,8 @@ function openStream(kind, handler, refreshOnOpen, isRestore) {
|
|
|
833
924
|
if (refreshOnOpen) refreshAll()
|
|
834
925
|
}
|
|
835
926
|
ws.onmessage = (msg) => {
|
|
836
|
-
ws
|
|
927
|
+
if (!streamIsCurrent(kind, ws, generation)) return
|
|
837
928
|
state.streamsOk[kind] = true
|
|
838
|
-
state.errCount = 0
|
|
839
929
|
updateConn()
|
|
840
930
|
try {
|
|
841
931
|
const full = JSON.parse(msg.data)
|
|
@@ -844,33 +934,33 @@ function openStream(kind, handler, refreshOnOpen, isRestore) {
|
|
|
844
934
|
}
|
|
845
935
|
ws.onclose = () => {
|
|
846
936
|
clearStreamTimers(ws)
|
|
937
|
+
if (!streamIsCurrent(kind, ws, generation)) return
|
|
938
|
+
streams[kind] = null
|
|
847
939
|
state.streamsOk[kind] = false
|
|
848
|
-
|
|
940
|
+
markStreamInfo(kind, {
|
|
941
|
+
status: 'closed',
|
|
942
|
+
lastCloseAt: Date.now(),
|
|
943
|
+
lastCloseCode: Number(ws.code) || 0,
|
|
944
|
+
lastCloseReason: String(ws.reason || ''),
|
|
945
|
+
})
|
|
946
|
+
meta.failures++
|
|
947
|
+
aggregateStreamFailures()
|
|
849
948
|
updateConn()
|
|
850
949
|
if (!navigator.onLine) { clearReconnect(); return }
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
if (ws._isRestore && streams[kind] === ws) {
|
|
854
|
-
const attempt = ws._attempt || 0
|
|
855
|
-
ws._attempt = attempt + 1
|
|
856
|
-
const baseDelay = Math.min(1200 * Math.pow(2, attempt), 30000)
|
|
857
|
-
const delay = Math.round(baseDelay * (0.8 + Math.random() * 0.4))
|
|
858
|
-
setTimeout(() => openStream(kind, handler, refreshOnOpen, true), delay)
|
|
859
|
-
}
|
|
860
|
-
return
|
|
861
|
-
}
|
|
862
|
-
// 连续失败 3 次 -> 降级为轮询
|
|
863
|
-
if (state.errCount >= 3) { enterPollMode(); return }
|
|
950
|
+
// 任一通道连续失败 3 次就降级轮询;另一个通道不会清零它的失败计数。
|
|
951
|
+
if (state.streamMode !== 'poll' && meta.failures >= 3) { enterPollMode(); return }
|
|
864
952
|
// 多服务器: 连续掉线若干次就重测速, 自动换到当前可达的最快地址
|
|
865
|
-
if (state.servers.length &&
|
|
866
|
-
//
|
|
867
|
-
const attempt =
|
|
868
|
-
|
|
869
|
-
const baseDelay = Math.min(1200 * Math.pow(2, attempt), 30000)
|
|
953
|
+
if (state.servers.length && meta.failures % 5 === 0) setTimeout(() => selectFastestServer({ silent: true }), 300)
|
|
954
|
+
// VPN/跨地域链路使用更宽松的指数退避: 1.5s 起步, 最大 60s, 带 20% 抖动。
|
|
955
|
+
const attempt = meta.attempt++
|
|
956
|
+
const baseDelay = Math.min(1500 * Math.pow(2, attempt), 60000)
|
|
870
957
|
const delay = Math.round(baseDelay * (0.8 + Math.random() * 0.4))
|
|
871
958
|
setReconnect(delay)
|
|
872
|
-
|
|
873
|
-
|
|
959
|
+
clearStreamRetry(kind)
|
|
960
|
+
meta.retryTimer = setTimeout(() => {
|
|
961
|
+
meta.retryTimer = null
|
|
962
|
+
if (state.token && navigator.onLine) openStream(kind, handler, refreshOnOpen, state.streamMode === 'poll')
|
|
963
|
+
}, delay)
|
|
874
964
|
}
|
|
875
965
|
ws.onerror = () => { try { ws.close() } catch {} }
|
|
876
966
|
}
|
|
@@ -881,10 +971,8 @@ function enterPollMode() {
|
|
|
881
971
|
state.streamMode = 'poll'
|
|
882
972
|
state.pollSeq = { mux: 0, host: 0 }
|
|
883
973
|
state.streamsOk = { mux: false, host: false }
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
streams.mux = null
|
|
887
|
-
streams.host = null
|
|
974
|
+
closeStream('mux')
|
|
975
|
+
closeStream('host')
|
|
888
976
|
refreshAll()
|
|
889
977
|
startPolling()
|
|
890
978
|
updateConn()
|
|
@@ -953,8 +1041,8 @@ async function pollKind(kind) {
|
|
|
953
1041
|
function tryRestoreWs() {
|
|
954
1042
|
if (state.streamMode !== 'poll' || !state.token) return
|
|
955
1043
|
// 轮询继续跑,等 WS 真正 onopen 后再切回,避免重连窗口丢事件
|
|
956
|
-
openStream('mux', onMuxFrame, true, true)
|
|
957
|
-
openStream('host', onHostFrame, false, true)
|
|
1044
|
+
if (!streams.mux && !streamMeta.mux.retryTimer) openStream('mux', onMuxFrame, true, true)
|
|
1045
|
+
if (!streams.host && !streamMeta.host.retryTimer) openStream('host', onHostFrame, false, true)
|
|
958
1046
|
}
|
|
959
1047
|
|
|
960
1048
|
/* 回前台恢复: 强制重排修复 MIUI WebView 后台切回时 sticky 顶栏不绘制的问题 */
|
|
@@ -995,8 +1083,8 @@ setInterval(() => {
|
|
|
995
1083
|
/* 网络感知: 离线立刻关 WS + 显示离线, 在线立即重连 */
|
|
996
1084
|
window.addEventListener('offline', () => {
|
|
997
1085
|
clearReconnect()
|
|
998
|
-
|
|
999
|
-
|
|
1086
|
+
closeStream('mux')
|
|
1087
|
+
closeStream('host')
|
|
1000
1088
|
if (state.streamMode === 'poll') stopPolling()
|
|
1001
1089
|
updateConn()
|
|
1002
1090
|
})
|
|
@@ -11,6 +11,16 @@ const LS = {
|
|
|
11
11
|
set(k, v) { try { localStorage.setItem(k, v) } catch {} },
|
|
12
12
|
del(k) { try { localStorage.removeItem(k) } catch {} }
|
|
13
13
|
}
|
|
14
|
+
const CLIENT_ID = (() => {
|
|
15
|
+
try {
|
|
16
|
+
let id = sessionStorage.getItem('dshRemoteClientId')
|
|
17
|
+
if (!id) {
|
|
18
|
+
id = (globalThis.crypto?.randomUUID?.() || `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`)
|
|
19
|
+
sessionStorage.setItem('dshRemoteClientId', id)
|
|
20
|
+
}
|
|
21
|
+
return id
|
|
22
|
+
} catch { return '' }
|
|
23
|
+
})()
|
|
14
24
|
const CAP = window.Capacitor || null
|
|
15
25
|
|
|
16
26
|
/* ---------------- 皮肤 ---------------- */
|
|
@@ -40,6 +50,7 @@ themeApply()
|
|
|
40
50
|
/* ---------------- 状态 ---------------- */
|
|
41
51
|
const state = {
|
|
42
52
|
token: LS.get('token', ''),
|
|
53
|
+
wsTicket: { token: '', server: '', value: '', expiresAt: 0 },
|
|
43
54
|
server: '',
|
|
44
55
|
servers: [],
|
|
45
56
|
groups: ['默认'],
|
|
@@ -59,6 +70,10 @@ const state = {
|
|
|
59
70
|
questionModal: null,
|
|
60
71
|
streamsOk: { mux: false, host: false },
|
|
61
72
|
errCount: 0,
|
|
73
|
+
streamInfo: {
|
|
74
|
+
mux: { status: 'idle', lastOpenAt: 0, lastCloseAt: 0, lastCloseCode: 0, lastCloseReason: '' },
|
|
75
|
+
host: { status: 'idle', lastOpenAt: 0, lastCloseAt: 0, lastCloseCode: 0, lastCloseReason: '' },
|
|
76
|
+
},
|
|
62
77
|
streamMode: 'ws', // 'ws' | 'poll'
|
|
63
78
|
pollSeq: { mux: 0, host: 0 },
|
|
64
79
|
fs: { path: null, initial: null, loaded: false },
|
|
@@ -499,6 +514,27 @@ function hideTip() { const tip = $('ds-tip'); if (tip) tip.classList.add('hidden
|
|
|
499
514
|
|
|
500
515
|
/* ---------------- API ---------------- */
|
|
501
516
|
function apiUrl(path) { return (state.server || '') + path }
|
|
517
|
+
let wsTicketPromise = null
|
|
518
|
+
async function getWsTicket() {
|
|
519
|
+
const now = Date.now()
|
|
520
|
+
if (state.wsTicket.token === state.token && state.wsTicket.server === state.server &&
|
|
521
|
+
state.wsTicket.value && state.wsTicket.expiresAt > now + 15000) return state.wsTicket.value
|
|
522
|
+
if (wsTicketPromise) return wsTicketPromise
|
|
523
|
+
const token = state.token
|
|
524
|
+
const server = state.server
|
|
525
|
+
wsTicketPromise = (async () => {
|
|
526
|
+
const res = await fetch(apiUrl('/api/ws-ticket'), {
|
|
527
|
+
method: 'POST',
|
|
528
|
+
headers: { authorization: 'Bearer ' + token, 'x-dsh-remote-client': 'web' }
|
|
529
|
+
})
|
|
530
|
+
if (!res.ok) throw new Error('ws ticket HTTP ' + res.status)
|
|
531
|
+
const data = await res.json()
|
|
532
|
+
if (!data?.ticket || !Number(data.expiresAt)) throw new Error('invalid ws ticket')
|
|
533
|
+
state.wsTicket = { token, server, value: data.ticket, expiresAt: Number(data.expiresAt) }
|
|
534
|
+
return data.ticket
|
|
535
|
+
})()
|
|
536
|
+
try { return await wsTicketPromise } finally { wsTicketPromise = null }
|
|
537
|
+
}
|
|
502
538
|
async function rpc(method, payload = {}, timeoutMs = 45000) {
|
|
503
539
|
const opts = {
|
|
504
540
|
method: 'POST',
|
|
@@ -818,12 +854,47 @@ function deleteGroup(name) {
|
|
|
818
854
|
}
|
|
819
855
|
|
|
820
856
|
/* ---------------- 事件流 (WebSocket + 轮询降级) ---------------- */
|
|
857
|
+
const streamMeta = {
|
|
858
|
+
mux: { generation: 0, attempt: 0, failures: 0, retryTimer: null },
|
|
859
|
+
host: { generation: 0, attempt: 0, failures: 0, retryTimer: null },
|
|
860
|
+
}
|
|
861
|
+
|
|
821
862
|
function clearStreamTimers(ws) {
|
|
822
863
|
if (!ws) return
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
864
|
+
if (ws._retryTimer) clearTimeout(ws._retryTimer)
|
|
865
|
+
ws._retryTimer = null
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
function streamIsCurrent(kind, ws, generation) {
|
|
869
|
+
return streams[kind] === ws && streamMeta[kind].generation === generation
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
function aggregateStreamFailures() {
|
|
873
|
+
state.errCount = Math.max(streamMeta.mux.failures, streamMeta.host.failures)
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
function markStreamInfo(kind, patch) {
|
|
877
|
+
state.streamInfo[kind] = { ...state.streamInfo[kind], ...patch }
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
function allStreamsOpen() {
|
|
881
|
+
return streams.mux?.readyState === WebSocket.OPEN && streams.host?.readyState === WebSocket.OPEN
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
function clearStreamRetry(kind) {
|
|
885
|
+
const meta = streamMeta[kind]
|
|
886
|
+
if (meta.retryTimer) clearTimeout(meta.retryTimer)
|
|
887
|
+
meta.retryTimer = null
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
function closeStream(kind) {
|
|
891
|
+
const meta = streamMeta[kind]
|
|
892
|
+
clearStreamRetry(kind)
|
|
893
|
+
meta.generation++
|
|
894
|
+
const ws = streams[kind]
|
|
895
|
+
streams[kind] = null
|
|
896
|
+
state.streamsOk[kind] = false
|
|
897
|
+
try { ws?.close() } catch {}
|
|
827
898
|
}
|
|
828
899
|
|
|
829
900
|
function clearConnTick() {
|
|
@@ -855,78 +926,97 @@ function clearReconnect() {
|
|
|
855
926
|
|
|
856
927
|
function openStreams() {
|
|
857
928
|
if (!state.token) return
|
|
858
|
-
if (state.streamMode
|
|
859
|
-
state.streamMode = 'ws'
|
|
860
|
-
clearReconnect()
|
|
929
|
+
if (state.streamMode !== 'poll') state.streamMode = 'ws'
|
|
861
930
|
openStream('mux', onMuxFrame, true)
|
|
862
931
|
openStream('host', onHostFrame, false)
|
|
863
932
|
}
|
|
864
|
-
function openStream(kind, handler, refreshOnOpen, isRestore) {
|
|
933
|
+
function openStream(kind, handler, refreshOnOpen, isRestore, ticket = null) {
|
|
865
934
|
if (!state.token) return
|
|
935
|
+
if (ticket === null) {
|
|
936
|
+
const token = state.token
|
|
937
|
+
void getWsTicket().then((value) => {
|
|
938
|
+
if (state.token === token) openStream(kind, handler, refreshOnOpen, isRestore, value)
|
|
939
|
+
}).catch(() => {
|
|
940
|
+
// 兼容旧网关/插件副本: ticket 接口不可用时临时回退旧 token 握手。
|
|
941
|
+
if (state.token === token) openStream(kind, handler, refreshOnOpen, isRestore, '')
|
|
942
|
+
})
|
|
943
|
+
return
|
|
944
|
+
}
|
|
866
945
|
let base
|
|
867
946
|
if (state.server) base = state.server.replace(/^http/, 'ws')
|
|
868
947
|
else { const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'; base = `${proto}//${location.host}` }
|
|
869
|
-
const
|
|
870
|
-
|
|
948
|
+
const auth = ticket ? `ticket=${encodeURIComponent(ticket)}` : `token=${encodeURIComponent(state.token)}`
|
|
949
|
+
const clientId = CLIENT_ID ? `&clientId=${encodeURIComponent(CLIENT_ID)}` : ''
|
|
950
|
+
const streamUrl = `${base}/api/events.${kind}?${auth}&client=web${clientId}`
|
|
951
|
+
const current = streams[kind]
|
|
952
|
+
if (current?._streamUrl === streamUrl &&
|
|
953
|
+
(current.readyState === WebSocket.OPEN || current.readyState === WebSocket.CONNECTING)) return
|
|
954
|
+
if (current && current._streamUrl !== streamUrl) {
|
|
955
|
+
streamMeta[kind].attempt = 0
|
|
956
|
+
streamMeta[kind].failures = 0
|
|
957
|
+
aggregateStreamFailures()
|
|
958
|
+
}
|
|
959
|
+
closeStream(kind)
|
|
960
|
+
const meta = streamMeta[kind]
|
|
961
|
+
const generation = meta.generation
|
|
962
|
+
const ws = new WebSocket(streamUrl)
|
|
871
963
|
streams[kind] = ws
|
|
872
|
-
ws.
|
|
873
|
-
ws.
|
|
964
|
+
ws._streamUrl = streamUrl
|
|
965
|
+
ws._generation = generation
|
|
874
966
|
ws._isRestore = !!isRestore
|
|
875
967
|
ws.onopen = () => {
|
|
968
|
+
if (!streamIsCurrent(kind, ws, generation)) return
|
|
876
969
|
state.streamsOk[kind] = true
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
970
|
+
markStreamInfo(kind, { status: 'open', lastOpenAt: Date.now(), lastCloseCode: 0, lastCloseReason: '' })
|
|
971
|
+
meta.attempt = 0
|
|
972
|
+
meta.failures = 0
|
|
973
|
+
aggregateStreamFailures()
|
|
880
974
|
clearStreamTimers(ws)
|
|
881
|
-
//
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
try { ws.close() } catch {}
|
|
889
|
-
}
|
|
890
|
-
}, 10000)
|
|
891
|
-
if (state.streamMode === 'poll') { stopPolling(); state.streamMode = 'ws' }
|
|
892
|
-
if (streams.mux?.readyState === WebSocket.OPEN && streams.host?.readyState === WebSocket.OPEN) clearReconnect()
|
|
975
|
+
// DSH mux/host 是只下行 WebSocket, 浏览器不能发送应用层 ping。
|
|
976
|
+
// 网关负责 RFC6455 Ping/Pong, 前端只监听业务帧和 close 事件。
|
|
977
|
+
if (state.streamMode === 'poll' && allStreamsOpen()) {
|
|
978
|
+
stopPolling()
|
|
979
|
+
state.streamMode = 'ws'
|
|
980
|
+
}
|
|
981
|
+
if (allStreamsOpen()) clearReconnect()
|
|
893
982
|
updateConn()
|
|
894
983
|
if (kind === 'mux') { state.approvals = []; state.questions = []; renderNotifStack() }
|
|
895
984
|
if (refreshOnOpen) refreshSessions()
|
|
896
985
|
}
|
|
897
986
|
ws.onmessage = (msg) => {
|
|
898
|
-
ws
|
|
987
|
+
if (!streamIsCurrent(kind, ws, generation)) return
|
|
899
988
|
state.streamsOk[kind] = true
|
|
900
|
-
state.errCount = 0
|
|
901
989
|
updateConn()
|
|
902
990
|
try { handler(JSON.parse(msg.data)) } catch {}
|
|
903
991
|
}
|
|
904
992
|
ws.onclose = () => {
|
|
905
993
|
clearStreamTimers(ws)
|
|
994
|
+
if (!streamIsCurrent(kind, ws, generation)) return
|
|
995
|
+
streams[kind] = null
|
|
906
996
|
state.streamsOk[kind] = false
|
|
907
|
-
|
|
997
|
+
markStreamInfo(kind, {
|
|
998
|
+
status: 'closed',
|
|
999
|
+
lastCloseAt: Date.now(),
|
|
1000
|
+
lastCloseCode: Number(ws.code) || 0,
|
|
1001
|
+
lastCloseReason: String(ws.reason || ''),
|
|
1002
|
+
})
|
|
1003
|
+
meta.failures++
|
|
1004
|
+
aggregateStreamFailures()
|
|
908
1005
|
updateConn()
|
|
909
1006
|
if (!navigator.onLine) { clearReconnect(); return }
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
const delay = Math.round(baseDelay * (0.8 + Math.random() * 0.4))
|
|
917
|
-
setTimeout(() => openStream(kind, handler, refreshOnOpen, true), delay)
|
|
918
|
-
}
|
|
919
|
-
return
|
|
920
|
-
}
|
|
921
|
-
if (state.errCount >= 3) { enterPollMode(); return }
|
|
922
|
-
if (state.servers.length && state.errCount % 5 === 0) setTimeout(() => selectFastestServer({ silent: true }), 300)
|
|
923
|
-
// 指数退避 + 20% 抖动: min(1200 * 2^attempt, 30000)
|
|
924
|
-
const attempt = ws._attempt || 0
|
|
925
|
-
ws._attempt = attempt + 1
|
|
926
|
-
const baseDelay = Math.min(1200 * Math.pow(2, attempt), 30000)
|
|
1007
|
+
// 任一通道连续失败 3 次就降级轮询;另一个通道不会清零它的失败计数。
|
|
1008
|
+
if (state.streamMode !== 'poll' && meta.failures >= 3) { enterPollMode(); return }
|
|
1009
|
+
if (state.servers.length && meta.failures % 5 === 0) setTimeout(() => selectFastestServer({ silent: true }), 300)
|
|
1010
|
+
// VPN/跨地域链路使用更宽松的指数退避: 1.5s 起步, 最大 60s, 带 20% 抖动。
|
|
1011
|
+
const attempt = meta.attempt++
|
|
1012
|
+
const baseDelay = Math.min(1500 * Math.pow(2, attempt), 60000)
|
|
927
1013
|
const delay = Math.round(baseDelay * (0.8 + Math.random() * 0.4))
|
|
928
1014
|
setReconnect(delay)
|
|
929
|
-
|
|
1015
|
+
clearStreamRetry(kind)
|
|
1016
|
+
meta.retryTimer = setTimeout(() => {
|
|
1017
|
+
meta.retryTimer = null
|
|
1018
|
+
if (state.token && navigator.onLine) openStream(kind, handler, refreshOnOpen, state.streamMode === 'poll')
|
|
1019
|
+
}, delay)
|
|
930
1020
|
}
|
|
931
1021
|
ws.onerror = () => { try { ws.close() } catch {} }
|
|
932
1022
|
}
|
|
@@ -937,10 +1027,8 @@ function enterPollMode() {
|
|
|
937
1027
|
state.streamMode = 'poll'
|
|
938
1028
|
state.pollSeq = { mux: 0, host: 0 }
|
|
939
1029
|
state.streamsOk = { mux: false, host: false }
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
streams.mux = null
|
|
943
|
-
streams.host = null
|
|
1030
|
+
closeStream('mux')
|
|
1031
|
+
closeStream('host')
|
|
944
1032
|
refreshSessions()
|
|
945
1033
|
startPolling()
|
|
946
1034
|
updateConn()
|
|
@@ -1003,15 +1091,15 @@ async function pollKind(kind) {
|
|
|
1003
1091
|
function tryRestoreWs() {
|
|
1004
1092
|
if (state.streamMode !== 'poll' || !state.token) return
|
|
1005
1093
|
// 轮询继续跑,等 WS 真正 onopen 后再切回,避免重连窗口丢事件
|
|
1006
|
-
openStream('mux', onMuxFrame, true, true)
|
|
1007
|
-
openStream('host', onHostFrame, false, true)
|
|
1094
|
+
if (!streams.mux && !streamMeta.mux.retryTimer) openStream('mux', onMuxFrame, true, true)
|
|
1095
|
+
if (!streams.host && !streamMeta.host.retryTimer) openStream('host', onHostFrame, false, true)
|
|
1008
1096
|
}
|
|
1009
1097
|
|
|
1010
1098
|
/* 网络感知: 离线立刻关 WS + 显示离线, 在线立即重连 */
|
|
1011
1099
|
window.addEventListener('offline', () => {
|
|
1012
1100
|
clearReconnect()
|
|
1013
|
-
|
|
1014
|
-
|
|
1101
|
+
closeStream('mux')
|
|
1102
|
+
closeStream('host')
|
|
1015
1103
|
if (state.streamMode === 'poll') stopPolling()
|
|
1016
1104
|
updateConn()
|
|
1017
1105
|
})
|
package/public/update.json
CHANGED
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version": "0.6.
|
|
2
|
+
"version": "0.6.9",
|
|
3
3
|
"apkUrl": "dsh-remote.apk",
|
|
4
|
-
"sha256": "
|
|
5
|
-
"releasedAt": "2026-08-
|
|
6
|
-
"notes": "0.6.
|
|
4
|
+
"sha256": "b8d869a6a6840261bc511005e4c86e3a2853df15f29ee33406e5c9e1f21f5796",
|
|
5
|
+
"releasedAt": "2026-08-21T19:36:51.824Z",
|
|
6
|
+
"notes": "0.6.9 正式版:全面优化网络连接稳定性,重构网关实时链路与连接管理;修复 downlink-only 心跳断联、网关重启风暴和 WebSocket 状态竞态;新增 VPN 友好 Ping/Pong、集中 mux/host collector、短时 WebSocket ticket、CORS 收紧与统计旁路优化。",
|
|
7
7
|
"history": [
|
|
8
|
+
{
|
|
9
|
+
"version": "0.6.9",
|
|
10
|
+
"notes": "0.6.9 正式版:全面优化网络连接稳定性,重构网关实时链路与连接管理;修复 downlink-only 心跳断联、网关重启风暴和 WebSocket 状态竞态;新增 VPN 友好 Ping/Pong、集中 mux/host collector、短时 WebSocket ticket、CORS 收紧与统计旁路优化。"
|
|
11
|
+
},
|
|
8
12
|
{
|
|
9
13
|
"version": "0.6.8",
|
|
10
14
|
"notes": "0.6.8 正式版:重构手机端、桌面端和插件管理界面;新增工作台、图片附件、历史公告和全屏输入;修复会话布局、系统提示裁切、主题图标对比度与全屏输入高度问题。"
|