dsh-remote-plugin 0.6.13 → 0.6.15
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 +261 -27
- package/package.json +1 -1
- package/public/announcements.json +25 -0
- package/public/app.js +570 -62
- package/public/desktop/desktop.css +42 -7
- package/public/desktop/desktop.html +30 -6
- package/public/desktop/desktop.js +395 -46
- package/public/index.html +57 -20
- package/public/md.js +71 -1
- package/public/morphicons-init.js +41 -0
- package/public/motion.js +469 -0
- package/public/plugin.html +4 -1
- package/public/plugin.js +1 -0
- package/public/styles.css +47 -6
- package/public/transcribe-core.js +74 -0
- package/public/update.json +12 -12
- package/public/vendor/gsap/NOTICE.md +10 -0
- package/public/vendor/gsap/gsap.min.js +11 -0
- package/public/vendor/morphicons/LICENSE +21 -0
- package/public/vendor/morphicons/README.md +10 -0
- package/public/vendor/morphicons/controller-CXZuwJ_M.js +152 -0
- package/public/vendor/morphicons/dom.js +206 -0
- package/public/vendor/morphicons/element.js +261 -0
- package/public/vendor/morphicons/normalize-CYnN3Npw.js +540 -0
- package/public/vendor/morphicons/spring-CFHloqPP.js +623 -0
- package/public/version.json +1 -1
package/apk/dsh-remote.apk
CHANGED
|
Binary file
|
package/gateway.cjs
CHANGED
|
@@ -114,6 +114,8 @@ const CAPABILITIES = Object.freeze({
|
|
|
114
114
|
centralAnnouncements: 2,
|
|
115
115
|
feedback: 1,
|
|
116
116
|
deviceKeys: 1,
|
|
117
|
+
healthProbes: 1,
|
|
118
|
+
resumableUploads: 2,
|
|
117
119
|
})
|
|
118
120
|
|
|
119
121
|
const MIME = {
|
|
@@ -144,6 +146,7 @@ const FS_ROOTS = (process.env.DSH_REMOTE_FS_ROOT || FS_DEFAULT_ROOT)
|
|
|
144
146
|
.map(r => path.resolve(r.trim() === '~' ? FS_DEFAULT_ROOT : r.trim()))
|
|
145
147
|
const FS_WORKSPACE_CACHE_MS = durationEnv('DSH_REMOTE_FS_WORKSPACE_CACHE_MS', 15_000, 1000, 10 * 60_000)
|
|
146
148
|
const FS_MAX_UPLOAD = Number(process.env.DSH_REMOTE_FS_MAX_UPLOAD) || 2 * 1024 * 1024 * 1024
|
|
149
|
+
const FS_UPLOAD_TTL_MS = durationEnv('DSH_REMOTE_FS_UPLOAD_TTL_MS', 24 * 60 * 60 * 1000, 60_000, 7 * 24 * 60 * 60 * 1000)
|
|
147
150
|
let FS_ROOT_REALS = null
|
|
148
151
|
let fsWorkspaceRootsCache = { roots: [], reals: [], fetchedAt: 0 }
|
|
149
152
|
let fsWorkspaceRootsFetch = null
|
|
@@ -400,7 +403,8 @@ function issueWsTicket(auth) {
|
|
|
400
403
|
}
|
|
401
404
|
|
|
402
405
|
// ---------- 设备监控 ----------
|
|
403
|
-
const devices = new Map() // ip -> device
|
|
406
|
+
const devices = new Map() // ip[|clientId] -> device
|
|
407
|
+
const legacyDeviceAliases = new Map() // ip -> { clientId, ua, expiresAt }
|
|
404
408
|
// 设备 TTL 是“记录保留时间”,和下方 online 判断的 60s 活跃窗口是两回事:
|
|
405
409
|
// online 只看最近 60s 是否有请求;TTL 用于防止长期运行的网关内存/响应无限膨胀。
|
|
406
410
|
const DEVICE_TTL_MS = 24 * 60 * 60 * 1000
|
|
@@ -417,6 +421,9 @@ function pruneDevices(now = Date.now()) {
|
|
|
417
421
|
for (const [ip, d] of devices) {
|
|
418
422
|
if (now - d.lastSeen > DEVICE_TTL_MS) devices.delete(ip)
|
|
419
423
|
}
|
|
424
|
+
for (const [ip, alias] of legacyDeviceAliases) {
|
|
425
|
+
if (!alias || alias.expiresAt <= now) legacyDeviceAliases.delete(ip)
|
|
426
|
+
}
|
|
420
427
|
}
|
|
421
428
|
|
|
422
429
|
function loadNotes() {
|
|
@@ -444,10 +451,53 @@ function kindOf(req) {
|
|
|
444
451
|
return 'browser'
|
|
445
452
|
}
|
|
446
453
|
|
|
454
|
+
function mergeDeviceRecords(target, legacy) {
|
|
455
|
+
if (!target || !legacy || target === legacy) return
|
|
456
|
+
target.firstSeen = Math.min(target.firstSeen || Date.now(), legacy.firstSeen || Date.now())
|
|
457
|
+
target.lastSeen = Math.max(target.lastSeen || 0, legacy.lastSeen || 0)
|
|
458
|
+
target.requests += legacy.requests || 0
|
|
459
|
+
target.authFailures += legacy.authFailures || 0
|
|
460
|
+
target.credentialId ||= legacy.credentialId || ''
|
|
461
|
+
if (!target.ua || (legacy.ua && legacy.ua.length > target.ua.length)) target.ua = legacy.ua
|
|
462
|
+
for (const channel of new Set([...Object.keys(legacy.channelCounts || {}), ...Object.keys(target.channelCounts || {})])) {
|
|
463
|
+
target.channelCounts[channel] = (target.channelCounts[channel] || 0) + (legacy.channelCounts?.[channel] || 0)
|
|
464
|
+
target.channels[channel] = !!(target.channelCounts[channel] || target.channels[channel] || legacy.channels?.[channel])
|
|
465
|
+
}
|
|
466
|
+
for (const socket of legacy.sockets || []) target.sockets.add(socket)
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
function legacyDeviceFor(ip, clientId, req) {
|
|
470
|
+
if (!clientId) return null
|
|
471
|
+
const legacy = devices.get(ip)
|
|
472
|
+
if (!legacy || legacy.clientId) return null
|
|
473
|
+
const requestUa = String(req.headers['user-agent'] || '')
|
|
474
|
+
const sameUa = requestUa && legacy.ua && requestUa === legacy.ua
|
|
475
|
+
const legacyBackgroundPoll = /^Dalvik\/2\.1\.0/i.test(legacy.ua || '') && req.headers['x-dsh-remote-client'] === 'app'
|
|
476
|
+
if (!sameUa && !legacyBackgroundPoll) return null
|
|
477
|
+
return legacy
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
function knownDeviceForLegacy(ip, req) {
|
|
481
|
+
const requestUa = String(req.headers['user-agent'] || '')
|
|
482
|
+
if (!requestUa) return null
|
|
483
|
+
const candidates = [...devices.values()].filter(d => {
|
|
484
|
+
if (d.ip !== ip || !d.clientId) return false
|
|
485
|
+
return (d.ua && d.ua === requestUa) || (d.kind === 'app' && /^Dalvik\/2\.1\.0/i.test(requestUa))
|
|
486
|
+
})
|
|
487
|
+
return candidates.length === 1 ? candidates[0] : null
|
|
488
|
+
}
|
|
489
|
+
|
|
447
490
|
function touchDevice(req, extra = {}) {
|
|
448
491
|
pruneDevices()
|
|
449
492
|
const ip = ipOf(req)
|
|
450
|
-
const
|
|
493
|
+
const headerClientId = req.headers['x-dsh-remote-client-id']
|
|
494
|
+
let clientId = String(extra.clientId || headerClientId || '').replace(/[^A-Za-z0-9._~-]/g, '').slice(0, 96)
|
|
495
|
+
const requestUa = String(req.headers['user-agent'] || '')
|
|
496
|
+
if (!clientId) {
|
|
497
|
+
const alias = legacyDeviceAliases.get(ip)
|
|
498
|
+
if (alias && alias.expiresAt > Date.now() && alias.ua && alias.ua === requestUa) clientId = alias.clientId
|
|
499
|
+
else clientId = knownDeviceForLegacy(ip, req)?.clientId || ''
|
|
500
|
+
}
|
|
451
501
|
const deviceKey = clientId ? `${ip}|${clientId}` : ip
|
|
452
502
|
totalRequests++
|
|
453
503
|
let d = devices.get(deviceKey)
|
|
@@ -458,6 +508,14 @@ function touchDevice(req, extra = {}) {
|
|
|
458
508
|
}
|
|
459
509
|
devices.set(deviceKey, d)
|
|
460
510
|
}
|
|
511
|
+
if (clientId && deviceKey !== ip) {
|
|
512
|
+
const legacy = legacyDeviceFor(ip, clientId, req)
|
|
513
|
+
if (legacy && legacy !== d) {
|
|
514
|
+
mergeDeviceRecords(d, legacy)
|
|
515
|
+
devices.delete(ip)
|
|
516
|
+
legacyDeviceAliases.set(ip, { clientId, ua: legacy.ua, expiresAt: Date.now() + DEVICE_TTL_MS })
|
|
517
|
+
}
|
|
518
|
+
}
|
|
461
519
|
d.lastSeen = Date.now()
|
|
462
520
|
d.requests++
|
|
463
521
|
if (extra.channel) {
|
|
@@ -473,7 +531,7 @@ function touchDevice(req, extra = {}) {
|
|
|
473
531
|
if (req.dshRemoteAuth?.type === 'device') d.credentialId = req.dshRemoteAuth.id
|
|
474
532
|
const marked = req.headers['x-dsh-remote-client']
|
|
475
533
|
if (marked) d.kind = marked
|
|
476
|
-
const ua =
|
|
534
|
+
const ua = requestUa
|
|
477
535
|
if (ua && ua.length > d.ua.length) d.ua = ua
|
|
478
536
|
return d
|
|
479
537
|
}
|
|
@@ -713,7 +771,7 @@ function cors(res, req = res.req) {
|
|
|
713
771
|
}
|
|
714
772
|
if (allowed) res.setHeader('access-control-allow-origin', origin || '*')
|
|
715
773
|
res.setHeader('vary', 'Origin')
|
|
716
|
-
res.setHeader('access-control-allow-headers', 'authorization, content-type, x-dsh-remote-client')
|
|
774
|
+
res.setHeader('access-control-allow-headers', 'authorization, content-type, x-dsh-remote-client, x-dsh-remote-client-id')
|
|
717
775
|
res.setHeader('access-control-allow-methods', 'GET, POST, OPTIONS')
|
|
718
776
|
res.setHeader('access-control-max-age', '600')
|
|
719
777
|
}
|
|
@@ -869,6 +927,8 @@ function failDshOperation(operation, code, message, detail = '', status = null)
|
|
|
869
927
|
detail: String(detail || '').slice(0, 1000),
|
|
870
928
|
...(status ? { status } : {}),
|
|
871
929
|
})
|
|
930
|
+
if (status) operation.observed = status
|
|
931
|
+
operation.evidence = { observed: status || operation.observed || null, upstream: operation.upstream || null, events: operation.events || null }
|
|
872
932
|
}
|
|
873
933
|
|
|
874
934
|
function dshEventChannelStatus() {
|
|
@@ -892,6 +952,7 @@ async function runDshControlOperation(operation) {
|
|
|
892
952
|
dshOperationStep(operation, 'checking', `正在检查 systemd 用户服务 ${DSH_SERVICE}`)
|
|
893
953
|
const initial = await dshServiceStatus()
|
|
894
954
|
operation.initialStatus = initial
|
|
955
|
+
operation.observed = initial
|
|
895
956
|
if (!initial.supported) {
|
|
896
957
|
failDshOperation(operation, initial.code || 'UNSUPPORTED', initial.message || '当前 DSH 服务不可控', initial.detail, initial)
|
|
897
958
|
return
|
|
@@ -925,6 +986,7 @@ async function runDshControlOperation(operation) {
|
|
|
925
986
|
const status = await dshServiceStatus()
|
|
926
987
|
lastStatus = status
|
|
927
988
|
operation.status = status
|
|
989
|
+
operation.observed = status
|
|
928
990
|
if (!status.supported) {
|
|
929
991
|
failDshOperation(operation, status.code || 'STATUS_FAILED', status.message || '无法读取 DSH 服务状态', status.detail, status)
|
|
930
992
|
return
|
|
@@ -956,6 +1018,7 @@ async function runDshControlOperation(operation) {
|
|
|
956
1018
|
dshOperationStep(operation, 'complete', `DSH ${operation.action === 'start' ? '启动' : '重启'}成功:服务已运行,HTTP ${lastProbe.status},实时通道已连接,PID ${status.mainPid || '未知'}`, {
|
|
957
1019
|
ok: true, done: true, code: 'SUCCESS', status, upstream: lastProbe, events,
|
|
958
1020
|
})
|
|
1021
|
+
operation.evidence = { observed: status, upstream: lastProbe, events }
|
|
959
1022
|
return
|
|
960
1023
|
}
|
|
961
1024
|
}
|
|
@@ -1055,6 +1118,9 @@ async function serveDshControl(req, res, url) {
|
|
|
1055
1118
|
const now = Date.now()
|
|
1056
1119
|
dshControlOperation = {
|
|
1057
1120
|
operationId: crypto.randomUUID(), action, service: DSH_SERVICE,
|
|
1121
|
+
desired: { service: DSH_SERVICE, running: true, action },
|
|
1122
|
+
observed: null,
|
|
1123
|
+
evidence: null,
|
|
1058
1124
|
ok: false, accepted: true, done: false, stage: 'queued', code: 'ACCEPTED',
|
|
1059
1125
|
message: `已接收 DSH ${action === 'start' ? '启动' : '重启'}请求,等待检查服务`,
|
|
1060
1126
|
startedAt: now, updatedAt: now, steps: [],
|
|
@@ -1070,12 +1136,14 @@ async function serveDshControl(req, res, url) {
|
|
|
1070
1136
|
// 内存环形缓冲并广播给已认证客户端;前端在 WebSocket 被隧道/受限网络
|
|
1071
1137
|
// 阻断时改走 GET /api/events.poll 增量拉取。
|
|
1072
1138
|
const EVENT_BUFFER_MAX = durationEnv('GATEWAY_EVENT_BUFFER_MAX', 1000, 100, 10000)
|
|
1139
|
+
const EVENT_POLL_WAIT_MAX = durationEnv('GATEWAY_EVENT_POLL_WAIT_MS', 25000, 0, 60000)
|
|
1073
1140
|
const EVENT_MAX_STRING = 16 * 1024
|
|
1074
1141
|
const WS_GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11'
|
|
1075
1142
|
const eventBuffers = { mux: [], host: [] }
|
|
1076
1143
|
const eventNextSeq = { mux: 1, host: 1 }
|
|
1077
1144
|
const collectorClients = { mux: new Set(), host: new Set() }
|
|
1078
1145
|
const collectorReplay = { mux: new Map(), host: new Map() }
|
|
1146
|
+
const eventPollWaiters = { mux: new Set(), host: new Set() }
|
|
1079
1147
|
const eventCollectorState = {
|
|
1080
1148
|
mux: { connected: false, lastEventAt: 0, lastConnectAt: 0, reconnects: 0, lastError: '', lastCloseCode: 0, lastCloseReason: '', attempt: 0, clients: 0, framesBroadcast: 0, lastBroadcastAt: 0 },
|
|
1081
1149
|
host: { connected: false, lastEventAt: 0, lastConnectAt: 0, reconnects: 0, lastError: '', lastCloseCode: 0, lastCloseReason: '', attempt: 0, clients: 0, framesBroadcast: 0, lastBroadcastAt: 0 },
|
|
@@ -1158,6 +1226,38 @@ function pushEvent(kind, full, raw = JSON.stringify(full)) {
|
|
|
1158
1226
|
if (buf.length > EVENT_BUFFER_MAX) buf.shift()
|
|
1159
1227
|
rememberCollectorReplay(kind, full, raw)
|
|
1160
1228
|
broadcastCollectorFrame(kind, raw)
|
|
1229
|
+
flushEventPollWaiters(kind)
|
|
1230
|
+
}
|
|
1231
|
+
|
|
1232
|
+
function eventPollPayload(kind, since, waitSupported = false) {
|
|
1233
|
+
const buf = eventBuffers[kind]
|
|
1234
|
+
const events = buf.filter(r => r.seq > since)
|
|
1235
|
+
const latestSeq = buf.length ? buf[buf.length - 1].seq : 0
|
|
1236
|
+
const truncated = buf.length > 0 && since < buf[0].seq - 1
|
|
1237
|
+
return { ok: true, kind, since, latestSeq, truncated, waitSupported, events }
|
|
1238
|
+
}
|
|
1239
|
+
|
|
1240
|
+
function sendEventPollResponse(waiter, payload) {
|
|
1241
|
+
if (waiter.req.destroyed || waiter.res.destroyed) return
|
|
1242
|
+
cors(waiter.res)
|
|
1243
|
+
waiter.res.writeHead(200, {
|
|
1244
|
+
'content-type': 'application/json; charset=utf-8',
|
|
1245
|
+
'cache-control': 'no-store'
|
|
1246
|
+
})
|
|
1247
|
+
waiter.res.end(JSON.stringify(payload))
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1250
|
+
function finishEventPollWaiter(waiter, send = true) {
|
|
1251
|
+
if (!eventPollWaiters[waiter.kind]?.delete(waiter)) return
|
|
1252
|
+
clearTimeout(waiter.timer)
|
|
1253
|
+
if (send) sendEventPollResponse(waiter, eventPollPayload(waiter.kind, waiter.since, true))
|
|
1254
|
+
}
|
|
1255
|
+
|
|
1256
|
+
function flushEventPollWaiters(kind) {
|
|
1257
|
+
for (const waiter of [...eventPollWaiters[kind]]) {
|
|
1258
|
+
const payload = eventPollPayload(kind, waiter.since, true)
|
|
1259
|
+
if (payload.events.length) finishEventPollWaiter(waiter, true)
|
|
1260
|
+
}
|
|
1161
1261
|
}
|
|
1162
1262
|
|
|
1163
1263
|
function serveWsTicket(req, res, url) {
|
|
@@ -1215,16 +1315,33 @@ function serveEventPoll(req, res, url) {
|
|
|
1215
1315
|
res.end(JSON.stringify({ error: 'bad-since', detail: 'since 必须是非负整数' }))
|
|
1216
1316
|
return
|
|
1217
1317
|
}
|
|
1218
|
-
const
|
|
1219
|
-
const
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1318
|
+
const waitRaw = url.searchParams.get('wait')
|
|
1319
|
+
const requestedWait = waitRaw === null ? 0 : Number(waitRaw)
|
|
1320
|
+
if (!Number.isFinite(requestedWait) || requestedWait < 0) {
|
|
1321
|
+
cors(res)
|
|
1322
|
+
res.writeHead(400, { 'content-type': 'application/json; charset=utf-8' })
|
|
1323
|
+
res.end(JSON.stringify({ error: 'bad-wait', detail: 'wait 必须是非负数字' }))
|
|
1324
|
+
return
|
|
1325
|
+
}
|
|
1326
|
+
const wait = Math.min(Math.floor(requestedWait), EVENT_POLL_WAIT_MAX)
|
|
1327
|
+
const waitSupported = wait > 0 && EVENT_POLL_WAIT_MAX > 0
|
|
1328
|
+
const payload = eventPollPayload(kind, since, waitSupported)
|
|
1329
|
+
if (payload.events.length || wait <= 0 || EVENT_POLL_WAIT_MAX <= 0) {
|
|
1330
|
+
cors(res)
|
|
1331
|
+
res.writeHead(200, {
|
|
1332
|
+
'content-type': 'application/json; charset=utf-8',
|
|
1333
|
+
'cache-control': 'no-store'
|
|
1334
|
+
})
|
|
1335
|
+
res.end(JSON.stringify(payload))
|
|
1336
|
+
return
|
|
1337
|
+
}
|
|
1338
|
+
const waiter = { req, res, kind, since, timer: null }
|
|
1339
|
+
eventPollWaiters[kind].add(waiter)
|
|
1340
|
+
waiter.timer = setTimeout(() => finishEventPollWaiter(waiter, true), wait)
|
|
1341
|
+
waiter.timer.unref?.()
|
|
1342
|
+
req.once('close', () => finishEventPollWaiter(waiter, false))
|
|
1343
|
+
// 事件可能刚好在首次检查和加入等待集合之间到达,加入后再检查一次避免漏唤醒。
|
|
1344
|
+
if (eventPollPayload(kind, since, true).events.length) finishEventPollWaiter(waiter, true)
|
|
1228
1345
|
}
|
|
1229
1346
|
|
|
1230
1347
|
/** 网关自带上游事件采集:mux/host 各一条 WS,断线自动重连。 */
|
|
@@ -2004,9 +2121,9 @@ function serveAdminApi(req, res, url) {
|
|
|
2004
2121
|
}
|
|
2005
2122
|
|
|
2006
2123
|
// ---------- /fs 文件传输: 实现 ----------
|
|
2007
|
-
function fsJson(res, status, body) {
|
|
2124
|
+
function fsJson(res, status, body, extraHeaders = {}) {
|
|
2008
2125
|
cors(res)
|
|
2009
|
-
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' })
|
|
2126
|
+
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', ...extraHeaders })
|
|
2010
2127
|
res.end(JSON.stringify(body))
|
|
2011
2128
|
}
|
|
2012
2129
|
|
|
@@ -2118,6 +2235,17 @@ function fsParseRange(header, size) {
|
|
|
2118
2235
|
return { start, end: Math.min(end, size - 1) }
|
|
2119
2236
|
}
|
|
2120
2237
|
|
|
2238
|
+
function fsEntityTag(st) {
|
|
2239
|
+
return `"${Number(st.size).toString(16)}-${Math.floor(Number(st.mtimeMs)).toString(16)}"`
|
|
2240
|
+
}
|
|
2241
|
+
|
|
2242
|
+
function fsIfRangeMatches(value, etag, mtimeMs) {
|
|
2243
|
+
if (!value) return true
|
|
2244
|
+
if (String(value).trim() === etag) return true
|
|
2245
|
+
const time = Date.parse(String(value))
|
|
2246
|
+
return Number.isFinite(time) && time >= Math.floor(Number(mtimeMs) / 1000) * 1000
|
|
2247
|
+
}
|
|
2248
|
+
|
|
2121
2249
|
async function fsList(req, res, url) {
|
|
2122
2250
|
if (req.method !== 'GET') {
|
|
2123
2251
|
res.writeHead(405, { allow: 'GET' })
|
|
@@ -2186,7 +2314,17 @@ async function fsFile(req, res, url) {
|
|
|
2186
2314
|
}
|
|
2187
2315
|
if (!st.isFile()) return fsJson(res, 400, { error: 'not-a-file' })
|
|
2188
2316
|
|
|
2189
|
-
const
|
|
2317
|
+
const etag = fsEntityTag(st)
|
|
2318
|
+
const lastModified = st.mtime.toUTCString()
|
|
2319
|
+
if (!req.headers.range && req.headers['if-none-match'] === etag) {
|
|
2320
|
+
cors(res)
|
|
2321
|
+
res.writeHead(304, { etag, 'last-modified': lastModified, 'cache-control': 'no-cache' })
|
|
2322
|
+
res.end()
|
|
2323
|
+
return
|
|
2324
|
+
}
|
|
2325
|
+
const range = fsIfRangeMatches(req.headers['if-range'], etag, st.mtimeMs)
|
|
2326
|
+
? fsParseRange(req.headers.range, st.size)
|
|
2327
|
+
: null
|
|
2190
2328
|
if (range && range.start >= st.size) {
|
|
2191
2329
|
cors(res)
|
|
2192
2330
|
res.writeHead(416, {
|
|
@@ -2205,6 +2343,8 @@ async function fsFile(req, res, url) {
|
|
|
2205
2343
|
'content-length': range ? range.end - range.start + 1 : st.size,
|
|
2206
2344
|
'content-disposition': fsContentDisposition(path.basename(checked.abs)),
|
|
2207
2345
|
'accept-ranges': 'bytes',
|
|
2346
|
+
etag,
|
|
2347
|
+
'last-modified': lastModified,
|
|
2208
2348
|
'cache-control': 'no-cache',
|
|
2209
2349
|
...(range ? { 'content-range': `bytes ${range.start}-${range.end}/${st.size}` } : {})
|
|
2210
2350
|
})
|
|
@@ -2280,10 +2420,49 @@ function sha256FileHex(file, cb) {
|
|
|
2280
2420
|
|
|
2281
2421
|
/* 进行中的续传写流: 取消时先 destroy 再删分片, 避免“先删后写”竞态 */
|
|
2282
2422
|
const activeUploads = new Map()
|
|
2423
|
+
const uploadPartDirs = new Set()
|
|
2283
2424
|
function fsActiveKey(dirReal, name, session) {
|
|
2284
2425
|
return dirReal + '\n' + name + '\n' + (session || '')
|
|
2285
2426
|
}
|
|
2286
2427
|
|
|
2428
|
+
function rememberUploadDir(dirReal) {
|
|
2429
|
+
uploadPartDirs.add(dirReal)
|
|
2430
|
+
}
|
|
2431
|
+
|
|
2432
|
+
function uploadDirHasActive(dirReal) {
|
|
2433
|
+
const prefix = dirReal + '\n'
|
|
2434
|
+
for (const key of activeUploads.keys()) if (key.startsWith(prefix)) return true
|
|
2435
|
+
return false
|
|
2436
|
+
}
|
|
2437
|
+
|
|
2438
|
+
function cleanupExpiredUploadParts(dirReal = '') {
|
|
2439
|
+
const dirs = dirReal ? [dirReal] : [...uploadPartDirs]
|
|
2440
|
+
const now = Date.now()
|
|
2441
|
+
for (const dir of dirs) {
|
|
2442
|
+
if (uploadDirHasActive(dir)) continue
|
|
2443
|
+
let entries
|
|
2444
|
+
try { entries = fs.readdirSync(dir) } catch { continue }
|
|
2445
|
+
for (const name of entries) {
|
|
2446
|
+
if (!name.startsWith('.') || !name.includes('.dsh-remote-part-')) continue
|
|
2447
|
+
const file = path.join(dir, name)
|
|
2448
|
+
try {
|
|
2449
|
+
const st = fs.statSync(file)
|
|
2450
|
+
if (st.isFile() && now - st.mtimeMs > FS_UPLOAD_TTL_MS) fs.unlinkSync(file)
|
|
2451
|
+
} catch {}
|
|
2452
|
+
}
|
|
2453
|
+
}
|
|
2454
|
+
}
|
|
2455
|
+
|
|
2456
|
+
const uploadCleanupTimer = setInterval(() => cleanupExpiredUploadParts(), 15 * 60 * 1000)
|
|
2457
|
+
uploadCleanupTimer.unref?.()
|
|
2458
|
+
|
|
2459
|
+
function fsUploadHeaders(offset, length = null, expiresAt = null) {
|
|
2460
|
+
const headers = { 'upload-offset': String(Math.max(0, Number(offset) || 0)) }
|
|
2461
|
+
if (Number.isSafeInteger(length) && length >= 0) headers['upload-length'] = String(length)
|
|
2462
|
+
if (Number.isFinite(expiresAt)) headers['upload-expires'] = new Date(expiresAt).toUTCString()
|
|
2463
|
+
return headers
|
|
2464
|
+
}
|
|
2465
|
+
|
|
2287
2466
|
/** 打开上传目标: 同名冲突/符号链接/临时文件都在这层判定。 */
|
|
2288
2467
|
function fsOpenUploadTarget(res, url, dirLex, dirReal, name) {
|
|
2289
2468
|
if (!fsValidName(name)) {
|
|
@@ -2484,14 +2663,22 @@ async function fsUploadProbe(req, res, url) {
|
|
|
2484
2663
|
if (resolved.error) return fsJson(res, resolved.error === 'forbidden' ? 403 : 404, { error: resolved.error })
|
|
2485
2664
|
const checked = fsRealChecked(resolved.abs)
|
|
2486
2665
|
if (checked.error) return fsJson(res, checked.error === 'forbidden' ? 403 : 404, { error: checked.error })
|
|
2666
|
+
rememberUploadDir(checked.abs)
|
|
2667
|
+
cleanupExpiredUploadParts(checked.abs)
|
|
2487
2668
|
const name = url.searchParams.get('name') || ''
|
|
2488
2669
|
if (!fsValidName(name)) return fsJson(res, 400, { error: 'bad-name' })
|
|
2489
2670
|
const part = fsPartPath(checked.abs, name, url.searchParams.get('session') || 'default')
|
|
2490
2671
|
let partialSize = 0, partExists = false
|
|
2672
|
+
let expiresAt = null
|
|
2491
2673
|
try {
|
|
2492
2674
|
const st = fs.statSync(part)
|
|
2493
|
-
if (st.isFile()) { partialSize = st.size; partExists = true }
|
|
2675
|
+
if (st.isFile()) { partialSize = st.size; partExists = true; expiresAt = st.mtimeMs + FS_UPLOAD_TTL_MS }
|
|
2494
2676
|
} catch {}
|
|
2677
|
+
const lengthRaw = url.searchParams.get('size')
|
|
2678
|
+
const uploadLength = lengthRaw === null || lengthRaw === '' ? null : Number(lengthRaw)
|
|
2679
|
+
if (uploadLength !== null && (!Number.isSafeInteger(uploadLength) || uploadLength < 0)) {
|
|
2680
|
+
return fsJson(res, 400, { error: 'bad-length', detail: 'size 必须是非负整数' })
|
|
2681
|
+
}
|
|
2495
2682
|
const target = fsTargetState(path.join(checked.abs, name))
|
|
2496
2683
|
let targetSize = 0
|
|
2497
2684
|
if (target.exists) {
|
|
@@ -2503,8 +2690,10 @@ async function fsUploadProbe(req, res, url) {
|
|
|
2503
2690
|
partialSize,
|
|
2504
2691
|
partExists,
|
|
2505
2692
|
targetExists: !!target.exists,
|
|
2506
|
-
targetSize
|
|
2507
|
-
|
|
2693
|
+
targetSize,
|
|
2694
|
+
uploadLength,
|
|
2695
|
+
expiresAt
|
|
2696
|
+
}, fsUploadHeaders(partialSize, uploadLength, expiresAt))
|
|
2508
2697
|
}
|
|
2509
2698
|
|
|
2510
2699
|
/** POST /fs/mkdir?path=<parent>&name=<directory> 创建一个工作区目录。 */
|
|
@@ -2539,15 +2728,27 @@ async function fsMkdir(req, res, url) {
|
|
|
2539
2728
|
}
|
|
2540
2729
|
|
|
2541
2730
|
function fsUploadResumable(req, res, url, dirLex, dirReal) {
|
|
2731
|
+
rememberUploadDir(dirReal)
|
|
2732
|
+
cleanupExpiredUploadParts(dirReal)
|
|
2542
2733
|
const name = url.searchParams.get('name') || ''
|
|
2543
2734
|
if (!fsValidName(name)) return fsJson(res, 400, { error: 'bad-name', detail: '文件名不能为空且不能包含路径分隔符' })
|
|
2544
2735
|
const session = url.searchParams.get('session') || ''
|
|
2545
2736
|
if (!session) return fsJson(res, 400, { error: 'missing-session', detail: '断点续传需要 session 参数' })
|
|
2546
|
-
const
|
|
2737
|
+
const queryOffsetRaw = url.searchParams.get('offset')
|
|
2738
|
+
const headerOffsetRaw = req.headers['upload-offset']
|
|
2739
|
+
const offsetRaw = queryOffsetRaw ?? headerOffsetRaw
|
|
2547
2740
|
const offset = Number(offsetRaw)
|
|
2548
2741
|
if (!Number.isSafeInteger(offset) || offset < 0) {
|
|
2549
2742
|
return fsJson(res, 400, { error: 'bad-offset', detail: 'offset 必须是非负整数' })
|
|
2550
2743
|
}
|
|
2744
|
+
if (queryOffsetRaw !== null && headerOffsetRaw !== undefined && Number(headerOffsetRaw) !== offset) {
|
|
2745
|
+
return fsJson(res, 400, { error: 'offset-mismatch', detail: 'URL offset 与 Upload-Offset 不一致' })
|
|
2746
|
+
}
|
|
2747
|
+
const lengthRaw = url.searchParams.get('size') ?? req.headers['upload-length']
|
|
2748
|
+
const uploadLength = lengthRaw === null || lengthRaw === undefined || lengthRaw === '' ? null : Number(lengthRaw)
|
|
2749
|
+
if (uploadLength !== null && (!Number.isSafeInteger(uploadLength) || uploadLength < 0)) {
|
|
2750
|
+
return fsJson(res, 400, { error: 'bad-length', detail: 'size/Upload-Length 必须是非负整数' })
|
|
2751
|
+
}
|
|
2551
2752
|
const finish = url.searchParams.get('finish') === '1' || url.searchParams.get('complete') === '1'
|
|
2552
2753
|
const overwrite = url.searchParams.get('overwrite') === '1' || url.searchParams.get('overwrite') === 'true'
|
|
2553
2754
|
const sha256Expected = (url.searchParams.get('sha256') || '').trim().toLowerCase()
|
|
@@ -2632,8 +2833,16 @@ function fsUploadResumable(req, res, url, dirLex, dirReal) {
|
|
|
2632
2833
|
try {
|
|
2633
2834
|
const st = fs.statSync(part)
|
|
2634
2835
|
if (!st.isFile() || st.size !== total) throw new Error('part-size-mismatch')
|
|
2836
|
+
if (uploadLength !== null && total > uploadLength) {
|
|
2837
|
+
fsJson(res, 409, { error: 'length-exceeded', size: total, uploadLength, session }, fsUploadHeaders(total, uploadLength, Date.now() + FS_UPLOAD_TTL_MS))
|
|
2838
|
+
return
|
|
2839
|
+
}
|
|
2635
2840
|
if (!finish) {
|
|
2636
|
-
fsJson(res, 200, { ok: true, partial: true, name, size: total, offset: total, session })
|
|
2841
|
+
fsJson(res, 200, { ok: true, partial: true, name, size: total, offset: total, session, uploadLength }, fsUploadHeaders(total, uploadLength, Date.now() + FS_UPLOAD_TTL_MS))
|
|
2842
|
+
return
|
|
2843
|
+
}
|
|
2844
|
+
if (uploadLength !== null && total !== uploadLength) {
|
|
2845
|
+
fsJson(res, 409, { error: 'length-incomplete', size: total, uploadLength, session }, fsUploadHeaders(total, uploadLength, Date.now() + FS_UPLOAD_TTL_MS))
|
|
2637
2846
|
return
|
|
2638
2847
|
}
|
|
2639
2848
|
const commit = (actualSha256) => {
|
|
@@ -2643,7 +2852,7 @@ function fsUploadResumable(req, res, url, dirLex, dirReal) {
|
|
|
2643
2852
|
if (ts.exists && !overwrite) return fsJson(res, 409, { error: 'conflict', detail: '文件已存在, overwrite=1 可覆盖' })
|
|
2644
2853
|
if (ts.exists) fs.rmSync(target, { force: true })
|
|
2645
2854
|
fs.renameSync(part, target)
|
|
2646
|
-
fsJson(res, 201, { ok: true, name, path: path.join(dirLex, name), size: total, resumed: offset > 0, session, ...(actualSha256 ? { sha256: actualSha256 } : {}) })
|
|
2855
|
+
fsJson(res, 201, { ok: true, name, path: path.join(dirLex, name), size: total, resumed: offset > 0, session, uploadLength, ...(actualSha256 ? { sha256: actualSha256 } : {}) }, fsUploadHeaders(total, uploadLength))
|
|
2647
2856
|
} catch (err) {
|
|
2648
2857
|
if (!res.headersSent) fsJson(res, 403, { error: 'write-failed', detail: err.message })
|
|
2649
2858
|
else try { res.destroy() } catch {}
|
|
@@ -2654,7 +2863,7 @@ function fsUploadResumable(req, res, url, dirLex, dirReal) {
|
|
|
2654
2863
|
sha256FileHex(part, (err, actual) => {
|
|
2655
2864
|
if (err) return fsJson(res, 403, { error: 'checksum-failed', detail: err.message })
|
|
2656
2865
|
if (actual !== sha256Expected) {
|
|
2657
|
-
return fsJson(res, 422, { error: 'checksum-mismatch', expected: sha256Expected, actual, partialSize: total, session })
|
|
2866
|
+
return fsJson(res, 422, { error: 'checksum-mismatch', expected: sha256Expected, actual, partialSize: total, session }, fsUploadHeaders(total, uploadLength, Date.now() + FS_UPLOAD_TTL_MS))
|
|
2658
2867
|
}
|
|
2659
2868
|
commit(actual)
|
|
2660
2869
|
})
|
|
@@ -2683,6 +2892,8 @@ async function fsUploadControl(req, res, url) {
|
|
|
2683
2892
|
if (resolved.error) return fsJson(res, resolved.error === 'forbidden' ? 403 : 404, { error: resolved.error })
|
|
2684
2893
|
const checked = fsRealChecked(resolved.abs)
|
|
2685
2894
|
if (checked.error) return fsJson(res, checked.error === 'forbidden' ? 403 : 404, { error: checked.error })
|
|
2895
|
+
rememberUploadDir(checked.abs)
|
|
2896
|
+
cleanupExpiredUploadParts(checked.abs)
|
|
2686
2897
|
const name = url.searchParams.get('name') || ''
|
|
2687
2898
|
if (!fsValidName(name)) return fsJson(res, 400, { error: 'bad-name' })
|
|
2688
2899
|
const session = url.searchParams.get('session') || 'default'
|
|
@@ -2943,7 +3154,24 @@ function proxyApi(req, res, url) {
|
|
|
2943
3154
|
}
|
|
2944
3155
|
|
|
2945
3156
|
// ---------- 其它 ----------
|
|
2946
|
-
async function serveHealth(res) {
|
|
3157
|
+
async function serveHealth(req, res, url) {
|
|
3158
|
+
const eventHealth = Object.fromEntries(Object.entries(eventCollectorState).map(([kind, state]) => [kind, {
|
|
3159
|
+
connected: state.connected,
|
|
3160
|
+
lastEventAt: state.lastEventAt,
|
|
3161
|
+
eventLagMs: state.lastEventAt ? Math.max(0, Date.now() - state.lastEventAt) : null,
|
|
3162
|
+
lastConnectAt: state.lastConnectAt,
|
|
3163
|
+
reconnects: state.reconnects,
|
|
3164
|
+
attempt: state.attempt,
|
|
3165
|
+
lastError: state.lastError,
|
|
3166
|
+
clients: state.clients,
|
|
3167
|
+
}]))
|
|
3168
|
+
const liveness = { ok: true, pid: process.pid, uptimeMs: Math.max(0, Date.now() - STARTED_AT), runtime: runtimeState }
|
|
3169
|
+
if (url?.searchParams.get('probe') === 'live') {
|
|
3170
|
+
cors(res)
|
|
3171
|
+
res.writeHead(200, { 'content-type': 'application/json' })
|
|
3172
|
+
res.end(JSON.stringify({ ok: true, service: 'dsh-remote', version: VERSION, probe: 'live', liveness }))
|
|
3173
|
+
return
|
|
3174
|
+
}
|
|
2947
3175
|
let upstreamOk = false
|
|
2948
3176
|
let upstreamReachable = false
|
|
2949
3177
|
let upstreamStatus = 0
|
|
@@ -2962,11 +3190,17 @@ async function serveHealth(res) {
|
|
|
2962
3190
|
} finally {
|
|
2963
3191
|
if (timer) clearTimeout(timer)
|
|
2964
3192
|
}
|
|
3193
|
+
const eventsOk = eventHealth.mux.connected && eventHealth.host.connected
|
|
3194
|
+
const readiness = { ok: upstreamOk && eventsOk, upstreamOk, eventsOk }
|
|
3195
|
+
const status = readiness.ok ? 'ready' : upstreamReachable ? 'degraded' : 'offline'
|
|
2965
3196
|
cors(res)
|
|
2966
3197
|
res.writeHead(200, { 'content-type': 'application/json; charset=utf-8' })
|
|
2967
3198
|
res.end(JSON.stringify({
|
|
2968
3199
|
ok: true,
|
|
2969
3200
|
service: 'dsh-remote',
|
|
3201
|
+
status,
|
|
3202
|
+
liveness,
|
|
3203
|
+
readiness,
|
|
2970
3204
|
version: VERSION,
|
|
2971
3205
|
protocol: { version: PROTOCOL_VERSION },
|
|
2972
3206
|
capabilities: CAPABILITIES,
|
|
@@ -2977,7 +3211,7 @@ async function serveHealth(res) {
|
|
|
2977
3211
|
upstreamReachable,
|
|
2978
3212
|
upstreamStatus,
|
|
2979
3213
|
...(upstreamError ? { upstreamError } : {}),
|
|
2980
|
-
events:
|
|
3214
|
+
events: eventHealth,
|
|
2981
3215
|
runtime: runtimeState,
|
|
2982
3216
|
}))
|
|
2983
3217
|
}
|
|
@@ -3006,7 +3240,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
3006
3240
|
if (url.pathname === '/api/events.poll') return serveEventPoll(req, res, url)
|
|
3007
3241
|
if (url.pathname.startsWith('/remote/')) return proxyApi(req, res, url)
|
|
3008
3242
|
if (url.pathname.startsWith('/api/')) return proxyApi(req, res, url)
|
|
3009
|
-
if (url.pathname === '/health') return serveHealth(res)
|
|
3243
|
+
if (url.pathname === '/health') return serveHealth(req, res, url)
|
|
3010
3244
|
touchDevice(req)
|
|
3011
3245
|
return serveStatic(req, res, url)
|
|
3012
3246
|
} catch (err) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-remote-plugin",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.15",
|
|
4
4
|
"description": "DSH Remote 官方 bundle 插件:DSH 左侧原生边栏入口 + 右侧抽屉内嵌管理控制台;内置网关随 DSH 自动启停(systemd 独立单元),抽屉直显令牌与设备监控;网关提供 /fs/* 文件传输端点(列表/断点下载/上传),配合 Android App 远程操控会话/审批/提问/goal 与文件互传(多服务器测速切换、聊天记录离线缓存)。",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./index.mjs",
|
|
@@ -50,6 +50,31 @@
|
|
|
50
50
|
{ "id": "not-used", "label": "尚未使用,暂时无法判断" }
|
|
51
51
|
]
|
|
52
52
|
}
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
"id": "2026-08-24-remote-plugin-demand-poll",
|
|
56
|
+
"title": "投票:你是否需要远程使用或管理插件?",
|
|
57
|
+
"content": "我们正在研究将插件管理和插件内具体功能接入 dsh-Remote 的可行性。后续可能由 DSH 端的插件管理工具负责实际处理,再通过远程接口提供给 App 使用。\n\n本投票用于了解大家的实际需求,结果仅作为后续版本规划参考,不代表功能已经确定或会自动执行插件操作。",
|
|
58
|
+
"minVersion": "0.6.11",
|
|
59
|
+
"maxVersion": "",
|
|
60
|
+
"publishedAt": "2026-08-24T23:28:14+08:00",
|
|
61
|
+
"poll": {
|
|
62
|
+
"id": "remote-plugin-demand-2026-08",
|
|
63
|
+
"question": "你更需要哪类远程插件能力?",
|
|
64
|
+
"options": [
|
|
65
|
+
{ "id": "use-plugin-features", "label": "远程使用插件内具体功能" },
|
|
66
|
+
{ "id": "manage-plugins-only", "label": "只需要远程安装、更新、卸载插件" },
|
|
67
|
+
{ "id": "no-remote-plugin", "label": "没有远程使用或管理插件的需求" }
|
|
68
|
+
]
|
|
69
|
+
}
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
"id": "2026-08-25-user-community-group",
|
|
73
|
+
"title": "dsh-Remote 用户交流群建立公告",
|
|
74
|
+
"content": "大家好,dsh-Remote 用户交流群已经建立,群号:1106138825。\n\n本群主要用于:\n\n- 用户需求和使用反馈\n- 后续版本更新说明\n- 使用过程中问题的交流与讨论\n\n欢迎有兴趣的用户加入。提交日志或截图前,请注意隐藏 Token、服务器地址等敏感信息。",
|
|
75
|
+
"minVersion": "",
|
|
76
|
+
"maxVersion": "",
|
|
77
|
+
"publishedAt": "2026-08-25T00:24:27+08:00"
|
|
53
78
|
}
|
|
54
79
|
]
|
|
55
80
|
}
|