dsh-remote-plugin 0.6.26 → 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/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']
@@ -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,
@@ -941,7 +953,9 @@ async function serveStatic(req, res, ctx) {
941
953
  createReadStream(abs).pipe(res)
942
954
  }
943
955
 
956
+ const pluginCenters = new WeakMap()
944
957
  export function apply(ctx) {
958
+ pluginCenters.set(ctx, createPluginCenter(ctx))
945
959
  dshListen = { host: ctx.webServer.host, port: ctx.webServer.port }
946
960
  dshConnection = ctx.connection
947
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.26",
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/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,9 +1,9 @@
1
1
  {
2
- "version": "0.6.26",
2
+ "version": "0.7.0-rc.1",
3
3
  "apkUrl": "dsh-remote.apk",
4
- "sha256": "5b60977e3960d65b1ab60cb9f168835c8de927113738d925881340749122d2aa",
5
- "releasedAt": "2026-09-16T16:59:10.029Z",
6
- "notes": "0.6.26:修复 DSH 通配监听地址导致的认证与实时连接失败,兼容 IPv6-only;上传覆盖失败保留原文件,避免并发同名覆盖和续传分片竞争;网关改用认证关闭,避免误杀其他进程;完善 dsh-ui 堆叠柱状图、标签页、表格排序与特殊单元格、趋势线、环形进度、文件树和修改前后预览。已通过 Windows 自动测试与浏览器检查,Linux/Docker 专项实测仍待完成。",
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
8
  {
9
9
  "version": "0.6.26",
@@ -1,3 +1,3 @@
1
1
  {
2
- "version": "0.6.26"
2
+ "version": "0.7.0-rc.1"
3
3
  }