dsh-remote-plugin 0.6.14 → 0.6.16
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 +314 -30
- package/package.json +1 -1
- package/public/admin.html +46 -1
- package/public/admin.js +79 -9
- package/public/announcements.json +33 -0
- package/public/app.js +1103 -62
- package/public/desktop/desktop.css +22 -7
- package/public/desktop/desktop.html +24 -4
- package/public/desktop/desktop.js +275 -26
- package/public/index.html +120 -15
- 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 +96 -6
- 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
|
@@ -83,6 +83,7 @@ const WORKBENCH_FILE = process.env.DSH_REMOTE_WORKBENCH || path.join(os.homedir(
|
|
|
83
83
|
const STARTED_AT = Date.now()
|
|
84
84
|
const DSH_SERVICE = String(process.env.DSH_REMOTE_DSH_SERVICE || 'dsh-web').trim()
|
|
85
85
|
const SYSTEMCTL = String(process.env.DSH_REMOTE_SYSTEMCTL || 'systemctl').trim() || 'systemctl'
|
|
86
|
+
const WINDOWS_SC = String(process.env.DSH_REMOTE_WINDOWS_SC || 'sc.exe').trim() || 'sc.exe'
|
|
86
87
|
const DSH_CONTROL_TIMEOUT_MS = durationEnv('DSH_REMOTE_DSH_CONTROL_TIMEOUT_MS', 45000, 2000, 5 * 60 * 1000)
|
|
87
88
|
const DSH_CONTROL_POLL_MS = durationEnv('DSH_REMOTE_DSH_CONTROL_POLL_MS', 500, 50, 5000)
|
|
88
89
|
const HTTP_REQUEST_TIMEOUT_MS = durationEnv('GATEWAY_HTTP_REQUEST_TIMEOUT_MS', 15 * 60 * 1000, 0, 24 * 60 * 60 * 1000)
|
|
@@ -114,6 +115,8 @@ const CAPABILITIES = Object.freeze({
|
|
|
114
115
|
centralAnnouncements: 2,
|
|
115
116
|
feedback: 1,
|
|
116
117
|
deviceKeys: 1,
|
|
118
|
+
healthProbes: 1,
|
|
119
|
+
resumableUploads: 2,
|
|
117
120
|
})
|
|
118
121
|
|
|
119
122
|
const MIME = {
|
|
@@ -144,6 +147,7 @@ const FS_ROOTS = (process.env.DSH_REMOTE_FS_ROOT || FS_DEFAULT_ROOT)
|
|
|
144
147
|
.map(r => path.resolve(r.trim() === '~' ? FS_DEFAULT_ROOT : r.trim()))
|
|
145
148
|
const FS_WORKSPACE_CACHE_MS = durationEnv('DSH_REMOTE_FS_WORKSPACE_CACHE_MS', 15_000, 1000, 10 * 60_000)
|
|
146
149
|
const FS_MAX_UPLOAD = Number(process.env.DSH_REMOTE_FS_MAX_UPLOAD) || 2 * 1024 * 1024 * 1024
|
|
150
|
+
const FS_UPLOAD_TTL_MS = durationEnv('DSH_REMOTE_FS_UPLOAD_TTL_MS', 24 * 60 * 60 * 1000, 60_000, 7 * 24 * 60 * 60 * 1000)
|
|
147
151
|
let FS_ROOT_REALS = null
|
|
148
152
|
let fsWorkspaceRootsCache = { roots: [], reals: [], fetchedAt: 0 }
|
|
149
153
|
let fsWorkspaceRootsFetch = null
|
|
@@ -837,13 +841,91 @@ function classifySystemctlFailure(result) {
|
|
|
837
841
|
return { code: 'COMMAND_FAILED', message: 'systemctl 未能接受 DSH 控制命令', detail }
|
|
838
842
|
}
|
|
839
843
|
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
844
|
+
const WINDOWS_SERVICE_STATE_NAMES = Object.freeze({
|
|
845
|
+
1: 'STOPPED',
|
|
846
|
+
2: 'START_PENDING',
|
|
847
|
+
3: 'STOP_PENDING',
|
|
848
|
+
4: 'RUNNING',
|
|
849
|
+
5: 'CONTINUE_PENDING',
|
|
850
|
+
6: 'PAUSE_PENDING',
|
|
851
|
+
7: 'PAUSED',
|
|
852
|
+
})
|
|
853
|
+
|
|
854
|
+
function parseWindowsServiceQuery(output) {
|
|
855
|
+
const text = String(output || '')
|
|
856
|
+
const state = /\bSTATE\s*:\s*(\d+)(?:\s+([^\r\n(]+))?/i.exec(text)
|
|
857
|
+
if (!state) return null
|
|
858
|
+
const stateCode = Number(state[1])
|
|
859
|
+
const stateName = WINDOWS_SERVICE_STATE_NAMES[stateCode] || String(state[2] || 'UNKNOWN').trim().split(/\s+/)[0].toUpperCase()
|
|
860
|
+
const pid = /\bPID\s*:\s*(\d+)/i.exec(text)
|
|
861
|
+
const pending = [2, 3, 5, 6].includes(stateCode)
|
|
862
|
+
return {
|
|
863
|
+
stateCode,
|
|
864
|
+
stateName,
|
|
865
|
+
pending,
|
|
866
|
+
running: stateCode === 4,
|
|
867
|
+
mainPid: Number(pid?.[1]) || 0,
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
function classifyWindowsServiceFailure(result) {
|
|
872
|
+
const detail = [result?.stderr, result?.stdout, result?.error].filter(Boolean).join(' · ').slice(0, 1000)
|
|
873
|
+
const code = String(result?.code ?? '')
|
|
874
|
+
if (result?.timedOut) return { code: 'COMMAND_TIMEOUT', message: 'Windows 服务控制命令超时', detail }
|
|
875
|
+
if (code === 'ENOENT') return { code: 'SERVICE_CONTROL_NOT_FOUND', message: '系统中找不到 sc.exe', detail }
|
|
876
|
+
if (code === '1060' || /1060|does not exist|cannot find the file specified|找不到指定的服务/i.test(detail)) {
|
|
877
|
+
return { code: 'SERVICE_NOT_FOUND', message: `未找到 Windows 服务 ${DSH_SERVICE}`, detail }
|
|
843
878
|
}
|
|
879
|
+
if (code === '1058' || /1058|disabled|禁用/i.test(detail)) return { code: 'SERVICE_DISABLED', message: `Windows 服务 ${DSH_SERVICE} 已被禁用`, detail }
|
|
880
|
+
if (/access is denied|permission denied|not authorized|需要提升|拒绝访问/i.test(detail)) return { code: 'PERMISSION_DENIED', message: '当前用户无权控制 Windows DSH 服务', detail }
|
|
881
|
+
if (code === 'SERVICE_STOP_TIMEOUT') return { code, message: `Windows 服务 ${DSH_SERVICE} 停止超时`, detail }
|
|
882
|
+
return { code: 'COMMAND_FAILED', message: 'Windows 服务控制命令失败', detail }
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
function classifyDshServiceFailure(result) {
|
|
886
|
+
return process.platform === 'win32' ? classifyWindowsServiceFailure(result) : classifySystemctlFailure(result)
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
async function windowsServiceStatus() {
|
|
890
|
+
const r = await execFileResult(WINDOWS_SC, ['queryex', DSH_SERVICE], 5000)
|
|
891
|
+
if (!r.ok) {
|
|
892
|
+
const failure = classifyWindowsServiceFailure(r)
|
|
893
|
+
if (failure.code === 'SERVICE_NOT_FOUND') {
|
|
894
|
+
return { ok: true, supported: false, running: false, service: DSH_SERVICE, ...failure }
|
|
895
|
+
}
|
|
896
|
+
return { ok: false, supported: false, running: false, service: DSH_SERVICE, ...failure }
|
|
897
|
+
}
|
|
898
|
+
const parsed = parseWindowsServiceQuery([r.stdout, r.stderr].filter(Boolean).join('\n'))
|
|
899
|
+
if (!parsed) {
|
|
900
|
+
return {
|
|
901
|
+
ok: false, supported: false, running: false, service: DSH_SERVICE,
|
|
902
|
+
code: 'STATUS_PARSE_FAILED', message: '无法解析 Windows DSH 服务状态', detail: r.stdout || r.stderr || 'sc.exe 没有返回 STATE',
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
const activeState = parsed.running ? 'active' : parsed.stateCode === 7 ? 'paused' : parsed.pending ? 'activating' : 'inactive'
|
|
906
|
+
return {
|
|
907
|
+
ok: true,
|
|
908
|
+
supported: true,
|
|
909
|
+
running: parsed.running,
|
|
910
|
+
service: DSH_SERVICE,
|
|
911
|
+
state: activeState,
|
|
912
|
+
loadState: 'loaded',
|
|
913
|
+
activeState,
|
|
914
|
+
subState: parsed.stateName.toLowerCase(),
|
|
915
|
+
unitFileState: 'windows-service',
|
|
916
|
+
mainPid: parsed.mainPid,
|
|
917
|
+
result: '',
|
|
918
|
+
execMainStatus: 0,
|
|
919
|
+
serviceStateCode: parsed.stateCode,
|
|
920
|
+
serviceState: parsed.stateName,
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
async function dshServiceStatus() {
|
|
844
925
|
if (!/^[A-Za-z0-9_.@-]+$/.test(DSH_SERVICE)) {
|
|
845
926
|
return { ok: false, supported: false, running: false, service: DSH_SERVICE, code: 'INVALID_SERVICE', message: 'DSH_REMOTE_DSH_SERVICE 服务名配置不合法' }
|
|
846
927
|
}
|
|
928
|
+
if (process.platform === 'win32') return windowsServiceStatus()
|
|
847
929
|
const r = await execFileResult(SYSTEMCTL, [
|
|
848
930
|
'--user', 'show', DSH_SERVICE,
|
|
849
931
|
'--property=Id,LoadState,ActiveState,SubState,UnitFileState,MainPID,Result,ExecMainStatus',
|
|
@@ -881,6 +963,31 @@ async function dshServiceStatus() {
|
|
|
881
963
|
}
|
|
882
964
|
}
|
|
883
965
|
|
|
966
|
+
async function executeDshServiceAction(action, initial) {
|
|
967
|
+
if (process.platform !== 'win32') {
|
|
968
|
+
return execFileResult(SYSTEMCTL, ['--user', '--no-block', action, DSH_SERVICE], 5000)
|
|
969
|
+
}
|
|
970
|
+
if (action === 'restart' && initial?.running) {
|
|
971
|
+
const stop = await execFileResult(WINDOWS_SC, ['stop', DSH_SERVICE], 5000)
|
|
972
|
+
if (!stop.ok) {
|
|
973
|
+
const current = await windowsServiceStatus()
|
|
974
|
+
if (!current.supported || current.running || current.serviceStateCode !== 1) return stop
|
|
975
|
+
}
|
|
976
|
+
let stopped = false
|
|
977
|
+
const checks = Math.max(1, Math.ceil(DSH_CONTROL_TIMEOUT_MS / Math.max(50, DSH_CONTROL_POLL_MS)))
|
|
978
|
+
for (let i = 0; i < checks; i++) {
|
|
979
|
+
const current = await windowsServiceStatus()
|
|
980
|
+
if (!current.supported) {
|
|
981
|
+
return { ok: false, code: current.code || 'SERVICE_STATUS_FAILED', error: current.message, stderr: current.detail }
|
|
982
|
+
}
|
|
983
|
+
if (current.serviceStateCode === 1) { stopped = true; break }
|
|
984
|
+
await delay(DSH_CONTROL_POLL_MS)
|
|
985
|
+
}
|
|
986
|
+
if (!stopped) return { ok: false, code: 'SERVICE_STOP_TIMEOUT', error: `Windows 服务 ${DSH_SERVICE} 停止超时` }
|
|
987
|
+
}
|
|
988
|
+
return execFileResult(WINDOWS_SC, ['start', DSH_SERVICE], 5000)
|
|
989
|
+
}
|
|
990
|
+
|
|
884
991
|
function delay(ms) {
|
|
885
992
|
return new Promise(resolvePromise => setTimeout(resolvePromise, ms))
|
|
886
993
|
}
|
|
@@ -924,6 +1031,8 @@ function failDshOperation(operation, code, message, detail = '', status = null)
|
|
|
924
1031
|
detail: String(detail || '').slice(0, 1000),
|
|
925
1032
|
...(status ? { status } : {}),
|
|
926
1033
|
})
|
|
1034
|
+
if (status) operation.observed = status
|
|
1035
|
+
operation.evidence = { observed: status || operation.observed || null, upstream: operation.upstream || null, events: operation.events || null }
|
|
927
1036
|
}
|
|
928
1037
|
|
|
929
1038
|
function dshEventChannelStatus() {
|
|
@@ -944,9 +1053,11 @@ function reconnectDshEventCollectors() {
|
|
|
944
1053
|
|
|
945
1054
|
async function runDshControlOperation(operation) {
|
|
946
1055
|
try {
|
|
947
|
-
|
|
1056
|
+
const manager = process.platform === 'win32' ? 'Windows 服务' : 'systemd 用户服务'
|
|
1057
|
+
dshOperationStep(operation, 'checking', `正在检查 ${manager} ${DSH_SERVICE}`)
|
|
948
1058
|
const initial = await dshServiceStatus()
|
|
949
1059
|
operation.initialStatus = initial
|
|
1060
|
+
operation.observed = initial
|
|
950
1061
|
if (!initial.supported) {
|
|
951
1062
|
failDshOperation(operation, initial.code || 'UNSUPPORTED', initial.message || '当前 DSH 服务不可控', initial.detail, initial)
|
|
952
1063
|
return
|
|
@@ -958,11 +1069,11 @@ async function runDshControlOperation(operation) {
|
|
|
958
1069
|
return
|
|
959
1070
|
}
|
|
960
1071
|
|
|
961
|
-
dshOperationStep(operation, 'command', `正在向
|
|
962
|
-
const command = await
|
|
1072
|
+
dshOperationStep(operation, 'command', `正在向 ${manager} 提交 DSH ${operation.action === 'start' ? '启动' : '重启'}命令`)
|
|
1073
|
+
const command = await executeDshServiceAction(operation.action, initial)
|
|
963
1074
|
operation.command = { ok: command.ok, code: command.code, signal: command.signal }
|
|
964
1075
|
if (!command.ok) {
|
|
965
|
-
const failure =
|
|
1076
|
+
const failure = classifyDshServiceFailure(command)
|
|
966
1077
|
failDshOperation(operation, failure.code, failure.message, failure.detail, await dshServiceStatus())
|
|
967
1078
|
return
|
|
968
1079
|
}
|
|
@@ -980,6 +1091,7 @@ async function runDshControlOperation(operation) {
|
|
|
980
1091
|
const status = await dshServiceStatus()
|
|
981
1092
|
lastStatus = status
|
|
982
1093
|
operation.status = status
|
|
1094
|
+
operation.observed = status
|
|
983
1095
|
if (!status.supported) {
|
|
984
1096
|
failDshOperation(operation, status.code || 'STATUS_FAILED', status.message || '无法读取 DSH 服务状态', status.detail, status)
|
|
985
1097
|
return
|
|
@@ -1011,6 +1123,7 @@ async function runDshControlOperation(operation) {
|
|
|
1011
1123
|
dshOperationStep(operation, 'complete', `DSH ${operation.action === 'start' ? '启动' : '重启'}成功:服务已运行,HTTP ${lastProbe.status},实时通道已连接,PID ${status.mainPid || '未知'}`, {
|
|
1012
1124
|
ok: true, done: true, code: 'SUCCESS', status, upstream: lastProbe, events,
|
|
1013
1125
|
})
|
|
1126
|
+
operation.evidence = { observed: status, upstream: lastProbe, events }
|
|
1014
1127
|
return
|
|
1015
1128
|
}
|
|
1016
1129
|
}
|
|
@@ -1110,6 +1223,9 @@ async function serveDshControl(req, res, url) {
|
|
|
1110
1223
|
const now = Date.now()
|
|
1111
1224
|
dshControlOperation = {
|
|
1112
1225
|
operationId: crypto.randomUUID(), action, service: DSH_SERVICE,
|
|
1226
|
+
desired: { service: DSH_SERVICE, running: true, action },
|
|
1227
|
+
observed: null,
|
|
1228
|
+
evidence: null,
|
|
1113
1229
|
ok: false, accepted: true, done: false, stage: 'queued', code: 'ACCEPTED',
|
|
1114
1230
|
message: `已接收 DSH ${action === 'start' ? '启动' : '重启'}请求,等待检查服务`,
|
|
1115
1231
|
startedAt: now, updatedAt: now, steps: [],
|
|
@@ -1125,12 +1241,14 @@ async function serveDshControl(req, res, url) {
|
|
|
1125
1241
|
// 内存环形缓冲并广播给已认证客户端;前端在 WebSocket 被隧道/受限网络
|
|
1126
1242
|
// 阻断时改走 GET /api/events.poll 增量拉取。
|
|
1127
1243
|
const EVENT_BUFFER_MAX = durationEnv('GATEWAY_EVENT_BUFFER_MAX', 1000, 100, 10000)
|
|
1244
|
+
const EVENT_POLL_WAIT_MAX = durationEnv('GATEWAY_EVENT_POLL_WAIT_MS', 25000, 0, 60000)
|
|
1128
1245
|
const EVENT_MAX_STRING = 16 * 1024
|
|
1129
1246
|
const WS_GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11'
|
|
1130
1247
|
const eventBuffers = { mux: [], host: [] }
|
|
1131
1248
|
const eventNextSeq = { mux: 1, host: 1 }
|
|
1132
1249
|
const collectorClients = { mux: new Set(), host: new Set() }
|
|
1133
1250
|
const collectorReplay = { mux: new Map(), host: new Map() }
|
|
1251
|
+
const eventPollWaiters = { mux: new Set(), host: new Set() }
|
|
1134
1252
|
const eventCollectorState = {
|
|
1135
1253
|
mux: { connected: false, lastEventAt: 0, lastConnectAt: 0, reconnects: 0, lastError: '', lastCloseCode: 0, lastCloseReason: '', attempt: 0, clients: 0, framesBroadcast: 0, lastBroadcastAt: 0 },
|
|
1136
1254
|
host: { connected: false, lastEventAt: 0, lastConnectAt: 0, reconnects: 0, lastError: '', lastCloseCode: 0, lastCloseReason: '', attempt: 0, clients: 0, framesBroadcast: 0, lastBroadcastAt: 0 },
|
|
@@ -1213,6 +1331,38 @@ function pushEvent(kind, full, raw = JSON.stringify(full)) {
|
|
|
1213
1331
|
if (buf.length > EVENT_BUFFER_MAX) buf.shift()
|
|
1214
1332
|
rememberCollectorReplay(kind, full, raw)
|
|
1215
1333
|
broadcastCollectorFrame(kind, raw)
|
|
1334
|
+
flushEventPollWaiters(kind)
|
|
1335
|
+
}
|
|
1336
|
+
|
|
1337
|
+
function eventPollPayload(kind, since, waitSupported = false) {
|
|
1338
|
+
const buf = eventBuffers[kind]
|
|
1339
|
+
const events = buf.filter(r => r.seq > since)
|
|
1340
|
+
const latestSeq = buf.length ? buf[buf.length - 1].seq : 0
|
|
1341
|
+
const truncated = buf.length > 0 && since < buf[0].seq - 1
|
|
1342
|
+
return { ok: true, kind, since, latestSeq, truncated, waitSupported, events }
|
|
1343
|
+
}
|
|
1344
|
+
|
|
1345
|
+
function sendEventPollResponse(waiter, payload) {
|
|
1346
|
+
if (waiter.req.destroyed || waiter.res.destroyed) return
|
|
1347
|
+
cors(waiter.res)
|
|
1348
|
+
waiter.res.writeHead(200, {
|
|
1349
|
+
'content-type': 'application/json; charset=utf-8',
|
|
1350
|
+
'cache-control': 'no-store'
|
|
1351
|
+
})
|
|
1352
|
+
waiter.res.end(JSON.stringify(payload))
|
|
1353
|
+
}
|
|
1354
|
+
|
|
1355
|
+
function finishEventPollWaiter(waiter, send = true) {
|
|
1356
|
+
if (!eventPollWaiters[waiter.kind]?.delete(waiter)) return
|
|
1357
|
+
clearTimeout(waiter.timer)
|
|
1358
|
+
if (send) sendEventPollResponse(waiter, eventPollPayload(waiter.kind, waiter.since, true))
|
|
1359
|
+
}
|
|
1360
|
+
|
|
1361
|
+
function flushEventPollWaiters(kind) {
|
|
1362
|
+
for (const waiter of [...eventPollWaiters[kind]]) {
|
|
1363
|
+
const payload = eventPollPayload(kind, waiter.since, true)
|
|
1364
|
+
if (payload.events.length) finishEventPollWaiter(waiter, true)
|
|
1365
|
+
}
|
|
1216
1366
|
}
|
|
1217
1367
|
|
|
1218
1368
|
function serveWsTicket(req, res, url) {
|
|
@@ -1270,16 +1420,33 @@ function serveEventPoll(req, res, url) {
|
|
|
1270
1420
|
res.end(JSON.stringify({ error: 'bad-since', detail: 'since 必须是非负整数' }))
|
|
1271
1421
|
return
|
|
1272
1422
|
}
|
|
1273
|
-
const
|
|
1274
|
-
const
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1423
|
+
const waitRaw = url.searchParams.get('wait')
|
|
1424
|
+
const requestedWait = waitRaw === null ? 0 : Number(waitRaw)
|
|
1425
|
+
if (!Number.isFinite(requestedWait) || requestedWait < 0) {
|
|
1426
|
+
cors(res)
|
|
1427
|
+
res.writeHead(400, { 'content-type': 'application/json; charset=utf-8' })
|
|
1428
|
+
res.end(JSON.stringify({ error: 'bad-wait', detail: 'wait 必须是非负数字' }))
|
|
1429
|
+
return
|
|
1430
|
+
}
|
|
1431
|
+
const wait = Math.min(Math.floor(requestedWait), EVENT_POLL_WAIT_MAX)
|
|
1432
|
+
const waitSupported = wait > 0 && EVENT_POLL_WAIT_MAX > 0
|
|
1433
|
+
const payload = eventPollPayload(kind, since, waitSupported)
|
|
1434
|
+
if (payload.events.length || wait <= 0 || EVENT_POLL_WAIT_MAX <= 0) {
|
|
1435
|
+
cors(res)
|
|
1436
|
+
res.writeHead(200, {
|
|
1437
|
+
'content-type': 'application/json; charset=utf-8',
|
|
1438
|
+
'cache-control': 'no-store'
|
|
1439
|
+
})
|
|
1440
|
+
res.end(JSON.stringify(payload))
|
|
1441
|
+
return
|
|
1442
|
+
}
|
|
1443
|
+
const waiter = { req, res, kind, since, timer: null }
|
|
1444
|
+
eventPollWaiters[kind].add(waiter)
|
|
1445
|
+
waiter.timer = setTimeout(() => finishEventPollWaiter(waiter, true), wait)
|
|
1446
|
+
waiter.timer.unref?.()
|
|
1447
|
+
req.once('close', () => finishEventPollWaiter(waiter, false))
|
|
1448
|
+
// 事件可能刚好在首次检查和加入等待集合之间到达,加入后再检查一次避免漏唤醒。
|
|
1449
|
+
if (eventPollPayload(kind, since, true).events.length) finishEventPollWaiter(waiter, true)
|
|
1283
1450
|
}
|
|
1284
1451
|
|
|
1285
1452
|
/** 网关自带上游事件采集:mux/host 各一条 WS,断线自动重连。 */
|
|
@@ -2059,9 +2226,9 @@ function serveAdminApi(req, res, url) {
|
|
|
2059
2226
|
}
|
|
2060
2227
|
|
|
2061
2228
|
// ---------- /fs 文件传输: 实现 ----------
|
|
2062
|
-
function fsJson(res, status, body) {
|
|
2229
|
+
function fsJson(res, status, body, extraHeaders = {}) {
|
|
2063
2230
|
cors(res)
|
|
2064
|
-
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' })
|
|
2231
|
+
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', ...extraHeaders })
|
|
2065
2232
|
res.end(JSON.stringify(body))
|
|
2066
2233
|
}
|
|
2067
2234
|
|
|
@@ -2173,6 +2340,17 @@ function fsParseRange(header, size) {
|
|
|
2173
2340
|
return { start, end: Math.min(end, size - 1) }
|
|
2174
2341
|
}
|
|
2175
2342
|
|
|
2343
|
+
function fsEntityTag(st) {
|
|
2344
|
+
return `"${Number(st.size).toString(16)}-${Math.floor(Number(st.mtimeMs)).toString(16)}"`
|
|
2345
|
+
}
|
|
2346
|
+
|
|
2347
|
+
function fsIfRangeMatches(value, etag, mtimeMs) {
|
|
2348
|
+
if (!value) return true
|
|
2349
|
+
if (String(value).trim() === etag) return true
|
|
2350
|
+
const time = Date.parse(String(value))
|
|
2351
|
+
return Number.isFinite(time) && time >= Math.floor(Number(mtimeMs) / 1000) * 1000
|
|
2352
|
+
}
|
|
2353
|
+
|
|
2176
2354
|
async function fsList(req, res, url) {
|
|
2177
2355
|
if (req.method !== 'GET') {
|
|
2178
2356
|
res.writeHead(405, { allow: 'GET' })
|
|
@@ -2241,7 +2419,17 @@ async function fsFile(req, res, url) {
|
|
|
2241
2419
|
}
|
|
2242
2420
|
if (!st.isFile()) return fsJson(res, 400, { error: 'not-a-file' })
|
|
2243
2421
|
|
|
2244
|
-
const
|
|
2422
|
+
const etag = fsEntityTag(st)
|
|
2423
|
+
const lastModified = st.mtime.toUTCString()
|
|
2424
|
+
if (!req.headers.range && req.headers['if-none-match'] === etag) {
|
|
2425
|
+
cors(res)
|
|
2426
|
+
res.writeHead(304, { etag, 'last-modified': lastModified, 'cache-control': 'no-cache' })
|
|
2427
|
+
res.end()
|
|
2428
|
+
return
|
|
2429
|
+
}
|
|
2430
|
+
const range = fsIfRangeMatches(req.headers['if-range'], etag, st.mtimeMs)
|
|
2431
|
+
? fsParseRange(req.headers.range, st.size)
|
|
2432
|
+
: null
|
|
2245
2433
|
if (range && range.start >= st.size) {
|
|
2246
2434
|
cors(res)
|
|
2247
2435
|
res.writeHead(416, {
|
|
@@ -2260,6 +2448,8 @@ async function fsFile(req, res, url) {
|
|
|
2260
2448
|
'content-length': range ? range.end - range.start + 1 : st.size,
|
|
2261
2449
|
'content-disposition': fsContentDisposition(path.basename(checked.abs)),
|
|
2262
2450
|
'accept-ranges': 'bytes',
|
|
2451
|
+
etag,
|
|
2452
|
+
'last-modified': lastModified,
|
|
2263
2453
|
'cache-control': 'no-cache',
|
|
2264
2454
|
...(range ? { 'content-range': `bytes ${range.start}-${range.end}/${st.size}` } : {})
|
|
2265
2455
|
})
|
|
@@ -2335,10 +2525,49 @@ function sha256FileHex(file, cb) {
|
|
|
2335
2525
|
|
|
2336
2526
|
/* 进行中的续传写流: 取消时先 destroy 再删分片, 避免“先删后写”竞态 */
|
|
2337
2527
|
const activeUploads = new Map()
|
|
2528
|
+
const uploadPartDirs = new Set()
|
|
2338
2529
|
function fsActiveKey(dirReal, name, session) {
|
|
2339
2530
|
return dirReal + '\n' + name + '\n' + (session || '')
|
|
2340
2531
|
}
|
|
2341
2532
|
|
|
2533
|
+
function rememberUploadDir(dirReal) {
|
|
2534
|
+
uploadPartDirs.add(dirReal)
|
|
2535
|
+
}
|
|
2536
|
+
|
|
2537
|
+
function uploadDirHasActive(dirReal) {
|
|
2538
|
+
const prefix = dirReal + '\n'
|
|
2539
|
+
for (const key of activeUploads.keys()) if (key.startsWith(prefix)) return true
|
|
2540
|
+
return false
|
|
2541
|
+
}
|
|
2542
|
+
|
|
2543
|
+
function cleanupExpiredUploadParts(dirReal = '') {
|
|
2544
|
+
const dirs = dirReal ? [dirReal] : [...uploadPartDirs]
|
|
2545
|
+
const now = Date.now()
|
|
2546
|
+
for (const dir of dirs) {
|
|
2547
|
+
if (uploadDirHasActive(dir)) continue
|
|
2548
|
+
let entries
|
|
2549
|
+
try { entries = fs.readdirSync(dir) } catch { continue }
|
|
2550
|
+
for (const name of entries) {
|
|
2551
|
+
if (!name.startsWith('.') || !name.includes('.dsh-remote-part-')) continue
|
|
2552
|
+
const file = path.join(dir, name)
|
|
2553
|
+
try {
|
|
2554
|
+
const st = fs.statSync(file)
|
|
2555
|
+
if (st.isFile() && now - st.mtimeMs > FS_UPLOAD_TTL_MS) fs.unlinkSync(file)
|
|
2556
|
+
} catch {}
|
|
2557
|
+
}
|
|
2558
|
+
}
|
|
2559
|
+
}
|
|
2560
|
+
|
|
2561
|
+
const uploadCleanupTimer = setInterval(() => cleanupExpiredUploadParts(), 15 * 60 * 1000)
|
|
2562
|
+
uploadCleanupTimer.unref?.()
|
|
2563
|
+
|
|
2564
|
+
function fsUploadHeaders(offset, length = null, expiresAt = null) {
|
|
2565
|
+
const headers = { 'upload-offset': String(Math.max(0, Number(offset) || 0)) }
|
|
2566
|
+
if (Number.isSafeInteger(length) && length >= 0) headers['upload-length'] = String(length)
|
|
2567
|
+
if (Number.isFinite(expiresAt)) headers['upload-expires'] = new Date(expiresAt).toUTCString()
|
|
2568
|
+
return headers
|
|
2569
|
+
}
|
|
2570
|
+
|
|
2342
2571
|
/** 打开上传目标: 同名冲突/符号链接/临时文件都在这层判定。 */
|
|
2343
2572
|
function fsOpenUploadTarget(res, url, dirLex, dirReal, name) {
|
|
2344
2573
|
if (!fsValidName(name)) {
|
|
@@ -2539,14 +2768,22 @@ async function fsUploadProbe(req, res, url) {
|
|
|
2539
2768
|
if (resolved.error) return fsJson(res, resolved.error === 'forbidden' ? 403 : 404, { error: resolved.error })
|
|
2540
2769
|
const checked = fsRealChecked(resolved.abs)
|
|
2541
2770
|
if (checked.error) return fsJson(res, checked.error === 'forbidden' ? 403 : 404, { error: checked.error })
|
|
2771
|
+
rememberUploadDir(checked.abs)
|
|
2772
|
+
cleanupExpiredUploadParts(checked.abs)
|
|
2542
2773
|
const name = url.searchParams.get('name') || ''
|
|
2543
2774
|
if (!fsValidName(name)) return fsJson(res, 400, { error: 'bad-name' })
|
|
2544
2775
|
const part = fsPartPath(checked.abs, name, url.searchParams.get('session') || 'default')
|
|
2545
2776
|
let partialSize = 0, partExists = false
|
|
2777
|
+
let expiresAt = null
|
|
2546
2778
|
try {
|
|
2547
2779
|
const st = fs.statSync(part)
|
|
2548
|
-
if (st.isFile()) { partialSize = st.size; partExists = true }
|
|
2780
|
+
if (st.isFile()) { partialSize = st.size; partExists = true; expiresAt = st.mtimeMs + FS_UPLOAD_TTL_MS }
|
|
2549
2781
|
} catch {}
|
|
2782
|
+
const lengthRaw = url.searchParams.get('size')
|
|
2783
|
+
const uploadLength = lengthRaw === null || lengthRaw === '' ? null : Number(lengthRaw)
|
|
2784
|
+
if (uploadLength !== null && (!Number.isSafeInteger(uploadLength) || uploadLength < 0)) {
|
|
2785
|
+
return fsJson(res, 400, { error: 'bad-length', detail: 'size 必须是非负整数' })
|
|
2786
|
+
}
|
|
2550
2787
|
const target = fsTargetState(path.join(checked.abs, name))
|
|
2551
2788
|
let targetSize = 0
|
|
2552
2789
|
if (target.exists) {
|
|
@@ -2558,8 +2795,10 @@ async function fsUploadProbe(req, res, url) {
|
|
|
2558
2795
|
partialSize,
|
|
2559
2796
|
partExists,
|
|
2560
2797
|
targetExists: !!target.exists,
|
|
2561
|
-
targetSize
|
|
2562
|
-
|
|
2798
|
+
targetSize,
|
|
2799
|
+
uploadLength,
|
|
2800
|
+
expiresAt
|
|
2801
|
+
}, fsUploadHeaders(partialSize, uploadLength, expiresAt))
|
|
2563
2802
|
}
|
|
2564
2803
|
|
|
2565
2804
|
/** POST /fs/mkdir?path=<parent>&name=<directory> 创建一个工作区目录。 */
|
|
@@ -2594,15 +2833,27 @@ async function fsMkdir(req, res, url) {
|
|
|
2594
2833
|
}
|
|
2595
2834
|
|
|
2596
2835
|
function fsUploadResumable(req, res, url, dirLex, dirReal) {
|
|
2836
|
+
rememberUploadDir(dirReal)
|
|
2837
|
+
cleanupExpiredUploadParts(dirReal)
|
|
2597
2838
|
const name = url.searchParams.get('name') || ''
|
|
2598
2839
|
if (!fsValidName(name)) return fsJson(res, 400, { error: 'bad-name', detail: '文件名不能为空且不能包含路径分隔符' })
|
|
2599
2840
|
const session = url.searchParams.get('session') || ''
|
|
2600
2841
|
if (!session) return fsJson(res, 400, { error: 'missing-session', detail: '断点续传需要 session 参数' })
|
|
2601
|
-
const
|
|
2842
|
+
const queryOffsetRaw = url.searchParams.get('offset')
|
|
2843
|
+
const headerOffsetRaw = req.headers['upload-offset']
|
|
2844
|
+
const offsetRaw = queryOffsetRaw ?? headerOffsetRaw
|
|
2602
2845
|
const offset = Number(offsetRaw)
|
|
2603
2846
|
if (!Number.isSafeInteger(offset) || offset < 0) {
|
|
2604
2847
|
return fsJson(res, 400, { error: 'bad-offset', detail: 'offset 必须是非负整数' })
|
|
2605
2848
|
}
|
|
2849
|
+
if (queryOffsetRaw !== null && headerOffsetRaw !== undefined && Number(headerOffsetRaw) !== offset) {
|
|
2850
|
+
return fsJson(res, 400, { error: 'offset-mismatch', detail: 'URL offset 与 Upload-Offset 不一致' })
|
|
2851
|
+
}
|
|
2852
|
+
const lengthRaw = url.searchParams.get('size') ?? req.headers['upload-length']
|
|
2853
|
+
const uploadLength = lengthRaw === null || lengthRaw === undefined || lengthRaw === '' ? null : Number(lengthRaw)
|
|
2854
|
+
if (uploadLength !== null && (!Number.isSafeInteger(uploadLength) || uploadLength < 0)) {
|
|
2855
|
+
return fsJson(res, 400, { error: 'bad-length', detail: 'size/Upload-Length 必须是非负整数' })
|
|
2856
|
+
}
|
|
2606
2857
|
const finish = url.searchParams.get('finish') === '1' || url.searchParams.get('complete') === '1'
|
|
2607
2858
|
const overwrite = url.searchParams.get('overwrite') === '1' || url.searchParams.get('overwrite') === 'true'
|
|
2608
2859
|
const sha256Expected = (url.searchParams.get('sha256') || '').trim().toLowerCase()
|
|
@@ -2687,8 +2938,16 @@ function fsUploadResumable(req, res, url, dirLex, dirReal) {
|
|
|
2687
2938
|
try {
|
|
2688
2939
|
const st = fs.statSync(part)
|
|
2689
2940
|
if (!st.isFile() || st.size !== total) throw new Error('part-size-mismatch')
|
|
2941
|
+
if (uploadLength !== null && total > uploadLength) {
|
|
2942
|
+
fsJson(res, 409, { error: 'length-exceeded', size: total, uploadLength, session }, fsUploadHeaders(total, uploadLength, Date.now() + FS_UPLOAD_TTL_MS))
|
|
2943
|
+
return
|
|
2944
|
+
}
|
|
2690
2945
|
if (!finish) {
|
|
2691
|
-
fsJson(res, 200, { ok: true, partial: true, name, size: total, offset: total, session })
|
|
2946
|
+
fsJson(res, 200, { ok: true, partial: true, name, size: total, offset: total, session, uploadLength }, fsUploadHeaders(total, uploadLength, Date.now() + FS_UPLOAD_TTL_MS))
|
|
2947
|
+
return
|
|
2948
|
+
}
|
|
2949
|
+
if (uploadLength !== null && total !== uploadLength) {
|
|
2950
|
+
fsJson(res, 409, { error: 'length-incomplete', size: total, uploadLength, session }, fsUploadHeaders(total, uploadLength, Date.now() + FS_UPLOAD_TTL_MS))
|
|
2692
2951
|
return
|
|
2693
2952
|
}
|
|
2694
2953
|
const commit = (actualSha256) => {
|
|
@@ -2698,7 +2957,7 @@ function fsUploadResumable(req, res, url, dirLex, dirReal) {
|
|
|
2698
2957
|
if (ts.exists && !overwrite) return fsJson(res, 409, { error: 'conflict', detail: '文件已存在, overwrite=1 可覆盖' })
|
|
2699
2958
|
if (ts.exists) fs.rmSync(target, { force: true })
|
|
2700
2959
|
fs.renameSync(part, target)
|
|
2701
|
-
fsJson(res, 201, { ok: true, name, path: path.join(dirLex, name), size: total, resumed: offset > 0, session, ...(actualSha256 ? { sha256: actualSha256 } : {}) })
|
|
2960
|
+
fsJson(res, 201, { ok: true, name, path: path.join(dirLex, name), size: total, resumed: offset > 0, session, uploadLength, ...(actualSha256 ? { sha256: actualSha256 } : {}) }, fsUploadHeaders(total, uploadLength))
|
|
2702
2961
|
} catch (err) {
|
|
2703
2962
|
if (!res.headersSent) fsJson(res, 403, { error: 'write-failed', detail: err.message })
|
|
2704
2963
|
else try { res.destroy() } catch {}
|
|
@@ -2709,7 +2968,7 @@ function fsUploadResumable(req, res, url, dirLex, dirReal) {
|
|
|
2709
2968
|
sha256FileHex(part, (err, actual) => {
|
|
2710
2969
|
if (err) return fsJson(res, 403, { error: 'checksum-failed', detail: err.message })
|
|
2711
2970
|
if (actual !== sha256Expected) {
|
|
2712
|
-
return fsJson(res, 422, { error: 'checksum-mismatch', expected: sha256Expected, actual, partialSize: total, session })
|
|
2971
|
+
return fsJson(res, 422, { error: 'checksum-mismatch', expected: sha256Expected, actual, partialSize: total, session }, fsUploadHeaders(total, uploadLength, Date.now() + FS_UPLOAD_TTL_MS))
|
|
2713
2972
|
}
|
|
2714
2973
|
commit(actual)
|
|
2715
2974
|
})
|
|
@@ -2738,6 +2997,8 @@ async function fsUploadControl(req, res, url) {
|
|
|
2738
2997
|
if (resolved.error) return fsJson(res, resolved.error === 'forbidden' ? 403 : 404, { error: resolved.error })
|
|
2739
2998
|
const checked = fsRealChecked(resolved.abs)
|
|
2740
2999
|
if (checked.error) return fsJson(res, checked.error === 'forbidden' ? 403 : 404, { error: checked.error })
|
|
3000
|
+
rememberUploadDir(checked.abs)
|
|
3001
|
+
cleanupExpiredUploadParts(checked.abs)
|
|
2741
3002
|
const name = url.searchParams.get('name') || ''
|
|
2742
3003
|
if (!fsValidName(name)) return fsJson(res, 400, { error: 'bad-name' })
|
|
2743
3004
|
const session = url.searchParams.get('session') || 'default'
|
|
@@ -2998,7 +3259,24 @@ function proxyApi(req, res, url) {
|
|
|
2998
3259
|
}
|
|
2999
3260
|
|
|
3000
3261
|
// ---------- 其它 ----------
|
|
3001
|
-
async function serveHealth(res) {
|
|
3262
|
+
async function serveHealth(req, res, url) {
|
|
3263
|
+
const eventHealth = Object.fromEntries(Object.entries(eventCollectorState).map(([kind, state]) => [kind, {
|
|
3264
|
+
connected: state.connected,
|
|
3265
|
+
lastEventAt: state.lastEventAt,
|
|
3266
|
+
eventLagMs: state.lastEventAt ? Math.max(0, Date.now() - state.lastEventAt) : null,
|
|
3267
|
+
lastConnectAt: state.lastConnectAt,
|
|
3268
|
+
reconnects: state.reconnects,
|
|
3269
|
+
attempt: state.attempt,
|
|
3270
|
+
lastError: state.lastError,
|
|
3271
|
+
clients: state.clients,
|
|
3272
|
+
}]))
|
|
3273
|
+
const liveness = { ok: true, pid: process.pid, uptimeMs: Math.max(0, Date.now() - STARTED_AT), runtime: runtimeState }
|
|
3274
|
+
if (url?.searchParams.get('probe') === 'live') {
|
|
3275
|
+
cors(res)
|
|
3276
|
+
res.writeHead(200, { 'content-type': 'application/json' })
|
|
3277
|
+
res.end(JSON.stringify({ ok: true, service: 'dsh-remote', version: VERSION, probe: 'live', liveness }))
|
|
3278
|
+
return
|
|
3279
|
+
}
|
|
3002
3280
|
let upstreamOk = false
|
|
3003
3281
|
let upstreamReachable = false
|
|
3004
3282
|
let upstreamStatus = 0
|
|
@@ -3017,11 +3295,17 @@ async function serveHealth(res) {
|
|
|
3017
3295
|
} finally {
|
|
3018
3296
|
if (timer) clearTimeout(timer)
|
|
3019
3297
|
}
|
|
3298
|
+
const eventsOk = eventHealth.mux.connected && eventHealth.host.connected
|
|
3299
|
+
const readiness = { ok: upstreamOk && eventsOk, upstreamOk, eventsOk }
|
|
3300
|
+
const status = readiness.ok ? 'ready' : upstreamReachable ? 'degraded' : 'offline'
|
|
3020
3301
|
cors(res)
|
|
3021
3302
|
res.writeHead(200, { 'content-type': 'application/json; charset=utf-8' })
|
|
3022
3303
|
res.end(JSON.stringify({
|
|
3023
3304
|
ok: true,
|
|
3024
3305
|
service: 'dsh-remote',
|
|
3306
|
+
status,
|
|
3307
|
+
liveness,
|
|
3308
|
+
readiness,
|
|
3025
3309
|
version: VERSION,
|
|
3026
3310
|
protocol: { version: PROTOCOL_VERSION },
|
|
3027
3311
|
capabilities: CAPABILITIES,
|
|
@@ -3032,7 +3316,7 @@ async function serveHealth(res) {
|
|
|
3032
3316
|
upstreamReachable,
|
|
3033
3317
|
upstreamStatus,
|
|
3034
3318
|
...(upstreamError ? { upstreamError } : {}),
|
|
3035
|
-
events:
|
|
3319
|
+
events: eventHealth,
|
|
3036
3320
|
runtime: runtimeState,
|
|
3037
3321
|
}))
|
|
3038
3322
|
}
|
|
@@ -3061,7 +3345,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
3061
3345
|
if (url.pathname === '/api/events.poll') return serveEventPoll(req, res, url)
|
|
3062
3346
|
if (url.pathname.startsWith('/remote/')) return proxyApi(req, res, url)
|
|
3063
3347
|
if (url.pathname.startsWith('/api/')) return proxyApi(req, res, url)
|
|
3064
|
-
if (url.pathname === '/health') return serveHealth(res)
|
|
3348
|
+
if (url.pathname === '/health') return serveHealth(req, res, url)
|
|
3065
3349
|
touchDevice(req)
|
|
3066
3350
|
return serveStatic(req, res, url)
|
|
3067
3351
|
} 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.16",
|
|
4
4
|
"description": "DSH Remote 官方 bundle 插件:DSH 左侧原生边栏入口 + 右侧抽屉内嵌管理控制台;内置网关随 DSH 自动启停(systemd 独立单元),抽屉直显令牌与设备监控;网关提供 /fs/* 文件传输端点(列表/断点下载/上传),配合 Android App 远程操控会话/审批/提问/goal 与文件互传(多服务器测速切换、聊天记录离线缓存)。",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./index.mjs",
|