@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/CHANGELOG.md +51 -0
- package/README.md +48 -11
- package/README.zh.md +53 -16
- package/RELEASE_NOTES.md +36 -74
- package/lib/client.js +9 -11
- package/lib/index.js +435 -50
- package/lib/lanproxy.js +228 -0
- package/lib/password.js +27 -0
- package/lib/patch.js +382 -244
- package/lib/patch.js.bak-0000 +244 -0
- package/package.json +4 -4
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
|
-
|
|
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,
|
|
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
|
-
|
|
327
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
/**
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
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
|
-
*
|
|
964
|
-
*
|
|
965
|
-
*
|
|
966
|
-
*
|
|
967
|
-
*
|
|
968
|
-
*
|
|
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 {
|
|
1061
|
+
* @param {ReturnType<typeof createLanProxy>} proxy
|
|
971
1062
|
*/
|
|
972
|
-
function
|
|
973
|
-
const port = ctx.webServer.port ||
|
|
974
|
-
|
|
975
|
-
const
|
|
976
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
1112
|
-
|
|
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:
|
|
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
|
|