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.
- package/apk/dsh-remote.apk +0 -0
- package/gateway.cjs +128 -0
- package/package.json +1 -1
- package/public/app.js +145 -6
- package/public/desktop/desktop.html +2 -0
- package/public/desktop/desktop.js +92 -4
- package/public/index.html +4 -0
- package/public/update.json +4 -3
- package/public/version.json +1 -1
package/apk/dsh-remote.apk
CHANGED
|
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.
|
|
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.
|
|
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
|
|
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 => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[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',
|
package/public/update.json
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version": "0.
|
|
2
|
+
"version": "0.6.0",
|
|
3
3
|
"apkUrl": "dsh-remote.apk",
|
|
4
|
-
"
|
|
5
|
-
"
|
|
4
|
+
"sha256": "ebab5d1ce9c669c50742a121f23d29ca4d8e38d60bee8fe95ba112db6761b788",
|
|
5
|
+
"releasedAt": "2026-08-18T13:08:00.384Z",
|
|
6
|
+
"notes": "新增事件轮询降级(公网隧道网络下自动切换,消息不丢、延迟数秒);更新下载完整性校验(SHA-256);网关稳定性加固(设备自动清理、发布流程修复);文档重构(能力对比表 / FAQ)"
|
|
6
7
|
}
|
package/public/version.json
CHANGED