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/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', 'GATEWAY_WS_IDLE_MS',
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
- if (versionMismatch || health.upstreamOk === false || (oldUpstream && oldUpstream !== upstream) || (!oldUpstream && upstream)) {
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.8",
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
- clearInterval(ws._hbTimer)
749
- clearInterval(ws._staleTimer)
750
- ws._hbTimer = null
751
- ws._staleTimer = null
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 === 'poll') stopPolling()
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 ws = new WebSocket(`${base}/api/events.${kind}?token=${encodeURIComponent(state.token)}&client=${clientMark}`)
801
- try { streams[kind]?.close() } catch {}
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._attempt = 0
804
- ws._lastMsgAt = 0
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
- state.errCount = 0
809
- ws._attempt = 0
810
- ws._lastMsgAt = Date.now()
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
- // 应用层心跳: 25s 发纯文本 ping, NAT/WiFi 切换后的 WS 半开假活
813
- ws._hbTimer = setInterval(() => {
814
- try { if (ws.readyState === WebSocket.OPEN) ws.send('ping') } catch {}
815
- }, 25000)
816
- // 60s 没有任何消息(含 pong/业务帧)就主动断开, 触发指数退避重连
817
- ws._staleTimer = setInterval(() => {
818
- if (ws.readyState === WebSocket.OPEN && Date.now() - (ws._lastMsgAt || 0) > 60000) {
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._lastMsgAt = Date.now()
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
- state.errCount++
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
- if (state.streamMode === 'poll') {
852
- // 降级轮询期间: 恢复尝试失败也用退避, 避免 30s 固定间隔内空转
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 && state.errCount % 5 === 0) setTimeout(() => selectFastestServer({ silent: true }), 300)
866
- // 指数退避 + 20% 抖动: min(1200 * 2^attempt, 30000)
867
- const attempt = ws._attempt || 0
868
- ws._attempt = attempt + 1
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
- // 页面被挂起时定时器暂停, visibilitychange 会再触发一次
873
- if (streams[kind] === ws) setTimeout(() => openStream(kind, handler, refreshOnOpen), delay)
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
- try { streams.mux?.close() } catch {}
885
- try { streams.host?.close() } catch {}
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
- try { streams.mux?.close() } catch {}
999
- try { streams.host?.close() } catch {}
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
- clearInterval(ws._hbTimer)
824
- clearInterval(ws._staleTimer)
825
- ws._hbTimer = null
826
- ws._staleTimer = null
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 === 'poll') stopPolling()
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 ws = new WebSocket(`${base}/api/events.${kind}?token=${encodeURIComponent(state.token)}&client=web`)
870
- try { streams[kind]?.close() } catch {}
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._attempt = 0
873
- ws._lastMsgAt = 0
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
- state.errCount = 0
878
- ws._attempt = 0
879
- ws._lastMsgAt = Date.now()
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
- // 应用层心跳: 25s 发纯文本 ping, NAT/WiFi 切换后的 WS 半开假活
882
- ws._hbTimer = setInterval(() => {
883
- try { if (ws.readyState === WebSocket.OPEN) ws.send('ping') } catch {}
884
- }, 25000)
885
- // 60s 没有任何消息(含 pong/业务帧)就主动断开, 触发指数退避重连
886
- ws._staleTimer = setInterval(() => {
887
- if (ws.readyState === WebSocket.OPEN && Date.now() - (ws._lastMsgAt || 0) > 60000) {
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._lastMsgAt = Date.now()
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
- state.errCount++
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
- if (state.streamMode === 'poll') {
911
- // 降级轮询期间: 恢复尝试失败也用退避, 避免 30s 固定间隔内空转
912
- if (ws._isRestore && streams[kind] === ws) {
913
- const attempt = ws._attempt || 0
914
- ws._attempt = attempt + 1
915
- const baseDelay = Math.min(1200 * Math.pow(2, attempt), 30000)
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
- if (streams[kind] === ws) setTimeout(() => openStream(kind, handler, refreshOnOpen), delay)
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
- try { streams.mux?.close() } catch {}
941
- try { streams.host?.close() } catch {}
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
- try { streams.mux?.close() } catch {}
1014
- try { streams.host?.close() } catch {}
1101
+ closeStream('mux')
1102
+ closeStream('host')
1015
1103
  if (state.streamMode === 'poll') stopPolling()
1016
1104
  updateConn()
1017
1105
  })
@@ -1,10 +1,14 @@
1
1
  {
2
- "version": "0.6.8",
2
+ "version": "0.6.9",
3
3
  "apkUrl": "dsh-remote.apk",
4
- "sha256": "e3f1e98bb3a1c2093182bbfa12c72411613a188d1c822b8c452cbbbe011707ef",
5
- "releasedAt": "2026-08-21T17:13:45.607Z",
6
- "notes": "0.6.8 正式版:重构手机端、桌面端和插件管理界面;新增工作台、图片附件、历史公告和全屏输入;修复会话布局、系统提示裁切、主题图标对比度与全屏输入高度问题。",
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 正式版:重构手机端、桌面端和插件管理界面;新增工作台、图片附件、历史公告和全屏输入;修复会话布局、系统提示裁切、主题图标对比度与全屏输入高度问题。"