@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,181 @@
1
+ /**
2
+ * 内核安装包(本机上的压缩包)的识别与解包。
3
+ *
4
+ * 面板**不联网下载内核**(2026-11 起:内核页只接受本地上传的安装包),所以这里只处理
5
+ * 已经落盘的 `.gz` / `.zip`:按**内容**判格式 → 拷到落点目录并算 sha256 → 解出可执行文件。
6
+ * 安装包从哪来(官方 release 页面手工下载、从别的机器拷过来)与面板无关。
7
+ *
8
+ * 只认二进制压缩包:darwin / linux 的单文件 `.gz`、Windows 的 `.zip`。同版本 release 里的
9
+ * `.deb` / `.rpm` / `.pkg.tar.zst` 是发行版安装包(会往系统里装文件、要 root、卸不干净),
10
+ * 一律拒 —— 面板只往自己的状态目录放一个可执行文件。
11
+ *
12
+ * 零依赖:Node 内置 zlib 处理 .gz;Windows 的 .zip 交给系统自带的 bsdtar(Win10 1803+ 的 tar.exe)。
13
+ *
14
+ * 外部命令(解 zip 用的 `tar`)走 `shared/exec.js` 的异步执行器:解几十 MB 的包时不能把面板
15
+ * 的事件循环卡住(以前用 spawnSync,安装任务期间整个面板都不响应)。
16
+ */
17
+ import { createHash } from 'node:crypto';
18
+ import fs from 'node:fs';
19
+ import { createReadStream, createWriteStream } from 'node:fs';
20
+ import { once } from 'node:events';
21
+ import os from 'node:os';
22
+ import path from 'node:path';
23
+ import { createGunzip } from 'node:zlib';
24
+ import { pipeline } from 'node:stream/promises';
25
+ import { defaultRun } from '../shared/exec.js';
26
+
27
+ const BINARY_ARCHIVE_RE = /\.(gz|zip)$/i;
28
+ const PACKAGE_FORMAT_RE = /\.(deb|rpm|apk|pkg\.tar\.zst|pkg\.tar\.xz|pkg\.tar\.gz)$/i;
29
+
30
+ /**
31
+ * 校验文件名确实是二进制压缩包(页面上的提示文案与上传校验共用)。
32
+ * @param {string} name
33
+ * @returns {string} 原样返回,方便链式调用
34
+ */
35
+ export function assertBinaryArchive(name) {
36
+ if (PACKAGE_FORMAT_RE.test(name) || !BINARY_ARCHIVE_RE.test(name)) {
37
+ const kind = PACKAGE_FORMAT_RE.test(name) ? '发行版安装包' : '不认识的格式';
38
+ throw new Error(
39
+ `文件 ${name} 是${kind}:只支持二进制压缩包(.gz / .zip),请换成 mihomo-<平台>-<架构>-<版本>.gz`,
40
+ );
41
+ }
42
+ return name;
43
+ }
44
+
45
+ /** 平台+架构 → 官方 release 资产里的目标串(darwin amd64 用 compatible 变体,兼容较老的 macOS/CPU) */
46
+ const ASSET_TARGETS = {
47
+ darwin: { arm64: 'darwin-arm64', x64: 'darwin-amd64-compatible' },
48
+ linux: { x64: 'linux-amd64', arm64: 'linux-arm64', arm: 'linux-armv7' },
49
+ win32: { x64: 'windows-amd64', arm64: 'windows-arm64' },
50
+ };
51
+
52
+ /**
53
+ * 本机该下哪个资产(**纯字符串,不联网**):页面上告诉用户去 release 页面拿哪一个。
54
+ * @param {string} [platform]
55
+ * @param {string} [arch]
56
+ * @returns {string|null} 例 `darwin-arm64`;平台/架构没有官方构建时返回 null
57
+ */
58
+ export function assetTarget(platform = process.platform, arch = process.arch) {
59
+ return ASSET_TARGETS[platform]?.[arch] || null;
60
+ }
61
+
62
+ /**
63
+ * 本机安装包的扩展名:只有 Windows 是 zip,其余(darwin / linux)都是单文件 .gz。
64
+ * @param {string} [platform]
65
+ * @returns {'gz'|'zip'}
66
+ */
67
+ export function archiveExt(platform = process.platform) {
68
+ return platform === 'win32' ? 'zip' : 'gz';
69
+ }
70
+
71
+ /**
72
+ * 按**文件内容**(不是名字)判压缩包格式:手工下载的文件常被改过名。
73
+ * gzip 头 `1f 8b`;zip 头 `PK`。
74
+ * @param {string} file
75
+ * @returns {'gz'|'zip'|null} 认不出来返回 null
76
+ */
77
+ export function sniffArchiveExt(file) {
78
+ let fd;
79
+ try {
80
+ fd = fs.openSync(file, 'r');
81
+ const head = Buffer.alloc(4);
82
+ const read = fs.readSync(fd, head, 0, 4, 0);
83
+ if (read >= 2 && head[0] === 0x1f && head[1] === 0x8b) return 'gz';
84
+ if (read >= 2 && head[0] === 0x50 && head[1] === 0x4b) return 'zip';
85
+ return null;
86
+ } catch {
87
+ return null;
88
+ } finally {
89
+ if (fd !== undefined) {
90
+ try {
91
+ fs.closeSync(fd);
92
+ } catch {
93
+ /* 关不掉就算了 */
94
+ }
95
+ }
96
+ }
97
+ }
98
+
99
+ /**
100
+ * 把本地压缩包拷到落点目录(边拷边算 sha256)。
101
+ * 路径由调用方保证是已校验的普通文件。
102
+ * @param {string} src
103
+ * @param {string} destPath
104
+ * @param {{ onProgress?: (written: number, total: number) => void }} [opts]
105
+ * @returns {Promise<{ sha256: string, size: number }>}
106
+ */
107
+ export async function copyToFile(src, destPath, opts = {}) {
108
+ const total = fs.statSync(src).size;
109
+ const hash = createHash('sha256');
110
+ const out = createWriteStream(destPath);
111
+ let size = 0;
112
+ try {
113
+ for await (const chunk of createReadStream(src)) {
114
+ hash.update(chunk);
115
+ size += chunk.length;
116
+ if (!out.write(chunk)) await once(out, 'drain');
117
+ opts.onProgress?.(size, total);
118
+ }
119
+ await new Promise((resolve, reject) => out.end((err) => (err ? reject(err) : resolve())));
120
+ } catch (err) {
121
+ out.destroy();
122
+ throw new Error(`读本地文件失败:${err?.message || err}(${src})`);
123
+ }
124
+ return { sha256: hash.digest('hex'), size };
125
+ }
126
+
127
+ /** 归档里找 mihomo 可执行文件:优先名字匹配,其次取最大的那个文件 */
128
+ function findBinaryIn(dir) {
129
+ let best = null;
130
+ const walk = (d) => {
131
+ for (const entry of fs.readdirSync(d, { withFileTypes: true })) {
132
+ const full = path.join(d, entry.name);
133
+ if (entry.isDirectory()) {
134
+ const hit = walk(full);
135
+ if (hit) return hit;
136
+ } else if (/^mihomo(\.exe)?$/i.test(entry.name)) {
137
+ return full;
138
+ } else if (!best || fs.statSync(full).size > fs.statSync(best).size) {
139
+ best = full;
140
+ }
141
+ }
142
+ return null;
143
+ };
144
+ return walk(dir) || best;
145
+ }
146
+
147
+ /** Windows 的 zip:用系统自带 bsdtar 解(Node 没有内置 zip 支持) */
148
+ async function extractZip(archivePath, destPath) {
149
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'mihomo-unzip-'));
150
+ try {
151
+ const r = await defaultRun('tar', ['-xf', archivePath, '-C', dir], { timeout: 120000 });
152
+ if (!r.ok) {
153
+ throw new Error(
154
+ `解压 zip 失败(依赖系统自带的 tar):${r.stderr.trim() || `退出码 ${r.status}`}`,
155
+ );
156
+ }
157
+ const found = findBinaryIn(dir);
158
+ if (!found) throw new Error('上传的 zip 里没找到 mihomo 可执行文件');
159
+ fs.copyFileSync(found, destPath);
160
+ } finally {
161
+ fs.rmSync(dir, { recursive: true, force: true });
162
+ }
163
+ }
164
+
165
+ /**
166
+ * 解出可执行文件并给上执行权限。
167
+ * @param {string} archivePath 本机上的 .gz / .zip
168
+ * @param {'gz'|'zip'} ext
169
+ * @param {string} destPath 目标可执行文件路径
170
+ */
171
+ export async function extractBinary(archivePath, ext, destPath) {
172
+ if (ext === 'gz') {
173
+ await pipeline(createReadStream(archivePath), createGunzip(), createWriteStream(destPath));
174
+ } else if (ext === 'zip') {
175
+ await extractZip(archivePath, destPath);
176
+ } else {
177
+ throw new Error(`不认识的归档格式:${ext}`);
178
+ }
179
+ if (process.platform !== 'win32') fs.chmodSync(destPath, 0o755);
180
+ return destPath;
181
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * 内核版本读取。
3
+ *
4
+ * 面板假定**主机上只有自己这一个内核**(唯一落点 `MIHOMO_DASH_DIR/bin/mihomo`),所以这里
5
+ * 不去扫 PATH / brew / 包管理器目录找「别的内核」:安装(kernel-install.js)与状态展示
6
+ * (kernel-admin.js)要版本号时,直接对目标文件跑一次 `-v` 就够了。面板不比较版本(不联网查新版),
7
+ * 所以这里没有「谁比谁新」的工具。
8
+ *
9
+ * 唯一会跑外部程序的地方就是 readKernelVersion(`mihomo -v`,带 5 秒超时,失败返回 null);
10
+ * 走 `shared/exec.js` 的异步执行器,不阻塞面板的事件循环(`/api/kernel-admin` 每次轮询都要读它)。
11
+ */
12
+ import { defaultRun } from '../shared/exec.js';
13
+
14
+ /**
15
+ * 跑一次 `mihomo -v` 取版本号,例:`Mihomo Meta 1.19.31 darwin arm64 with go1.27.1` → 1.19.31
16
+ * @param {string} binPath
17
+ * @returns {Promise<string|null>} 拿不到(不是 mihomo / 跑不起来)返回 null
18
+ */
19
+ export async function readKernelVersion(binPath) {
20
+ try {
21
+ const r = await defaultRun(binPath, ['-v'], { timeout: 5000 });
22
+ const text = `${r.stdout || ''}${r.stderr || ''}`;
23
+ const m = text.match(/(\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?)/);
24
+ return m ? m[1] : null;
25
+ } catch {
26
+ return null;
27
+ }
28
+ }
29
+
30
+ /** 版本号归一化:去掉可能的 v 前缀,便于展示 */
31
+ export function bareVersion(v) {
32
+ return String(v || '').replace(/^v/, '');
33
+ }
@@ -0,0 +1,138 @@
1
+ /** 内核控制接口客户端:解析 external-controller / secret,发请求,抽状态摘要。 */
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import yaml from 'js-yaml';
5
+ import { detectMihomoDir } from '../shared/paths.js';
6
+
7
+ const FALLBACK_API = 'http://127.0.0.1:9090';
8
+
9
+ export function normalizeUrl(raw) {
10
+ if (!raw) return FALLBACK_API;
11
+ return /^https?:\/\//.test(raw) ? raw.replace(/\/$/, '') : `http://${raw}`;
12
+ }
13
+
14
+ /**
15
+ * 读某个配置目录里写的控制接口与密钥(每次都读文件,不走缓存)。
16
+ * 拉内核之前用它做前置检查:配置里没写 `external-controller` 时内核根本不听控制接口,
17
+ * 面板拉起来也只能干看着(见 kernel-lifecycle.js 的 `no-controller`)。
18
+ * @param {string|null} dir
19
+ * @returns {{ external: string, secret: string }}
20
+ */
21
+ export function readControllerConfig(dir) {
22
+ const out = { external: '', secret: '' };
23
+ if (!dir) return out;
24
+ try {
25
+ const cfg = yaml.load(fs.readFileSync(path.join(dir, 'config.yaml'), 'utf8')) || {};
26
+ out.external = cfg['external-controller'] || '';
27
+ out.secret = cfg.secret || '';
28
+ } catch {
29
+ // 文件不在 / 解析不了都当「没写」
30
+ }
31
+ return out;
32
+ }
33
+
34
+ let cachedController;
35
+
36
+ /**
37
+ * 清掉控制接口缓存。
38
+ * 缓存是为了不改配置时少读文件,但面板**自己**改了 config.yaml(安装写默认配置、配置文件页保存)时
39
+ * 必须失效 —— 否则面板会一直拿旧地址去探内核(典型现象:装完点侧栏「启动内核」被告知「改完 external-controller
40
+ * 要重启面板」)。外部工具改的文件仍靠 kernel-lifecycle.js 的 `controller-changed` 检查提醒重启。
41
+ */
42
+ export function resetControllerCache() {
43
+ cachedController = undefined;
44
+ }
45
+
46
+ /**
47
+ * 控制接口地址与密钥:只读 config.yaml 的 `external-controller` / `secret`(内核自己也读这份,
48
+ * 单一来源,没有环境变量覆盖)。读不到时回退默认地址 127.0.0.1:9090。
49
+ * 结果缓存到进程结束(面板自己改完配置会主动清缓存,见 resetControllerCache)。
50
+ * @returns {{ url: string, secret: string }}
51
+ */
52
+ export function getController() {
53
+ if (cachedController) return cachedController;
54
+ const { external, secret } = readControllerConfig(detectMihomoDir());
55
+ cachedController = { url: normalizeUrl(external), secret };
56
+ return cachedController;
57
+ }
58
+
59
+ /**
60
+ * 拿到内核原始 Response(流式端点用:body 边收边透传)。
61
+ * @param {string} apiPath
62
+ * @returns {Promise<Response>}
63
+ */
64
+ export async function kernelFetchRaw(apiPath) {
65
+ const { url, secret } = getController();
66
+ const headers = {};
67
+ if (secret) headers.authorization = `Bearer ${secret}`;
68
+ return fetch(url + apiPath, { headers });
69
+ }
70
+
71
+ /**
72
+ * 请求内核控制接口。
73
+ * @param {string} apiPath 形如 /version、/configs?force=true
74
+ * @param {{ method?: string, body?: unknown }} [opts]
75
+ */
76
+ export async function kernelFetch(apiPath, opts = {}) {
77
+ const { url, secret } = getController();
78
+ const method = opts.method || 'GET';
79
+ const headers = {};
80
+ if (secret) headers.authorization = `Bearer ${secret}`;
81
+ if (opts.body !== undefined) headers['content-type'] = 'application/json';
82
+
83
+ const res = await fetch(url + apiPath, {
84
+ method,
85
+ headers,
86
+ body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined,
87
+ });
88
+ const text = await res.text();
89
+ let data;
90
+ try {
91
+ data = text ? JSON.parse(text) : null;
92
+ } catch {
93
+ data = text;
94
+ }
95
+ if (!res.ok) {
96
+ const msg = typeof data === 'object' && data && data.message ? data.message : text;
97
+ const err = new Error(`内核返回 ${res.status}: ${msg}`);
98
+ err.status = res.status;
99
+ throw err;
100
+ }
101
+ return data;
102
+ }
103
+
104
+ /**
105
+ * 内核的本地代理入口(系统代理要指过来的地址):运行时 `mixed-port` 优先,回退 config.yaml 的
106
+ * `mixed-port` / `socks-port` / `port`,再回退 7890。每次现读(改完端口不用重启面板)。
107
+ * @returns {Promise<{ host: string, port: number, source: string }>}
108
+ */
109
+ export async function resolveKernelProxyTarget() {
110
+ try {
111
+ const cfg = await kernelFetch('/configs');
112
+ const port = Number(cfg?.['mixed-port'] || 0);
113
+ if (port) return { host: '127.0.0.1', port, source: '内核运行时 mixed-port' };
114
+ } catch {
115
+ // 内核没跑就看配置文件
116
+ }
117
+ const dir = detectMihomoDir();
118
+ if (dir) {
119
+ try {
120
+ const doc = yaml.load(fs.readFileSync(path.join(dir, 'config.yaml'), 'utf8')) || {};
121
+ const port = Number(doc['mixed-port'] || doc['socks-port'] || doc.port || 0);
122
+ if (port) return { host: '127.0.0.1', port, source: 'config.yaml' };
123
+ } catch {
124
+ /* 解析失败就退默认值 */
125
+ }
126
+ }
127
+ return { host: '127.0.0.1', port: 7890, source: '默认 7890' };
128
+ }
129
+
130
+ /** status 用的摘要:版本 / 模式 / 接口 */
131
+ export async function kernelSummary() {
132
+ const [version, configs] = await Promise.all([kernelFetch('/version'), kernelFetch('/configs')]);
133
+ return {
134
+ version: `${version.version}${version.meta ? ' (Meta)' : ''}`,
135
+ mode: configs.mode,
136
+ api: getController().url,
137
+ };
138
+ }
@@ -0,0 +1,150 @@
1
+ /**
2
+ * 面板自有内核的安装 / 升级 / 卸载:文件级操作 + 台账。
3
+ *
4
+ * 安装包是**本地上传的压缩包**(面板不联网下载,见 kernel-archive.js),这里只管
5
+ * 「拷到落点 → 校验 sha256 → 解压 → 试跑 `mihomo -v` → 备份旧文件 → 同目录 rename 原子替换 → 记台账」。
6
+ *
7
+ * 与 config.yaml 那套一致:替换用同目录 rename(原子),旧版本会留一个 `.bak-<版本>` 备份,
8
+ * 但**没有自动回滚** —— 备份是留给用户手工恢复的(`bin/mihomo.bak-<版本>` 直接拷回去即可)。
9
+ *
10
+ * 台账 kernels.json 记录「这个文件是我们装的、装的是哪一版」(主机上就当只有这一个落点,
11
+ * 不去探测别处有没有别的 mihomo)。
12
+ */
13
+ import fs from 'node:fs';
14
+ import path from 'node:path';
15
+ import { KERNEL_BIN, KERNEL_BIN_DIR, KERNEL_LEDGER_FILE, ensureCliDir } from '../shared/paths.js';
16
+ import { readKernelVersion } from './binary.js';
17
+ import { assertBinaryArchive, copyToFile, extractBinary } from './archive.js';
18
+
19
+ /** 台账最多留几条历史,够回查即可 */
20
+ const MAX_HISTORY = 10;
21
+
22
+ /** @returns {{ installed: Array<Record<string, unknown>> }} */
23
+ export function readLedger() {
24
+ try {
25
+ const data = JSON.parse(fs.readFileSync(KERNEL_LEDGER_FILE, 'utf8'));
26
+ return { installed: Array.isArray(data.installed) ? data.installed : [] };
27
+ } catch {
28
+ return { installed: [] };
29
+ }
30
+ }
31
+
32
+ /** 面板在指定落点上装的那一版(台账里同路径最新一条),没装过返回 null */
33
+ export function currentInstall(target = KERNEL_BIN) {
34
+ return readLedger().installed.find((e) => e.path === target) || null;
35
+ }
36
+
37
+ function writeLedger(installed) {
38
+ ensureCliDir();
39
+ const payload = { installed: installed.slice(0, MAX_HISTORY) };
40
+ fs.writeFileSync(KERNEL_LEDGER_FILE, JSON.stringify(payload, null, 2));
41
+ }
42
+
43
+ /**
44
+ * 安装(升级):把已落到本机(浏览器上传时是状态目录下的临时文件)的压缩包
45
+ * 校验 sha256 → 解压 → 试跑 → 备份旧文件 → 原子替换 → 记台账。
46
+ * 校验失败时现有文件原封不动(临时文件都带 `.` 前缀,最后一步才 rename)。
47
+ * @param {{
48
+ * localFile: string,
49
+ * ext: 'gz'|'zip',
50
+ * asset?: string,
51
+ * target?: string,
52
+ * sha256?: string,
53
+ * onProgress?: (written: number, total: number) => void,
54
+ * }} opts asset = 安装包原始文件名(只用于日志/台账展示),默认取 localFile 的文件名
55
+ * @returns {Promise<Record<string, any>>} 台账里新增的那条记录
56
+ */
57
+ export async function performInstall({
58
+ localFile,
59
+ ext,
60
+ asset,
61
+ target = KERNEL_BIN,
62
+ sha256,
63
+ onProgress,
64
+ }) {
65
+ const name = assertBinaryArchive(asset || path.basename(localFile));
66
+ ensureCliDir();
67
+ const dir = path.dirname(target);
68
+ fs.mkdirSync(dir, { recursive: true });
69
+ const stamp = `${process.pid}.${Date.now()}`;
70
+ const archive = path.join(dir, `.${path.basename(target)}.${stamp}.upload`);
71
+ const staged = path.join(dir, `.${path.basename(target)}.${stamp}.new`);
72
+
73
+ try {
74
+ const got = await copyToFile(localFile, archive, { onProgress });
75
+ if (sha256 && sha256.trim().toLowerCase() !== got.sha256) {
76
+ throw new Error(`sha256 不匹配:期望 ${sha256.trim()},实际 ${got.sha256}`);
77
+ }
78
+ await extractBinary(archive, ext, staged);
79
+ const version = await readKernelVersion(staged);
80
+ if (!version) {
81
+ throw new Error('这个文件跑不起来(`mihomo -v` 没输出),安装包可能选错了或已损坏');
82
+ }
83
+
84
+ let backup = null;
85
+ if (fs.existsSync(target)) {
86
+ const prev = currentInstall();
87
+ const prevLabel = prev?.version || (await readKernelVersion(target)) || 'unknown';
88
+ backup = `${target}.bak-${prevLabel}`;
89
+ fs.copyFileSync(target, backup);
90
+ if (process.platform !== 'win32') fs.chmodSync(backup, 0o755);
91
+ }
92
+ fs.renameSync(staged, target);
93
+ if (process.platform !== 'win32') fs.chmodSync(target, 0o755);
94
+
95
+ const entry = {
96
+ version,
97
+ asset: name,
98
+ sha256: got.sha256,
99
+ size: got.size,
100
+ path: target,
101
+ backup,
102
+ /** 安装来源:本地上传的安装包(面板不联网下载,所以这里是常量) */
103
+ source: 'upload',
104
+ installedAt: new Date().toISOString(),
105
+ };
106
+ const { installed } = readLedger();
107
+ // 同一路径只留最新一条在台账首位,历史留 10 条方便回查(不再有自动回滚用它)
108
+ writeLedger([entry, ...installed.filter((e) => e.installedAt !== entry.installedAt)]);
109
+ return entry;
110
+ } finally {
111
+ for (const f of [archive, staged]) fs.rmSync(f, { force: true });
112
+ }
113
+ }
114
+
115
+ /**
116
+ * 卸载:只删面板自己装的二进制(含 `.bak-*` 与残留的临时文件)与台账,目录里别的东西不动。
117
+ * @param {string} target
118
+ * @returns {string[]} 删掉的文件
119
+ */
120
+ export function removeInstalled(target = KERNEL_BIN) {
121
+ const dir = path.dirname(target);
122
+ const base = path.basename(target);
123
+ const removed = [];
124
+ let entries = [];
125
+ try {
126
+ entries = fs.readdirSync(dir);
127
+ } catch {
128
+ entries = [];
129
+ }
130
+ for (const name of entries) {
131
+ const mine = name === base || name.startsWith(`${base}.bak-`) || name.startsWith(`.${base}.`);
132
+ if (!mine) continue;
133
+ const full = path.join(dir, name);
134
+ fs.rmSync(full, { force: true });
135
+ removed.push(full);
136
+ }
137
+ // 目录本来就只为我们而建的话顺手删掉(HOME 之外别人塞过东西就留着)
138
+ try {
139
+ if (dir !== KERNEL_BIN_DIR && fs.readdirSync(dir).length === 0) fs.rmdirSync(dir);
140
+ } catch {
141
+ /* 删不掉就算了 */
142
+ }
143
+ try {
144
+ if (dir === KERNEL_BIN_DIR && fs.readdirSync(dir).length === 0) fs.rmdirSync(dir);
145
+ } catch {
146
+ /* 同上 */
147
+ }
148
+ fs.rmSync(KERNEL_LEDGER_FILE, { force: true });
149
+ return removed;
150
+ }