dsh-remote-plugin 0.4.4
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/README.md +34 -0
- package/client.js +140 -0
- package/cordis.patch.yml +4 -0
- package/gateway.cjs +675 -0
- package/index.mjs +392 -0
- package/package.json +56 -0
- package/public/admin.html +89 -0
- package/public/admin.js +265 -0
- package/public/app.js +1163 -0
- package/public/icon.svg +12 -0
- package/public/index.html +176 -0
- package/public/manifest.webmanifest +16 -0
- package/public/styles.css +396 -0
- package/public/update.json +6 -0
- package/public/version.json +3 -0
package/index.mjs
ADDED
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
/* dsh-remote DSH 插件 · Node half
|
|
2
|
+
* 在 DSH Web 的 httpServer 上挂 /remote 前缀路由:
|
|
3
|
+
* - /remote/... 移动控制台 + 主机管理页静态资源
|
|
4
|
+
* - /remote/admin/api 管理控制台数据: 优先代理本地网关(完整设备监控/更新检查),
|
|
5
|
+
* 网关不可用时回退到插件模式主机状态
|
|
6
|
+
* 浏览器侧入口由 client half 注册在 DSH 原生侧边栏(见 client.js)。
|
|
7
|
+
*/
|
|
8
|
+
import { spawn } from 'node:child_process'
|
|
9
|
+
import { createReadStream, existsSync, mkdirSync, openSync, readFileSync, writeFileSync } from 'node:fs'
|
|
10
|
+
import { stat } from 'node:fs/promises'
|
|
11
|
+
import { homedir, hostname, networkInterfaces } from 'node:os'
|
|
12
|
+
import { dirname, extname, normalize, resolve } from 'node:path'
|
|
13
|
+
import { fileURLToPath } from 'node:url'
|
|
14
|
+
|
|
15
|
+
export const name = 'dsh-remote'
|
|
16
|
+
export const inject = ['webServer']
|
|
17
|
+
|
|
18
|
+
const MOUNT = '/remote'
|
|
19
|
+
const PUBLIC_DIR = fileURLToPath(new URL('./public/', import.meta.url))
|
|
20
|
+
const INDEX_FILE = 'index.html'
|
|
21
|
+
const GATEWAY_SCRIPT = fileURLToPath(new URL('./gateway.cjs', import.meta.url))
|
|
22
|
+
const gatewayInstalled = existsSync(GATEWAY_SCRIPT)
|
|
23
|
+
// 本地网关管理 API 代理: 让插件抽屉显示与 8787 网关管理页完全一致的数据。
|
|
24
|
+
const GATEWAY_BASE = (process.env.DSH_REMOTE_GATEWAY || 'http://127.0.0.1:8787').replace(/\/+$/, '')
|
|
25
|
+
|
|
26
|
+
function gatewayToken() {
|
|
27
|
+
if (process.env.DSH_REMOTE_TOKEN) return process.env.DSH_REMOTE_TOKEN
|
|
28
|
+
try {
|
|
29
|
+
return readFileSync(`${homedir()}/.dsh-remote/token`, 'utf8').trim() || ''
|
|
30
|
+
} catch {
|
|
31
|
+
return ''
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const MIME = {
|
|
36
|
+
'.html': 'text/html; charset=utf-8',
|
|
37
|
+
'.js': 'text/javascript; charset=utf-8',
|
|
38
|
+
'.css': 'text/css; charset=utf-8',
|
|
39
|
+
'.json': 'application/json; charset=utf-8',
|
|
40
|
+
'.svg': 'image/svg+xml',
|
|
41
|
+
'.webmanifest': 'application/manifest+json; charset=utf-8',
|
|
42
|
+
'.png': 'image/png',
|
|
43
|
+
'.ico': 'image/x-icon',
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
let version = '0.0.0'
|
|
47
|
+
try {
|
|
48
|
+
const v = JSON.parse(readFileSync(new URL('./public/version.json', import.meta.url), 'utf8'))
|
|
49
|
+
if (v?.version) version = v.version
|
|
50
|
+
} catch {}
|
|
51
|
+
|
|
52
|
+
// DSH 实际监听地址由 apply 时从 webServer 服务读取
|
|
53
|
+
let dshListen = { host: '127.0.0.1', port: 3080 }
|
|
54
|
+
|
|
55
|
+
function lanIPs() {
|
|
56
|
+
const out = []
|
|
57
|
+
for (const list of Object.values(networkInterfaces())) {
|
|
58
|
+
for (const it of list ?? []) {
|
|
59
|
+
if (it.family === 'IPv4' && !it.internal) out.push(it.address)
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return out
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function targetPath(pathname) {
|
|
66
|
+
const rel = decodeURIComponent(pathname.slice(MOUNT.length)) || '/'
|
|
67
|
+
const file = rel === '/' ? INDEX_FILE : rel.replace(/^\/+/, '')
|
|
68
|
+
const abs = resolve(PUBLIC_DIR, normalize(file))
|
|
69
|
+
if (abs !== PUBLIC_DIR && !abs.startsWith(PUBLIC_DIR)) return null
|
|
70
|
+
return abs
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function sendJson(res, status, body) {
|
|
74
|
+
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' })
|
|
75
|
+
res.end(JSON.stringify(body))
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function readBody(req, maxBytes) {
|
|
79
|
+
return new Promise((resolvePromise, reject) => {
|
|
80
|
+
let body = ''
|
|
81
|
+
req.on('data', (chunk) => {
|
|
82
|
+
body += chunk
|
|
83
|
+
if (body.length > maxBytes) {
|
|
84
|
+
reject(new Error('body too large'))
|
|
85
|
+
req.destroy()
|
|
86
|
+
}
|
|
87
|
+
})
|
|
88
|
+
req.on('end', () => resolvePromise(body))
|
|
89
|
+
req.on('error', reject)
|
|
90
|
+
})
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** 转发到本地网关管理 API; 失败/超时返回 null。 */
|
|
94
|
+
async function proxyGateway(path, method, body) {
|
|
95
|
+
const token = gatewayToken()
|
|
96
|
+
if (!token) return null
|
|
97
|
+
try {
|
|
98
|
+
const res = await fetch(`${GATEWAY_BASE}${path}`, {
|
|
99
|
+
method,
|
|
100
|
+
headers: {
|
|
101
|
+
authorization: `Bearer ${token}`,
|
|
102
|
+
'content-type': 'application/json',
|
|
103
|
+
'x-dsh-remote-client': 'admin',
|
|
104
|
+
},
|
|
105
|
+
body: method === 'POST' ? body : undefined,
|
|
106
|
+
signal: AbortSignal.timeout(1500),
|
|
107
|
+
})
|
|
108
|
+
const json = await res.json().catch(() => ({ ok: false, error: `gateway ${res.status}` }))
|
|
109
|
+
return { status: res.status, json }
|
|
110
|
+
} catch {
|
|
111
|
+
return null
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/* ---------- 本地网关开关(持久化 + 自愈) ---------- */
|
|
116
|
+
|
|
117
|
+
function sleep(ms) { return new Promise(r => setTimeout(r, ms)) }
|
|
118
|
+
|
|
119
|
+
function runExit(cmd, args) {
|
|
120
|
+
return new Promise((resolvePromise) => {
|
|
121
|
+
let p
|
|
122
|
+
try { p = spawn(cmd, args, { stdio: 'ignore' }) }
|
|
123
|
+
catch { return resolvePromise(1) }
|
|
124
|
+
p.on('error', () => resolvePromise(1))
|
|
125
|
+
p.on('exit', (code) => resolvePromise(code ?? 1))
|
|
126
|
+
})
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async function gatewayRunning() {
|
|
130
|
+
try {
|
|
131
|
+
const res = await fetch(`${GATEWAY_BASE}/health`, { signal: AbortSignal.timeout(800) })
|
|
132
|
+
return res.ok
|
|
133
|
+
} catch {
|
|
134
|
+
return false
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** 用户意图持久化在 ~/.dsh-remote/gateway.enabled: on=跟随 DSH 自启/自愈, off=手动停止。 */
|
|
139
|
+
function gatewayStateFile() { return `${homedir()}/.dsh-remote/gateway.enabled` }
|
|
140
|
+
|
|
141
|
+
function gatewayAutostart() {
|
|
142
|
+
if (process.env.DSH_REMOTE_AUTOSTART === '0') return false
|
|
143
|
+
try {
|
|
144
|
+
const v = readFileSync(gatewayStateFile(), 'utf8').trim()
|
|
145
|
+
return v !== 'off'
|
|
146
|
+
} catch {
|
|
147
|
+
return true // 全新安装: 默认自动拉起网关, 抽屉打开即有网关模式
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function setGatewayEnabled(on) {
|
|
152
|
+
try {
|
|
153
|
+
mkdirSync(`${homedir()}/.dsh-remote`, { recursive: true })
|
|
154
|
+
writeFileSync(gatewayStateFile(), on ? 'on\n' : 'off\n')
|
|
155
|
+
} catch {}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** 启动随插件分发的 gateway.cjs; 已运行则直接返回。 */
|
|
159
|
+
async function startGateway() {
|
|
160
|
+
if (await gatewayRunning()) {
|
|
161
|
+
setGatewayEnabled(true)
|
|
162
|
+
return { ok: true, running: true, started: false }
|
|
163
|
+
}
|
|
164
|
+
const script = GATEWAY_SCRIPT
|
|
165
|
+
if (!existsSync(script)) {
|
|
166
|
+
return { ok: false, running: false, error: '插件包缺少 gateway.cjs, 请升级插件' }
|
|
167
|
+
}
|
|
168
|
+
const port = process.env.DSH_REMOTE_GATEWAY_PORT || '8787'
|
|
169
|
+
|
|
170
|
+
// 首选 systemd-run: 网关成为独立 user 单元, DSH 重启/升级不会连带杀掉它
|
|
171
|
+
let sysd = false
|
|
172
|
+
try {
|
|
173
|
+
await runExit('systemctl', ['--user', 'reset-failed', 'dsh-remote-gateway'])
|
|
174
|
+
sysd = (await runExit('systemd-run', [
|
|
175
|
+
'--user', '--unit=dsh-remote-gateway', '--service-type=exec',
|
|
176
|
+
'--setenv=PORT=' + port, '--setenv=HOST=0.0.0.0',
|
|
177
|
+
'--', process.execPath, script,
|
|
178
|
+
])) === 0
|
|
179
|
+
} catch {}
|
|
180
|
+
|
|
181
|
+
// 无 systemd 的机器回退: detached 子进程
|
|
182
|
+
if (!sysd) {
|
|
183
|
+
let logFd = null
|
|
184
|
+
try {
|
|
185
|
+
logFd = openSync(`${homedir()}/.dsh-remote/plugin-gateway.log`, 'a')
|
|
186
|
+
} catch {}
|
|
187
|
+
const child = spawn(process.execPath, [script], {
|
|
188
|
+
cwd: dirname(script),
|
|
189
|
+
detached: true,
|
|
190
|
+
stdio: ['ignore', logFd ?? 'ignore', logFd ?? 'ignore'],
|
|
191
|
+
env: { ...process.env, PORT: port },
|
|
192
|
+
})
|
|
193
|
+
child.unref()
|
|
194
|
+
}
|
|
195
|
+
// 最多等 4 秒; 超过可能是端口冲突或首次初始化, 前端稍后刷新即可
|
|
196
|
+
for (let i = 0; i < 16; i++) {
|
|
197
|
+
await sleep(250)
|
|
198
|
+
if (await gatewayRunning()) {
|
|
199
|
+
setGatewayEnabled(true)
|
|
200
|
+
return { ok: true, running: true, started: true }
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
return { ok: true, running: false, pending: true, hint: '网关启动中, 稍后刷新' }
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
let ensurePromise = null
|
|
207
|
+
/** 自愈入口: 状态轮询/DSH 启动时调用。开关为 on 且网关没起来, 就自动拉起(并发只拉一次)。 */
|
|
208
|
+
function ensureGateway() {
|
|
209
|
+
if (!gatewayAutostart()) return Promise.resolve(false)
|
|
210
|
+
if (ensurePromise) return ensurePromise
|
|
211
|
+
ensurePromise = (async () => {
|
|
212
|
+
try {
|
|
213
|
+
if (await gatewayRunning()) return true
|
|
214
|
+
const out = await startGateway()
|
|
215
|
+
return !!out.running
|
|
216
|
+
} finally {
|
|
217
|
+
setTimeout(() => { ensurePromise = null }, 4000)
|
|
218
|
+
}
|
|
219
|
+
})()
|
|
220
|
+
return ensurePromise
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** 通过网关自身的 /admin/api/shutdown 优雅停止(不管它当初是谁拉起的); 并写入 off 防自愈拉起。 */
|
|
224
|
+
async function stopGateway() {
|
|
225
|
+
const token = gatewayToken()
|
|
226
|
+
if (!token) return { ok: false, running: false, error: '找不到 ~/.dsh-remote/token, 无法认证网关' }
|
|
227
|
+
try {
|
|
228
|
+
const res = await fetch(`${GATEWAY_BASE}/admin/api/shutdown`, {
|
|
229
|
+
method: 'POST',
|
|
230
|
+
headers: { authorization: `Bearer ${token}`, 'x-dsh-remote-client': 'admin' },
|
|
231
|
+
signal: AbortSignal.timeout(2000),
|
|
232
|
+
})
|
|
233
|
+
const json = await res.json().catch(() => ({}))
|
|
234
|
+
if (res.ok) setGatewayEnabled(false)
|
|
235
|
+
return { ok: res.ok, running: false, ...json }
|
|
236
|
+
} catch (e) {
|
|
237
|
+
return { ok: false, running: false, error: '网关不可达: ' + (e?.message || e) }
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
async function resolveFile(pathname) {
|
|
242
|
+
let abs = targetPath(pathname)
|
|
243
|
+
if (abs === null) return null
|
|
244
|
+
try {
|
|
245
|
+
let info = await stat(abs)
|
|
246
|
+
if (info.isDirectory()) {
|
|
247
|
+
abs = resolve(abs, INDEX_FILE)
|
|
248
|
+
info = await stat(abs)
|
|
249
|
+
}
|
|
250
|
+
if (!info.isFile() && !extname(abs)) {
|
|
251
|
+
abs = abs + '.html' // /remote/admin -> admin.html
|
|
252
|
+
info = await stat(abs)
|
|
253
|
+
}
|
|
254
|
+
return info.isFile() ? { abs, info } : null
|
|
255
|
+
} catch {
|
|
256
|
+
if (!extname(abs)) {
|
|
257
|
+
// /remote/admin 无此裸文件 -> 再试 admin.html
|
|
258
|
+
try {
|
|
259
|
+
const alt = abs + '.html'
|
|
260
|
+
const info = await stat(alt)
|
|
261
|
+
return info.isFile() ? { abs: alt, info } : null
|
|
262
|
+
} catch {
|
|
263
|
+
return null
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
return null
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
async function serveStatic(req, res) {
|
|
271
|
+
const pathname = new URL(req.url ?? '/', 'http://x').pathname
|
|
272
|
+
|
|
273
|
+
// 无尾斜杠的入口重定向到带斜杠版本:
|
|
274
|
+
// 否则相对资源 styles.css/app.js 会按 URL 规则解析到上级路径 /styles.css,
|
|
275
|
+
// 被 DSH 的 SPA fallback 返回 HTML, 表现为白底 + 脚本不运行。
|
|
276
|
+
if (pathname === MOUNT) {
|
|
277
|
+
res.writeHead(302, { location: `${MOUNT}/` })
|
|
278
|
+
res.end()
|
|
279
|
+
return
|
|
280
|
+
}
|
|
281
|
+
if (pathname === `${MOUNT}/admin`) {
|
|
282
|
+
res.writeHead(302, { location: `${MOUNT}/admin/` })
|
|
283
|
+
res.end()
|
|
284
|
+
return
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// 管理控制台数据: 优先代理本地网关(设备监控/更新检查完整), 网关不可用回退插件状态
|
|
288
|
+
if (pathname === `${MOUNT}/admin/api/state`) {
|
|
289
|
+
void ensureGateway() // 自愈: 开关为 on 而网关没起来时, 后台拉起, 下个轮询即可见网关
|
|
290
|
+
const localToken = gatewayToken()
|
|
291
|
+
const proxied = await proxyGateway('/admin/api/state', 'GET', '')
|
|
292
|
+
if (proxied !== null) {
|
|
293
|
+
// 主机端 DSH 面板本身已登录本机用户, 管理页无需令牌门禁;
|
|
294
|
+
// 把真实网关令牌一并返回, 抽屉里直接显示并允许复制(供手机 App 使用)。
|
|
295
|
+
sendJson(res, proxied.status, { ...proxied.json, token: localToken, mode: 'gateway', via: 'gateway', gatewayInstalled })
|
|
296
|
+
return
|
|
297
|
+
}
|
|
298
|
+
sendJson(res, 200, {
|
|
299
|
+
ok: true,
|
|
300
|
+
mode: 'plugin',
|
|
301
|
+
version,
|
|
302
|
+
token: localToken || '',
|
|
303
|
+
gatewayInstalled,
|
|
304
|
+
hostname: hostname(),
|
|
305
|
+
lanIPs: lanIPs(),
|
|
306
|
+
startedAt: Date.now() - Math.floor(process.uptime() * 1000),
|
|
307
|
+
uptimeSec: Math.floor(process.uptime()),
|
|
308
|
+
host: dshListen.host,
|
|
309
|
+
port: dshListen.port,
|
|
310
|
+
upstream: { url: 'DSH 内嵌(同进程, 无需网关)', reachable: true },
|
|
311
|
+
latest: { version, newer: false },
|
|
312
|
+
onlineCount: 0,
|
|
313
|
+
deviceCount: 0,
|
|
314
|
+
totalRequests: 0,
|
|
315
|
+
authFailures: 0,
|
|
316
|
+
devices: [],
|
|
317
|
+
})
|
|
318
|
+
return
|
|
319
|
+
}
|
|
320
|
+
if (pathname === `${MOUNT}/admin/api/note` || pathname === `${MOUNT}/admin/api/kick`) {
|
|
321
|
+
if (req.method !== 'POST') {
|
|
322
|
+
res.writeHead(405, { allow: 'POST' })
|
|
323
|
+
res.end()
|
|
324
|
+
return
|
|
325
|
+
}
|
|
326
|
+
const body = await readBody(req, 4096)
|
|
327
|
+
const sub = pathname.endsWith('/note') ? '/note' : '/kick'
|
|
328
|
+
const proxied = await proxyGateway(`/admin/api${sub}`, 'POST', body)
|
|
329
|
+
if (proxied !== null) {
|
|
330
|
+
sendJson(res, proxied.status, proxied.json)
|
|
331
|
+
} else {
|
|
332
|
+
sendJson(res, 502, { ok: false, error: '本地网关不可用, 设备管理需在 8787 网关模式操作' })
|
|
333
|
+
}
|
|
334
|
+
return
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// 本地网关开关(仅插件内嵌页使用): GET 状态 / POST {action:'start'|'stop'}
|
|
338
|
+
if (pathname === `${MOUNT}/admin/api/gateway`) {
|
|
339
|
+
if (req.method === 'GET') {
|
|
340
|
+
sendJson(res, 200, { ok: true, running: await gatewayRunning() })
|
|
341
|
+
return
|
|
342
|
+
}
|
|
343
|
+
if (req.method === 'POST') {
|
|
344
|
+
let action = ''
|
|
345
|
+
try {
|
|
346
|
+
const raw = await readBody(req, 4096)
|
|
347
|
+
action = JSON.parse(raw || '{}').action
|
|
348
|
+
} catch {}
|
|
349
|
+
if (action === 'start') sendJson(res, 200, await startGateway())
|
|
350
|
+
else if (action === 'stop') sendJson(res, 200, await stopGateway())
|
|
351
|
+
else sendJson(res, 400, { ok: false, error: 'action 必须是 start 或 stop' })
|
|
352
|
+
return
|
|
353
|
+
}
|
|
354
|
+
res.writeHead(405, { allow: 'GET, POST' })
|
|
355
|
+
res.end()
|
|
356
|
+
return
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
|
360
|
+
res.writeHead(405, { allow: 'GET, HEAD' })
|
|
361
|
+
res.end()
|
|
362
|
+
return
|
|
363
|
+
}
|
|
364
|
+
const found = await resolveFile(pathname)
|
|
365
|
+
if (found === null) {
|
|
366
|
+
res.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' })
|
|
367
|
+
res.end('not found')
|
|
368
|
+
return
|
|
369
|
+
}
|
|
370
|
+
const { abs, info } = found
|
|
371
|
+
res.writeHead(200, {
|
|
372
|
+
'content-type': MIME[extname(abs)] ?? 'application/octet-stream',
|
|
373
|
+
'content-length': info.size,
|
|
374
|
+
'cache-control': 'no-cache',
|
|
375
|
+
})
|
|
376
|
+
if (req.method === 'HEAD') {
|
|
377
|
+
res.end()
|
|
378
|
+
return
|
|
379
|
+
}
|
|
380
|
+
createReadStream(abs).pipe(res)
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
export function apply(ctx) {
|
|
384
|
+
dshListen = { host: ctx.webServer.host, port: ctx.webServer.port }
|
|
385
|
+
ctx.effect(() => ctx.webServer.register({
|
|
386
|
+
kind: 'prefix',
|
|
387
|
+
path: MOUNT,
|
|
388
|
+
handler: serveStatic,
|
|
389
|
+
}), 'dsh-remote: /remote route')
|
|
390
|
+
// DSH 启动/重启后自愈: 用户没关过网关就自动拉起(默认开, DSH_REMOTE_AUTOSTART=0 关闭)
|
|
391
|
+
void ensureGateway()
|
|
392
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "dsh-remote-plugin",
|
|
3
|
+
"version": "0.4.4",
|
|
4
|
+
"description": "DSH Remote 官方 bundle 插件:DSH 左侧原生边栏入口 + 右侧抽屉内嵌管理控制台;内置网关随 DSH 自动启停(systemd 独立单元),抽屉直显令牌与设备监控,配合 Android App 远程操控会话/审批/提问/goal。",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./index.mjs",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./index.mjs",
|
|
9
|
+
"./client": "./client.js",
|
|
10
|
+
"./cordis.patch.yml": "./cordis.patch.yml",
|
|
11
|
+
"./package.json": "./package.json"
|
|
12
|
+
},
|
|
13
|
+
"files": [
|
|
14
|
+
"index.mjs",
|
|
15
|
+
"client.js",
|
|
16
|
+
"gateway.cjs",
|
|
17
|
+
"public",
|
|
18
|
+
"cordis.patch.yml"
|
|
19
|
+
],
|
|
20
|
+
"keywords": [
|
|
21
|
+
"dsh-plugin",
|
|
22
|
+
"deepseek-harness",
|
|
23
|
+
"dsh",
|
|
24
|
+
"bundle",
|
|
25
|
+
"remote-control",
|
|
26
|
+
"mobile"
|
|
27
|
+
],
|
|
28
|
+
"license": "MIT",
|
|
29
|
+
"homepage": "https://github.com/Blank-not-black/dsh-Remote",
|
|
30
|
+
"repository": {
|
|
31
|
+
"type": "git",
|
|
32
|
+
"url": "git+https://github.com/Blank-not-black/dsh-Remote.git",
|
|
33
|
+
"directory": "packages/plugin"
|
|
34
|
+
},
|
|
35
|
+
"bugs": {
|
|
36
|
+
"url": "https://github.com/Blank-not-black/dsh-Remote/issues"
|
|
37
|
+
},
|
|
38
|
+
"publishConfig": {
|
|
39
|
+
"access": "public"
|
|
40
|
+
},
|
|
41
|
+
"engines": {
|
|
42
|
+
"node": ">=18"
|
|
43
|
+
},
|
|
44
|
+
"dsh": {
|
|
45
|
+
"bundle": {
|
|
46
|
+
"patch": "./cordis.patch.yml"
|
|
47
|
+
},
|
|
48
|
+
"client": {
|
|
49
|
+
"inject": [
|
|
50
|
+
"@deepseek-ai/dsh-client-runtime",
|
|
51
|
+
"@deepseek-ai/dsh-client-ui-slots"
|
|
52
|
+
],
|
|
53
|
+
"platform": "web"
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="zh-CN">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
|
6
|
+
<meta name="theme-color" content="#0b0e1a">
|
|
7
|
+
<title>DSH Remote · 管理</title>
|
|
8
|
+
<link rel="stylesheet" href="../styles.css">
|
|
9
|
+
<style>
|
|
10
|
+
.admin-wrap { max-width: 860px; margin: 0 auto; padding: calc(env(safe-area-inset-top, 0px) + 14px) 14px 40px; }
|
|
11
|
+
.admin-title { display: flex; align-items: center; justify-content: space-between; margin-bottom: 14px; }
|
|
12
|
+
.admin-title h1 { font-size: 20px; margin: 0; }
|
|
13
|
+
.login-card { background: var(--panel); border: 1px solid var(--line); border-radius: var(--radius); padding: 16px; display: flex; gap: 8px; }
|
|
14
|
+
.login-card input { flex: 1; background: var(--bg); color: var(--text); border: 1px solid var(--line); border-radius: 10px; padding: 10px 12px; font: inherit; outline: none; }
|
|
15
|
+
.stat-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 8px; margin-bottom: 14px; }
|
|
16
|
+
.stat-card { background: var(--panel); border: 1px solid var(--line); border-radius: var(--radius); padding: 12px 14px; }
|
|
17
|
+
.stat-card .v { font-size: 20px; font-weight: 700; word-break: break-all; }
|
|
18
|
+
.stat-card .k { font-size: 11px; color: var(--muted); margin-top: 2px; }
|
|
19
|
+
.stat-card.ok .v { color: var(--cyan); } .stat-card.warn .v { color: var(--orange); }
|
|
20
|
+
.table-wrap { background: var(--panel); border: 1px solid var(--line); border-radius: var(--radius); overflow-x: auto; }
|
|
21
|
+
table { width: 100%; border-collapse: collapse; font-size: 13px; min-width: 760px; }
|
|
22
|
+
th, td { text-align: left; padding: 10px 12px; border-bottom: 1px solid var(--line); vertical-align: top; }
|
|
23
|
+
th { font-size: 11px; color: var(--muted); letter-spacing: .5px; }
|
|
24
|
+
tr:last-child td { border-bottom: none; }
|
|
25
|
+
.badge { display: inline-block; font-size: 11px; padding: 1px 8px; border-radius: 999px; border: 1px solid var(--line); color: var(--muted); }
|
|
26
|
+
.badge.app { color: var(--blue); border-color: rgba(91,140,255,.4); background: rgba(91,140,255,.08); }
|
|
27
|
+
.badge.admin { color: var(--purple); border-color: rgba(180,140,255,.4); background: rgba(180,140,255,.08); }
|
|
28
|
+
.dot { display: inline-block; width: 7px; height: 7px; border-radius: 50%; background: var(--line); margin-right: 5px; }
|
|
29
|
+
.dot.on { background: var(--cyan); box-shadow: 0 0 6px rgba(125,207,255,.9); }
|
|
30
|
+
.dot.off { background: var(--orange); }
|
|
31
|
+
.mono { font-family: ui-monospace, monospace; font-size: 12px; word-break: break-all; }
|
|
32
|
+
.ua { max-width: 260px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--muted); }
|
|
33
|
+
.token-row { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; margin: 10px 0 14px; }
|
|
34
|
+
.token-row code { flex: 1; background: var(--bg); border: 1px solid var(--line); border-radius: 8px; padding: 8px 10px; font-size: 12px; word-break: break-all; }
|
|
35
|
+
.empty { text-align: center; color: var(--muted); padding: 30px 0; }
|
|
36
|
+
.empty-actions { display: flex; gap: 8px; justify-content: center; flex-wrap: wrap; margin-top: 12px; }
|
|
37
|
+
a.mini-btn { text-decoration: none; display: inline-flex; align-items: center; }
|
|
38
|
+
.gh-btn { display: inline-flex; align-items: center; gap: 6px; text-decoration: none; color: var(--text); }
|
|
39
|
+
.gh-btn svg { width: 16px; height: 16px; fill: currentColor; }
|
|
40
|
+
.admin-title .right { display: flex; align-items: center; gap: 10px; }
|
|
41
|
+
</style>
|
|
42
|
+
</head>
|
|
43
|
+
<body>
|
|
44
|
+
<div class="admin-wrap">
|
|
45
|
+
<div class="admin-title">
|
|
46
|
+
<div style="display:flex;align-items:center;gap:10px">
|
|
47
|
+
<a id="btn-console" class="mini-btn" href="../" title="返回控制台">‹ 控制台</a>
|
|
48
|
+
<button id="btn-close-drawer" class="mini-btn hidden" title="收起面板">‹ 收起面板</button>
|
|
49
|
+
<h1>DSH Remote 管理</h1>
|
|
50
|
+
</div>
|
|
51
|
+
<div class="right">
|
|
52
|
+
<a class="mini-btn gh-btn" href="https://github.com/Blank-not-black/dsh-Remote" target="_blank" rel="noopener" title="访问 GitHub 仓库">
|
|
53
|
+
<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M8 0C3.58 0 0 3.58 0 8a8.01 8.01 0 0 0 5.47 7.59c.4.08.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27s1.36.09 2 .27c1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.01 8.01 0 0 0 16 8c0-4.42-3.58-8-8-8Z"/></svg>
|
|
54
|
+
仓库
|
|
55
|
+
</a>
|
|
56
|
+
<span id="conn-badge" class="conn-badge off">未认证</span>
|
|
57
|
+
</div>
|
|
58
|
+
</div>
|
|
59
|
+
|
|
60
|
+
<div id="login-view" class="login-card hidden">
|
|
61
|
+
<input id="token-input" type="password" placeholder="输入网关访问令牌" autocomplete="off">
|
|
62
|
+
<button id="btn-login" class="mini-btn">进入</button>
|
|
63
|
+
</div>
|
|
64
|
+
|
|
65
|
+
<div id="main-view" class="hidden">
|
|
66
|
+
<div class="token-row">
|
|
67
|
+
<code id="token-full">—</code>
|
|
68
|
+
<button id="btn-gateway" class="mini-btn hidden">启动网关</button>
|
|
69
|
+
<button id="btn-copy" class="mini-btn">复制令牌</button>
|
|
70
|
+
<button id="btn-logout" class="mini-btn">退出</button>
|
|
71
|
+
</div>
|
|
72
|
+
|
|
73
|
+
<div class="stat-grid" id="stats"></div>
|
|
74
|
+
|
|
75
|
+
<div class="section-head"><span>已连接设备</span><span id="device-summary" class="muted"></span></div>
|
|
76
|
+
<div class="table-wrap">
|
|
77
|
+
<table>
|
|
78
|
+
<thead><tr><th>状态</th><th>名称</th><th>类型</th><th>IP</th><th>通道</th><th>请求</th><th>最后活跃</th><th>UA</th><th></th></tr></thead>
|
|
79
|
+
<tbody id="device-rows"></tbody>
|
|
80
|
+
</table>
|
|
81
|
+
<div id="device-empty" class="empty hidden">暂无设备记录</div>
|
|
82
|
+
</div>
|
|
83
|
+
</div>
|
|
84
|
+
</div>
|
|
85
|
+
|
|
86
|
+
<div id="toast" class="toast hidden"></div>
|
|
87
|
+
<script src="../admin.js"></script>
|
|
88
|
+
</body>
|
|
89
|
+
</html>
|