dsh-remote-plugin 0.6.25 → 0.6.26
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 +73 -37
- package/index.mjs +41 -40
- package/package.json +1 -1
- package/public/genui.css +6 -0
- package/public/genui.js +119 -9
- package/public/update.json +8 -8
- package/public/version.json +1 -1
package/apk/dsh-remote.apk
CHANGED
|
Binary file
|
package/gateway.cjs
CHANGED
|
@@ -72,6 +72,8 @@ const WS_IDLE_MS = durationEnv('GATEWAY_WS_IDLE_MS', 180000, 0, 24 * 60 * 60 * 1
|
|
|
72
72
|
const WS_UPGRADE_TIMEOUT_MS = durationEnv('GATEWAY_WS_UPGRADE_TIMEOUT_MS', 15000, 1000, 5 * 60 * 1000)
|
|
73
73
|
const UPSTREAM_REQUEST_TIMEOUT_MS = durationEnv('GATEWAY_UPSTREAM_TIMEOUT_MS', 30000, 1000, 10 * 60 * 1000)
|
|
74
74
|
const UPSTREAM = new URL(process.env.DSH_UPSTREAM || 'http://127.0.0.1:3080')
|
|
75
|
+
// URL 中 IPv6 带方括号,http.request 的 hostname 则要求裸地址。
|
|
76
|
+
const UPSTREAM_HOSTNAME = UPSTREAM.hostname.replace(/^\[|\]$/g, '')
|
|
75
77
|
const UPSTREAM_TRANSPORT = UPSTREAM.protocol === 'https:' ? https : http
|
|
76
78
|
const UPSTREAM_PORT = Number(UPSTREAM.port) || (UPSTREAM.protocol === 'https:' ? 443 : 80)
|
|
77
79
|
const UPSTREAM_AUTHORITY = `${UPSTREAM.hostname}${UPSTREAM.port ? ':' + UPSTREAM.port : ''}`
|
|
@@ -2766,7 +2768,7 @@ function serveStatic(req, res, url) {
|
|
|
2766
2768
|
// ---------- 管理 API ----------
|
|
2767
2769
|
function upstreamReachable(cb) {
|
|
2768
2770
|
const req = UPSTREAM_TRANSPORT.request({
|
|
2769
|
-
hostname:
|
|
2771
|
+
hostname: UPSTREAM_HOSTNAME,
|
|
2770
2772
|
port: UPSTREAM_PORT,
|
|
2771
2773
|
method: 'GET',
|
|
2772
2774
|
path: '/health',
|
|
@@ -3347,7 +3349,8 @@ function sha256FileHex(file, cb) {
|
|
|
3347
3349
|
const activeUploads = new Map()
|
|
3348
3350
|
const uploadPartDirs = new Set()
|
|
3349
3351
|
function fsActiveKey(dirReal, name, session) {
|
|
3350
|
-
|
|
3352
|
+
const key = fsPartPath(dirReal, name, session)
|
|
3353
|
+
return process.platform === 'win32' ? key.toLowerCase() : key
|
|
3351
3354
|
}
|
|
3352
3355
|
|
|
3353
3356
|
function rememberUploadDir(dirReal) {
|
|
@@ -3355,8 +3358,8 @@ function rememberUploadDir(dirReal) {
|
|
|
3355
3358
|
}
|
|
3356
3359
|
|
|
3357
3360
|
function uploadDirHasActive(dirReal) {
|
|
3358
|
-
const
|
|
3359
|
-
for (const key of activeUploads.keys()) if (
|
|
3361
|
+
const dir = process.platform === 'win32' ? dirReal.toLowerCase() : dirReal
|
|
3362
|
+
for (const key of activeUploads.keys()) if (path.dirname(key) === dir) return true
|
|
3360
3363
|
return false
|
|
3361
3364
|
}
|
|
3362
3365
|
|
|
@@ -3431,24 +3434,49 @@ function fsUploadPipe(res, url, dirLex, dirReal, name) {
|
|
|
3431
3434
|
return up ? fsUploadPipeFromTarget(res, up) : null
|
|
3432
3435
|
}
|
|
3433
3436
|
|
|
3437
|
+
/** 同目录提交:覆盖用原子 rename;禁止覆盖用排他 link,绝不先删旧文件。 */
|
|
3438
|
+
function fsCommitUpload(tmp, target, overwrite) {
|
|
3439
|
+
if (overwrite) fs.renameSync(tmp, target)
|
|
3440
|
+
else {
|
|
3441
|
+
fs.linkSync(tmp, target)
|
|
3442
|
+
// 目标已提交,临时名字清理失败不应把成功误报为失败。
|
|
3443
|
+
try { fs.unlinkSync(tmp) } catch {}
|
|
3444
|
+
}
|
|
3445
|
+
}
|
|
3446
|
+
|
|
3434
3447
|
function fsUploadPipeFromTarget(res, up) {
|
|
3435
3448
|
let finished = false
|
|
3449
|
+
let ending = false
|
|
3450
|
+
let written = false
|
|
3436
3451
|
const cleanup = () => {
|
|
3437
3452
|
if (finished) return
|
|
3438
3453
|
finished = true
|
|
3439
3454
|
try { up.stream.destroy() } catch {}
|
|
3440
|
-
try { fs.unlinkSync(up.tmp) } catch {}
|
|
3455
|
+
up.stream.once('close', () => { try { fs.unlinkSync(up.tmp) } catch {} })
|
|
3441
3456
|
}
|
|
3442
3457
|
up.stream.on('error', () => {
|
|
3443
3458
|
if (finished) return
|
|
3444
3459
|
finished = true
|
|
3445
|
-
try { fs.unlinkSync(up.tmp) } catch {}
|
|
3460
|
+
up.stream.once('close', () => { try { fs.unlinkSync(up.tmp) } catch {} })
|
|
3446
3461
|
if (!res.headersSent) fsJson(res, 500, { error: 'write-failed' })
|
|
3447
3462
|
else try { res.destroy() } catch {}
|
|
3448
3463
|
})
|
|
3464
|
+
up.stream.once('finish', () => { written = true })
|
|
3465
|
+
up.stream.once('close', () => {
|
|
3466
|
+
if (finished || !written) return
|
|
3467
|
+
finished = true
|
|
3468
|
+
try {
|
|
3469
|
+
fsCommitUpload(up.tmp, up.target, up.overwrite)
|
|
3470
|
+
} catch (err) {
|
|
3471
|
+
try { fs.unlinkSync(up.tmp) } catch {}
|
|
3472
|
+
if (!res.headersSent) fsJson(res, err.code === 'EEXIST' ? 409 : 403, { error: err.code === 'EEXIST' ? 'conflict' : 'write-failed', detail: err.message })
|
|
3473
|
+
return
|
|
3474
|
+
}
|
|
3475
|
+
fsJson(res, 201, { ok: true, path: up.displayPath, name: up.name, size: up.bytes })
|
|
3476
|
+
})
|
|
3449
3477
|
return {
|
|
3450
3478
|
write(chunk) {
|
|
3451
|
-
if (finished) return
|
|
3479
|
+
if (finished || ending) return
|
|
3452
3480
|
up.bytes += chunk.length
|
|
3453
3481
|
if (up.bytes > FS_MAX_UPLOAD) {
|
|
3454
3482
|
cleanup()
|
|
@@ -3459,19 +3487,9 @@ function fsUploadPipeFromTarget(res, up) {
|
|
|
3459
3487
|
up.stream.write(chunk)
|
|
3460
3488
|
},
|
|
3461
3489
|
end() {
|
|
3462
|
-
if (finished) return
|
|
3463
|
-
|
|
3464
|
-
up.stream.end(
|
|
3465
|
-
try {
|
|
3466
|
-
if (up.overwrite) fs.rmSync(up.target, { force: true })
|
|
3467
|
-
fs.renameSync(up.tmp, up.target)
|
|
3468
|
-
} catch (err) {
|
|
3469
|
-
try { fs.unlinkSync(up.tmp) } catch {}
|
|
3470
|
-
if (!res.headersSent) return fsJson(res, 403, { error: 'permission-denied', detail: err.message })
|
|
3471
|
-
return
|
|
3472
|
-
}
|
|
3473
|
-
fsJson(res, 201, { ok: true, path: up.displayPath, name: up.name, size: up.bytes })
|
|
3474
|
-
})
|
|
3490
|
+
if (finished || ending) return
|
|
3491
|
+
ending = true
|
|
3492
|
+
up.stream.end()
|
|
3475
3493
|
},
|
|
3476
3494
|
abort(status, msg) {
|
|
3477
3495
|
cleanup()
|
|
@@ -3659,6 +3677,8 @@ function fsUploadResumable(req, res, url, dirLex, dirReal) {
|
|
|
3659
3677
|
if (!fsValidName(name)) return fsJson(res, 400, { error: 'bad-name', detail: '文件名不能为空且不能包含路径分隔符' })
|
|
3660
3678
|
const session = url.searchParams.get('session') || ''
|
|
3661
3679
|
if (!session) return fsJson(res, 400, { error: 'missing-session', detail: '断点续传需要 session 参数' })
|
|
3680
|
+
const activeKey = fsActiveKey(dirReal, name, session)
|
|
3681
|
+
if (activeUploads.has(activeKey)) return fsJson(res, 409, { error: 'upload-busy' })
|
|
3662
3682
|
const queryOffsetRaw = url.searchParams.get('offset')
|
|
3663
3683
|
const headerOffsetRaw = req.headers['upload-offset']
|
|
3664
3684
|
const offsetRaw = queryOffsetRaw ?? headerOffsetRaw
|
|
@@ -3719,21 +3739,37 @@ function fsUploadResumable(req, res, url, dirLex, dirReal) {
|
|
|
3719
3739
|
} catch (err) {
|
|
3720
3740
|
return fsJson(res, 403, { error: 'permission-denied', detail: err.message })
|
|
3721
3741
|
}
|
|
3722
|
-
const activeKey = fsActiveKey(dirReal, name, session)
|
|
3723
3742
|
activeUploads.set(activeKey, stream)
|
|
3724
3743
|
|
|
3725
3744
|
let bytes = 0
|
|
3726
3745
|
let finished = false
|
|
3746
|
+
let written = false
|
|
3747
|
+
let cancelled = false
|
|
3748
|
+
const release = () => {
|
|
3749
|
+
if (activeUploads.get(activeKey) === stream) activeUploads.delete(activeKey)
|
|
3750
|
+
}
|
|
3751
|
+
res.once('finish', () => { finished = true; if (!cancelled) release() })
|
|
3727
3752
|
const abort = (status, msg, extra = {}) => {
|
|
3728
3753
|
if (finished) return
|
|
3729
3754
|
finished = true
|
|
3730
|
-
|
|
3755
|
+
cancelled = true
|
|
3756
|
+
const cleanup = () => {
|
|
3757
|
+
if (status === 413 || status === 500 || msg === 'cancelled') { try { fs.unlinkSync(part) } catch {} }
|
|
3758
|
+
release()
|
|
3759
|
+
}
|
|
3760
|
+
if (stream.closed) cleanup()
|
|
3761
|
+
else stream.once('close', cleanup)
|
|
3731
3762
|
try { stream.destroy() } catch {}
|
|
3732
3763
|
// 网络中断时保留分片, 客户端 probe 后续传; 只有超限/写失败才删
|
|
3733
|
-
if (status === 413 || status === 500) { try { fs.unlinkSync(part) } catch {} }
|
|
3734
3764
|
if (!res.headersSent) fsJson(res, status, { error: msg, ...extra })
|
|
3735
3765
|
else try { res.destroy() } catch {}
|
|
3736
3766
|
}
|
|
3767
|
+
stream.cancelUpload = () => {
|
|
3768
|
+
const closed = stream.closed ? Promise.resolve() : new Promise(resolve => stream.once('close', resolve))
|
|
3769
|
+
abort(409, 'cancelled')
|
|
3770
|
+
return closed
|
|
3771
|
+
}
|
|
3772
|
+
res.once('close', () => { if (!finished) abort(400, 'client-aborted') })
|
|
3737
3773
|
|
|
3738
3774
|
stream.on('error', (err) => {
|
|
3739
3775
|
abort(500, err.code === 'ENOENT' ? 'part-missing' : 'write-failed', { detail: err.message })
|
|
@@ -3751,9 +3787,11 @@ function fsUploadResumable(req, res, url, dirLex, dirReal) {
|
|
|
3751
3787
|
})
|
|
3752
3788
|
req.on('end', () => {
|
|
3753
3789
|
if (finished) return
|
|
3754
|
-
|
|
3755
|
-
|
|
3756
|
-
|
|
3790
|
+
stream.end()
|
|
3791
|
+
})
|
|
3792
|
+
stream.once('finish', () => { written = true })
|
|
3793
|
+
stream.once('close', () => {
|
|
3794
|
+
if (finished || !written) return
|
|
3757
3795
|
const total = offset + bytes
|
|
3758
3796
|
try {
|
|
3759
3797
|
const st = fs.statSync(part)
|
|
@@ -3771,21 +3809,22 @@ function fsUploadResumable(req, res, url, dirLex, dirReal) {
|
|
|
3771
3809
|
return
|
|
3772
3810
|
}
|
|
3773
3811
|
const commit = (actualSha256) => {
|
|
3812
|
+
if (cancelled) return
|
|
3774
3813
|
try {
|
|
3775
3814
|
const ts = fsTargetState(target)
|
|
3776
3815
|
if (ts.status) return fsJson(res, ts.status, { error: ts.error, detail: ts.detail })
|
|
3777
3816
|
if (ts.exists && !overwrite) return fsJson(res, 409, { error: 'conflict', detail: '文件已存在, overwrite=1 可覆盖' })
|
|
3778
|
-
|
|
3779
|
-
fs.renameSync(part, target)
|
|
3817
|
+
fsCommitUpload(part, target, overwrite)
|
|
3780
3818
|
fsJson(res, 201, { ok: true, name, path: path.join(dirLex, name), size: total, resumed: offset > 0, session, uploadLength, ...(actualSha256 ? { sha256: actualSha256 } : {}) }, fsUploadHeaders(total, uploadLength))
|
|
3781
3819
|
} catch (err) {
|
|
3782
|
-
if (!res.headersSent) fsJson(res, 403, { error: 'write-failed', detail: err.message })
|
|
3820
|
+
if (!res.headersSent) fsJson(res, err.code === 'EEXIST' ? 409 : 403, { error: err.code === 'EEXIST' ? 'conflict' : 'write-failed', detail: err.message })
|
|
3783
3821
|
else try { res.destroy() } catch {}
|
|
3784
3822
|
}
|
|
3785
3823
|
}
|
|
3786
3824
|
if (sha256Expected) {
|
|
3787
3825
|
// 落盘前校验: 不匹配保留分片并返回 422, 客户端可重传或取消
|
|
3788
3826
|
sha256FileHex(part, (err, actual) => {
|
|
3827
|
+
if (cancelled) return
|
|
3789
3828
|
if (err) return fsJson(res, 403, { error: 'checksum-failed', detail: err.message })
|
|
3790
3829
|
if (actual !== sha256Expected) {
|
|
3791
3830
|
return fsJson(res, 422, { error: 'checksum-mismatch', expected: sha256Expected, actual, partialSize: total, session }, fsUploadHeaders(total, uploadLength, Date.now() + FS_UPLOAD_TTL_MS))
|
|
@@ -3799,7 +3838,6 @@ function fsUploadResumable(req, res, url, dirLex, dirReal) {
|
|
|
3799
3838
|
if (!res.headersSent) fsJson(res, 403, { error: 'write-failed', detail: err.message })
|
|
3800
3839
|
else try { res.destroy() } catch {}
|
|
3801
3840
|
}
|
|
3802
|
-
})
|
|
3803
3841
|
})
|
|
3804
3842
|
}
|
|
3805
3843
|
|
|
@@ -3828,17 +3866,15 @@ async function fsUploadControl(req, res, url) {
|
|
|
3828
3866
|
const part = fsPartPath(checked.abs, name, session)
|
|
3829
3867
|
const active = activeUploads.get(fsActiveKey(checked.abs, name, session))
|
|
3830
3868
|
if (active) {
|
|
3831
|
-
|
|
3832
|
-
|
|
3869
|
+
await active.cancelUpload()
|
|
3870
|
+
return fsJson(res, 200, { ok: true, cancelled: true, session })
|
|
3833
3871
|
}
|
|
3834
|
-
//
|
|
3835
|
-
setTimeout(() => {
|
|
3872
|
+
// 无活动写流时同步删除,不留下可被新上传插入的延时窗口。
|
|
3836
3873
|
let removed = false
|
|
3837
3874
|
try { fs.unlinkSync(part); removed = true } catch (err) {
|
|
3838
3875
|
if (err.code !== 'ENOENT') return fsJson(res, 403, { error: 'permission-denied', detail: err.message })
|
|
3839
3876
|
}
|
|
3840
3877
|
fsJson(res, 200, { ok: true, cancelled: true, removed, session })
|
|
3841
|
-
}, 80)
|
|
3842
3878
|
}
|
|
3843
3879
|
|
|
3844
3880
|
async function serveFs(req, res, url) {
|
|
@@ -4164,7 +4200,7 @@ async function proxyLegacyApi(req, res, url) {
|
|
|
4164
4200
|
|
|
4165
4201
|
let responseDone = false
|
|
4166
4202
|
const upstreamReq = UPSTREAM_TRANSPORT.request({
|
|
4167
|
-
hostname:
|
|
4203
|
+
hostname: UPSTREAM_HOSTNAME,
|
|
4168
4204
|
port: UPSTREAM_PORT,
|
|
4169
4205
|
method: req.method,
|
|
4170
4206
|
path: url.pathname + url.search,
|
|
@@ -4552,7 +4588,7 @@ server.on('upgrade', (req, socket, head) => {
|
|
|
4552
4588
|
handshakeTimer = null
|
|
4553
4589
|
}
|
|
4554
4590
|
const upstreamReq = UPSTREAM_TRANSPORT.request({
|
|
4555
|
-
hostname:
|
|
4591
|
+
hostname: UPSTREAM_HOSTNAME,
|
|
4556
4592
|
port: UPSTREAM_PORT,
|
|
4557
4593
|
method: req.method,
|
|
4558
4594
|
path: url.pathname + url.search,
|
package/index.mjs
CHANGED
|
@@ -77,6 +77,14 @@ try {
|
|
|
77
77
|
let dshListen = { host: '127.0.0.1', port: 3080 }
|
|
78
78
|
let dshConnection = null
|
|
79
79
|
|
|
80
|
+
// 监听通配地址不是连接目标;保留地址族,避免 IPv6-only 服务退回 IPv4。
|
|
81
|
+
export function upstreamUrlForListener({ host, port }) {
|
|
82
|
+
let target = String(host || '127.0.0.1').replace(/^\[|\]$/g, '')
|
|
83
|
+
if (target === '0.0.0.0') target = '127.0.0.1'
|
|
84
|
+
if (net.isIP(target) === 6 && new URL(`http://[${target}]`).hostname === '[::]') target = '::1'
|
|
85
|
+
return `http://${net.isIP(target) === 6 ? `[${target}]` : target}:${port}`
|
|
86
|
+
}
|
|
87
|
+
|
|
80
88
|
function dshUpstreamCookieFile() {
|
|
81
89
|
return process.env.DSH_REMOTE_DSH_COOKIE_FILE || `${homedir()}/.dsh-remote/dsh-upstream.cookie`
|
|
82
90
|
}
|
|
@@ -88,7 +96,7 @@ function dshUpstreamCookieFile() {
|
|
|
88
96
|
*/
|
|
89
97
|
async function refreshDshUpstreamCookie() {
|
|
90
98
|
if (typeof dshConnection?.authenticatedUrl !== 'function') return false
|
|
91
|
-
const upstream =
|
|
99
|
+
const upstream = upstreamUrlForListener(dshListen)
|
|
92
100
|
try {
|
|
93
101
|
const loginUrl = dshConnection.authenticatedUrl(upstream)
|
|
94
102
|
const response = await fetch(loginUrl, {
|
|
@@ -254,11 +262,16 @@ function portInUse(port) {
|
|
|
254
262
|
|
|
255
263
|
async function gatewayRunning() {
|
|
256
264
|
try {
|
|
257
|
-
const
|
|
265
|
+
const base = gatewayBase()
|
|
266
|
+
const res = await fetch(`${base}/health`, { signal: AbortSignal.timeout(6000) })
|
|
258
267
|
if (!res.ok) return { running: false }
|
|
259
268
|
const data = await res.json().catch(() => ({}))
|
|
269
|
+
if (data.service !== 'dsh-remote' || !Number.isInteger(data.pid) || data.pid <= 1
|
|
270
|
+
|| typeof data.version !== 'string' || typeof data.upstream !== 'string'
|
|
271
|
+
|| !data.events?.mux || !data.events?.host) return { running: false }
|
|
260
272
|
return {
|
|
261
273
|
running: true,
|
|
274
|
+
base,
|
|
262
275
|
pid: Number(data.pid) || 0,
|
|
263
276
|
version: typeof data.version === 'string' ? data.version : '',
|
|
264
277
|
upstream: typeof data.upstream === 'string' ? data.upstream : '',
|
|
@@ -274,15 +287,6 @@ async function gatewayRunning() {
|
|
|
274
287
|
|
|
275
288
|
function gatewayPidFile() { return `${homedir()}/.dsh-remote/plugin-gateway.pid` }
|
|
276
289
|
|
|
277
|
-
function readGatewayPid() {
|
|
278
|
-
try {
|
|
279
|
-
const pid = Number(readFileSync(gatewayPidFile(), 'utf8').trim())
|
|
280
|
-
return Number.isFinite(pid) && pid > 0 ? pid : 0
|
|
281
|
-
} catch {
|
|
282
|
-
return 0
|
|
283
|
-
}
|
|
284
|
-
}
|
|
285
|
-
|
|
286
290
|
function writeGatewayPid(pid) {
|
|
287
291
|
try {
|
|
288
292
|
mkdirSync(`${homedir()}/.dsh-remote`, { recursive: true })
|
|
@@ -298,19 +302,22 @@ function logGateway(msg) {
|
|
|
298
302
|
}
|
|
299
303
|
|
|
300
304
|
async function killGateway(health) {
|
|
301
|
-
|
|
302
|
-
if (!pid) return false
|
|
305
|
+
// PID 文件和公开健康响应不能证明进程归属;仅走网关认证关闭接口。
|
|
306
|
+
if (!health?.running || !Number.isInteger(health.pid) || health.pid <= 1) return false
|
|
307
|
+
const token = gatewayToken()
|
|
308
|
+
if (!token) return false
|
|
303
309
|
try {
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
}
|
|
309
|
-
|
|
310
|
+
const res = await fetch(`${health.base || gatewayBase()}/admin/api/shutdown`, {
|
|
311
|
+
method: 'POST', headers: { authorization: `Bearer ${token}`, 'x-dsh-remote-client': 'admin' },
|
|
312
|
+
signal: AbortSignal.timeout(2000),
|
|
313
|
+
})
|
|
314
|
+
const body = await res.json().catch(() => ({}))
|
|
315
|
+
if (!res.ok || body.ok !== true || body.bye !== true) return false
|
|
316
|
+
logGateway('已请求旧网关认证关闭')
|
|
310
317
|
await sleep(300)
|
|
311
318
|
return true
|
|
312
319
|
} catch (e) {
|
|
313
|
-
logGateway('
|
|
320
|
+
logGateway('认证关闭旧网关失败: ' + (e?.message || String(e)))
|
|
314
321
|
return false
|
|
315
322
|
}
|
|
316
323
|
}
|
|
@@ -337,7 +344,7 @@ function setGatewayEnabled(on) {
|
|
|
337
344
|
|
|
338
345
|
/** 启动随插件分发的 gateway.cjs; 已运行则直接返回。 */
|
|
339
346
|
async function startGateway() {
|
|
340
|
-
const upstream =
|
|
347
|
+
const upstream = upstreamUrlForListener(dshListen)
|
|
341
348
|
const health = await gatewayRunning()
|
|
342
349
|
if (health.running) {
|
|
343
350
|
setGatewayEnabled(true)
|
|
@@ -413,7 +420,7 @@ function ensureGateway() {
|
|
|
413
420
|
const out = await startGateway()
|
|
414
421
|
return !!out.running
|
|
415
422
|
}
|
|
416
|
-
const upstream =
|
|
423
|
+
const upstream = upstreamUrlForListener(dshListen)
|
|
417
424
|
const oldUpstream = health.upstream || ''
|
|
418
425
|
const oldVersion = health.version || '?'
|
|
419
426
|
const versionMismatch = oldVersion !== version
|
|
@@ -421,7 +428,10 @@ function ensureGateway() {
|
|
|
421
428
|
// 重启只能制造额外断线,网关应保持运行并通过 /health 暴露 degraded 状态。
|
|
422
429
|
if (versionMismatch || (oldUpstream && oldUpstream !== upstream) || (!oldUpstream && upstream)) {
|
|
423
430
|
logGateway(`网关需刷新: 版本 ${oldVersion} -> ${version}, 上游 ${oldUpstream || '?'} -> ${upstream}`)
|
|
424
|
-
await killGateway(health)
|
|
431
|
+
if (!await killGateway(health)) {
|
|
432
|
+
logGateway('拒绝自动重启: 无法通过认证关闭旧网关,请检查令牌或端口占用')
|
|
433
|
+
return false
|
|
434
|
+
}
|
|
425
435
|
for (let i = 0; i < 10; i++) {
|
|
426
436
|
if (!(await gatewayRunning()).running) break
|
|
427
437
|
await sleep(200)
|
|
@@ -439,20 +449,10 @@ function ensureGateway() {
|
|
|
439
449
|
|
|
440
450
|
/** 通过网关自身的 /admin/api/shutdown 优雅停止(不管它当初是谁拉起的); 并写入 off 防自愈拉起。 */
|
|
441
451
|
async function stopGateway() {
|
|
442
|
-
const
|
|
443
|
-
if (!
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
method: 'POST',
|
|
447
|
-
headers: { authorization: `Bearer ${token}`, 'x-dsh-remote-client': 'admin' },
|
|
448
|
-
signal: AbortSignal.timeout(2000),
|
|
449
|
-
})
|
|
450
|
-
const json = await res.json().catch(() => ({}))
|
|
451
|
-
if (res.ok) setGatewayEnabled(false)
|
|
452
|
-
return { ok: res.ok, running: false, ...json }
|
|
453
|
-
} catch (e) {
|
|
454
|
-
return { ok: false, running: false, error: '网关不可达: ' + (e?.message || e) }
|
|
455
|
-
}
|
|
452
|
+
const health = await gatewayRunning()
|
|
453
|
+
if (!await killGateway(health)) return { ok: false, running: health.running, error: '无法确认网关身份或认证关闭失败,请检查令牌与端口占用' }
|
|
454
|
+
setGatewayEnabled(false)
|
|
455
|
+
return { ok: true, running: false, bye: true }
|
|
456
456
|
}
|
|
457
457
|
|
|
458
458
|
// ---------- 统计事件投递(实时 assistant/message + usage -> 网关 /stats/ingest) ----------
|
|
@@ -750,6 +750,10 @@ async function serveStatic(req, res, ctx) {
|
|
|
750
750
|
// 先在切换配置前读取旧端口上的健康状态;写入新端口后 gatewayRunning()
|
|
751
751
|
// 只会探测新端口,否则旧网关会变成孤儿进程继续占用旧端口。
|
|
752
752
|
const oldHealth = process.env.DSH_REMOTE_GATEWAY ? { running: false } : await gatewayRunning()
|
|
753
|
+
if (!process.env.DSH_REMOTE_GATEWAY_PORT && port !== oldPort && oldHealth.running && !await killGateway(oldHealth)) {
|
|
754
|
+
sendJson(res, 409, { ok: false, error: '无法通过认证关闭旧网关,端口配置未修改' })
|
|
755
|
+
return
|
|
756
|
+
}
|
|
753
757
|
try {
|
|
754
758
|
mkdirSync(`${homedir()}/.dsh-remote`, { recursive: true })
|
|
755
759
|
writeFileSync(gatewayPortFile(), String(port) + '\n')
|
|
@@ -758,9 +762,6 @@ async function serveStatic(req, res, ctx) {
|
|
|
758
762
|
return
|
|
759
763
|
}
|
|
760
764
|
const effectivePort = Number(readGatewayPort())
|
|
761
|
-
if (effectivePort !== oldPort) {
|
|
762
|
-
if (oldHealth.running) await killGateway(oldHealth)
|
|
763
|
-
}
|
|
764
765
|
let running = (await gatewayRunning()).running
|
|
765
766
|
if (gatewayAutostart()) {
|
|
766
767
|
const startOut = await startGateway()
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-remote-plugin",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.26",
|
|
4
4
|
"description": "DSH Remote 官方 bundle 插件:DSH 左侧原生边栏入口 + 右侧抽屉内嵌管理控制台;内置网关随 DSH 自动启停(systemd 独立单元),抽屉直显令牌与设备监控;网关提供 /fs/* 文件传输端点(列表/断点下载/上传),配合 Android App 远程操控会话/审批/提问/goal 与文件互传(多服务器测速切换、聊天记录离线缓存)。",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./index.mjs",
|
package/public/genui.css
CHANGED
|
@@ -1,2 +1,8 @@
|
|
|
1
1
|
.genui{margin:14px 0;padding:14px;border:1px solid #8884;border-radius:12px;line-height:1.65;overflow:hidden;color:inherit;background:#88808;font-size:14px}
|
|
2
2
|
.genui *{box-sizing:border-box}.genui h1,.genui h2,.genui h3,.genui h4,.genui p{margin:0 0 10px}.genui-caption{font-size:11px;opacity:.65;margin-bottom:12px}.genui-row{display:flex;flex-wrap:wrap;gap:12px}.genui-row>*{flex:1;min-width:120px}.genui-col{display:grid;gap:12px}.genui-grid{display:grid;grid-template-columns:repeat(var(--genui-cols,2),minmax(0,1fr));gap:12px}.genui-card{border:1px solid #8884;border-radius:8px;padding:12px;min-width:0}.genui-card>h4{font-size:16px}.genui-stat{display:flex;flex-direction:column;min-width:0;padding:8px 0}.genui-stat strong{font-size:28px;line-height:1.25;font-variant-numeric:tabular-nums;overflow-wrap:anywhere}.genui-stat small,.genui-stat span{opacity:.75}.genui-badge{display:inline-block;padding:2px 9px;border:1px solid #8885;border-radius:20px}.genui progress{display:block;width:100%;accent-color:#2563eb;height:10px;margin:7px 0}.genui-spacer{height:12px}.genui-callout{border-left:3px solid #2563eb;padding:10px 12px;background:#2563eb12}.genui-chart{margin:10px 0}.genui-chart figcaption{font-weight:600}.genui-chart svg{display:block;width:100%;height:auto;max-height:520px}.genui-chart svg text{fill:currentColor;font:11px system-ui}.genui-gridline{stroke:currentColor;opacity:.15;fill:none}.genui-legend{display:flex;flex-wrap:wrap;gap:7px 14px;font-size:12px}.genui-legend i{display:inline-block;width:9px;height:9px;border-radius:2px;margin-right:5px}.genui-scroll{overflow:auto;max-height:420px}.genui table{width:100%;border-collapse:collapse;font-size:12px}.genui td,.genui th{text-align:left;padding:7px;border-bottom:1px solid #8884;white-space:nowrap}.genui-source{margin-top:12px}.genui summary{cursor:pointer;font-size:12px}.genui pre{max-height:320px;overflow:auto;white-space:pre-wrap;word-break:break-word}.genui-unsupported{border-left:2px solid #d97706;padding:8px;margin:8px 0}.genui-html{display:block;width:100%;height:440px;border:1px solid #8884;border-radius:7px;background:white}.genui dl{display:grid;grid-template-columns:auto 1fr;gap:6px 12px}.genui dd{margin:0}.genui li{margin:4px 0}@media(max-width:520px){.genui{padding:10px}.genui-grid{grid-template-columns:repeat(2,minmax(0,1fr))}.genui-stat strong{font-size:23px}.genui-html{height:380px}}
|
|
3
|
+
.genui-tabs>[role=tablist]{display:flex;gap:5px;overflow-x:auto;margin-bottom:12px;border-bottom:1px solid #8884;padding-bottom:6px}
|
|
4
|
+
.genui button{font:inherit;color:inherit;background:transparent;border:1px solid #8884;border-radius:6px;padding:5px 9px;cursor:pointer}
|
|
5
|
+
.genui button:focus-visible{outline:2px solid #2563eb;outline-offset:2px}.genui [role=tab][aria-selected=true]{background:#2563eb20;border-color:#2563eb}.genui [hidden]{display:none!important}
|
|
6
|
+
.genui .genui-spark{display:block;width:120px;height:30px;max-width:100%;color:#2563eb}.genui-ring{display:inline-flex;gap:8px;align-items:center}.genui-ring svg{width:40px;height:40px;flex:none;color:#2563eb}
|
|
7
|
+
.genui-positive{color:#059669}.genui-negative{color:#dc2626}.genui-table tfoot{font-weight:600}.genui-table th[aria-sort=ascending] button::after{content:' ↑'}.genui-table th[aria-sort=descending] button::after{content:' ↓'}
|
|
8
|
+
.genui-tree{list-style:none;padding-left:16px;border-left:1px solid #8884}.genui-diff{margin:10px 0}.genui-diff section{min-width:0}.genui-diff pre{white-space:pre;overflow:auto}.genui-diff .genui-grid{gap:12px;margin-top:8px}@media(max-width:520px){.genui-diff .genui-grid{grid-template-columns:1fr}}
|
package/public/genui.js
CHANGED
|
@@ -11,13 +11,32 @@
|
|
|
11
11
|
const color = (value, i) => /^#[0-9a-f]{3}(?:[0-9a-f]{3})?$/i.test(value || '') ? value : palette[i % palette.length]
|
|
12
12
|
const raw = value => '<pre><code>' + esc(typeof value === 'string' ? value : JSON.stringify(value, null, 2)) + '</code></pre>'
|
|
13
13
|
const details = value => '<details class="genui-source"><summary>JSON / 源码</summary>' + raw(value) + '</details>'
|
|
14
|
+
let uid = 0
|
|
15
|
+
function spark(values) {
|
|
16
|
+
if (!Array.isArray(values) || values.length < 2 || values.length > 100 || !values.every(number)) return ''
|
|
17
|
+
const low = Math.min(...values), span = Math.max(...values) - low || 1
|
|
18
|
+
return '<svg class="genui-spark" viewBox="0 0 120 30" role="img" aria-label="趋势"><title>' + esc(values.join(', ')) + '</title><polyline fill="none" stroke="currentColor" stroke-width="2" points="' + values.map((v, i) => (2 + i * 116 / (values.length - 1)) + ',' + (28 - (v - low) / span * 26)).join(' ') + '"/></svg>'
|
|
19
|
+
}
|
|
20
|
+
function ring(value, label) {
|
|
21
|
+
const pct = Math.max(0, Math.min(100, value))
|
|
22
|
+
return '<span class="genui-ring"><svg viewBox="0 0 40 40" aria-hidden="true"><circle cx="20" cy="20" r="16" fill="none" stroke="currentColor" opacity=".15" stroke-width="4"/><circle cx="20" cy="20" r="16" fill="none" stroke="currentColor" stroke-width="4" pathLength="100" stroke-dasharray="' + pct + ' 100" transform="rotate(-90 20 20)"/></svg><span>' + esc(label) + '</span></span>'
|
|
23
|
+
}
|
|
24
|
+
function numeric(value) {
|
|
25
|
+
if (typeof value === 'number') return Number.isFinite(value) ? value : NaN
|
|
26
|
+
let s = String(value ?? '').trim().replace(/^[¥$€£]/, '').replace(/%$/, '')
|
|
27
|
+
if (!s) return NaN
|
|
28
|
+
let factor = 1
|
|
29
|
+
const unit = s.slice(-1).toLowerCase()
|
|
30
|
+
if ('kmb万亿'.includes(unit)) { factor = ({ k: 1e3, m: 1e6, b: 1e9, 万: 1e4, 亿: 1e8 })[unit]; s = s.slice(0, -1) }
|
|
31
|
+
return s.trim() ? Number(s.replace(/[,,\s]/g, '')) * factor : NaN
|
|
32
|
+
}
|
|
14
33
|
function unsupported(node, reason) {
|
|
15
34
|
return '<div class="genui-unsupported">暂不支持 / Unsupported: ' + esc(reason || node?.type || 'spec') + details(node) + '</div>'
|
|
16
35
|
}
|
|
17
36
|
|
|
18
37
|
function chart(node) {
|
|
19
38
|
const kind = node.kind || 'bars'
|
|
20
|
-
if (!['bars', 'line', 'donut'].includes(kind) || node.stacked || node.filter || node.sortField) return unsupported(node, 'chart options')
|
|
39
|
+
if (!['bars', 'line', 'donut'].includes(kind) || (node.stacked && kind !== 'bars') || node.filter || node.sortField) return unsupported(node, 'chart options')
|
|
21
40
|
const series = array(node.series).length ? node.series : [{ label: node.title || '', data: node.data }]
|
|
22
41
|
if (series.length > 6 || (kind === 'donut' && series.length !== 1)) return unsupported(node, 'chart series')
|
|
23
42
|
if (series.some(s => !s || !Array.isArray(s.data) || !s.data.length || s.data.length > 100 || s.data.some(d => !d || !number(d.value)))) return unsupported(node, 'chart data')
|
|
@@ -40,6 +59,11 @@
|
|
|
40
59
|
svg += '<div class="genui-legend">' + data.map((d, i) => '<span><i style="background:' + color(d.color || array(node.palette)[i], i) + '"></i>' + esc(d.label) + ' · ' + (d.value / total * 100).toFixed(1) + '%</span>').join('') + '</div>'
|
|
41
60
|
} else {
|
|
42
61
|
const values = series.flatMap(s => s.data.map(d => d.value))
|
|
62
|
+
const stacked = node.stacked === true && kind === 'bars'
|
|
63
|
+
if (stacked) data.forEach((_, i) => {
|
|
64
|
+
values.push(series.reduce((sum, s) => sum + Math.max(0, s.data[i].value), 0))
|
|
65
|
+
values.push(series.reduce((sum, s) => sum + Math.min(0, s.data[i].value), 0))
|
|
66
|
+
})
|
|
43
67
|
let min = Math.min(0, ...values), max = Math.max(0, ...values)
|
|
44
68
|
if (min === max) max = min + 1
|
|
45
69
|
const horizontal = kind === 'bars' && node.horizontal === true
|
|
@@ -57,14 +81,15 @@
|
|
|
57
81
|
series.forEach((s, si) => {
|
|
58
82
|
const points = []
|
|
59
83
|
s.data.forEach((d, i) => {
|
|
60
|
-
const
|
|
84
|
+
const base = stacked ? series.slice(0, si).reduce((sum, prev) => sum + (d.value >= 0 ? Math.max(0, prev.data[i].value) : Math.min(0, prev.data[i].value)), 0) : 0
|
|
85
|
+
const center = (horizontal ? top : left) + step * (i + 0.5), pos = scale(base + d.value)
|
|
61
86
|
const hint = '<title>' + esc((s.label ? s.label + ' · ' : '') + d.label + ': ' + d.value) + '</title>'
|
|
62
87
|
if (kind === 'line') {
|
|
63
88
|
points.push(center + ',' + pos)
|
|
64
89
|
svg += '<circle cx="' + center + '" cy="' + pos + '" r="3" fill="' + colors[si] + '">' + hint + '</circle>'
|
|
65
90
|
} else {
|
|
66
|
-
const size = step * 0.7 / series.length, lane = center - step * 0.35 + si * size
|
|
67
|
-
const zero = scale(
|
|
91
|
+
const size = step * 0.7 / (stacked ? 1 : series.length), lane = center - step * 0.35 + (stacked ? 0 : si * size)
|
|
92
|
+
const zero = scale(base)
|
|
68
93
|
svg += '<rect x="' + (horizontal ? Math.min(zero, pos) : lane) + '" y="' + (horizontal ? lane : Math.min(zero, pos)) + '" width="' + (horizontal ? Math.abs(zero - pos) : size) + '" height="' + (horizontal ? size : Math.abs(zero - pos)) + '" fill="' + (series.length === 1 ? color(d.color || array(node.palette)[i], i) : colors[si]) + '">' + hint + '</rect>'
|
|
69
94
|
}
|
|
70
95
|
})
|
|
@@ -85,9 +110,38 @@
|
|
|
85
110
|
if (!spec || typeof spec !== 'object') throw Error('Invalid spec')
|
|
86
111
|
let count = 0
|
|
87
112
|
function nodes(items, depth) { return array(items).map(n => node(n, depth)).join('') }
|
|
113
|
+
function fileTree(items, depth) {
|
|
114
|
+
if (depth > 12 || (count += array(items).length) > 240) throw Error('Component limit')
|
|
115
|
+
return '<ul class="genui-tree">' + array(items).map(v => !v || typeof v !== 'object' ? '<li>' + esc(v) + '</li>' : '<li>' + (v.type === 'dir' || Array.isArray(v.children) ? '<details open><summary>' + esc(v.name) + '</summary>' + fileTree(v.children, depth + 1) + '</details>' : '<span>' + esc(v.name) + '</span>') + '</li>').join('') + '</ul>'
|
|
116
|
+
}
|
|
117
|
+
function table(n, depth) {
|
|
118
|
+
const columns = array(n.columns), rows = array(n.rows), types = array(n.types)
|
|
119
|
+
if (!columns.length || rows.some(r => !Array.isArray(r)) || types.some(t => !['text', 'num', 'delta', 'bar', 'badge', 'spark', 'ring', 'index'].includes(t))) return unsupported(n, 'table shape/types')
|
|
120
|
+
const cell = (v, type, i) => {
|
|
121
|
+
const num = numeric(v)
|
|
122
|
+
if (type === 'index') return String(i + 1)
|
|
123
|
+
if (type === 'spark') return spark(String(v).split(/[\s,;]+/).map(Number)) || esc(v)
|
|
124
|
+
if (type === 'ring' && Number.isFinite(num)) return ring(num, v)
|
|
125
|
+
if (type === 'bar' && Number.isFinite(num)) return '<span>' + esc(v) + '</span><progress max="100" value="' + Math.max(0, Math.min(100, num)) + '"></progress>'
|
|
126
|
+
if (type === 'badge') return '<span class="genui-badge">' + esc(v) + '</span>'
|
|
127
|
+
if (type === 'delta') return '<span class="genui-' + (num > 0 ? 'positive' : num < 0 ? 'negative' : 'neutral') + '">' + esc(v) + '</span>'
|
|
128
|
+
return esc(v)
|
|
129
|
+
}
|
|
130
|
+
let foot = ''
|
|
131
|
+
if (n.total) foot = '<tfoot><tr>' + columns.map((_, j) => {
|
|
132
|
+
const values = rows.map(r => numeric(r[j]))
|
|
133
|
+
return '<td>' + (j === 0 ? '合计 / Total' : values.length && values.every(Number.isFinite) && !['index', 'spark', 'ring', 'bar'].includes(types[j]) ? esc(Number(values.reduce((a, b) => a + b, 0).toPrecision(12))) : '') + '</td>'
|
|
134
|
+
}).join('') + '</tr></tfoot>'
|
|
135
|
+
return '<div class="genui-scroll"><table class="genui-table"><thead><tr>' + columns.map((v, j) => '<th aria-sort="none"><button type="button" data-genui-sort="' + j + '">' + esc(v) + ' ↕</button></th>').join('') + '</tr></thead>' + rows.map((r, i) => '<tbody data-genui-order="' + i + '"><tr>' + columns.map((_, j) => '<td data-genui-value="' + esc(r[j]) + '">' + cell(r[j], types[j], i) + '</td>').join('') + '</tr>' + (array(n.details?.[i]).length ? '<tr><td colspan="' + columns.length + '"><details><summary>详情 / Details</summary>' + nodes(n.details[i], depth + 1) + '</details></td></tr>' : '') + '</tbody>').join('') + foot + '</table></div>' + (n.export ? '<details><summary>导出数据 / CSV</summary>' + raw([columns, ...rows].map(r => r.map(v => '"' + String(v ?? '').replace(/"/g, '""') + '"').join(',')).join('\n')) + '</details>' : '')
|
|
136
|
+
}
|
|
88
137
|
function node(n, depth) {
|
|
89
138
|
if (++count > 240 || depth > 12) throw Error('Component limit')
|
|
90
139
|
if (!n || typeof n !== 'object' || Array.isArray(n)) return unsupported(n)
|
|
140
|
+
// 插件公开的常用字段别名。
|
|
141
|
+
n = { ...n }
|
|
142
|
+
if (n.type === 'text' && n.content === undefined) n.content = n.text
|
|
143
|
+
if (n.type === 'table') { n.columns ??= n.headers; n.rows ??= n.data }
|
|
144
|
+
if (n.type === 'card') { n.title ??= n.label; n.items ??= n.content }
|
|
91
145
|
const type = n.type
|
|
92
146
|
// Reject data-transforming options rather than silently displaying different data.
|
|
93
147
|
if (n.filter || n.sortField) return unsupported(n, type + ' filter/sort')
|
|
@@ -98,21 +152,33 @@
|
|
|
98
152
|
return '<section class="genui-' + type + '"' + (type === 'grid' ? ' style="--genui-cols:' + cols + '"' : '') + '>' + (n.title ? '<h4>' + esc(n.title) + '</h4>' : '') + nodes(n.items, depth + 1) + '</section>'
|
|
99
153
|
}
|
|
100
154
|
if (type === 'text') { const tag = ['h1', 'h2', 'h3'].includes(n.size) ? n.size : 'p'; return '<' + tag + '>' + esc(n.content) + '</' + tag + '>' }
|
|
101
|
-
if (type === 'hero') return '<div class="genui-stat"><small>' + esc(n.label) + '</small><strong>' + esc(n.value) + '</strong><h4>' + esc(n.title) + '</h4><span>' + esc(n.subtitle) + '</span><span>' + esc(n.delta) + '</span
|
|
102
|
-
if (type === 'stat') return '<div class="genui-stat"><small>' + esc(n.label) + '</small><strong>' + esc(n.value) + '</strong><span>' + esc(n.delta) + '</span
|
|
155
|
+
if (type === 'hero') return '<div class="genui-stat"><small>' + esc(n.label) + '</small><strong>' + esc(n.value) + '</strong><h4>' + esc(n.title) + '</h4><span>' + esc(n.subtitle) + '</span><span>' + esc(n.delta) + '</span>' + spark(n.spark) + '</div>'
|
|
156
|
+
if (type === 'stat') return '<div class="genui-stat"><small>' + esc(n.label) + '</small><strong>' + esc(n.value) + '</strong><span>' + esc(n.delta) + '</span>' + spark(n.spark) + '</div>'
|
|
103
157
|
if (type === 'badge') return '<span class="genui-badge">' + esc(n.label) + '</span>'
|
|
104
158
|
if (type === 'divider') return '<hr>'
|
|
105
159
|
if (type === 'spacer') return '<div class="genui-spacer"></div>'
|
|
106
160
|
if (type === 'progress' && !number(n.value)) return unsupported(n, 'progress value')
|
|
161
|
+
if (type === 'progress' && n.variant === 'ring') return '<div>' + esc(n.label) + ring(n.value, n.valueLabel || n.value + '%') + (number(n.target) ? '<small>目标 / Target: ' + esc(n.target) + '%</small>' : '') + '</div>'
|
|
107
162
|
if (type === 'progress') return '<div>' + esc(n.label) + '<progress max="100" value="' + Math.max(0, Math.min(100, Number(n.value) || 0)) + '"></progress><small>' + esc(n.valueLabel || String(n.value || 0) + '%') + '</small>' + (number(n.target) ? '<small> · 目标 / Target: ' + esc(n.target) + '%</small>' : '') + '</div>'
|
|
108
163
|
if (type === 'callout') return '<aside class="genui-callout"><strong>' + esc(n.title) + '</strong><p>' + esc(n.content) + '</p></aside>'
|
|
109
164
|
if (type === 'code' || type === 'json') return raw(type === 'code' ? n.code : n.value)
|
|
110
165
|
if (type === 'list' || type === 'timeline' || type === 'steps') return '<ul>' + array(n.items || n.steps).slice(0, 100).map(v => '<li>' + (v && typeof v === 'object' ? (v.type ? node(v, depth + 1) : '<strong>' + esc(v.title) + '</strong> ' + esc(v.desc) + ' ' + esc(v.time)) : esc(v)) + '</li>').join('') + '</ul>'
|
|
111
166
|
if (type === 'keyvalue') return '<dl>' + array(n.pairs).slice(0, 100).map(v => '<dt>' + esc(v?.key) + '</dt><dd>' + esc(v?.value) + '</dd>').join('') + '</dl>'
|
|
112
|
-
if (type === 'table') return
|
|
167
|
+
if (type === 'table') return table(n, depth)
|
|
168
|
+
if (type === 'file-tree') return fileTree(n.items, depth + 1)
|
|
169
|
+
if (type === 'breadcrumb') return '<nav aria-label="路径">' + array(n.items).map(esc).join(' › ') + '</nav>'
|
|
170
|
+
if (type === 'avatar') return '<span class="genui-badge">' + esc(n.name) + '</span>'
|
|
171
|
+
if (type === 'diff') {
|
|
172
|
+
if (array(n.diffs).length > 30) return unsupported(n, 'diff size limit')
|
|
173
|
+
return array(n.diffs).map(d => '<details class="genui-diff" open><summary>' + esc(d?.path) + '</summary><div class="genui-grid">' + (d?.oldText == null ? '<section>新增文件 / New file</section>' : '<section><strong>修改前 / Before</strong>' + raw(d.oldText) + '</section>') + '<section><strong>修改后 / After</strong>' + raw(d?.newText) + '</section></div></details>').join('')
|
|
174
|
+
}
|
|
113
175
|
if (type === 'chart') return chart(n)
|
|
114
176
|
if (type === 'echart' && !n.option && ['bar', 'line', 'pie'].includes(n.preset)) return '<small>ECharts 基础数据预览 / Basic preview</small>' + chart({ ...n, type: 'chart', kind: { bar: 'bars', line: 'line', pie: 'donut' }[n.preset] })
|
|
115
|
-
if (type === '
|
|
177
|
+
if (type === 'tabs') {
|
|
178
|
+
const id = 'genui-tabs-' + (++uid), tabs = array(n.tabs).slice(0, 30)
|
|
179
|
+
return '<div class="genui-tabs"><div role="tablist" aria-label="' + esc(n.title || '标签页') + '">' + tabs.map((v, i) => '<button type="button" role="tab" id="' + id + '-tab-' + i + '" aria-controls="' + id + '-panel-' + i + '" aria-selected="' + (i === 0) + '" tabindex="' + (i === 0 ? 0 : -1) + '" data-genui-tab="' + i + '">' + esc(v?.label) + '</button>').join('') + '</div>' + tabs.map((v, i) => '<div role="tabpanel" id="' + id + '-panel-' + i + '" aria-labelledby="' + id + '-tab-' + i + '"' + (i ? ' hidden' : '') + '>' + nodes(v?.items || v?.content, depth + 1) + '</div>').join('') + '</div>'
|
|
180
|
+
}
|
|
181
|
+
if (type === 'accordion') return array(n.items).slice(0, 30).map(v => '<details><summary>' + esc(v?.title) + '</summary>' + nodes(v?.items, depth + 1) + '</details>').join('')
|
|
116
182
|
return unsupported(n)
|
|
117
183
|
}
|
|
118
184
|
const body = Array.isArray(spec) ? nodes(spec, 0) : spec.type ? node(spec, 0) : nodes(spec.items, 0)
|
|
@@ -140,5 +206,49 @@
|
|
|
140
206
|
const srcDoc = '<!doctype html><html><head><meta http-equiv="Content-Security-Policy" content="' + policy + '"><meta name="viewport" content="width=device-width,initial-scale=1"><style>body{font:14px/1.6 system-ui;margin:16px;overflow-wrap:anywhere}table{border-collapse:collapse}td,th{padding:6px;border:1px solid #ccc}pre{white-space:pre-wrap}*{max-width:100%;box-sizing:border-box}</style></head><body>' + template.innerHTML + '</body></html>'
|
|
141
207
|
return '<section class="genui"><div class="genui-caption">HTML · 只读预览(脚本及外部资源已禁用)</div><iframe class="genui-html" sandbox="" referrerpolicy="no-referrer" title="HTML preview" srcdoc="' + esc(srcDoc) + '"></iframe></section>'
|
|
142
208
|
}
|
|
143
|
-
|
|
209
|
+
function activateTab(button, focus = false) {
|
|
210
|
+
const list = button.parentElement, tabs = Array.from(list.children)
|
|
211
|
+
tabs.forEach(tab => {
|
|
212
|
+
const active = tab === button
|
|
213
|
+
tab.setAttribute('aria-selected', String(active)); tab.tabIndex = active ? 0 : -1
|
|
214
|
+
const panel = Array.from(list.parentElement.children).find(p => p.id === tab.getAttribute('aria-controls'))
|
|
215
|
+
if (panel) panel.hidden = !active
|
|
216
|
+
})
|
|
217
|
+
if (focus) button.focus()
|
|
218
|
+
}
|
|
219
|
+
function compareCells(a, b) {
|
|
220
|
+
const x = numeric(a), y = numeric(b)
|
|
221
|
+
if (Number.isFinite(x) && Number.isFinite(y)) return x - y
|
|
222
|
+
if (Number.isFinite(x)) return -1
|
|
223
|
+
if (Number.isFinite(y)) return 1
|
|
224
|
+
return String(a ?? '').localeCompare(String(b ?? ''))
|
|
225
|
+
}
|
|
226
|
+
if (typeof document !== 'undefined') {
|
|
227
|
+
document.addEventListener('click', event => {
|
|
228
|
+
const button = event.target.closest?.('.genui button')
|
|
229
|
+
if (!button) return
|
|
230
|
+
if (button.hasAttribute('data-genui-tab')) activateTab(button)
|
|
231
|
+
if (button.hasAttribute('data-genui-sort')) {
|
|
232
|
+
const table = button.closest('table'), th = button.parentElement
|
|
233
|
+
const col = Number(button.dataset.genuiSort)
|
|
234
|
+
const previous = th.getAttribute('aria-sort'), direction = previous === 'ascending' ? 'descending' : previous === 'descending' ? 'none' : 'ascending'
|
|
235
|
+
Array.from(table.tHead.rows[0].cells).forEach(cell => cell.setAttribute('aria-sort', 'none'))
|
|
236
|
+
th.setAttribute('aria-sort', direction)
|
|
237
|
+
Array.from(table.tBodies).sort((a, b) => {
|
|
238
|
+
const order = Number(a.dataset.genuiOrder) - Number(b.dataset.genuiOrder)
|
|
239
|
+
if (direction === 'none') return order
|
|
240
|
+
const compared = compareCells(a.rows[0].cells[col]?.dataset.genuiValue, b.rows[0].cells[col]?.dataset.genuiValue)
|
|
241
|
+
return (direction === 'ascending' ? compared : -compared) || order
|
|
242
|
+
}).forEach(body => table.insertBefore(body, table.tFoot))
|
|
243
|
+
}
|
|
244
|
+
})
|
|
245
|
+
document.addEventListener('keydown', event => {
|
|
246
|
+
const button = event.target.closest?.('.genui [data-genui-tab]')
|
|
247
|
+
if (!button || !['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) return
|
|
248
|
+
const tabs = Array.from(button.parentElement.children), i = tabs.indexOf(button)
|
|
249
|
+
const next = event.key === 'Home' ? 0 : event.key === 'End' ? tabs.length - 1 : (i + (event.key === 'ArrowRight' ? 1 : -1) + tabs.length) % tabs.length
|
|
250
|
+
event.preventDefault(); activateTab(tabs[next], true)
|
|
251
|
+
})
|
|
252
|
+
}
|
|
253
|
+
return { render, html, compareCells }
|
|
144
254
|
})
|
package/public/update.json
CHANGED
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version": "0.6.
|
|
2
|
+
"version": "0.6.26",
|
|
3
3
|
"apkUrl": "dsh-remote.apk",
|
|
4
|
-
"sha256": "
|
|
5
|
-
"releasedAt": "2026-09-
|
|
6
|
-
"notes": "0.6.
|
|
4
|
+
"sha256": "5b60977e3960d65b1ab60cb9f168835c8de927113738d925881340749122d2aa",
|
|
5
|
+
"releasedAt": "2026-09-16T16:59:10.029Z",
|
|
6
|
+
"notes": "0.6.26:修复 DSH 通配监听地址导致的认证与实时连接失败,兼容 IPv6-only;上传覆盖失败保留原文件,避免并发同名覆盖和续传分片竞争;网关改用认证关闭,避免误杀其他进程;完善 dsh-ui 堆叠柱状图、标签页、表格排序与特殊单元格、趋势线、环形进度、文件树和修改前后预览。已通过 Windows 自动测试与浏览器检查,Linux/Docker 专项实测仍待完成。",
|
|
7
7
|
"history": [
|
|
8
|
+
{
|
|
9
|
+
"version": "0.6.26",
|
|
10
|
+
"notes": "0.6.26:修复 DSH 通配监听地址导致的认证与实时连接失败,兼容 IPv6-only;上传覆盖失败保留原文件,避免并发同名覆盖和续传分片竞争;网关改用认证关闭,避免误杀其他进程;完善 dsh-ui 堆叠柱状图、标签页、表格排序与特殊单元格、趋势线、环形进度、文件树和修改前后预览。已通过 Windows 自动测试与浏览器检查,Linux/Docker 专项实测仍待完成。"
|
|
11
|
+
},
|
|
8
12
|
{
|
|
9
13
|
"version": "0.6.25",
|
|
10
14
|
"notes": "0.6.25:适配 DSH 0.1.5 的实时思考、文件消息与 V3 会话统计;修复 Windows 跨盘文件访问和空会话持久归档;完善 APK 下载进度与校验;新增 dsh-ui 常用组件及柱状图、折线图、环形图只读预览,HTML 代码块支持隔离预览,未支持的高级组件保留原始规格。"
|
|
@@ -40,10 +44,6 @@
|
|
|
40
44
|
{
|
|
41
45
|
"version": "0.6.17",
|
|
42
46
|
"notes": "0.6.17:修复 host.describe 初次失败后总览误报 DSH 上游离线并增加恢复重试;识别 Docker、无 systemd 和插件内嵌等外部生命周期环境,隐藏不可用的 DSH 启停并给出明确提示;兼容 Caddy Basic Auth,避免管理 API 覆盖 Authorization 导致登录循环;支持通过环境变量或管理页手动补充 Docker 宿主、局域网和 Tailscale 地址并写入配对二维码;完整适配 Windows 盘符、UNC、多文件根和跨主机文件路径清理,并新增 Windows 原生 CI 安全门禁。"
|
|
43
|
-
},
|
|
44
|
-
{
|
|
45
|
-
"version": "0.6.16",
|
|
46
|
-
"notes": "0.6.16:Windows 端 DSH 服务启动/重启接入 Windows Service(实验性功能),启动后继续检查 DSH HTTP 与 mux/host 实时通道;空对话退出时清理 App 本地残留;网关控制台主机 IP 改为可逐项启用的地址表,关闭地址不再进入配对二维码、诊断和防火墙建议;Android 设置新增模型配置、自定义模型思考深度档位和小米/系统 ASR 功能测试,支持复制设备、权限、partial/final、session 重建和错误诊断日志。"
|
|
47
47
|
}
|
|
48
48
|
]
|
|
49
49
|
}
|
package/public/version.json
CHANGED