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.
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: UPSTREAM.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
- return dirReal + '\n' + name + '\n' + (session || '')
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 prefix = dirReal + '\n'
3359
- for (const key of activeUploads.keys()) if (key.startsWith(prefix)) return true
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
- finished = true
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
- activeUploads.delete(activeKey)
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
- finished = true
3755
- stream.end(() => {
3756
- activeUploads.delete(activeKey)
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
- if (ts.exists) fs.rmSync(target, { force: true })
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
- try { active.destroy() } catch {}
3832
- activeUploads.delete(fsActiveKey(checked.abs, name, session))
3869
+ await active.cancelUpload()
3870
+ return fsJson(res, 200, { ok: true, cancelled: true, session })
3833
3871
  }
3834
- // 等写流关闭后再删, 防止 write 把分片重新创建出来
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: UPSTREAM.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: UPSTREAM.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 = `http://${dshListen.host}:${dshListen.port}`
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 res = await fetch(`${gatewayBase()}/health`, { signal: AbortSignal.timeout(3000) })
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
- const pid = (health && Number(health.pid)) || readGatewayPid()
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
- if (process.platform === 'win32') {
305
- await runExit('taskkill', ['/F', '/PID', String(pid)])
306
- } else {
307
- process.kill(pid)
308
- }
309
- logGateway('已停止旧网关 PID=' + pid)
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('停止旧网关失败 PID=' + pid + ' ' + (e?.message || String(e)))
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 = `http://${dshListen.host}:${dshListen.port}`
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 = `http://${dshListen.host}:${dshListen.port}`
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 token = gatewayToken()
443
- if (!token) return { ok: false, running: false, error: '找不到 ~/.dsh-remote/token, 无法认证网关' }
444
- try {
445
- const res = await fetch(`${gatewayBase()}/admin/api/shutdown`, {
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.6.25",
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 center = (horizontal ? top : left) + step * (i + 0.5), pos = scale(d.value)
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(0)
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></div>'
102
- if (type === 'stat') return '<div class="genui-stat"><small>' + esc(n.label) + '</small><strong>' + esc(n.value) + '</strong><span>' + esc(n.delta) + '</span></div>'
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 '<div class="genui-scroll"><table><thead><tr>' + array(n.columns).slice(0, 30).map(v => '<th>' + esc(v) + '</th>').join('') + '</tr></thead><tbody>' + array(n.rows).slice(0, 200).map(row => '<tr>' + array(row).slice(0, 30).map(v => '<td>' + esc(v) + '</td>').join('') + '</tr>').join('') + '</tbody></table></div>'
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 === 'accordion' || type === 'tabs') return array(n.tabs || n.items).slice(0, 30).map(v => '<details><summary>' + esc(v?.label || v?.title) + '</summary>' + nodes(v?.items, depth + 1) + '</details>').join('')
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
- return { render, html }
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
+ })()
@@ -1,10 +1,14 @@
1
1
  {
2
- "version": "0.6.25",
2
+ "version": "0.7.0-rc.1",
3
3
  "apkUrl": "dsh-remote.apk",
4
- "sha256": "56e3aa905bcc4a746c604af7b09a5bfe50c21deafd1b04756a345bd03cc44828",
5
- "releasedAt": "2026-09-16T06:56:53.046Z",
6
- "notes": "0.6.25:适配 DSH 0.1.5 的实时思考、文件消息与 V3 会话统计;修复 Windows 跨盘文件访问和空会话持久归档;完善 APK 下载进度与校验;新增 dsh-ui 常用组件及柱状图、折线图、环形图只读预览,HTML 代码块支持隔离预览,未支持的高级组件保留原始规格。",
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
  }
@@ -1,3 +1,3 @@
1
1
  {
2
- "version": "0.6.25"
2
+ "version": "0.7.0-rc.1"
3
3
  }