@feiyang666/dsh-mobile-remote 1.0.1 → 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 +86 -14
- package/LICENSE +1 -1
- package/README.md +195 -63
- package/README.zh.md +194 -62
- package/RELEASE_NOTES.md +52 -0
- package/cordis.patch.yml +12 -16
- package/lib/client.js +976 -503
- package/lib/external.js +729 -0
- package/lib/index.js +1173 -188
- package/lib/password.js +218 -0
- package/lib/qrcode.js +2300 -0
- package/package.json +30 -22
package/lib/index.js
CHANGED
|
@@ -1,221 +1,1206 @@
|
|
|
1
|
-
// dsh-mobile-remote —
|
|
2
|
-
// Host + Client 双面插件:
|
|
3
|
-
// - Host:检测 webServer 监听地址与端口、枚举局域网 IPv4 地址并生成手机访问
|
|
4
|
-
// URL,通过 webServer 提供 /api/dsh-mobile-remote JSON API(状态 / 心跳),
|
|
5
|
-
// 注册 mobile_remote_status 模型工具。
|
|
6
|
-
// - Client(lib/client.js):设置页「远程控制」页面(展示状态 / 访问地址 /
|
|
7
|
-
// 二维码 / 使用说明)+ 移动端 UI 适配(单列布局、侧栏抽屉化、浮动控制条)。
|
|
1
|
+
// dsh-mobile-remote — DSH 移动端远程控制插件(host 侧)。
|
|
8
2
|
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
3
|
+
// 两大职责:
|
|
4
|
+
// 1. **移动端呈现层补丁**:向 Web UI index.html 注入移动端 CSS/JS
|
|
5
|
+
// (composer 输入栏窄屏适配 + iOS 触控优化),不破坏 DSH 原生交互。
|
|
6
|
+
// 2. **远程控制设置 API**(`/__dsh_remote/*`):
|
|
7
|
+
// - `GET /__dsh_remote/status` 返回当前监听 host / port / LAN IP 列表 /
|
|
8
|
+
// 远程控制开关状态 / 当前活跃设备数;
|
|
9
|
+
// - `POST /__dsh_remote/toggle` 开启或关闭远程控制(写入 / 移除
|
|
10
|
+
// profile 的 cordis.patch.yml,经官方 HMR
|
|
11
|
+
// watcher 热重载 webserver 行,无需重启);
|
|
12
|
+
// - `POST /__dsh_remote/heartbeat` 移动端设备心跳上报,用于统计在线设备数;
|
|
13
|
+
// - `GET /__dsh_remote/qr` 返回连接二维码(SVG,URL 为局域网地址)。
|
|
14
|
+
//
|
|
15
|
+
// 为什么命令行启动也能远程控制(不需要桌面端):
|
|
16
|
+
// 官方 CLI 出于安全拒绝 `--host 0.0.0.0`(见 dsh-web-app/startup.ts),但
|
|
17
|
+
// 官方 `--patch <overlay>` / profile 的 cordis.patch.yml 配置层完整支持
|
|
18
|
+
// `webserver.host: 0.0.0.0`,且 profile patch 由 app-boot 的 watchUserPatches
|
|
19
|
+
// 通过 Cordis HMR 热重载。因此本插件在设置页切换开关 = 写入 / 移除 profile
|
|
20
|
+
// patch 的 webserver 行,即可让 dsh 重新监听所有网卡——与桌面端 `--patch`
|
|
21
|
+
// overlay 机制一致,官方 CLI 用户无需安装桌面端。
|
|
22
|
+
//
|
|
23
|
+
// 零运行时依赖(除 lib/qrcode.js 内联的 MIT QR 生成器):
|
|
24
|
+
// 不 import 任何 dsh-client / dsh-host 内部包,仅使用 Node 内置模块与
|
|
25
|
+
// webServer 服务公开的 tapIndex / register 接口。
|
|
26
|
+
|
|
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'
|
|
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'
|
|
46
|
+
|
|
47
|
+
/** 插件名(loader 诊断用)。 */
|
|
48
|
+
export const name = 'dsh-mobile-remote'
|
|
49
|
+
|
|
50
|
+
/** 依赖 dsh-host-webserver 注入的 webServer 服务,保证 tapIndex / register 可用。 */
|
|
51
|
+
export const inject = ['webServer']
|
|
52
|
+
|
|
53
|
+
/** 移动端断点:与 DSH 原生 SIDEBAR_AUTO_COLLAPSE = 1024 对齐。 */
|
|
54
|
+
const MOBILE_MAX_WIDTH = 1024
|
|
55
|
+
|
|
56
|
+
/** 心跳过期时间(毫秒):超过该时长未上报的设备视为离线。 */
|
|
57
|
+
const HEARTBEAT_TTL = 90_000
|
|
58
|
+
|
|
59
|
+
/** 心跳清理定时器间隔(毫秒)。 */
|
|
60
|
+
const HEARTBEAT_SWEEP = 30_000
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* 插件入口。webServer 服务就绪后被调用。
|
|
64
|
+
* @param {import('@deepseek-ai/cordis').Context} ctx
|
|
65
|
+
*/
|
|
66
|
+
export function apply(ctx) {
|
|
67
|
+
// 当前监听地址在服务启动时即确定。
|
|
68
|
+
const remoteEnabled = ctx.webServer.host === '0.0.0.0'
|
|
69
|
+
const port = ctx.webServer.port
|
|
70
|
+
|
|
71
|
+
// 活跃设备表:deviceId -> 详细设备信息(见 handleHeartbeat)。
|
|
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 }
|
|
84
|
+
|
|
85
|
+
ctx.effect(() => {
|
|
86
|
+
const dispose = ctx.webServer.tapIndex((html) => injectIntoIndex(html, remoteEnabled))
|
|
87
|
+
return () => dispose()
|
|
88
|
+
}, 'dsh-mobile-remote: index transform')
|
|
14
89
|
|
|
15
|
-
|
|
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)
|
|
16
97
|
|
|
17
|
-
const
|
|
98
|
+
const disposers = [
|
|
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() }),
|
|
114
|
+
]
|
|
115
|
+
const timer = setInterval(() => {
|
|
116
|
+
const now = Date.now()
|
|
117
|
+
for (const [id, info] of devices) {
|
|
118
|
+
if (now - info.lastSeen > HEARTBEAT_TTL) devices.delete(id)
|
|
119
|
+
}
|
|
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
|
+
}
|
|
127
|
+
return () => {
|
|
128
|
+
clearInterval(timer)
|
|
129
|
+
clearInterval(dshTimer)
|
|
130
|
+
stopFrpc(external)
|
|
131
|
+
for (const dispose of disposers) dispose()
|
|
132
|
+
}
|
|
133
|
+
}, 'dsh-mobile-remote: api routes')
|
|
134
|
+
}
|
|
18
135
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
+
|
|
146
|
+
/* ------------------------------------------------------------------ */
|
|
147
|
+
/* 远程控制 API */
|
|
148
|
+
/* ------------------------------------------------------------------ */
|
|
149
|
+
|
|
150
|
+
/** 读取响应体的 JSON(仅 POST)。 */
|
|
151
|
+
function readBody(req) {
|
|
152
|
+
return new Promise((resolve, reject) => {
|
|
153
|
+
let data = ''
|
|
154
|
+
req.on('data', (chunk) => { data += chunk })
|
|
155
|
+
req.on('end', () => {
|
|
156
|
+
try { resolve(data ? JSON.parse(data) : {}) }
|
|
157
|
+
catch { resolve({}) }
|
|
158
|
+
})
|
|
159
|
+
req.on('error', reject)
|
|
160
|
+
})
|
|
161
|
+
}
|
|
23
162
|
|
|
24
|
-
/**
|
|
25
|
-
function
|
|
26
|
-
const
|
|
27
|
-
|
|
163
|
+
/** 发送 JSON 响应。 */
|
|
164
|
+
function json(res, status, body) {
|
|
165
|
+
const payload = JSON.stringify(body)
|
|
166
|
+
res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8' })
|
|
167
|
+
res.end(payload)
|
|
28
168
|
}
|
|
29
169
|
|
|
30
|
-
/**
|
|
31
|
-
function
|
|
170
|
+
/** 本机局域网 IPv4 地址列表(不含回环)。 */
|
|
171
|
+
function getLanAddresses() {
|
|
32
172
|
const out = []
|
|
33
|
-
|
|
34
|
-
const
|
|
35
|
-
|
|
36
|
-
for (const it of list || []) {
|
|
37
|
-
if (it && it.family === 'IPv4' && !it.internal && it.address) {
|
|
38
|
-
out.push(it.address)
|
|
39
|
-
}
|
|
40
|
-
}
|
|
173
|
+
for (const entries of Object.values(networkInterfaces() ?? {})) {
|
|
174
|
+
for (const iface of entries ?? []) {
|
|
175
|
+
if (iface.family === 'IPv4' && !iface.internal) out.push(iface.address)
|
|
41
176
|
}
|
|
42
|
-
} catch (e) {
|
|
43
|
-
/* ignore */
|
|
44
177
|
}
|
|
45
|
-
|
|
46
|
-
return Array.from(new Set(out))
|
|
178
|
+
return out
|
|
47
179
|
}
|
|
48
180
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
const
|
|
52
|
-
const
|
|
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
|
+
}
|
|
53
199
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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) {
|
|
58
252
|
try {
|
|
59
|
-
if (
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
if (
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
+
}))
|
|
84
287
|
}
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
workspace: typeof process !== 'undefined' ? process.cwd() : '',
|
|
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
|
+
}))
|
|
98
300
|
}
|
|
99
301
|
}
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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 }))
|
|
106
309
|
}
|
|
107
310
|
}
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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
|
+
|
|
596
|
+
/** 当前 profile 的 cordis.patch.yml 绝对路径。 */
|
|
597
|
+
function patchFilePath(ctx) {
|
|
598
|
+
// DSH_HOME 环境变量优先,否则 ~/.dsh;profile 名为 DSH_PROFILE 或默认 web。
|
|
599
|
+
const home = process.env.DSH_HOME || join(homedir(), '.dsh')
|
|
600
|
+
const profile = process.env.DSH_PROFILE || 'web'
|
|
601
|
+
return join(home, 'profiles', profile, 'cordis.patch.yml')
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
/**
|
|
605
|
+
* 从 profile 的 cordis.patch.yml 中读取补丁列表(YAML 数组)。
|
|
606
|
+
* 不引入 yaml 依赖:远程控制仅需对 webserver 行做增删,采用精确文本匹配。
|
|
607
|
+
* @returns {{ file: string, content: string, lines: string[] }}
|
|
608
|
+
*/
|
|
609
|
+
function readPatchFile(ctx) {
|
|
610
|
+
const file = patchFilePath(ctx)
|
|
611
|
+
const content = existsSync(file) ? readFileSync(file, 'utf8') : ''
|
|
612
|
+
return { file, content, lines: content.split(/\r?\n/) }
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
/** webserver 远程控制覆盖块的行区间(含分隔注释)。不存在返回 null。 */
|
|
616
|
+
function findRemoteBlock(lines) {
|
|
617
|
+
const start = lines.findIndex((l) => l.includes('# --- dsh-mobile-remote managed'))
|
|
618
|
+
if (start === -1) return null
|
|
619
|
+
const end = lines.findIndex((l, i) => i > start && l.includes('# --- end dsh-mobile-remote managed'))
|
|
620
|
+
if (end === -1) return { start, end: lines.length }
|
|
621
|
+
return { start, end: end + 1 }
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
/** 检查当前 patch 中是否已启用远程控制。 */
|
|
625
|
+
function isPatchEnabled(ctx) {
|
|
626
|
+
const { lines } = readPatchFile(ctx)
|
|
627
|
+
const block = findRemoteBlock(lines)
|
|
628
|
+
if (!block) return false
|
|
629
|
+
return lines.slice(block.start, block.end).some((l) => l.includes("host: '0.0.0.0'"))
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
/** 追加 / 移除 webserver host 覆盖块。 */
|
|
633
|
+
function setPatchEnabled(ctx, enabled) {
|
|
634
|
+
const { file, content, lines } = readPatchFile(ctx)
|
|
635
|
+
const block = findRemoteBlock(lines)
|
|
636
|
+
let out = lines.slice()
|
|
637
|
+
|
|
638
|
+
if (block) {
|
|
639
|
+
out.splice(block.start, block.end - block.start)
|
|
640
|
+
}
|
|
641
|
+
if (enabled) {
|
|
642
|
+
const blockLines = [
|
|
643
|
+
'',
|
|
644
|
+
'# --- dsh-mobile-remote managed (auto-generated; do not edit) ---',
|
|
645
|
+
'- id: webserver',
|
|
646
|
+
' config:',
|
|
647
|
+
" host: '0.0.0.0'",
|
|
648
|
+
' port: !!js ctx.webStartup.port ?? ' + (ctx.webServer.port || 3080),
|
|
649
|
+
'# --- end dsh-mobile-remote managed ---',
|
|
650
|
+
'',
|
|
651
|
+
]
|
|
652
|
+
out = [...out, ...blockLines]
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
const next = out.join('\n').replace(/\n{3,}/g, '\n\n').replace(/\s*$/, '\n')
|
|
656
|
+
writeFileSync(file, next, 'utf8')
|
|
657
|
+
return { file, enabled }
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
/** GET /__dsh_remote/status */
|
|
661
|
+
function handleStatus(ctx, devices, external, tracker, dshCache) {
|
|
662
|
+
return async (req, res) => {
|
|
663
|
+
const lan = getLanAddresses()
|
|
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
|
+
}
|
|
704
|
+
json(res, 200, {
|
|
705
|
+
host: ctx.webServer.host,
|
|
706
|
+
port: ctx.webServer.port,
|
|
707
|
+
lanAddresses: lan,
|
|
708
|
+
lanInterfaces: getLanInterfaces(),
|
|
709
|
+
remoteEnabled,
|
|
710
|
+
patchEnabled: isPatchEnabled(ctx),
|
|
711
|
+
deviceCount: devices.size,
|
|
712
|
+
devices: deviceList,
|
|
713
|
+
patchFile: patchFilePath(ctx),
|
|
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,
|
|
141
745
|
},
|
|
746
|
+
// 远程访问密码门禁状态(供设置面板展示)
|
|
747
|
+
auth: {
|
|
748
|
+
enabled: isEnabled(),
|
|
749
|
+
requiredHere: isRemoteRequest(req) && isEnabled(),
|
|
750
|
+
authenticated: authorized(req),
|
|
751
|
+
},
|
|
752
|
+
cliHint: remoteEnabled
|
|
753
|
+
? null
|
|
754
|
+
: 'remote control is off; toggle it in settings or start dsh with --patch',
|
|
755
|
+
})
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
/** POST /__dsh_remote/toggle { enabled: boolean } */
|
|
760
|
+
function handleToggle(ctx) {
|
|
761
|
+
return async (req, res) => {
|
|
762
|
+
const body = await readBody(req)
|
|
763
|
+
const enabled = body.enabled === true
|
|
764
|
+
try {
|
|
765
|
+
const result = setPatchEnabled(ctx, enabled)
|
|
766
|
+
json(res, 200, {
|
|
767
|
+
ok: true,
|
|
768
|
+
enabled,
|
|
769
|
+
note: enabled
|
|
770
|
+
? 'patch written; dsh HMR will re-listen on 0.0.0.0'
|
|
771
|
+
: 'patch removed; dsh HMR will re-listen on 127.0.0.1',
|
|
772
|
+
patchFile: result.file,
|
|
773
|
+
})
|
|
774
|
+
} catch (error) {
|
|
775
|
+
json(res, 500, { ok: false, error: String(error?.message || error) })
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
/** POST /__dsh_remote/heartbeat(移动端上报的详细设备元数据,见下方 MOBILE_JS)。 */
|
|
781
|
+
function handleHeartbeat(devices, tracker) {
|
|
782
|
+
return async (req, res) => {
|
|
783
|
+
const body = await readBody(req)
|
|
784
|
+
const id = typeof body.id === 'string' && body.id ? body.id : 'anonymous'
|
|
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
|
+
})
|
|
817
|
+
json(res, 200, { ok: true, deviceCount: devices.size })
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
/** GET /__dsh_remote/qr?url=... */
|
|
822
|
+
function handleQr() {
|
|
823
|
+
return async (req, res) => {
|
|
824
|
+
const raw = new URL(req.url ?? '/', 'http://x').searchParams.get('url')
|
|
825
|
+
const url = raw && /^https?:\/\//i.test(raw) ? raw : null
|
|
826
|
+
if (!url) {
|
|
827
|
+
json(res, 400, { ok: false, error: 'missing or invalid url param' })
|
|
828
|
+
return
|
|
142
829
|
}
|
|
143
830
|
try {
|
|
144
|
-
const
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
831
|
+
const qr = qrcode(0, 'M')
|
|
832
|
+
qr.addData(url, 'Byte')
|
|
833
|
+
qr.make()
|
|
834
|
+
const svg = qr.createSvgTag({ cellSize: 4, margin: 8, scalable: true })
|
|
835
|
+
res.writeHead(200, { 'Content-Type': 'image/svg+xml; charset=utf-8' })
|
|
836
|
+
res.end(svg)
|
|
837
|
+
} catch (error) {
|
|
838
|
+
json(res, 500, { ok: false, error: String(error?.message || error) })
|
|
148
839
|
}
|
|
149
840
|
}
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
/* ------------------------------------------------------------------ */
|
|
844
|
+
/* 移动端呈现层补丁 */
|
|
845
|
+
/* ------------------------------------------------------------------ */
|
|
150
846
|
|
|
151
|
-
|
|
152
|
-
|
|
847
|
+
/**
|
|
848
|
+
* 把移动端 CSS/JS 注入到 index.html。优先注入到 </head> 之前,
|
|
849
|
+
* 找不到则退化为追加到文档末尾。
|
|
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 */ }
|
|
153
860
|
try {
|
|
154
|
-
const
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
},
|
|
161
|
-
output: {
|
|
162
|
-
schema: {
|
|
163
|
-
type: 'object',
|
|
164
|
-
additionalProperties: false,
|
|
165
|
-
required: ['ok'],
|
|
166
|
-
properties: {
|
|
167
|
-
ok: { type: 'boolean' },
|
|
168
|
-
enabled: { type: 'boolean' },
|
|
169
|
-
host: { type: 'string' },
|
|
170
|
-
port: { type: 'number' },
|
|
171
|
-
lanAddresses: { type: 'array' },
|
|
172
|
-
urls: { type: 'array' },
|
|
173
|
-
sessionCount: { type: 'number' },
|
|
174
|
-
serverTimeIso: { type: 'string' },
|
|
175
|
-
error: { type: 'string' },
|
|
176
|
-
},
|
|
177
|
-
},
|
|
178
|
-
render: (args, value) => {
|
|
179
|
-
if (!value || !value.ok) {
|
|
180
|
-
return [{ type: 'text', text: '[dsh-mobile-remote] 状态查询失败: ' + ((value && value.error) || '未知错误') }]
|
|
181
|
-
}
|
|
182
|
-
const lines = []
|
|
183
|
-
lines.push('[dsh-mobile-remote] 远程控制' + (value.enabled ? '已开启' : '未开启(需在 dsh-desktop 设置中打开「移动端远程控制」)'))
|
|
184
|
-
lines.push('监听地址: ' + value.host + ':' + value.port)
|
|
185
|
-
if (value.lanAddresses && value.lanAddresses.length) {
|
|
186
|
-
lines.push('局域网地址: ' + value.lanAddresses.join(' / '))
|
|
187
|
-
lines.push('手机访问: ' + ((value.urls || []).join(' 或 ')))
|
|
188
|
-
} else {
|
|
189
|
-
lines.push('局域网地址: 未检测到(请检查电脑网络连接)')
|
|
190
|
-
}
|
|
191
|
-
lines.push('当前会话数: ' + (value.sessionCount ?? '—'))
|
|
192
|
-
return [{ type: 'text', text: lines.join('\n') }]
|
|
193
|
-
},
|
|
194
|
-
},
|
|
195
|
-
async execute(args) {
|
|
196
|
-
return dispatchAction({ action: 'status' })
|
|
197
|
-
},
|
|
198
|
-
})
|
|
199
|
-
ctx.effect(() => disposeTool, 'dsh-mobile-remote: mobile_remote_status tool')
|
|
200
|
-
} catch (e) {
|
|
201
|
-
console.error('[dsh-mobile-remote] mobile_remote_status 工具注册失败:', (e && e.message) || e)
|
|
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 })
|
|
202
867
|
}
|
|
203
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
|
+
}
|
|
890
|
+
function injectIntoIndex(html, remoteEnabled) {
|
|
891
|
+
const bootConfig =
|
|
892
|
+
'<script id="dsh-mobile-remote-config">window.__DSH_MOBILE_REMOTE__ = ' +
|
|
893
|
+
JSON.stringify({ lanEnabled: remoteEnabled, mobileMaxWidth: MOBILE_MAX_WIDTH }) +
|
|
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>`
|
|
897
|
+
const style = `<style id="dsh-mobile-remote-css">${MOBILE_CSS}</style>`
|
|
898
|
+
const script = `<script id="dsh-mobile-remote-js">${MOBILE_JS}</script>`
|
|
899
|
+
// 门禁脚本注入在 head 最前,确保在 DSH 界面渲染前就绪
|
|
900
|
+
const injection = bootConfig + gateStyle + gateScript + style + script
|
|
901
|
+
|
|
902
|
+
if (html.includes('</head>')) {
|
|
903
|
+
return html.replace('</head>', `${injection}\n</head>`)
|
|
904
|
+
}
|
|
905
|
+
return html + injection
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
/* ------------------------------------------------------------------ */
|
|
909
|
+
/* 远程访问密码门禁:样式 + 前端脚本 */
|
|
910
|
+
/* 作用:当本次访问是“远程”(外网隧道 / HTTPS 反代)且管理员已设置密码时, */
|
|
911
|
+
/* 在 DSH 界面加载前弹出全屏密码验证遮罩。输对密码后种会话 cookie 并重载。 */
|
|
912
|
+
/* 不输密码无法操作 DSH(遮罩拦截所有交互)。 */
|
|
913
|
+
/* ------------------------------------------------------------------ */
|
|
204
914
|
|
|
205
|
-
|
|
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
|
+
|
|
1035
|
+
const MOBILE_CSS = `
|
|
1036
|
+
/* === dsh-mobile-remote 移动端输入栏补丁 ===
|
|
1037
|
+
* 作用范围:仅在移动设备(<1024px)下生效。DSH 原生已经在 <1024px 时把侧边栏
|
|
1038
|
+
* 折叠为 rail 并提供原生汉堡按钮,本补丁不碰那一套,只修 composer。
|
|
1039
|
+
*
|
|
1040
|
+
* 选择器结构(packages/client/ui-conversation/src/client/skeleton/InputBar.tsx):
|
|
1041
|
+
* [data-composer-card] ← composer 卡片根
|
|
1042
|
+
* ├ [data-input-scroll] ← 文本输入滚动区
|
|
1043
|
+
* └ div:last-child ← 工具行 .row
|
|
1044
|
+
* ├ div:first-child ← .tools(+ 按钮、访问模式、安全模式 select)
|
|
1045
|
+
* └ div:last-child ← .trailing(模型 select、上下文计量、发送按钮)
|
|
1046
|
+
*/
|
|
1047
|
+
|
|
1048
|
+
html[data-dsh-mobile] {
|
|
1049
|
+
-webkit-text-size-adjust: 100%;
|
|
1050
|
+
text-size-adjust: 100%;
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
html[data-dsh-mobile] body {
|
|
1054
|
+
-webkit-tap-highlight-color: transparent;
|
|
1055
|
+
touch-action: manipulation;
|
|
1056
|
+
overscroll-behavior-y: contain;
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
/* ----- 输入栏工具行:窄屏下允许换行,避免左右两侧的选择器互相挤压粘连 ----- */
|
|
1060
|
+
html[data-dsh-mobile] [data-composer-card] > div:last-child {
|
|
1061
|
+
flex-wrap: wrap;
|
|
1062
|
+
row-gap: 6px;
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
/* 左半区(+ 按钮 + 访问模式 + 安全模式)可收缩 */
|
|
1066
|
+
html[data-dsh-mobile] [data-composer-card] > div:last-child > div:first-child {
|
|
1067
|
+
flex: 1 1 auto;
|
|
1068
|
+
min-width: 0;
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
/* 右半区(模型选择 + 上下文 + 发送)换到第二行右对齐,与左半区彻底分开 */
|
|
1072
|
+
html[data-dsh-mobile] [data-composer-card] > div:last-child > div:last-child {
|
|
1073
|
+
flex: none !important;
|
|
1074
|
+
width: 100% !important;
|
|
1075
|
+
justify-content: flex-end !important;
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
/* 工具行内的所有 <select>(选择模型 / 选择安全模式等)限宽防溢出 + 加大触控目标 */
|
|
1079
|
+
html[data-dsh-mobile] [data-composer-card] select {
|
|
1080
|
+
max-width: min(46vw, 200px) !important;
|
|
1081
|
+
min-height: 36px;
|
|
1082
|
+
font-size: 14px;
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
/* 工具行内的按钮加大触控目标(44px 苹果 HIG 推荐) */
|
|
1086
|
+
html[data-dsh-mobile] [data-composer-card] > div:last-child button {
|
|
1087
|
+
min-width: 40px;
|
|
1088
|
+
min-height: 40px;
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
/* ----- iOS 触屏优化:输入框字号 >= 16px 防止聚焦自动缩放 ----- */
|
|
1092
|
+
html[data-dsh-mobile] textarea,
|
|
1093
|
+
html[data-dsh-mobile] input[type='text'],
|
|
1094
|
+
html[data-dsh-mobile] input[type='search'] {
|
|
1095
|
+
font-size: 16px !important;
|
|
1096
|
+
}
|
|
1097
|
+
`
|
|
1098
|
+
|
|
1099
|
+
/* ------------------------------------------------------------------ */
|
|
1100
|
+
/* 移动端设备检测 + 心跳上报:仅设置 html[data-dsh-mobile] 标记,并定时向 */
|
|
1101
|
+
/* host 上报设备在线状态(供设置页“当前连接的设备数量”统计)。 */
|
|
1102
|
+
/* 不注入任何 UI、不修改任何 React 状态、不动任何 DOM 节点。 */
|
|
1103
|
+
/* ------------------------------------------------------------------ */
|
|
1104
|
+
|
|
1105
|
+
const MOBILE_JS = `
|
|
1106
|
+
(function () {
|
|
1107
|
+
'use strict';
|
|
1108
|
+
|
|
1109
|
+
var cfg = window.__DSH_MOBILE_REMOTE__ || { lanEnabled: false, mobileMaxWidth: 1024 };
|
|
1110
|
+
var MAX_WIDTH = cfg.mobileMaxWidth || 1024;
|
|
1111
|
+
|
|
1112
|
+
/* 设备检测:移动 UA,或触屏 + 窄视口。与 DSH 原生 SIDEBAR_AUTO_COLLAPSE 同界。 */
|
|
1113
|
+
function isMobile() {
|
|
1114
|
+
var ua = navigator.userAgent || '';
|
|
1115
|
+
var mobileUA = /Android|iPhone|iPad|iPod|webOS|BlackBerry|IEMobile|Opera Mini|Mobile/i.test(ua);
|
|
1116
|
+
var touch = ('ontouchstart' in window) || (navigator.maxTouchPoints > 0);
|
|
1117
|
+
var narrow = Math.min(window.innerWidth, (window.screen && window.screen.width) || Infinity) <= MAX_WIDTH;
|
|
1118
|
+
return mobileUA || (touch && narrow);
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
// 设备心跳:稳定标识 + 30s 间隔上报,host 统计并展示"已连接设备"。
|
|
1122
|
+
// 所有访问本 DSH 的浏览器(PC + 手机)都上报,仅移动端才打 data-dsh-mobile 标记。
|
|
206
1123
|
try {
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
1124
|
+
var KEY = '__dsh_mobile_remote_device';
|
|
1125
|
+
var id = localStorage.getItem(KEY);
|
|
1126
|
+
if (!id) {
|
|
1127
|
+
id = 'dev-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 10);
|
|
1128
|
+
localStorage.setItem(KEY, id);
|
|
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 () {});
|
|
216
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
|
+
};
|
|
217
1174
|
}
|
|
218
|
-
|
|
219
|
-
|
|
1175
|
+
function beat() {
|
|
1176
|
+
try {
|
|
1177
|
+
fetch('/__dsh_remote/heartbeat', {
|
|
1178
|
+
method: 'POST',
|
|
1179
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1180
|
+
body: JSON.stringify(deviceMeta()),
|
|
1181
|
+
keepalive: true,
|
|
1182
|
+
}).catch(function () {});
|
|
1183
|
+
} catch (e) {}
|
|
1184
|
+
}
|
|
1185
|
+
beat();
|
|
1186
|
+
setInterval(beat, 30000);
|
|
1187
|
+
} catch (e) {}
|
|
1188
|
+
|
|
1189
|
+
if (!isMobile()) return;
|
|
1190
|
+
|
|
1191
|
+
document.documentElement.setAttribute('data-dsh-mobile', '');
|
|
1192
|
+
|
|
1193
|
+
// 调试钩子:在控制台暴露状态,便于移动端远程排查。
|
|
1194
|
+
window.__DSH_MOBILE_REMOTE_READY__ = {
|
|
1195
|
+
mobile: true,
|
|
1196
|
+
viewport: { w: window.innerWidth, h: window.innerHeight },
|
|
1197
|
+
dpr: window.devicePixelRatio || 1,
|
|
1198
|
+
lanEnabled: cfg.lanEnabled === true,
|
|
1199
|
+
};
|
|
1200
|
+
})();
|
|
1201
|
+
`
|
|
220
1202
|
|
|
221
|
-
export
|
|
1203
|
+
// 注意:不要添加 `export default apply`。cordis 的 Loader.unwrapExports 会优先取
|
|
1204
|
+
// default 导出,若 default 是 apply 函数,会丢失 `name` / `inject` 命名导出
|
|
1205
|
+
// (详见 vendor/loader/src/index.ts 的 unwrapExports 与 vendor/cordis/src/registry.ts
|
|
1206
|
+
// 的 plugin())。保持仅命名导出,loader 才能拿到完整的 { name, inject, apply }。
|