@feiyang666/dsh-mobile-remote 1.7.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 +123 -0
- package/README.md +51 -12
- package/README.zh.md +56 -17
- package/RELEASE_NOTES.md +32 -27
- package/lib/client.js +18 -16
- package/lib/external.js +40 -38
- package/lib/index.js +742 -99
- package/lib/lanproxy.js +228 -0
- package/lib/password.js +27 -0
- package/lib/patch.js +382 -0
- package/lib/patch.js.bak-0000 +244 -0
- package/package.json +4 -4
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,
|
|
@@ -42,8 +43,14 @@ import {
|
|
|
42
43
|
import {
|
|
43
44
|
hasPassword, verifyPassword, setPassword,
|
|
44
45
|
isEnabled, login, verifyToken, revokeToken, clearSessions,
|
|
46
|
+
getSetting, setSetting,
|
|
45
47
|
} from './password.js'
|
|
46
48
|
import { wsAvailable } from './ws-client.js'
|
|
49
|
+
import { createLanProxy } from './lanproxy.js'
|
|
50
|
+
import {
|
|
51
|
+
patchFilePath, isManagedPatchEnabled, profileName, isLoopbackHost,
|
|
52
|
+
migrateLegacyPatch,
|
|
53
|
+
} from './patch.js'
|
|
47
54
|
|
|
48
55
|
/** 插件名(loader 诊断用)。 */
|
|
49
56
|
export const name = 'dsh-mobile-remote'
|
|
@@ -51,6 +58,170 @@ export const name = 'dsh-mobile-remote'
|
|
|
51
58
|
/** 依赖 dsh-host-webserver 注入的 webServer 服务,保证 tapIndex / register 可用。 */
|
|
52
59
|
export const inject = ['webServer']
|
|
53
60
|
|
|
61
|
+
// ------------------------------------------------------------
|
|
62
|
+
// 新版 dsh(>=0.1.2-rc.1)Web UI 浏览器会话认证适配
|
|
63
|
+
// 新版 dsh 每次启动生成一次性令牌,任何浏览器访问首页都必须先带
|
|
64
|
+
// `?token=`(服务端 303 + Set-Cookie 后跳回 /),否则返回 401。手机扫码 /
|
|
65
|
+
// 手动输入 LAN 地址访问的是同一个 dsh Web UI,同样受该认证保护。
|
|
66
|
+
// 因此本插件在返回「手机访问地址 / 二维码 URL / 外网 URL」时,若宿主提供了
|
|
67
|
+
// 官方 connection 服务(dsh-client-connection 的 BrowserAuth),就用其
|
|
68
|
+
// authenticatedUrl() 自动附加 token;老版 dsh(无 connection / 无认证)时
|
|
69
|
+
// 返回原 URL,行为与旧版完全一致。
|
|
70
|
+
// ------------------------------------------------------------
|
|
71
|
+
/**
|
|
72
|
+
* 用宿主 connection 服务的 authenticatedUrl() 给访问 URL 附加认证 token。
|
|
73
|
+
* 老版 dsh(无 connection / 未提供该方法)时原样返回,兼容旧版与命令行裸启。
|
|
74
|
+
* @param {import('@deepseek-ai/cordis').Context} ctx
|
|
75
|
+
* @param {string} base - 形如 http://192.168.1.5:3080 的裸访问地址
|
|
76
|
+
* @returns {string} 带 token 的完整访问地址(或原地址)
|
|
77
|
+
*/
|
|
78
|
+
function resolveConnection(ctx) {
|
|
79
|
+
if (!ctx) return undefined
|
|
80
|
+
// 1) cordis:ctx.get(name) 默认 strict=true,只返回「provider fiber 已激活」的服务。
|
|
81
|
+
// 插件 fiber 未 inject connection 时可能取不到,故补非严格与属性两条回退。
|
|
82
|
+
if (typeof ctx.get === 'function') {
|
|
83
|
+
try {
|
|
84
|
+
const strict = ctx.get('connection')
|
|
85
|
+
if (strict) return strict
|
|
86
|
+
} catch (e) { /* 继续回退 */ }
|
|
87
|
+
try {
|
|
88
|
+
const loose = ctx.get('connection', false)
|
|
89
|
+
if (loose) return loose
|
|
90
|
+
} catch (e) { /* 继续回退 */ }
|
|
91
|
+
}
|
|
92
|
+
// 2) 少数宿主把服务直接挂在 ctx 上(ctx.connection 在未 inject 时可能抛错)。
|
|
93
|
+
try {
|
|
94
|
+
if (ctx.connection) return ctx.connection
|
|
95
|
+
} catch (e) { /* 忽略 */ }
|
|
96
|
+
return undefined
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function withAuthUrl(ctx, base) {
|
|
100
|
+
if (!base || typeof base !== 'string') return base
|
|
101
|
+
try {
|
|
102
|
+
const conn = resolveConnection(ctx)
|
|
103
|
+
if (conn && typeof conn.authenticatedUrl === 'function') {
|
|
104
|
+
return conn.authenticatedUrl(base)
|
|
105
|
+
}
|
|
106
|
+
} catch (e) { /* 取 connection 失败则按无认证处理,返回原 URL */ }
|
|
107
|
+
return base
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* 把「带 token 的绝对地址」降级成 origin 相对的 `/?token=…`。
|
|
112
|
+
* 浏览器按当前协议 + authority 解析,因此 http / https / 反代路径前缀都能正确工作,
|
|
113
|
+
* 也避开了反代跨协议 301 丢 token 的问题。
|
|
114
|
+
* @param {string|null} absUrl - authenticatedUrl() 的返回值
|
|
115
|
+
* @returns {string|null} 形如 `/?token=xxx` 的相对地址;拿不到 token 时返回 null
|
|
116
|
+
*/
|
|
117
|
+
function relativeTokenLocation(absUrl) {
|
|
118
|
+
if (!absUrl || typeof absUrl !== 'string') return null
|
|
119
|
+
try {
|
|
120
|
+
const u = new URL(absUrl)
|
|
121
|
+
// 直接透传原始 pathname + search,不解析、不重新编码 token:
|
|
122
|
+
// token 是 base64url,经 searchParams 往返会引入 +/空格 之类的编码歧义。
|
|
123
|
+
if (!u.search || !/[?&]token=/.test(u.search)) return null
|
|
124
|
+
const path = u.pathname && u.pathname !== '' ? u.pathname : '/'
|
|
125
|
+
return `${path}${u.search}`
|
|
126
|
+
} catch (e) {
|
|
127
|
+
return null
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* 判断该请求对浏览器而言的真实协议。
|
|
134
|
+
* 外网访问通常经 nginx / frp 反代,本机看到的永远是明文 HTTP,
|
|
135
|
+
* 因此必须优先信任反代设置的 X-Forwarded-Proto,否则会生成 http:// 跳转,
|
|
136
|
+
* 被反代再 301 回 https,形成协议来回跳转。
|
|
137
|
+
* @param {import('node:http').IncomingMessage} req
|
|
138
|
+
* @returns {'http'|'https'}
|
|
139
|
+
*/
|
|
140
|
+
function requestScheme(req) {
|
|
141
|
+
try {
|
|
142
|
+
const xf = req.headers['x-forwarded-proto']
|
|
143
|
+
if (typeof xf === 'string' && xf.trim()) {
|
|
144
|
+
// 可能是 "https, http" 这样的列表,取最外层(第一个)。
|
|
145
|
+
const first = xf.split(',')[0].trim().toLowerCase()
|
|
146
|
+
if (first === 'https' || first === 'http') return first
|
|
147
|
+
}
|
|
148
|
+
// 直连时按 socket 是否 TLS 判断(本插件自身不终结 TLS,通常为 http)。
|
|
149
|
+
if (req.socket && req.socket.encrypted) return 'https'
|
|
150
|
+
} catch (e) { /* 忽略,回落 http */ }
|
|
151
|
+
return 'http'
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* 取宿主的浏览器会话认证器(connection.browserAuth)。
|
|
156
|
+
* 新版 dsh(>=0.1.2)由它完成 token → cookie 的官方握手;
|
|
157
|
+
* HostConnectionService 自身即 BrowserAuth 的委托层,字段不可见时可直接复用。
|
|
158
|
+
* @param {import('@deepseek-ai/cordis').Context} ctx
|
|
159
|
+
* @returns {object|undefined}
|
|
160
|
+
*/
|
|
161
|
+
function resolveBrowserAuth(ctx) {
|
|
162
|
+
const conn = resolveConnection(ctx)
|
|
163
|
+
if (!conn) return undefined
|
|
164
|
+
if (conn.browserAuth) return conn.browserAuth
|
|
165
|
+
if (typeof conn.authorizeIndex === 'function' && typeof conn.authenticatedUrl === 'function') return conn
|
|
166
|
+
return undefined
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// ------------------------------------------------------------
|
|
170
|
+
// 裸地址直达(自动登录)
|
|
171
|
+
// 手机 / 远程设备直接访问裸地址(http://192.168.x.x:3080,不带 token)时,
|
|
172
|
+
// 官方 dsh 会返回 401 纯文本提示。本插件接管 GET /:
|
|
173
|
+
// · 未授权 → 302 到带 token 的 URL(服务端自动完成官方 303 + Set-Cookie)
|
|
174
|
+
// · 已授权 → 返回官方 index(经内部请求取回,含全部 index taps 注入)
|
|
175
|
+
// 官方 token → cookie 的握手完整保留,只是把「手动重开带 token 链接」自动化。
|
|
176
|
+
// 内部拉取时需临时注销本插件的 / 路由,让请求直达官方 index owner。
|
|
177
|
+
// ------------------------------------------------------------
|
|
178
|
+
|
|
179
|
+
/** 简易 HTTP GET:收集 cookie、跟随 3xx 重定向(最多 4 跳),返回最终响应。 */
|
|
180
|
+
function httpFollow(startUrl, maxRedirects = 4) {
|
|
181
|
+
return new Promise((resolve) => {
|
|
182
|
+
const jar = new Map()
|
|
183
|
+
let current = startUrl
|
|
184
|
+
let hops = 0
|
|
185
|
+
const step = () => {
|
|
186
|
+
if (hops++ > maxRedirects) return resolve(null)
|
|
187
|
+
let u
|
|
188
|
+
try { u = new URL(current) } catch { return resolve(null) }
|
|
189
|
+
const headers = {}
|
|
190
|
+
if (jar.size) headers.cookie = [...jar.entries()].map(([k, v]) => `${k}=${v}`).join('; ')
|
|
191
|
+
const req = http.request({
|
|
192
|
+
host: u.hostname, port: Number(u.port) || 80,
|
|
193
|
+
path: u.pathname + (u.search || ''), method: 'GET', headers, timeout: 8000,
|
|
194
|
+
}, (res) => {
|
|
195
|
+
for (const sc of res.headers['set-cookie'] || []) {
|
|
196
|
+
const kv = String(sc).split(';')[0]
|
|
197
|
+
const at = kv.indexOf('=')
|
|
198
|
+
if (at > 0) jar.set(kv.slice(0, at).trim(), kv.slice(at + 1).trim())
|
|
199
|
+
}
|
|
200
|
+
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
|
|
201
|
+
res.resume()
|
|
202
|
+
try { current = new URL(res.headers.location, current).href } catch { return resolve(null) }
|
|
203
|
+
return step()
|
|
204
|
+
}
|
|
205
|
+
const chunks = []
|
|
206
|
+
res.on('data', (c) => chunks.push(c))
|
|
207
|
+
res.on('end', () => {
|
|
208
|
+
if (res.statusCode !== 200) return resolve(null)
|
|
209
|
+
const keep = {}
|
|
210
|
+
for (const h of ['content-type', 'cache-control', 'content-security-policy', 'referrer-policy', 'x-content-type-options']) {
|
|
211
|
+
if (res.headers[h]) keep[h] = res.headers[h]
|
|
212
|
+
}
|
|
213
|
+
resolve({ status: 200, headers: keep, body: Buffer.concat(chunks) })
|
|
214
|
+
})
|
|
215
|
+
res.on('error', () => resolve(null))
|
|
216
|
+
})
|
|
217
|
+
req.on('timeout', () => { req.destroy(); resolve(null) })
|
|
218
|
+
req.on('error', () => resolve(null))
|
|
219
|
+
req.end()
|
|
220
|
+
}
|
|
221
|
+
step()
|
|
222
|
+
})
|
|
223
|
+
}
|
|
224
|
+
|
|
54
225
|
/** 移动端断点:与 DSH 原生 SIDEBAR_AUTO_COLLAPSE = 1024 对齐。 */
|
|
55
226
|
const MOBILE_MAX_WIDTH = 1024
|
|
56
227
|
|
|
@@ -78,10 +249,18 @@ function notifyChanged() {
|
|
|
78
249
|
* @param {import('@deepseek-ai/cordis').Context} ctx
|
|
79
250
|
*/
|
|
80
251
|
export function apply(ctx) {
|
|
81
|
-
//
|
|
82
|
-
const remoteEnabled = ctx.webServer.host === '0.0.0.0'
|
|
252
|
+
// 宿主端口(官方桌面端用 --port 0,由系统分配,启动后才是真实值)。
|
|
83
253
|
const port = ctx.webServer.port
|
|
84
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
|
+
|
|
85
264
|
// 活跃设备表:deviceId -> 详细设备信息(见 handleHeartbeat)。
|
|
86
265
|
const devices = new Map()
|
|
87
266
|
// 累计去重设备数 + 累计心跳数(进程生命周期内的统计,供设置页展示更丰富的数据)。
|
|
@@ -97,7 +276,7 @@ export function apply(ctx) {
|
|
|
97
276
|
const external = { proc: null, status: 'idle', url: null, domain: null, tunnelPort: null }
|
|
98
277
|
|
|
99
278
|
ctx.effect(() => {
|
|
100
|
-
const dispose = ctx.webServer.tapIndex((html) => injectIntoIndex(html,
|
|
279
|
+
const dispose = ctx.webServer.tapIndex((html) => injectIntoIndex(html, remoteEnabledNow()))
|
|
101
280
|
return () => dispose()
|
|
102
281
|
}, 'dsh-mobile-remote: index transform')
|
|
103
282
|
|
|
@@ -110,8 +289,8 @@ export function apply(ctx) {
|
|
|
110
289
|
const dshTimer = setInterval(refreshDsh, 60_000)
|
|
111
290
|
|
|
112
291
|
const disposers = [
|
|
113
|
-
ctx.webServer.register({ kind: 'exact', path: '/__dsh_remote/status', handler: authGate(handleStatus(ctx, devices, external, tracker, dshCache)) }),
|
|
114
|
-
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)) }),
|
|
115
294
|
ctx.webServer.register({ kind: 'exact', path: '/__dsh_remote/heartbeat', handler: authGate(handleHeartbeat(devices, tracker)) }),
|
|
116
295
|
ctx.webServer.register({ kind: 'exact', path: '/__dsh_remote/qr', handler: authGate(handleQr()) }),
|
|
117
296
|
ctx.webServer.register({ kind: 'exact', path: '/__dsh_remote/external/start', handler: authGate(handleExternalStart(ctx, external)) }),
|
|
@@ -121,30 +300,224 @@ export function apply(ctx) {
|
|
|
121
300
|
// —— SSE 实时事件流:设备心跳 / 远程开关 / 外网隧道状态变化时推送 {"type":"changed"} ——
|
|
122
301
|
ctx.webServer.register({ kind: 'exact', path: '/__dsh_remote/events', handler: authGate(handleEvents()) }),
|
|
123
302
|
// —— 远程访问密码门禁相关(auth 系列不套 authGate,需放行)——
|
|
124
|
-
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) }),
|
|
125
304
|
ctx.webServer.register({ kind: 'exact', path: '/__dsh_remote/auth', handler: handleAuth() }),
|
|
126
305
|
ctx.webServer.register({ kind: 'exact', path: '/__dsh_remote/logout', handler: handleLogout() }),
|
|
127
306
|
ctx.webServer.register({ kind: 'exact', path: '/__dsh_remote/set-password', handler: handleSetPassword() }),
|
|
128
307
|
// —— Service Worker:远程访问首屏缓存加速(仅 HTTPS 外网生效)——
|
|
129
308
|
ctx.webServer.register({ kind: 'exact', path: '/__dsh_remote/sw.js', handler: handleServiceWorker() }),
|
|
130
309
|
]
|
|
310
|
+
|
|
311
|
+
// —— 裸地址直达(自动登录):接管 GET / ——
|
|
312
|
+
// 未授权访问裸地址时 302 到带 token 的 URL(浏览器自动完成官方 303 + Set-Cookie),
|
|
313
|
+
// 已授权时返回官方 index。官方 token→cookie 握手完整保留。
|
|
314
|
+
let rootDisposed = false
|
|
315
|
+
let rootRouteDisposer = null
|
|
316
|
+
let rootIndexCache = null // { headers, body } 官方最终 index(含全部 index taps 注入)
|
|
317
|
+
let rootIndexJob = null
|
|
318
|
+
|
|
319
|
+
const registerRootRoute = () => {
|
|
320
|
+
if (rootDisposed || rootRouteDisposer) return
|
|
321
|
+
rootRouteDisposer = ctx.webServer.register({ kind: 'exact', path: '/', handler: rootHandler })
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// 内部拉取官方 index:临时注销本插件的 / 路由,让请求直达官方 index owner
|
|
325
|
+
// (frontend-static,经完整授权检查与全部 taps),取回最终 HTML 后缓存并重新注册。
|
|
326
|
+
// 关键:必须用「带 token 的本机地址」拉取;若宿主启用浏览器会话认证而此处拿不到
|
|
327
|
+
// token,官方会回 401,则 rootIndexCache 保持 null,由调用方回退到官方 index owner。
|
|
328
|
+
const ensureRootIndex = () => {
|
|
329
|
+
if (rootIndexCache) return Promise.resolve(rootIndexCache)
|
|
330
|
+
if (!rootIndexJob) {
|
|
331
|
+
rootIndexJob = (async () => {
|
|
332
|
+
if (rootRouteDisposer) { try { rootRouteDisposer() } catch { /* ignore */ } rootRouteDisposer = null }
|
|
333
|
+
try {
|
|
334
|
+
await new Promise((r) => setTimeout(r, 30))
|
|
335
|
+
const p = port || 3080
|
|
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
|
|
342
|
+
rootIndexCache = await httpFollow(tokenUrl)
|
|
343
|
+
// 无 token 可用且官方回 401:说明宿主强制认证但本上下文取不到 browserAuth。
|
|
344
|
+
// 绝不能把空结果当成可用 index,交由调用方走官方 index owner。
|
|
345
|
+
if (same && rootIndexCache === null) {
|
|
346
|
+
try { ctx.logger?.warn?.('[dsh-mobile-remote] 无法获取浏览器会话 token:首页自动登录不可用,已交回官方认证') } catch { /* ignore */ }
|
|
347
|
+
}
|
|
348
|
+
} catch { /* 保持 null,由调用方回退 */ }
|
|
349
|
+
registerRootRoute()
|
|
350
|
+
rootIndexJob = null
|
|
351
|
+
return rootIndexCache
|
|
352
|
+
})()
|
|
353
|
+
}
|
|
354
|
+
return rootIndexJob
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
async function handleRootRequest(req, res) {
|
|
358
|
+
const conn = resolveConnection(ctx)
|
|
359
|
+
const ba = resolveBrowserAuth(ctx)
|
|
360
|
+
let url
|
|
361
|
+
try { url = new URL(req.url ?? '/', 'http://x') } catch { url = new URL('/', 'http://x') }
|
|
362
|
+
if (ba) {
|
|
363
|
+
if (url.searchParams.has('token') && typeof ba.authorizeIndex === 'function') {
|
|
364
|
+
// 带 token:走官方握手(匹配 → 303 + Set-Cookie;无效 → 官方 401),浏览器自动跟随
|
|
365
|
+
const ok = ba.authorizeIndex(req, res)
|
|
366
|
+
if (!ok) return
|
|
367
|
+
// ok===true:该请求已可授权,继续 serve index
|
|
368
|
+
} else if (typeof ba.isAuthenticated === 'function' && !ba.isAuthenticated(req)) {
|
|
369
|
+
// 未授权的裸地址访问:自动登录——302 到带 token 的地址,
|
|
370
|
+
// 浏览器自动完成官方 303 + Set-Cookie 后回到裸 /,全程无感。
|
|
371
|
+
const host = req.headers.host || `127.0.0.1:${port || 3080}`
|
|
372
|
+
const scheme = requestScheme(req)
|
|
373
|
+
const target = typeof conn.authenticatedUrl === 'function'
|
|
374
|
+
? conn.authenticatedUrl(`${scheme}://${host}/`)
|
|
375
|
+
: (typeof ba.authenticatedUrl === 'function' ? ba.authenticatedUrl(`${scheme}://${host}/`) : null)
|
|
376
|
+
// 关键:跳转地址一律降级为「相对地址」(只保留 ?token=…)。
|
|
377
|
+
// 原因是外网访问经 nginx / frp 反代时,本进程只看到明文 HTTP,
|
|
378
|
+
// 无法可靠推断浏览器侧的协议:若发出 http:// 绝对地址,反代会再
|
|
379
|
+
// 301 回 https,且 token 在这一次跨协议跳转中被丢掉,握手永远完不成
|
|
380
|
+
// (表现为外网打不开、/api 一直 401)。相对地址由浏览器按当前
|
|
381
|
+
// 协议与 authority 自行解析,http/https/反代前缀全都正确。
|
|
382
|
+
const relative = relativeTokenLocation(target)
|
|
383
|
+
if (relative) {
|
|
384
|
+
res.writeHead(302, { location: relative, 'cache-control': 'no-store' })
|
|
385
|
+
res.end()
|
|
386
|
+
return
|
|
387
|
+
}
|
|
388
|
+
// 取不到 token:不能回 302 到自身(会导致浏览器无限重定向循环),
|
|
389
|
+
// 明确回 401,交回官方语义。
|
|
390
|
+
res.writeHead(401, { 'content-type': 'text/plain; charset=utf-8', 'cache-control': 'no-store' })
|
|
391
|
+
res.end('dsh web authentication required; reopen the URL printed by dsh web.\n')
|
|
392
|
+
return
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
// 已授权 / 老版 dsh 无认证 / 取不到 browserAuth:交回官方 index owner。
|
|
396
|
+
// token 可用时 ensureRootIndex 会带回完整 index;否则保持 null,由下方回退。
|
|
397
|
+
const idx = await ensureRootIndex()
|
|
398
|
+
if (idx) {
|
|
399
|
+
res.writeHead(200, idx.headers)
|
|
400
|
+
res.end(idx.body)
|
|
401
|
+
return
|
|
402
|
+
}
|
|
403
|
+
if (ba) {
|
|
404
|
+
// 已确证存在认证器却仍拿不到 index:说明确实需要认证,明确回 401,
|
|
405
|
+
// 而不是把 503「启动中」这种误导性状态返回给手机端。
|
|
406
|
+
res.writeHead(401, { 'content-type': 'text/plain; charset=utf-8', 'cache-control': 'no-store' })
|
|
407
|
+
res.end('dsh web authentication required; reopen the URL printed by dsh web.\n')
|
|
408
|
+
return
|
|
409
|
+
}
|
|
410
|
+
res.writeHead(503, { 'content-type': 'text/plain; charset=utf-8' })
|
|
411
|
+
res.end('dsh web is starting, please try again in a moment.')
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
const rootHandler = (req, res) => {
|
|
415
|
+
void handleRootRequest(req, res).catch(() => {
|
|
416
|
+
try { if (!res.headersSent) { res.writeHead(500); res.end() } } catch { /* ignore */ }
|
|
417
|
+
})
|
|
418
|
+
}
|
|
419
|
+
registerRootRoute()
|
|
420
|
+
|
|
131
421
|
const timer = setInterval(() => {
|
|
132
422
|
const now = Date.now()
|
|
133
423
|
for (const [id, info] of devices) {
|
|
134
424
|
if (now - info.lastSeen > HEARTBEAT_TTL) devices.delete(id)
|
|
135
425
|
}
|
|
136
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
|
+
|
|
137
471
|
// dsh 重启后自动恢复外网访问:读取持久化的绑定码 / 中转地址重新拉起 frpc,
|
|
138
472
|
// 免去每次手动输入。设置 DSH_DISABLE_AUTO_RESTORE=1 可关闭该行为。
|
|
139
473
|
if (process.env.DSH_DISABLE_AUTO_RESTORE !== '1') {
|
|
140
474
|
const patchFile = patchFilePath(ctx)
|
|
141
475
|
void autoRestoreExternal(external, patchFile, ctx.webServer.port || 3080)
|
|
142
476
|
}
|
|
477
|
+
|
|
478
|
+
// ---- 端口自适应 ----
|
|
479
|
+
// frpc 代理的 localPort 必须始终等于当前真实监听端口,否则流量会打到无人监听
|
|
480
|
+
// 的端口上,表现为外网域名 502 Bad Gateway(frpc 日志:connect to local
|
|
481
|
+
// service ... refused)。端口可能因 --port 变更、被占用后回退到 0(OS 分配)、
|
|
482
|
+
// 或 profile patch 改动而变化,因此这里持续跟随,变化即重新绑定隧道。
|
|
483
|
+
let lastKnownPort = ctx.webServer.port || 0
|
|
484
|
+
const portWatcher = setInterval(() => {
|
|
485
|
+
const now = ctx.webServer.port || 0
|
|
486
|
+
if (!now || now === lastKnownPort) return
|
|
487
|
+
const from = lastKnownPort
|
|
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
|
+
}
|
|
497
|
+
const persisted = loadPersistedState()
|
|
498
|
+
// 仅在隧道确实在跑、且有绑定码可复用时才自动重绑(避免无谓打中转服务器)。
|
|
499
|
+
if (external.proc && external.proc.exitCode === null && persisted && persisted.bindCode) {
|
|
500
|
+
void startExternal(external, {
|
|
501
|
+
bindCode: persisted.bindCode,
|
|
502
|
+
serverBase: persisted.serverBase || undefined,
|
|
503
|
+
}, patchFilePath(ctx), now).catch(() => { /* 状态由 startExternal 内部记录 */ })
|
|
504
|
+
try {
|
|
505
|
+
ctx.logger?.info?.(`[dsh-mobile-remote] 监听端口 ${from} → ${now},外网隧道已自适应重绑`)
|
|
506
|
+
} catch { /* ignore */ }
|
|
507
|
+
}
|
|
508
|
+
}, 15_000)
|
|
509
|
+
|
|
143
510
|
return () => {
|
|
144
511
|
clearInterval(timer)
|
|
145
512
|
clearInterval(dshTimer)
|
|
513
|
+
clearInterval(portWatcher)
|
|
514
|
+
for (const t of migrationTimers) clearTimeout(t)
|
|
146
515
|
stopFrpc(external)
|
|
516
|
+
void lan.stop().catch(() => { /* 忽略 */ })
|
|
147
517
|
for (const dispose of disposers) dispose()
|
|
518
|
+
// 裸地址直达(/ 接管)路由的清理
|
|
519
|
+
rootDisposed = true
|
|
520
|
+
if (rootRouteDisposer) { try { rootRouteDisposer() } catch { /* ignore */ } rootRouteDisposer = null }
|
|
148
521
|
}
|
|
149
522
|
}, 'dsh-mobile-remote: api routes')
|
|
150
523
|
}
|
|
@@ -183,15 +556,19 @@ function json(res, status, body) {
|
|
|
183
556
|
res.end(payload)
|
|
184
557
|
}
|
|
185
558
|
|
|
186
|
-
/** 本机局域网 IPv4
|
|
559
|
+
/** 本机局域网 IPv4 地址列表(不含回环与 APIPA 自动私有地址)。 */
|
|
187
560
|
function getLanAddresses() {
|
|
188
561
|
const out = []
|
|
189
562
|
for (const entries of Object.values(networkInterfaces() ?? {})) {
|
|
190
563
|
for (const iface of entries ?? []) {
|
|
191
|
-
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)
|
|
192
567
|
}
|
|
193
568
|
}
|
|
194
|
-
|
|
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))
|
|
195
572
|
}
|
|
196
573
|
|
|
197
574
|
/** 所有 IPv4 网卡详情(含网卡名 / 掩码 / MAC / CIDR / 是否回环),供设置页展示。 */
|
|
@@ -213,8 +590,11 @@ function getLanInterfaces() {
|
|
|
213
590
|
return out
|
|
214
591
|
}
|
|
215
592
|
|
|
216
|
-
/**
|
|
217
|
-
|
|
593
|
+
/**
|
|
594
|
+
* 当前进程运行时信息(诊断用,设置页展示)。
|
|
595
|
+
* @param {import('@deepseek-ai/cordis').Context} [ctx]
|
|
596
|
+
*/
|
|
597
|
+
function runtimeInfo(ctx) {
|
|
218
598
|
// 采集时刻:前端可据此在两次 /status 轮询之间本地累加,让"运行时长"每秒跳动。
|
|
219
599
|
const at = Date.now()
|
|
220
600
|
const mem = process.memoryUsage()
|
|
@@ -241,7 +621,8 @@ function runtimeInfo() {
|
|
|
241
621
|
rss: mem.rss,
|
|
242
622
|
heapUsed: mem.heapUsed,
|
|
243
623
|
heapTotal: mem.heapTotal,
|
|
244
|
-
profile
|
|
624
|
+
// profile 以宿主实际加载的 profileContext 为准(官方桌面端不设 DSH_PROFILE 环境变量)
|
|
625
|
+
profile: profileName(ctx),
|
|
245
626
|
dshHome: process.env.DSH_HOME || join(homedir(), '.dsh'),
|
|
246
627
|
}
|
|
247
628
|
}
|
|
@@ -254,13 +635,27 @@ function withTimeout(promise, ms, fallback = null) {
|
|
|
254
635
|
])
|
|
255
636
|
}
|
|
256
637
|
|
|
257
|
-
/**
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
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'))
|
|
264
659
|
if (process.env.APPDATA) {
|
|
265
660
|
candidates.push(
|
|
266
661
|
join(process.env.APPDATA, 'dsh-desktop', 'dsh-local', 'node_modules', '@deepseek-ai', 'dsh', 'package.json'),
|
|
@@ -284,7 +679,7 @@ function detectDshVersion() {
|
|
|
284
679
|
*/
|
|
285
680
|
async function collectDshInfo(ctx) {
|
|
286
681
|
const out = { version: null, sessions: null, workspaces: null, plugins: null, llm: null }
|
|
287
|
-
try { out.version = detectDshVersion() } catch { /* ignore */ }
|
|
682
|
+
try { out.version = detectDshVersion(ctx) } catch { /* ignore */ }
|
|
288
683
|
try {
|
|
289
684
|
const sp = ctx.get('sessionPersistence')
|
|
290
685
|
if (sp && typeof sp.list === 'function') {
|
|
@@ -452,7 +847,7 @@ function handleEvents() {
|
|
|
452
847
|
/** GET /__dsh_remote/auth-status:返回远程访问密码的启用状态与本次请求是否需鉴权。
|
|
453
848
|
* 额外返回 service 摘要(服务名 / 本机主机名 / dsh 版本 / 当前访问域名),
|
|
454
849
|
* 供密码门禁界面展示"当前是谁在提供服务"等更多信息。 */
|
|
455
|
-
function handleAuthStatus() {
|
|
850
|
+
function handleAuthStatus(ctx) {
|
|
456
851
|
return async (req, res) => {
|
|
457
852
|
json(res, 200, {
|
|
458
853
|
ok: true,
|
|
@@ -465,7 +860,7 @@ function handleAuthStatus() {
|
|
|
465
860
|
service: {
|
|
466
861
|
name: 'DeepSeek Harness',
|
|
467
862
|
hostname: hostname(),
|
|
468
|
-
version: detectDshVersion() || '',
|
|
863
|
+
version: detectDshVersion(ctx) || '',
|
|
469
864
|
host: String(req.headers.host || ''),
|
|
470
865
|
},
|
|
471
866
|
})
|
|
@@ -654,81 +1049,44 @@ async function queryServerStatus(base, bindCode) {
|
|
|
654
1049
|
return data
|
|
655
1050
|
}
|
|
656
1051
|
|
|
657
|
-
/**
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
1052
|
+
/**
|
|
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
|
+
* 也不写任何配置文件:开关即时生效、无需重启、本机访问完全不受影响。
|
|
1060
|
+
* @param {import('@deepseek-ai/cordis').Context} ctx
|
|
1061
|
+
* @param {ReturnType<typeof createLanProxy>} proxy
|
|
1062
|
+
*/
|
|
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
|
|
663
1069
|
}
|
|
664
1070
|
|
|
665
1071
|
/**
|
|
666
|
-
*
|
|
667
|
-
*
|
|
668
|
-
* @returns {{ file: string, content: string, lines: string[] }}
|
|
1072
|
+
* 关闭局域网访问:释放旁挂听众,本机监听自始至终未受影响。
|
|
1073
|
+
* @param {ReturnType<typeof createLanProxy>} proxy
|
|
669
1074
|
*/
|
|
670
|
-
function
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
return { file, content, lines: content.split(/\r?\n/) }
|
|
674
|
-
}
|
|
675
|
-
|
|
676
|
-
/** webserver 远程控制覆盖块的行区间(含分隔注释)。不存在返回 null。 */
|
|
677
|
-
function findRemoteBlock(lines) {
|
|
678
|
-
const start = lines.findIndex((l) => l.includes('# --- dsh-mobile-remote managed'))
|
|
679
|
-
if (start === -1) return null
|
|
680
|
-
const end = lines.findIndex((l, i) => i > start && l.includes('# --- end dsh-mobile-remote managed'))
|
|
681
|
-
if (end === -1) return { start, end: lines.length }
|
|
682
|
-
return { start, end: end + 1 }
|
|
683
|
-
}
|
|
684
|
-
|
|
685
|
-
/** 检查当前 patch 中是否已启用远程控制。 */
|
|
686
|
-
function isPatchEnabled(ctx) {
|
|
687
|
-
const { lines } = readPatchFile(ctx)
|
|
688
|
-
const block = findRemoteBlock(lines)
|
|
689
|
-
if (!block) return false
|
|
690
|
-
return lines.slice(block.start, block.end).some((l) => l.includes("host: '0.0.0.0'"))
|
|
691
|
-
}
|
|
692
|
-
|
|
693
|
-
/** 追加 / 移除 webserver host 覆盖块。 */
|
|
694
|
-
function setPatchEnabled(ctx, enabled) {
|
|
695
|
-
const { file, content, lines } = readPatchFile(ctx)
|
|
696
|
-
const block = findRemoteBlock(lines)
|
|
697
|
-
let out = lines.slice()
|
|
698
|
-
// 移除官方模板的空数组占位行 `[]`:它单独构成一个 YAML 文档,与追加的
|
|
699
|
-
// 列表块拼接会形成无 `---` 分隔符的多文档流,js-yaml 解析报
|
|
700
|
-
// "end of the stream or a document separator is expected",导致 dsh 服务启动即崩溃。
|
|
701
|
-
out = out.filter((l) => l.trim() !== '[]')
|
|
702
|
-
|
|
703
|
-
if (block) {
|
|
704
|
-
out.splice(block.start, block.end - block.start)
|
|
705
|
-
}
|
|
706
|
-
if (enabled) {
|
|
707
|
-
const blockLines = [
|
|
708
|
-
'',
|
|
709
|
-
'# --- dsh-mobile-remote managed (auto-generated; do not edit) ---',
|
|
710
|
-
'- id: webserver',
|
|
711
|
-
' config:',
|
|
712
|
-
" host: '0.0.0.0'",
|
|
713
|
-
' port: !!js ctx.webStartup.port ?? ' + (ctx.webServer.port || 3080),
|
|
714
|
-
'# --- end dsh-mobile-remote managed ---',
|
|
715
|
-
'',
|
|
716
|
-
]
|
|
717
|
-
out = [...out, ...blockLines]
|
|
718
|
-
}
|
|
719
|
-
|
|
720
|
-
const next = out.join('\n').replace(/\n{3,}/g, '\n\n').replace(/\s*$/, '\n')
|
|
721
|
-
writeFileSync(file, next, 'utf8')
|
|
722
|
-
return { file, enabled }
|
|
1075
|
+
async function disableRemote(proxy) {
|
|
1076
|
+
await proxy.stop()
|
|
1077
|
+
setSetting('remoteEnabled', false)
|
|
723
1078
|
}
|
|
724
1079
|
|
|
725
1080
|
/** GET /__dsh_remote/status */
|
|
726
|
-
function handleStatus(ctx, devices, external, tracker, dshCache) {
|
|
1081
|
+
function handleStatus(ctx, devices, external, tracker, dshCache, proxy) {
|
|
727
1082
|
return async (req, res) => {
|
|
728
1083
|
const lan = getLanAddresses()
|
|
729
|
-
const
|
|
1084
|
+
const proxyState = proxy.status()
|
|
1085
|
+
const remoteEnabled = proxyState.running
|
|
730
1086
|
// 外网 frpc 是否仍在运行
|
|
731
1087
|
const extRunning = !!external.proc && external.proc.exitCode === null
|
|
1088
|
+
// 手机访问地址统一附加新版 dsh 的浏览器会话 token(无认证时返回原 URL)
|
|
1089
|
+
const authUrl = (u) => withAuthUrl(ctx, u)
|
|
732
1090
|
// 已持久化的绑定信息(重启后自动恢复用,前端据此回填表单)
|
|
733
1091
|
const persisted = loadPersistedState()
|
|
734
1092
|
// 活跃设备详情列表(按最近活跃倒序)
|
|
@@ -782,13 +1140,24 @@ function handleStatus(ctx, devices, external, tracker, dshCache) {
|
|
|
782
1140
|
lanAddresses: lan,
|
|
783
1141
|
lanInterfaces: getLanInterfaces(),
|
|
784
1142
|
remoteEnabled,
|
|
785
|
-
|
|
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
|
+
},
|
|
1151
|
+
patchEnabled: isManagedPatchEnabled(ctx),
|
|
786
1152
|
deviceCount: devices.size,
|
|
787
1153
|
devices: deviceList,
|
|
788
1154
|
patchFile: patchFilePath(ctx),
|
|
789
|
-
|
|
1155
|
+
profile: profileName(ctx),
|
|
1156
|
+
url: lan.length ? authUrl(`http://${lan[0]}:${ctx.webServer.port}`) : null,
|
|
1157
|
+
// 全部局域网 IP 的「手机可访问」地址(已带新版 dsh 认证 token;无认证时即裸地址)
|
|
1158
|
+
lanUrls: lan.map((ip) => authUrl(`http://${ip}:${ctx.webServer.port}`)),
|
|
790
1159
|
// 本机运行时信息(诊断用,设置页展示更详细的数据)
|
|
791
|
-
runtime: runtimeInfo(),
|
|
1160
|
+
runtime: runtimeInfo(ctx),
|
|
792
1161
|
uptime: Math.floor(process.uptime()),
|
|
793
1162
|
nodeVersion: process.version,
|
|
794
1163
|
totalDevicesEver: tracker.seenEver.size,
|
|
@@ -801,7 +1170,7 @@ function handleStatus(ctx, devices, external, tracker, dshCache) {
|
|
|
801
1170
|
enabled: extRunning,
|
|
802
1171
|
running: extRunning,
|
|
803
1172
|
status: external.status || 'idle',
|
|
804
|
-
url: extRunning ? external.url : null,
|
|
1173
|
+
url: extRunning && external.url ? authUrl(external.url) : null,
|
|
805
1174
|
domain: external.domain,
|
|
806
1175
|
tunnelPort: external.tunnelPort,
|
|
807
1176
|
frpcVersion: external.frpcVersion || null,
|
|
@@ -839,26 +1208,43 @@ function handleStatus(ctx, devices, external, tracker, dshCache) {
|
|
|
839
1208
|
},
|
|
840
1209
|
cliHint: remoteEnabled
|
|
841
1210
|
? null
|
|
842
|
-
: 'remote control is off; toggle it in settings
|
|
1211
|
+
: 'remote control is off; toggle it in settings (no restart required)',
|
|
843
1212
|
})
|
|
844
1213
|
}
|
|
845
1214
|
}
|
|
846
1215
|
|
|
847
1216
|
/** POST /__dsh_remote/toggle { enabled: boolean } */
|
|
848
|
-
function handleToggle(ctx) {
|
|
1217
|
+
function handleToggle(ctx, proxy) {
|
|
849
1218
|
return async (req, res) => {
|
|
850
1219
|
const body = await readBody(req)
|
|
851
1220
|
const enabled = body.enabled === true
|
|
852
1221
|
try {
|
|
853
|
-
|
|
854
|
-
|
|
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()
|
|
855
1244
|
json(res, 200, {
|
|
856
1245
|
ok: true,
|
|
857
|
-
enabled,
|
|
858
|
-
note:
|
|
859
|
-
? 'patch written; dsh HMR will re-listen on 0.0.0.0'
|
|
860
|
-
: 'patch removed; dsh HMR will re-listen on 127.0.0.1',
|
|
861
|
-
patchFile: result.file,
|
|
1246
|
+
enabled: false,
|
|
1247
|
+
note: 'lan listener stopped; local access was never affected',
|
|
862
1248
|
})
|
|
863
1249
|
} catch (error) {
|
|
864
1250
|
json(res, 500, { ok: false, error: String(error?.message || error) })
|
|
@@ -1560,6 +1946,114 @@ html[data-dsh-mobile] .dsh-usage-table {
|
|
|
1560
1946
|
min-width: 320px !important;
|
|
1561
1947
|
}
|
|
1562
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
|
+
}
|
|
1563
2057
|
`
|
|
1564
2058
|
|
|
1565
2059
|
/* ------------------------------------------------------------------ */
|
|
@@ -1978,6 +2472,155 @@ const MOBILE_JS = `
|
|
|
1978
2472
|
});
|
|
1979
2473
|
} catch (e) {}
|
|
1980
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) {}
|
|
1981
2624
|
})();
|
|
1982
2625
|
`
|
|
1983
2626
|
|