@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.
- package/LICENSE +21 -0
- package/README.md +99 -2
- package/bin/mihomo-dash.js +4 -0
- package/package.json +63 -4
- package/server/CHANGELOG.md +51 -0
- package/server/package.json +13 -0
- package/server/src/cli/index.js +75 -0
- package/server/src/cli/service.js +167 -0
- package/server/src/cli/status.js +245 -0
- package/server/src/http/config-files.js +148 -0
- package/server/src/http/config-order.js +186 -0
- package/server/src/http/config-sections.js +278 -0
- package/server/src/http/default-config.js +69 -0
- package/server/src/http/groups.js +386 -0
- package/server/src/http/index.js +260 -0
- package/server/src/http/kernel-admin.js +522 -0
- package/server/src/http/kernel-proxy.js +43 -0
- package/server/src/http/providers.js +251 -0
- package/server/src/http/rules.js +509 -0
- package/server/src/http/settings.js +409 -0
- package/server/src/http/system-proxy.js +86 -0
- package/server/src/index.js +26 -0
- package/server/src/kernel/archive.js +181 -0
- package/server/src/kernel/binary.js +33 -0
- package/server/src/kernel/client.js +138 -0
- package/server/src/kernel/install.js +150 -0
- package/server/src/kernel/lifecycle.js +339 -0
- package/server/src/kernel/run.js +11 -0
- package/server/src/shared/args.js +57 -0
- package/server/src/shared/atomic-file.js +41 -0
- package/server/src/shared/exec.js +44 -0
- package/server/src/shared/kernel-config.js +27 -0
- package/server/src/shared/log-file.js +92 -0
- package/server/src/shared/panel.js +58 -0
- package/server/src/shared/paths.js +72 -0
- package/server/src/shared/service.js +552 -0
- package/server/src/shared/system-proxy.js +375 -0
- package/server/src/shared/text.js +11 -0
- package/server/tests/cli-service.test.js +153 -0
- package/server/tests/config-dir.test.js +133 -0
- package/server/tests/config-order.test.js +148 -0
- package/server/tests/groups.test.js +558 -0
- package/server/tests/kernel-admin.test.js +647 -0
- package/server/tests/kernel.test.js +250 -0
- package/server/tests/log-file.test.js +110 -0
- package/server/tests/providers.test.js +259 -0
- package/server/tests/rules.test.js +463 -0
- package/server/tests/service.test.js +470 -0
- package/server/tests/settings.test.js +456 -0
- package/server/tests/status.test.js +269 -0
- package/server/tests/system-proxy.test.js +497 -0
- package/server/tsconfig.json +17 -0
- package/web/dist/assets/index-BpDzYhuI.css +1 -0
- package/web/dist/assets/index-WmujD2EM.js +515 -0
- package/web/dist/favicon.svg +5 -0
- package/web/dist/index.html +14 -0
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/** 常量与目录:状态目录(MIHOMO_DASH_DIR)与内核配置目录(MIHOMO_DIR)。 */
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
|
|
6
|
+
// 面板默认监听
|
|
7
|
+
export const DEFAULT_HOST = '127.0.0.1';
|
|
8
|
+
export const DEFAULT_PORT = 8981;
|
|
9
|
+
|
|
10
|
+
// 状态目录(内核管理用;面板本身不写 pid / 日志文件,进程由系统服务管理器托管)
|
|
11
|
+
// 兼容旧变量 MIHOMO_CLI_DIR(命令改名前的写法),仅在没设新变量时生效
|
|
12
|
+
export const CLI_DIR =
|
|
13
|
+
process.env.MIHOMO_DASH_DIR ||
|
|
14
|
+
process.env.MIHOMO_CLI_DIR ||
|
|
15
|
+
path.join(os.homedir(), '.config', 'mihomo-dash');
|
|
16
|
+
|
|
17
|
+
// 内核(面板自有安装)相关文件,只在面板「内核」页装/升/卸与启停时产生:
|
|
18
|
+
// bin/ 面板自有内核唯一的落点(外面怎么装的与我们无关)
|
|
19
|
+
// data/ 内核配置目录(config.yaml / providers / cache.db / GEO 数据)的推荐落点
|
|
20
|
+
// kernels.json 安装台账:版本 / 资产名 / sha256 / 备份文件
|
|
21
|
+
// kernel-run.json start 写的运行时元数据(pid / 二进制 / 配置目录;supervised = 由系统服务管理器拉起)
|
|
22
|
+
// kernel.pid 自有内核 pid,stop 只杀这一个
|
|
23
|
+
// kernel.log 自有内核的 stdout/stderr
|
|
24
|
+
export const KERNEL_BIN_DIR = path.join(CLI_DIR, 'bin');
|
|
25
|
+
export const KERNEL_BIN = path.join(
|
|
26
|
+
KERNEL_BIN_DIR,
|
|
27
|
+
process.platform === 'win32' ? 'mihomo.exe' : 'mihomo',
|
|
28
|
+
);
|
|
29
|
+
// 面板自有内核的配置目录(data/):跟 bin/ 一起放在状态目录里。
|
|
30
|
+
// **2026-11 起面板的「内核配置目录」就钉在它上面**(见 detectMihomoDir):以前是「候选表里第一个含
|
|
31
|
+
// config.yaml 的胜出」,结果卸载自有内核后(data/ 被删)面板会悄悄转去管别的目录(实测落到了
|
|
32
|
+
// /opt/homebrew/etc/mihomo,用户看到「内核都卸载了怎么还有配置文件」)—— 这种「看着像自己的其实不是」
|
|
33
|
+
// 最误导人,所以候选表整个去掉了。要管别处的配置:把它放进来,或显式设 MIHOMO_DIR。
|
|
34
|
+
export const KERNEL_CONFIG_DIR = path.join(CLI_DIR, 'data');
|
|
35
|
+
export const KERNEL_LEDGER_FILE = path.join(CLI_DIR, 'kernels.json');
|
|
36
|
+
export const KERNEL_META_FILE = path.join(CLI_DIR, 'kernel-run.json');
|
|
37
|
+
export const KERNEL_PID_FILE = path.join(CLI_DIR, 'kernel.pid');
|
|
38
|
+
export const KERNEL_LOG_FILE = path.join(CLI_DIR, 'kernel.log');
|
|
39
|
+
|
|
40
|
+
export function ensureCliDir() {
|
|
41
|
+
fs.mkdirSync(CLI_DIR, { recursive: true });
|
|
42
|
+
return CLI_DIR;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
let cachedMihomoDir;
|
|
46
|
+
|
|
47
|
+
/** 清掉探测缓存(目录来源变了之后重来一次;现在来源只有环境变量与状态目录,留着是给调用方一个统一收口) */
|
|
48
|
+
export function resetMihomoDirCache() {
|
|
49
|
+
cachedMihomoDir = undefined;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* 内核配置目录:**`MIHOMO_DIR` 显式覆盖 > 面板自有落点 `MIHOMO_DASH_DIR/data`**。
|
|
54
|
+
* 不再扫 ~/.config/mihomo、/etc/mihomo、/opt/homebrew/etc/mihomo 这类地方(理由见上面 `KERNEL_CONFIG_DIR`)。
|
|
55
|
+
* 目录或 config.yaml 不存在也照常返回这个路径 —— 页面据此显示「还没有配置」,写盘时会按需建目录。
|
|
56
|
+
* @returns {string}
|
|
57
|
+
*/
|
|
58
|
+
export function detectMihomoDir() {
|
|
59
|
+
if (cachedMihomoDir !== undefined) return cachedMihomoDir;
|
|
60
|
+
cachedMihomoDir = process.env.MIHOMO_DIR || KERNEL_CONFIG_DIR;
|
|
61
|
+
return cachedMihomoDir;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function configPath() {
|
|
65
|
+
return path.join(detectMihomoDir(), 'config.yaml');
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** 建好配置目录(写 config.yaml / providers 之前调;目录已存在则什么都不做) */
|
|
69
|
+
export function ensureConfigDir(dir = detectMihomoDir()) {
|
|
70
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
71
|
+
return dir;
|
|
72
|
+
}
|
|
@@ -0,0 +1,552 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 开机自启:把「面板」与「面板自有内核」交给系统服务管理器监管。
|
|
3
|
+
*
|
|
4
|
+
* 支持 macOS(LaunchAgent,`~/Library/LaunchAgents`,用户级、不需要 sudo)与 Linux
|
|
5
|
+
* (`systemd --user` 单元,落在 `~/.config/systemd/user`);Windows 与其它平台只提示。
|
|
6
|
+
*
|
|
7
|
+
* 三条硬约定,改动前先读:
|
|
8
|
+
* 1. **只开机拉起一次,不保活**:launchd 故意不写 `KeepAlive`、systemd 写 `Restart=no`。
|
|
9
|
+
* 好处是语义干净 —— 服务管理器 stop / 侧栏的「停止内核」就是真停,不会被拉回来,也不会因为
|
|
10
|
+
* 配置写坏(内核起不来)造成「每 10 秒重启一次」的风暴;代价是崩了不会自愈,想再起来重新登录,
|
|
11
|
+
* 或 `launchctl kickstart -k gui/<uid>/…` / `systemctl --user start …` 手动拉一次。
|
|
12
|
+
* 2. **固定 argv,不经 shell**:plist / unit 里的可执行文件与参数都在这里拼好,
|
|
13
|
+
* 路径只来自 `MIHOMO_DASH_DIR` 与 `process.execPath`,不把外部字符串拼进命令。
|
|
14
|
+
* 3. **安装 / 卸载都不动正在跑的进程**:`bootout` / `disable --now` 会当场把面板(或内核)杀掉,
|
|
15
|
+
* 而用户很可能正开着页面在用它;已装载的 job 只更新磁盘上的定义(下次登录生效),卸载也只删定义
|
|
16
|
+
* (要停进程去服务管理器 stop / 侧栏的「停止内核」)。
|
|
17
|
+
* 「安装」仍会真的 bootstrap / `enable --now`(否则页面上的「已装载」就是假的);进程正在手动跑的
|
|
18
|
+
* job 在 Linux 上退化为只 `enable`(免得留一个注定失败的单元),而那个必然立刻退出的第二实例
|
|
19
|
+
* 由 CLI 补一句 `notes` 说明。只有 `start()`(手动拉起)会真的去拉进程。
|
|
20
|
+
*
|
|
21
|
+
* 用户侧入口是 CLI 的 `mihomo-dash service print|install|uninstall|status`(面板里没有「自启」页)。
|
|
22
|
+
* 服务定义里写的是两个真实入口:`server/src/index.js`(panel job)与 `server/src/kernel/run.js`(kernel job)——
|
|
23
|
+
* 没有额外的中转脚本,也没有 pid / meta 文件:job 状态直接问 launchctl / `systemctl --user`,
|
|
24
|
+
* 面板存活靠 `shared/panel.js` 探 `/api/health`。
|
|
25
|
+
*
|
|
26
|
+
* `run` 可注入:测试用桩函数断言真实 argv,不会碰本机的 launchd / systemd。
|
|
27
|
+
*/
|
|
28
|
+
import fs from 'node:fs';
|
|
29
|
+
import os from 'node:os';
|
|
30
|
+
import path from 'node:path';
|
|
31
|
+
import { fileURLToPath } from 'node:url';
|
|
32
|
+
import { defaultRun } from './exec.js';
|
|
33
|
+
import { CLI_DIR, DEFAULT_HOST, DEFAULT_PORT } from './paths.js';
|
|
34
|
+
|
|
35
|
+
/** 两个 job 各自的入口脚本(plist / unit 里的第二个参数;都在 server/ 内,随包发布) */
|
|
36
|
+
export const SERVICE_ENTRIES = {
|
|
37
|
+
panel: path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../index.js'),
|
|
38
|
+
kernel: path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../kernel/run.js'),
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
/** 面板 / 内核两个 agent 共用的 bundle 标识:只是给「登录项」UI 认领用的,不是真的 app bundle */
|
|
42
|
+
export const BUNDLE_ID = 'com.mihomo-dash';
|
|
43
|
+
|
|
44
|
+
/** 两个受监管的 job:面板与面板自有内核 */
|
|
45
|
+
const JOBS = {
|
|
46
|
+
panel: {
|
|
47
|
+
label: 'com.mihomo-dash.panel',
|
|
48
|
+
unit: 'mihomo-dash-panel.service',
|
|
49
|
+
description: 'mihomo-dash 面板(Web UI + REST 转发)',
|
|
50
|
+
args: (opts) => ['--port', String(opts.port), '--host', opts.host],
|
|
51
|
+
log: (cliDir) => path.join(cliDir, 'daemon.log'),
|
|
52
|
+
},
|
|
53
|
+
kernel: {
|
|
54
|
+
label: 'com.mihomo-dash.kernel',
|
|
55
|
+
unit: 'mihomo-dash-kernel.service',
|
|
56
|
+
description: 'mihomo-dash 自有内核(用配置目录里的 config.yaml)',
|
|
57
|
+
args: () => [],
|
|
58
|
+
log: (cliDir) => path.join(cliDir, 'kernel.log'),
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
export const SERVICE_JOBS = Object.keys(JOBS);
|
|
63
|
+
|
|
64
|
+
function jobOf(job) {
|
|
65
|
+
const def = JOBS[job];
|
|
66
|
+
if (!def) throw new Error(`未知服务: ${job}(只能是 ${SERVICE_JOBS.join(' / ')})`);
|
|
67
|
+
return def;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** plist / unit 里出现的路径都要过一遍 XML 转义(家目录带 & 之类也不是不可能) */
|
|
71
|
+
export function xmlEscape(text) {
|
|
72
|
+
return String(text)
|
|
73
|
+
.replace(/&/g, '&')
|
|
74
|
+
.replace(/</g, '<')
|
|
75
|
+
.replace(/>/g, '>')
|
|
76
|
+
.replace(/"/g, '"')
|
|
77
|
+
.replace(/'/g, ''');
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** systemd 的 ExecStart 参数:含空格/引号时加引号,其余原样 */
|
|
81
|
+
export function systemdQuote(arg) {
|
|
82
|
+
const text = String(arg);
|
|
83
|
+
if (!/[\s"\\]/.test(text)) return text;
|
|
84
|
+
return `"${text.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* macOS LaunchAgent 定义。故意**不写 KeepAlive**(见文件头注释:只开机起一次)。
|
|
89
|
+
* @param {'panel'|'kernel'} job
|
|
90
|
+
* @param {{ node: string, entries: Record<'panel'|'kernel', string>, entry?: string, cliDir: string, port?: number, host?: string }} opts
|
|
91
|
+
*/
|
|
92
|
+
export function renderPlist(job, opts) {
|
|
93
|
+
const def = jobOf(job);
|
|
94
|
+
const port = opts.port ?? DEFAULT_PORT;
|
|
95
|
+
const host = opts.host ?? DEFAULT_HOST;
|
|
96
|
+
const entry = opts.entry ?? opts.entries[job];
|
|
97
|
+
const argv = [opts.node, entry, ...def.args({ port, host })];
|
|
98
|
+
const args = argv.map((a) => ` <string>${xmlEscape(a)}</string>`).join('\n');
|
|
99
|
+
const log = def.log(opts.cliDir);
|
|
100
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
101
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
102
|
+
<plist version="1.0">
|
|
103
|
+
<dict>
|
|
104
|
+
<key>Label</key>
|
|
105
|
+
<string>${xmlEscape(def.label)}</string>
|
|
106
|
+
<key>ProgramArguments</key>
|
|
107
|
+
<array>
|
|
108
|
+
${args}
|
|
109
|
+
</array>
|
|
110
|
+
<key>WorkingDirectory</key>
|
|
111
|
+
<string>${xmlEscape(opts.cliDir)}</string>
|
|
112
|
+
<!-- 只开机拉起一次:不写 KeepAlive,崩了不自动重拉,stop 就是真停 -->
|
|
113
|
+
<key>RunAtLoad</key>
|
|
114
|
+
<true/>
|
|
115
|
+
<key>ProcessType</key>
|
|
116
|
+
<string>Background</string>
|
|
117
|
+
<!-- 「登录项 → 允许在后台」靠这个键把两个服务归到 mihomo-dash 名下;
|
|
118
|
+
不写的话系统按被拉起进程的签名归属,会显示成 node 的发行方 Node.js Foundation -->
|
|
119
|
+
<key>AssociatedBundleIdentifiers</key>
|
|
120
|
+
<array>
|
|
121
|
+
<string>${BUNDLE_ID}</string>
|
|
122
|
+
</array>
|
|
123
|
+
<key>StandardOutPath</key>
|
|
124
|
+
<string>${xmlEscape(log)}</string>
|
|
125
|
+
<key>StandardErrorPath</key>
|
|
126
|
+
<string>${xmlEscape(log)}</string>
|
|
127
|
+
</dict>
|
|
128
|
+
</plist>
|
|
129
|
+
`;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Linux systemd --user 单元定义。`Restart=no` 与 launchd 侧「不设 KeepAlive」对应。
|
|
134
|
+
* @param {'panel'|'kernel'} job
|
|
135
|
+
* @param {{ node: string, entries: Record<'panel'|'kernel', string>, entry?: string, cliDir: string, port?: number, host?: string, scope?: 'system'|'user' }} opts
|
|
136
|
+
*/
|
|
137
|
+
export function renderUnit(job, opts) {
|
|
138
|
+
const scope = opts.scope === 'system' ? 'system' : 'user';
|
|
139
|
+
const wantedBy = scope === 'system' ? 'multi-user.target' : 'default.target';
|
|
140
|
+
const def = jobOf(job);
|
|
141
|
+
const port = opts.port ?? DEFAULT_PORT;
|
|
142
|
+
const host = opts.host ?? DEFAULT_HOST;
|
|
143
|
+
const entry = opts.entry ?? opts.entries[job];
|
|
144
|
+
const argv = [opts.node, entry, ...def.args({ port, host })];
|
|
145
|
+
const log = def.log(opts.cliDir);
|
|
146
|
+
return `# 由 \`mihomo-dash service install\` 生成(server/src/shared/service.js),勿手改 —— 重新安装会覆盖
|
|
147
|
+
# 作用域:${scope === 'system' ? 'system(/etc/systemd/system,以 root 运行,开机即起)' : 'user(~/.config/systemd/user,登录后启动 or linger)'}
|
|
148
|
+
[Unit]
|
|
149
|
+
Description=${def.description}
|
|
150
|
+
After=network-online.target
|
|
151
|
+
|
|
152
|
+
[Service]
|
|
153
|
+
Type=simple
|
|
154
|
+
# 只开机拉起一次:崩了不自动重拉,侧栏的「停止内核」/ 服务管理器 stop 就是真停
|
|
155
|
+
Restart=no
|
|
156
|
+
WorkingDirectory=${opts.cliDir}
|
|
157
|
+
ExecStart=${argv.map(systemdQuote).join(' ')}
|
|
158
|
+
StandardOutput=append:${log}
|
|
159
|
+
StandardError=append:${log}
|
|
160
|
+
|
|
161
|
+
[Install]
|
|
162
|
+
WantedBy=${wantedBy}
|
|
163
|
+
`;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** `launchctl print` 输出里取 job 自己的状态(第一处 state / pid 就是主进程的) */
|
|
167
|
+
export function parseLaunchctlPrint(text) {
|
|
168
|
+
const s = String(text);
|
|
169
|
+
const state = s.match(/^\s*state = (.+)$/m);
|
|
170
|
+
const pid = s.match(/^\s*pid = (\d+)$/m);
|
|
171
|
+
return { state: state ? state[1].trim() : null, pid: pid ? Number(pid[1]) : 0 };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** systemd 的 is-enabled / is-active 输出归一化 */
|
|
175
|
+
export function parseSystemctlState(enabled, active) {
|
|
176
|
+
const e = String(enabled).trim();
|
|
177
|
+
const a = String(active).trim();
|
|
178
|
+
return {
|
|
179
|
+
installed: e !== 'not-found',
|
|
180
|
+
enabled: e === 'enabled' || e === 'enabled-runtime',
|
|
181
|
+
active: a === 'active',
|
|
182
|
+
state: a || e,
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function serviceDir({ platform, home, scope = 'user', systemDir }) {
|
|
187
|
+
if (platform === 'darwin') return path.join(home, 'Library', 'LaunchAgents');
|
|
188
|
+
return scope === 'system'
|
|
189
|
+
? systemDir || '/etc/systemd/system'
|
|
190
|
+
: path.join(home, '.config', 'systemd', 'user');
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* 服务管理器(每个平台一份实现,对外一套方法)。
|
|
195
|
+
*
|
|
196
|
+
* 方法都返回结果对象、不自行退出(退出码由 CLI 决定),并且**只走固定 argv**。
|
|
197
|
+
*
|
|
198
|
+
* Linux 的 `scope`:`system`(**默认当 root 时**)写 `/etc/systemd/system`、`systemctl enable --now`,
|
|
199
|
+
* 开机即起、不依赖 linger;`user` 写 `~/.config/systemd/user`、`systemctl --user`,不需要 root,
|
|
200
|
+
* 但要开机即起得靠 `loginctl enable-linger`。macOS 固定用户级 LaunchAgent,没有 system 域。
|
|
201
|
+
* @param {{
|
|
202
|
+
* run?: typeof defaultRun,
|
|
203
|
+
* platform?: string,
|
|
204
|
+
* home?: string,
|
|
205
|
+
* cliDir?: string,
|
|
206
|
+
* node?: string,
|
|
207
|
+
* entries?: Record<'panel'|'kernel', string>,
|
|
208
|
+
* scope?: 'system' | 'user',
|
|
209
|
+
* systemDir?: string,
|
|
210
|
+
* uid?: number,
|
|
211
|
+
* user?: string,
|
|
212
|
+
* }} [options]
|
|
213
|
+
*/
|
|
214
|
+
export function createServiceManager(options = {}) {
|
|
215
|
+
const run = options.run || defaultRun;
|
|
216
|
+
const platform = options.platform || process.platform;
|
|
217
|
+
const home = options.home || os.homedir();
|
|
218
|
+
const cliDir = options.cliDir || CLI_DIR;
|
|
219
|
+
const node = options.node || process.execPath;
|
|
220
|
+
const entries = options.entries || SERVICE_ENTRIES;
|
|
221
|
+
const uid = options.uid ?? process.getuid?.() ?? 0;
|
|
222
|
+
const user = options.user || os.userInfo().username;
|
|
223
|
+
const supported = platform === 'darwin' || platform === 'linux';
|
|
224
|
+
// Linux 默认 system(与 NAS 上 deck 的部署形态一致,开机即起且不依赖 linger);
|
|
225
|
+
// 非 root 时默认退成 user,避免一上手就写 /etc 失败。
|
|
226
|
+
const isRoot = platform !== 'win32' && uid === 0;
|
|
227
|
+
const scope = platform === 'linux' ? (options.scope ?? (isRoot ? 'system' : 'user')) : 'user';
|
|
228
|
+
/** systemd 的公共参数:system 作用域不带 `--user` */
|
|
229
|
+
const systemdArgs = scope === 'system' ? [] : ['--user'];
|
|
230
|
+
/** launchd 的域:GUI 会话固定在 gui/<uid>,只有 SSH 时会话里退到 user/<uid> */
|
|
231
|
+
const domains = [`gui/${uid}`, `user/${uid}`];
|
|
232
|
+
const dir = serviceDir({ platform, home, scope, systemDir: options.systemDir });
|
|
233
|
+
const file = (job) =>
|
|
234
|
+
path.join(dir, platform === 'darwin' ? `${jobOf(job).label}.plist` : jobOf(job).unit);
|
|
235
|
+
const installed = (job) => fs.existsSync(file(job));
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* 渲染某个 job 的服务定义。node / cliDir / 端口没传时用 manager 自己的默认值
|
|
239
|
+
* (CLI 的 `service print` 只关心端口,不重复拼这些路径)。
|
|
240
|
+
*/
|
|
241
|
+
const render = (job, opts = {}) =>
|
|
242
|
+
(platform === 'darwin' ? renderPlist : renderUnit)(job, {
|
|
243
|
+
node: opts.node ?? node,
|
|
244
|
+
cliDir: opts.cliDir ?? cliDir,
|
|
245
|
+
scope: opts.scope ?? scope,
|
|
246
|
+
port: opts.port ?? DEFAULT_PORT,
|
|
247
|
+
host: opts.host ?? DEFAULT_HOST,
|
|
248
|
+
entry: entries[job],
|
|
249
|
+
});
|
|
250
|
+
const renderOpts = (opts = {}) => ({
|
|
251
|
+
node,
|
|
252
|
+
cliDir,
|
|
253
|
+
scope,
|
|
254
|
+
port: opts.port ?? DEFAULT_PORT,
|
|
255
|
+
host: opts.host ?? DEFAULT_HOST,
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
const hint =
|
|
259
|
+
platform === 'win32'
|
|
260
|
+
? 'Windows 没有 launchd / systemd:请把 mihomo-dash 的快捷方式放进「启动」文件夹(shell:startup),或用任务计划程序'
|
|
261
|
+
: `暂不支持在 ${platform} 上装开机自启`;
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Linux:`systemctl --user` 到底能不能用(探一次缓存住,顺便留住失败原因给提示用)。
|
|
265
|
+
* 非 systemd 发行版(Alpine / OpenRC…)根本没这个命令;容器 / 没有用户会话的环境里
|
|
266
|
+
* 命令在但连不上用户实例(`Failed to connect to bus`)—— 两种都装不了服务,一并算不可用。
|
|
267
|
+
* 缓存的是 **Promise**(并发调用只探一次,不需要额外的 inflight 标记)。
|
|
268
|
+
*/
|
|
269
|
+
let systemdProbe;
|
|
270
|
+
function probeSystemd() {
|
|
271
|
+
if (!systemdProbe) {
|
|
272
|
+
systemdProbe = (async () => {
|
|
273
|
+
const r = await run('systemctl', [...systemdArgs, 'is-system-running']);
|
|
274
|
+
const reason = String(r.stderr || '').trim();
|
|
275
|
+
// 注意:systemd 在 degraded / starting 时也返回非 0,但那是 stdout 里的状态词;
|
|
276
|
+
// 这里只认「命令不存在」「连不上总线」这类硬失败
|
|
277
|
+
const broken =
|
|
278
|
+
/ENOENT|not found|No such file|command not found|Failed to connect to bus/i.test(reason);
|
|
279
|
+
return { ok: !broken, reason: broken ? reason : '' };
|
|
280
|
+
})();
|
|
281
|
+
}
|
|
282
|
+
return systemdProbe;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** 干不了活的原因(null = 可以);unsupported 平台与「Linux 上没有可用 systemd」共用一套判定 */
|
|
286
|
+
async function unavailable() {
|
|
287
|
+
if (!supported) return hint;
|
|
288
|
+
// 只有 Linux 才探 systemd:macOS 上白跑一条 systemctl 是多余的进程(测试也盯着这点)
|
|
289
|
+
if (platform === 'linux') {
|
|
290
|
+
const probe = await probeSystemd();
|
|
291
|
+
if (!probe.ok) {
|
|
292
|
+
const { reason } = probe;
|
|
293
|
+
return scope === 'system'
|
|
294
|
+
? `本机没有可用的 systemd${reason ? `(${reason})` : ''}:非 systemd 发行版请用发行版自己的开机自启方式`
|
|
295
|
+
: `本机没有可用的 systemd 用户实例${reason ? `(${reason})` : ''}:` +
|
|
296
|
+
'要么先在有用户会话的环境里跑,要么用 service install --system(system 单元要 root)';
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
return null;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** Linux:用户 linger 是否开着(决定是「开机即起」还是「登录后启动」);问不到返回 null */
|
|
303
|
+
async function readLinger() {
|
|
304
|
+
const r = await run('loginctl', ['show-user', user, '-p', 'Linger']);
|
|
305
|
+
const m = /Linger=(\w+)/.exec(r.stdout || '');
|
|
306
|
+
return m ? m[1] === 'yes' : null;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/** 已装载的域(找不到返回 null) */
|
|
310
|
+
async function loadedDomain(job) {
|
|
311
|
+
for (const d of domains) {
|
|
312
|
+
const r = await run('launchctl', ['print', `${d}/${jobOf(job).label}`]);
|
|
313
|
+
if (r.ok) return d;
|
|
314
|
+
}
|
|
315
|
+
return null;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* 安装:写两个 plist / unit 并装载启用。
|
|
320
|
+
*
|
|
321
|
+
* - 已装载的 job **不 bootout**(那会当场杀掉它),只更新磁盘上的定义 —— 新定义下次登录生效;
|
|
322
|
+
* - 其余一律 bootstrap / `enable --now`:「安装」就该当场装载上(页面上的「已装载」才是真的)。
|
|
323
|
+
* 进程正在手动跑着的 job,服务拉起的第二个实例会因端口被占 / pid 文件活着而立刻退出 ——
|
|
324
|
+
* 那是预期内的,调用方(CLI 的 `service install`)会就这件事补一句说明。
|
|
325
|
+
* - `manualRunning` 只影响 Linux:这类 job 只 `enable` 不 `--now`,免得 systemd 里留一个
|
|
326
|
+
* 注定失败(端口被占)的 failed 单元。
|
|
327
|
+
* @param {{ port?: number, host?: string, manualRunning?: string[] }} [opts]
|
|
328
|
+
*/
|
|
329
|
+
async function install(opts = {}) {
|
|
330
|
+
const why = await unavailable();
|
|
331
|
+
if (why) {
|
|
332
|
+
return { supported: false, hint: why, files: [], actions: [], errors: [], notes: [] };
|
|
333
|
+
}
|
|
334
|
+
const manualRunning = new Set(opts.manualRunning || []);
|
|
335
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
336
|
+
const files = [];
|
|
337
|
+
const actions = [];
|
|
338
|
+
const errors = [];
|
|
339
|
+
const notes = [];
|
|
340
|
+
const loadedIn = {};
|
|
341
|
+
for (const job of SERVICE_JOBS) {
|
|
342
|
+
const target = file(job);
|
|
343
|
+
fs.writeFileSync(target, render(job, renderOpts(opts)));
|
|
344
|
+
files.push(target);
|
|
345
|
+
if (platform === 'darwin') {
|
|
346
|
+
const loaded = await loadedDomain(job);
|
|
347
|
+
if (loaded) {
|
|
348
|
+
loadedIn[job] = loaded;
|
|
349
|
+
notes.push(`${job}: 已在 ${loaded} 装载,只更新了定义(下次登录生效)`);
|
|
350
|
+
continue;
|
|
351
|
+
}
|
|
352
|
+
let ok = false;
|
|
353
|
+
const failures = [];
|
|
354
|
+
for (const d of domains) {
|
|
355
|
+
const r = await run('launchctl', ['bootstrap', d, target], { timeout: 10000 });
|
|
356
|
+
if (r.ok) {
|
|
357
|
+
ok = true;
|
|
358
|
+
loadedIn[job] = d;
|
|
359
|
+
actions.push(`launchctl bootstrap ${d} ${target}`);
|
|
360
|
+
break;
|
|
361
|
+
}
|
|
362
|
+
// gui 域失败但 user 域成功是正常情况(SSH 会话),只有全都失败才算错
|
|
363
|
+
failures.push(`${d}: ${r.stderr.trim() || `退出码 ${r.status}`}`);
|
|
364
|
+
}
|
|
365
|
+
if (!ok) {
|
|
366
|
+
errors.push(
|
|
367
|
+
`${job}: launchctl bootstrap 失败(${failures.join(';')});plist 已写好,下次登录仍会启动`,
|
|
368
|
+
);
|
|
369
|
+
}
|
|
370
|
+
} else {
|
|
371
|
+
const r1 = await run('systemctl', [...systemdArgs, 'daemon-reload']);
|
|
372
|
+
if (!r1.ok && r1.stderr) errors.push(`daemon-reload: ${r1.stderr.trim()}`);
|
|
373
|
+
// 手动跑着的只 enable(不开机就拉起第二个必然失败的实例)
|
|
374
|
+
const args = manualRunning.has(job)
|
|
375
|
+
? [...systemdArgs, 'enable', jobOf(job).unit]
|
|
376
|
+
: [...systemdArgs, 'enable', '--now', jobOf(job).unit];
|
|
377
|
+
if (manualRunning.has(job)) {
|
|
378
|
+
notes.push(`${job}: 进程正在手动运行,本次只启用不拉起(下次登录由服务管理器接管)`);
|
|
379
|
+
}
|
|
380
|
+
const r2 = await run('systemctl', args);
|
|
381
|
+
if (r2.ok) actions.push(`systemctl ${args.join(' ')}`);
|
|
382
|
+
else errors.push(`${job}: ${r2.stderr.trim() || 'systemctl enable 失败'}`);
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
const hints = [];
|
|
386
|
+
if (platform === 'darwin') {
|
|
387
|
+
hints.push(
|
|
388
|
+
`plist 里的 Node 固定为 ${node}:换 Node 版本(nvm use 等)后要重新点一次「安装」`,
|
|
389
|
+
);
|
|
390
|
+
} else if (scope === 'system') {
|
|
391
|
+
hints.push('system 单元以 root 运行:开机即起,不依赖登录与 linger');
|
|
392
|
+
} else {
|
|
393
|
+
hints.push(
|
|
394
|
+
'用户级单元:服务器/无桌面登录场景还要 sudo loginctl enable-linger <用户>,否则退出登录后单元会被停掉;' +
|
|
395
|
+
'有 root 时也可改用系统级(service install --system)',
|
|
396
|
+
);
|
|
397
|
+
}
|
|
398
|
+
return {
|
|
399
|
+
supported: true,
|
|
400
|
+
platform,
|
|
401
|
+
scope,
|
|
402
|
+
dir,
|
|
403
|
+
files,
|
|
404
|
+
actions,
|
|
405
|
+
errors,
|
|
406
|
+
notes,
|
|
407
|
+
loadedIn,
|
|
408
|
+
hints,
|
|
409
|
+
};
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* 卸载:删服务定义与 enable 状态,**不杀正在跑的进程**(见文件头第 3 条)。
|
|
414
|
+
*
|
|
415
|
+
* Linux 侧要 `disable`(不 `--now`):enable 的符号链接与 unit 文件是两回事,不 disable 的话
|
|
416
|
+
* systemd 下次登录还会照着符号链接把它拉起来。launchd 只要 plist 没了,下次登录就不会拉 ——
|
|
417
|
+
* 但**本会话里那个 job 还挂在 launchd 上**(不 bootout 是为了不杀进程),页面会把它标成「残留」。
|
|
418
|
+
*/
|
|
419
|
+
async function uninstall() {
|
|
420
|
+
const why = await unavailable();
|
|
421
|
+
if (why) {
|
|
422
|
+
return { supported: false, hint: why, removed: [], actions: [], errors: [], notes: [] };
|
|
423
|
+
}
|
|
424
|
+
const removed = [];
|
|
425
|
+
const actions = [];
|
|
426
|
+
const errors = [];
|
|
427
|
+
const notes = [
|
|
428
|
+
'服务定义已删除;正在跑的进程没动(要停它们:服务管理器 stop / 侧栏的「停止内核」)',
|
|
429
|
+
];
|
|
430
|
+
for (const job of SERVICE_JOBS) {
|
|
431
|
+
const target = file(job);
|
|
432
|
+
// 删文件前先看一眼:launchd 的加载状态与文件无关,删了也照样挂着
|
|
433
|
+
const stillLoaded = platform === 'darwin' && !!(await loadedDomain(job));
|
|
434
|
+
if (platform === 'linux') {
|
|
435
|
+
const r = await run('systemctl', [...systemdArgs, 'disable', jobOf(job).unit]);
|
|
436
|
+
if (r.ok) actions.push(`systemctl --user disable ${jobOf(job).unit}`);
|
|
437
|
+
else if (r.stderr && !/not loaded|does not exist|No such file/i.test(r.stderr)) {
|
|
438
|
+
errors.push(`${job}: ${r.stderr.trim()}`);
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
try {
|
|
442
|
+
fs.rmSync(target, { force: true });
|
|
443
|
+
removed.push(target);
|
|
444
|
+
} catch (err) {
|
|
445
|
+
errors.push(`${target}: ${err?.message || err}`);
|
|
446
|
+
}
|
|
447
|
+
if (stillLoaded) {
|
|
448
|
+
notes.push(
|
|
449
|
+
`${job}: plist 已删,但 launchd 里这个 job 还挂着(本会话内 launchctl 仍能看到)` +
|
|
450
|
+
'—— 卸载不 bootout 是为了不杀掉进程,重新登录后它就没了',
|
|
451
|
+
);
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
if (platform === 'linux') await run('systemctl', [...systemdArgs, 'daemon-reload']);
|
|
455
|
+
return { supported: true, platform, removed, actions, errors, notes };
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
async function status() {
|
|
459
|
+
const why = await unavailable();
|
|
460
|
+
if (why) return { supported: false, platform, hint: why, jobs: [] };
|
|
461
|
+
const statuses = await Promise.all(
|
|
462
|
+
SERVICE_JOBS.map(async (job) => {
|
|
463
|
+
const filePath = file(job);
|
|
464
|
+
if (platform === 'darwin') {
|
|
465
|
+
const d = await loadedDomain(job);
|
|
466
|
+
const r = d ? await run('launchctl', ['print', `${d}/${jobOf(job).label}`]) : null;
|
|
467
|
+
const parsed = r?.ok ? parseLaunchctlPrint(r.stdout) : { state: null, pid: 0 };
|
|
468
|
+
return {
|
|
469
|
+
job,
|
|
470
|
+
file: filePath,
|
|
471
|
+
installed: fs.existsSync(filePath),
|
|
472
|
+
loaded: !!d,
|
|
473
|
+
domain: d,
|
|
474
|
+
running: parsed.state === 'running',
|
|
475
|
+
pid: parsed.pid,
|
|
476
|
+
state: parsed.state,
|
|
477
|
+
};
|
|
478
|
+
}
|
|
479
|
+
const enabled = await run('systemctl', [...systemdArgs, 'is-enabled', jobOf(job).unit]);
|
|
480
|
+
const active = await run('systemctl', [...systemdArgs, 'is-active', jobOf(job).unit]);
|
|
481
|
+
const parsed = parseSystemctlState(enabled.stdout || '', active.stdout || '');
|
|
482
|
+
return {
|
|
483
|
+
job,
|
|
484
|
+
file: filePath,
|
|
485
|
+
installed: fs.existsSync(filePath) && parsed.installed,
|
|
486
|
+
loaded: parsed.installed,
|
|
487
|
+
domain: 'systemd --user',
|
|
488
|
+
running: parsed.active,
|
|
489
|
+
pid: 0,
|
|
490
|
+
state: parsed.state,
|
|
491
|
+
};
|
|
492
|
+
}),
|
|
493
|
+
);
|
|
494
|
+
// linger 只对「用户级」有意义:system 单元本来就开机即起
|
|
495
|
+
return {
|
|
496
|
+
supported: true,
|
|
497
|
+
platform,
|
|
498
|
+
scope,
|
|
499
|
+
dir,
|
|
500
|
+
jobs: statuses,
|
|
501
|
+
linger: platform === 'linux' && scope === 'user' ? await readLinger() : null,
|
|
502
|
+
};
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
/**
|
|
506
|
+
* 手动拉起已安装的服务:「只开机拉起一次、不保活」,所以停掉之后靠这个或重新登录。
|
|
507
|
+
* launchd:未装载就先 bootstrap,已装载就 kickstart;systemd:直接 `--user start`。
|
|
508
|
+
* @param {'panel'|'kernel'} [job] 缺省时两个都拉
|
|
509
|
+
*/
|
|
510
|
+
async function start(job) {
|
|
511
|
+
const why = await unavailable();
|
|
512
|
+
if (why) return { supported: false, hint: why, started: [], errors: [] };
|
|
513
|
+
const want = job ? [job] : SERVICE_JOBS;
|
|
514
|
+
const started = [];
|
|
515
|
+
const errors = [];
|
|
516
|
+
for (const j of want) {
|
|
517
|
+
jobOf(j);
|
|
518
|
+
if (!installed(j)) {
|
|
519
|
+
errors.push(`${j}: 还没安装开机自启(先点「安装」)`);
|
|
520
|
+
continue;
|
|
521
|
+
}
|
|
522
|
+
if (platform === 'darwin') {
|
|
523
|
+
const d = await loadedDomain(j);
|
|
524
|
+
if (!d) {
|
|
525
|
+
let ok = false;
|
|
526
|
+
const failures = [];
|
|
527
|
+
for (const dom of domains) {
|
|
528
|
+
const r = await run('launchctl', ['bootstrap', dom, file(j)], { timeout: 10000 });
|
|
529
|
+
if (r.ok) {
|
|
530
|
+
ok = true;
|
|
531
|
+
started.push(`launchctl bootstrap ${dom} ${file(j)}`);
|
|
532
|
+
break;
|
|
533
|
+
}
|
|
534
|
+
failures.push(`${dom}: ${r.stderr.trim() || `退出码 ${r.status}`}`);
|
|
535
|
+
}
|
|
536
|
+
if (!ok) errors.push(`${j}: launchctl bootstrap 失败(${failures.join(';')})`);
|
|
537
|
+
continue;
|
|
538
|
+
}
|
|
539
|
+
const r = await run('launchctl', ['kickstart', `${d}/${jobOf(j).label}`]);
|
|
540
|
+
if (r.ok) started.push(`launchctl kickstart ${d}/${jobOf(j).label}`);
|
|
541
|
+
else errors.push(`${j}: ${r.stderr.trim() || 'launchctl kickstart 失败'}`);
|
|
542
|
+
} else {
|
|
543
|
+
const r = await run('systemctl', [...systemdArgs, 'start', jobOf(j).unit]);
|
|
544
|
+
if (r.ok) started.push(`systemctl --user start ${jobOf(j).unit}`);
|
|
545
|
+
else errors.push(`${j}: ${r.stderr.trim() || 'systemctl start 失败'}`);
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
return { supported: true, platform, started, errors };
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
return { platform, supported, hint, scope, dir, file, render, install, uninstall, status, start };
|
|
552
|
+
}
|