@feiyang666/dsh-mobile-remote 1.9.0 → 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.
@@ -1,244 +0,0 @@
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
- }