@feiyang666/dsh-mobile-remote 1.1.0 → 1.4.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/lib/index.js CHANGED
@@ -24,11 +24,25 @@
24
24
  // 不 import 任何 dsh-client / dsh-host 内部包,仅使用 Node 内置模块与
25
25
  // webServer 服务公开的 tapIndex / register 接口。
26
26
 
27
- import { networkInterfaces } from 'node:os'
28
- import { homedir } from 'node:os'
29
- import { join } from 'node:path'
30
- import { existsSync, readFileSync, writeFileSync, unlinkSync } from 'node:fs'
27
+ import {
28
+ networkInterfaces, homedir, hostname, cpus, totalmem, freemem,
29
+ platform, release, arch, loadavg, uptime as osUptime,
30
+ } from 'node:os'
31
+ import { join, dirname } from 'node:path'
32
+ import {
33
+ existsSync, readFileSync, writeFileSync, unlinkSync, mkdirSync, rmSync,
34
+ } from 'node:fs'
35
+ import { spawn } from 'node:child_process'
31
36
  import qrcode from './qrcode.js'
37
+ import {
38
+ startExternal, stopExternal, externalView,
39
+ autoRestoreExternal, loadPersistedState, DEFAULT_SERVER_BASE,
40
+ frpcLogTail,
41
+ } from './external.js'
42
+ import {
43
+ hasPassword, verifyPassword, setPassword,
44
+ isEnabled, login, verifyToken, revokeToken, clearSessions,
45
+ } from './password.js'
32
46
 
33
47
  /** 插件名(loader 诊断用)。 */
34
48
  export const name = 'dsh-mobile-remote'
@@ -54,8 +68,19 @@ export function apply(ctx) {
54
68
  const remoteEnabled = ctx.webServer.host === '0.0.0.0'
55
69
  const port = ctx.webServer.port
56
70
 
57
- // 活跃设备表:deviceId -> 最近心跳时间戳。
71
+ // 活跃设备表:deviceId -> 详细设备信息(见 handleHeartbeat)。
58
72
  const devices = new Map()
73
+ // 累计去重设备数 + 累计心跳数(进程生命周期内的统计,供设置页展示更丰富的数据)。
74
+ const seenEver = new Set()
75
+ let totalBeats = 0
76
+ const tracker = {
77
+ seenEver,
78
+ recordBeat() { totalBeats += 1 },
79
+ beats: () => totalBeats,
80
+ }
81
+
82
+ // 外网隧道状态(frpc 子进程 + 配置)。
83
+ const external = { proc: null, status: 'idle', url: null, domain: null, tunnelPort: null }
59
84
 
60
85
  ctx.effect(() => {
61
86
  const dispose = ctx.webServer.tapIndex((html) => injectIntoIndex(html, remoteEnabled))
@@ -63,25 +88,61 @@ export function apply(ctx) {
63
88
  }, 'dsh-mobile-remote: index transform')
64
89
 
65
90
  ctx.effect(() => {
91
+ // DSH 应用层状态缓存(版本 / 会话 / 工作区 / 插件 / 模型),后台定时刷新,
92
+ // status 请求直接读缓存,避免 sessionPersistence.list() 拖慢每 5s 的轮询。
93
+ const dshCache = { value: null, at: 0 }
94
+ const refreshDsh = () => { void refreshDshInfo(ctx, dshCache) }
95
+ refreshDsh()
96
+ const dshTimer = setInterval(refreshDsh, 60_000)
97
+
66
98
  const disposers = [
67
- ctx.webServer.register({ kind: 'exact', path: '/__dsh_remote/status', handler: handleStatus(ctx, devices) }),
68
- ctx.webServer.register({ kind: 'exact', path: '/__dsh_remote/toggle', handler: handleToggle(ctx) }),
69
- ctx.webServer.register({ kind: 'exact', path: '/__dsh_remote/heartbeat', handler: handleHeartbeat(devices) }),
70
- ctx.webServer.register({ kind: 'exact', path: '/__dsh_remote/qr', handler: handleQr() }),
99
+ ctx.webServer.register({ kind: 'exact', path: '/__dsh_remote/status', handler: authGate(handleStatus(ctx, devices, external, tracker, dshCache)) }),
100
+ ctx.webServer.register({ kind: 'exact', path: '/__dsh_remote/toggle', handler: authGate(handleToggle(ctx)) }),
101
+ ctx.webServer.register({ kind: 'exact', path: '/__dsh_remote/heartbeat', handler: authGate(handleHeartbeat(devices, tracker)) }),
102
+ ctx.webServer.register({ kind: 'exact', path: '/__dsh_remote/qr', handler: authGate(handleQr()) }),
103
+ ctx.webServer.register({ kind: 'exact', path: '/__dsh_remote/external/start', handler: authGate(handleExternalStart(ctx, external)) }),
104
+ ctx.webServer.register({ kind: 'exact', path: '/__dsh_remote/external/stop', handler: authGate(handleExternalStop(ctx, external)) }),
105
+ // frpc 运行日志读取(供设置页展示外网隧道详细状态)
106
+ ctx.webServer.register({ kind: 'exact', path: '/__dsh_remote/external/log', handler: authGate(handleExternalLog()) }),
107
+ // —— 远程访问密码门禁相关(auth 系列不套 authGate,需放行)——
108
+ ctx.webServer.register({ kind: 'exact', path: '/__dsh_remote/auth-status', handler: handleAuthStatus() }),
109
+ ctx.webServer.register({ kind: 'exact', path: '/__dsh_remote/auth', handler: handleAuth() }),
110
+ ctx.webServer.register({ kind: 'exact', path: '/__dsh_remote/logout', handler: handleLogout() }),
111
+ ctx.webServer.register({ kind: 'exact', path: '/__dsh_remote/set-password', handler: handleSetPassword() }),
112
+ // —— Service Worker:远程访问首屏缓存加速(仅 HTTPS 外网生效)——
113
+ ctx.webServer.register({ kind: 'exact', path: '/__dsh_remote/sw.js', handler: handleServiceWorker() }),
71
114
  ]
72
115
  const timer = setInterval(() => {
73
116
  const now = Date.now()
74
- for (const [id, seen] of devices) {
75
- if (now - seen > HEARTBEAT_TTL) devices.delete(id)
117
+ for (const [id, info] of devices) {
118
+ if (now - info.lastSeen > HEARTBEAT_TTL) devices.delete(id)
76
119
  }
77
120
  }, HEARTBEAT_SWEEP)
121
+ // dsh 重启后自动恢复外网访问:读取持久化的绑定码 / 中转地址重新拉起 frpc,
122
+ // 免去每次手动输入。设置 DSH_DISABLE_AUTO_RESTORE=1 可关闭该行为。
123
+ if (process.env.DSH_DISABLE_AUTO_RESTORE !== '1') {
124
+ const patchFile = patchFilePath(ctx)
125
+ void autoRestoreExternal(external, patchFile, ctx.webServer.port || 3080)
126
+ }
78
127
  return () => {
79
128
  clearInterval(timer)
129
+ clearInterval(dshTimer)
130
+ stopFrpc(external)
80
131
  for (const dispose of disposers) dispose()
81
132
  }
82
133
  }, 'dsh-mobile-remote: api routes')
83
134
  }
84
135
 
136
+ /** 后台刷新 DSH 应用层状态缓存(失败时保留旧值或置 null)。 */
137
+ async function refreshDshInfo(ctx, cache) {
138
+ try {
139
+ cache.value = await collectDshInfo(ctx)
140
+ cache.at = Date.now()
141
+ } catch {
142
+ cache.value = null
143
+ }
144
+ }
145
+
85
146
  /* ------------------------------------------------------------------ */
86
147
  /* 远程控制 API */
87
148
  /* ------------------------------------------------------------------ */
@@ -117,6 +178,421 @@ function getLanAddresses() {
117
178
  return out
118
179
  }
119
180
 
181
+ /** 所有 IPv4 网卡详情(含网卡名 / 掩码 / MAC / CIDR / 是否回环),供设置页展示。 */
182
+ function getLanInterfaces() {
183
+ const out = []
184
+ for (const [name, entries] of Object.entries(networkInterfaces() ?? {})) {
185
+ for (const iface of entries ?? []) {
186
+ if (iface.family !== 'IPv4') continue
187
+ out.push({
188
+ name,
189
+ address: iface.address,
190
+ netmask: iface.netmask || null,
191
+ mac: iface.mac || '',
192
+ cidr: iface.cidr || null,
193
+ internal: iface.internal === true,
194
+ })
195
+ }
196
+ }
197
+ return out
198
+ }
199
+
200
+ /** 当前进程运行时信息(诊断用,设置页展示)。 */
201
+ function runtimeInfo() {
202
+ const mem = process.memoryUsage()
203
+ const cpu = cpus()
204
+ const load = loadavg()
205
+ const total = totalmem()
206
+ return {
207
+ pid: process.pid,
208
+ hostname: hostname(),
209
+ platform: platform(),
210
+ release: release(),
211
+ arch: arch(),
212
+ cpus: cpu.length,
213
+ cpuModel: (cpu[0] && cpu[0].model) ? cpu[0].model : null,
214
+ cpuSpeed: (cpu[0] && cpu[0].speed) ? cpu[0].speed : null,
215
+ loadavg: Array.isArray(load) ? load.map((n) => Number(n.toFixed(2))) : null,
216
+ sysUptime: Math.floor(osUptime()),
217
+ totalmem: total,
218
+ freemem: freemem(),
219
+ memPct: total > 0 ? Math.round((1 - freemem() / total) * 100) : null,
220
+ nodeVersion: process.version,
221
+ uptime: Math.floor(process.uptime()),
222
+ rss: mem.rss,
223
+ heapUsed: mem.heapUsed,
224
+ heapTotal: mem.heapTotal,
225
+ profile: process.env.DSH_PROFILE || 'web',
226
+ dshHome: process.env.DSH_HOME || join(homedir(), '.dsh'),
227
+ }
228
+ }
229
+
230
+ /** 给异步操作加超时保护,超时返回 fallback(默认 null),避免拖慢 status 响应。 */
231
+ function withTimeout(promise, ms, fallback = null) {
232
+ return Promise.race([
233
+ Promise.resolve(promise),
234
+ new Promise((resolve) => setTimeout(() => resolve(fallback), ms)),
235
+ ])
236
+ }
237
+
238
+ /** 多位置探测 dsh 版本号(profile 本地 / 桌面版 / 全局安装)。找不到返回 null。 */
239
+ function detectDshVersion() {
240
+ const home = process.env.DSH_HOME || join(homedir(), '.dsh')
241
+ const profile = process.env.DSH_PROFILE || 'web'
242
+ const candidates = [
243
+ join(home, 'profiles', profile, 'node_modules', '@deepseek-ai', 'dsh', 'package.json'),
244
+ ]
245
+ if (process.env.APPDATA) {
246
+ candidates.push(
247
+ join(process.env.APPDATA, 'dsh-desktop', 'dsh-local', 'node_modules', '@deepseek-ai', 'dsh', 'package.json'),
248
+ )
249
+ }
250
+ candidates.push(join(dirname(process.execPath), '..', '..', 'node_modules', '@deepseek-ai', 'dsh', 'package.json'))
251
+ for (const f of candidates) {
252
+ try {
253
+ if (!existsSync(f)) continue
254
+ const pkg = JSON.parse(readFileSync(f, 'utf8'))
255
+ if (pkg && typeof pkg.version === 'string') return pkg.version
256
+ } catch { /* ignore */ }
257
+ }
258
+ return null
259
+ }
260
+
261
+ /**
262
+ * 采集 DSH 应用层状态(尽力而为,任一失败/不可用都降级为 null):
263
+ * 版本号 / 会话总数 / 工作区列表 / 已安装插件 / LLM 提供方。
264
+ * 通过 ctx.get() 可选获取服务,不注入必选依赖,避免插件在精简启动下起不来。
265
+ */
266
+ async function collectDshInfo(ctx) {
267
+ const out = { version: null, sessions: null, workspaces: null, plugins: null, llm: null }
268
+ try { out.version = detectDshVersion() } catch { /* ignore */ }
269
+ try {
270
+ const sp = ctx.get('sessionPersistence')
271
+ if (sp && typeof sp.list === 'function') {
272
+ const headers = await withTimeout(sp.list(), 2000)
273
+ if (Array.isArray(headers)) out.sessions = headers.length
274
+ }
275
+ } catch { /* ignore */ }
276
+ try {
277
+ const wr = ctx.get('workspaceRegistry')
278
+ if (wr && typeof wr.list === 'function') {
279
+ const list = wr.list()
280
+ if (Array.isArray(list)) {
281
+ out.workspaces = list.map((w) => ({
282
+ path: w.path,
283
+ title: w.title || '',
284
+ sessionCount: Array.isArray(w.sessionIds) ? w.sessionIds.length : 0,
285
+ updatedAt: w.updatedAt || null,
286
+ }))
287
+ }
288
+ }
289
+ } catch { /* ignore */ }
290
+ try {
291
+ const pi = ctx.get('pluginInventory')
292
+ if (pi && typeof pi.list === 'function') {
293
+ const snap = pi.list()
294
+ if (snap && Array.isArray(snap.entries)) {
295
+ out.plugins = snap.entries.map((e) => ({
296
+ name: e.moduleName || e.entryId,
297
+ enabled: e.enabled === true,
298
+ phase: e.fiberPhase || null,
299
+ }))
300
+ }
301
+ }
302
+ } catch { /* ignore */ }
303
+ try {
304
+ const llm = ctx.get('llm')
305
+ if (llm && typeof llm.listProviders === 'function') {
306
+ const ps = llm.listProviders()
307
+ if (Array.isArray(ps)) {
308
+ out.llm = ps.map((p) => ({ id: p.id, name: p.name || p.id }))
309
+ }
310
+ }
311
+ } catch { /* ignore */ }
312
+ return out
313
+ }
314
+
315
+ /** 从 User-Agent 推断设备名称。 */
316
+ function deviceNameFromUA(ua) {
317
+ const s = String(ua || '')
318
+ if (s.includes('iPhone')) return 'iPhone'
319
+ if (s.includes('iPad')) return 'iPad'
320
+ if (s.includes('Android')) return 'Android 设备'
321
+ if (s.includes('Windows')) return 'Windows 电脑'
322
+ if (s.includes('Macintosh') || s.includes('Mac OS')) return 'Mac 电脑'
323
+ if (s.includes('CrOS')) return 'Chromebook'
324
+ if (s.includes('Linux')) return 'Linux 设备'
325
+ if (!s) return '未知设备'
326
+ return '浏览器'
327
+ }
328
+
329
+ /** 从 User-Agent 推断操作系统(用于设备详情展示)。 */
330
+ function osFromUA(ua) {
331
+ const s = String(ua || '')
332
+ if (/iPhone|iPad|iPod/i.test(s)) return 'iOS'
333
+ if (/Android/i.test(s)) return 'Android'
334
+ if (/Windows NT 10/i.test(s)) return 'Windows 10/11'
335
+ if (/Windows NT/i.test(s)) return 'Windows'
336
+ if (/Mac OS X/i.test(s)) return 'macOS'
337
+ if (/CrOS/i.test(s)) return 'ChromeOS'
338
+ if (/Linux/i.test(s)) return 'Linux'
339
+ if (!s) return '未知系统'
340
+ return '其他系统'
341
+ }
342
+
343
+ /** 从 User-Agent 推断浏览器内核 / 名称(用于设备详情展示)。 */
344
+ function browserFromUA(ua) {
345
+ const s = String(ua || '')
346
+ if (/Edg\//i.test(s)) return 'Edge'
347
+ if (/OPR\/|Opera/i.test(s)) return 'Opera'
348
+ if (/Firefox\//i.test(s)) return 'Firefox'
349
+ if (/Chrome\//i.test(s)) return 'Chrome'
350
+ if (/Safari\//i.test(s)) return 'Safari'
351
+ if (!s) return '未知浏览器'
352
+ return '其他浏览器'
353
+ }
354
+
355
+ /** 从请求中提取客户端 IP:优先代理转发头(frpc/nginx 会加),回退 socket 地址。 */
356
+ function clientIpFromReq(req) {
357
+ const fwd = req.headers['x-forwarded-for']
358
+ if (fwd) {
359
+ const first = String(fwd).split(',')[0].trim()
360
+ if (first) return first
361
+ }
362
+ if (req.headers['x-real-ip']) return String(req.headers['x-real-ip']).trim()
363
+ return (req.socket && req.socket.remoteAddress) || null
364
+ }
365
+
366
+ /* ------------------------------------------------------------------ */
367
+ /* 远程访问密码门禁(host 侧) */
368
+ /* ------------------------------------------------------------------ */
369
+
370
+ /** 会话 cookie 名。 */
371
+ const AUTH_COOKIE = '__dsh_remote_auth'
372
+
373
+ /**
374
+ * 判断当前请求是否为“远程访问”(外网隧道 / 经 HTTPS 反代进入)。
375
+ * 依据:外网隧道由 Nginx 反代并设置 X-Forwarded-Proto: https,或 socket 本身
376
+ * 已加密(TLS)。本机 127.0.0.1 与局域网直连(http)不带该头 → 视为内网,
377
+ * 不强制密码。这样管理员在本机/内网总能正常使用与修复。
378
+ * @param {import('node:http').IncomingMessage} req
379
+ */
380
+ function isRemoteRequest(req) {
381
+ const proto = String(req.headers['x-forwarded-proto'] || '').toLowerCase()
382
+ if (proto === 'https') return true
383
+ if (req.socket && req.socket.encrypted) return true
384
+ return false
385
+ }
386
+
387
+ /** 从请求 Cookie 头解析某个 cookie 的值(未找到返回 null)。 */
388
+ function parseCookie(req, name) {
389
+ const raw = String(req.headers.cookie || '')
390
+ for (const pair of raw.split(';')) {
391
+ const idx = pair.indexOf('=')
392
+ if (idx < 0) continue
393
+ const k = pair.slice(0, idx).trim()
394
+ if (k === name) return pair.slice(idx + 1).trim()
395
+ }
396
+ return null
397
+ }
398
+
399
+ /** 是否满足远程访问鉴权:远程且已设密码时要求有效 token。 */
400
+ function authorized(req) {
401
+ if (!isRemoteRequest(req)) return true
402
+ if (!isEnabled()) return true
403
+ return verifyToken(parseCookie(req, AUTH_COOKIE))
404
+ }
405
+
406
+ /** 远程访问鉴权中间件(注册给需要保护的 path 使用)。 */
407
+ function authGate(handler) {
408
+ return (req, res) => {
409
+ if (!authorized(req)) {
410
+ res.writeHead(401, { 'Content-Type': 'application/json; charset=utf-8' })
411
+ res.end(JSON.stringify({ ok: false, code: 'AUTH_REQUIRED', message: '请先输入远程访问密码' }))
412
+ return
413
+ }
414
+ return handler(req, res)
415
+ }
416
+ }
417
+
418
+ /** GET /__dsh_remote/auth-status:返回远程访问密码的启用状态与本次请求是否需鉴权。 */
419
+ function handleAuthStatus() {
420
+ return async (req, res) => {
421
+ json(res, 200, {
422
+ ok: true,
423
+ enabled: isEnabled(),
424
+ // 仅当是远程访问且已设密码时才需要输入
425
+ required: isRemoteRequest(req) && isEnabled(),
426
+ authenticated: authorized(req),
427
+ remote: isRemoteRequest(req),
428
+ })
429
+ }
430
+ }
431
+
432
+ /** POST /__dsh_remote/auth { password }:校验远程访问密码并签发会话 token(写入 cookie)。 */
433
+ function handleAuth() {
434
+ return async (req, res) => {
435
+ // 仅远程访问才允许(本机内网无需走这步,但仍可被调用)
436
+ const body = await readBody(req)
437
+ const password = typeof body.password === 'string' ? body.password : ''
438
+ const ip = clientIpFromReq(req)
439
+ const result = login(password, ip)
440
+ if (!result.ok) {
441
+ json(res, 401, { ok: false, code: 'BAD_PASSWORD', message: result.error || '密码错误' })
442
+ return
443
+ }
444
+ const cookie = `${AUTH_COOKIE}=${result.token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=86400` + (isRemoteRequest(req) ? '; Secure' : '')
445
+ res.writeHead(200, {
446
+ 'Content-Type': 'application/json; charset=utf-8',
447
+ 'Set-Cookie': cookie,
448
+ })
449
+ res.end(JSON.stringify({ ok: true, authenticated: true }))
450
+ }
451
+ }
452
+
453
+ /** POST /__dsh_remote/logout:撤销当前会话。 */
454
+ function handleLogout() {
455
+ return async (req, res) => {
456
+ revokeToken(parseCookie(req, AUTH_COOKIE))
457
+ res.writeHead(200, {
458
+ 'Content-Type': 'application/json; charset=utf-8',
459
+ 'Set-Cookie': `${AUTH_COOKIE}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0`,
460
+ })
461
+ res.end(JSON.stringify({ ok: true }))
462
+ }
463
+ }
464
+
465
+ /**
466
+ * POST /__dsh_remote/set-password { password }(仅本机/内网来源可用):
467
+ * 设置或修改远程访问密码。远程来源被拒绝,防止外部任意篡改。空密码=清除。
468
+ */
469
+ function handleSetPassword() {
470
+ return async (req, res) => {
471
+ if (isRemoteRequest(req)) {
472
+ json(res, 403, { ok: false, code: 'LOCAL_ONLY', message: '仅限在本机设置远程访问密码' })
473
+ return
474
+ }
475
+ const body = await readBody(req)
476
+ const password = typeof body.password === 'string' ? body.password : ''
477
+ // 密码强度检查
478
+ if (password && password.length < 4) {
479
+ json(res, 400, { ok: false, code: 'WEAK_PASSWORD', message: '密码至少 4 位' })
480
+ return
481
+ }
482
+ if (password && password.length > 128) {
483
+ json(res, 400, { ok: false, code: 'BAD_PASSWORD', message: '密码过长(最多 128 位)' })
484
+ return
485
+ }
486
+ const result = setPassword(password)
487
+ json(res, 200, Object.assign({ ok: true }, result))
488
+ }
489
+ }
490
+
491
+ /* ------------------------------------------------------------------ */
492
+ /* Service Worker:远程访问首屏加速 */
493
+ /* ------------------------------------------------------------------ */
494
+
495
+ /**
496
+ * 处理 GET /__dsh_remote/sw.js —— 返回 Service Worker 脚本。
497
+ *
498
+ * 背景:dsh 的静态服务(frontend-static)响应头只有 content-type,没有
499
+ * Cache-Control / gzip。远程(外网 HTTPS)访问时,浏览器每次都要全量重新
500
+ * 下载 ~1.25MB 的前端资源,经 frp 隧道每个文件要 1~2 秒,导致首屏
501
+ * "Loading plugins…" 转圈很久。
502
+ *
503
+ * 方案:注册一个 Service Worker(SW 只在 HTTPS 下生效,正好覆盖外网访问;
504
+ * 内网 http 不启用,但内网直连本来就快)。SW 对 `/assets/*.js|css`(文件名
505
+ * 带内容哈希,内容不可变)做 cache-first 缓存——首次访问后,后续访问直接
506
+ * 命中浏览器缓存秒开;DSH 更新后资源文件名哈希变化,浏览器自动加载新文件,
507
+ * 不会出现旧版本。
508
+ *
509
+ * 响应头必须带 `Service-Worker-Allowed: /`,否则浏览器不允许把 SW 的 scope
510
+ * 扩展到根路径(SW 脚本位于 /__dsh_remote/ 下,默认 scope 只覆盖该子路径)。
511
+ */
512
+ function handleServiceWorker() {
513
+ return async (req, res) => {
514
+ if (req.method !== 'GET' && req.method !== 'HEAD') {
515
+ res.writeHead(405)
516
+ res.end()
517
+ return
518
+ }
519
+ res.writeHead(200, {
520
+ 'Content-Type': 'application/javascript; charset=utf-8',
521
+ 'Service-Worker-Allowed': '/',
522
+ // SW 本身不缓存或短缓存:保证 DSH 更新后 SW 能及时拿到新版本
523
+ 'Cache-Control': 'no-cache',
524
+ })
525
+ res.end(SERVICE_WORKER_JS)
526
+ }
527
+ }
528
+
529
+ /** Service Worker 脚本体(cache-first 缓存 /assets/ 下的 js/css)。 */
530
+ const SERVICE_WORKER_JS = `
531
+ 'use strict';
532
+ /* dsh-mobile-remote 远程访问加速 SW:缓存 /assets/ 下带内容哈希的静态资源。
533
+ * 文件名带哈希(如 index-C-1AiF3k.js)→ 内容不变则文件名不变 → 可安全
534
+ * cache-first;DSH 更新后文件名哈希变化 → 自动回源加载新文件。 */
535
+ var CACHE_NAME = 'dsh-remote-static-v1';
536
+ var CACHE_PATTERN = /^\\/assets\\/.*\\.(js|css)$/;
537
+
538
+ self.addEventListener('install', function (event) {
539
+ self.skipWaiting();
540
+ });
541
+
542
+ self.addEventListener('activate', function (event) {
543
+ event.waitUntil(
544
+ caches.keys().then(function (keys) {
545
+ return Promise.all(keys.map(function (k) {
546
+ if (k !== CACHE_NAME) return caches.delete(k);
547
+ }));
548
+ }).then(function () { return self.clients.claim(); })
549
+ );
550
+ });
551
+
552
+ self.addEventListener('fetch', function (event) {
553
+ var url;
554
+ try { url = new URL(event.request.url); } catch (e) { return; }
555
+ if (url.origin !== self.location.origin) return;
556
+ if (!CACHE_PATTERN.test(url.pathname)) return;
557
+ event.respondWith(
558
+ caches.open(CACHE_NAME).then(function (cache) {
559
+ return cache.match(event.request).then(function (hit) {
560
+ if (hit) return hit;
561
+ return fetch(event.request).then(function (resp) {
562
+ if (resp && resp.ok) cache.put(event.request, resp.clone());
563
+ return resp;
564
+ });
565
+ });
566
+ }).catch(function () {
567
+ return fetch(event.request);
568
+ })
569
+ );
570
+ });
571
+ `
572
+
573
+ /**
574
+ * 从中转服务器查询本成员隧道状态与统计;失败时返回 null(不影响整体 status)。
575
+ * 优先调用 /api/tunnel/stats(含 frps 实时流量/连接数等),接口不可用则回退到
576
+ * /api/tunnel/status(旧版后端)。任一失败返回 null。
577
+ */
578
+ async function queryServerStatus(base, bindCode) {
579
+ if (!bindCode) return null
580
+ const server = String(base || DEFAULT_SERVER_BASE).replace(/\/$/, '')
581
+ const call = (path) => fetch(`${server}${path}`, {
582
+ headers: { Accept: 'application/json' },
583
+ // 避免无限期挂起,5 秒超时
584
+ signal: AbortSignal.timeout(5000),
585
+ }).then(async (res) => {
586
+ if (!res.ok) return null
587
+ const body = await res.json()
588
+ return body && body.success ? body.data : null
589
+ }).catch(() => null)
590
+ const q = `?bindCode=${encodeURIComponent(bindCode)}`
591
+ const stats = await call(`/api/tunnel/stats${q}`)
592
+ if (stats) return stats
593
+ return call(`/api/tunnel/status${q}`)
594
+ }
595
+
120
596
  /** 当前 profile 的 cordis.patch.yml 绝对路径。 */
121
597
  function patchFilePath(ctx) {
122
598
  // DSH_HOME 环境变量优先,否则 ~/.dsh;profile 名为 DSH_PROFILE 或默认 web。
@@ -182,19 +658,97 @@ function setPatchEnabled(ctx, enabled) {
182
658
  }
183
659
 
184
660
  /** GET /__dsh_remote/status */
185
- function handleStatus(ctx, devices) {
661
+ function handleStatus(ctx, devices, external, tracker, dshCache) {
186
662
  return async (req, res) => {
187
663
  const lan = getLanAddresses()
188
664
  const remoteEnabled = ctx.webServer.host === '0.0.0.0'
665
+ // 外网 frpc 是否仍在运行
666
+ const extRunning = !!external.proc && external.proc.exitCode === null
667
+ // 已持久化的绑定信息(重启后自动恢复用,前端据此回填表单)
668
+ const persisted = loadPersistedState()
669
+ // 活跃设备详情列表(按最近活跃倒序)
670
+ const deviceList = []
671
+ for (const [id, info] of devices) {
672
+ deviceList.push({
673
+ id,
674
+ ip: info.ip || null,
675
+ name: info.name || '未知设备',
676
+ os: info.os || '未知系统',
677
+ browser: info.browser || '未知浏览器',
678
+ ua: info.ua || '',
679
+ platform: info.platform || '',
680
+ lang: info.lang || '',
681
+ online: info.online === true,
682
+ touch: info.touch === true,
683
+ dpr: info.dpr || null,
684
+ screen: info.screen || '',
685
+ viewport: info.viewport || '',
686
+ connection: info.connection || null,
687
+ path: info.path || '',
688
+ title: info.title || '',
689
+ mobile: info.mobile === true,
690
+ beatCount: info.beatCount || 1,
691
+ battery: info.battery || null,
692
+ deviceMemory: info.deviceMemory || null,
693
+ hardwareConcurrency: info.hardwareConcurrency || null,
694
+ firstSeen: info.firstSeen,
695
+ lastSeen: info.lastSeen,
696
+ })
697
+ }
698
+ deviceList.sort((a, b) => b.lastSeen - a.lastSeen)
699
+ // 外网隧道在线时,向中转服务器查询成员真实状态(成员名 / frps 在线 / 最后心跳)
700
+ let server = null
701
+ if (extRunning && persisted) {
702
+ server = await queryServerStatus(persisted.serverBase || undefined, persisted.bindCode)
703
+ }
189
704
  json(res, 200, {
190
705
  host: ctx.webServer.host,
191
706
  port: ctx.webServer.port,
192
707
  lanAddresses: lan,
708
+ lanInterfaces: getLanInterfaces(),
193
709
  remoteEnabled,
194
710
  patchEnabled: isPatchEnabled(ctx),
195
711
  deviceCount: devices.size,
712
+ devices: deviceList,
196
713
  patchFile: patchFilePath(ctx),
197
714
  url: lan.length ? `http://${lan[0]}:${ctx.webServer.port}` : null,
715
+ // 本机运行时信息(诊断用,设置页展示更详细的数据)
716
+ runtime: runtimeInfo(),
717
+ uptime: Math.floor(process.uptime()),
718
+ nodeVersion: process.version,
719
+ totalDevicesEver: tracker.seenEver.size,
720
+ totalHeartbeats: tracker.beats(),
721
+ // DSH 应用层状态(版本 / 会话 / 工作区 / 插件 / 模型;后台缓存,可能为 null)
722
+ dsh: dshCache ? dshCache.value : null,
723
+ // 外网隧道字段(status 直接透传 externalView 的真实状态:
724
+ // online=隧道已建立 / connecting / error=鉴权失败等 / stopped / idle)
725
+ external: {
726
+ enabled: extRunning,
727
+ running: extRunning,
728
+ status: external.status || 'idle',
729
+ url: extRunning ? external.url : null,
730
+ domain: external.domain,
731
+ tunnelPort: external.tunnelPort,
732
+ frpcVersion: external.frpcVersion || null,
733
+ pid: extRunning && external.proc ? external.proc.pid : null,
734
+ startedAt: external.startedAt || null,
735
+ bindCodeShort: persisted && persisted.bindCode
736
+ ? String(persisted.bindCode).slice(0, 4) + '****'
737
+ : '',
738
+ error: external.error || null,
739
+ defaultServerBase: DEFAULT_SERVER_BASE,
740
+ persisted: persisted
741
+ ? { bindCode: persisted.bindCode || '', serverBase: persisted.serverBase || '' }
742
+ : null,
743
+ // 中转服务器返回的成员状态(外网在线时才有)
744
+ server,
745
+ },
746
+ // 远程访问密码门禁状态(供设置面板展示)
747
+ auth: {
748
+ enabled: isEnabled(),
749
+ requiredHere: isRemoteRequest(req) && isEnabled(),
750
+ authenticated: authorized(req),
751
+ },
198
752
  cliHint: remoteEnabled
199
753
  ? null
200
754
  : 'remote control is off; toggle it in settings or start dsh with --patch',
@@ -223,12 +777,43 @@ function handleToggle(ctx) {
223
777
  }
224
778
  }
225
779
 
226
- /** POST /__dsh_remote/heartbeat { id?: string, ua?: string } */
227
- function handleHeartbeat(devices) {
780
+ /** POST /__dsh_remote/heartbeat(移动端上报的详细设备元数据,见下方 MOBILE_JS)。 */
781
+ function handleHeartbeat(devices, tracker) {
228
782
  return async (req, res) => {
229
783
  const body = await readBody(req)
230
784
  const id = typeof body.id === 'string' && body.id ? body.id : 'anonymous'
231
- devices.set(id, Date.now())
785
+ const now = Date.now()
786
+ const prev = devices.get(id)
787
+ tracker.seenEver.add(id)
788
+ tracker.recordBeat()
789
+ const conn = (body.connection && typeof body.connection === 'object') ? body.connection : null
790
+ const battery = (body.battery && typeof body.battery === 'object')
791
+ ? { level: Number(body.battery.level) || null, charging: body.battery.charging === true }
792
+ : null
793
+ devices.set(id, {
794
+ ip: clientIpFromReq(req),
795
+ ua: String(body.ua || ''),
796
+ name: deviceNameFromUA(body.ua),
797
+ os: osFromUA(body.ua),
798
+ browser: browserFromUA(body.ua),
799
+ platform: String(body.platform || ''),
800
+ lang: String(body.lang || ''),
801
+ online: body.online === true,
802
+ touch: body.touch === true,
803
+ dpr: Number(body.dpr) || null,
804
+ screen: String(body.screen || ''),
805
+ viewport: String(body.viewport || ''),
806
+ connection: conn,
807
+ battery,
808
+ deviceMemory: Number(body.deviceMemory) || null,
809
+ hardwareConcurrency: Number(body.hardwareConcurrency) || null,
810
+ path: String(body.path || ''),
811
+ title: String(body.title || ''),
812
+ mobile: body.mobile === true,
813
+ beatCount: prev ? prev.beatCount + 1 : 1,
814
+ firstSeen: prev ? prev.firstSeen : now,
815
+ lastSeen: now,
816
+ })
232
817
  json(res, 200, { ok: true, deviceCount: devices.size })
233
818
  }
234
819
  }
@@ -263,14 +848,56 @@ function handleQr() {
263
848
  * 把移动端 CSS/JS 注入到 index.html。优先注入到 </head> 之前,
264
849
  * 找不到则退化为追加到文档末尾。
265
850
  */
851
+ /* ------------------------------------------------------------------ */
852
+ /* 外网隧道管理(中转服务 + frpc)—— 见 lib/external.js */
853
+ /* ------------------------------------------------------------------ */
854
+
855
+ /** POST /__dsh_remote/external/start { bindCode?, serverBase?, localPort? } */
856
+ function handleExternalStart(ctx, external) {
857
+ return async (req, res) => {
858
+ let body = {}
859
+ try { body = await readBody(req) } catch { /* ignore */ }
860
+ try {
861
+ const patchFile = patchFilePath(ctx)
862
+ const result = await startExternal(external, body, patchFile, ctx.webServer.port || 3080)
863
+ json(res, 200, Object.assign({ ok: true }, result))
864
+ } catch (error) {
865
+ external.error = String(error?.message || error)
866
+ json(res, 500, { ok: false, error: external.error })
867
+ }
868
+ }
869
+ }
870
+
871
+ /** POST /__dsh_remote/external/stop */
872
+ function handleExternalStop(ctx, external) {
873
+ return async (req, res) => {
874
+ stopExternal(external)
875
+ json(res, 200, { ok: true, enabled: false })
876
+ }
877
+ }
878
+
879
+ /** GET /__dsh_remote/external/log:返回 frpc 运行日志尾部,供设置页排查外网隧道问题。 */
880
+ function handleExternalLog() {
881
+ return async (req, res) => {
882
+ json(res, 200, { ok: true, log: frpcLogTail() })
883
+ }
884
+ }
885
+
886
+ /** 停止 frpc(apply 清理时调用)。keepState 保留持久化,重启后自动恢复。 */
887
+ function stopFrpc(external) {
888
+ stopExternal(external, { keepState: true })
889
+ }
266
890
  function injectIntoIndex(html, remoteEnabled) {
267
891
  const bootConfig =
268
892
  '<script id="dsh-mobile-remote-config">window.__DSH_MOBILE_REMOTE__ = ' +
269
893
  JSON.stringify({ lanEnabled: remoteEnabled, mobileMaxWidth: MOBILE_MAX_WIDTH }) +
270
894
  ';</script>'
895
+ const gateStyle = `<style id="dsh-mobile-remote-gate-css">${GATE_CSS}</style>`
896
+ const gateScript = `<script id="dsh-mobile-remote-gate-js">${GATE_JS}</script>`
271
897
  const style = `<style id="dsh-mobile-remote-css">${MOBILE_CSS}</style>`
272
898
  const script = `<script id="dsh-mobile-remote-js">${MOBILE_JS}</script>`
273
- const injection = bootConfig + style + script
899
+ // 门禁脚本注入在 head 最前,确保在 DSH 界面渲染前就绪
900
+ const injection = bootConfig + gateStyle + gateScript + style + script
274
901
 
275
902
  if (html.includes('</head>')) {
276
903
  return html.replace('</head>', `${injection}\n</head>`)
@@ -279,11 +906,132 @@ function injectIntoIndex(html, remoteEnabled) {
279
906
  }
280
907
 
281
908
  /* ------------------------------------------------------------------ */
282
- /* 移动端响应式 CSS */
283
- /* 作用:仅修补 composer 输入栏在窄屏下的挤压/粘连,并做 iOS 触屏优化。 */
284
- /* 不动 sidebar / details / frame / 任何 React 渲染结构。 */
909
+ /* 远程访问密码门禁:样式 + 前端脚本 */
910
+ /* 作用:当本次访问是“远程”(外网隧道 / HTTPS 反代)且管理员已设置密码时, */
911
+ /* 在 DSH 界面加载前弹出全屏密码验证遮罩。输对密码后种会话 cookie 并重载。 */
912
+ /* 不输密码无法操作 DSH(遮罩拦截所有交互)。 */
285
913
  /* ------------------------------------------------------------------ */
286
914
 
915
+ const GATE_CSS = `
916
+ #dsh-remote-gate {
917
+ position: fixed; inset: 0; z-index: 2147483647;
918
+ display: flex; align-items: center; justify-content: center;
919
+ background: rgba(15, 17, 26, 0.92);
920
+ backdrop-filter: blur(6px);
921
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
922
+ color: #e8e8ea;
923
+ }
924
+ #dsh-remote-gate .gate-card {
925
+ width: min(360px, 88vw); box-sizing: border-box;
926
+ background: #1d2030; border: 1px solid rgba(255,255,255,.12);
927
+ border-radius: 14px; padding: 24px 22px;
928
+ box-shadow: 0 18px 50px rgba(0,0,0,.45);
929
+ }
930
+ #dsh-remote-gate .gate-title {
931
+ font-size: 17px; font-weight: 700; margin: 0 0 6px;
932
+ }
933
+ #dsh-remote-gate .gate-sub {
934
+ font-size: 13px; color: #9aa0b5; margin: 0 0 16px; line-height: 1.5;
935
+ }
936
+ #dsh-remote-gate .gate-input {
937
+ width: 100%; box-sizing: border-box;
938
+ padding: 10px 12px; font-size: 14px; color: #e8e8ea;
939
+ background: #14171f; border: 1px solid rgba(255,255,255,.18);
940
+ border-radius: 9px; outline: none;
941
+ }
942
+ #dsh-remote-gate .gate-input:focus { border-color: #4c8dff; }
943
+ #dsh-remote-gate .gate-btn {
944
+ width: 100%; margin-top: 12px; padding: 10px 12px;
945
+ font-size: 14px; font-weight: 600; color: #fff;
946
+ background: #2a7de1; border: none; border-radius: 9px; cursor: pointer;
947
+ }
948
+ #dsh-remote-gate .gate-btn:hover { background: #3a8df0; }
949
+ #dsh-remote-gate .gate-btn:disabled { opacity: .6; cursor: default; }
950
+ #dsh-remote-gate .gate-err {
951
+ font-size: 12px; color: #ff6b6b; margin-top: 10px; min-height: 16px;
952
+ }
953
+ #dsh-remote-gate .gate-loading { opacity: .6; }
954
+ `
955
+
956
+ const GATE_JS = `
957
+ (function () {
958
+ 'use strict';
959
+ function canCover() {
960
+ try { return document.getElementById('dsh-remote-gate') === null; } catch (e) { return true; }
961
+ }
962
+ function createGate() {
963
+ if (!canCover()) return;
964
+ var g = document.createElement('div');
965
+ g.id = 'dsh-remote-gate';
966
+ g.innerHTML =
967
+ '<div class="gate-card">' +
968
+ '<p class="gate-title">远程访问已锁定</p>' +
969
+ '<p class="gate-sub">请输入管理员设置的远程访问密码以继续使用。</p>' +
970
+ '<input class="gate-input" type="password" autocomplete="current-password" placeholder="远程访问密码" />' +
971
+ '<button class="gate-btn" type="button">进入</button>' +
972
+ '<p class="gate-err"></p>' +
973
+ '</div>';
974
+ document.documentElement.appendChild(g);
975
+ return g;
976
+ }
977
+ function hideGate(g) { try { g.parentNode && g.parentNode.removeChild(g); } catch (e) {} }
978
+ function submit(g, input, btn) {
979
+ var pwd = input.value;
980
+ if (!pwd) { g.querySelector('.gate-err').textContent = '请输入密码'; return; }
981
+ btn.disabled = true; btn.classList.add('gate-loading');
982
+ fetch('/__dsh_remote/auth', {
983
+ method: 'POST',
984
+ headers: { 'Content-Type': 'application/json' },
985
+ body: JSON.stringify({ password: pwd }),
986
+ }).then(function (r) {
987
+ if (!r.ok) throw new Error('密码错误');
988
+ return r.json();
989
+ }).then(function () {
990
+ window.location.reload();
991
+ }).catch(function (e) {
992
+ btn.disabled = false; btn.classList.remove('gate-loading');
993
+ g.querySelector('.gate-err').textContent = String(e && e.message || e);
994
+ input.select();
995
+ });
996
+ }
997
+ function init() {
998
+ if (!canCover()) return;
999
+ fetch('/__dsh_remote/auth-status', { headers: { 'Accept': 'application/json' } })
1000
+ .then(function (r) { return r.json(); })
1001
+ .then(function (st) {
1002
+ if (!st || !st.required || st.authenticated) return;
1003
+ var g = createGate();
1004
+ if (!g) return;
1005
+ var input = g.querySelector('.gate-input');
1006
+ var btn = g.querySelector('.gate-btn');
1007
+ var doSubmit = function () { submit(g, input, btn); };
1008
+ btn.addEventListener('click', doSubmit);
1009
+ input.addEventListener('keydown', function (e) { if (e.key === 'Enter') doSubmit(); });
1010
+ input.focus();
1011
+ })
1012
+ .catch(function () { /* auth-status 不可达(本机直连等)时不遮罩 */ });
1013
+ }
1014
+
1015
+ /* 远程访问首屏加速:注册 Service Worker,缓存 /assets/ 下带哈希的静态资源。
1016
+ * 仅 HTTPS(外网隧道)下浏览器才允许注册 SW;内网 http 自动跳过。 */
1017
+ function registerSw() {
1018
+ try {
1019
+ if (!('serviceWorker' in navigator)) return;
1020
+ if (location.protocol !== 'https:') return;
1021
+ navigator.serviceWorker.register('/__dsh_remote/sw.js', { scope: '/' })
1022
+ .catch(function () { /* 注册失败不阻塞页面 */ });
1023
+ } catch (e) { /* ignore */ }
1024
+ }
1025
+
1026
+ if (document.readyState === 'loading') {
1027
+ document.addEventListener('DOMContentLoaded', function () { init(); registerSw(); });
1028
+ } else {
1029
+ init();
1030
+ registerSw();
1031
+ }
1032
+ })();
1033
+ `
1034
+
287
1035
  const MOBILE_CSS = `
288
1036
  /* === dsh-mobile-remote 移动端输入栏补丁 ===
289
1037
  * 作用范围:仅在移动设备(<1024px)下生效。DSH 原生已经在 <1024px 时把侧边栏
@@ -370,11 +1118,8 @@ const MOBILE_JS = `
370
1118
  return mobileUA || (touch && narrow);
371
1119
  }
372
1120
 
373
- if (!isMobile()) return;
374
-
375
- document.documentElement.setAttribute('data-dsh-mobile', '');
376
-
377
- // 设备心跳:稳定标识 + 30s 间隔上报,host 统计在线设备数。
1121
+ // 设备心跳:稳定标识 + 30s 间隔上报,host 统计并展示"已连接设备"。
1122
+ // 所有访问本 DSH 的浏览器(PC + 手机)都上报,仅移动端才打 data-dsh-mobile 标记。
378
1123
  try {
379
1124
  var KEY = '__dsh_mobile_remote_device';
380
1125
  var id = localStorage.getItem(KEY);
@@ -382,12 +1127,57 @@ const MOBILE_JS = `
382
1127
  id = 'dev-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 10);
383
1128
  localStorage.setItem(KEY, id);
384
1129
  }
1130
+ // 采集当前设备元数据,随心跳上报,host 在设置页展示详细设备信息。
1131
+ // 电池信息为异步获取,首次拿到后缓存在变量中,后续心跳一并上报。
1132
+ var batteryLevel = null;
1133
+ var batteryCharging = null;
1134
+ try {
1135
+ if (navigator.getBattery) {
1136
+ navigator.getBattery().then(function (b) {
1137
+ batteryLevel = Math.round((b && b.level) * 100);
1138
+ batteryCharging = !!(b && b.charging);
1139
+ }).catch(function () {});
1140
+ }
1141
+ } catch (e) {}
1142
+ function deviceMeta() {
1143
+ var scr = window.screen || {};
1144
+ var scrStr = (scr.width && scr.height)
1145
+ ? (scr.width + '\u00d7' + scr.height + (scr.colorDepth ? ' @ ' + scr.colorDepth + 'bit' : ''))
1146
+ : '';
1147
+ var conn = navigator.connection || null;
1148
+ var langs = navigator.languages || [];
1149
+ var title = '';
1150
+ try { title = document.title || ''; } catch (e) {}
1151
+ return {
1152
+ id: id,
1153
+ ua: navigator.userAgent,
1154
+ platform: navigator.platform || '',
1155
+ lang: navigator.language || (langs.length ? langs[0] : '') || '',
1156
+ online: navigator.onLine === true,
1157
+ touch: ('ontouchstart' in window) || (navigator.maxTouchPoints > 0),
1158
+ dpr: window.devicePixelRatio || 1,
1159
+ screen: scrStr,
1160
+ viewport: window.innerWidth + '\u00d7' + window.innerHeight,
1161
+ deviceMemory: navigator.deviceMemory || null,
1162
+ hardwareConcurrency: navigator.hardwareConcurrency || null,
1163
+ battery: (batteryLevel === null) ? null : { level: batteryLevel, charging: batteryCharging },
1164
+ connection: conn ? {
1165
+ effectiveType: conn.effectiveType || null,
1166
+ downlink: (typeof conn.downlink === 'number') ? conn.downlink : null,
1167
+ rtt: (typeof conn.rtt === 'number') ? conn.rtt : null,
1168
+ saveData: conn.saveData === true,
1169
+ } : null,
1170
+ path: (location.pathname || '/') + (location.search || ''),
1171
+ title: title,
1172
+ mobile: isMobile(),
1173
+ };
1174
+ }
385
1175
  function beat() {
386
1176
  try {
387
1177
  fetch('/__dsh_remote/heartbeat', {
388
1178
  method: 'POST',
389
1179
  headers: { 'Content-Type': 'application/json' },
390
- body: JSON.stringify({ id: id, ua: navigator.userAgent }),
1180
+ body: JSON.stringify(deviceMeta()),
391
1181
  keepalive: true,
392
1182
  }).catch(function () {});
393
1183
  } catch (e) {}
@@ -396,6 +1186,10 @@ const MOBILE_JS = `
396
1186
  setInterval(beat, 30000);
397
1187
  } catch (e) {}
398
1188
 
1189
+ if (!isMobile()) return;
1190
+
1191
+ document.documentElement.setAttribute('data-dsh-mobile', '');
1192
+
399
1193
  // 调试钩子:在控制台暴露状态,便于移动端远程排查。
400
1194
  window.__DSH_MOBILE_REMOTE_READY__ = {
401
1195
  mobile: true,