@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/CHANGELOG.md +71 -0
- package/README.md +45 -2
- package/README.zh.md +45 -2
- package/RELEASE_NOTES.md +26 -23
- package/lib/client.js +976 -292
- package/lib/external.js +729 -0
- package/lib/index.js +819 -25
- package/lib/password.js +218 -0
- package/package.json +7 -5
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 {
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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,
|
|
75
|
-
if (now -
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
/*
|
|
283
|
-
/*
|
|
284
|
-
/*
|
|
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
|
-
|
|
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(
|
|
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,
|