@lancewu602/mihomo-dash 0.0.0-stage → 0.6.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.
Files changed (56) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +99 -2
  3. package/bin/mihomo-dash.js +4 -0
  4. package/package.json +63 -4
  5. package/server/CHANGELOG.md +51 -0
  6. package/server/package.json +13 -0
  7. package/server/src/cli/index.js +75 -0
  8. package/server/src/cli/service.js +167 -0
  9. package/server/src/cli/status.js +245 -0
  10. package/server/src/http/config-files.js +148 -0
  11. package/server/src/http/config-order.js +186 -0
  12. package/server/src/http/config-sections.js +278 -0
  13. package/server/src/http/default-config.js +69 -0
  14. package/server/src/http/groups.js +386 -0
  15. package/server/src/http/index.js +260 -0
  16. package/server/src/http/kernel-admin.js +522 -0
  17. package/server/src/http/kernel-proxy.js +43 -0
  18. package/server/src/http/providers.js +251 -0
  19. package/server/src/http/rules.js +509 -0
  20. package/server/src/http/settings.js +409 -0
  21. package/server/src/http/system-proxy.js +86 -0
  22. package/server/src/index.js +26 -0
  23. package/server/src/kernel/archive.js +181 -0
  24. package/server/src/kernel/binary.js +33 -0
  25. package/server/src/kernel/client.js +138 -0
  26. package/server/src/kernel/install.js +150 -0
  27. package/server/src/kernel/lifecycle.js +339 -0
  28. package/server/src/kernel/run.js +11 -0
  29. package/server/src/shared/args.js +57 -0
  30. package/server/src/shared/atomic-file.js +41 -0
  31. package/server/src/shared/exec.js +44 -0
  32. package/server/src/shared/kernel-config.js +27 -0
  33. package/server/src/shared/log-file.js +92 -0
  34. package/server/src/shared/panel.js +58 -0
  35. package/server/src/shared/paths.js +72 -0
  36. package/server/src/shared/service.js +552 -0
  37. package/server/src/shared/system-proxy.js +375 -0
  38. package/server/src/shared/text.js +11 -0
  39. package/server/tests/cli-service.test.js +153 -0
  40. package/server/tests/config-dir.test.js +133 -0
  41. package/server/tests/config-order.test.js +148 -0
  42. package/server/tests/groups.test.js +558 -0
  43. package/server/tests/kernel-admin.test.js +647 -0
  44. package/server/tests/kernel.test.js +250 -0
  45. package/server/tests/log-file.test.js +110 -0
  46. package/server/tests/providers.test.js +259 -0
  47. package/server/tests/rules.test.js +463 -0
  48. package/server/tests/service.test.js +470 -0
  49. package/server/tests/settings.test.js +456 -0
  50. package/server/tests/status.test.js +269 -0
  51. package/server/tests/system-proxy.test.js +497 -0
  52. package/server/tsconfig.json +17 -0
  53. package/web/dist/assets/index-BpDzYhuI.css +1 -0
  54. package/web/dist/assets/index-WmujD2EM.js +515 -0
  55. package/web/dist/favicon.svg +5 -0
  56. package/web/dist/index.html +14 -0
@@ -0,0 +1,497 @@
1
+ /**
2
+ * 系统代理开关(server/src/shared/system-proxy.js + server/src/http/system-proxy.js)测试。
3
+ *
4
+ * 全程用桩执行器:断言真实 argv(不经过 shell、外部字符串不参与拼命令),不碰本机网络设置。
5
+ * server 那两个端点用 express + 桩执行器直接挂路由验证。运行:npm test
6
+ */
7
+ import assert from 'node:assert/strict';
8
+ import { after, before, test } from 'node:test';
9
+ import express from 'express';
10
+ import fs from 'node:fs';
11
+ import http from 'node:http';
12
+ import os from 'node:os';
13
+ import path from 'node:path';
14
+
15
+ /** @type {Record<string, any>} */ let mods = {};
16
+ /** @type {string} */ let tmpDir;
17
+
18
+ /** scutil --proxy 样例:HTTP/HTTPS 开、SOCKS 关 */
19
+ const SCUTIL_ON = `<dictionary> {
20
+ ExceptionsList : <array> {
21
+ 0 : localhost
22
+ 1 : 127.0.0.0/8
23
+ 2 : *.local
24
+ }
25
+ HTTPEnable : 1
26
+ HTTPPort : 7890
27
+ HTTPProxy : 127.0.0.1
28
+ HTTPSEnable : 1
29
+ HTTPSPort : 7890
30
+ HTTPSProxy : 127.0.0.1
31
+ ProxyAutoConfigEnable : 0
32
+ ProxyAutoDiscoveryEnable : 0
33
+ SOCKSEnable : 0
34
+ SOCKSPort : 0
35
+ }`;
36
+
37
+ /** 全关 + PAC 开:用来验证 off 时连 PAC 一起清 */
38
+ const SCUTIL_OFF = `<dictionary> {
39
+ ExceptionsList : <array> {
40
+ 0 : localhost
41
+ 1 : 127.0.0.0/8
42
+ 2 : *.local
43
+ }
44
+ HTTPEnable : 0
45
+ HTTPSEnable : 0
46
+ ProxyAutoConfigEnable : 0
47
+ ProxyAutoDiscoveryEnable : 0
48
+ SOCKSEnable : 0
49
+ }`;
50
+
51
+ /** 造一份 scutil --proxy 输出:协议全关 + 指定绕过列表(测「绕过项已经齐了」) */
52
+ const scutilWith = (bypass) => `<dictionary> {
53
+ ExceptionsList : <array> {
54
+ ${bypass.map((b, i) => ` ${i} : ${b}`).join('\n')}
55
+ }
56
+ HTTPEnable : 0
57
+ HTTPSEnable : 0
58
+ ProxyAutoConfigEnable : 0
59
+ ProxyAutoDiscoveryEnable : 0
60
+ SOCKSEnable : 0
61
+ }`;
62
+
63
+ const SCUTIL_PAC = `<dictionary> {
64
+ HTTPEnable : 0
65
+ ProxyAutoConfigEnable : 1
66
+ ProxyAutoConfigURLString : http://example.com/proxy.pac
67
+ ProxyAutoDiscoveryEnable : 1
68
+ SOCKSEnable : 0
69
+ }`;
70
+
71
+ const SERVICE_ORDER = `An asterisk (*) denotes that a network service is disabled.
72
+ (1) Wi-Fi
73
+ (Hardware Port: Wi-Fi, Device: en0)
74
+
75
+ (2) USB 10/100 LAN
76
+ (Hardware Port: USB 10/100 LAN, Device: en5)
77
+
78
+ (3) Thunderbolt Bridge
79
+ (Hardware Port: Thunderbolt Bridge, Device: bridge0)
80
+ `;
81
+
82
+ const ALL_SERVICES = `An asterisk (*) denotes that a network service is disabled.
83
+ Wi-Fi
84
+ USB 10/100 LAN
85
+ *Bluetooth PAN
86
+ Thunderbolt Bridge
87
+ `;
88
+
89
+ const ROUTE_GET = ` route to: default
90
+ destination: default
91
+ mask: default
92
+ gateway: 192.168.1.1
93
+ interface: en5
94
+ flags: <UP,GATEWAY,DONE,STATIC,PRCLONING>
95
+ `;
96
+
97
+ /** 桩执行器:记录 argv,按 handler 返回伪造输出 */
98
+ function stubRun(handler) {
99
+ const calls = [];
100
+ const run = (cmd, args) => {
101
+ calls.push([cmd, ...args]);
102
+ const out = handler(cmd, args) || {};
103
+ return {
104
+ ok: out.ok !== false,
105
+ status: out.ok === false ? 1 : 0,
106
+ stdout: out.stdout || '',
107
+ stderr: out.stderr || '',
108
+ };
109
+ };
110
+ run.calls = calls;
111
+ run.commands = () => calls.map((c) => c.join(' '));
112
+ return run;
113
+ }
114
+
115
+ /** macOS 桩:默认活动服务是 USB 10/100 LAN(en5,与默认路由一致) */
116
+ function macOSRun({ proxy = SCUTIL_ON, extra } = {}) {
117
+ return stubRun((cmd, args) => {
118
+ if (cmd === 'scutil') return { stdout: proxy };
119
+ if (cmd === 'route') return { stdout: ROUTE_GET };
120
+ if (cmd === 'networksetup') {
121
+ if (args[0] === '-listnetworkserviceorder') return { stdout: SERVICE_ORDER };
122
+ if (args[0] === '-listallnetworkservices') return { stdout: ALL_SERVICES };
123
+ return extra?.(args) || { stdout: '' };
124
+ }
125
+ return { ok: false, stderr: `unexpected: ${cmd}` };
126
+ });
127
+ }
128
+
129
+ before(async () => {
130
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mihomo-dash-sysproxy-'));
131
+ process.env.MIHOMO_DASH_DIR = tmpDir;
132
+ mods = {
133
+ shared: await import('../src/shared/system-proxy.js'),
134
+ routes: await import('../src/http/system-proxy.js'),
135
+ paths: await import('../src/shared/paths.js'),
136
+ kernel: await import('../src/kernel/client.js'),
137
+ };
138
+ fs.rmSync(tmpDir, { recursive: true, force: true });
139
+ });
140
+
141
+ after(() => {
142
+ fs.rmSync(tmpDir, { recursive: true, force: true });
143
+ });
144
+
145
+ test('parseScutilProxy:解析协议、PAC、绕过列表', () => {
146
+ const { parseScutilProxy } = mods.shared;
147
+ const on = parseScutilProxy(SCUTIL_ON);
148
+ assert.deepEqual(on.protocols.http, { enabled: true, host: '127.0.0.1', port: 7890 });
149
+ assert.equal(on.protocols.https.enabled, true);
150
+ assert.equal(on.protocols.socks.enabled, false);
151
+ assert.equal(on.protocols.socks.port, 0);
152
+ assert.deepEqual(on.bypass, ['localhost', '127.0.0.0/8', '*.local']);
153
+ assert.deepEqual(on.autoProxy, { enabled: false, url: null });
154
+
155
+ const pac = parseScutilProxy(SCUTIL_PAC);
156
+ assert.equal(pac.protocols.http.enabled, false);
157
+ assert.deepEqual(pac.autoProxy, { enabled: true, url: 'http://example.com/proxy.pac' });
158
+ assert.equal(pac.autoDiscovery, true);
159
+ });
160
+
161
+ test('parseServiceOrder / parseDefaultInterface:服务顺序与默认路由接口', () => {
162
+ const { parseServiceOrder, parseDefaultInterface } = mods.shared;
163
+ assert.deepEqual(parseServiceOrder(SERVICE_ORDER), [
164
+ { name: 'Wi-Fi', device: 'en0' },
165
+ { name: 'USB 10/100 LAN', device: 'en5' },
166
+ { name: 'Thunderbolt Bridge', device: 'bridge0' },
167
+ ]);
168
+ assert.equal(parseDefaultInterface(ROUTE_GET), 'en5');
169
+ assert.equal(parseDefaultInterface('route to: default'), null);
170
+ });
171
+
172
+ test('macOS read:只动活动服务(默认路由接口 en5 → USB 10/100 LAN)', async () => {
173
+ const run = macOSRun();
174
+ const proxy = mods.shared.createSystemProxy({ run, platform: 'darwin' });
175
+ const state = await proxy.read();
176
+ assert.equal(state.supported, true);
177
+ assert.equal(state.service, 'USB 10/100 LAN');
178
+ assert.equal(state.device, 'en5');
179
+ assert.equal(state.enabled, true, 'HTTP/HTTPS 开着就算开');
180
+ assert.equal(state.protocols.http.port, 7890);
181
+ assert.deepEqual(run.commands(), [
182
+ 'scutil --proxy',
183
+ 'networksetup -listnetworkserviceorder',
184
+ 'networksetup -listallnetworkservices',
185
+ 'route -n get default',
186
+ ]);
187
+ });
188
+
189
+ test('macOS off:只关开着的协议,PAC / 自动发现一并清掉', async () => {
190
+ const run = macOSRun({ proxy: SCUTIL_ON });
191
+ const proxy = mods.shared.createSystemProxy({ run, platform: 'darwin' });
192
+ const result = await proxy.setEnabled(false, {});
193
+ assert.deepEqual(result.applied, ['关闭 HTTP', '关闭 HTTPS']);
194
+ const writes = run.commands().filter((c) => c.includes('-set'));
195
+ assert.deepEqual(writes, [
196
+ 'networksetup -setwebproxystate USB 10/100 LAN off',
197
+ 'networksetup -setsecurewebproxystate USB 10/100 LAN off',
198
+ ]);
199
+ assert.ok(
200
+ !run.commands().some((c) => c.includes('socksfirewallproxystate')),
201
+ '本来就没开的 SOCKS 不该被写',
202
+ );
203
+
204
+ const pacRun = macOSRun({ proxy: SCUTIL_PAC });
205
+ const pacProxy = mods.shared.createSystemProxy({ run: pacRun, platform: 'darwin' });
206
+ const pacResult = await pacProxy.setEnabled(false, {});
207
+ assert.deepEqual(pacResult.applied, ['关闭 PAC(自动代理配置)', '关闭自动代理发现']);
208
+ assert.ok(
209
+ pacRun.commands().includes('networksetup -setautoproxystate USB 10/100 LAN off'),
210
+ 'PAC 开着时必须一起关,否则系统还是走代理',
211
+ );
212
+ });
213
+
214
+ test('macOS on:写地址+端口(set*proxy 会自动启用),并把固定绕过项补进列表', async () => {
215
+ const run = macOSRun();
216
+ const proxy = mods.shared.createSystemProxy({ run, platform: 'darwin' });
217
+ const result = await proxy.setEnabled(true, { host: '127.0.0.1', port: 7890 });
218
+ assert.deepEqual(result.applied, [
219
+ 'HTTP → 127.0.0.1:7890',
220
+ 'HTTPS → 127.0.0.1:7890',
221
+ 'SOCKS → 127.0.0.1:7890',
222
+ `绕过列表 +${result.bypassAdded.length} 项`,
223
+ ]);
224
+ // 已有 localhost / 127.0.0.0/8 / *.local:只补缺的,顺序不排(已有的原样保留)
225
+ const existing = ['localhost', '127.0.0.0/8', '*.local'];
226
+ assert.deepEqual(
227
+ result.bypassAdded,
228
+ mods.shared.BYPASS_DEFAULTS.map((d) => d.value).filter((v) => !existing.includes(v)),
229
+ );
230
+ const writes = run.calls.filter((c) => c[0] === 'networksetup' && c[1].startsWith('-set'));
231
+ assert.deepEqual(writes, [
232
+ ['networksetup', '-setwebproxy', 'USB 10/100 LAN', '127.0.0.1', '7890'],
233
+ ['networksetup', '-setsecurewebproxy', 'USB 10/100 LAN', '127.0.0.1', '7890'],
234
+ ['networksetup', '-setsocksfirewallproxy', 'USB 10/100 LAN', '127.0.0.1', '7890'],
235
+ [
236
+ 'networksetup',
237
+ '-setproxybypassdomains',
238
+ 'USB 10/100 LAN',
239
+ 'localhost',
240
+ '127.0.0.0/8',
241
+ '*.local',
242
+ ...result.bypassAdded,
243
+ ],
244
+ ]);
245
+ // 任何调用都必须是「命令 + 参数数组」,不能出现 sh -c 之类
246
+ assert.ok(!run.calls.some((c) => /^(sh|bash|zsh)$/.test(c[0])), '不允许经过 shell');
247
+ });
248
+
249
+ test('macOS on:绕过列表已经齐了就不写、不报「补了几项」', async () => {
250
+ const bypass = [...mods.shared.BYPASS_DEFAULTS.map((d) => d.value), 'my.internal'];
251
+ const proxy = mods.shared.createSystemProxy({
252
+ run: macOSRun({ proxy: scutilWith(bypass) }),
253
+ platform: 'darwin',
254
+ });
255
+ const result = await proxy.setEnabled(true, {});
256
+ assert.deepEqual(result.bypassAdded, []);
257
+ assert.ok(!result.applied.some((l) => l.includes('绕过列表')));
258
+ });
259
+
260
+ test('BYPASS_DEFAULTS / mergeBypass:每项都带说明;只补缺的,大小写不敏感,已有顺序原样保留', () => {
261
+ const { BYPASS_DEFAULTS, mergeBypass } = mods.shared;
262
+ assert.ok(BYPASS_DEFAULTS.length >= 8, '固定项不该太少');
263
+ // 面板要拿 note 展示「这一项到底绕过什么」,所以一项都不能缺
264
+ for (const d of BYPASS_DEFAULTS) {
265
+ assert.ok(d.value && d.note, `缺 value / note:${JSON.stringify(d)}`);
266
+ }
267
+ assert.equal(
268
+ new Set(BYPASS_DEFAULTS.map((d) => d.value)).size,
269
+ BYPASS_DEFAULTS.length,
270
+ '不能有重复',
271
+ );
272
+
273
+ const { list, added } = mergeBypass(['LOCALHOST', 'my.internal']);
274
+ assert.equal(list[0], 'LOCALHOST', '已有的原样保留(不改大小写、不重排)');
275
+ assert.equal(list[1], 'my.internal');
276
+ assert.ok(!added.includes('localhost'), '大小写不敏感:不该重复补');
277
+ assert.equal(added.length, BYPASS_DEFAULTS.length - 1);
278
+ // 全都有就不补
279
+ assert.deepEqual(mergeBypass(BYPASS_DEFAULTS.map((d) => d.value)).added, []);
280
+ });
281
+
282
+ test('地址 / 端口白名单:非法值在下发命令之前就被拒', async () => {
283
+ const { assertHostPort } = mods.shared;
284
+ assert.deepEqual(assertHostPort('127.0.0.1', 7890), { host: '127.0.0.1', port: 7890 });
285
+ assert.deepEqual(assertHostPort('::1', 1080), { host: '::1', port: 1080 });
286
+ for (const [host, port] of [
287
+ ['127.0.0.1; rm -rf /', 7890],
288
+ ['$(whoami)', 7890],
289
+ ['127.0.0.1', 0],
290
+ ['127.0.0.1', 70000],
291
+ ['127.0.0.1', 'abc'],
292
+ ]) {
293
+ assert.throws(() => assertHostPort(host, port), /不合法/);
294
+ }
295
+
296
+ const run = macOSRun();
297
+ const proxy = mods.shared.createSystemProxy({ run, platform: 'darwin' });
298
+ await assert.rejects(
299
+ () => proxy.setEnabled(true, { host: '127.0.0.1; touch /tmp/pwned', port: 7890 }),
300
+ /不合法/,
301
+ );
302
+ const writes = run.calls.filter((c) => c[0] === 'networksetup' && c[1].startsWith('-set'));
303
+ assert.deepEqual(writes, [], '校验失败时一个写命令都不该发出去');
304
+ });
305
+
306
+ test('Linux/GNOME:读 mode + 三协议,关=切 none,开=写 host/port 再切 manual', async () => {
307
+ const gsettings = {
308
+ 'org.gnome.system.proxy mode': "'manual'",
309
+ 'org.gnome.system.proxy http host': "'127.0.0.1'",
310
+ 'org.gnome.system.proxy http port': '7890',
311
+ 'org.gnome.system.proxy https host': "'127.0.0.1'",
312
+ 'org.gnome.system.proxy https port': '7890',
313
+ 'org.gnome.system.proxy socks host': "'127.0.0.1'",
314
+ 'org.gnome.system.proxy socks port': '7890',
315
+ 'org.gnome.system.proxy ignore-hosts': "['localhost', '127.0.0.0/8']",
316
+ };
317
+ const run = stubRun((cmd, args) => {
318
+ assert.equal(cmd, 'gsettings');
319
+ assert.equal(args[0], 'get');
320
+ const key = args.slice(1).join(' ');
321
+ return { stdout: gsettings[key] ?? "''", ok: key in gsettings };
322
+ });
323
+ const proxy = mods.shared.createSystemProxy({ run, platform: 'linux' });
324
+ const state = await proxy.read();
325
+ assert.equal(state.supported, true);
326
+ assert.equal(state.enabled, true);
327
+ assert.equal(state.protocols.http.port, 7890);
328
+ assert.equal(state.protocols.socks.host, '127.0.0.1');
329
+ assert.deepEqual(state.bypass, ['localhost', '127.0.0.0/8']);
330
+
331
+ const offRun = stubRun((cmd, args) => {
332
+ if (args[0] === 'set') return { stdout: '' };
333
+ const key = args.slice(1).join(' ');
334
+ return { stdout: gsettings[key] ?? "''", ok: key in gsettings };
335
+ });
336
+ const offProxy = mods.shared.createSystemProxy({ run: offRun, platform: 'linux' });
337
+ const off = await offProxy.setEnabled(false, {});
338
+ assert.deepEqual(off.applied, ['mode → none']);
339
+ assert.ok(offRun.commands().includes('gsettings set org.gnome.system.proxy mode none'));
340
+
341
+ const onRun = stubRun((cmd, args) => {
342
+ if (args[0] === 'set') return { stdout: '' };
343
+ const key = args.slice(1).join(' ');
344
+ return { stdout: gsettings[key] ?? "''", ok: key in gsettings };
345
+ });
346
+ const onProxy = mods.shared.createSystemProxy({ run: onRun, platform: 'linux' });
347
+ const on = await onProxy.setEnabled(true, { host: '127.0.0.1', port: 7891 });
348
+ const commands = onRun.commands();
349
+ for (const key of ['http', 'https', 'socks']) {
350
+ assert.ok(
351
+ commands.includes(`gsettings set org.gnome.system.proxy.${key} host 127.0.0.1`),
352
+ `${key} host 要写`,
353
+ );
354
+ assert.ok(
355
+ commands.includes(`gsettings set org.gnome.system.proxy.${key} port 7891`),
356
+ `${key} port 要写`,
357
+ );
358
+ }
359
+ // 固定绕过项:ignore-hosts 里已有 localhost / 127.0.0.0/8 → 只补缺的,顺序保留已有的
360
+ assert.deepEqual(
361
+ on.bypassAdded,
362
+ mods.shared.BYPASS_DEFAULTS.map((d) => d.value).filter(
363
+ (v) => !['localhost', '127.0.0.0/8'].includes(v),
364
+ ),
365
+ );
366
+ const ignore = commands.find((c) =>
367
+ c.startsWith('gsettings set org.gnome.system.proxy ignore-hosts'),
368
+ );
369
+ assert.match(
370
+ ignore,
371
+ /^gsettings set org\.gnome\.system\.proxy ignore-hosts \['localhost', '127\.0\.0\.0\/8',/,
372
+ );
373
+ assert.ok(commands.includes('gsettings set org.gnome.system.proxy mode manual'));
374
+ });
375
+
376
+ test('非 GNOME / 不支持的平台:read 给提示,写直接拒', async () => {
377
+ const noGs = stubRun(() => ({ ok: false, stderr: 'gsettings: command not found' }));
378
+ const linux = mods.shared.createSystemProxy({ run: noGs, platform: 'linux' });
379
+ const state = await linux.read();
380
+ assert.equal(state.supported, false);
381
+ assert.match(state.hint, /gsettings/);
382
+ await assert.rejects(() => linux.setEnabled(false, {}), /gsettings/);
383
+
384
+ const win = mods.shared.createSystemProxy({ run: stubRun(() => ({})), platform: 'win32' });
385
+ const winState = await win.read();
386
+ assert.equal(winState.supported, false);
387
+ assert.match(winState.hint, /Internet 选项/);
388
+ await assert.rejects(
389
+ () => win.setEnabled(true, { host: '127.0.0.1', port: 7890 }),
390
+ /Internet 选项/,
391
+ );
392
+ });
393
+
394
+ test('关系统代理(disableSystemProxyIfOn):开着就关、关着不动、不支持/失败都不抛错', async () => {
395
+ const calls = [];
396
+ const spy = (inner) => (cmd, args) => {
397
+ calls.push(`${cmd} ${args.join(' ')}`);
398
+ return inner(cmd, args);
399
+ };
400
+
401
+ // 开着(SCUTIL_ON)→ 关:会下发 networksetup 的关闭命令,返回 disabled: true
402
+ const onRun = spy(macOSRun({ proxy: SCUTIL_ON }));
403
+ const on = await mods.routes.disableSystemProxyIfOn({ run: onRun, platform: 'darwin' });
404
+ assert.equal(on.disabled, true);
405
+ assert.ok(
406
+ calls.some((c) => c.startsWith('networksetup -setwebproxystate')),
407
+ `应当真的下发了关闭命令:${calls.join(' | ')}`,
408
+ );
409
+
410
+ // 已经是关的 → 一条写命令都不下发
411
+ const offCalls = [];
412
+ const offRun = (cmd, args) => {
413
+ offCalls.push(`${cmd} ${args.join(' ')}`);
414
+ return macOSRun({ proxy: SCUTIL_OFF })(cmd, args);
415
+ };
416
+ const off = await mods.routes.disableSystemProxyIfOn({ run: offRun, platform: 'darwin' });
417
+ assert.deepEqual(off, { disabled: false, reason: 'already-off' });
418
+ assert.equal(offCalls.filter((c) => c.startsWith('networksetup -set')).length, 0);
419
+
420
+ // 不支持的平台 / 执行器报错:都不阻止调用方(停内核),只把原因带回来
421
+ const unsupported = await mods.routes.disableSystemProxyIfOn({
422
+ run: () => ({ ok: false, stderr: 'no' }),
423
+ platform: 'win32',
424
+ });
425
+ assert.equal(unsupported.disabled, false);
426
+ assert.equal(unsupported.reason, 'unsupported');
427
+ const broken = await mods.routes.disableSystemProxyIfOn({
428
+ run: () => {
429
+ throw new Error('boom');
430
+ },
431
+ platform: 'darwin',
432
+ });
433
+ assert.equal(broken.disabled, false);
434
+ assert.match(broken.reason, /boom/);
435
+ });
436
+
437
+ test('HTTP 端点:GET 读状态(含内核端口),PUT 开关,非法 body 报 400', async () => {
438
+ // 桩内核:/configs 报 mixed-port 7890,面板据此知道系统代理该指向哪
439
+ const kernel = http.createServer((_req, res) => {
440
+ res.writeHead(200, { 'content-type': 'application/json' });
441
+ res.end(JSON.stringify({ 'mixed-port': 7890, mode: 'direct' }));
442
+ });
443
+ await new Promise((resolve) => kernel.listen(0, '127.0.0.1', resolve));
444
+ // 代码只读 config.yaml 的 external-controller(MIHOMO_API 已去掉)→ 写一份指向桩内核的配置
445
+ const cfgDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mihomo-dash-sysproxy-cfg-'));
446
+ fs.writeFileSync(
447
+ path.join(cfgDir, 'config.yaml'),
448
+ `mixed-port: 7890\nexternal-controller: 127.0.0.1:${kernel.address().port}\n`,
449
+ );
450
+ process.env.MIHOMO_DIR = cfgDir;
451
+ // 本用例在运行中换了配置目录:清掉「配置目录」与「控制接口」两个缓存再走
452
+ mods.paths.resetMihomoDirCache();
453
+ mods.kernel.resetControllerCache();
454
+
455
+ const run = macOSRun();
456
+ const app = express();
457
+ app.use(express.json());
458
+ mods.routes.registerSystemProxyRoutes(app, { run, platform: 'darwin' });
459
+ const server = app.listen(0, '127.0.0.1');
460
+ await new Promise((resolve) => server.once('listening', resolve));
461
+ const base = `http://127.0.0.1:${server.address().port}`;
462
+
463
+ try {
464
+ const got = await (await fetch(`${base}/api/system-proxy`)).json();
465
+ assert.equal(got.supported, true);
466
+ assert.equal(got.service, 'USB 10/100 LAN');
467
+ assert.equal(got.enabled, true);
468
+ assert.deepEqual(got.target, {
469
+ host: '127.0.0.1',
470
+ port: 7890,
471
+ source: '内核运行时 mixed-port',
472
+ });
473
+
474
+ const off = await fetch(`${base}/api/system-proxy`, {
475
+ method: 'PUT',
476
+ headers: { 'content-type': 'application/json' },
477
+ body: JSON.stringify({ enabled: false }),
478
+ });
479
+ assert.equal(off.status, 200);
480
+ const offBody = await off.json();
481
+ assert.deepEqual(offBody.applied, ['关闭 HTTP', '关闭 HTTPS']);
482
+ assert.equal(offBody.target.port, 7890);
483
+
484
+ const bad = await fetch(`${base}/api/system-proxy`, {
485
+ method: 'PUT',
486
+ headers: { 'content-type': 'application/json' },
487
+ body: JSON.stringify({ enabled: 'yes' }),
488
+ });
489
+ assert.equal(bad.status, 400);
490
+ assert.match((await bad.json()).error, /enabled 必须是/);
491
+ } finally {
492
+ server.close();
493
+ kernel.close();
494
+ delete process.env.MIHOMO_DIR;
495
+ fs.rmSync(cfgDir, { recursive: true, force: true });
496
+ }
497
+ });
@@ -0,0 +1,17 @@
1
+ {
2
+ // 后端是 JS + JSDoc(零构建):这份配置主要给编辑器解析、跳转与语法检查用。
3
+ // 刻意不开 checkJs —— 现在打开会有 900+ 条类型错误(历史代码的 JSDoc 不全),
4
+ // 那是单独一件事;要开得先清完再把它加进 prepublishOnly。
5
+ "compilerOptions": {
6
+ "target": "ES2022",
7
+ "lib": ["ES2022"],
8
+ "module": "NodeNext",
9
+ "moduleResolution": "NodeNext",
10
+ "allowJs": true,
11
+ "checkJs": false,
12
+ "noEmit": true,
13
+ "skipLibCheck": true,
14
+ "types": ["node"]
15
+ },
16
+ "include": ["src", "tests"]
17
+ }
@@ -0,0 +1 @@
1
+ html,body,#root{margin:0;padding:0;height:100%;overflow:hidden}body{font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Inter,PingFang SC,Hiragino Sans GB,Microsoft YaHei,sans-serif;background:var(--bg-layout);color:var(--text)}.pill-group{display:inline-flex;align-items:center;gap:2px;padding:3px;background:var(--pill-track);border-radius:999px}.pill-group-lg{gap:3px;padding:4px}.pill-group-lg .pill-item{padding:4px 16px;font-size:14px}.pill-item{position:relative;border:none;padding:3px 13px;border-radius:999px;background:transparent;font-family:inherit;font-size:13px;line-height:1.7;cursor:pointer;color:var(--text-secondary);transition:color .18s ease}.pill-item:hover:not(:disabled){color:var(--text)}.pill-item:disabled{cursor:not-allowed;opacity:.55}.pill-item[data-active]{color:var(--primary)}.pill-item:focus-visible{outline:2px solid var(--primary);outline-offset:2px}.pill-indicator{position:absolute;top:0;right:0;bottom:0;left:0;border-radius:999px;background:var(--pill-active)}.pill-label{position:relative;z-index:1}.geo-row{display:grid;gap:18px 28px;grid-template-columns:repeat(auto-fit,minmax(240px,1fr))}.metric-grid{display:grid;gap:18px;grid-template-columns:minmax(0,1fr)}@media (min-width: 720px){.metric-grid{grid-template-columns:repeat(3,minmax(0,1fr));gap:0}.metric-grid>*{padding-inline:18px}.metric-grid>*+*{border-inline-start:1px solid var(--hairline-soft)}.metric-grid>*:first-child{padding-inline-start:0}.metric-grid>*:last-child{padding-inline-end:0}}.log-surface{background:var(--bg-inset);border:1px solid var(--hairline-soft);border-radius:10px;padding:10px 12px;overflow:auto;white-space:pre;font-family:ui-monospace,SF Mono,JetBrains Mono,Menlo,Consolas,monospace;font-size:12px;line-height:1.7;color:var(--text)}@media (prefers-reduced-motion: reduce){*,*:before,*:after{animation-duration:.01ms!important;animation-iteration-count:1!important;transition-duration:.01ms!important}}*{scrollbar-width:thin;scrollbar-color:var(--hairline) transparent}*::-webkit-scrollbar{width:8px;height:8px}*::-webkit-scrollbar-thumb{background:var(--hairline);border-radius:4px}*::-webkit-scrollbar-track{background:transparent}.config-editor>[class^=cm-theme],.config-editor .cm-editor,.config-editor .cm-scroller{height:100%}.config-editor .cm-scroller{overflow:auto}