@feiyang666/dsh-mobile-remote 1.8.1 → 1.9.0

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
@@ -43,11 +43,13 @@ import {
43
43
  import {
44
44
  hasPassword, verifyPassword, setPassword,
45
45
  isEnabled, login, verifyToken, revokeToken, clearSessions,
46
+ getSetting, setSetting,
46
47
  } from './password.js'
47
48
  import { wsAvailable } from './ws-client.js'
49
+ import { createLanProxy } from './lanproxy.js'
48
50
  import {
49
- patchFilePath, isManagedPatchEnabled,
50
- collectTrustedAuthorities, writeManagedPatch,
51
+ patchFilePath, isManagedPatchEnabled, profileName, isLoopbackHost,
52
+ migrateLegacyPatch,
51
53
  } from './patch.js'
52
54
 
53
55
  /** 插件名(loader 诊断用)。 */
@@ -247,10 +249,18 @@ function notifyChanged() {
247
249
  * @param {import('@deepseek-ai/cordis').Context} ctx
248
250
  */
249
251
  export function apply(ctx) {
250
- // 当前监听地址在服务启动时即确定。
251
- const remoteEnabled = ctx.webServer.host === '0.0.0.0'
252
+ // 宿主端口(官方桌面端用 --port 0,由系统分配,启动后才是真实值)。
252
253
  const port = ctx.webServer.port
253
254
 
255
+ // 局域网旁挂听众:本机监听不动,另开一份 <LAN IP>:<port> 转发到回环。
256
+ // 这是 0.2.1-rc.3 之后唯一不破坏本机访问的「对局域网开放」实现方式。
257
+ const lan = createLanProxy({
258
+ log: (message, error) => {
259
+ try { ctx.logger?.warn?.(`[dsh-mobile-remote] ${message}${error ? `: ${String(error?.message || error)}` : ''}`) } catch { /* ignore */ }
260
+ },
261
+ })
262
+ const remoteEnabledNow = () => lan.status().running
263
+
254
264
  // 活跃设备表:deviceId -> 详细设备信息(见 handleHeartbeat)。
255
265
  const devices = new Map()
256
266
  // 累计去重设备数 + 累计心跳数(进程生命周期内的统计,供设置页展示更丰富的数据)。
@@ -266,7 +276,7 @@ export function apply(ctx) {
266
276
  const external = { proc: null, status: 'idle', url: null, domain: null, tunnelPort: null }
267
277
 
268
278
  ctx.effect(() => {
269
- const dispose = ctx.webServer.tapIndex((html) => injectIntoIndex(html, remoteEnabled))
279
+ const dispose = ctx.webServer.tapIndex((html) => injectIntoIndex(html, remoteEnabledNow()))
270
280
  return () => dispose()
271
281
  }, 'dsh-mobile-remote: index transform')
272
282
 
@@ -279,8 +289,8 @@ export function apply(ctx) {
279
289
  const dshTimer = setInterval(refreshDsh, 60_000)
280
290
 
281
291
  const disposers = [
282
- ctx.webServer.register({ kind: 'exact', path: '/__dsh_remote/status', handler: authGate(handleStatus(ctx, devices, external, tracker, dshCache)) }),
283
- ctx.webServer.register({ kind: 'exact', path: '/__dsh_remote/toggle', handler: authGate(handleToggle(ctx)) }),
292
+ ctx.webServer.register({ kind: 'exact', path: '/__dsh_remote/status', handler: authGate(handleStatus(ctx, devices, external, tracker, dshCache, lan)) }),
293
+ ctx.webServer.register({ kind: 'exact', path: '/__dsh_remote/toggle', handler: authGate(handleToggle(ctx, lan)) }),
284
294
  ctx.webServer.register({ kind: 'exact', path: '/__dsh_remote/heartbeat', handler: authGate(handleHeartbeat(devices, tracker)) }),
285
295
  ctx.webServer.register({ kind: 'exact', path: '/__dsh_remote/qr', handler: authGate(handleQr()) }),
286
296
  ctx.webServer.register({ kind: 'exact', path: '/__dsh_remote/external/start', handler: authGate(handleExternalStart(ctx, external)) }),
@@ -290,7 +300,7 @@ export function apply(ctx) {
290
300
  // —— SSE 实时事件流:设备心跳 / 远程开关 / 外网隧道状态变化时推送 {"type":"changed"} ——
291
301
  ctx.webServer.register({ kind: 'exact', path: '/__dsh_remote/events', handler: authGate(handleEvents()) }),
292
302
  // —— 远程访问密码门禁相关(auth 系列不套 authGate,需放行)——
293
- ctx.webServer.register({ kind: 'exact', path: '/__dsh_remote/auth-status', handler: handleAuthStatus() }),
303
+ ctx.webServer.register({ kind: 'exact', path: '/__dsh_remote/auth-status', handler: handleAuthStatus(ctx) }),
294
304
  ctx.webServer.register({ kind: 'exact', path: '/__dsh_remote/auth', handler: handleAuth() }),
295
305
  ctx.webServer.register({ kind: 'exact', path: '/__dsh_remote/logout', handler: handleLogout() }),
296
306
  ctx.webServer.register({ kind: 'exact', path: '/__dsh_remote/set-password', handler: handleSetPassword() }),
@@ -323,8 +333,12 @@ export function apply(ctx) {
323
333
  try {
324
334
  await new Promise((r) => setTimeout(r, 30))
325
335
  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}/`
336
+ // 0.2.1 起宿主只绑一块具体网卡(不再允许 0.0.0.0),回环地址可能根本没在监听,
337
+ // 内部取 index 必须走真实绑定地址,否则 ECONNREFUSED → 首页被回 401。
338
+ const bindHost = String(ctx.webServer?.host || '127.0.0.1')
339
+ const localBase = `http://${bindHost.includes(':') ? `[${bindHost}]` : bindHost}:${p}/`
340
+ const tokenUrl = withAuthUrl(ctx, localBase)
341
+ const same = tokenUrl === localBase
328
342
  rootIndexCache = await httpFollow(tokenUrl)
329
343
  // 无 token 可用且官方回 401:说明宿主强制认证但本上下文取不到 browserAuth。
330
344
  // 绝不能把空结果当成可用 index,交由调用方走官方 index owner。
@@ -410,6 +424,50 @@ export function apply(ctx) {
410
424
  if (now - info.lastSeen > HEARTBEAT_TTL) devices.delete(id)
411
425
  }
412
426
  }, HEARTBEAT_SWEEP)
427
+ // —— 局域网开关的自恢复与旧配置迁移 ——
428
+ // 旧版本靠「patch 里写入 webserver.host」开启远程,那个块在新版会毁掉本机回环
429
+ // 监听(官方桌面端窗口直接白屏)。这里先把它清掉,再按配置项重建旁挂听众。
430
+ const migrationTimers = []
431
+ const restoreRemote = async () => {
432
+ const warn = (msg) => { try { ctx.logger?.warn?.(msg) } catch { /* ignore */ } }
433
+ const migrate = () => {
434
+ try {
435
+ const result = migrateLegacyPatch(ctx)
436
+ if (result.changed) {
437
+ warn('[dsh-mobile-remote] 已迁移旧版本遗留配置:不再改写 webserver 绑定(改为受管 connection.trustedHosts + 旁挂 LAN 听众)')
438
+ }
439
+ } catch (error) {
440
+ warn(`[dsh-mobile-remote] 旧配置迁移失败:${String(error?.message || error)}`)
441
+ }
442
+ }
443
+ if (isLoopbackHost(ctx.webServer.host)) {
444
+ // 旧绑定没生效(比如当前 profile 本来就没写过):直接清理,不影响运行中的监听
445
+ migrate()
446
+ } else {
447
+ // 旧版本把 webserver 绑到了局域网 IP:现在改会触发一次重载把监听拉回回环,
448
+ // 必须等 HMR watcher 就绪后再写,否则这一次写入不会触发重载。
449
+ const timer = setTimeout(() => {
450
+ migrate()
451
+ warn('[dsh-mobile-remote] 监听已切回本机回环;局域网访问由插件的旁挂听众提供')
452
+ }, 4000)
453
+ migrationTimers.push(timer)
454
+ }
455
+ // 旧版本以「受管 patch 存在」表示开启;首次升级时迁移成持久化配置项
456
+ if (getSetting('remoteEnabled', null) === null) {
457
+ try { setSetting('remoteEnabled', isManagedPatchEnabled(ctx)) } catch { /* ignore */ }
458
+ }
459
+ if (getSetting('remoteEnabled', false) !== true) return
460
+ try {
461
+ const result = await enableRemote(ctx, lan)
462
+ if (!result.running) {
463
+ try { ctx.logger?.warn?.('[dsh-mobile-remote] 局域网监听恢复失败(端口可能已被占用),可在设置页重新开启') } catch { /* ignore */ }
464
+ }
465
+ } catch (error) {
466
+ try { ctx.logger?.warn?.(`[dsh-mobile-remote] 局域网监听恢复失败:${String(error?.message || error)}`) } catch { /* ignore */ }
467
+ }
468
+ }
469
+ void restoreRemote()
470
+
413
471
  // dsh 重启后自动恢复外网访问:读取持久化的绑定码 / 中转地址重新拉起 frpc,
414
472
  // 免去每次手动输入。设置 DSH_DISABLE_AUTO_RESTORE=1 可关闭该行为。
415
473
  if (process.env.DSH_DISABLE_AUTO_RESTORE !== '1') {
@@ -428,6 +486,14 @@ export function apply(ctx) {
428
486
  if (!now || now === lastKnownPort) return
429
487
  const from = lastKnownPort
430
488
  lastKnownPort = now
489
+ // 局域网听众也要跟着端口走(否则手机地址指向一个没人监听的端口)
490
+ if (lan.status().running) {
491
+ void lan.start(getLanAddresses(), now).then((result) => {
492
+ if (result.running) {
493
+ try { ctx.logger?.info?.(`[dsh-mobile-remote] 监听端口 ${from} → ${now},局域网听众已自适应重绑`) } catch { /* ignore */ }
494
+ }
495
+ }).catch(() => { /* 忽略 */ })
496
+ }
431
497
  const persisted = loadPersistedState()
432
498
  // 仅在隧道确实在跑、且有绑定码可复用时才自动重绑(避免无谓打中转服务器)。
433
499
  if (external.proc && external.proc.exitCode === null && persisted && persisted.bindCode) {
@@ -445,7 +511,9 @@ export function apply(ctx) {
445
511
  clearInterval(timer)
446
512
  clearInterval(dshTimer)
447
513
  clearInterval(portWatcher)
514
+ for (const t of migrationTimers) clearTimeout(t)
448
515
  stopFrpc(external)
516
+ void lan.stop().catch(() => { /* 忽略 */ })
449
517
  for (const dispose of disposers) dispose()
450
518
  // 裸地址直达(/ 接管)路由的清理
451
519
  rootDisposed = true
@@ -488,15 +556,19 @@ function json(res, status, body) {
488
556
  res.end(payload)
489
557
  }
490
558
 
491
- /** 本机局域网 IPv4 地址列表(不含回环)。 */
559
+ /** 本机局域网 IPv4 地址列表(不含回环与 APIPA 自动私有地址)。 */
492
560
  function getLanAddresses() {
493
561
  const out = []
494
562
  for (const entries of Object.values(networkInterfaces() ?? {})) {
495
563
  for (const iface of entries ?? []) {
496
- if (iface.family === 'IPv4' && !iface.internal) out.push(iface.address)
564
+ if (iface.family !== 'IPv4' || iface.internal) continue
565
+ if (String(iface.address).startsWith('169.254.')) continue
566
+ if (!out.includes(iface.address)) out.push(iface.address)
497
567
  }
498
568
  }
499
- return out
569
+ // 私有网段优先,其它(如公网直连地址)排后面:设置页展示与监听顺序都更符合直觉
570
+ const score = (ip) => (/^192\.168\./.test(ip) ? 3 : /^10\./.test(ip) ? 2 : /^172\.(1[6-9]|2\d|3[01])\./.test(ip) ? 1 : 0)
571
+ return out.sort((a, b) => score(b) - score(a))
500
572
  }
501
573
 
502
574
  /** 所有 IPv4 网卡详情(含网卡名 / 掩码 / MAC / CIDR / 是否回环),供设置页展示。 */
@@ -518,8 +590,11 @@ function getLanInterfaces() {
518
590
  return out
519
591
  }
520
592
 
521
- /** 当前进程运行时信息(诊断用,设置页展示)。 */
522
- function runtimeInfo() {
593
+ /**
594
+ * 当前进程运行时信息(诊断用,设置页展示)。
595
+ * @param {import('@deepseek-ai/cordis').Context} [ctx]
596
+ */
597
+ function runtimeInfo(ctx) {
523
598
  // 采集时刻:前端可据此在两次 /status 轮询之间本地累加,让"运行时长"每秒跳动。
524
599
  const at = Date.now()
525
600
  const mem = process.memoryUsage()
@@ -546,7 +621,8 @@ function runtimeInfo() {
546
621
  rss: mem.rss,
547
622
  heapUsed: mem.heapUsed,
548
623
  heapTotal: mem.heapTotal,
549
- profile: process.env.DSH_PROFILE || 'web',
624
+ // profile 以宿主实际加载的 profileContext 为准(官方桌面端不设 DSH_PROFILE 环境变量)
625
+ profile: profileName(ctx),
550
626
  dshHome: process.env.DSH_HOME || join(homedir(), '.dsh'),
551
627
  }
552
628
  }
@@ -559,13 +635,27 @@ function withTimeout(promise, ms, fallback = null) {
559
635
  ])
560
636
  }
561
637
 
562
- /** 多位置探测 dsh 版本号(profile 本地 / 桌面版 / 全局安装)。找不到返回 null。 */
563
- function detectDshVersion() {
564
- const home = process.env.DSH_HOME || join(homedir(), '.dsh')
565
- const profile = process.env.DSH_PROFILE || 'web'
566
- const candidates = [
567
- join(home, 'profiles', profile, 'node_modules', '@deepseek-ai', 'dsh', 'package.json'),
568
- ]
638
+ /**
639
+ * 当前运行的 profile 目录(宿主 profileContext 优先)。
640
+ * @param {import('@deepseek-ai/cordis').Context} [ctx]
641
+ * @returns {string|null}
642
+ */
643
+ function profileDirOf(ctx) {
644
+ try {
645
+ const pc = typeof ctx?.get === 'function' ? ctx.get('profileContext') : ctx?.profileContext
646
+ if (pc && typeof pc.dir === 'string' && pc.dir) return pc.dir
647
+ } catch { /* 忽略 */ }
648
+ return null
649
+ }
650
+
651
+ /**
652
+ * 多位置探测 dsh 版本号(profile 本地 / 桌面版 / 全局安装)。找不到返回 null。
653
+ * @param {import('@deepseek-ai/cordis').Context} [ctx]
654
+ */
655
+ function detectDshVersion(ctx) {
656
+ const candidates = []
657
+ const dir = profileDirOf(ctx)
658
+ if (dir) candidates.push(join(dir, 'node_modules', '@deepseek-ai', 'dsh', 'package.json'))
569
659
  if (process.env.APPDATA) {
570
660
  candidates.push(
571
661
  join(process.env.APPDATA, 'dsh-desktop', 'dsh-local', 'node_modules', '@deepseek-ai', 'dsh', 'package.json'),
@@ -589,7 +679,7 @@ function detectDshVersion() {
589
679
  */
590
680
  async function collectDshInfo(ctx) {
591
681
  const out = { version: null, sessions: null, workspaces: null, plugins: null, llm: null }
592
- try { out.version = detectDshVersion() } catch { /* ignore */ }
682
+ try { out.version = detectDshVersion(ctx) } catch { /* ignore */ }
593
683
  try {
594
684
  const sp = ctx.get('sessionPersistence')
595
685
  if (sp && typeof sp.list === 'function') {
@@ -757,7 +847,7 @@ function handleEvents() {
757
847
  /** GET /__dsh_remote/auth-status:返回远程访问密码的启用状态与本次请求是否需鉴权。
758
848
  * 额外返回 service 摘要(服务名 / 本机主机名 / dsh 版本 / 当前访问域名),
759
849
  * 供密码门禁界面展示"当前是谁在提供服务"等更多信息。 */
760
- function handleAuthStatus() {
850
+ function handleAuthStatus(ctx) {
761
851
  return async (req, res) => {
762
852
  json(res, 200, {
763
853
  ok: true,
@@ -770,7 +860,7 @@ function handleAuthStatus() {
770
860
  service: {
771
861
  name: 'DeepSeek Harness',
772
862
  hostname: hostname(),
773
- version: detectDshVersion() || '',
863
+ version: detectDshVersion(ctx) || '',
774
864
  host: String(req.headers.host || ''),
775
865
  },
776
866
  })
@@ -960,27 +1050,39 @@ async function queryServerStatus(base, bindCode) {
960
1050
  }
961
1051
 
962
1052
  /**
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。
1053
+ * 开启局域网访问:在每块局域网网卡上旁挂一个听众,转发到宿主的回环监听。
1054
+ *
1055
+ * 为什么不再写 patch:
1056
+ * dsh 0.2.1-rc.3 起 webserver 只接受具体网卡 IP 字面量(0.0.0.0 会让启动直接失败),
1057
+ * 而改绑局域网 IP 会让 127.0.0.1 监听消失 —— 桌面端窗口(固定连回环)立刻白屏,
1058
+ * 官方桌面端还会因为 `--port 0` 让端口在重载后漂移。旁挂听众既不碰宿主绑定,
1059
+ * 也不写任何配置文件:开关即时生效、无需重启、本机访问完全不受影响。
969
1060
  * @param {import('@deepseek-ai/cordis').Context} ctx
970
- * @param {boolean} enabled
1061
+ * @param {ReturnType<typeof createLanProxy>} proxy
971
1062
  */
972
- function setPatchEnabled(ctx, enabled) {
973
- const port = ctx.webServer.port || 3080
974
- // 局域网各网卡地址都要放行(手机与主机可能不在同一网段时命中的是不同网卡)。
975
- const authorities = collectTrustedAuthorities({ lanAddresses: getLanAddresses(), port })
976
- return writeManagedPatch(ctx, { enabled, port, authorities })
1063
+ async function enableRemote(ctx, proxy) {
1064
+ const port = ctx.webServer.port || 0
1065
+ if (!port) throw new Error('dsh 尚未监听端口,请稍后重试')
1066
+ const result = await proxy.start(getLanAddresses(), port)
1067
+ if (result.running) setSetting('remoteEnabled', true)
1068
+ return result
1069
+ }
1070
+
1071
+ /**
1072
+ * 关闭局域网访问:释放旁挂听众,本机监听自始至终未受影响。
1073
+ * @param {ReturnType<typeof createLanProxy>} proxy
1074
+ */
1075
+ async function disableRemote(proxy) {
1076
+ await proxy.stop()
1077
+ setSetting('remoteEnabled', false)
977
1078
  }
978
1079
 
979
1080
  /** GET /__dsh_remote/status */
980
- function handleStatus(ctx, devices, external, tracker, dshCache) {
1081
+ function handleStatus(ctx, devices, external, tracker, dshCache, proxy) {
981
1082
  return async (req, res) => {
982
1083
  const lan = getLanAddresses()
983
- const remoteEnabled = ctx.webServer.host === '0.0.0.0'
1084
+ const proxyState = proxy.status()
1085
+ const remoteEnabled = proxyState.running
984
1086
  // 外网 frpc 是否仍在运行
985
1087
  const extRunning = !!external.proc && external.proc.exitCode === null
986
1088
  // 手机访问地址统一附加新版 dsh 的浏览器会话 token(无认证时返回原 URL)
@@ -1038,15 +1140,24 @@ function handleStatus(ctx, devices, external, tracker, dshCache) {
1038
1140
  lanAddresses: lan,
1039
1141
  lanInterfaces: getLanInterfaces(),
1040
1142
  remoteEnabled,
1143
+ // 本机监听地址(新版永远是回环:局域网访问由旁挂听众转发,不再改绑定)
1144
+ bindHost: ctx.webServer.host,
1145
+ // 旁挂听众的实时状态(开启后有 running/hosts/port)
1146
+ lan: {
1147
+ running: proxyState.running,
1148
+ hosts: proxyState.hosts,
1149
+ port: proxyState.port,
1150
+ },
1041
1151
  patchEnabled: isManagedPatchEnabled(ctx),
1042
1152
  deviceCount: devices.size,
1043
1153
  devices: deviceList,
1044
1154
  patchFile: patchFilePath(ctx),
1155
+ profile: profileName(ctx),
1045
1156
  url: lan.length ? authUrl(`http://${lan[0]}:${ctx.webServer.port}`) : null,
1046
1157
  // 全部局域网 IP 的「手机可访问」地址(已带新版 dsh 认证 token;无认证时即裸地址)
1047
1158
  lanUrls: lan.map((ip) => authUrl(`http://${ip}:${ctx.webServer.port}`)),
1048
1159
  // 本机运行时信息(诊断用,设置页展示更详细的数据)
1049
- runtime: runtimeInfo(),
1160
+ runtime: runtimeInfo(ctx),
1050
1161
  uptime: Math.floor(process.uptime()),
1051
1162
  nodeVersion: process.version,
1052
1163
  totalDevicesEver: tracker.seenEver.size,
@@ -1097,26 +1208,43 @@ function handleStatus(ctx, devices, external, tracker, dshCache) {
1097
1208
  },
1098
1209
  cliHint: remoteEnabled
1099
1210
  ? null
1100
- : 'remote control is off; toggle it in settings or start dsh with --patch',
1211
+ : 'remote control is off; toggle it in settings (no restart required)',
1101
1212
  })
1102
1213
  }
1103
1214
  }
1104
1215
 
1105
1216
  /** POST /__dsh_remote/toggle { enabled: boolean } */
1106
- function handleToggle(ctx) {
1217
+ function handleToggle(ctx, proxy) {
1107
1218
  return async (req, res) => {
1108
1219
  const body = await readBody(req)
1109
1220
  const enabled = body.enabled === true
1110
1221
  try {
1111
- const result = setPatchEnabled(ctx, enabled)
1112
- notifyChanged() // 远程开关变化:通知 SSE 订阅者
1222
+ if (enabled) {
1223
+ const result = await enableRemote(ctx, proxy)
1224
+ notifyChanged() // 远程开关变化:通知 SSE 订阅者
1225
+ if (!result.running) {
1226
+ const detail = (result.failures || [])
1227
+ .map((f) => `${f.host || '?'} → ${f.error}`)
1228
+ .join('; ') || '没有可用的局域网地址'
1229
+ json(res, 500, { ok: false, enabled: false, error: `局域网监听失败:${detail}` })
1230
+ return
1231
+ }
1232
+ const port = ctx.webServer.port || 0
1233
+ json(res, 200, {
1234
+ ok: true,
1235
+ enabled: true,
1236
+ lanHosts: result.hosts,
1237
+ urls: result.hosts.map((ip) => `http://${ip}:${port}`),
1238
+ note: 'lan listener started; dsh keeps listening on loopback (no restart, no patch)',
1239
+ })
1240
+ return
1241
+ }
1242
+ await disableRemote(proxy)
1243
+ notifyChanged()
1113
1244
  json(res, 200, {
1114
1245
  ok: true,
1115
- enabled,
1116
- note: enabled
1117
- ? 'patch written; dsh HMR will re-listen on 0.0.0.0'
1118
- : 'patch removed; dsh HMR will re-listen on 127.0.0.1',
1119
- patchFile: result.file,
1246
+ enabled: false,
1247
+ note: 'lan listener stopped; local access was never affected',
1120
1248
  })
1121
1249
  } catch (error) {
1122
1250
  json(res, 500, { ok: false, error: String(error?.message || error) })
@@ -1818,6 +1946,114 @@ html[data-dsh-mobile] .dsh-usage-table {
1818
1946
  min-width: 320px !important;
1819
1947
  }
1820
1948
  }
1949
+
1950
+ /* ================================================================== */
1951
+ /* 移动端侧栏:默认彻底收起,悬浮按钮唤出「抽屉」 */
1952
+ /* ------------------------------------------------------------------ */
1953
+ /* 原生在 <1024px 时会把侧栏折成 56px 图标 rail(IconPanelLeft 按钮), */
1954
+ /* 这在 390px 宽的手机上仍吃掉 14% 的横向空间;而原生展开是「挤压中栏」的 */
1955
+ /* 网格列(center 会被压到 ~110px),手机上基本不可用。 */
1956
+ /* 因此这里改成: */
1957
+ /* 1) 收起的常态下把侧栏轨道压到 0px(内容区吃满整屏); */
1958
+ /* 2) 点悬浮按钮展开时,让侧栏脱离网格、变成覆盖式抽屉 + 半透明遮罩; */
1959
+ /* 3) 抽屉的开合仍然驱动官方 store(点击原生 toggle 按钮),只是用 CSS */
1960
+ /* 把「挤压」改写成「浮层」,因此原生交互、快捷键、aria 全部保留。 */
1961
+ /* 框架节点用 data-dsh-mobile-frame 标记(由 MOBILE_JS 打上),避免依赖 */
1962
+ /* harness 的哈希类名。 */
1963
+ /* ================================================================== */
1964
+
1965
+ /* 常态:侧栏轨道 0 宽,内容区独占整屏 */
1966
+ html[data-dsh-mobile] [data-dsh-mobile-frame] {
1967
+ grid-template-columns: 0px minmax(0px, 1fr) minmax(0px, 0px) !important;
1968
+ }
1969
+
1970
+ /* 抽屉打开:侧栏变成从左侧滑入的浮层 */
1971
+ html[data-dsh-mobile][data-dsh-nav='open'] [data-dsh-mobile-frame] > div:first-child {
1972
+ position: fixed !important;
1973
+ left: 0 !important;
1974
+ top: 0 !important;
1975
+ bottom: 0 !important;
1976
+ width: min(86vw, 330px) !important;
1977
+ max-width: 86vw !important;
1978
+ z-index: 9010 !important;
1979
+ overflow: hidden !important;
1980
+ border-right: 1px solid var(--dsw-alias-border-l2, rgba(127, 127, 127, .22)) !important;
1981
+ border-radius: 0 16px 16px 0 !important;
1982
+ box-shadow: 0 18px 60px rgba(0, 0, 0, .38) !important;
1983
+ animation: dshMobileNavIn .2s cubic-bezier(.2, .8, .3, 1);
1984
+ }
1985
+
1986
+ /* 侧栏内部根节点:宽度铺满抽屉(原生在窄屏下用内联宽度 = rail 宽度) */
1987
+ html[data-dsh-mobile][data-dsh-nav='open'] [data-dsh-mobile-frame] > div:first-child > * {
1988
+ width: 100% !important;
1989
+ max-width: none !important;
1990
+ height: 100% !important;
1991
+ }
1992
+
1993
+ @keyframes dshMobileNavIn {
1994
+ from { transform: translateX(-14px); opacity: .6; }
1995
+ to { transform: translateX(0); opacity: 1; }
1996
+ }
1997
+
1998
+ /* 抽屉遮罩:点击任意空白处收起 */
1999
+ #dsh-mobile-nav-scrim {
2000
+ position: fixed;
2001
+ inset: 0;
2002
+ z-index: 9005;
2003
+ background: rgba(0, 0, 0, .46);
2004
+ opacity: 0;
2005
+ pointer-events: none;
2006
+ transition: opacity .18s ease;
2007
+ -webkit-tap-highlight-color: transparent;
2008
+ }
2009
+
2010
+ html[data-dsh-mobile][data-dsh-nav='open'] #dsh-mobile-nav-scrim {
2011
+ opacity: 1;
2012
+ pointer-events: auto;
2013
+ }
2014
+
2015
+ /* 抽屉打开时锁住背景滚动(只锁 body,不动框架自身布局) */
2016
+ html[data-dsh-mobile][data-dsh-nav='open'] body {
2017
+ overflow: hidden !important;
2018
+ overscroll-behavior: none;
2019
+ }
2020
+
2021
+ /* 悬浮唤出按钮(收起态唯一入口;抽屉打开时淡出) */
2022
+ #dsh-mobile-nav-btn {
2023
+ position: fixed;
2024
+ top: calc(env(safe-area-inset-top, 0px) + 8px);
2025
+ left: 8px;
2026
+ width: 38px;
2027
+ height: 38px;
2028
+ display: flex;
2029
+ align-items: center;
2030
+ justify-content: center;
2031
+ padding: 0;
2032
+ border: 1px solid var(--dsw-alias-border-l2, rgba(127, 127, 127, .26));
2033
+ border-radius: 12px;
2034
+ background: var(--dsw-alias-bg-l1, rgba(255, 255, 255, .86));
2035
+ color: var(--dsw-alias-text-l1, inherit);
2036
+ box-shadow: 0 2px 12px rgba(0, 0, 0, .18);
2037
+ -webkit-backdrop-filter: blur(10px);
2038
+ backdrop-filter: blur(10px);
2039
+ z-index: 9020;
2040
+ cursor: pointer;
2041
+ transition: transform .15s ease, opacity .15s ease;
2042
+ }
2043
+
2044
+ #dsh-mobile-nav-btn:active {
2045
+ transform: scale(.94);
2046
+ }
2047
+
2048
+ html[data-dsh-mobile][data-dsh-nav='open'] #dsh-mobile-nav-btn {
2049
+ opacity: 0;
2050
+ pointer-events: none;
2051
+ }
2052
+
2053
+ /* 抽屉里没有可折叠的宿主时(极端情况)也不留白 */
2054
+ html[data-dsh-mobile]:not([data-dsh-nav='open']) [data-dsh-mobile-frame] > div:first-child {
2055
+ overflow: hidden !important;
2056
+ }
1821
2057
  `
1822
2058
 
1823
2059
  /* ------------------------------------------------------------------ */
@@ -2236,6 +2472,155 @@ const MOBILE_JS = `
2236
2472
  });
2237
2473
  } catch (e) {}
2238
2474
  }
2475
+
2476
+ /* ------------------------------------------------------------------ */
2477
+ /* 移动端侧栏:默认收起,悬浮按钮唤出覆盖式抽屉 */
2478
+ /* ------------------------------------------------------------------ */
2479
+ /* 原生在窄屏把侧栏折成 56px 的 icon rail,展开又是「挤压中栏」的网格列—— */
2480
+ /* 手机上一展开内容区只剩百来像素。这里保留官方 store 与原生 toggle 按钮, */
2481
+ /* 只用 CSS 把「挤压」改写成「浮层抽屉」: */
2482
+ /* · 常态:侧栏轨道 0px,内容吃满整屏(由 MOBILE_CSS 负责); */
2483
+ /* · 点悬浮按钮 → 触发原生 toggle(store 真正展开)→ 打上 data-dsh-nav */
2484
+ /* · 点遮罩 / 按 Esc / 选中会话 → 触发原生 toggle 收起并去掉标记 */
2485
+ function mrNavInit() {
2486
+ if (!isMobile()) return;
2487
+ if (document.getElementById('dsh-mobile-nav-btn')) return;
2488
+
2489
+ var html = document.documentElement;
2490
+ var SIDEBAR_LABELS = ['打开侧边栏', 'Open sidebar', '展开侧边栏'];
2491
+ var COLLAPSE_LABELS = ['收起侧边栏', 'Collapse sidebar'];
2492
+
2493
+ function findFrame() {
2494
+ var overlay = document.querySelector('[data-shell-overlay]');
2495
+ if (overlay && overlay.parentElement) return overlay.parentElement;
2496
+ var root = document.getElementById('root');
2497
+ return root ? root.firstElementChild : null;
2498
+ }
2499
+
2500
+ function markFrame() {
2501
+ var frame = findFrame();
2502
+ if (!frame) return null;
2503
+ if (!frame.hasAttribute('data-dsh-mobile-frame')) frame.setAttribute('data-dsh-mobile-frame', '');
2504
+ return frame;
2505
+ }
2506
+
2507
+ function sidebarCollapsed() {
2508
+ var frame = findFrame();
2509
+ return !!(frame && frame.hasAttribute('data-sidebar-collapsed'));
2510
+ }
2511
+
2512
+ function findToggle(wanted) {
2513
+ var buttons = document.querySelectorAll('button[aria-label]');
2514
+ for (var i = 0; i < buttons.length; i++) {
2515
+ var label = buttons[i].getAttribute('aria-label') || '';
2516
+ for (var j = 0; j < wanted.length; j++) {
2517
+ if (label === wanted[j]) return buttons[i];
2518
+ }
2519
+ }
2520
+ return null;
2521
+ }
2522
+
2523
+ /* 点原生 toggle:不自造状态,保证官方 store / aria / 快捷键始终一致 */
2524
+ function clickNativeToggle(expand) {
2525
+ var btn = findToggle(expand ? SIDEBAR_LABELS : COLLAPSE_LABELS);
2526
+ if (!btn) {
2527
+ // 兜底:带侧栏快捷键标注的按钮(命令 id: sidebar.left.toggle)
2528
+ var alt = document.querySelector('button[aria-keyshortcuts]');
2529
+ var all = document.querySelectorAll('button[aria-keyshortcuts]');
2530
+ for (var k = 0; k < all.length; k++) {
2531
+ var ks = (all[k].getAttribute('aria-keyshortcuts') || '').toLowerCase();
2532
+ if (ks.indexOf('b') !== -1) { btn = all[k]; break; }
2533
+ }
2534
+ if (!btn) btn = alt;
2535
+ }
2536
+ if (!btn) return false;
2537
+ try { btn.click(); return true } catch (e) { return false }
2538
+ }
2539
+
2540
+ function openNav() {
2541
+ markFrame();
2542
+ if (sidebarCollapsed()) clickNativeToggle(true);
2543
+ html.setAttribute('data-dsh-nav', 'open');
2544
+ // 原生展开后会重新排版,下一帧再确认一次框架标记仍然有效
2545
+ requestAnimationFrame(markFrame);
2546
+ }
2547
+
2548
+ function closeNav() {
2549
+ // 先撤标记:避免原生收起动画期间出现「挤压态」的中间画面
2550
+ html.removeAttribute('data-dsh-nav');
2551
+ if (!sidebarCollapsed()) clickNativeToggle(false);
2552
+ }
2553
+
2554
+ var btn = document.createElement('button');
2555
+ btn.id = 'dsh-mobile-nav-btn';
2556
+ btn.type = 'button';
2557
+ btn.setAttribute('aria-label', '打开侧边栏');
2558
+ btn.innerHTML =
2559
+ '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" ' +
2560
+ 'stroke-width="2" stroke-linecap="round" aria-hidden="true">' +
2561
+ '<path d="M4 7h16M4 12h16M4 17h16"/></svg>';
2562
+ btn.addEventListener('click', function (event) {
2563
+ event.preventDefault();
2564
+ event.stopPropagation();
2565
+ if (html.getAttribute('data-dsh-nav') === 'open') closeNav();
2566
+ else openNav();
2567
+ });
2568
+
2569
+ var scrim = document.createElement('div');
2570
+ scrim.id = 'dsh-mobile-nav-scrim';
2571
+ scrim.setAttribute('aria-hidden', 'true');
2572
+ scrim.addEventListener('click', function (event) {
2573
+ event.preventDefault();
2574
+ closeNav();
2575
+ });
2576
+
2577
+ document.body.appendChild(scrim);
2578
+ document.body.appendChild(btn);
2579
+
2580
+ // Esc 关闭(外接键盘 / 平板)
2581
+ document.addEventListener('keydown', function (event) {
2582
+ if (event.key === 'Escape' && html.getAttribute('data-dsh-nav') === 'open') closeNav();
2583
+ });
2584
+
2585
+ // 抽屉里点了会话 / 工作区等可导航项就自动收起
2586
+ document.addEventListener('click', function (event) {
2587
+ if (html.getAttribute('data-dsh-nav') !== 'open') return;
2588
+ var frame = findFrame();
2589
+ var col = frame && frame.firstElementChild;
2590
+ if (!col || !col.contains(event.target)) return;
2591
+ if (event.target.closest && event.target.closest('button[aria-label]')) {
2592
+ var label = event.target.closest('button[aria-label]').getAttribute('aria-label');
2593
+ if (COLLAPSE_LABELS.indexOf(label) !== -1) { closeNav(); return }
2594
+ }
2595
+ var item = event.target.closest && event.target.closest('a,[role="option"],[role="treeitem"],[role="menuitem"]');
2596
+ if (item) setTimeout(closeNav, 60);
2597
+ }, true);
2598
+
2599
+ // 原生 toggle(含快捷键 Alt+B)改变了折叠态时,保持我们的标记同步
2600
+ var frameWatch = null;
2601
+ function watchFrame() {
2602
+ var frame = markFrame();
2603
+ if (!frame) return false;
2604
+ if (frameWatch || typeof MutationObserver === 'undefined') return true;
2605
+ frameWatch = new MutationObserver(function () {
2606
+ var open = html.getAttribute('data-dsh-nav') === 'open';
2607
+ var collapsed = frame.hasAttribute('data-sidebar-collapsed');
2608
+ if (open && collapsed) html.removeAttribute('data-dsh-nav');
2609
+ });
2610
+ try { frameWatch.observe(frame, { attributes: true, attributeFilter: ['data-sidebar-collapsed'] }) } catch (e) {}
2611
+ return true;
2612
+ }
2613
+
2614
+ // 框架节点可能晚于本脚本出现:短暂重试 + 兜底轮询
2615
+ var tries = 0;
2616
+ (function tick() {
2617
+ if (watchFrame()) return;
2618
+ if (tries++ > 40) return;
2619
+ setTimeout(tick, 250);
2620
+ })();
2621
+ }
2622
+
2623
+ try { mrNavInit(); } catch (e) {}
2239
2624
  })();
2240
2625
  `
2241
2626