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.
- package/apk/dsh-remote.apk +0 -0
- package/index.mjs +14 -0
- package/package.json +3 -2
- package/plugin-center.mjs +246 -0
- package/public/app.js +11 -0
- package/public/desktop/desktop.html +3 -0
- package/public/desktop/desktop.js +11 -0
- package/public/index.html +3 -0
- package/public/plugin-center.css +1 -0
- package/public/plugin-center.js +181 -0
- package/public/update.json +4 -4
- package/public/version.json +1 -1
package/apk/dsh-remote.apk
CHANGED
|
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.
|
|
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
|
+
})()
|
package/public/update.json
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version": "0.
|
|
2
|
+
"version": "0.7.0-rc.1",
|
|
3
3
|
"apkUrl": "dsh-remote.apk",
|
|
4
|
-
"sha256": "
|
|
5
|
-
"releasedAt": "2026-09-
|
|
6
|
-
"notes": "0.
|
|
4
|
+
"sha256": "5393a9a7866566b63f2f0998ec8ac984600e182c153ee1ee892573405385e63f",
|
|
5
|
+
"releasedAt": "2026-09-18T23:57:45.705Z",
|
|
6
|
+
"notes": "0.7.0-rc.1:新增插件中心,支持手机端和桌面端管理当前 DSH profile 的插件,搜索 npm 插件目录、查看详情及安装指定版本;支持更新、卸载、配置启停、任务日志与配置备份。启停需重启 DSH 生效,安装默认禁用脚本。此为测试版,Android 真机与 Linux 使用场景仍待验收。",
|
|
7
7
|
"history": [
|
|
8
8
|
{
|
|
9
9
|
"version": "0.6.26",
|
package/public/version.json
CHANGED