dsh-remote-plugin 0.6.25 → 0.7.0-rc.1
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 +55 -40
- package/package.json +3 -2
- package/plugin-center.mjs +246 -0
- package/public/app.js +11 -0
- package/public/desktop/desktop.html +3 -0
- package/public/desktop/desktop.js +11 -0
- package/public/genui.css +6 -0
- package/public/genui.js +119 -9
- package/public/index.html +3 -0
- package/public/plugin-center.css +1 -0
- package/public/plugin-center.js +181 -0
- 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
|
@@ -12,6 +12,7 @@ import net from 'node:net'
|
|
|
12
12
|
import { homedir, hostname, networkInterfaces } from 'node:os'
|
|
13
13
|
import { dirname, extname, normalize, resolve } from 'node:path'
|
|
14
14
|
import { fileURLToPath } from 'node:url'
|
|
15
|
+
import { createPluginCenter } from './plugin-center.mjs'
|
|
15
16
|
|
|
16
17
|
export const name = 'dsh-remote'
|
|
17
18
|
export const inject = ['webServer', 'commands', 'agents', 'connection']
|
|
@@ -77,6 +78,14 @@ try {
|
|
|
77
78
|
let dshListen = { host: '127.0.0.1', port: 3080 }
|
|
78
79
|
let dshConnection = null
|
|
79
80
|
|
|
81
|
+
// 监听通配地址不是连接目标;保留地址族,避免 IPv6-only 服务退回 IPv4。
|
|
82
|
+
export function upstreamUrlForListener({ host, port }) {
|
|
83
|
+
let target = String(host || '127.0.0.1').replace(/^\[|\]$/g, '')
|
|
84
|
+
if (target === '0.0.0.0') target = '127.0.0.1'
|
|
85
|
+
if (net.isIP(target) === 6 && new URL(`http://[${target}]`).hostname === '[::]') target = '::1'
|
|
86
|
+
return `http://${net.isIP(target) === 6 ? `[${target}]` : target}:${port}`
|
|
87
|
+
}
|
|
88
|
+
|
|
80
89
|
function dshUpstreamCookieFile() {
|
|
81
90
|
return process.env.DSH_REMOTE_DSH_COOKIE_FILE || `${homedir()}/.dsh-remote/dsh-upstream.cookie`
|
|
82
91
|
}
|
|
@@ -88,7 +97,7 @@ function dshUpstreamCookieFile() {
|
|
|
88
97
|
*/
|
|
89
98
|
async function refreshDshUpstreamCookie() {
|
|
90
99
|
if (typeof dshConnection?.authenticatedUrl !== 'function') return false
|
|
91
|
-
const upstream =
|
|
100
|
+
const upstream = upstreamUrlForListener(dshListen)
|
|
92
101
|
try {
|
|
93
102
|
const loginUrl = dshConnection.authenticatedUrl(upstream)
|
|
94
103
|
const response = await fetch(loginUrl, {
|
|
@@ -254,11 +263,16 @@ function portInUse(port) {
|
|
|
254
263
|
|
|
255
264
|
async function gatewayRunning() {
|
|
256
265
|
try {
|
|
257
|
-
const
|
|
266
|
+
const base = gatewayBase()
|
|
267
|
+
const res = await fetch(`${base}/health`, { signal: AbortSignal.timeout(6000) })
|
|
258
268
|
if (!res.ok) return { running: false }
|
|
259
269
|
const data = await res.json().catch(() => ({}))
|
|
270
|
+
if (data.service !== 'dsh-remote' || !Number.isInteger(data.pid) || data.pid <= 1
|
|
271
|
+
|| typeof data.version !== 'string' || typeof data.upstream !== 'string'
|
|
272
|
+
|| !data.events?.mux || !data.events?.host) return { running: false }
|
|
260
273
|
return {
|
|
261
274
|
running: true,
|
|
275
|
+
base,
|
|
262
276
|
pid: Number(data.pid) || 0,
|
|
263
277
|
version: typeof data.version === 'string' ? data.version : '',
|
|
264
278
|
upstream: typeof data.upstream === 'string' ? data.upstream : '',
|
|
@@ -274,15 +288,6 @@ async function gatewayRunning() {
|
|
|
274
288
|
|
|
275
289
|
function gatewayPidFile() { return `${homedir()}/.dsh-remote/plugin-gateway.pid` }
|
|
276
290
|
|
|
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
291
|
function writeGatewayPid(pid) {
|
|
287
292
|
try {
|
|
288
293
|
mkdirSync(`${homedir()}/.dsh-remote`, { recursive: true })
|
|
@@ -298,19 +303,22 @@ function logGateway(msg) {
|
|
|
298
303
|
}
|
|
299
304
|
|
|
300
305
|
async function killGateway(health) {
|
|
301
|
-
|
|
302
|
-
if (!pid) return false
|
|
306
|
+
// PID 文件和公开健康响应不能证明进程归属;仅走网关认证关闭接口。
|
|
307
|
+
if (!health?.running || !Number.isInteger(health.pid) || health.pid <= 1) return false
|
|
308
|
+
const token = gatewayToken()
|
|
309
|
+
if (!token) return false
|
|
303
310
|
try {
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
}
|
|
309
|
-
|
|
311
|
+
const res = await fetch(`${health.base || gatewayBase()}/admin/api/shutdown`, {
|
|
312
|
+
method: 'POST', headers: { authorization: `Bearer ${token}`, 'x-dsh-remote-client': 'admin' },
|
|
313
|
+
signal: AbortSignal.timeout(2000),
|
|
314
|
+
})
|
|
315
|
+
const body = await res.json().catch(() => ({}))
|
|
316
|
+
if (!res.ok || body.ok !== true || body.bye !== true) return false
|
|
317
|
+
logGateway('已请求旧网关认证关闭')
|
|
310
318
|
await sleep(300)
|
|
311
319
|
return true
|
|
312
320
|
} catch (e) {
|
|
313
|
-
logGateway('
|
|
321
|
+
logGateway('认证关闭旧网关失败: ' + (e?.message || String(e)))
|
|
314
322
|
return false
|
|
315
323
|
}
|
|
316
324
|
}
|
|
@@ -337,7 +345,7 @@ function setGatewayEnabled(on) {
|
|
|
337
345
|
|
|
338
346
|
/** 启动随插件分发的 gateway.cjs; 已运行则直接返回。 */
|
|
339
347
|
async function startGateway() {
|
|
340
|
-
const upstream =
|
|
348
|
+
const upstream = upstreamUrlForListener(dshListen)
|
|
341
349
|
const health = await gatewayRunning()
|
|
342
350
|
if (health.running) {
|
|
343
351
|
setGatewayEnabled(true)
|
|
@@ -413,7 +421,7 @@ function ensureGateway() {
|
|
|
413
421
|
const out = await startGateway()
|
|
414
422
|
return !!out.running
|
|
415
423
|
}
|
|
416
|
-
const upstream =
|
|
424
|
+
const upstream = upstreamUrlForListener(dshListen)
|
|
417
425
|
const oldUpstream = health.upstream || ''
|
|
418
426
|
const oldVersion = health.version || '?'
|
|
419
427
|
const versionMismatch = oldVersion !== version
|
|
@@ -421,7 +429,10 @@ function ensureGateway() {
|
|
|
421
429
|
// 重启只能制造额外断线,网关应保持运行并通过 /health 暴露 degraded 状态。
|
|
422
430
|
if (versionMismatch || (oldUpstream && oldUpstream !== upstream) || (!oldUpstream && upstream)) {
|
|
423
431
|
logGateway(`网关需刷新: 版本 ${oldVersion} -> ${version}, 上游 ${oldUpstream || '?'} -> ${upstream}`)
|
|
424
|
-
await killGateway(health)
|
|
432
|
+
if (!await killGateway(health)) {
|
|
433
|
+
logGateway('拒绝自动重启: 无法通过认证关闭旧网关,请检查令牌或端口占用')
|
|
434
|
+
return false
|
|
435
|
+
}
|
|
425
436
|
for (let i = 0; i < 10; i++) {
|
|
426
437
|
if (!(await gatewayRunning()).running) break
|
|
427
438
|
await sleep(200)
|
|
@@ -439,20 +450,10 @@ function ensureGateway() {
|
|
|
439
450
|
|
|
440
451
|
/** 通过网关自身的 /admin/api/shutdown 优雅停止(不管它当初是谁拉起的); 并写入 off 防自愈拉起。 */
|
|
441
452
|
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
|
-
}
|
|
453
|
+
const health = await gatewayRunning()
|
|
454
|
+
if (!await killGateway(health)) return { ok: false, running: health.running, error: '无法确认网关身份或认证关闭失败,请检查令牌与端口占用' }
|
|
455
|
+
setGatewayEnabled(false)
|
|
456
|
+
return { ok: true, running: false, bye: true }
|
|
456
457
|
}
|
|
457
458
|
|
|
458
459
|
// ---------- 统计事件投递(实时 assistant/message + usage -> 网关 /stats/ingest) ----------
|
|
@@ -618,6 +619,17 @@ async function resolveFile(pathname) {
|
|
|
618
619
|
|
|
619
620
|
async function serveStatic(req, res, ctx) {
|
|
620
621
|
const pathname = new URL(req.url ?? '/', 'http://x').pathname
|
|
622
|
+
if (pathname.startsWith(`${MOUNT}/api/plugins/`)) {
|
|
623
|
+
if (!remoteCommandAuthorized(req)) return sendJson(res, 401, { ok: false, message: 'unauthorized' })
|
|
624
|
+
try {
|
|
625
|
+
const url = new URL(req.url, 'http://x')
|
|
626
|
+
const body = req.method === 'POST' ? JSON.parse((await readBody(req, 8192)) || '{}') : {}
|
|
627
|
+
const result = await pluginCenters.get(ctx).handle(req.method, pathname.slice(`${MOUNT}/api/plugins`.length), body, url.searchParams)
|
|
628
|
+
return sendJson(res, req.method === 'POST' ? 202 : 200, result)
|
|
629
|
+
} catch (error) {
|
|
630
|
+
return sendJson(res, error.status || (error instanceof SyntaxError ? 400 : 500), { ok: false, message: error.message || 'Plugin operation failed' })
|
|
631
|
+
}
|
|
632
|
+
}
|
|
621
633
|
|
|
622
634
|
// 无尾斜杠的入口重定向到带斜杠版本:
|
|
623
635
|
// 否则相对资源 styles.css/app.js 会按 URL 规则解析到上级路径 /styles.css,
|
|
@@ -750,6 +762,10 @@ async function serveStatic(req, res, ctx) {
|
|
|
750
762
|
// 先在切换配置前读取旧端口上的健康状态;写入新端口后 gatewayRunning()
|
|
751
763
|
// 只会探测新端口,否则旧网关会变成孤儿进程继续占用旧端口。
|
|
752
764
|
const oldHealth = process.env.DSH_REMOTE_GATEWAY ? { running: false } : await gatewayRunning()
|
|
765
|
+
if (!process.env.DSH_REMOTE_GATEWAY_PORT && port !== oldPort && oldHealth.running && !await killGateway(oldHealth)) {
|
|
766
|
+
sendJson(res, 409, { ok: false, error: '无法通过认证关闭旧网关,端口配置未修改' })
|
|
767
|
+
return
|
|
768
|
+
}
|
|
753
769
|
try {
|
|
754
770
|
mkdirSync(`${homedir()}/.dsh-remote`, { recursive: true })
|
|
755
771
|
writeFileSync(gatewayPortFile(), String(port) + '\n')
|
|
@@ -758,9 +774,6 @@ async function serveStatic(req, res, ctx) {
|
|
|
758
774
|
return
|
|
759
775
|
}
|
|
760
776
|
const effectivePort = Number(readGatewayPort())
|
|
761
|
-
if (effectivePort !== oldPort) {
|
|
762
|
-
if (oldHealth.running) await killGateway(oldHealth)
|
|
763
|
-
}
|
|
764
777
|
let running = (await gatewayRunning()).running
|
|
765
778
|
if (gatewayAutostart()) {
|
|
766
779
|
const startOut = await startGateway()
|
|
@@ -940,7 +953,9 @@ async function serveStatic(req, res, ctx) {
|
|
|
940
953
|
createReadStream(abs).pipe(res)
|
|
941
954
|
}
|
|
942
955
|
|
|
956
|
+
const pluginCenters = new WeakMap()
|
|
943
957
|
export function apply(ctx) {
|
|
958
|
+
pluginCenters.set(ctx, createPluginCenter(ctx))
|
|
944
959
|
dshListen = { host: ctx.webServer.host, port: ctx.webServer.port }
|
|
945
960
|
dshConnection = ctx.connection
|
|
946
961
|
ctx.effect(() => ctx.webServer.register({
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-remote-plugin",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0-rc.1",
|
|
4
4
|
"description": "DSH Remote 官方 bundle 插件:DSH 左侧原生边栏入口 + 右侧抽屉内嵌管理控制台;内置网关随 DSH 自动启停(systemd 独立单元),抽屉直显令牌与设备监控;网关提供 /fs/* 文件传输端点(列表/断点下载/上传),配合 Android App 远程操控会话/审批/提问/goal 与文件互传(多服务器测速切换、聊天记录离线缓存)。",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./index.mjs",
|
|
@@ -18,7 +18,8 @@
|
|
|
18
18
|
"public",
|
|
19
19
|
"apk",
|
|
20
20
|
"cordis.patch.yml",
|
|
21
|
-
"*.md"
|
|
21
|
+
"*.md",
|
|
22
|
+
"plugin-center.mjs"
|
|
22
23
|
],
|
|
23
24
|
"keywords": [
|
|
24
25
|
"dsh-plugin",
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
/* Profile-scoped plugin management. No shell input, dependencies or client-selected paths. */
|
|
2
|
+
import { readFileSync, writeFileSync, renameSync, mkdirSync, existsSync, readdirSync, copyFileSync, unlinkSync, realpathSync } from 'node:fs'
|
|
3
|
+
import { dirname, basename, join, resolve } from 'node:path'
|
|
4
|
+
import { fileURLToPath } from 'node:url'
|
|
5
|
+
import { createHash } from 'node:crypto'
|
|
6
|
+
import { spawn } from 'node:child_process'
|
|
7
|
+
|
|
8
|
+
const REGISTRY = 'https://registry.npmjs.org'
|
|
9
|
+
const SELF = fileURLToPath(import.meta.url)
|
|
10
|
+
const packagePattern = /^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*(?![\s\S])/
|
|
11
|
+
const versionPattern = /^\d+\.\d+\.\d+(?:-[a-zA-Z0-9.-]+)?(?:\+[a-zA-Z0-9.-]+)?(?![\s\S])/
|
|
12
|
+
const idPattern = /^[a-zA-Z0-9-]{16,80}(?![\s\S])/
|
|
13
|
+
const read = path => JSON.parse(readFileSync(path, 'utf8'))
|
|
14
|
+
function atomic(path, value) {
|
|
15
|
+
const tmp = path + '.' + process.pid + '.tmp'
|
|
16
|
+
writeFileSync(tmp, JSON.stringify(value, null, 2) + '\n', { mode: 0o600 })
|
|
17
|
+
renameSync(tmp, path)
|
|
18
|
+
}
|
|
19
|
+
const fail = (message, status = 400) => Object.assign(new Error(message), { status })
|
|
20
|
+
const protectedPackage = name => name === 'dsh-remote-plugin' || name.startsWith('@deepseek-ai/')
|
|
21
|
+
const revision = path => createHash('sha256').update(readFileSync(path)).digest('hex')
|
|
22
|
+
|
|
23
|
+
export function detectProfile(ctx, cli = process.argv[1]) {
|
|
24
|
+
try {
|
|
25
|
+
const dir = realpathSync(fileURLToPath(ctx.root?.baseUrl || ctx.baseUrl))
|
|
26
|
+
const manifest = read(join(dir, 'package.json'))
|
|
27
|
+
if (basename(dirname(dir)) !== 'profiles' || !Array.isArray(manifest.dsh?.profile?.bundles)) return null
|
|
28
|
+
const name = basename(dir)
|
|
29
|
+
if (!/^[a-zA-Z0-9][a-zA-Z0-9_-]*(?![\s\S])/.test(name)) return null
|
|
30
|
+
let root = dirname(realpathSync(cli))
|
|
31
|
+
let found = null
|
|
32
|
+
for (let i = 0; i < 5; i++, root = dirname(root)) {
|
|
33
|
+
try { if (read(join(root, 'package.json')).name === '@deepseek-ai/dsh') { found = join(root, 'lib', 'bin.js'); break } } catch {}
|
|
34
|
+
}
|
|
35
|
+
return { dir, name, home: dirname(dirname(dir)), cli: found && existsSync(found) ? found : null }
|
|
36
|
+
} catch { return null }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function registryJson(path) {
|
|
40
|
+
const response = await fetch(REGISTRY + path, { signal: AbortSignal.timeout(20000), redirect: 'error' })
|
|
41
|
+
if (!response.ok) throw fail('npm registry: HTTP ' + response.status, 502)
|
|
42
|
+
let raw = '', bytes = 0
|
|
43
|
+
const decoder = new TextDecoder()
|
|
44
|
+
for await (const chunk of response.body) {
|
|
45
|
+
bytes += chunk.length
|
|
46
|
+
if (bytes > 8 * 1024 * 1024) throw fail('Registry response too large', 502)
|
|
47
|
+
raw += decoder.decode(chunk, { stream: true })
|
|
48
|
+
}
|
|
49
|
+
raw += decoder.decode()
|
|
50
|
+
return JSON.parse(raw)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export async function pluginDetails(name, version = 'latest') {
|
|
54
|
+
if (!packagePattern.test(name) || !(version === 'latest' || versionPattern.test(version))) throw fail('Invalid package or version')
|
|
55
|
+
const data = await registryJson('/' + encodeURIComponent(name) + '/' + encodeURIComponent(version))
|
|
56
|
+
if (data.name !== name || !versionPattern.test(data.version)) throw fail('Invalid registry metadata', 502)
|
|
57
|
+
return {
|
|
58
|
+
name, version: data.version, description: String(data.description || ''),
|
|
59
|
+
bundle: typeof data.dsh?.bundle?.patch === 'string',
|
|
60
|
+
license: typeof data.license === 'string' ? data.license : '',
|
|
61
|
+
homepage: typeof data.homepage === 'string' ? data.homepage : '',
|
|
62
|
+
engines: data.engines || {}, peers: data.peerDependencies || {},
|
|
63
|
+
protected: protectedPackage(name), source: REGISTRY,
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function createPluginCenter(ctx, options = {}) {
|
|
68
|
+
const profile = options.profile === undefined ? detectProfile(ctx) : options.profile
|
|
69
|
+
const details = options.details || pluginDetails
|
|
70
|
+
const launch = options.launch || (args => {
|
|
71
|
+
const child = spawn(process.execPath, [SELF, '--worker', ...args], { detached: true, windowsHide: true, stdio: 'ignore' })
|
|
72
|
+
return new Promise((resolveLaunch, reject) => {
|
|
73
|
+
child.once('error', reject)
|
|
74
|
+
child.once('spawn', () => { child.unref(); resolveLaunch() })
|
|
75
|
+
})
|
|
76
|
+
})
|
|
77
|
+
const manifestPath = profile && join(profile.dir, 'package.json')
|
|
78
|
+
const initialRevision = profile && revision(manifestPath)
|
|
79
|
+
const storage = profile && join(profile.dir, '.remote-plugin-center')
|
|
80
|
+
let submitting = false
|
|
81
|
+
async function inventory() {
|
|
82
|
+
let runtime = [], runtimeAvailable = false
|
|
83
|
+
const loader = ctx.get?.('loader') || ctx.loader
|
|
84
|
+
if (loader?.entries) {
|
|
85
|
+
runtime = [...loader.entries()].filter(entry => !entry.options.group).map(entry => ({
|
|
86
|
+
id: entry.id, name: entry.options.name, enabled: !entry.disabled,
|
|
87
|
+
phase: ['pending', 'loading', 'active', 'failed', 'disposed', 'unloading'][entry.fiber?.state] || 'inactive',
|
|
88
|
+
}))
|
|
89
|
+
runtimeAvailable = true
|
|
90
|
+
}
|
|
91
|
+
if (!profile) return { ok: true, writable: false, reason: '无法确认当前 DSH profile;仅显示运行状态。', runtime, runtimeAvailable, items: [], operations: [] }
|
|
92
|
+
const manifest = read(manifestPath)
|
|
93
|
+
const bundles = manifest.dsh.profile.bundles
|
|
94
|
+
const names = [...new Set([...bundles, ...Object.keys(manifest.dependencies || {})])]
|
|
95
|
+
const items = names.map(name => {
|
|
96
|
+
let pkg = null
|
|
97
|
+
if (packagePattern.test(name)) {
|
|
98
|
+
try { pkg = read(join(profile.dir, 'node_modules', ...name.split('/'), 'package.json')) } catch {}
|
|
99
|
+
}
|
|
100
|
+
return { name, version: pkg?.version || '', requested: manifest.dependencies?.[name] || '',
|
|
101
|
+
enabled: bundles.includes(name), bundle: !!pkg?.dsh?.bundle?.patch || bundles.includes(name),
|
|
102
|
+
managed: !!manifest.dependencies?.[name] && !protectedPackage(name),
|
|
103
|
+
description: pkg?.description || '' }
|
|
104
|
+
})
|
|
105
|
+
let operations = []
|
|
106
|
+
if (existsSync(storage)) operations = readdirSync(storage).filter(name => /^job-[a-zA-Z0-9-]+\.json$/.test(name)).map(name => {
|
|
107
|
+
try { return read(join(storage, name)) } catch { return null }
|
|
108
|
+
}).filter(Boolean).sort((a, b) => b.startedAt - a.startedAt).slice(0, 20).map(publicJob)
|
|
109
|
+
return { ok: true, profile: profile.name, revision: revision(manifestPath), writable: !!profile.cli,
|
|
110
|
+
reason: profile.cli ? '' : '当前启动方式没有可验证的 DSH CLI,管理操作不可用。',
|
|
111
|
+
pendingRestart: revision(manifestPath) !== initialRevision,
|
|
112
|
+
busy: submitting || existsSync(join(storage, 'lock.json')), items, runtime, runtimeAvailable, operations }
|
|
113
|
+
}
|
|
114
|
+
async function submit(body) {
|
|
115
|
+
if (!profile?.cli) throw fail('Current DSH profile is read-only', 409)
|
|
116
|
+
if (!body || typeof body !== 'object' || Array.isArray(body)) throw fail('Invalid operation')
|
|
117
|
+
const { id, action, name, version } = body
|
|
118
|
+
if (!idPattern.test(id || '') || !packagePattern.test(name || '') || !['install', 'update', 'remove', 'enable', 'disable'].includes(action)) throw fail('Invalid operation')
|
|
119
|
+
if (protectedPackage(name)) throw fail('核心插件与 Remote 自身请通过主机维护流程管理', 409)
|
|
120
|
+
mkdirSync(storage, { recursive: true, mode: 0o700 })
|
|
121
|
+
const jobPath = join(storage, 'job-' + id + '.json')
|
|
122
|
+
if (existsSync(jobPath)) {
|
|
123
|
+
const previous = read(jobPath)
|
|
124
|
+
if (previous.action !== action || previous.name !== name || previous.version !== (version || '')) throw fail('Operation id already used', 409)
|
|
125
|
+
return publicJob(previous)
|
|
126
|
+
}
|
|
127
|
+
if (submitting) throw fail('另一个插件操作正在准备中', 409)
|
|
128
|
+
submitting = true
|
|
129
|
+
try {
|
|
130
|
+
if (revision(manifestPath) !== body.revision) throw fail('插件列表已变化,请刷新后重试', 409)
|
|
131
|
+
const manifest = read(manifestPath)
|
|
132
|
+
const installed = Object.hasOwn(manifest.dependencies || {}, name)
|
|
133
|
+
if (action === 'install' && installed || action !== 'install' && !installed) throw fail('安装状态已变化,请刷新', 409)
|
|
134
|
+
if (['install', 'update'].includes(action)) {
|
|
135
|
+
if (!versionPattern.test(version || '')) throw fail('需要明确的版本号')
|
|
136
|
+
const metadata = await details(name, version)
|
|
137
|
+
if (!metadata.bundle) throw fail('该版本未声明 DSH bundle,不能作为插件安装')
|
|
138
|
+
}
|
|
139
|
+
if (['enable', 'disable'].includes(action)) {
|
|
140
|
+
const pkg = read(join(profile.dir, 'node_modules', ...name.split('/'), 'package.json'))
|
|
141
|
+
if (!pkg.dsh?.bundle?.patch) throw fail('该依赖不是 DSH bundle')
|
|
142
|
+
}
|
|
143
|
+
if (revision(manifestPath) !== body.revision) throw fail('插件配置已变化,请刷新', 409)
|
|
144
|
+
const lock = join(storage, 'lock.json')
|
|
145
|
+
try { writeFileSync(lock, JSON.stringify({ id }), { flag: 'wx', mode: 0o600 }) }
|
|
146
|
+
catch (error) { if (error.code === 'EEXIST') throw fail('已有插件任务运行中;请查看操作记录', 409); throw error }
|
|
147
|
+
const job = { id, action, name, version: version || '', revision: body.revision, profile: profile.name, phase: 'queued', startedAt: Date.now(), log: '', restartRequired: false }
|
|
148
|
+
try {
|
|
149
|
+
atomic(jobPath, job)
|
|
150
|
+
await launch([profile.dir, profile.cli, id])
|
|
151
|
+
} catch (error) {
|
|
152
|
+
job.phase = 'failed'; job.message = '无法启动插件任务'; job.endedAt = Date.now()
|
|
153
|
+
atomic(jobPath, job); unlinkSync(lock); throw error
|
|
154
|
+
}
|
|
155
|
+
return publicJob(job)
|
|
156
|
+
} finally { submitting = false }
|
|
157
|
+
}
|
|
158
|
+
async function handle(method, sub, body = {}, query = new URLSearchParams()) {
|
|
159
|
+
if (method === 'GET' && sub === '/state') return inventory()
|
|
160
|
+
if (method === 'GET' && sub === '/details') return { ok: true, item: await details(query.get('name') || '', query.get('version') || 'latest') }
|
|
161
|
+
if (method === 'GET' && sub === '/market') {
|
|
162
|
+
const q = String(query.get('q') || '').trim()
|
|
163
|
+
if (q.length > 80) throw fail('Search too long')
|
|
164
|
+
const offset = Number(query.get('offset') || 0)
|
|
165
|
+
if (!Number.isInteger(offset) || offset < 0 || offset > 1000) throw fail('Invalid offset')
|
|
166
|
+
const data = await registryJson('/-/v1/search?text=' + encodeURIComponent('keywords:dsh-plugin ' + q) + '&size=20&from=' + offset)
|
|
167
|
+
return { ok: true, source: REGISTRY, total: data.total || 0, items: (data.objects || []).map(({ package: p }) => ({ name: p.name, version: p.version, description: p.description || '' })) }
|
|
168
|
+
}
|
|
169
|
+
if (method === 'POST' && sub === '/operations') return { ok: true, operation: await submit(body) }
|
|
170
|
+
throw fail('Unknown plugin endpoint', 404)
|
|
171
|
+
}
|
|
172
|
+
return { handle, inventory, submit }
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function publicJob(job) {
|
|
176
|
+
const { id, action, name, version, profile, phase, startedAt, endedAt, log, message, restartRequired } = job
|
|
177
|
+
return { id, action, name, version, profile, phase, startedAt, endedAt, log, message, restartRequired }
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export async function runWorker(dir, cli, id, execute = runCli) {
|
|
181
|
+
if (!idPattern.test(id || '')) throw fail('Invalid worker id')
|
|
182
|
+
const storage = join(dir, '.remote-plugin-center')
|
|
183
|
+
const lock = join(storage, 'lock.json')
|
|
184
|
+
if (read(lock).id !== id) throw fail('Worker does not own lock')
|
|
185
|
+
const jobPath = join(storage, 'job-' + id + '.json')
|
|
186
|
+
const job = read(jobPath)
|
|
187
|
+
const manifestPath = join(dir, 'package.json')
|
|
188
|
+
const disabledPath = join(storage, 'disabled.json')
|
|
189
|
+
let disabled
|
|
190
|
+
const save = () => atomic(jobPath, job)
|
|
191
|
+
try {
|
|
192
|
+
job.phase = 'running'; save()
|
|
193
|
+
if (job.revision !== revision(manifestPath)) throw fail('任务开始前 profile 已变化,请刷新后重试')
|
|
194
|
+
disabled = new Set(existsSync(disabledPath) ? read(disabledPath) : [])
|
|
195
|
+
const backup = join(storage, 'backup-' + id)
|
|
196
|
+
mkdirSync(backup, { mode: 0o700 })
|
|
197
|
+
for (const name of ['package.json', 'pnpm-lock.yaml', 'pnpm-workspace.yaml', 'cordis.patch.yml']) {
|
|
198
|
+
if (existsSync(join(dir, name))) copyFileSync(join(dir, name), join(backup, name))
|
|
199
|
+
}
|
|
200
|
+
if (existsSync(disabledPath)) copyFileSync(disabledPath, join(backup, 'disabled.json'))
|
|
201
|
+
if (['install', 'update', 'remove'].includes(job.action)) {
|
|
202
|
+
const args = job.action === 'remove' ? ['remove', job.name, '--ignore-scripts'] : ['add', job.name + '@' + job.version, '--save-exact', '--ignore-scripts', '--registry=' + REGISTRY]
|
|
203
|
+
await execute(cli, ['plugin', '--profile', basename(dir), ...args], dir, chunk => {
|
|
204
|
+
job.log = (job.log + chunk.replace(/\x1b\[[0-9;]*m/g, '').replace(/(token|password|authorization)\s*[=:]\s*\S+/gi, '$1=[redacted]')).slice(-12000)
|
|
205
|
+
save()
|
|
206
|
+
})
|
|
207
|
+
}
|
|
208
|
+
if (job.action === 'disable') disabled.add(job.name)
|
|
209
|
+
if (job.action === 'enable' || job.action === 'remove') disabled.delete(job.name)
|
|
210
|
+
const manifest = read(manifestPath)
|
|
211
|
+
manifest.dsh.profile.bundles = manifest.dsh.profile.bundles.filter(name => !disabled.has(name))
|
|
212
|
+
if (job.action === 'enable' && !manifest.dsh.profile.bundles.includes(job.name)) manifest.dsh.profile.bundles.push(job.name)
|
|
213
|
+
atomic(manifestPath, manifest)
|
|
214
|
+
atomic(disabledPath, [...disabled])
|
|
215
|
+
const installed = Object.hasOwn(manifest.dependencies || {}, job.name)
|
|
216
|
+
if (job.action === 'remove' ? installed : !installed) throw fail('操作后依赖状态与预期不符')
|
|
217
|
+
if (['install', 'update'].includes(job.action)) {
|
|
218
|
+
const pkg = read(join(dir, 'node_modules', ...job.name.split('/'), 'package.json'))
|
|
219
|
+
if (pkg.version !== job.version || !pkg.dsh?.bundle?.patch) throw fail('安装后的包版本或 bundle 校验失败')
|
|
220
|
+
}
|
|
221
|
+
job.phase = 'complete'; job.restartRequired = true
|
|
222
|
+
job.message = '配置已保存;重启 DSH 后生效。安装脚本未执行。'
|
|
223
|
+
} catch (error) {
|
|
224
|
+
job.phase = 'failed'; job.message = String(error.message || error)
|
|
225
|
+
// A failed package manager may have changed files. Never claim rollback.
|
|
226
|
+
job.restartRequired = true
|
|
227
|
+
} finally {
|
|
228
|
+
job.endedAt = Date.now(); save()
|
|
229
|
+
if (read(lock).id === id) unlinkSync(lock)
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function runCli(cli, args, dir, output) {
|
|
234
|
+
return new Promise((resolveRun, reject) => {
|
|
235
|
+
const env = { ...process.env, DSH_HOME: dirname(dirname(dir)), npm_config_ignore_scripts: 'true' }
|
|
236
|
+
const child = spawn(process.execPath, [cli, ...args], { cwd: dir, env, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] })
|
|
237
|
+
child.stdout.on('data', chunk => output(chunk.toString()))
|
|
238
|
+
child.stderr.on('data', chunk => output(chunk.toString()))
|
|
239
|
+
child.once('error', reject)
|
|
240
|
+
child.once('close', code => code === 0 ? resolveRun() : reject(fail('DSH 插件命令失败,退出码 ' + code)))
|
|
241
|
+
})
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
if (process.argv[1] && resolve(process.argv[1]) === SELF && process.argv[2] === '--worker') {
|
|
245
|
+
await runWorker(process.argv[3], process.argv[4], process.argv[5])
|
|
246
|
+
}
|
package/public/app.js
CHANGED
|
@@ -7305,3 +7305,14 @@ async function boot() {
|
|
|
7305
7305
|
}
|
|
7306
7306
|
|
|
7307
7307
|
document.addEventListener('DOMContentLoaded', boot)
|
|
7308
|
+
|
|
7309
|
+
// Capture the connection so switching servers cannot redirect a plugin mutation.
|
|
7310
|
+
document.getElementById('btn-plugin-center')?.addEventListener('click', () => {
|
|
7311
|
+
const server = state.server || ''
|
|
7312
|
+
const token = state.token
|
|
7313
|
+
window.DshPluginCenter.open({
|
|
7314
|
+
url: path => server + path,
|
|
7315
|
+
headers: { authorization: 'Bearer ' + token, 'x-dsh-remote-client': 'web', ...clientIdHeaders() },
|
|
7316
|
+
valid: () => (state.server || '') === server && state.token === token,
|
|
7317
|
+
})
|
|
7318
|
+
})
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
<script>/* 首帧前应用皮肤 */ (function(){try{var t=localStorage.getItem('dshTheme');if(t!=='default'&&t!=='dark'&&t!=='light'&&t!=='neutral'&&t!=='mono'){t=window.matchMedia('(prefers-color-scheme: light)').matches?'light':'default'}document.documentElement.setAttribute('data-theme',t)}catch(e){}})()</script>
|
|
8
8
|
<link rel="stylesheet" href="../theme-vars.css">
|
|
9
9
|
<link rel="stylesheet" href="desktop.css">
|
|
10
|
+
<link rel="stylesheet" href="../plugin-center.css">
|
|
10
11
|
</head>
|
|
11
12
|
<body>
|
|
12
13
|
<div class="ds-app" id="ds-app">
|
|
@@ -218,6 +219,7 @@
|
|
|
218
219
|
<section id="view-settings" class="ds-view hidden">
|
|
219
220
|
<div class="ds-section-label" data-i18n="ds.settings">设置</div>
|
|
220
221
|
<div id="settings-home">
|
|
222
|
+
<button type="button" id="btn-plugin-center" class="ds-setting-row ds-setting-link"><span>插件中心 · 已安装 / 发现插件</span><span>›</span></button>
|
|
221
223
|
<div class="ds-settings">
|
|
222
224
|
<button type="button" class="ds-setting-row ds-setting-link" data-settings-group="general">
|
|
223
225
|
<div><div class="ds-setting-name" data-i18n="ds.groupGeneral">通用</div><div class="ds-setting-desc" data-i18n="ds.groupGeneralDesc">工具调用、预设提示词</div></div>
|
|
@@ -709,6 +711,7 @@
|
|
|
709
711
|
<script src="../vendor/gsap/gsap.min.js"></script>
|
|
710
712
|
<script src="../motion.js"></script>
|
|
711
713
|
<script type="module" src="../morphicons-init.js"></script>
|
|
714
|
+
<script src="../plugin-center.js"></script>
|
|
712
715
|
<script src="desktop.js"></script>
|
|
713
716
|
</body>
|
|
714
717
|
</html>
|
|
@@ -3518,3 +3518,14 @@ async function start() {
|
|
|
3518
3518
|
}
|
|
3519
3519
|
|
|
3520
3520
|
start()
|
|
3521
|
+
|
|
3522
|
+
// Capture the connection so switching servers cannot redirect a plugin mutation.
|
|
3523
|
+
document.getElementById('btn-plugin-center')?.addEventListener('click', () => {
|
|
3524
|
+
const server = state.server || ''
|
|
3525
|
+
const token = state.token
|
|
3526
|
+
window.DshPluginCenter.open({
|
|
3527
|
+
url: path => server + path,
|
|
3528
|
+
headers: { authorization: 'Bearer ' + token, 'x-dsh-remote-client': 'web', ...clientIdHeaders() },
|
|
3529
|
+
valid: () => (state.server || '') === server && state.token === token,
|
|
3530
|
+
})
|
|
3531
|
+
})
|
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/index.html
CHANGED
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
<link rel="icon" href="icon.svg" type="image/svg+xml">
|
|
14
14
|
<link rel="stylesheet" href="styles.css">
|
|
15
15
|
<link rel="stylesheet" href="genui.css">
|
|
16
|
+
<link rel="stylesheet" href="plugin-center.css">
|
|
16
17
|
</head>
|
|
17
18
|
<body>
|
|
18
19
|
<header class="topbar">
|
|
@@ -293,6 +294,7 @@
|
|
|
293
294
|
<!-- 设置 -->
|
|
294
295
|
<section id="view-settings" class="view hidden">
|
|
295
296
|
<div id="settings-home">
|
|
297
|
+
<button type="button" id="btn-plugin-center" class="setting-row setting-link"><span>插件中心 · 已安装 / 发现插件</span></button>
|
|
296
298
|
<div class="settings-group">
|
|
297
299
|
<button type="button" class="setting-row setting-link" data-settings-group="general">
|
|
298
300
|
<div><div class="setting-name" data-i18n="settings.groupGeneral">通用</div><div class="setting-desc" data-i18n="settings.groupGeneralDesc">显示工具调用、预设提示词</div></div>
|
|
@@ -1379,6 +1381,7 @@
|
|
|
1379
1381
|
<script src="vendor/gsap/gsap.min.js"></script>
|
|
1380
1382
|
<script src="motion.js"></script>
|
|
1381
1383
|
<script type="module" src="morphicons-init.js"></script>
|
|
1384
|
+
<script src="plugin-center.js"></script>
|
|
1382
1385
|
<script src="app.js"></script>
|
|
1383
1386
|
</body>
|
|
1384
1387
|
</html>
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
.pc-dialog{width:min(860px,94vw);max-height:88dvh;box-sizing:border-box;border:1px solid #7775;border-radius:18px;background:var(--dsr-panel,#171b24);color:var(--dsr-text,#eee);padding:22px;overflow:auto}.pc-dialog::backdrop{background:#0009}.pc-heading,.pc-actions{display:flex;align-items:center;gap:10px;flex-wrap:wrap}.pc-heading{justify-content:space-between}.pc-dialog h2,.pc-dialog h3{margin:0}.pc-dialog h3{font-size:16px;overflow-wrap:anywhere}.pc-dialog p{line-height:1.6;overflow-wrap:anywhere}.pc-dialog button,.pc-dialog input{font:inherit;border:1px solid #8886;border-radius:9px;padding:9px 12px;background:transparent;color:inherit}.pc-dialog button{cursor:pointer}.pc-dialog button:disabled{opacity:.4;cursor:not-allowed}.pc-dialog button:focus-visible,.pc-dialog input:focus-visible{outline:2px solid #6e9dff;outline-offset:2px}.pc-dialog input{max-width:100%;box-sizing:border-box;min-width:0}.pc-market-tools:not([hidden]){display:flex;gap:8px;flex-wrap:wrap;margin-top:14px}.pc-search{flex:1}.pc-card{border:1px solid #8884;border-radius:12px;padding:16px;margin:12px 0}.pc-card>button,.pc-card>input,.pc-card>a{margin:8px 8px 0 0}.pc-dialog a{color:var(--dsr-accent-strong)}.pc-dialog details{padding:10px 0}.pc-dialog summary{cursor:pointer;overflow-wrap:anywhere}.pc-dialog pre{white-space:pre-wrap;overflow-wrap:anywhere;max-height:240px;overflow:auto;font-size:12px}.pc-message{color:var(--dsr-text)}.pc-profile{font-weight:600}.pc-dialog small{opacity:.7}@media(max-width:520px){.pc-dialog{padding:15px}.pc-dialog button{min-height:42px}.pc-actions>*{flex:1}.pc-card{padding:12px}}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
/* Shared mobile/desktop plugin center. Credentials stay in the parent connection. */
|
|
2
|
+
'use strict';
|
|
3
|
+
window.DshPluginCenter = (() => {
|
|
4
|
+
let dialog, connection, snapshot, timer, generation = 0, pending = false, tab = 'installed', offset = 0, searchSequence = 0, detailSequence = 0
|
|
5
|
+
let renderedInstalled = '', renderedJobs = ''
|
|
6
|
+
const labels = { install: '安装', update: '更新', remove: '卸载', enable: '启用', disable: '停用', queued: '等待执行', running: '执行中', complete: '已完成', failed: '失败' }
|
|
7
|
+
function node(tag, text, className) {
|
|
8
|
+
const el = document.createElement(tag)
|
|
9
|
+
if (text != null) el.textContent = text
|
|
10
|
+
if (className) el.className = className
|
|
11
|
+
return el
|
|
12
|
+
}
|
|
13
|
+
function button(text, action) {
|
|
14
|
+
const el = node('button', text)
|
|
15
|
+
el.type = 'button'; el.addEventListener('click', () => {
|
|
16
|
+
const current = generation
|
|
17
|
+
Promise.resolve().then(action).catch(error => { if (current === generation) message(error.message) })
|
|
18
|
+
})
|
|
19
|
+
return el
|
|
20
|
+
}
|
|
21
|
+
function message(text) { dialog.querySelector('.pc-message').textContent = text }
|
|
22
|
+
async function api(path, body) {
|
|
23
|
+
if (!connection.valid()) throw new Error('连接已切换,请关闭后重新打开插件中心')
|
|
24
|
+
const current = generation
|
|
25
|
+
const response = await fetch(connection.url('/remote/api/plugins' + path), {
|
|
26
|
+
method: body ? 'POST' : 'GET', headers: { ...connection.headers, 'content-type': 'application/json' },
|
|
27
|
+
...(body ? { body: JSON.stringify(body) } : {}), signal: AbortSignal.timeout(30000),
|
|
28
|
+
})
|
|
29
|
+
if (current !== generation || !dialog.open || !connection.valid()) throw new Error('连接已切换,请重新打开插件中心')
|
|
30
|
+
let data
|
|
31
|
+
try { data = await response.json() } catch { throw new Error('当前服务器不支持插件中心,请升级主机端 Remote 插件') }
|
|
32
|
+
if (current !== generation || !dialog.open || !connection.valid()) throw new Error('连接已切换,请重新打开插件中心')
|
|
33
|
+
if (!response.ok || data.ok === false) throw new Error(data.message || '请求失败:HTTP ' + response.status)
|
|
34
|
+
return data
|
|
35
|
+
}
|
|
36
|
+
function card(name, description) {
|
|
37
|
+
const el = node('article', null, 'pc-card')
|
|
38
|
+
el.append(node('h3', name), node('p', description))
|
|
39
|
+
return el
|
|
40
|
+
}
|
|
41
|
+
function renderInstalled() {
|
|
42
|
+
const list = dialog.querySelector('.pc-list'); list.replaceChildren()
|
|
43
|
+
if (!snapshot.items.length) list.append(node('p', '暂无可管理插件'))
|
|
44
|
+
for (const item of snapshot.items) {
|
|
45
|
+
const el = card(item.name, item.description)
|
|
46
|
+
el.append(node('p', (item.version || item.requested || '内置') + ' · ' + (item.enabled ? '配置启用' : '配置停用') + (snapshot.pendingRestart ? ' · 待重启' : '')))
|
|
47
|
+
if (item.managed && snapshot.writable) {
|
|
48
|
+
const actions = node('div', null, 'pc-actions')
|
|
49
|
+
actions.append(button('查看更新', () => showDetails(item.name)))
|
|
50
|
+
if (item.bundle) actions.append(button(item.enabled ? '停用' : '启用', () => mutate(item.enabled ? 'disable' : 'enable', item.name)))
|
|
51
|
+
actions.append(button('卸载', () => mutate('remove', item.name)))
|
|
52
|
+
for (const action of actions.children) action.disabled = pending || snapshot.busy
|
|
53
|
+
el.append(actions)
|
|
54
|
+
} else el.append(node('small', '内置、核心或只读插件'))
|
|
55
|
+
list.append(el)
|
|
56
|
+
}
|
|
57
|
+
const runtime = node('details')
|
|
58
|
+
runtime.append(node('summary', '当前进程实际加载状态 (' + snapshot.runtime.length + ')'))
|
|
59
|
+
if (!snapshot.runtimeAvailable) runtime.append(node('p', '此 DSH 版本未提供加载状态'))
|
|
60
|
+
for (const entry of snapshot.runtime) runtime.append(node('p', entry.name + ' · ' + entry.phase + (entry.enabled ? '' : ' · disabled')))
|
|
61
|
+
list.append(runtime)
|
|
62
|
+
}
|
|
63
|
+
async function refresh() {
|
|
64
|
+
snapshot = await api('/state')
|
|
65
|
+
dialog.querySelector('.pc-profile').textContent = snapshot.profile ? '当前环境:' + snapshot.profile : '当前环境无法识别'
|
|
66
|
+
dialog.querySelector('.pc-status').textContent = snapshot.reason || (snapshot.pendingRestart ? '插件配置已变更,重启 DSH 后生效。' : '安装到当前连接的 DSH 主机。')
|
|
67
|
+
const installedKey = JSON.stringify([snapshot.items, snapshot.runtime, snapshot.pendingRestart, snapshot.busy, snapshot.writable, pending])
|
|
68
|
+
if (tab === 'installed' && installedKey !== renderedInstalled) { renderInstalled(); renderedInstalled = installedKey }
|
|
69
|
+
const jobsKey = JSON.stringify(snapshot.operations)
|
|
70
|
+
if (jobsKey === renderedJobs) return
|
|
71
|
+
renderedJobs = jobsKey
|
|
72
|
+
const jobs = dialog.querySelector('.pc-jobs')
|
|
73
|
+
const expanded = new Set([...jobs.querySelectorAll('details[open]')].map(el => el.dataset.id))
|
|
74
|
+
jobs.replaceChildren()
|
|
75
|
+
for (const job of snapshot.operations) {
|
|
76
|
+
const el = node('details')
|
|
77
|
+
el.dataset.id = job.id; el.open = expanded.has(job.id)
|
|
78
|
+
el.append(node('summary', (labels[job.action] || job.action) + ' ' + job.name + (job.version ? '@' + job.version : '') + ' · ' + (labels[job.phase] || job.phase)))
|
|
79
|
+
el.append(node('small', new Date(job.startedAt).toLocaleString()))
|
|
80
|
+
el.append(node('p', job.message || '任务在主机端执行,可关闭窗口后回来查看。'))
|
|
81
|
+
if (job.log) el.append(node('pre', job.log))
|
|
82
|
+
jobs.append(el)
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
async function search() {
|
|
86
|
+
const sequence = ++searchSequence
|
|
87
|
+
const q = dialog.querySelector('.pc-search').value.trim()
|
|
88
|
+
const list = dialog.querySelector('.pc-list'); list.replaceChildren(node('p', '正在查询 npm 插件目录…'))
|
|
89
|
+
const data = await api('/market?q=' + encodeURIComponent(q) + '&offset=' + offset)
|
|
90
|
+
if (tab !== 'market' || sequence !== searchSequence) return
|
|
91
|
+
list.replaceChildren(node('p', '来源:npm · 安装前校验 DSH bundle。目录收录不代表安全或兼容性审核。'))
|
|
92
|
+
if (!data.items.length) list.append(node('p', '没有找到插件;也可直接输入完整 npm 包名后查看详情。'))
|
|
93
|
+
for (const item of data.items) {
|
|
94
|
+
const el = card(item.name, item.description)
|
|
95
|
+
el.append(node('small', item.version), button('查看详情', () => showDetails(item.name)))
|
|
96
|
+
list.append(el)
|
|
97
|
+
}
|
|
98
|
+
if (offset > 0) list.append(button('上一页', () => { offset -= 20; return search() }))
|
|
99
|
+
if (offset + 20 < data.total) list.append(button('下一页', () => { offset += 20; return search() }))
|
|
100
|
+
}
|
|
101
|
+
async function showDetails(name, version) {
|
|
102
|
+
const sequence = ++detailSequence
|
|
103
|
+
const { item } = await api('/details?name=' + encodeURIComponent(name) + (version ? '&version=' + encodeURIComponent(version) : ''))
|
|
104
|
+
if (sequence !== detailSequence) return
|
|
105
|
+
const area = dialog.querySelector('.pc-detail'); area.replaceChildren()
|
|
106
|
+
const el = card(item.name + ' @ ' + item.version, item.description)
|
|
107
|
+
el.append(node('p', '许可证:' + (item.license || '未声明') + ' · ' + (item.bundle ? 'DSH bundle' : '未声明 DSH bundle')))
|
|
108
|
+
el.append(node('p', '运行要求:' + JSON.stringify(item.engines) + ';依赖要求:' + JSON.stringify(item.peers)))
|
|
109
|
+
el.append(node('p', '插件代码将在 DSH 主机运行。安装脚本默认禁用;需要额外配置的插件须在主机完成配置。'))
|
|
110
|
+
if (/^https:\/\//.test(item.homepage)) {
|
|
111
|
+
const link = node('a', '项目主页'); link.href = item.homepage; link.target = '_blank'; link.rel = 'noopener noreferrer'; el.append(link)
|
|
112
|
+
}
|
|
113
|
+
const input = node('input'); input.value = item.version; input.setAttribute('aria-label', '插件版本'); input.placeholder = '指定版本,如 1.2.3'
|
|
114
|
+
el.append(input, button('查看此版本', () => showDetails(name, input.value.trim())))
|
|
115
|
+
const installed = snapshot?.items.find(row => row.name === name)
|
|
116
|
+
const action = installed ? 'update' : 'install'
|
|
117
|
+
const install = button((labels[action]) + ' ' + item.version, () => mutate(action, name, item.version))
|
|
118
|
+
install.disabled = !item.bundle || item.protected || !snapshot?.writable || snapshot.busy || pending || installed?.version === item.version
|
|
119
|
+
el.append(install, button('关闭详情', () => area.replaceChildren()))
|
|
120
|
+
area.append(el); area.scrollIntoView({ block: 'nearest' })
|
|
121
|
+
}
|
|
122
|
+
async function mutate(action, name, version) {
|
|
123
|
+
if (pending) return
|
|
124
|
+
// LAN HTTP is not a secure context; an idempotency key does not need WebCrypto.
|
|
125
|
+
const id = globalThis.crypto?.randomUUID?.() || `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}-${Math.random().toString(36).slice(2)}`
|
|
126
|
+
const body = { id, action, name, ...(version ? { version } : {}), revision: snapshot.revision }
|
|
127
|
+
const area = dialog.querySelector('.pc-detail')
|
|
128
|
+
const review = card('确认' + labels[action], name + (version ? '@' + version : ''))
|
|
129
|
+
review.append(node('p', '目标环境:' + snapshot.profile + '。配置修改后需要重启 DSH;不会自动重启。'))
|
|
130
|
+
review.append(button('确认' + labels[action], () => executeMutation(body)), button('取消', () => area.replaceChildren()))
|
|
131
|
+
area.replaceChildren(review); area.scrollIntoView({ block: 'nearest' })
|
|
132
|
+
}
|
|
133
|
+
async function executeMutation(body) {
|
|
134
|
+
if (pending) return
|
|
135
|
+
const current = generation
|
|
136
|
+
pending = true
|
|
137
|
+
try {
|
|
138
|
+
message('正在提交…')
|
|
139
|
+
await api('/operations', body)
|
|
140
|
+
message('已受理,可在操作记录查看结果。')
|
|
141
|
+
dialog.querySelector('.pc-detail').replaceChildren()
|
|
142
|
+
} catch (error) {
|
|
143
|
+
if (current !== generation) return
|
|
144
|
+
message(error.message + ';请先查看操作记录,避免重复提交。')
|
|
145
|
+
const retry = button('重试同一请求', () => executeMutation(body))
|
|
146
|
+
dialog.querySelector('.pc-detail').replaceChildren(retry)
|
|
147
|
+
} finally { if (current === generation) { pending = false; await refresh() } }
|
|
148
|
+
}
|
|
149
|
+
async function open(config) {
|
|
150
|
+
if (!dialog) {
|
|
151
|
+
dialog = node('dialog', null, 'pc-dialog')
|
|
152
|
+
const heading = node('div', null, 'pc-heading'); heading.append(node('h2', '插件中心'), button('关闭', () => dialog.close()))
|
|
153
|
+
const toolbar = node('div', null, 'pc-actions')
|
|
154
|
+
toolbar.append(button('已安装', () => { tab = 'installed'; renderedInstalled = ''; dialog.querySelector('.pc-market-tools').hidden = true; return refresh() }), button('发现插件', () => { tab = 'market'; offset = 0; dialog.querySelector('.pc-market-tools').hidden = false; return search() }), button('刷新状态', refresh))
|
|
155
|
+
const market = node('form', null, 'pc-market-tools'); market.hidden = true
|
|
156
|
+
const input = node('input', null, 'pc-search'); input.placeholder = '搜索插件或输入完整 npm 包名'; input.maxLength = 80; input.setAttribute('aria-label', '搜索插件或 npm 包名')
|
|
157
|
+
market.append(input, button('搜索', () => { offset = 0; return search() }), button('按包名查看', () => showDetails(input.value.trim())))
|
|
158
|
+
market.addEventListener('submit', event => { event.preventDefault(); offset = 0; search().catch(error => message(error.message)) })
|
|
159
|
+
const jobs = node('details'); jobs.append(node('summary', '操作记录'), node('div', null, 'pc-jobs'))
|
|
160
|
+
const alert = node('p', null, 'pc-message'); alert.setAttribute('role', 'status')
|
|
161
|
+
dialog.append(heading, node('p', null, 'pc-profile'), node('p', null, 'pc-status'), toolbar, market, alert, node('div', null, 'pc-detail'), node('div', null, 'pc-list'), jobs)
|
|
162
|
+
dialog.addEventListener('close', () => { generation++; clearTimeout(timer) })
|
|
163
|
+
document.body.append(dialog)
|
|
164
|
+
}
|
|
165
|
+
generation++; searchSequence++; detailSequence++; connection = config; tab = 'installed'; pending = false; snapshot = null
|
|
166
|
+
const current = generation
|
|
167
|
+
renderedInstalled = ''; renderedJobs = ''
|
|
168
|
+
dialog.querySelector('.pc-market-tools').hidden = true
|
|
169
|
+
dialog.querySelector('.pc-detail').replaceChildren(); dialog.querySelector('.pc-list').replaceChildren(); dialog.querySelector('.pc-jobs').replaceChildren()
|
|
170
|
+
dialog.showModal(); message('正在读取插件状态…')
|
|
171
|
+
try { await refresh(); message('') } catch (error) { if (current === generation) message(error.message) }
|
|
172
|
+
if (current !== generation) return
|
|
173
|
+
async function poll() {
|
|
174
|
+
if (!dialog.open || current !== generation) return
|
|
175
|
+
try { await refresh() } catch (error) { if (current === generation) message(error.message) }
|
|
176
|
+
if (dialog.open && current === generation) timer = setTimeout(poll, 4000)
|
|
177
|
+
}
|
|
178
|
+
timer = setTimeout(poll, 4000)
|
|
179
|
+
}
|
|
180
|
+
return { open }
|
|
181
|
+
})()
|
package/public/update.json
CHANGED
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version": "0.
|
|
2
|
+
"version": "0.7.0-rc.1",
|
|
3
3
|
"apkUrl": "dsh-remote.apk",
|
|
4
|
-
"sha256": "
|
|
5
|
-
"releasedAt": "2026-09-
|
|
6
|
-
"notes": "0.
|
|
4
|
+
"sha256": "5393a9a7866566b63f2f0998ec8ac984600e182c153ee1ee892573405385e63f",
|
|
5
|
+
"releasedAt": "2026-09-18T23:57:45.705Z",
|
|
6
|
+
"notes": "0.7.0-rc.1:新增插件中心,支持手机端和桌面端管理当前 DSH profile 的插件,搜索 npm 插件目录、查看详情及安装指定版本;支持更新、卸载、配置启停、任务日志与配置备份。启停需重启 DSH 生效,安装默认禁用脚本。此为测试版,Android 真机与 Linux 使用场景仍待验收。",
|
|
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