@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/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
- * 从 profile 的 cordis.patch.yml 中读取补丁列表(YAML 数组)。
648
- * 不引入 yaml 依赖:远程控制仅需对 webserver 行做增删,采用精确文本匹配。
649
- * @returns {{ file: string, content: string, lines: string[] }}
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 { file, content, lines } = readPatchFile(ctx)
677
- const block = findRemoteBlock(lines)
678
- let out = lines.slice()
679
- // 移除官方模板的空数组占位行 `[]`:它单独构成一个 YAML 文档,与追加的
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: isPatchEnabled(ctx),
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
- background: rgba(15, 17, 26, 0.92);
994
- backdrop-filter: blur(6px);
995
- font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
996
- color: #e8e8ea;
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
- width: min(360px, 88vw); box-sizing: border-box;
1000
- background: #1d2030; border: 1px solid rgba(255,255,255,.12);
1001
- border-radius: 14px; padding: 24px 22px;
1002
- box-shadow: 0 18px 50px rgba(0,0,0,.45);
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: 17px; font-weight: 700; margin: 0 0 6px;
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: #9aa0b5; margin: 0 0 16px; line-height: 1.5;
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: 10px 12px; font-size: 14px; color: #e8e8ea;
1013
- background: #14171f; border: 1px solid rgba(255,255,255,.18);
1014
- border-radius: 9px; outline: none;
1015
- }
1016
- #dsh-remote-gate .gate-input:focus { border-color: #4c8dff; }
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: 12px; padding: 10px 12px;
1019
- font-size: 14px; font-weight: 600; color: #fff;
1020
- background: #2a7de1; border: none; border-radius: 9px; cursor: pointer;
1021
- }
1022
- #dsh-remote-gate .gate-btn:hover { background: #3a8df0; }
1023
- #dsh-remote-gate .gate-btn:disabled { opacity: .6; cursor: default; }
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: #ff6b6b; margin-top: 10px; min-height: 16px;
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
- function createGate() {
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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
1418
+ .replace(/"/g, '&quot;').replace(/'/g, '&#39;');
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');