dsh-remote-plugin 0.5.9 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Binary file
package/gateway.cjs CHANGED
@@ -182,9 +182,18 @@ function authorized(req, url) {
182
182
 
183
183
  // ---------- 设备监控 ----------
184
184
  const devices = new Map() // ip -> device
185
+ // 设备 TTL 是“记录保留时间”,和下方 online 判断的 60s 活跃窗口是两回事:
186
+ // online 只看最近 60s 是否有请求;TTL 用于防止长期运行的网关内存/响应无限膨胀。
187
+ const DEVICE_TTL_MS = 24 * 60 * 60 * 1000
185
188
  let totalRequests = 0
186
189
  let authFailures = 0
187
190
 
191
+ function pruneDevices(now = Date.now()) {
192
+ for (const [ip, d] of devices) {
193
+ if (now - d.lastSeen > DEVICE_TTL_MS) devices.delete(ip)
194
+ }
195
+ }
196
+
188
197
  function loadNotes() {
189
198
  try { return JSON.parse(fs.readFileSync(NOTES_FILE, 'utf8')) } catch { return {} }
190
199
  }
@@ -211,6 +220,7 @@ function kindOf(req) {
211
220
  }
212
221
 
213
222
  function touchDevice(req, extra = {}) {
223
+ pruneDevices()
214
224
  const ip = ipOf(req)
215
225
  totalRequests++
216
226
  let d = devices.get(ip)
@@ -402,6 +412,120 @@ function cors(res) {
402
412
  res.setHeader('access-control-allow-methods', 'GET, POST, OPTIONS')
403
413
  }
404
414
 
415
+ // ---------- 事件轮询缓冲 ----------
416
+ // 网关自己维护到 DSH 的 mux/host WebSocket,把事件写入内存环形缓冲;
417
+ // 前端在 WebSocket 被隧道/受限网络阻断时改走 GET /api/events.poll 增量拉取。
418
+ const EVENT_BUFFER_MAX = 300
419
+ const EVENT_MAX_STRING = 16 * 1024
420
+ const eventBuffers = { mux: [], host: [] }
421
+ const eventNextSeq = { mux: 1, host: 1 }
422
+
423
+ /** 递归截断超大字段,避免单条超大事件撑爆环形缓冲。 */
424
+ function truncateEventValue(v, depth = 0) {
425
+ if (typeof v === 'string') return v.length > EVENT_MAX_STRING ? v.slice(0, EVENT_MAX_STRING) + '…[truncated]' : v
426
+ if (Array.isArray(v)) {
427
+ if (depth > 3 || v.length > 200) return v.slice(0, 200)
428
+ return v.map(x => truncateEventValue(x, depth + 1))
429
+ }
430
+ if (v && typeof v === 'object' && depth <= 3) {
431
+ const out = {}
432
+ for (const k of Object.keys(v)) out[k] = truncateEventValue(v[k], depth + 1)
433
+ return out
434
+ }
435
+ return v
436
+ }
437
+
438
+ function pushEvent(kind, full) {
439
+ if (!eventBuffers[kind] || !full || typeof full !== 'object') return
440
+ const buf = eventBuffers[kind]
441
+ buf.push({ seq: eventNextSeq[kind]++, ts: Date.now(), event: truncateEventValue(full) })
442
+ if (buf.length > EVENT_BUFFER_MAX) buf.shift()
443
+ }
444
+
445
+ function serveEventPoll(req, res, url) {
446
+ if (req.method !== 'GET') {
447
+ res.writeHead(405, { allow: 'GET' })
448
+ res.end()
449
+ return
450
+ }
451
+ if (!authorized(req, url)) {
452
+ authFailures++
453
+ touchDevice(req, { failedAuth: true })
454
+ cors(res)
455
+ res.writeHead(401, { 'content-type': 'application/json; charset=utf-8' })
456
+ res.end(JSON.stringify({ error: 'unauthorized' }))
457
+ return
458
+ }
459
+ touchDevice(req)
460
+ const kind = url.searchParams.get('kind')
461
+ if (kind !== 'mux' && kind !== 'host') {
462
+ cors(res)
463
+ res.writeHead(400, { 'content-type': 'application/json; charset=utf-8' })
464
+ res.end(JSON.stringify({ error: 'bad-kind', detail: 'kind 必须是 mux 或 host' }))
465
+ return
466
+ }
467
+ const sinceRaw = url.searchParams.get('since')
468
+ const since = sinceRaw === null ? 0 : Number(sinceRaw)
469
+ if (!Number.isSafeInteger(since) || since < 0) {
470
+ cors(res)
471
+ res.writeHead(400, { 'content-type': 'application/json; charset=utf-8' })
472
+ res.end(JSON.stringify({ error: 'bad-since', detail: 'since 必须是非负整数' }))
473
+ return
474
+ }
475
+ const buf = eventBuffers[kind]
476
+ const events = buf.filter(r => r.seq > since)
477
+ const latestSeq = buf.length ? buf[buf.length - 1].seq : 0
478
+ const truncated = buf.length > 0 && since < buf[0].seq - 1
479
+ cors(res)
480
+ res.writeHead(200, {
481
+ 'content-type': 'application/json; charset=utf-8',
482
+ 'cache-control': 'no-store'
483
+ })
484
+ res.end(JSON.stringify({ ok: true, kind, since, latestSeq, truncated, events }))
485
+ }
486
+
487
+ /** 网关自带上游事件采集:mux/host 各一条 WS,断线自动重连。 */
488
+ function startEventCollector(kind) {
489
+ if (typeof WebSocket !== 'function') return null
490
+ let ws = null
491
+ let stopped = false
492
+ let retryTimer = null
493
+ const url = `ws://${UPSTREAM.hostname}:${UPSTREAM.port}/api/events.${kind}?client=web`
494
+ const connect = () => {
495
+ if (stopped) return
496
+ try {
497
+ ws = new WebSocket(url)
498
+ } catch {
499
+ retryTimer = setTimeout(connect, 3000)
500
+ return
501
+ }
502
+ ws.onopen = () => {
503
+ if (stopped) { try { ws.close() } catch {} }
504
+ }
505
+ ws.onmessage = (ev) => {
506
+ if (stopped) return
507
+ try {
508
+ const data = typeof ev.data === 'string' ? ev.data : Buffer.isBuffer(ev.data) ? ev.data.toString() : String(ev.data)
509
+ pushEvent(kind, JSON.parse(data))
510
+ } catch {}
511
+ }
512
+ ws.onclose = () => {
513
+ ws = null
514
+ if (!stopped) retryTimer = setTimeout(connect, 3000)
515
+ }
516
+ ws.onerror = () => { try { ws.close() } catch {} }
517
+ }
518
+ connect()
519
+ return {
520
+ kind,
521
+ close() {
522
+ stopped = true
523
+ clearTimeout(retryTimer)
524
+ try { ws?.close() } catch {}
525
+ }
526
+ }
527
+ }
528
+
405
529
  // ---------- 统计 API ----------
406
530
  let statsScanning = false
407
531
  async function scanStatsOnce(delay) {
@@ -1548,6 +1672,7 @@ const server = http.createServer((req, res) => {
1548
1672
  if (url.pathname === '/feedback') return serveFeedback(req, res, url)
1549
1673
  if (url.pathname.startsWith('/admin/api')) return serveAdminApi(req, res, url)
1550
1674
  if (url.pathname.startsWith('/stats')) return serveStats(req, res, url)
1675
+ if (url.pathname === '/api/events.poll') return serveEventPoll(req, res, url)
1551
1676
  if (url.pathname.startsWith('/api/')) return proxyApi(req, res, url)
1552
1677
  if (url.pathname === '/health') return serveHealth(res)
1553
1678
  touchDevice(req)
@@ -1669,6 +1794,9 @@ server.listen(PORT, HOST, () => {
1669
1794
  console.log(' 提示: 监听在 127.0.0.1, 手机请改用 Tailscale serve 或设置 HOST=0.0.0.0')
1670
1795
  }
1671
1796
  console.log(' 上游: ' + UPSTREAM.origin + ' (Ctrl+C 退出)')
1797
+ // 事件轮询缓冲:网关自身上游 WS 采集,断线自动重连
1798
+ startEventCollector('mux')
1799
+ startEventCollector('host')
1672
1800
  // 启动 8 秒后首查, 之后每 6 小时查一次 GitHub/镜像最新版
1673
1801
  setTimeout(() => checkForUpdates(false), 8000)
1674
1802
  setInterval(() => checkForUpdates(false), UPDATE_INTERVAL_MS)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-remote-plugin",
3
- "version": "0.5.9",
3
+ "version": "0.6.0",
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
@@ -57,6 +57,8 @@ const state = {
57
57
  jobs: {}, // sessionId -> jobs
58
58
  history: emptyHistory(),
59
59
  errCount: 0,
60
+ streamMode: 'ws', // 'ws' | 'poll'
61
+ pollSeq: { mux: 0, host: 0 },
60
62
  refreshTimer: null,
61
63
  fs: { path: null, initial: null, loaded: false, upload: null },
62
64
  models: { loaded: false, loading: false, groups: [], current: null, failures: [] }
@@ -697,12 +699,16 @@ function deleteGroup(name) {
697
699
  if (state.token) selectFastestServer({ silent: true })
698
700
  }
699
701
 
700
- /* ---------------- 事件流 (WebSocket) ---------------- */
702
+ /* ---------------- 事件流 (WebSocket + 轮询降级) ---------------- */
701
703
  const streams = {}
702
704
  state.streamsOk = { mux: false, host: false }
705
+ let pollTimer = null
706
+ let wsRetryTimer = null
703
707
 
704
708
  function openStreams() {
705
709
  if (!state.token) return
710
+ if (state.streamMode === 'poll') stopPolling()
711
+ state.streamMode = 'ws'
706
712
  openStream('mux', onMuxFrame, true)
707
713
  openStream('host', onHostFrame, false)
708
714
  }
@@ -723,6 +729,8 @@ function openStream(kind, handler, refreshOnOpen) {
723
729
  ws.onopen = () => {
724
730
  state.streamsOk[kind] = true
725
731
  state.errCount = 0
732
+ // 重连成功:切回 WS 并停止轮询
733
+ if (state.streamMode === 'poll') { stopPolling(); state.streamMode = 'ws' }
726
734
  updateConn()
727
735
  // mux 每次(重)连接都会重放“仍待处理”的审批/提问基线:
728
736
  // 先清空旧列表, 避免“桌面已自定义回答, 手机漏收 question/resolved”后永久残留。
@@ -746,7 +754,9 @@ function openStream(kind, handler, refreshOnOpen) {
746
754
  state.streamsOk[kind] = false
747
755
  state.errCount++
748
756
  updateConn()
749
- if (state.errCount === 3) toast(t('conn.reconnecting'), 'err')
757
+ if (state.streamMode === 'poll') return
758
+ // 连续失败 3 次 -> 降级为轮询
759
+ if (state.errCount >= 3) { enterPollMode(); return }
750
760
  // 多服务器: 连续掉线若干次就重测速, 自动换到当前可达的最快地址
751
761
  if (state.servers.length && state.errCount % 5 === 0) setTimeout(() => selectFastestServer({ silent: true }), 300)
752
762
  // 无条件重连; 页面被挂起时定时器暂停, visibilitychange 会再触发一次
@@ -755,6 +765,78 @@ function openStream(kind, handler, refreshOnOpen) {
755
765
  ws.onerror = () => { try { ws.close() } catch {} }
756
766
  }
757
767
 
768
+ /* ---------------- 轮询降级模式 ---------------- */
769
+ function enterPollMode() {
770
+ if (state.streamMode === 'poll') return
771
+ state.streamMode = 'poll'
772
+ state.pollSeq = { mux: 0, host: 0 }
773
+ state.streamsOk = { mux: false, host: false }
774
+ try { streams.mux?.close() } catch {}
775
+ try { streams.host?.close() } catch {}
776
+ streams.mux = null
777
+ streams.host = null
778
+ refreshAll()
779
+ startPolling()
780
+ updateConn()
781
+ }
782
+
783
+ function stopPolling() {
784
+ clearInterval(pollTimer)
785
+ pollTimer = null
786
+ clearTimeout(wsRetryTimer)
787
+ wsRetryTimer = null
788
+ }
789
+
790
+ function startPolling() {
791
+ stopPolling()
792
+ pollTimer = setInterval(pollOnce, 4000)
793
+ wsRetryTimer = setInterval(tryRestoreWs, 30000)
794
+ pollOnce()
795
+ }
796
+
797
+ let pollInFlight = false
798
+ async function pollOnce() {
799
+ if (state.streamMode !== 'poll' || pollInFlight) return
800
+ pollInFlight = true
801
+ try {
802
+ await Promise.all([pollKind('mux'), pollKind('host')])
803
+ } finally {
804
+ pollInFlight = false
805
+ }
806
+ }
807
+
808
+ async function pollKind(kind) {
809
+ if (state.streamMode !== 'poll') return
810
+ const since = state.pollSeq[kind] || 0
811
+ let res
812
+ try {
813
+ const signal = typeof AbortSignal?.timeout === 'function' ? AbortSignal.timeout(5000) : undefined
814
+ const headers = { authorization: 'Bearer ' + state.token, 'x-dsh-remote-client': CAP?.isNativePlatform?.() ? 'app' : 'web' }
815
+ res = signal ? await fetch(apiUrl(`/api/events.poll?kind=${kind}&since=${since}`), { signal, headers }) : await fetch(apiUrl(`/api/events.poll?kind=${kind}&since=${since}`), { headers })
816
+ } catch { return }
817
+ if (res.status === 401) { authFailure(); return }
818
+ if (!res.ok) return
819
+ let data
820
+ try { data = await res.json() } catch { return }
821
+ if (!data || !Array.isArray(data.events)) return
822
+ // 网关重启后 seq 会重置:落后就从头拉当前缓冲
823
+ if (typeof data.latestSeq === 'number' && data.latestSeq < since) state.pollSeq[kind] = 0
824
+ for (const item of data.events) {
825
+ if (item.seq > (state.pollSeq[kind] || 0)) {
826
+ state.pollSeq[kind] = item.seq
827
+ if (kind === 'mux') onMuxFrame(item.event)
828
+ else onHostFrame(item.event)
829
+ }
830
+ }
831
+ }
832
+
833
+ function tryRestoreWs() {
834
+ if (state.streamMode !== 'poll' || !state.token) return
835
+ // 轮询继续跑,等 WS 真正 onopen 后再切回,避免重连窗口丢事件
836
+ openStream('mux', onMuxFrame, true)
837
+ openStream('host', onHostFrame, false)
838
+ }
839
+
758
840
  /* 回前台恢复: 强制重排修复 MIUI WebView 后台切回时 sticky 顶栏不绘制的问题 */
759
841
  function onResume() {
760
842
  if (document.visibilityState !== 'visible') return
@@ -2201,13 +2283,64 @@ async function checkUpdate(silent) {
2201
2283
  }
2202
2284
  }
2203
2285
 
2204
- function downloadUpdate() {
2286
+ async function sha256Hex(buffer) {
2287
+ const digest = await crypto.subtle.digest('SHA-256', buffer)
2288
+ return [...new Uint8Array(digest)].map(b => b.toString(16).padStart(2, '0')).join('')
2289
+ }
2290
+
2291
+ /**
2292
+ * 下载 APK 并用 update.json 的 sha256 校验。
2293
+ * 返回 { ok, skipped } 或 { ok:false, status | corrupted | network }。
2294
+ * 老产物没有 sha256 时跳过校验;crypto.subtle 不可用也跳过(不阻塞老 WebView)。
2295
+ */
2296
+ async function verifyUpdateApk(info, url) {
2297
+ const expected = String(info.sha256 || '').trim().toLowerCase()
2298
+ if (!expected || !/^[0-9a-f]{64}$/.test(expected)) return { ok: true, skipped: true }
2299
+ let res
2300
+ try {
2301
+ const signal = typeof AbortSignal?.timeout === 'function' ? AbortSignal.timeout(120000) : undefined
2302
+ res = signal ? await fetch(url, { signal }) : await fetch(url)
2303
+ } catch (err) {
2304
+ return { ok: false, network: true, msg: err?.message || '' }
2305
+ }
2306
+ if (!res.ok) return { ok: false, status: res.status }
2307
+ let buf
2308
+ try {
2309
+ buf = await res.arrayBuffer()
2310
+ } catch (err) {
2311
+ return { ok: false, network: true, msg: err?.message || '' }
2312
+ }
2313
+ let actual
2314
+ try {
2315
+ actual = await sha256Hex(buf)
2316
+ } catch {
2317
+ return { ok: true, skipped: true }
2318
+ }
2319
+ if (actual.toLowerCase() !== expected) return { ok: false, corrupted: true }
2320
+ return { ok: true, skipped: false }
2321
+ }
2322
+
2323
+ async function downloadUpdate() {
2205
2324
  const info = state.updateInfo
2206
2325
  if (!info) return
2207
2326
  const base = state.server || ''
2208
2327
  let url
2209
2328
  try { url = new URL(info.apkUrl || 'dsh-remote.apk', base + '/').href }
2210
2329
  catch { url = base + '/' + (info.apkUrl || 'dsh-remote.apk') }
2330
+
2331
+ // 先下载校验再交给原生/浏览器安装;校验失败不进入安装
2332
+ const verify = await verifyUpdateApk(info, url)
2333
+ if (!verify.ok) {
2334
+ if (verify.corrupted) {
2335
+ toast(t('update.corrupted'), 'err')
2336
+ } else if (verify.status) {
2337
+ toast(t('update.serverFileMissing'), 'err')
2338
+ } else {
2339
+ toast(t('update.downloadFailed', { msg: verify.msg || t('fs.networkError') }), 'err')
2340
+ }
2341
+ return
2342
+ }
2343
+
2211
2344
  if (CAP?.isNativePlatform?.()) {
2212
2345
  // Android WebView 原生桥(不依赖 Capacitor 插件路由)
2213
2346
  if (window.NativeUpdate?.downloadAndInstall) {
@@ -2316,14 +2449,20 @@ function showView(id) {
2316
2449
  }
2317
2450
 
2318
2451
  function updateConn() {
2319
- const ok = !!state.streamsOk?.mux
2320
2452
  const el = $('conn-badge')
2321
- el.textContent = ok ? t('conn.on') : t('conn.off')
2322
- el.className = 'topbar-btn conn-badge ' + (ok ? 'on' : 'off')
2323
2453
  const cur = state.servers.find(s => s.url === state.server)
2324
2454
  const ms = state.serverLatency[state.server]
2325
2455
  const curGroup = cur ? cur.group : state.activeGroup
2326
2456
  const curLabel = cur ? (cur.note || cur.url) : (state.server || t('speed.origin'))
2457
+ if (state.streamMode === 'poll') {
2458
+ el.textContent = t('conn.poll')
2459
+ el.className = 'topbar-btn conn-badge off'
2460
+ el.title = t('conn.pollTitle') + ' · ' + t('conn.titleGroup', { group: curGroup, url: curLabel, ms: Number.isFinite(ms) ? ms + 'ms' : '—' })
2461
+ return
2462
+ }
2463
+ const ok = !!state.streamsOk?.mux
2464
+ el.textContent = ok ? t('conn.on') : t('conn.off')
2465
+ el.className = 'topbar-btn conn-badge ' + (ok ? 'on' : 'off')
2327
2466
  el.title = t('conn.titleGroup', { group: curGroup, url: curLabel, ms: Number.isFinite(ms) ? ms + 'ms' : '—' })
2328
2467
  }
2329
2468
 
@@ -218,6 +218,7 @@
218
218
  'ds.feedbackRateLimited': '提交太频繁,请 5 分钟后再试', 'ds.feedbackSubmitFailed': '提交失败:{msg}', 'ds.feedbackNetworkError': '网络错误',
219
219
  'ds.questionTitle': '回答问题', 'ds.ignore': '忽略', 'ds.submit': '提交',
220
220
  'ds.connOn': '已连接', 'ds.connOff': '未连接', 'ds.connIng': '连接中',
221
+ 'ds.connPollTitle': '当前网络不支持实时推送,已降级为轮询(延迟数秒)',
221
222
  'ds.currentServer': '{group} · {url}', 'ds.origin': '当前页面',
222
223
  'ds.toastSent': '已发送', 'ds.toastCopied': '令牌已复制', 'ds.toastAuth': '令牌无效',
223
224
  'ds.toastConnFailed': '连接失败', 'ds.toastOpFailed': '操作失败',
@@ -280,6 +281,7 @@
280
281
  'ds.feedbackRateLimited': 'Too frequent, try again in 5 minutes', 'ds.feedbackSubmitFailed': 'Submit failed: {msg}', 'ds.feedbackNetworkError': 'Network error',
281
282
  'ds.questionTitle': 'Answer question', 'ds.ignore': 'Ignore', 'ds.submit': 'Submit',
282
283
  'ds.connOn': 'Connected', 'ds.connOff': 'Offline', 'ds.connIng': 'Connecting',
284
+ 'ds.connPollTitle': 'Realtime push is unavailable on this network; degraded to polling (a few seconds delay)',
283
285
  'ds.currentServer': '{group} · {url}', 'ds.origin': 'this page',
284
286
  'ds.toastSent': 'Sent', 'ds.toastCopied': 'Token copied', 'ds.toastAuth': 'Invalid token',
285
287
  'ds.toastConnFailed': 'Connection failed', 'ds.toastOpFailed': 'Operation failed',
@@ -57,10 +57,14 @@ const state = {
57
57
  questionModal: null,
58
58
  streamsOk: { mux: false, host: false },
59
59
  errCount: 0,
60
+ streamMode: 'ws', // 'ws' | 'poll'
61
+ pollSeq: { mux: 0, host: 0 },
60
62
  fs: { path: null, initial: null, loaded: false },
61
63
  view: 'sessions'
62
64
  }
63
65
  const streams = {}
66
+ let pollTimer = null
67
+ let wsRetryTimer = null
64
68
 
65
69
  function esc(s) { return String(s ?? '').replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c])) }
66
70
  function short(id) { return '…' + String(id).slice(-8) }
@@ -483,9 +487,11 @@ function deleteGroup(name) {
483
487
  if (state.token) selectFastestServer({ silent: true })
484
488
  }
485
489
 
486
- /* ---------------- 事件流 ---------------- */
490
+ /* ---------------- 事件流 (WebSocket + 轮询降级) ---------------- */
487
491
  function openStreams() {
488
492
  if (!state.token) return
493
+ if (state.streamMode === 'poll') stopPolling()
494
+ state.streamMode = 'ws'
489
495
  openStream('mux', onMuxFrame, true)
490
496
  openStream('host', onHostFrame, false)
491
497
  }
@@ -500,12 +506,14 @@ function openStream(kind, handler, refreshOnOpen) {
500
506
  ws.onopen = () => {
501
507
  state.streamsOk[kind] = true
502
508
  state.errCount = 0
509
+ if (state.streamMode === 'poll') { stopPolling(); state.streamMode = 'ws' }
503
510
  updateConn()
504
511
  if (kind === 'mux') { state.approvals = []; state.questions = []; renderNotifStack() }
505
512
  if (refreshOnOpen) refreshSessions()
506
513
  }
507
514
  ws.onmessage = (msg) => {
508
515
  state.streamsOk[kind] = true
516
+ state.errCount = 0
509
517
  updateConn()
510
518
  try { handler(JSON.parse(msg.data)) } catch {}
511
519
  }
@@ -513,11 +521,84 @@ function openStream(kind, handler, refreshOnOpen) {
513
521
  state.streamsOk[kind] = false
514
522
  state.errCount++
515
523
  updateConn()
524
+ if (state.streamMode === 'poll') return
525
+ if (state.errCount >= 3) { enterPollMode(); return }
516
526
  if (state.servers.length && state.errCount % 5 === 0) setTimeout(() => selectFastestServer({ silent: true }), 300)
517
527
  if (streams[kind] === ws) setTimeout(() => openStream(kind, handler, refreshOnOpen), 1200)
518
528
  }
519
529
  ws.onerror = () => { try { ws.close() } catch {} }
520
530
  }
531
+
532
+ /* ---------------- 轮询降级模式 ---------------- */
533
+ function enterPollMode() {
534
+ if (state.streamMode === 'poll') return
535
+ state.streamMode = 'poll'
536
+ state.pollSeq = { mux: 0, host: 0 }
537
+ state.streamsOk = { mux: false, host: false }
538
+ try { streams.mux?.close() } catch {}
539
+ try { streams.host?.close() } catch {}
540
+ streams.mux = null
541
+ streams.host = null
542
+ refreshSessions()
543
+ startPolling()
544
+ updateConn()
545
+ }
546
+
547
+ function stopPolling() {
548
+ clearInterval(pollTimer)
549
+ pollTimer = null
550
+ clearTimeout(wsRetryTimer)
551
+ wsRetryTimer = null
552
+ }
553
+
554
+ function startPolling() {
555
+ stopPolling()
556
+ pollTimer = setInterval(pollOnce, 4000)
557
+ wsRetryTimer = setInterval(tryRestoreWs, 30000)
558
+ pollOnce()
559
+ }
560
+
561
+ let pollInFlight = false
562
+ async function pollOnce() {
563
+ if (state.streamMode !== 'poll' || pollInFlight) return
564
+ pollInFlight = true
565
+ try {
566
+ await Promise.all([pollKind('mux'), pollKind('host')])
567
+ } finally {
568
+ pollInFlight = false
569
+ }
570
+ }
571
+
572
+ async function pollKind(kind) {
573
+ if (state.streamMode !== 'poll') return
574
+ const since = state.pollSeq[kind] || 0
575
+ let res
576
+ try {
577
+ const signal = typeof AbortSignal?.timeout === 'function' ? AbortSignal.timeout(5000) : undefined
578
+ const headers = { authorization: 'Bearer ' + state.token, 'x-dsh-remote-client': 'web' }
579
+ res = signal ? await fetch(apiUrl(`/api/events.poll?kind=${kind}&since=${since}`), { signal, headers }) : await fetch(apiUrl(`/api/events.poll?kind=${kind}&since=${since}`), { headers })
580
+ } catch { return }
581
+ if (res.status === 401) { toast(t('ds.toastAuth'), 'err'); return }
582
+ if (!res.ok) return
583
+ let data
584
+ try { data = await res.json() } catch { return }
585
+ if (!data || !Array.isArray(data.events)) return
586
+ if (typeof data.latestSeq === 'number' && data.latestSeq < since) state.pollSeq[kind] = 0
587
+ for (const item of data.events) {
588
+ if (item.seq > (state.pollSeq[kind] || 0)) {
589
+ state.pollSeq[kind] = item.seq
590
+ if (kind === 'mux') onMuxFrame(item.event)
591
+ else onHostFrame(item.event)
592
+ }
593
+ }
594
+ }
595
+
596
+ function tryRestoreWs() {
597
+ if (state.streamMode !== 'poll' || !state.token) return
598
+ // 轮询继续跑,等 WS 真正 onopen 后再切回,避免重连窗口丢事件
599
+ openStream('mux', onMuxFrame, true)
600
+ openStream('host', onHostFrame, false)
601
+ }
521
602
  function onMuxFrame(full) {
522
603
  const f = full.payload
523
604
  if (!f) return
@@ -915,14 +996,21 @@ function showView(id) {
915
996
  }
916
997
  function updateConn() {
917
998
  const el = $('conn-badge')
999
+ const cur = state.servers.find(s => s.url === state.server)
1000
+ const group = cur ? cur.group : state.activeGroup
1001
+ const label = cur ? (cur.note || cur.url) : (state.server || t('ds.origin'))
1002
+ if (state.streamMode === 'poll') {
1003
+ el.textContent = '●'
1004
+ el.className = 'ds-conn off'
1005
+ el.title = t('ds.connPollTitle')
1006
+ $('server-badge').textContent = t('ds.currentServer', { group, url: label })
1007
+ return
1008
+ }
918
1009
  const any = Object.values(state.streamsOk).some(Boolean)
919
1010
  const all = state.streamsOk.mux && state.streamsOk.host
920
1011
  el.textContent = '●'
921
1012
  el.className = 'ds-conn ' + (all ? 'on' : any ? 'ing' : '')
922
1013
  el.title = all ? t('ds.connOn') : any ? t('ds.connIng') : t('ds.connOff')
923
- const cur = state.servers.find(s => s.url === state.server)
924
- const group = cur ? cur.group : state.activeGroup
925
- const label = cur ? (cur.note || cur.url) : (state.server || t('ds.origin'))
926
1014
  $('server-badge').textContent = t('ds.currentServer', { group, url: label })
927
1015
  }
928
1016
 
package/public/index.html CHANGED
@@ -348,6 +348,7 @@
348
348
  zh: {
349
349
  'a11y.hostAdmin': '主机管理', 'a11y.refresh': '刷新', 'a11y.more': '更多操作', 'a11y.moreTitle': '指令/权限/模型',
350
350
  'conn.on': '已连接', 'conn.off': '未连接', 'conn.reconnecting': '连接中断,正在重连…',
351
+ 'conn.poll': '轮询', 'conn.pollTitle': '当前网络不支持实时推送,已降级为轮询(延迟数秒)',
351
352
  'conn.titleGroup': '{group} · {url}({ms})',
352
353
  'common.refreshing': '刷新中…',
353
354
  'nav.sessions': '会话', 'nav.files': '文件', 'nav.pending': '待办', 'nav.stats': '统计', 'nav.settings': '设置',
@@ -450,6 +451,7 @@
450
451
  'update.latestV': '已是最新 v{version}', 'update.latestRemote': '最新版本 v{version}', 'update.latestToast': '已是最新版本',
451
452
  'update.checkFailedDesc': '检查失败:{msg}', 'update.checkFailed': '检查更新失败:{msg}',
452
453
  'update.downloadStarted': '开始下载,完成后会弹出安装页', 'update.downloadFailed': '无法启动下载:{msg}',
454
+ 'update.corrupted': '下载文件损坏,请重试', 'update.serverFileMissing': '服务器上还没有对应版本的文件,请稍后再试',
453
455
  'update.installUnsupported': '当前版本不支持 App 内安装,已转浏览器下载',
454
456
  'update.expand': '展开', 'update.collapse': '收起',
455
457
  'scan.imageLoadFailed': '图片加载失败', 'scan.decodeUnsupported': '当前设备不支持图片解码',
@@ -502,6 +504,7 @@
502
504
  en: {
503
505
  'a11y.hostAdmin': 'Host admin', 'a11y.refresh': 'Refresh', 'a11y.more': 'More actions', 'a11y.moreTitle': 'Commands / Permissions / Models',
504
506
  'conn.on': 'Connected', 'conn.off': 'Offline', 'conn.reconnecting': 'Connection lost, reconnecting…',
507
+ 'conn.poll': 'Polling', 'conn.pollTitle': 'Realtime push is unavailable on this network; degraded to polling (a few seconds delay)',
505
508
  'conn.titleGroup': '{group} · {url} ({ms})',
506
509
  'common.refreshing': 'Refreshing…',
507
510
  'nav.sessions': 'Sessions', 'nav.files': 'Files', 'nav.pending': 'Inbox', 'nav.stats': 'Stats', 'nav.settings': 'Settings',
@@ -604,6 +607,7 @@
604
607
  'update.latestV': 'Up to date v{version}', 'update.latestRemote': 'Latest version v{version}', 'update.latestToast': 'Already up to date',
605
608
  'update.checkFailedDesc': 'Check failed: {msg}', 'update.checkFailed': 'Update check failed: {msg}',
606
609
  'update.downloadStarted': 'Downloading, the install page will open when ready', 'update.downloadFailed': 'Could not start download: {msg}',
610
+ 'update.corrupted': 'Downloaded file is corrupted, please retry', 'update.serverFileMissing': 'The file for this version is not on the server yet, please try again later',
607
611
  'update.installUnsupported': 'This version cannot install in-app, opening browser download',
608
612
  'update.expand': 'Expand', 'update.collapse': 'Collapse',
609
613
  'scan.imageLoadFailed': 'Image failed to load', 'scan.decodeUnsupported': 'This device cannot decode images',
@@ -1,6 +1,7 @@
1
1
  {
2
- "version": "0.5.9",
2
+ "version": "0.6.0",
3
3
  "apkUrl": "dsh-remote.apk",
4
- "releasedAt": "2026-08-18T07:05:40.678Z",
5
- "notes": "反馈渠道升级:App 顶栏 / 桌面端侧边栏 / 管理页右上角三端入口;App 与桌面端「写反馈」弹层直接提交,网关转发到反馈收集器(DSH_REMOTE_FEEDBACK_URL 可覆盖,无 token 配置,成功后 1 分钟节流);管理页顶栏滚动置顶并适配窄屏;桌面端反馈按钮与导航项同层级。"
4
+ "sha256": "ebab5d1ce9c669c50742a121f23d29ca4d8e38d60bee8fe95ba112db6761b788",
5
+ "releasedAt": "2026-08-18T13:08:00.384Z",
6
+ "notes": "新增事件轮询降级(公网隧道网络下自动切换,消息不丢、延迟数秒);更新下载完整性校验(SHA-256);网关稳定性加固(设备自动清理、发布流程修复);文档重构(能力对比表 / FAQ)"
6
7
  }
@@ -1,3 +1,3 @@
1
1
  {
2
- "version": "0.5.9"
2
+ "version": "0.6.0"
3
3
  }