@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.
@@ -0,0 +1,228 @@
1
+ // dsh-mobile-remote — 局域网旁挂听众(host 侧)。
2
+ //
3
+ // 为什么需要它(dsh 0.2.1-rc.3 起的现实):
4
+ // · webserver 的 host 只接受「一块具体网卡的 IP 字面量」,0.0.0.0 直接抛错退出;
5
+ // · 就算改绑到 192.168.x.x,本机 127.0.0.1 的监听也随之消失 —— 桌面端窗口、
6
+ // 官方桌面端(固定加载 http://127.0.0.1:<port>)会立刻白屏;
7
+ // · 官方桌面端用 `--port 0` 由系统分配端口,patch 里重述 port 会让端口漂移。
8
+ // 因此本模块采用「**本机监听完全不动,旁挂一个 LAN 听众**」的方案:
9
+ // · 在 `<局域网IP>:<当前端口>` 上再监听一份(同端口不同地址,Windows/Linux 都允许);
10
+ // · 把请求反向代理到 `127.0.0.1:<当前端口>`(TCP 层以上,HTTP 与 WebSocket 都通);
11
+ // · 代理时把 Host / Origin 改写成回环 authority —— dsh 的 Host/Origin 信任围栏
12
+ // 对回环一律放行,于是**局域网访问不需要任何 patch 改动**,也没有重启/CORS 风险;
13
+ // · 真实客户端 IP 通过 X-Forwarded-For 传给宿主(设备列表里仍然显示手机 IP)。
14
+ //
15
+ // 关闭时释放监听,本机访问自始至终不受影响。
16
+
17
+ import http from 'node:http'
18
+ import net from 'node:net'
19
+
20
+ /** 回环 authority(dsh 信任围栏自动放行的形式)。 */
21
+ function loopbackAuthority(port) {
22
+ return `127.0.0.1:${String(port)}`
23
+ }
24
+
25
+ /**
26
+ * 把 Origin 头的 authority 换成回环 authority(保留协议),
27
+ * 让围栏的「Origin 必须等于 Host」判断通过。
28
+ * @param {string} origin
29
+ * @param {string} authority
30
+ * @returns {string}
31
+ */
32
+ function rewriteOrigin(origin, authority) {
33
+ try {
34
+ const url = new URL(origin)
35
+ return `${url.protocol}//${authority}`
36
+ } catch {
37
+ return `http://${authority}`
38
+ }
39
+ }
40
+
41
+ /** 追加一条 X-Forwarded-For(保留上游已有值)。 */
42
+ function forwardedFor(headers, socket) {
43
+ const prev = headers['x-forwarded-for']
44
+ const ip = (socket && (socket.remoteAddress || '')) || ''
45
+ const clean = String(ip).replace(/^::ffff:/, '')
46
+ if (!clean) return prev
47
+ return prev ? `${prev}, ${clean}` : clean
48
+ }
49
+
50
+ /**
51
+ * 创建 LAN 旁挂听众。
52
+ * @param {{log?: (msg: string, err?: unknown) => void}} [options]
53
+ */
54
+ export function createLanProxy(options = {}) {
55
+ const log = typeof options.log === 'function' ? options.log : () => {}
56
+ /** @type {Array<{server: import('node:http').Server, host: string}>} */
57
+ let listeners = []
58
+ /** 当前实际监听端口(等于宿主端口)。 */
59
+ let listenPort = 0
60
+ /** 转发目标端口(宿主端口;监听建立前就已确定,避免启动窗口内 503)。 */
61
+ let targetPort = 0
62
+ /** 当前对外提供的局域网地址列表。 */
63
+ let hosts = []
64
+ /** 每次 start/stop 递增,用于丢弃过期的启动结果。 */
65
+ let generation = 0
66
+
67
+ const state = () => ({
68
+ running: listeners.length > 0,
69
+ hosts: hosts.slice(),
70
+ port: listenPort,
71
+ })
72
+
73
+ function targetPortOf() {
74
+ const n = Number(targetPort)
75
+ return Number.isFinite(n) && n > 0 ? n : 0
76
+ }
77
+
78
+ /** 普通 HTTP 请求转发。 */
79
+ function handleRequest(req, res) {
80
+ const port = targetPortOf()
81
+ if (!port) {
82
+ res.writeHead(503, { 'content-type': 'text/plain; charset=utf-8', 'cache-control': 'no-store' })
83
+ res.end('dsh is starting, please retry in a moment.\n')
84
+ return
85
+ }
86
+ const authority = loopbackAuthority(port)
87
+ const headers = { ...req.headers }
88
+ const realHost = headers.host || ''
89
+ headers.host = authority
90
+ if (headers.origin) headers.origin = rewriteOrigin(headers.origin, authority)
91
+ headers['x-forwarded-for'] = forwardedFor(headers, req.socket)
92
+ headers['x-forwarded-proto'] = 'http'
93
+ if (realHost) headers['x-forwarded-host'] = realHost
94
+ const upstream = http.request({
95
+ host: '127.0.0.1',
96
+ port,
97
+ method: req.method,
98
+ path: req.url,
99
+ headers,
100
+ }, (up) => {
101
+ res.writeHead(up.statusCode || 502, up.headers)
102
+ up.pipe(res)
103
+ })
104
+ upstream.on('error', (error) => {
105
+ log('lan proxy upstream error', error)
106
+ if (!res.headersSent) {
107
+ res.writeHead(502, { 'content-type': 'text/plain; charset=utf-8', 'cache-control': 'no-store' })
108
+ }
109
+ try { res.end('dsh upstream unavailable\n') } catch { /* ignore */ }
110
+ })
111
+ req.on('error', () => { try { upstream.destroy() } catch { /* ignore */ } })
112
+ req.pipe(upstream)
113
+ }
114
+
115
+ /** WebSocket / 其它 Upgrade 请求:按原始字节隧道转发。 */
116
+ function handleUpgrade(req, socket, head) {
117
+ const port = targetPortOf()
118
+ if (!port) { try { socket.destroy() } catch { /* ignore */ } return }
119
+ const authority = loopbackAuthority(port)
120
+ const headers = { ...req.headers }
121
+ const realHost = headers.host || ''
122
+ headers.host = authority
123
+ if (headers.origin) headers.origin = rewriteOrigin(headers.origin, authority)
124
+ headers['x-forwarded-for'] = forwardedFor(headers, req.socket)
125
+ headers['x-forwarded-proto'] = 'http'
126
+ if (realHost) headers['x-forwarded-host'] = realHost
127
+
128
+ const upstream = net.connect(port, '127.0.0.1', () => {
129
+ const lines = [`${req.method || 'GET'} ${req.url || '/'} HTTP/1.1`]
130
+ for (const [key, value] of Object.entries(headers)) {
131
+ if (value === undefined) continue
132
+ if (Array.isArray(value)) for (const item of value) lines.push(`${key}: ${item}`)
133
+ else lines.push(`${key}: ${value}`)
134
+ }
135
+ upstream.write(`${lines.join('\r\n')}\r\n\r\n`)
136
+ if (head && head.length) upstream.write(head)
137
+ upstream.pipe(socket)
138
+ socket.pipe(upstream)
139
+ })
140
+ upstream.on('error', (error) => {
141
+ log('lan proxy upgrade error', error)
142
+ try { socket.destroy() } catch { /* ignore */ }
143
+ })
144
+ socket.on('error', () => { try { upstream.destroy() } catch { /* ignore */ } })
145
+ }
146
+
147
+ /** 在一个 host 上开监听,失败时抛出。 */
148
+ function listenOne(host, port) {
149
+ return new Promise((resolve, reject) => {
150
+ const server = http.createServer(handleRequest)
151
+ server.on('upgrade', handleUpgrade)
152
+ const onError = (error) => {
153
+ server.removeListener('listening', onListening)
154
+ try { server.close() } catch { /* ignore */ }
155
+ reject(error)
156
+ }
157
+ const onListening = () => {
158
+ server.removeListener('error', onError)
159
+ server.on('error', (error) => log(`lan proxy ${host} error`, error))
160
+ // 长连接(SSE / WebSocket)不能因为 keep-alive 超时被误杀
161
+ server.keepAliveTimeout = 65_000
162
+ server.headersTimeout = 70_000
163
+ resolve(server)
164
+ }
165
+ server.once('error', onError)
166
+ server.once('listening', onListening)
167
+ server.listen(port, host)
168
+ })
169
+ }
170
+
171
+ /**
172
+ * 在给定局域网地址上开启监听。
173
+ * @param {string[]} lanHosts - 局域网 IPv4 列表(按优先级排序)
174
+ * @param {number} port - 宿主当前监听端口
175
+ * @returns {Promise<{running: boolean, hosts: string[], port: number, failures: Array<{host: string, error: string}>}>}
176
+ */
177
+ async function start(lanHosts, port) {
178
+ const wanted = [...new Set((lanHosts || []).filter(Boolean))]
179
+ if (!wanted.length) {
180
+ return { running: false, hosts: [], port: 0, failures: [{ host: '', error: '没有可用的局域网 IPv4 地址' }] }
181
+ }
182
+ if (listeners.length > 0 && listenPort === port && hosts.join(',') === wanted.join(',')) {
183
+ return { running: true, hosts: hosts.slice(), port: listenPort, failures: [] }
184
+ }
185
+ await stop()
186
+ // stop() 已推进代次;此刻记录本轮的代次,后续任何 stop() 都会让它过期
187
+ const mark = generation
188
+ // 先确定转发目标:监听建立期间到达的请求也能正确转发
189
+ targetPort = port
190
+ const opened = []
191
+ const failures = []
192
+ for (const host of wanted) {
193
+ try {
194
+ opened.push({ server: await listenOne(host, port), host })
195
+ } catch (error) {
196
+ failures.push({ host, error: String((error && error.message) || error) })
197
+ }
198
+ }
199
+ if (mark !== generation) {
200
+ // 期间被 stop() 抢先:本次结果作废
201
+ for (const item of opened) { try { item.server.close() } catch { /* ignore */ } }
202
+ return state().running
203
+ ? { ...state(), failures }
204
+ : { running: false, hosts: [], port: 0, failures }
205
+ }
206
+ listeners = opened
207
+ listenPort = opened.length > 0 ? port : 0
208
+ hosts = opened.map((item) => item.host)
209
+ if (!opened.length) log('lan proxy: no listener could bind', failures)
210
+ return { running: opened.length > 0, hosts: hosts.slice(), port: listenPort, failures }
211
+ }
212
+
213
+ /** 关闭全部监听。 */
214
+ async function stop() {
215
+ generation += 1
216
+ const closing = listeners
217
+ listeners = []
218
+ listenPort = 0
219
+ targetPort = 0
220
+ hosts = []
221
+ await Promise.all(closing.map(({ server }) => new Promise((resolve) => {
222
+ try { server.close(() => resolve()) } catch { resolve() }
223
+ })))
224
+ return state()
225
+ }
226
+
227
+ return { start, stop, status: state }
228
+ }
package/lib/password.js CHANGED
@@ -133,6 +133,33 @@ export function isEnabled() {
133
133
  return hasPassword()
134
134
  }
135
135
 
136
+ /* ------------------------------------------------------------------ */
137
+ /* 插件本地配置(与密码共用 config.json,未知字段原样保留) */
138
+ /* ------------------------------------------------------------------ */
139
+
140
+ /**
141
+ * 读取插件配置里的一个值(不存在返回 fallback)。
142
+ * @param {string} key
143
+ * @param {*} [fallback]
144
+ */
145
+ export function getSetting(key, fallback = null) {
146
+ const cfg = readRawConfig()
147
+ return Object.prototype.hasOwnProperty.call(cfg, key) ? cfg[key] : fallback
148
+ }
149
+
150
+ /**
151
+ * 写入插件配置里的一个值(保留其它字段,例如密码哈希)。
152
+ * @param {string} key
153
+ * @param {*} value
154
+ */
155
+ export function setSetting(key, value) {
156
+ const cfg = readRawConfig()
157
+ if (value === undefined) delete cfg[key]
158
+ else cfg[key] = value
159
+ writeRawConfig(cfg)
160
+ return value
161
+ }
162
+
136
163
  /* ------------------------------------------------------------------ */
137
164
  /* 认证会话(内存) */
138
165
  /* ------------------------------------------------------------------ */
package/lib/patch.js ADDED
@@ -0,0 +1,382 @@
1
+ // dsh-mobile-remote — profile patch(cordis.patch.yml)受管区域读写(host 侧)。
2
+ //
3
+ // 背景(对 dsh 0.2.1-rc.x 源码的实际行为):
4
+ // 1) webserver 的 host 配置自 0.2.1-rc.3 起**只接受一块具体网卡的 IP 字面量**,
5
+ // `0.0.0.0` / `::` 这类通配地址会在配置校验阶段直接抛错、进程 code=1 退出。
6
+ // 因此「把 webserver 改绑到局域网 IP」这条路在新版是**不可用且破坏性的**:
7
+ // 一旦改绑,本机回环监听消失,桌面端窗口(固定连 127.0.0.1)立刻打不开;
8
+ // 官方桌面端还用 `--port 0`(系统分配端口),patch 里重述 port 会让端口在
9
+ // 重载后漂移,二维码/链接全部失效。
10
+ // 2) 所以新版插件**不再改 webserver 的绑定**:局域网访问由插件自己旁挂一个
11
+ // LAN 听众(见 lib/lanproxy.js)转发到回环监听,本机链路完全不动。
12
+ // 3) dsh 的 connection 服务另有一道 Host/Origin 信任围栏(isTrustedApiRequest):
13
+ // 非回环 authority 只有出现在 `connection.trustedHosts` 里才放行。
14
+ // LAN 听众会把 Host 改写成回环,因此局域网不需要放行;**外网隧道(frp/反代)
15
+ // 是直连真实监听**,Host 是外网域名,必须写进 trustedHosts,否则页面能开、
16
+ // /api 一律 403。
17
+ //
18
+ // 因此受管区域**只写 connection.trustedHosts 一块**,绝不写 webserver。
19
+ // 依赖零第三方 YAML 库,仅做精确文本匹配。
20
+
21
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs'
22
+ import { join } from 'node:path'
23
+ import { homedir, networkInterfaces } from 'node:os'
24
+ import { fileURLToPath } from 'node:url'
25
+
26
+ /** 受管区域起始行标记(含 'dsh-mobile-remote managed' 子串)。 */
27
+ export const MANAGED_START = '# --- dsh-mobile-remote managed (auto-generated; do not edit) ---'
28
+
29
+ /** 受管区域结束行标记。 */
30
+ export const MANAGED_END = '# --- end dsh-mobile-remote managed ---'
31
+
32
+ /**
33
+ * 求 profile 的 patch 文件路径。
34
+ *
35
+ * 优先级(从最可靠到兜底):
36
+ * 1) `ctx.get('profileContext').patchPath` —— 官方 app-boot 在启动时 provide 的
37
+ * 真实路径,CLI / 官方桌面端(--profile desktop)/ 第三方壳全部适用;
38
+ * 2) `ctx.baseUrl`(cordis.yml 所在目录)—— profileContext 不可用时的等价来源;
39
+ * 3) 环境变量 DSH_PROFILE_DIR / DSH_PROFILE + DSH_HOME;
40
+ * 4) ~/.dsh/profiles/web。
41
+ *
42
+ * 为什么不能只靠环境变量:官方桌面端启动 dsh 时**不设置 DSH_PROFILE**,
43
+ * 它只把 profile 目录作为 argv 传给 dsh-desktop-host。旧实现因此把 patch 写到
44
+ * `profiles/web`,而真正运行的是 `profiles/desktop` —— 开关点了等于没点。
45
+ * @param {import('@deepseek-ai/cordis').Context} [ctx]
46
+ * @returns {string}
47
+ */
48
+ export function patchFilePath(ctx) {
49
+ try {
50
+ const pc = typeof ctx?.get === 'function' ? ctx.get('profileContext') : ctx?.profileContext
51
+ if (pc && typeof pc.patchPath === 'string' && pc.patchPath) return pc.patchPath
52
+ } catch { /* 继续回退 */ }
53
+ try {
54
+ if (ctx && typeof ctx.baseUrl === 'string' && ctx.baseUrl) {
55
+ return join(fileURLToPath(ctx.baseUrl), 'cordis.patch.yml')
56
+ }
57
+ } catch { /* 继续回退 */ }
58
+ const home = process.env.DSH_HOME || join(homedir(), '.dsh')
59
+ const dir = process.env.DSH_PROFILE_DIR
60
+ const profile = process.env.DSH_PROFILE || 'web'
61
+ if (dir) return join(dir, 'cordis.patch.yml')
62
+ return join(home, 'profiles', profile, 'cordis.patch.yml')
63
+ }
64
+
65
+ /** 当前运行的 profile 名(诊断展示用)。 */
66
+ export function profileName(ctx) {
67
+ try {
68
+ const pc = typeof ctx?.get === 'function' ? ctx.get('profileContext') : ctx?.profileContext
69
+ if (pc && typeof pc.name === 'string' && pc.name) return pc.name
70
+ } catch { /* 忽略 */ }
71
+ return process.env.DSH_PROFILE || 'web'
72
+ }
73
+
74
+ /** 读取 patch 文件内容与行数组。 */
75
+ export function readPatchFile(ctx) {
76
+ const file = patchFilePath(ctx)
77
+ const content = existsSync(file) ? readFileSync(file, 'utf8') : ''
78
+ return { file, content, lines: content.split(/\r?\n/) }
79
+ }
80
+
81
+ /**
82
+ * 受管区域的行区间 [start, end)(含分隔注释)。不存在返回 null。
83
+ * @param {string[]} lines
84
+ * @returns {{start: number, end: number}|null}
85
+ */
86
+ export function findManagedBlock(lines) {
87
+ const start = lines.findIndex((l) => l.includes('# --- dsh-mobile-remote managed'))
88
+ if (start === -1) return null
89
+ const end = lines.findIndex((l, i) => i > start && l.includes('# --- end dsh-mobile-remote managed'))
90
+ if (end === -1) return { start, end: lines.length }
91
+ return { start, end: end + 1 }
92
+ }
93
+
94
+ /**
95
+ * 归一化成一个「裸 authority」(host 或 host:port),使其能通过 dsh 的
96
+ * assertTrustedAuthority 校验:必须是 WHATWG 解析后保持不变的规范形式。
97
+ * 形如 http://1.2.3.4:80/ 的输入、带路径/大小写/默认端口的输入都会被收敛。
98
+ * @param {string} raw
99
+ * @returns {string|null} 规范 authority,无法解析时返回 null
100
+ */
101
+ export function normalizeAuthority(raw) {
102
+ if (typeof raw !== 'string') return null
103
+ let value = raw.trim()
104
+ if (!value) return null
105
+ // 去掉可能出现的协议与路径,只保留 authority 部分。
106
+ value = value.replace(/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//, '')
107
+ value = value.split('/')[0].split('?')[0].split('#')[0]
108
+ // 用户信息禁止出现在 trustedHosts(会授权内嵌主机名),直接拒绝。
109
+ if (value.includes('@')) return null
110
+ let url
111
+ try {
112
+ url = new URL(`http://${value}`)
113
+ } catch {
114
+ return null
115
+ }
116
+ if (!url.hostname) return null
117
+ const host = url.hostname.toLowerCase()
118
+ const port = url.port
119
+ // 端口只在原始字符串显式写出时才保留(:80/:443 对裸 authority 无意义且易被改写)。
120
+ return port ? `${host}:${port}` : host
121
+ }
122
+
123
+ /**
124
+ * 汇总需要放行的 authority 列表(去重、保持顺序、仅保留规范形式)。
125
+ * @param {{lanAddresses?: string[], port?: number, domains?: string[], extra?: string[]}} input
126
+ * @returns {string[]}
127
+ */
128
+ export function collectTrustedAuthorities(input = {}) {
129
+ const { lanAddresses = [], port, domains = [], extra = [] } = input
130
+ const out = []
131
+ const push = (v) => { if (v && !out.includes(v)) out.push(v) }
132
+ // 局域网 IP:写成「无端口」形式,任何端口都匹配,重启后端口漂移也不会失效。
133
+ for (const ip of lanAddresses) push(normalizeAuthority(ip))
134
+ // 外网域名:不带端口(经 frp / 反向代理时浏览器 Host 通常无端口)。
135
+ for (const d of domains) push(normalizeAuthority(d))
136
+ // 显式附加项(用户自定义 / 兼容旧的运行时来源)。
137
+ for (const e of extra) push(normalizeAuthority(e))
138
+ return out
139
+ }
140
+
141
+ /** 绑定地址是否为回环(空值按回环处理)。 */
142
+ export function isLoopbackHost(host) {
143
+ const h = String(host || '').replace(/^\[|\]$/g, '').toLowerCase()
144
+ return !h || h === '::1' || h === 'localhost' || h === '::ffff:127.0.0.1' || /^127\./.test(h)
145
+ }
146
+
147
+ /**
148
+ * 选择要绑定的局域网 IPv4:私有网段优先(192.168 > 10 > 172.16-31),
149
+ * 跳过虚拟网卡与 APIPA;没有可用地址时返回 null。
150
+ * @returns {string|null}
151
+ */
152
+ export function pickBindAddress() {
153
+ const VIRTUAL = /vEthernet|VirtualBox|VMware|Hyper-V|WSL|Docker|Loopback|Npcap|Bluetooth|Tailscale|ZeroTier/i
154
+ let best = null
155
+ for (const [name, list] of Object.entries(networkInterfaces() || {})) {
156
+ if (VIRTUAL.test(name)) continue
157
+ for (const ni of list || []) {
158
+ if (!ni || ni.internal) continue
159
+ const family = typeof ni.family === 'string' ? ni.family : `IPv${ni.family}`
160
+ if (family !== 'IPv4') continue
161
+ const addr = String(ni.address || '')
162
+ if (!addr || addr.startsWith('169.254.')) continue
163
+ const score = /^192\.168\./.test(addr) ? 3
164
+ : /^10\./.test(addr) ? 2
165
+ : /^172\.(1[6-9]|2\d|3[01])\./.test(addr) ? 1 : 0
166
+ if (!best || score > best.score) best = { addr, score }
167
+ }
168
+ }
169
+ return best ? best.addr : null
170
+ }
171
+
172
+ /**
173
+ * 生成受管区域的文本行。
174
+ *
175
+ * **只写 connection.trustedHosts**:webserver 的 host/port 一律不碰(新版禁止通配,
176
+ * 且改绑会让本机回环监听消失、端口漂移)。信任列表用 `!!js` 表达式把官方
177
+ * webStartup 里的 --trusted-host 与我们的值合并,避免覆盖启动参数。
178
+ * @param {{enabled: boolean, authorities?: string[]}} opts
179
+ * @returns {string[]}
180
+ */
181
+ export function renderManagedBlock(opts) {
182
+ const { enabled, authorities = [] } = opts || {}
183
+ if (!enabled || authorities.length === 0) return []
184
+ const list = authorities.map((a) => `'${a}'`).join(', ')
185
+ // 关键:`!!js` 在这个 YAML schema 里只能作用于**标量**。写成 `!!js [...]` 会让
186
+ // js-yaml 把 `[...]` 解析成流式序列、再套标量标签,于是报
187
+ // "unknown tag !<tag:yaml.org,2002:js>" 并让 dsh 启动直接失败。
188
+ // 因此表达式必须整体加引号,作为标量交给 Loader 求值。
189
+ const expression = `[...ctx.webStartup.trustedHosts, ${list}]`
190
+ return [
191
+ '',
192
+ MANAGED_START,
193
+ '- id: connection',
194
+ ' config:',
195
+ ` trustedHosts: !!js "${expression}"`,
196
+ MANAGED_END,
197
+ '',
198
+ ]
199
+ }
200
+
201
+ /** 从受管区域里解析出已有的 trustedHosts authority(供增量更新)。 */
202
+ export function readManagedAuthorities(lines, block) {
203
+ if (!block) return []
204
+ const out = []
205
+ for (let i = block.start; i < Math.min(block.end, lines.length); i++) {
206
+ const line = lines[i]
207
+ // 新写法:trustedHosts: !!js "[...ctx.webStartup.trustedHosts, 'a', 'b']"
208
+ // 旧写法:trustedHosts: ['a', 'b']
209
+ const m = /^\s*trustedHosts\s*:\s*(?:!!js\s*)?["']?\[(.*?)\]["']?\s*$/.exec(line)
210
+ if (!m) continue
211
+ for (const part of m[1].split(',')) {
212
+ const text = part
213
+ .replace(/\.\.\.\s*ctx\.webStartup\.trustedHosts/g, '')
214
+ .replace(/['"]/g, '')
215
+ .trim()
216
+ if (!text) continue
217
+ const a = normalizeAuthority(text)
218
+ if (a && !out.includes(a)) out.push(a)
219
+ }
220
+ }
221
+ return out
222
+ }
223
+
224
+ /**
225
+ * 写入受管区域(整体替换,幂等)。
226
+ * 关闭时整块移除;开启时写入 connection.trustedHosts 块。
227
+ * 无论开关,受管区域里**遗留的 webserver 覆盖**都会被清掉(旧版本写进去的
228
+ * 0.0.0.0 / 局域网 IP 绑定在新版会毁掉本机访问)。
229
+ *
230
+ * authorities 语义(既避免"探测失败把已放行的域名清掉",也避免"空数组被误当成沿用"):
231
+ * - 未传(undefined) → 沿用文件中已有的 authority;
232
+ * - 传了数组(含空数组) → 以传入值为准,但与文件中已有的合并去重。
233
+ * @param {import('@deepseek-ai/cordis').Context} [ctx]
234
+ * @param {{enabled: boolean, authorities?: string[]}} opts
235
+ * @returns {{file: string, enabled: boolean, authorities: string[]}}
236
+ */
237
+ export function writeManagedPatch(ctx, opts) {
238
+ const { file, lines } = readPatchFile(ctx)
239
+ const block = findManagedBlock(lines)
240
+ const existing = readManagedAuthorities(lines, block)
241
+ const authorities = Array.isArray(opts.authorities)
242
+ ? [...new Set([...opts.authorities, ...existing])]
243
+ : existing
244
+
245
+ let out = lines.slice()
246
+ // 移除官方模板的空数组占位行 `[]`:它单独构成一个 YAML 文档,与追加的列表块
247
+ // 拼接会形成无 `---` 分隔符的多文档流,js-yaml 解析报
248
+ // "end of the stream or a document separator is expected",导致 dsh 服务启动即崩溃。
249
+ out = out.filter((l) => l.trim() !== '[]')
250
+ // 行号会因上面的过滤而偏移,重新定位受管区域。
251
+ const block2 = findManagedBlock(out)
252
+ if (block2) out.splice(block2.start, block2.end - block2.start)
253
+
254
+ if (opts.enabled) {
255
+ out = [...out, ...renderManagedBlock({ enabled: true, authorities })]
256
+ }
257
+
258
+ const next = out.join('\n').replace(/\n{3,}/g, '\n\n').replace(/\s*$/, '\n')
259
+ writeFileSync(file, next, 'utf8')
260
+ return { file, enabled: !!opts.enabled, authorities }
261
+ }
262
+
263
+ /**
264
+ * 确保某个 authority(通常是外网域名)加入受管区域的 connection.trustedHosts。
265
+ * @param {string} patchFile
266
+ * @param {string} authority - 裸 authority(host 或 host:port)
267
+ * @returns {boolean} 是否已确保存在
268
+ */
269
+ export function ensureTrustedAuthority(patchFile, authority) {
270
+ const target = normalizeAuthority(authority)
271
+ if (!target) return false
272
+ const content = existsSync(patchFile) ? readFileSync(patchFile, 'utf8') : ''
273
+ const lines = content.split(/\r?\n/)
274
+ const block = findManagedBlock(lines)
275
+ const merged = block ? readManagedAuthorities(lines, block) : []
276
+ if (merged.includes(target)) return true
277
+ merged.push(target)
278
+ let out = lines.slice()
279
+ out = out.filter((l) => l.trim() !== '[]')
280
+ const block2 = findManagedBlock(out)
281
+ if (block2) out.splice(block2.start, block2.end - block2.start)
282
+ out = [...out, ...renderManagedBlock({ enabled: true, authorities: merged })]
283
+ const next = out.join('\n').replace(/\n{3,}/g, '\n\n').replace(/\s*$/, '\n')
284
+ writeFileSync(patchFile, next, 'utf8')
285
+ return true
286
+ }
287
+
288
+ /** 受管区域里是否存在(trustedHosts 放行块)。 */
289
+ export function isManagedPatchEnabled(ctx) {
290
+ const { lines } = readPatchFile(ctx)
291
+ const block = findManagedBlock(lines)
292
+ if (!block) return false
293
+ return readManagedAuthorities(lines, block).length > 0
294
+ }
295
+
296
+ /**
297
+ * 清理旧版本遗留的 webserver 绑定覆盖(受管区域内)。
298
+ * 旧版本会写 `- id: webserver / host: 0.0.0.0|192.168.x.x`,新版这样写会让
299
+ * dsh 启动失败或让本机回环监听消失。这里只在**受管区域**内删除 webserver 条目,
300
+ * 用户自己手写的配置一律不动。
301
+ * @param {import('@deepseek-ai/cordis').Context} [ctx]
302
+ * @returns {boolean} 是否真的清理了内容
303
+ */
304
+ export function stripLegacyWebserverBinding(ctx) {
305
+ const { file, lines } = readPatchFile(ctx)
306
+ const block = findManagedBlock(lines)
307
+ if (!block) return false
308
+ const inner = lines.slice(block.start, block.end)
309
+ if (!inner.some((l) => /^\s*-?\s*id:\s*['"]?webserver['"]?\s*$/.test(l))) return false
310
+ const authorities = readManagedAuthorities(lines, block)
311
+ let out = lines.slice()
312
+ out = out.filter((l) => l.trim() !== '[]')
313
+ const block2 = findManagedBlock(out)
314
+ if (block2) out.splice(block2.start, block2.end - block2.start)
315
+ out = [...out, ...renderManagedBlock({ enabled: authorities.length > 0, authorities })]
316
+ const next = out.join('\n').replace(/\n{3,}/g, '\n\n').replace(/\s*$/, '\n')
317
+ writeFileSync(file, next, 'utf8')
318
+ return true
319
+ }
320
+
321
+ /**
322
+ * 清理旧版本遗留的「external trusted-host」块。
323
+ * 旧实现写入的是 `trustedHosts: !!js "['dom', ...ctx.webRuntime.trustedHosts]"`,
324
+ * 而 `ctx.webRuntime` 在 0.2.x 根本不存在 —— 表达式求值时会抛错。现在改成
325
+ * 受管区域里的真实字符串数组,因此这段必须清掉(其中的域名会迁移过去)。
326
+ * @param {import('@deepseek-ai/cordis').Context} [ctx]
327
+ * @returns {{changed: boolean, domains: string[]}}
328
+ */
329
+ export function stripLegacyExternalHostBlock(ctx) {
330
+ const { file, lines } = readPatchFile(ctx)
331
+ const start = lines.findIndex((l) => l.includes('# --- dsh-mobile-remote external trusted-host ---'))
332
+ if (start === -1) return { changed: false, domains: [] }
333
+ const end = lines.findIndex((l, i) => i > start && l.includes('# --- end dsh-mobile-remote external trusted-host ---'))
334
+ const inner = lines.slice(start, end === -1 ? start + 1 : end + 1)
335
+ const domains = []
336
+ for (const line of inner) {
337
+ const m = /trustedHosts\s*:\s*(?:!!js\s*)?["']?\[(.*?)\]/i.exec(line)
338
+ const scope = m ? m[1] : line
339
+ for (const quoted of scope.match(/'([^']+)'|"([^"]+)"/g) || []) {
340
+ const value = quoted.slice(1, -1)
341
+ if (!value || value.includes('ctx.')) continue
342
+ const a = normalizeAuthority(value)
343
+ if (a && !domains.includes(a)) domains.push(a)
344
+ }
345
+ }
346
+ const out = [...lines.slice(0, start), ...lines.slice(end === -1 ? start + 1 : end + 1)]
347
+ const next = out.join('\n').replace(/\n{3,}/g, '\n\n').replace(/\s*$/, '\n')
348
+ writeFileSync(file, next, 'utf8')
349
+ return { changed: true, domains }
350
+ }
351
+
352
+ /**
353
+ * 一次性迁移所有旧版本遗留结构:
354
+ * 1) `webserver` 绑定覆盖(会让本机回环监听消失 / 新版直接启动失败);
355
+ * 2) `ctx.webRuntime.trustedHosts` 表达式块(0.2.x 无此字段,求值即抛错);
356
+ * 3) 把迁移出来的域名并入受管 trustedHosts。
357
+ * @param {import('@deepseek-ai/cordis').Context} [ctx]
358
+ * @returns {{changed: boolean, domains: string[]}}
359
+ */
360
+ export function migrateLegacyPatch(ctx) {
361
+ const legacy = stripLegacyExternalHostBlock(ctx)
362
+ const removedBind = stripLegacyWebserverBinding(ctx)
363
+ for (const domain of legacy.domains) {
364
+ try { ensureTrustedAuthority(patchFilePath(ctx), domain) } catch { /* 忽略 */ }
365
+ }
366
+ return { changed: legacy.changed || removedBind, domains: legacy.domains }
367
+ }
368
+
369
+ /** 读取局域网 IPv4(非回环)地址列表。 */
370
+ export function lanAddresses() {
371
+ const out = []
372
+ for (const list of Object.values(networkInterfaces() || {})) {
373
+ for (const ni of list || []) {
374
+ if (!ni || ni.internal) continue
375
+ // Node 18+ 的 family 是 'IPv4',旧版是数字 4,两种都要兼容。
376
+ const family = typeof ni.family === 'string' ? ni.family : `IPv${ni.family}`
377
+ if (family !== 'IPv4') continue
378
+ if (!out.includes(ni.address)) out.push(ni.address)
379
+ }
380
+ }
381
+ return out
382
+ }