@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/CHANGELOG.md +59 -0
- package/README.md +48 -11
- package/README.zh.md +53 -16
- package/RELEASE_NOTES.md +46 -81
- package/lib/client.js +1059 -1061
- package/lib/index.js +435 -50
- package/lib/lanproxy.js +228 -0
- package/lib/password.js +245 -218
- package/lib/patch.js +382 -244
- package/package.json +4 -4
package/lib/patch.js
CHANGED
|
@@ -1,244 +1,382 @@
|
|
|
1
|
-
// dsh-mobile-remote — profile patch(cordis.patch.yml)受管区域读写(host 侧)。
|
|
2
|
-
//
|
|
3
|
-
// 背景(对 dsh 0.1.
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
if (!
|
|
117
|
-
const
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
const
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
*
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
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
|
+
}
|