@feiyang666/dsh-mobile-remote 1.7.1-1 → 1.8.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 +80 -2
- package/README.md +3 -1
- package/README.zh.md +3 -1
- package/RELEASE_NOTES.md +56 -13
- package/lib/client.js +37 -9
- package/lib/external.js +40 -38
- package/lib/index.js +459 -95
- package/lib/patch.js +244 -0
- package/lib/ws-client.js +4 -1
- package/package.json +3 -3
package/lib/index.js
CHANGED
|
@@ -33,6 +33,7 @@ import {
|
|
|
33
33
|
existsSync, readFileSync, writeFileSync, unlinkSync, mkdirSync, rmSync,
|
|
34
34
|
} from 'node:fs'
|
|
35
35
|
import { spawn } from 'node:child_process'
|
|
36
|
+
import http from 'node:http'
|
|
36
37
|
import qrcode from './qrcode.js'
|
|
37
38
|
import {
|
|
38
39
|
startExternal, stopExternal, externalView,
|
|
@@ -44,6 +45,10 @@ import {
|
|
|
44
45
|
isEnabled, login, verifyToken, revokeToken, clearSessions,
|
|
45
46
|
} from './password.js'
|
|
46
47
|
import { wsAvailable } from './ws-client.js'
|
|
48
|
+
import {
|
|
49
|
+
patchFilePath, isManagedPatchEnabled,
|
|
50
|
+
collectTrustedAuthorities, writeManagedPatch,
|
|
51
|
+
} from './patch.js'
|
|
47
52
|
|
|
48
53
|
/** 插件名(loader 诊断用)。 */
|
|
49
54
|
export const name = 'dsh-mobile-remote'
|
|
@@ -51,6 +56,170 @@ export const name = 'dsh-mobile-remote'
|
|
|
51
56
|
/** 依赖 dsh-host-webserver 注入的 webServer 服务,保证 tapIndex / register 可用。 */
|
|
52
57
|
export const inject = ['webServer']
|
|
53
58
|
|
|
59
|
+
// ------------------------------------------------------------
|
|
60
|
+
// 新版 dsh(>=0.1.2-rc.1)Web UI 浏览器会话认证适配
|
|
61
|
+
// 新版 dsh 每次启动生成一次性令牌,任何浏览器访问首页都必须先带
|
|
62
|
+
// `?token=`(服务端 303 + Set-Cookie 后跳回 /),否则返回 401。手机扫码 /
|
|
63
|
+
// 手动输入 LAN 地址访问的是同一个 dsh Web UI,同样受该认证保护。
|
|
64
|
+
// 因此本插件在返回「手机访问地址 / 二维码 URL / 外网 URL」时,若宿主提供了
|
|
65
|
+
// 官方 connection 服务(dsh-client-connection 的 BrowserAuth),就用其
|
|
66
|
+
// authenticatedUrl() 自动附加 token;老版 dsh(无 connection / 无认证)时
|
|
67
|
+
// 返回原 URL,行为与旧版完全一致。
|
|
68
|
+
// ------------------------------------------------------------
|
|
69
|
+
/**
|
|
70
|
+
* 用宿主 connection 服务的 authenticatedUrl() 给访问 URL 附加认证 token。
|
|
71
|
+
* 老版 dsh(无 connection / 未提供该方法)时原样返回,兼容旧版与命令行裸启。
|
|
72
|
+
* @param {import('@deepseek-ai/cordis').Context} ctx
|
|
73
|
+
* @param {string} base - 形如 http://192.168.1.5:3080 的裸访问地址
|
|
74
|
+
* @returns {string} 带 token 的完整访问地址(或原地址)
|
|
75
|
+
*/
|
|
76
|
+
function resolveConnection(ctx) {
|
|
77
|
+
if (!ctx) return undefined
|
|
78
|
+
// 1) cordis:ctx.get(name) 默认 strict=true,只返回「provider fiber 已激活」的服务。
|
|
79
|
+
// 插件 fiber 未 inject connection 时可能取不到,故补非严格与属性两条回退。
|
|
80
|
+
if (typeof ctx.get === 'function') {
|
|
81
|
+
try {
|
|
82
|
+
const strict = ctx.get('connection')
|
|
83
|
+
if (strict) return strict
|
|
84
|
+
} catch (e) { /* 继续回退 */ }
|
|
85
|
+
try {
|
|
86
|
+
const loose = ctx.get('connection', false)
|
|
87
|
+
if (loose) return loose
|
|
88
|
+
} catch (e) { /* 继续回退 */ }
|
|
89
|
+
}
|
|
90
|
+
// 2) 少数宿主把服务直接挂在 ctx 上(ctx.connection 在未 inject 时可能抛错)。
|
|
91
|
+
try {
|
|
92
|
+
if (ctx.connection) return ctx.connection
|
|
93
|
+
} catch (e) { /* 忽略 */ }
|
|
94
|
+
return undefined
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function withAuthUrl(ctx, base) {
|
|
98
|
+
if (!base || typeof base !== 'string') return base
|
|
99
|
+
try {
|
|
100
|
+
const conn = resolveConnection(ctx)
|
|
101
|
+
if (conn && typeof conn.authenticatedUrl === 'function') {
|
|
102
|
+
return conn.authenticatedUrl(base)
|
|
103
|
+
}
|
|
104
|
+
} catch (e) { /* 取 connection 失败则按无认证处理,返回原 URL */ }
|
|
105
|
+
return base
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* 把「带 token 的绝对地址」降级成 origin 相对的 `/?token=…`。
|
|
110
|
+
* 浏览器按当前协议 + authority 解析,因此 http / https / 反代路径前缀都能正确工作,
|
|
111
|
+
* 也避开了反代跨协议 301 丢 token 的问题。
|
|
112
|
+
* @param {string|null} absUrl - authenticatedUrl() 的返回值
|
|
113
|
+
* @returns {string|null} 形如 `/?token=xxx` 的相对地址;拿不到 token 时返回 null
|
|
114
|
+
*/
|
|
115
|
+
function relativeTokenLocation(absUrl) {
|
|
116
|
+
if (!absUrl || typeof absUrl !== 'string') return null
|
|
117
|
+
try {
|
|
118
|
+
const u = new URL(absUrl)
|
|
119
|
+
// 直接透传原始 pathname + search,不解析、不重新编码 token:
|
|
120
|
+
// token 是 base64url,经 searchParams 往返会引入 +/空格 之类的编码歧义。
|
|
121
|
+
if (!u.search || !/[?&]token=/.test(u.search)) return null
|
|
122
|
+
const path = u.pathname && u.pathname !== '' ? u.pathname : '/'
|
|
123
|
+
return `${path}${u.search}`
|
|
124
|
+
} catch (e) {
|
|
125
|
+
return null
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* 判断该请求对浏览器而言的真实协议。
|
|
132
|
+
* 外网访问通常经 nginx / frp 反代,本机看到的永远是明文 HTTP,
|
|
133
|
+
* 因此必须优先信任反代设置的 X-Forwarded-Proto,否则会生成 http:// 跳转,
|
|
134
|
+
* 被反代再 301 回 https,形成协议来回跳转。
|
|
135
|
+
* @param {import('node:http').IncomingMessage} req
|
|
136
|
+
* @returns {'http'|'https'}
|
|
137
|
+
*/
|
|
138
|
+
function requestScheme(req) {
|
|
139
|
+
try {
|
|
140
|
+
const xf = req.headers['x-forwarded-proto']
|
|
141
|
+
if (typeof xf === 'string' && xf.trim()) {
|
|
142
|
+
// 可能是 "https, http" 这样的列表,取最外层(第一个)。
|
|
143
|
+
const first = xf.split(',')[0].trim().toLowerCase()
|
|
144
|
+
if (first === 'https' || first === 'http') return first
|
|
145
|
+
}
|
|
146
|
+
// 直连时按 socket 是否 TLS 判断(本插件自身不终结 TLS,通常为 http)。
|
|
147
|
+
if (req.socket && req.socket.encrypted) return 'https'
|
|
148
|
+
} catch (e) { /* 忽略,回落 http */ }
|
|
149
|
+
return 'http'
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* 取宿主的浏览器会话认证器(connection.browserAuth)。
|
|
154
|
+
* 新版 dsh(>=0.1.2)由它完成 token → cookie 的官方握手;
|
|
155
|
+
* HostConnectionService 自身即 BrowserAuth 的委托层,字段不可见时可直接复用。
|
|
156
|
+
* @param {import('@deepseek-ai/cordis').Context} ctx
|
|
157
|
+
* @returns {object|undefined}
|
|
158
|
+
*/
|
|
159
|
+
function resolveBrowserAuth(ctx) {
|
|
160
|
+
const conn = resolveConnection(ctx)
|
|
161
|
+
if (!conn) return undefined
|
|
162
|
+
if (conn.browserAuth) return conn.browserAuth
|
|
163
|
+
if (typeof conn.authorizeIndex === 'function' && typeof conn.authenticatedUrl === 'function') return conn
|
|
164
|
+
return undefined
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// ------------------------------------------------------------
|
|
168
|
+
// 裸地址直达(自动登录)
|
|
169
|
+
// 手机 / 远程设备直接访问裸地址(http://192.168.x.x:3080,不带 token)时,
|
|
170
|
+
// 官方 dsh 会返回 401 纯文本提示。本插件接管 GET /:
|
|
171
|
+
// · 未授权 → 302 到带 token 的 URL(服务端自动完成官方 303 + Set-Cookie)
|
|
172
|
+
// · 已授权 → 返回官方 index(经内部请求取回,含全部 index taps 注入)
|
|
173
|
+
// 官方 token → cookie 的握手完整保留,只是把「手动重开带 token 链接」自动化。
|
|
174
|
+
// 内部拉取时需临时注销本插件的 / 路由,让请求直达官方 index owner。
|
|
175
|
+
// ------------------------------------------------------------
|
|
176
|
+
|
|
177
|
+
/** 简易 HTTP GET:收集 cookie、跟随 3xx 重定向(最多 4 跳),返回最终响应。 */
|
|
178
|
+
function httpFollow(startUrl, maxRedirects = 4) {
|
|
179
|
+
return new Promise((resolve) => {
|
|
180
|
+
const jar = new Map()
|
|
181
|
+
let current = startUrl
|
|
182
|
+
let hops = 0
|
|
183
|
+
const step = () => {
|
|
184
|
+
if (hops++ > maxRedirects) return resolve(null)
|
|
185
|
+
let u
|
|
186
|
+
try { u = new URL(current) } catch { return resolve(null) }
|
|
187
|
+
const headers = {}
|
|
188
|
+
if (jar.size) headers.cookie = [...jar.entries()].map(([k, v]) => `${k}=${v}`).join('; ')
|
|
189
|
+
const req = http.request({
|
|
190
|
+
host: u.hostname, port: Number(u.port) || 80,
|
|
191
|
+
path: u.pathname + (u.search || ''), method: 'GET', headers, timeout: 8000,
|
|
192
|
+
}, (res) => {
|
|
193
|
+
for (const sc of res.headers['set-cookie'] || []) {
|
|
194
|
+
const kv = String(sc).split(';')[0]
|
|
195
|
+
const at = kv.indexOf('=')
|
|
196
|
+
if (at > 0) jar.set(kv.slice(0, at).trim(), kv.slice(at + 1).trim())
|
|
197
|
+
}
|
|
198
|
+
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
|
|
199
|
+
res.resume()
|
|
200
|
+
try { current = new URL(res.headers.location, current).href } catch { return resolve(null) }
|
|
201
|
+
return step()
|
|
202
|
+
}
|
|
203
|
+
const chunks = []
|
|
204
|
+
res.on('data', (c) => chunks.push(c))
|
|
205
|
+
res.on('end', () => {
|
|
206
|
+
if (res.statusCode !== 200) return resolve(null)
|
|
207
|
+
const keep = {}
|
|
208
|
+
for (const h of ['content-type', 'cache-control', 'content-security-policy', 'referrer-policy', 'x-content-type-options']) {
|
|
209
|
+
if (res.headers[h]) keep[h] = res.headers[h]
|
|
210
|
+
}
|
|
211
|
+
resolve({ status: 200, headers: keep, body: Buffer.concat(chunks) })
|
|
212
|
+
})
|
|
213
|
+
res.on('error', () => resolve(null))
|
|
214
|
+
})
|
|
215
|
+
req.on('timeout', () => { req.destroy(); resolve(null) })
|
|
216
|
+
req.on('error', () => resolve(null))
|
|
217
|
+
req.end()
|
|
218
|
+
}
|
|
219
|
+
step()
|
|
220
|
+
})
|
|
221
|
+
}
|
|
222
|
+
|
|
54
223
|
/** 移动端断点:与 DSH 原生 SIDEBAR_AUTO_COLLAPSE = 1024 对齐。 */
|
|
55
224
|
const MOBILE_MAX_WIDTH = 1024
|
|
56
225
|
|
|
@@ -128,6 +297,113 @@ export function apply(ctx) {
|
|
|
128
297
|
// —— Service Worker:远程访问首屏缓存加速(仅 HTTPS 外网生效)——
|
|
129
298
|
ctx.webServer.register({ kind: 'exact', path: '/__dsh_remote/sw.js', handler: handleServiceWorker() }),
|
|
130
299
|
]
|
|
300
|
+
|
|
301
|
+
// —— 裸地址直达(自动登录):接管 GET / ——
|
|
302
|
+
// 未授权访问裸地址时 302 到带 token 的 URL(浏览器自动完成官方 303 + Set-Cookie),
|
|
303
|
+
// 已授权时返回官方 index。官方 token→cookie 握手完整保留。
|
|
304
|
+
let rootDisposed = false
|
|
305
|
+
let rootRouteDisposer = null
|
|
306
|
+
let rootIndexCache = null // { headers, body } 官方最终 index(含全部 index taps 注入)
|
|
307
|
+
let rootIndexJob = null
|
|
308
|
+
|
|
309
|
+
const registerRootRoute = () => {
|
|
310
|
+
if (rootDisposed || rootRouteDisposer) return
|
|
311
|
+
rootRouteDisposer = ctx.webServer.register({ kind: 'exact', path: '/', handler: rootHandler })
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// 内部拉取官方 index:临时注销本插件的 / 路由,让请求直达官方 index owner
|
|
315
|
+
// (frontend-static,经完整授权检查与全部 taps),取回最终 HTML 后缓存并重新注册。
|
|
316
|
+
// 关键:必须用「带 token 的本机地址」拉取;若宿主启用浏览器会话认证而此处拿不到
|
|
317
|
+
// token,官方会回 401,则 rootIndexCache 保持 null,由调用方回退到官方 index owner。
|
|
318
|
+
const ensureRootIndex = () => {
|
|
319
|
+
if (rootIndexCache) return Promise.resolve(rootIndexCache)
|
|
320
|
+
if (!rootIndexJob) {
|
|
321
|
+
rootIndexJob = (async () => {
|
|
322
|
+
if (rootRouteDisposer) { try { rootRouteDisposer() } catch { /* ignore */ } rootRouteDisposer = null }
|
|
323
|
+
try {
|
|
324
|
+
await new Promise((r) => setTimeout(r, 30))
|
|
325
|
+
const p = port || 3080
|
|
326
|
+
const tokenUrl = withAuthUrl(ctx, `http://127.0.0.1:${p}/`)
|
|
327
|
+
const same = tokenUrl === `http://127.0.0.1:${p}/`
|
|
328
|
+
rootIndexCache = await httpFollow(tokenUrl)
|
|
329
|
+
// 无 token 可用且官方回 401:说明宿主强制认证但本上下文取不到 browserAuth。
|
|
330
|
+
// 绝不能把空结果当成可用 index,交由调用方走官方 index owner。
|
|
331
|
+
if (same && rootIndexCache === null) {
|
|
332
|
+
try { ctx.logger?.warn?.('[dsh-mobile-remote] 无法获取浏览器会话 token:首页自动登录不可用,已交回官方认证') } catch { /* ignore */ }
|
|
333
|
+
}
|
|
334
|
+
} catch { /* 保持 null,由调用方回退 */ }
|
|
335
|
+
registerRootRoute()
|
|
336
|
+
rootIndexJob = null
|
|
337
|
+
return rootIndexCache
|
|
338
|
+
})()
|
|
339
|
+
}
|
|
340
|
+
return rootIndexJob
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
async function handleRootRequest(req, res) {
|
|
344
|
+
const conn = resolveConnection(ctx)
|
|
345
|
+
const ba = resolveBrowserAuth(ctx)
|
|
346
|
+
let url
|
|
347
|
+
try { url = new URL(req.url ?? '/', 'http://x') } catch { url = new URL('/', 'http://x') }
|
|
348
|
+
if (ba) {
|
|
349
|
+
if (url.searchParams.has('token') && typeof ba.authorizeIndex === 'function') {
|
|
350
|
+
// 带 token:走官方握手(匹配 → 303 + Set-Cookie;无效 → 官方 401),浏览器自动跟随
|
|
351
|
+
const ok = ba.authorizeIndex(req, res)
|
|
352
|
+
if (!ok) return
|
|
353
|
+
// ok===true:该请求已可授权,继续 serve index
|
|
354
|
+
} else if (typeof ba.isAuthenticated === 'function' && !ba.isAuthenticated(req)) {
|
|
355
|
+
// 未授权的裸地址访问:自动登录——302 到带 token 的地址,
|
|
356
|
+
// 浏览器自动完成官方 303 + Set-Cookie 后回到裸 /,全程无感。
|
|
357
|
+
const host = req.headers.host || `127.0.0.1:${port || 3080}`
|
|
358
|
+
const scheme = requestScheme(req)
|
|
359
|
+
const target = typeof conn.authenticatedUrl === 'function'
|
|
360
|
+
? conn.authenticatedUrl(`${scheme}://${host}/`)
|
|
361
|
+
: (typeof ba.authenticatedUrl === 'function' ? ba.authenticatedUrl(`${scheme}://${host}/`) : null)
|
|
362
|
+
// 关键:跳转地址一律降级为「相对地址」(只保留 ?token=…)。
|
|
363
|
+
// 原因是外网访问经 nginx / frp 反代时,本进程只看到明文 HTTP,
|
|
364
|
+
// 无法可靠推断浏览器侧的协议:若发出 http:// 绝对地址,反代会再
|
|
365
|
+
// 301 回 https,且 token 在这一次跨协议跳转中被丢掉,握手永远完不成
|
|
366
|
+
// (表现为外网打不开、/api 一直 401)。相对地址由浏览器按当前
|
|
367
|
+
// 协议与 authority 自行解析,http/https/反代前缀全都正确。
|
|
368
|
+
const relative = relativeTokenLocation(target)
|
|
369
|
+
if (relative) {
|
|
370
|
+
res.writeHead(302, { location: relative, 'cache-control': 'no-store' })
|
|
371
|
+
res.end()
|
|
372
|
+
return
|
|
373
|
+
}
|
|
374
|
+
// 取不到 token:不能回 302 到自身(会导致浏览器无限重定向循环),
|
|
375
|
+
// 明确回 401,交回官方语义。
|
|
376
|
+
res.writeHead(401, { 'content-type': 'text/plain; charset=utf-8', 'cache-control': 'no-store' })
|
|
377
|
+
res.end('dsh web authentication required; reopen the URL printed by dsh web.\n')
|
|
378
|
+
return
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
// 已授权 / 老版 dsh 无认证 / 取不到 browserAuth:交回官方 index owner。
|
|
382
|
+
// token 可用时 ensureRootIndex 会带回完整 index;否则保持 null,由下方回退。
|
|
383
|
+
const idx = await ensureRootIndex()
|
|
384
|
+
if (idx) {
|
|
385
|
+
res.writeHead(200, idx.headers)
|
|
386
|
+
res.end(idx.body)
|
|
387
|
+
return
|
|
388
|
+
}
|
|
389
|
+
if (ba) {
|
|
390
|
+
// 已确证存在认证器却仍拿不到 index:说明确实需要认证,明确回 401,
|
|
391
|
+
// 而不是把 503「启动中」这种误导性状态返回给手机端。
|
|
392
|
+
res.writeHead(401, { 'content-type': 'text/plain; charset=utf-8', 'cache-control': 'no-store' })
|
|
393
|
+
res.end('dsh web authentication required; reopen the URL printed by dsh web.\n')
|
|
394
|
+
return
|
|
395
|
+
}
|
|
396
|
+
res.writeHead(503, { 'content-type': 'text/plain; charset=utf-8' })
|
|
397
|
+
res.end('dsh web is starting, please try again in a moment.')
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
const rootHandler = (req, res) => {
|
|
401
|
+
void handleRootRequest(req, res).catch(() => {
|
|
402
|
+
try { if (!res.headersSent) { res.writeHead(500); res.end() } } catch { /* ignore */ }
|
|
403
|
+
})
|
|
404
|
+
}
|
|
405
|
+
registerRootRoute()
|
|
406
|
+
|
|
131
407
|
const timer = setInterval(() => {
|
|
132
408
|
const now = Date.now()
|
|
133
409
|
for (const [id, info] of devices) {
|
|
@@ -140,11 +416,40 @@ export function apply(ctx) {
|
|
|
140
416
|
const patchFile = patchFilePath(ctx)
|
|
141
417
|
void autoRestoreExternal(external, patchFile, ctx.webServer.port || 3080)
|
|
142
418
|
}
|
|
419
|
+
|
|
420
|
+
// ---- 端口自适应 ----
|
|
421
|
+
// frpc 代理的 localPort 必须始终等于当前真实监听端口,否则流量会打到无人监听
|
|
422
|
+
// 的端口上,表现为外网域名 502 Bad Gateway(frpc 日志:connect to local
|
|
423
|
+
// service ... refused)。端口可能因 --port 变更、被占用后回退到 0(OS 分配)、
|
|
424
|
+
// 或 profile patch 改动而变化,因此这里持续跟随,变化即重新绑定隧道。
|
|
425
|
+
let lastKnownPort = ctx.webServer.port || 0
|
|
426
|
+
const portWatcher = setInterval(() => {
|
|
427
|
+
const now = ctx.webServer.port || 0
|
|
428
|
+
if (!now || now === lastKnownPort) return
|
|
429
|
+
const from = lastKnownPort
|
|
430
|
+
lastKnownPort = now
|
|
431
|
+
const persisted = loadPersistedState()
|
|
432
|
+
// 仅在隧道确实在跑、且有绑定码可复用时才自动重绑(避免无谓打中转服务器)。
|
|
433
|
+
if (external.proc && external.proc.exitCode === null && persisted && persisted.bindCode) {
|
|
434
|
+
void startExternal(external, {
|
|
435
|
+
bindCode: persisted.bindCode,
|
|
436
|
+
serverBase: persisted.serverBase || undefined,
|
|
437
|
+
}, patchFilePath(ctx), now).catch(() => { /* 状态由 startExternal 内部记录 */ })
|
|
438
|
+
try {
|
|
439
|
+
ctx.logger?.info?.(`[dsh-mobile-remote] 监听端口 ${from} → ${now},外网隧道已自适应重绑`)
|
|
440
|
+
} catch { /* ignore */ }
|
|
441
|
+
}
|
|
442
|
+
}, 15_000)
|
|
443
|
+
|
|
143
444
|
return () => {
|
|
144
445
|
clearInterval(timer)
|
|
145
446
|
clearInterval(dshTimer)
|
|
447
|
+
clearInterval(portWatcher)
|
|
146
448
|
stopFrpc(external)
|
|
147
449
|
for (const dispose of disposers) dispose()
|
|
450
|
+
// 裸地址直达(/ 接管)路由的清理
|
|
451
|
+
rootDisposed = true
|
|
452
|
+
if (rootRouteDisposer) { try { rootRouteDisposer() } catch { /* ignore */ } rootRouteDisposer = null }
|
|
148
453
|
}
|
|
149
454
|
}, 'dsh-mobile-remote: api routes')
|
|
150
455
|
}
|
|
@@ -449,7 +754,9 @@ function handleEvents() {
|
|
|
449
754
|
}
|
|
450
755
|
}
|
|
451
756
|
|
|
452
|
-
/** GET /__dsh_remote/auth-status:返回远程访问密码的启用状态与本次请求是否需鉴权。
|
|
757
|
+
/** GET /__dsh_remote/auth-status:返回远程访问密码的启用状态与本次请求是否需鉴权。
|
|
758
|
+
* 额外返回 service 摘要(服务名 / 本机主机名 / dsh 版本 / 当前访问域名),
|
|
759
|
+
* 供密码门禁界面展示"当前是谁在提供服务"等更多信息。 */
|
|
453
760
|
function handleAuthStatus() {
|
|
454
761
|
return async (req, res) => {
|
|
455
762
|
json(res, 200, {
|
|
@@ -459,6 +766,13 @@ function handleAuthStatus() {
|
|
|
459
766
|
required: isRemoteRequest(req) && isEnabled(),
|
|
460
767
|
authenticated: authorized(req),
|
|
461
768
|
remote: isRemoteRequest(req),
|
|
769
|
+
// 服务信息:密码界面上展示("服务者"以本机主机名标识)
|
|
770
|
+
service: {
|
|
771
|
+
name: 'DeepSeek Harness',
|
|
772
|
+
hostname: hostname(),
|
|
773
|
+
version: detectDshVersion() || '',
|
|
774
|
+
host: String(req.headers.host || ''),
|
|
775
|
+
},
|
|
462
776
|
})
|
|
463
777
|
}
|
|
464
778
|
}
|
|
@@ -497,8 +811,10 @@ function handleLogout() {
|
|
|
497
811
|
}
|
|
498
812
|
|
|
499
813
|
/**
|
|
500
|
-
* POST /__dsh_remote/set-password { password }(仅本机/内网来源可用):
|
|
501
|
-
*
|
|
814
|
+
* POST /__dsh_remote/set-password { password, currentPassword }(仅本机/内网来源可用):
|
|
815
|
+
* 设置或修改远程访问密码。远程来源被拒绝,防止外部任意篡改。
|
|
816
|
+
* 安全要求:已设置过密码时,修改或清除都必须携带 currentPassword 并验证通过,
|
|
817
|
+
* 防止"改密码/清密码"接口被无鉴权滥用。
|
|
502
818
|
*/
|
|
503
819
|
function handleSetPassword() {
|
|
504
820
|
return async (req, res) => {
|
|
@@ -508,6 +824,14 @@ function handleSetPassword() {
|
|
|
508
824
|
}
|
|
509
825
|
const body = await readBody(req)
|
|
510
826
|
const password = typeof body.password === 'string' ? body.password : ''
|
|
827
|
+
const current = typeof body.currentPassword === 'string' ? body.currentPassword : ''
|
|
828
|
+
// 已设置密码时:修改 / 清除都必须先验证当前密码(防未授权篡改门禁)
|
|
829
|
+
if (isEnabled()) {
|
|
830
|
+
if (!verifyPassword(current)) {
|
|
831
|
+
json(res, 401, { ok: false, code: 'BAD_CURRENT_PASSWORD', message: '当前密码不正确,无法修改或清除' })
|
|
832
|
+
return
|
|
833
|
+
}
|
|
834
|
+
}
|
|
511
835
|
// 密码强度检查
|
|
512
836
|
if (password && password.length < 4) {
|
|
513
837
|
json(res, 400, { ok: false, code: 'WEAK_PASSWORD', message: '密码至少 4 位' })
|
|
@@ -635,72 +959,21 @@ async function queryServerStatus(base, bindCode) {
|
|
|
635
959
|
return data
|
|
636
960
|
}
|
|
637
961
|
|
|
638
|
-
/** 当前 profile 的 cordis.patch.yml 绝对路径。 */
|
|
639
|
-
function patchFilePath(ctx) {
|
|
640
|
-
// DSH_HOME 环境变量优先,否则 ~/.dsh;profile 名为 DSH_PROFILE 或默认 web。
|
|
641
|
-
const home = process.env.DSH_HOME || join(homedir(), '.dsh')
|
|
642
|
-
const profile = process.env.DSH_PROFILE || 'web'
|
|
643
|
-
return join(home, 'profiles', profile, 'cordis.patch.yml')
|
|
644
|
-
}
|
|
645
|
-
|
|
646
962
|
/**
|
|
647
|
-
*
|
|
648
|
-
*
|
|
649
|
-
*
|
|
963
|
+
* 开启 / 关闭远程控制:把受管区域整体重写进 profile 的 cordis.patch.yml。
|
|
964
|
+
* 必须同时写两块,缺一不可:
|
|
965
|
+
* 1) webserver.host = '0.0.0.0' → 手机能连上(官方 CLI 拒绝 --host 0.0.0.0,
|
|
966
|
+
* 只有 patch 层能覆盖)。
|
|
967
|
+
* 2) connection.trustedHosts = ['192.168.x.x:port', ...] → 手机的 /api 请求
|
|
968
|
+
* 能通过 dsh 的 Host/Origin 信任围栏,否则页面能打开但功能全 403。
|
|
969
|
+
* @param {import('@deepseek-ai/cordis').Context} ctx
|
|
970
|
+
* @param {boolean} enabled
|
|
650
971
|
*/
|
|
651
|
-
function readPatchFile(ctx) {
|
|
652
|
-
const file = patchFilePath(ctx)
|
|
653
|
-
const content = existsSync(file) ? readFileSync(file, 'utf8') : ''
|
|
654
|
-
return { file, content, lines: content.split(/\r?\n/) }
|
|
655
|
-
}
|
|
656
|
-
|
|
657
|
-
/** webserver 远程控制覆盖块的行区间(含分隔注释)。不存在返回 null。 */
|
|
658
|
-
function findRemoteBlock(lines) {
|
|
659
|
-
const start = lines.findIndex((l) => l.includes('# --- dsh-mobile-remote managed'))
|
|
660
|
-
if (start === -1) return null
|
|
661
|
-
const end = lines.findIndex((l, i) => i > start && l.includes('# --- end dsh-mobile-remote managed'))
|
|
662
|
-
if (end === -1) return { start, end: lines.length }
|
|
663
|
-
return { start, end: end + 1 }
|
|
664
|
-
}
|
|
665
|
-
|
|
666
|
-
/** 检查当前 patch 中是否已启用远程控制。 */
|
|
667
|
-
function isPatchEnabled(ctx) {
|
|
668
|
-
const { lines } = readPatchFile(ctx)
|
|
669
|
-
const block = findRemoteBlock(lines)
|
|
670
|
-
if (!block) return false
|
|
671
|
-
return lines.slice(block.start, block.end).some((l) => l.includes("host: '0.0.0.0'"))
|
|
672
|
-
}
|
|
673
|
-
|
|
674
|
-
/** 追加 / 移除 webserver host 覆盖块。 */
|
|
675
972
|
function setPatchEnabled(ctx, enabled) {
|
|
676
|
-
const
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
// 列表块拼接会形成无 `---` 分隔符的多文档流,js-yaml 解析报
|
|
681
|
-
// "end of the stream or a document separator is expected",导致 dsh 服务启动即崩溃。
|
|
682
|
-
out = out.filter((l) => l.trim() !== '[]')
|
|
683
|
-
|
|
684
|
-
if (block) {
|
|
685
|
-
out.splice(block.start, block.end - block.start)
|
|
686
|
-
}
|
|
687
|
-
if (enabled) {
|
|
688
|
-
const blockLines = [
|
|
689
|
-
'',
|
|
690
|
-
'# --- dsh-mobile-remote managed (auto-generated; do not edit) ---',
|
|
691
|
-
'- id: webserver',
|
|
692
|
-
' config:',
|
|
693
|
-
" host: '0.0.0.0'",
|
|
694
|
-
' port: !!js ctx.webStartup.port ?? ' + (ctx.webServer.port || 3080),
|
|
695
|
-
'# --- end dsh-mobile-remote managed ---',
|
|
696
|
-
'',
|
|
697
|
-
]
|
|
698
|
-
out = [...out, ...blockLines]
|
|
699
|
-
}
|
|
700
|
-
|
|
701
|
-
const next = out.join('\n').replace(/\n{3,}/g, '\n\n').replace(/\s*$/, '\n')
|
|
702
|
-
writeFileSync(file, next, 'utf8')
|
|
703
|
-
return { file, enabled }
|
|
973
|
+
const port = ctx.webServer.port || 3080
|
|
974
|
+
// 局域网各网卡地址都要放行(手机与主机可能不在同一网段时命中的是不同网卡)。
|
|
975
|
+
const authorities = collectTrustedAuthorities({ lanAddresses: getLanAddresses(), port })
|
|
976
|
+
return writeManagedPatch(ctx, { enabled, port, authorities })
|
|
704
977
|
}
|
|
705
978
|
|
|
706
979
|
/** GET /__dsh_remote/status */
|
|
@@ -710,6 +983,8 @@ function handleStatus(ctx, devices, external, tracker, dshCache) {
|
|
|
710
983
|
const remoteEnabled = ctx.webServer.host === '0.0.0.0'
|
|
711
984
|
// 外网 frpc 是否仍在运行
|
|
712
985
|
const extRunning = !!external.proc && external.proc.exitCode === null
|
|
986
|
+
// 手机访问地址统一附加新版 dsh 的浏览器会话 token(无认证时返回原 URL)
|
|
987
|
+
const authUrl = (u) => withAuthUrl(ctx, u)
|
|
713
988
|
// 已持久化的绑定信息(重启后自动恢复用,前端据此回填表单)
|
|
714
989
|
const persisted = loadPersistedState()
|
|
715
990
|
// 活跃设备详情列表(按最近活跃倒序)
|
|
@@ -763,11 +1038,13 @@ function handleStatus(ctx, devices, external, tracker, dshCache) {
|
|
|
763
1038
|
lanAddresses: lan,
|
|
764
1039
|
lanInterfaces: getLanInterfaces(),
|
|
765
1040
|
remoteEnabled,
|
|
766
|
-
patchEnabled:
|
|
1041
|
+
patchEnabled: isManagedPatchEnabled(ctx),
|
|
767
1042
|
deviceCount: devices.size,
|
|
768
1043
|
devices: deviceList,
|
|
769
1044
|
patchFile: patchFilePath(ctx),
|
|
770
|
-
url: lan.length ? `http://${lan[0]}:${ctx.webServer.port}` : null,
|
|
1045
|
+
url: lan.length ? authUrl(`http://${lan[0]}:${ctx.webServer.port}`) : null,
|
|
1046
|
+
// 全部局域网 IP 的「手机可访问」地址(已带新版 dsh 认证 token;无认证时即裸地址)
|
|
1047
|
+
lanUrls: lan.map((ip) => authUrl(`http://${ip}:${ctx.webServer.port}`)),
|
|
771
1048
|
// 本机运行时信息(诊断用,设置页展示更详细的数据)
|
|
772
1049
|
runtime: runtimeInfo(),
|
|
773
1050
|
uptime: Math.floor(process.uptime()),
|
|
@@ -782,7 +1059,7 @@ function handleStatus(ctx, devices, external, tracker, dshCache) {
|
|
|
782
1059
|
enabled: extRunning,
|
|
783
1060
|
running: extRunning,
|
|
784
1061
|
status: external.status || 'idle',
|
|
785
|
-
url: extRunning ? external.url : null,
|
|
1062
|
+
url: extRunning && external.url ? authUrl(external.url) : null,
|
|
786
1063
|
domain: external.domain,
|
|
787
1064
|
tunnelPort: external.tunnelPort,
|
|
788
1065
|
frpcVersion: external.frpcVersion || null,
|
|
@@ -987,44 +1264,109 @@ function injectIntoIndex(html, remoteEnabled) {
|
|
|
987
1264
|
/* ------------------------------------------------------------------ */
|
|
988
1265
|
|
|
989
1266
|
const GATE_CSS = `
|
|
1267
|
+
/* DeepSeek 官网风格:深蓝黑底 + 极简白字 + 细边框 + 下划线输入 + 大留白 */
|
|
990
1268
|
#dsh-remote-gate {
|
|
991
1269
|
position: fixed; inset: 0; z-index: 2147483647;
|
|
992
1270
|
display: flex; align-items: center; justify-content: center;
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
996
|
-
color: #
|
|
1271
|
+
padding: 24px; box-sizing: border-box;
|
|
1272
|
+
background: #06101e;
|
|
1273
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'PingFang SC', 'Microsoft YaHei', sans-serif;
|
|
1274
|
+
color: #fff;
|
|
1275
|
+
}
|
|
1276
|
+
/* 背景光斑纹理(参考官网深蓝氛围) */
|
|
1277
|
+
#dsh-remote-gate::before {
|
|
1278
|
+
content: '';
|
|
1279
|
+
position: absolute; inset: 0;
|
|
1280
|
+
background:
|
|
1281
|
+
radial-gradient(800px 500px at 18% 8%, rgba(70, 100, 170, 0.12), transparent 60%),
|
|
1282
|
+
radial-gradient(700px 420px at 88% 92%, rgba(50, 70, 140, 0.10), transparent 60%);
|
|
1283
|
+
pointer-events: none;
|
|
997
1284
|
}
|
|
998
1285
|
#dsh-remote-gate .gate-card {
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1286
|
+
position: relative;
|
|
1287
|
+
width: min(360px, 92vw); box-sizing: border-box;
|
|
1288
|
+
background: rgba(10, 18, 32, 0.85);
|
|
1289
|
+
border: 1px solid rgba(255,255,255,.14);
|
|
1290
|
+
border-radius: 14px; padding: 44px 36px 30px;
|
|
1291
|
+
backdrop-filter: blur(10px);
|
|
1292
|
+
}
|
|
1293
|
+
/* Logo 组合:FishLogo + 文字 "deepseek | Harness",胶囊圆角条(复刻官网 logo 与药丸按钮) */
|
|
1294
|
+
#dsh-remote-gate .gate-logo-wrap {
|
|
1295
|
+
display: flex; align-items: center; justify-content: center; gap: 10px;
|
|
1296
|
+
width: max-content; margin: 0 auto 28px;
|
|
1297
|
+
padding: 10px 18px;
|
|
1298
|
+
border: 1px solid rgba(255,255,255,.16);
|
|
1299
|
+
border-radius: 999px;
|
|
1300
|
+
background: rgba(255,255,255,.04);
|
|
1003
1301
|
}
|
|
1302
|
+
#dsh-remote-gate .gate-logo {
|
|
1303
|
+
width: 26px; height: 19px; color: #fff; display: block; flex: none;
|
|
1304
|
+
}
|
|
1305
|
+
#dsh-remote-gate .gate-wordmark {
|
|
1306
|
+
font-size: 18px; line-height: 1; letter-spacing: .3px;
|
|
1307
|
+
display: flex; align-items: center; gap: 8px;
|
|
1308
|
+
}
|
|
1309
|
+
#dsh-remote-gate .gate-wordmark .w-name { color: #fff; font-weight: 500; }
|
|
1310
|
+
#dsh-remote-gate .gate-wordmark .w-sep { color: #555; font-weight: 300; }
|
|
1311
|
+
#dsh-remote-gate .gate-wordmark .w-prod { color: #d0d0d0; font-weight: 400; }
|
|
1312
|
+
/* 标题 + 副标题 */
|
|
1004
1313
|
#dsh-remote-gate .gate-title {
|
|
1005
|
-
font-size:
|
|
1314
|
+
font-size: 16px; font-weight: 600; margin: 0 0 8px; text-align: center; letter-spacing: .2px;
|
|
1006
1315
|
}
|
|
1007
1316
|
#dsh-remote-gate .gate-sub {
|
|
1008
|
-
font-size: 13px; color: #
|
|
1317
|
+
font-size: 13px; color: #8a8a8a; margin: 0 0 28px; line-height: 1.6; text-align: center;
|
|
1318
|
+
}
|
|
1319
|
+
/* 服务信息(访问地址 / 提供服务者 / 版本):细边框、黑白灰 */
|
|
1320
|
+
#dsh-remote-gate .gate-service {
|
|
1321
|
+
margin: 0 0 24px; padding: 12px 14px;
|
|
1322
|
+
border: 1px solid rgba(255,255,255,.12);
|
|
1323
|
+
border-radius: 10px;
|
|
1324
|
+
font-size: 12px; color: #8a8a8a; line-height: 1.9;
|
|
1325
|
+
}
|
|
1326
|
+
#dsh-remote-gate .gate-service .sv-row {
|
|
1327
|
+
display: flex; justify-content: space-between; align-items: baseline; gap: 12px; min-width: 0;
|
|
1009
1328
|
}
|
|
1329
|
+
#dsh-remote-gate .gate-service .sv-label { flex: none; color: #6a6a6a; }
|
|
1330
|
+
#dsh-remote-gate .gate-service .sv-value {
|
|
1331
|
+
flex: 1; min-width: 0; text-align: right; color: #e8e8e8;
|
|
1332
|
+
word-break: break-all; overflow-wrap: anywhere; font-weight: 500;
|
|
1333
|
+
}
|
|
1334
|
+
/* 圆角输入框:深色底 + 细白边 + 圆角,与整体圆角风格一致 */
|
|
1010
1335
|
#dsh-remote-gate .gate-input {
|
|
1011
1336
|
width: 100%; box-sizing: border-box;
|
|
1012
|
-
padding:
|
|
1013
|
-
background:
|
|
1014
|
-
border
|
|
1015
|
-
|
|
1016
|
-
|
|
1337
|
+
padding: 11px 13px; font-size: 14px; color: #fff;
|
|
1338
|
+
background: rgba(255,255,255,.05);
|
|
1339
|
+
border: 1px solid rgba(255,255,255,.18);
|
|
1340
|
+
border-radius: 10px; outline: none;
|
|
1341
|
+
transition: border-color .15s ease, background .15s ease;
|
|
1342
|
+
}
|
|
1343
|
+
#dsh-remote-gate .gate-input:focus { border-color: #fff; background: rgba(255,255,255,.08); }
|
|
1344
|
+
#dsh-remote-gate .gate-input::placeholder { color: #555; }
|
|
1345
|
+
/* 主按钮:白底黑字(强对比 CTA) */
|
|
1017
1346
|
#dsh-remote-gate .gate-btn {
|
|
1018
|
-
width: 100%; margin-top:
|
|
1019
|
-
font-size: 14px; font-weight: 600; color: #
|
|
1020
|
-
background: #
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1347
|
+
width: 100%; margin-top: 24px; padding: 12px;
|
|
1348
|
+
font-size: 14px; font-weight: 600; color: #000;
|
|
1349
|
+
background: #fff; border: none; border-radius: 10px; cursor: pointer;
|
|
1350
|
+
display: flex; align-items: center; justify-content: center; gap: 6px;
|
|
1351
|
+
transition: background .15s ease, opacity .15s ease;
|
|
1352
|
+
}
|
|
1353
|
+
#dsh-remote-gate .gate-btn:hover { background: #e8e8e8; }
|
|
1354
|
+
#dsh-remote-gate .gate-btn:disabled { opacity: .5; cursor: default; }
|
|
1355
|
+
/* 加载动画:提交后按钮内旋转 spinner + 文案切换 */
|
|
1356
|
+
#dsh-remote-gate .gate-spinner {
|
|
1357
|
+
display: none; width: 15px; height: 15px; flex: none;
|
|
1358
|
+
border: 2px solid rgba(0,0,0,.25); border-top-color: #000;
|
|
1359
|
+
border-radius: 50%; box-sizing: border-box;
|
|
1360
|
+
animation: dsh-gate-spin .7s linear infinite;
|
|
1361
|
+
}
|
|
1362
|
+
#dsh-remote-gate .gate-btn.gate-loading .gate-spinner { display: inline-block; }
|
|
1363
|
+
@keyframes dsh-gate-spin { to { transform: rotate(360deg); } }
|
|
1024
1364
|
#dsh-remote-gate .gate-err {
|
|
1025
|
-
font-size: 12px; color: #
|
|
1365
|
+
font-size: 12px; color: #ff5f56; margin-top: 10px; min-height: 16px; line-height: 1.4;
|
|
1366
|
+
}
|
|
1367
|
+
#dsh-remote-gate .gate-foot {
|
|
1368
|
+
margin-top: 20px; font-size: 11px; color: #555; text-align: center; letter-spacing: .2px;
|
|
1026
1369
|
}
|
|
1027
|
-
#dsh-remote-gate .gate-loading { opacity: .6; }
|
|
1028
1370
|
`
|
|
1029
1371
|
|
|
1030
1372
|
const GATE_JS = `
|
|
@@ -1067,17 +1409,35 @@ const GATE_JS = `
|
|
|
1067
1409
|
function canCover() {
|
|
1068
1410
|
try { return document.getElementById('dsh-remote-gate') === null; } catch (e) { return true; }
|
|
1069
1411
|
}
|
|
1070
|
-
|
|
1412
|
+
/* DSH FishLogo(来自 @deepseek-ai/dsh-client-ui-primitives FishLogo,SVG path 直出) */
|
|
1413
|
+
var GATE_FISH = '<svg class="gate-logo" viewBox="0 0 23.16 17.04" fill="currentColor" aria-hidden="true"><path d="M22.9168 1.43018C22.6713 1.31018 22.5658 1.53918 22.4223 1.65519C22.3733 1.69269 22.3318 1.74169 22.2903 1.78669C21.9317 2.1697 21.5127 2.42121 20.9657 2.39121C20.1657 2.34621 19.4827 2.59771 18.8787 3.20973C18.7502 2.45521 18.3236 2.0047 17.6746 1.71569C17.3351 1.56568 16.9916 1.41518 16.7536 1.08867C16.5876 0.856163 16.5421 0.597155 16.4591 0.341647C16.4061 0.187643 16.3536 0.0301382 16.1761 0.00363739C15.9836 -0.0263635 15.9081 0.135141 15.8326 0.270145C15.5306 0.822162 15.4136 1.43018 15.4251 2.0462C15.4516 3.43174 16.0366 4.53527 17.1991 5.3203C17.3311 5.4103 17.3651 5.5003 17.3236 5.63181C17.2441 5.90231 17.1501 6.16482 17.0671 6.43533C17.0141 6.60784 16.9351 6.64584 16.7501 6.57033C16.1121 6.30383 15.5611 5.90931 15.074 5.4328C14.2475 4.63328 13.5 3.75075 12.568 3.05973C12.349 2.89822 12.13 2.74822 11.9034 2.60522C10.9524 1.68169 12.028 0.923165 12.277 0.833162C12.5375 0.739159 12.3675 0.41615 11.5259 0.42015C10.6844 0.42365 9.91439 0.705658 8.93286 1.08117C8.78935 1.13767 8.63835 1.17867 8.48384 1.21267C7.59332 1.04367 6.66829 1.00617 5.70226 1.11517C3.88321 1.31768 2.43016 2.1777 1.36213 3.64575C0.0790928 5.4103 -0.222916 7.41536 0.146595 9.50642C0.535106 11.7105 1.66014 13.535 3.38869 14.9616C5.18125 16.4406 7.24581 17.1657 9.60138 17.0266C11.0319 16.9441 12.6245 16.7526 14.421 15.2321C14.874 15.4576 15.3496 15.5476 16.1381 15.6151C16.7456 15.6716 17.3306 15.5851 17.7836 15.4911C18.4931 15.3411 18.4441 14.6841 18.1876 14.5636C16.1081 13.595 16.5646 13.9891 16.1496 13.67C17.2061 12.42 18.8202 10.1979 19.3182 7.17235C19.3672 6.83834 19.4297 6.36783 19.4222 6.09732C19.4182 5.93231 19.4562 5.86831 19.6447 5.84931C20.1657 5.78931 20.6712 5.64681 21.1357 5.3913C22.4833 4.65528 23.0268 3.44624 23.1548 1.9972C23.1738 1.77569 23.1508 1.54668 22.9168 1.43018ZM11.1749 14.4736C9.15936 12.889 8.18184 12.3675 7.77832 12.39C7.40081 12.4125 7.46881 12.8445 7.55182 13.126C7.63882 13.404 7.75182 13.5955 7.91033 13.8396C8.01983 14.0011 8.09533 14.2411 7.80083 14.4216C7.15181 14.8231 6.02327 14.2866 5.97027 14.2601C4.65673 13.4865 3.5587 12.4655 2.78467 11.069C2.03715 9.72493 1.60314 8.28289 1.53164 6.74384C1.51264 6.37233 1.62214 6.24082 1.99215 6.17332C2.47916 6.08332 2.98118 6.06432 3.46769 6.13582C5.52476 6.43633 7.27581 7.35586 8.74385 8.8129C9.58188 9.64243 10.2159 10.634 10.8689 11.6025C11.5634 12.631 12.3105 13.611 13.262 14.4146C13.598 14.6961 13.866 14.9101 14.1225 15.0681C13.349 15.1546 12.058 15.1731 11.1749 14.4746L11.1749 14.4736ZM12.141 8.25988C12.141 8.09488 12.273 7.96338 12.439 7.96338C12.4765 7.96338 12.5105 7.97088 12.541 7.98188C12.5825 7.99688 12.6205 8.01938 12.6505 8.05338C12.7035 8.10588 12.7335 8.18088 12.7335 8.25988C12.7335 8.42489 12.6015 8.55639 12.4355 8.55639C12.2695 8.55639 12.141 8.42489 12.141 8.25988ZM15.1415 9.79893C14.949 9.87793 14.7565 9.94544 14.5715 9.95294C14.2845 9.96794 13.9715 9.85143 13.8015 9.70893C13.5375 9.48742 13.3485 9.36342 13.2695 8.97691C13.2355 8.8119 13.2545 8.55639 13.2845 8.40989C13.3525 8.09438 13.277 7.89187 13.0545 7.70787C12.8735 7.55786 12.643 7.51636 12.39 7.51636C12.2955 7.51636 12.209 7.47486 12.1445 7.44136C12.039 7.38886 11.9519 7.25735 12.035 7.09585C12.0615 7.04335 12.19 6.91584 12.22 6.89334C12.5635 6.69784 12.9595 6.76184 13.326 6.90834C13.6655 7.04735 13.9225 7.30236 14.292 7.66287C14.6695 8.09838 14.7375 8.21838 14.9525 8.54539C15.1225 8.8009 15.277 9.06341 15.3831 9.36392C15.4471 9.55142 15.3641 9.70493 15.1415 9.79893Z"/></svg>';
|
|
1414
|
+
/* HTML 转义,防止服务信息中的特殊字符破坏结构 */
|
|
1415
|
+
function esc(s) {
|
|
1416
|
+
return String(s == null ? '' : s)
|
|
1417
|
+
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
1418
|
+
.replace(/"/g, '"').replace(/'/g, ''');
|
|
1419
|
+
}
|
|
1420
|
+
function createGate(service) {
|
|
1071
1421
|
if (!canCover()) return;
|
|
1422
|
+
var svc = service || {};
|
|
1423
|
+
var rows = '';
|
|
1424
|
+
if (svc.host) rows += '<div class="sv-row"><span class="sv-label">访问地址</span><span class="sv-value">' + esc(svc.host) + '</span></div>';
|
|
1425
|
+
if (svc.hostname) rows += '<div class="sv-row"><span class="sv-label">提供服务</span><span class="sv-value">' + esc(svc.hostname) + '</span></div>';
|
|
1426
|
+
if (svc.version) rows += '<div class="sv-row"><span class="sv-label">版本</span><span class="sv-value">' + esc(svc.version) + '</span></div>';
|
|
1072
1427
|
var g = document.createElement('div');
|
|
1073
1428
|
g.id = 'dsh-remote-gate';
|
|
1074
1429
|
g.innerHTML =
|
|
1075
1430
|
'<div class="gate-card">' +
|
|
1431
|
+
'<div class="gate-logo-wrap">' + GATE_FISH +
|
|
1432
|
+
'<div class="gate-wordmark"><span class="w-name">deepseek</span><span class="w-sep">|</span><span class="w-prod">Harness</span></div>' +
|
|
1433
|
+
'</div>' +
|
|
1076
1434
|
'<p class="gate-title">远程访问已锁定</p>' +
|
|
1077
1435
|
'<p class="gate-sub">请输入管理员设置的远程访问密码以继续使用。</p>' +
|
|
1436
|
+
(rows ? '<div class="gate-service">' + rows + '</div>' : '') +
|
|
1078
1437
|
'<input class="gate-input" type="password" autocomplete="current-password" placeholder="远程访问密码" />' +
|
|
1079
|
-
'<button class="gate-btn" type="button">进入</button>' +
|
|
1438
|
+
'<button class="gate-btn" type="button"><span class="gate-spinner"></span><span class="gate-btn-text">进入</span></button>' +
|
|
1080
1439
|
'<p class="gate-err"></p>' +
|
|
1440
|
+
'<p class="gate-foot">DeepSeek Harness · 移动端远程控制</p>' +
|
|
1081
1441
|
'</div>';
|
|
1082
1442
|
document.documentElement.appendChild(g);
|
|
1083
1443
|
return g;
|
|
@@ -1086,7 +1446,10 @@ const GATE_JS = `
|
|
|
1086
1446
|
function submit(g, input, btn) {
|
|
1087
1447
|
var pwd = input.value;
|
|
1088
1448
|
if (!pwd) { g.querySelector('.gate-err').textContent = '请输入密码'; return; }
|
|
1449
|
+
var btnText = btn.querySelector('.gate-btn-text');
|
|
1450
|
+
// 立即进入加载态:禁用按钮 + spinner 旋转 + 文案切换,让用户明确知道正在进入
|
|
1089
1451
|
btn.disabled = true; btn.classList.add('gate-loading');
|
|
1452
|
+
if (btnText) btnText.textContent = '正在进入…';
|
|
1090
1453
|
fetch('/__dsh_remote/auth', {
|
|
1091
1454
|
method: 'POST',
|
|
1092
1455
|
headers: { 'Content-Type': 'application/json' },
|
|
@@ -1098,6 +1461,7 @@ const GATE_JS = `
|
|
|
1098
1461
|
window.location.reload();
|
|
1099
1462
|
}).catch(function (e) {
|
|
1100
1463
|
btn.disabled = false; btn.classList.remove('gate-loading');
|
|
1464
|
+
if (btnText) btnText.textContent = '进入';
|
|
1101
1465
|
g.querySelector('.gate-err').textContent = String(e && e.message || e);
|
|
1102
1466
|
input.select();
|
|
1103
1467
|
});
|
|
@@ -1108,7 +1472,7 @@ const GATE_JS = `
|
|
|
1108
1472
|
.then(function (r) { return r.json(); })
|
|
1109
1473
|
.then(function (st) {
|
|
1110
1474
|
if (!st || !st.required || st.authenticated) return;
|
|
1111
|
-
var g = createGate();
|
|
1475
|
+
var g = createGate(st.service);
|
|
1112
1476
|
if (!g) return;
|
|
1113
1477
|
var input = g.querySelector('.gate-input');
|
|
1114
1478
|
var btn = g.querySelector('.gate-btn');
|