@feiyang666/dsh-mobile-remote 1.8.1 → 1.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/patch.js CHANGED
@@ -1,244 +1,382 @@
1
- // dsh-mobile-remote — profile patch(cordis.patch.yml)受管区域读写(host 侧)。
2
- //
3
- // 背景(对 dsh 0.1.7 / 0.2.x 源码的实际行为):
4
- // 官方 CLI 出于安全硬编码拒绝 `--host 0.0.0.0`(见 dsh-web-app/lib/startup.js),
5
- // 但 profile 的 patch 层完整支持覆盖 `webserver.host`。因此本插件用 patch 打开
6
- // 局域网监听。而 dsh 的 connection 服务另有一道独立的 **Host/Origin 信任围栏**
7
- // (isTrustedApiRequest):非回环 authority 只有在 `connection.trustedHosts`
8
- // 里才会放行,否则手机的 /api 请求一律 403(页面能打开但一直转圈/报错)。
9
- //
10
- // 所以"开启远程控制"必须同时写两块:
11
- // 1) `- id: webserver` → host: '0.0.0.0'(让手机能连上)
12
- // 2) `- id: connection` → trustedHosts: ['192.168.x.x:port', ...](让手机能调 API)
13
- //
14
- // 两块写在同一个受管区域内,便于整体增删,也避免多次追加造成互相冲突。
15
- // 依赖零第三方 YAML 库,仅做精确文本匹配。
16
-
17
- import { existsSync, readFileSync, writeFileSync } from 'node:fs'
18
- import { join } from 'node:path'
19
- import { homedir, networkInterfaces } from 'node:os'
20
-
21
- /** 受管区域起始行标记(含 'dsh-mobile-remote managed' 子串)。 */
22
- export const MANAGED_START = '# --- dsh-mobile-remote managed (auto-generated; do not edit) ---'
23
-
24
- /** 受管区域结束行标记。 */
25
- export const MANAGED_END = '# --- end dsh-mobile-remote managed ---'
26
-
27
- /**
28
- * 求 profile 的 patch 文件路径。
29
- * DSH_PROFILE / DSH_PROFILE_DIR 优先,其次 DSH_HOME,最后 ~/.dsh。
30
- * @param {import('@deepseek-ai/cordis').Context} [ctx]
31
- * @returns {string}
32
- */
33
- export function patchFilePath(_ctx) {
34
- const home = process.env.DSH_HOME || join(homedir(), '.dsh')
35
- const dir = process.env.DSH_PROFILE_DIR
36
- const profile = process.env.DSH_PROFILE || 'web'
37
- // 宿主给了 profile 目录就直接用,避免 DSH_PROFILE 与实际运行 profile 不一致。
38
- if (dir) return join(dir, 'cordis.patch.yml')
39
- return join(home, 'profiles', profile, 'cordis.patch.yml')
40
- }
41
-
42
- /** 读取 patch 文件内容与行数组。 */
43
- export function readPatchFile(ctx) {
44
- const file = patchFilePath(ctx)
45
- const content = existsSync(file) ? readFileSync(file, 'utf8') : ''
46
- return { file, content, lines: content.split(/\r?\n/) }
47
- }
48
-
49
- /**
50
- * 受管区域的行区间 [start, end)(含分隔注释)。不存在返回 null。
51
- * @param {string[]} lines
52
- * @returns {{start: number, end: number}|null}
53
- */
54
- export function findManagedBlock(lines) {
55
- const start = lines.findIndex((l) => l.includes('# --- dsh-mobile-remote managed'))
56
- if (start === -1) return null
57
- const end = lines.findIndex((l, i) => i > start && l.includes('# --- end dsh-mobile-remote managed'))
58
- if (end === -1) return { start, end: lines.length }
59
- return { start, end: end + 1 }
60
- }
61
-
62
- /**
63
- * 归一化成一个「裸 authority」(host 或 host:port),使其能通过 dsh 的
64
- * assertTrustedAuthority 校验:必须是 WHATWG 解析后保持不变的规范形式。
65
- * 形如 http://1.2.3.4:80/ 的输入、带路径/大小写/默认端口的输入都会被收敛。
66
- * @param {string} raw
67
- * @returns {string|null} 规范 authority,无法解析时返回 null
68
- */
69
- export function normalizeAuthority(raw) {
70
- if (typeof raw !== 'string') return null
71
- let value = raw.trim()
72
- if (!value) return null
73
- // 去掉可能出现的协议与路径,只保留 authority 部分。
74
- value = value.replace(/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//, '')
75
- value = value.split('/')[0].split('?')[0].split('#')[0]
76
- // 用户信息禁止出现在 trustedHosts(会授权内嵌主机名),直接拒绝。
77
- if (value.includes('@')) return null
78
- let url
79
- try {
80
- url = new URL(`http://${value}`)
81
- } catch {
82
- return null
83
- }
84
- if (!url.hostname) return null
85
- const host = url.hostname.toLowerCase()
86
- const port = url.port
87
- // 端口只在原始字符串显式写出时才保留(:80/:443 对裸 authority 无意义且易被改写)。
88
- return port ? `${host}:${port}` : host
89
- }
90
-
91
- /**
92
- * 汇总需要放行的 authority 列表(去重、保持顺序、仅保留规范形式)。
93
- * @param {{lanAddresses?: string[], port?: number, domains?: string[], extra?: string[]}} input
94
- * @returns {string[]}
95
- */
96
- export function collectTrustedAuthorities(input = {}) {
97
- const { lanAddresses = [], port, domains = [], extra = [] } = input
98
- const out = []
99
- const push = (v) => { if (v && !out.includes(v)) out.push(v) }
100
- // 局域网 IP:DSH 的围栏对回环之外的 authority 要求显式声明。
101
- for (const ip of lanAddresses) push(normalizeAuthority(port ? `${ip}:${port}` : ip))
102
- // 外网域名:不带端口(经 frp / 反向代理时浏览器 Host 通常无端口)。
103
- for (const d of domains) push(normalizeAuthority(d))
104
- // 显式附加项(用户自定义 / 兼容旧的运行时来源)。
105
- for (const e of extra) push(normalizeAuthority(e))
106
- return out
107
- }
108
-
109
- /**
110
- * 生成受管区域的文本行。
111
- * @param {{enabled: boolean, port: number, authorities?: string[]}} opts
112
- * @returns {string[]}
113
- */
114
- export function renderManagedBlock(opts) {
115
- const { enabled, port, authorities = [] } = opts
116
- if (!enabled) return []
117
- const lines = [
118
- '',
119
- MANAGED_START,
120
- '- id: webserver',
121
- ' config:',
122
- " host: '0.0.0.0'",
123
- ` port: !!js ctx.webStartup.port ?? ${port}`,
124
- ]
125
- if (authorities.length) {
126
- lines.push('- id: connection')
127
- lines.push(' config:')
128
- lines.push(` trustedHosts: [${authorities.map((a) => `'${a}'`).join(', ')}]`)
129
- }
130
- lines.push(MANAGED_END, '')
131
- return lines
132
- }
133
-
134
- /** 从受管区域里解析出已有的 trustedHosts authority(供增量更新)。 */
135
- export function readManagedAuthorities(lines, block) {
136
- if (!block) return []
137
- const out = []
138
- for (let i = block.start; i < Math.min(block.end, lines.length); i++) {
139
- const m = /^\s*trustedHosts:\s*\[(.*)\]\s*$/.exec(lines[i])
140
- if (!m) continue
141
- for (const part of m[1].split(',')) {
142
- const a = normalizeAuthority(part.replace(/['"]/g, '').trim())
143
- if (a && !out.includes(a)) out.push(a)
144
- }
145
- }
146
- return out
147
- }
148
-
149
- /**
150
- * 写入受管区域(整体替换,幂等)。
151
- * 关闭时整块移除;开启时写入 webserver 块,并在有 authority 时附带 connection 块。
152
- *
153
- * authorities 语义(既避免"探测失败把已放行的域名清掉",也避免"空数组被误当成沿用"):
154
- * - 未传(undefined) → 沿用文件中已有的 authority;
155
- * - 传了数组(含空数组) → 以传入值为准,但与文件中已有的合并去重。这样开启开关时
156
- * 即使局域网探测失败,也不会丢掉外网隧道此前写入的域名。
157
- * @param {import('@deepseek-ai/cordis').Context} [ctx]
158
- * @param {{enabled: boolean, port?: number, authorities?: string[]}} opts
159
- * @returns {{file: string, enabled: boolean, authorities: string[]}}
160
- */
161
- export function writeManagedPatch(ctx, opts) {
162
- const { file, lines } = readPatchFile(ctx)
163
- const block = findManagedBlock(lines)
164
- const port = opts.port || 3080
165
- const existing = readManagedAuthorities(lines, block)
166
- const authorities = Array.isArray(opts.authorities)
167
- ? [...new Set([...opts.authorities, ...existing])]
168
- : existing
169
-
170
- let out = lines.slice()
171
- // 移除官方模板的空数组占位行 `[]`:它单独构成一个 YAML 文档,与追加的列表块
172
- // 拼接会形成无 `---` 分隔符的多文档流,js-yaml 解析报
173
- // "end of the stream or a document separator is expected",导致 dsh 服务启动即崩溃。
174
- out = out.filter((l) => l.trim() !== '[]')
175
- // 行号会因上面的过滤而偏移,重新定位受管区域。
176
- const block2 = findManagedBlock(out)
177
- if (block2) out.splice(block2.start, block2.end - block2.start)
178
-
179
- if (opts.enabled) {
180
- out = [...out, ...renderManagedBlock({ enabled: true, port, authorities })]
181
- }
182
-
183
- const next = out.join('\n').replace(/\n{3,}/g, '\n\n').replace(/\s*$/, '\n')
184
- writeFileSync(file, next, 'utf8')
185
- return { file, enabled: !!opts.enabled, authorities }
186
- }
187
-
188
- /**
189
- * 确保某个 authority(通常是外网域名)加入受管区域的 connection.trustedHosts。
190
- * 与旧实现不同:不再依赖 `ctx.webRuntime.trustedHosts`(该字段在 dsh 0.1.7/0.2.x
191
- * 不存在,表达式会求值成对象而被 schema 拒绝),改为写出真实的字符串数组。
192
- * @param {string} patchFile
193
- * @param {string} authority - 裸 authority(host 或 host:port)
194
- * @returns {boolean} 是否已确保存在
195
- */
196
- export function ensureTrustedAuthority(patchFile, authority) {
197
- const target = normalizeAuthority(authority)
198
- if (!target) return false
199
- const content = existsSync(patchFile) ? readFileSync(patchFile, 'utf8') : ''
200
- const lines = content.split(/\r?\n/)
201
- const block = findManagedBlock(lines)
202
- if (block) {
203
- const existing = readManagedAuthorities(lines, block)
204
- if (existing.includes(target)) return true
205
- }
206
- const merged = block ? readManagedAuthorities(lines, block) : []
207
- if (!merged.includes(target)) merged.push(target)
208
- // 直接整体重写受管区域:保留原 webserver 开关状态(区域内是否存在 host: '0.0.0.0')。
209
- const inner = block ? lines.slice(block.start, block.end) : []
210
- const enabled = inner.some((l) => l.includes("host: '0.0.0.0'"))
211
- const portMatch = inner.map((l) => /port:\s*!!js[^\d]*(\d+)/.exec(l)).find(Boolean)
212
- const port = portMatch ? Number(portMatch[1]) : 3080
213
- let out = lines.slice()
214
- out = out.filter((l) => l.trim() !== '[]')
215
- const block2 = findManagedBlock(out)
216
- if (block2) out.splice(block2.start, block2.end - block2.start)
217
- out = [...out, ...renderManagedBlock({ enabled, port, authorities: merged })]
218
- const next = out.join('\n').replace(/\n{3,}/g, '\n\n').replace(/\s*$/, '\n')
219
- writeFileSync(patchFile, next, 'utf8')
220
- return true
221
- }
222
-
223
- /** 受管区域内是否已开启远程控制(host: '0.0.0.0')。 */
224
- export function isManagedPatchEnabled(ctx) {
225
- const { lines } = readPatchFile(ctx)
226
- const block = findManagedBlock(lines)
227
- if (!block) return false
228
- return lines.slice(block.start, block.end).some((l) => l.includes("host: '0.0.0.0'"))
229
- }
230
-
231
- /** 读取局域网 IPv4(非回环)地址列表。 */
232
- export function lanAddresses() {
233
- const out = []
234
- for (const list of Object.values(networkInterfaces() || {})) {
235
- for (const ni of list || []) {
236
- if (!ni || ni.internal) continue
237
- // Node 18+ 的 family 是 'IPv4',旧版是数字 4,两种都要兼容。
238
- const family = typeof ni.family === 'string' ? ni.family : `IPv${ni.family}`
239
- if (family !== 'IPv4') continue
240
- if (!out.includes(ni.address)) out.push(ni.address)
241
- }
242
- }
243
- return out
244
- }
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
+ }