tt-help-cli-ycl 1.4.157 → 1.4.158
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/package.json +1 -1
- package/src/clash/daemon.js +118 -35
- package/src/clash/discover.js +22 -1
- package/src/clash/runtime.js +161 -55
- package/src/cli/refresh-mini.js +2 -1
- package/src/watch/public/app.js +1 -1
- package/src/watch/public/index.html +1 -1
- package/src/watchdog/upgrader.js +4 -1
package/package.json
CHANGED
package/src/clash/daemon.js
CHANGED
|
@@ -11,7 +11,7 @@ import { normalizeCountries } from "./checker.js";
|
|
|
11
11
|
import * as api from "./api.js";
|
|
12
12
|
import * as checker from "./checker.js";
|
|
13
13
|
import * as ipc from "./ipc.js";
|
|
14
|
-
import { discoverAll, invalidateDiscoverCache } from "./discover.js";
|
|
14
|
+
import { discoverAll, invalidateDiscoverCache, DISCOVER_DEFAULTS, RUNTIME_MAX_INSTANCES } from "./discover.js";
|
|
15
15
|
import * as runtime from "./runtime.js";
|
|
16
16
|
|
|
17
17
|
// ── 国家过滤辅助(normalizeCountries 由 checker.js 提供单一来源) ──
|
|
@@ -298,22 +298,32 @@ export class ClashDaemon {
|
|
|
298
298
|
if (!aliveAny) {
|
|
299
299
|
// 内置内核自动拉起(先发现后拉起:Verge/已跑的 mihomo 会被发现并认领,
|
|
300
300
|
// 真没有才 spawn;端口被占/冷却期会跳过,详见 runtime.ensureMihomoRunning)
|
|
301
|
+
// 多实例: 订阅每条对应一个实例,逐个拉起(首个实例失败也不阻塞后续)
|
|
301
302
|
try {
|
|
302
|
-
const
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
303
|
+
const stored = runtime.readStoredSubscriptions();
|
|
304
|
+
const want = Math.max(1, stored.length);
|
|
305
|
+
for (let i = 0; i < want; i++) {
|
|
306
|
+
const idx = i + 1;
|
|
307
|
+
if (!stored[i] && idx > 1) break; // 无订阅时只尝试实例1(兼容无订阅+Verge 接管路径)
|
|
308
|
+
const r = await runtime
|
|
309
|
+
.ensureMihomoRunning({
|
|
310
|
+
subscriptions: stored[i] ? [stored[i]] : undefined,
|
|
311
|
+
index: idx,
|
|
312
|
+
log: (m) => this._log(`[实例${idx}] ${m}`),
|
|
313
|
+
})
|
|
314
|
+
.catch((e) => ({ status: "failed", error: e.message }));
|
|
315
|
+
this._runtimeStatus = { ...r, at: Date.now(), index: idx };
|
|
316
|
+
if (r.status === "started") {
|
|
317
|
+
this._log(`✓ 实例${idx} 内置 mihomo 已就绪,下轮发现将接管`);
|
|
318
|
+
} else if (r.status === "no-subscription") {
|
|
319
|
+
// 无订阅不接管(不拉起不占位),防刷屏降频打日志
|
|
320
|
+
if (Date.now() - (this._lastNoSubLogAt || 0) > 300000) {
|
|
321
|
+
this._log(`⏭ 未配置订阅,内置 mihomo 不参与(后台配置订阅后自动接管)`);
|
|
322
|
+
this._lastNoSubLogAt = Date.now();
|
|
323
|
+
}
|
|
324
|
+
} else if (r.status === "port-busy") {
|
|
325
|
+
this._log(`⚠ ${r.error}(不影响发现已有控制端)`);
|
|
314
326
|
}
|
|
315
|
-
} else if (r.status === "port-busy") {
|
|
316
|
-
this._log(`⚠ ${r.error}(不影响发现已有控制端)`);
|
|
317
327
|
}
|
|
318
328
|
} catch (e) {
|
|
319
329
|
this._log(`内置内核拉起异常: ${e.message}`);
|
|
@@ -396,27 +406,74 @@ export class ClashDaemon {
|
|
|
396
406
|
for (const c of this._controllers) {
|
|
397
407
|
await this._healthCheckController(c);
|
|
398
408
|
}
|
|
409
|
+
// 期望实例自愈:订阅对应的全部实例,不在线的自动拉起
|
|
410
|
+
// (覆盖 discover 发现不了的死实例——挂掉的实例不会出现在控制端列表,只能按订阅配置主动拉)
|
|
411
|
+
await this._ensureDesiredInstances();
|
|
399
412
|
}).catch((e) => this._log(`巡检异常: ${e.message}`));
|
|
400
413
|
}
|
|
401
414
|
|
|
415
|
+
/**
|
|
416
|
+
* 内置期望实例自愈: stored 订阅每条对应一个实例,逐个检查 API 存活,
|
|
417
|
+
* 不在线的 ensureMihomoRunning 拉起(per-instance 冷却防崩溃循环,端口占用内部自会跳过)
|
|
418
|
+
* @returns {Promise<{started:number, failed:number}>}
|
|
419
|
+
*/
|
|
420
|
+
async _ensureDesiredInstances() {
|
|
421
|
+
const stored = runtime.readStoredSubscriptions();
|
|
422
|
+
if (!stored.length) return { started: 0, failed: 0 };
|
|
423
|
+
let started = 0;
|
|
424
|
+
let failed = 0;
|
|
425
|
+
for (let i = 0; i < stored.length; i++) {
|
|
426
|
+
const idx = i + 1;
|
|
427
|
+
const spec = runtime.instanceSpec(idx);
|
|
428
|
+
if (await runtime.controllerAlive(spec.apiPort, spec.secret)) continue;
|
|
429
|
+
const r = await runtime
|
|
430
|
+
.ensureMihomoRunning({
|
|
431
|
+
subscriptions: [stored[i]],
|
|
432
|
+
index: idx,
|
|
433
|
+
log: (m) => this._log(`[实例${idx}] ${m}`),
|
|
434
|
+
})
|
|
435
|
+
.catch((e) => ({ status: "failed", error: e.message }));
|
|
436
|
+
this._runtimeStatus = { ...r, at: Date.now(), index: idx };
|
|
437
|
+
if (r.status === "started") {
|
|
438
|
+
started++;
|
|
439
|
+
this._log(`✓ 实例${idx} (${spec.apiPort}/${spec.mixedPort}) 自愈拉起成功,下轮发现将接管`);
|
|
440
|
+
} else if (r.status === "failed" || r.status === "no-vendor") {
|
|
441
|
+
failed++;
|
|
442
|
+
this._log(`⚠ 实例${idx} 自愈拉起失败: ${r.error || r.status}`);
|
|
443
|
+
}
|
|
444
|
+
// adopted/cooldown/port-busy/no-subscription: 正常等待场景,不打日志刷屏
|
|
445
|
+
}
|
|
446
|
+
return { started, failed };
|
|
447
|
+
}
|
|
448
|
+
|
|
402
449
|
async _healthCheckController(c) {
|
|
403
450
|
if (c._probing) return; // delay 初测中跳过本轮巡检
|
|
404
451
|
this._useController(c);
|
|
405
452
|
try {
|
|
406
453
|
if (!checker.checkClashAlive()) {
|
|
407
|
-
//
|
|
408
|
-
//
|
|
409
|
-
const
|
|
410
|
-
.
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
454
|
+
// 控制端失联: 若是内置实例(端口在 9097+N 段内)按对应实例拉起(冷却保护,
|
|
455
|
+
// 端口占用场景内部会跳过);外来控制端不动。本轮先报不健康,拉起结果下轮巡检验证
|
|
456
|
+
const idx =
|
|
457
|
+
c.port >= DISCOVER_DEFAULTS.port && c.port < DISCOVER_DEFAULTS.port + RUNTIME_MAX_INSTANCES
|
|
458
|
+
? c.port - DISCOVER_DEFAULTS.port + 1
|
|
459
|
+
: 0;
|
|
460
|
+
if (idx) {
|
|
461
|
+
const stored = runtime.readStoredSubscriptions();
|
|
462
|
+
if (stored[idx - 1]) {
|
|
463
|
+
const r = await runtime
|
|
464
|
+
.ensureMihomoRunning({
|
|
465
|
+
subscriptions: [stored[idx - 1]],
|
|
466
|
+
index: idx,
|
|
467
|
+
log: (m) => this._log(`[实例${idx}] ${m}`),
|
|
468
|
+
})
|
|
469
|
+
.catch((e) => ({ status: "failed", error: e.message }));
|
|
470
|
+
this._runtimeStatus = { ...r, at: Date.now(), index: idx };
|
|
471
|
+
}
|
|
472
|
+
}
|
|
416
473
|
return this._writeHealth(c, {
|
|
417
474
|
node: "?",
|
|
418
475
|
healthy: false,
|
|
419
|
-
error: `${c.clientName} 未运行${
|
|
476
|
+
error: `${c.clientName} 未运行${idx ? "(内置实例已尝试自动拉起)" : ""}`,
|
|
420
477
|
});
|
|
421
478
|
}
|
|
422
479
|
const node = await api
|
|
@@ -953,14 +1010,15 @@ export class ClashDaemon {
|
|
|
953
1010
|
|
|
954
1011
|
/**
|
|
955
1012
|
* 看门狗指令: 应用订阅配置(服务端页面改动 → 心跳下发 → 此处热应用)
|
|
956
|
-
*
|
|
1013
|
+
* 多实例: 一条订阅 = 一个内置 mihomo 实例(sub[0]→实例1@9097/7897,sub[n]→9097+n/7897+n)
|
|
1014
|
+
* 幂等: 与运行时配置比对,无变化不动作;全部成功后持久化到 ~/.tt-help.json
|
|
957
1015
|
* 保证 daemon/机器重启后自动恢复,不依赖服务端可达
|
|
958
1016
|
*/
|
|
959
1017
|
async _handleSetSubscription(cmd, c) {
|
|
960
1018
|
try {
|
|
961
1019
|
const subs = runtime.normalizeSubscriptions(cmd.subscriptions);
|
|
962
1020
|
if (!subs.length) {
|
|
963
|
-
// 清空订阅:
|
|
1021
|
+
// 清空订阅: 回收全部内置实例 + 清持久化 → 回到"无订阅不接管"态(用户可回退到 Verge)
|
|
964
1022
|
const killed = await runtime.stopRuntimeMihomo({ log: (m) => this._log(m) });
|
|
965
1023
|
runtime.storeSubscriptions([]);
|
|
966
1024
|
c._subsSnapshot = [];
|
|
@@ -969,17 +1027,42 @@ export class ClashDaemon {
|
|
|
969
1027
|
this._writeHealth(c, { node: "?", healthy: null });
|
|
970
1028
|
return;
|
|
971
1029
|
}
|
|
972
|
-
this._log(`📋 收到订阅配置: ${subs.length}
|
|
973
|
-
|
|
974
|
-
|
|
1030
|
+
this._log(`📋 收到订阅配置: ${subs.length} 条,按条启动独立实例(9097+每条递增)...`);
|
|
1031
|
+
// 一条订阅 = 一个实例,逐个应用;单实例失败不影响其余
|
|
1032
|
+
const results = [];
|
|
1033
|
+
for (let i = 0; i < subs.length; i++) {
|
|
1034
|
+
const idx = i + 1;
|
|
1035
|
+
const spec = runtime.instanceSpec(idx);
|
|
1036
|
+
const r = await runtime
|
|
1037
|
+
.applyUserSubscriptions([subs[i]], { index: idx, log: (m) => this._log(`[实例${idx}] ${m}`) })
|
|
1038
|
+
.catch((e) => ({ ok: false, error: e.message }));
|
|
1039
|
+
results.push({ index: idx, url: subs[i].url, ok: !!r.ok, error: r.error || null });
|
|
1040
|
+
this._log(
|
|
1041
|
+
r.ok
|
|
1042
|
+
? `✓ 实例${idx} (${spec.apiPort}/${spec.mixedPort}) 订阅应用成功: ${subs[i].url.slice(0, 60)}`
|
|
1043
|
+
: `⚠ 实例${idx} (${spec.apiPort}/${spec.mixedPort}) 订阅应用失败: ${r.error}`,
|
|
1044
|
+
);
|
|
1045
|
+
}
|
|
1046
|
+
// 订阅减少 → 回收多余实例
|
|
1047
|
+
const pruned = await runtime.pruneRuntimeInstances(subs.length, { log: (m) => this._log(m) });
|
|
1048
|
+
const failed = results.filter((r) => !r.ok);
|
|
1049
|
+
const allOk = failed.length === 0;
|
|
1050
|
+
if (allOk) {
|
|
1051
|
+
// 全部成功才持久化;部分失败不持久化,服务端下轮心跳重发全量重试(成功实例幂等短路)
|
|
975
1052
|
runtime.storeSubscriptions(subs);
|
|
976
|
-
|
|
977
|
-
c._applyResult = { ok: true, changed: r.changed, at: Date.now(), count: subs.length };
|
|
978
|
-
this._log(`📋 订阅应用成功(${subs.length} 条)${r.changed ? "" : "(无变化)"}`);
|
|
1053
|
+
this._log(`📋 订阅应用完成(${subs.length} 实例${pruned ? `,回收多余 ${pruned} 个` : ""})`);
|
|
979
1054
|
} else {
|
|
980
|
-
|
|
981
|
-
this._log(`⚠ 订阅应用失败: ${r.error}`);
|
|
1055
|
+
this._log(`⚠ ${failed.length}/${subs.length} 个实例应用失败(未持久化,待下轮心跳重试): ${failed.map((f) => `实例${f.index}`).join(",")}`);
|
|
982
1056
|
}
|
|
1057
|
+
c._subsSnapshot = await api.getSubscriptionSummaries().catch(() => null);
|
|
1058
|
+
c._applyResult = {
|
|
1059
|
+
ok: allOk,
|
|
1060
|
+
changed: true,
|
|
1061
|
+
at: Date.now(),
|
|
1062
|
+
count: subs.length,
|
|
1063
|
+
instances: results.map((r) => ({ index: r.index, apiPort: runtime.instanceSpec(r.index).apiPort, ok: r.ok, error: r.error })),
|
|
1064
|
+
error: allOk ? null : failed.map((f) => `实例${f.index}: ${f.error}`).join("; "),
|
|
1065
|
+
};
|
|
983
1066
|
// 结果写健康文件,页面下次心跳可见
|
|
984
1067
|
const node = await api.getCurrentNode(checker.CONFIG.groupName).catch(() => "?");
|
|
985
1068
|
this._writeHealth(c, { node, healthy: null });
|
package/src/clash/discover.js
CHANGED
|
@@ -25,6 +25,13 @@ export const DISCOVER_DEFAULTS = {
|
|
|
25
25
|
selection: "system-proxy",
|
|
26
26
|
};
|
|
27
27
|
|
|
28
|
+
/**
|
|
29
|
+
* 内置 mihomo 多实例上限(一条订阅 = 一个实例):
|
|
30
|
+
* 实例 n 的 API 端口 = 9097+(n-1),mixed 端口 = 7897+(n-1),secret 统一 abcdefg
|
|
31
|
+
* 发现/回收均按此端口段枚举(9097-9104 / 7897-7904)
|
|
32
|
+
*/
|
|
33
|
+
export const RUNTIME_MAX_INSTANCES = 8;
|
|
34
|
+
|
|
28
35
|
// 已知配置文件探测表(决策 4A: Clash Verge 优先)
|
|
29
36
|
const CONFIG_PATHS = (() => {
|
|
30
37
|
const appData = process.env.APPDATA || "";
|
|
@@ -313,7 +320,21 @@ async function _collectCandidates({ apiPort, secret, extraPaths }) {
|
|
|
313
320
|
});
|
|
314
321
|
}
|
|
315
322
|
|
|
316
|
-
// 3)
|
|
323
|
+
// 3) 内置 runtime 实例段探测(~/.tt-help/mihomo 拉起的多实例: 9097+N,secret 统一 abcdefg)
|
|
324
|
+
for (let i = 1; i <= RUNTIME_MAX_INSTANCES; i++) {
|
|
325
|
+
const rtPort = DISCOVER_DEFAULTS.port + (i - 1);
|
|
326
|
+
if (candidates.some((c) => c.port === rtPort)) continue;
|
|
327
|
+
if (await verifyController(rtPort, DISCOVER_DEFAULTS.secret)) {
|
|
328
|
+
candidates.push({
|
|
329
|
+
port: rtPort,
|
|
330
|
+
secret: DISCOVER_DEFAULTS.secret,
|
|
331
|
+
clientName: "mihomo(内置)",
|
|
332
|
+
source: `内置实例${i}(${rtPort})`,
|
|
333
|
+
});
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// 4) 端口枚举兜底(配置文件一无所获时)
|
|
317
338
|
if (!candidates.length) {
|
|
318
339
|
const probes = listListeningPorts().map(async (port) => {
|
|
319
340
|
const r = await probeVersion(port, "", 2000);
|
package/src/clash/runtime.js
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
* 被调用方: daemon.js(自动托管)、CLI(tt-help proxy,共用同一套)
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
|
-
import { existsSync, readFileSync, writeFileSync, mkdirSync, rmSync, statSync, readdirSync, copyFileSync, openSync, renameSync } from "fs";
|
|
16
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync, rmSync, statSync, readdirSync, copyFileSync, openSync, renameSync, lstatSync, symlinkSync } from "fs";
|
|
17
17
|
import net from "net";
|
|
18
18
|
import { homedir, platform, arch } from "os";
|
|
19
19
|
import { join, dirname } from "path";
|
|
@@ -22,7 +22,7 @@ import { gunzipSync } from "zlib";
|
|
|
22
22
|
import { createHash } from "crypto";
|
|
23
23
|
import { fileURLToPath } from "url";
|
|
24
24
|
import { parseNetstatListeningPids } from "../lib/browser/cdp.js";
|
|
25
|
-
import { DISCOVER_DEFAULTS } from "./discover.js";
|
|
25
|
+
import { DISCOVER_DEFAULTS, RUNTIME_MAX_INSTANCES } from "./discover.js";
|
|
26
26
|
|
|
27
27
|
const IS_WIN = platform() === "win32";
|
|
28
28
|
|
|
@@ -32,18 +32,35 @@ const IS_WIN = platform() === "win32";
|
|
|
32
32
|
const PKG_ROOT = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
|
|
33
33
|
/** 随包内核目录 */
|
|
34
34
|
export const VENDOR_DIR = join(PKG_ROOT, "vendor", "mihomo");
|
|
35
|
-
/**
|
|
35
|
+
/** 运行时根目录(实例1;bin/ui/stamp 共享,各实例 config/log/providers 隔离在子目录) */
|
|
36
36
|
export const RUNTIME_DIR = join(homedir(), ".tt-help", "mihomo");
|
|
37
37
|
|
|
38
|
-
|
|
38
|
+
/**
|
|
39
|
+
* 实例 n(1-based)的端口/目录规格 — 一条订阅 = 一个实例:
|
|
40
|
+
* API 端口 = 9097+(n-1),mixed 端口 = 7897+(n-1),secret 统一 DISCOVER_DEFAULTS.secret
|
|
41
|
+
* 实例1 沿用根目录(向后兼容),实例2+ 在 i2/i3/… 子目录(config/providers/run.log 隔离)
|
|
42
|
+
*/
|
|
43
|
+
export function instanceSpec(index = 1) {
|
|
44
|
+
const n = Math.max(1, Math.floor(index) || 1);
|
|
39
45
|
return {
|
|
40
|
-
|
|
46
|
+
index: n,
|
|
47
|
+
apiPort: DISCOVER_DEFAULTS.port + (n - 1),
|
|
48
|
+
mixedPort: DISCOVER_DEFAULTS.proxyPort + (n - 1),
|
|
49
|
+
secret: DISCOVER_DEFAULTS.secret,
|
|
50
|
+
dir: n <= 1 ? RUNTIME_DIR : join(RUNTIME_DIR, `i${n}`),
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function runtimePaths(index = 1) {
|
|
55
|
+
const spec = instanceSpec(index);
|
|
56
|
+
return {
|
|
57
|
+
dir: spec.dir,
|
|
41
58
|
bin: join(RUNTIME_DIR, "bin", IS_WIN ? "mihomo.exe" : "mihomo"),
|
|
42
59
|
ui: join(RUNTIME_DIR, "ui"),
|
|
43
|
-
configFile: join(
|
|
44
|
-
logFile: join(
|
|
60
|
+
configFile: join(spec.dir, "config.yaml"),
|
|
61
|
+
logFile: join(spec.dir, "run.log"),
|
|
45
62
|
stampFile: join(RUNTIME_DIR, ".installed"),
|
|
46
|
-
providersDir: join(
|
|
63
|
+
providersDir: join(spec.dir, "providers"),
|
|
47
64
|
};
|
|
48
65
|
}
|
|
49
66
|
|
|
@@ -62,9 +79,11 @@ const UI_MANIFEST = { file: "metacubexd-dist.tgz", sha256: "93eb334af7e37d213317
|
|
|
62
79
|
/**
|
|
63
80
|
* 内核/面板下载源(顺序 failover):
|
|
64
81
|
* 1. ~/.tt-help.json → clash.vendorBase 或环境变量 TT_VENDOR_BASE(覆盖)
|
|
65
|
-
* 2.
|
|
66
|
-
*
|
|
67
|
-
*
|
|
82
|
+
* 2. 配置的服务端(~/.tt-help.json → server)→ {server}/scripts/vendor
|
|
83
|
+
* (内网 watch 优先:213 等内网机器走内网下载,不绕公网)
|
|
84
|
+
* 3. 自建服务器公网(201 watch 静态路由 /scripts/vendor,免鉴权)
|
|
85
|
+
* 4. 自建服务器内网(农场机器直连)
|
|
86
|
+
* 5. GitHub Releases(仅海外/兜底;农场不可达属预期)
|
|
68
87
|
*/
|
|
69
88
|
export function vendorBases() {
|
|
70
89
|
const urls = [];
|
|
@@ -76,6 +95,11 @@ export function vendorBases() {
|
|
|
76
95
|
if (existsSync(cfgPath)) {
|
|
77
96
|
const cfg = JSON.parse(readFileSync(cfgPath, "utf-8"));
|
|
78
97
|
push(cfg?.clash?.vendorBase);
|
|
98
|
+
const server = String(cfg?.server || "").replace(/\/+$/, "");
|
|
99
|
+
// 显式配置的服务端地址 → 推导 vendor 下载源(未配置/非 http 则跳过,避免污染 127.0.0.1 默认值)
|
|
100
|
+
if (/^https?:\/\//.test(server) && !/^https?:\/\/127\.0\.0\.1/.test(server)) {
|
|
101
|
+
push(`${server}/scripts/vendor`);
|
|
102
|
+
}
|
|
79
103
|
}
|
|
80
104
|
} catch {}
|
|
81
105
|
push(process.env.TT_VENDOR_BASE);
|
|
@@ -309,6 +333,34 @@ function installUIFromArt(artPath, { log = () => {} } = {}) {
|
|
|
309
333
|
}
|
|
310
334
|
}
|
|
311
335
|
|
|
336
|
+
/**
|
|
337
|
+
* 实例目录内就位 ui 入口(子目录实例专用):
|
|
338
|
+
* mihomo 限制 external-ui 必须在 -d 目录子路径内,共享面板在根目录,
|
|
339
|
+
* 子目录实例建符号链接 ui → ../ui(Windows 用 junction,免管理员权限)
|
|
340
|
+
* @returns {boolean} 面板入口是否可用
|
|
341
|
+
*/
|
|
342
|
+
export function ensureInstanceUI(index = 1) {
|
|
343
|
+
if (index <= 1) return true; // 实例1 直用根目录 ui/
|
|
344
|
+
const { dir } = runtimePaths(index);
|
|
345
|
+
const link = join(dir, "ui");
|
|
346
|
+
const target = join(RUNTIME_DIR, "ui");
|
|
347
|
+
if (!existsSync(target)) return false; // 共享面板未就位,热加载路径不强求面板
|
|
348
|
+
try {
|
|
349
|
+
const st = lstatSync(link);
|
|
350
|
+
if (st.isSymbolicLink()) return true;
|
|
351
|
+
return true; // 实体目录(老版本残留)也可用
|
|
352
|
+
} catch {
|
|
353
|
+
// 不存在 → 建链接
|
|
354
|
+
}
|
|
355
|
+
try {
|
|
356
|
+
mkdirSync(dir, { recursive: true });
|
|
357
|
+
symlinkSync(target, link, IS_WIN ? "junction" : "dir");
|
|
358
|
+
return true;
|
|
359
|
+
} catch {
|
|
360
|
+
return false;
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
312
364
|
function findFileRecursive(dir, regex) {
|
|
313
365
|
try {
|
|
314
366
|
for (const f of readdirSync(dir, { withFileTypes: true })) {
|
|
@@ -375,6 +427,8 @@ export function storeSubscriptions(list) {
|
|
|
375
427
|
* external-ui 挂面板(仅启动时生效,所以必须生成时就写入)
|
|
376
428
|
*/
|
|
377
429
|
export function buildConfig({ subscriptions, apiPort, secret, mixedPort }) {
|
|
430
|
+
// external-ui 用相对路径(相对 -d 运行目录):实例1 直连共享 ui/,
|
|
431
|
+
// 子目录实例由 ensureInstanceUI 建 ui 符号链接指向共享面板(内核限制: 路径必须在 -d 内)
|
|
378
432
|
const lines = [
|
|
379
433
|
"# 由 tt-help runtime 自动生成 — 手动修改会被覆盖",
|
|
380
434
|
`mixed-port: ${mixedPort}`,
|
|
@@ -417,11 +471,11 @@ export function buildConfig({ subscriptions, apiPort, secret, mixedPort }) {
|
|
|
417
471
|
* 写 config.yaml(内容不变则跳过,返回是否变化)
|
|
418
472
|
* @returns {{ok:boolean, changed:boolean, error?:string}}
|
|
419
473
|
*/
|
|
420
|
-
export function writeConfig({ subscriptions, apiPort, secret, mixedPort }) {
|
|
421
|
-
const { configFile } = runtimePaths();
|
|
474
|
+
export function writeConfig({ subscriptions, apiPort, secret, mixedPort, index = 1 }) {
|
|
475
|
+
const { configFile, providersDir } = runtimePaths(index);
|
|
422
476
|
try {
|
|
423
|
-
mkdirSync(
|
|
424
|
-
mkdirSync(
|
|
477
|
+
mkdirSync(runtimePaths(index).dir, { recursive: true });
|
|
478
|
+
mkdirSync(providersDir, { recursive: true });
|
|
425
479
|
const content = buildConfig({ subscriptions, apiPort, secret, mixedPort });
|
|
426
480
|
const prev = existsSync(configFile) ? readFileSync(configFile, "utf-8") : "";
|
|
427
481
|
if (prev === content) return { ok: true, changed: false };
|
|
@@ -563,18 +617,24 @@ export async function runtimeControllerIsOurs(apiPort = DISCOVER_DEFAULTS.port)
|
|
|
563
617
|
return false;
|
|
564
618
|
}
|
|
565
619
|
|
|
566
|
-
/**
|
|
567
|
-
export async function stopRuntimeMihomo({
|
|
568
|
-
apiPort = DISCOVER_DEFAULTS.port,
|
|
569
|
-
mixedPort = DISCOVER_DEFAULTS.proxyPort,
|
|
570
|
-
log = () => {},
|
|
571
|
-
} = {}) {
|
|
620
|
+
/** 回收我们拉起的全部内置 mihomo 实例(清空订阅时调用;外来内核不动) */
|
|
621
|
+
export async function stopRuntimeMihomo({ log = () => {} } = {}) {
|
|
572
622
|
let killed = 0;
|
|
573
|
-
for (
|
|
623
|
+
for (let i = 1; i <= RUNTIME_MAX_INSTANCES; i++) {
|
|
624
|
+
killed += await stopRuntimeInstance(i, { log });
|
|
625
|
+
}
|
|
626
|
+
return killed;
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
/** 回收指定实例(按端口找命令行匹配内置目录的进程;外来内核不动) */
|
|
630
|
+
export async function stopRuntimeInstance(index = 1, { log = () => {} } = {}) {
|
|
631
|
+
const spec = instanceSpec(index);
|
|
632
|
+
let killed = 0;
|
|
633
|
+
for (const port of [spec.apiPort, spec.mixedPort]) {
|
|
574
634
|
for (const pid of getListeningPids(port)) {
|
|
575
635
|
const cmd = getProcessCmd(pid);
|
|
576
636
|
if (!cmd || !RUNTIME_DIR_RE.test(cmd)) continue;
|
|
577
|
-
log(`🧹 回收内置 mihomo (pid ${pid})`);
|
|
637
|
+
log(`🧹 回收内置 mihomo 实例${index} (pid ${pid})`);
|
|
578
638
|
try {
|
|
579
639
|
if (IS_WIN) {
|
|
580
640
|
execSync(`taskkill /PID ${pid} /T /F`, { stdio: "ignore", timeout: 15000, windowsHide: true });
|
|
@@ -588,45 +648,75 @@ export async function stopRuntimeMihomo({
|
|
|
588
648
|
return killed;
|
|
589
649
|
}
|
|
590
650
|
|
|
651
|
+
/**
|
|
652
|
+
* 回收多余实例(订阅减少后调用):保留前 keepCount 个,其余内置实例逐个回收
|
|
653
|
+
* @returns {Promise<number>} 回收的实例数
|
|
654
|
+
*/
|
|
655
|
+
export async function pruneRuntimeInstances(keepCount, { log = () => {} } = {}) {
|
|
656
|
+
let pruned = 0;
|
|
657
|
+
for (let i = Math.max(1, keepCount) + 1; i <= RUNTIME_MAX_INSTANCES; i++) {
|
|
658
|
+
const spec = instanceSpec(i);
|
|
659
|
+
if (!(await controllerAlive(spec.apiPort, spec.secret))) continue;
|
|
660
|
+
const killed = await stopRuntimeInstance(i, { log });
|
|
661
|
+
if (killed) {
|
|
662
|
+
pruned++;
|
|
663
|
+
log(`🧹 实例${i} (${spec.apiPort}/${spec.mixedPort}) 已回收(订阅已减少)`);
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
return pruned;
|
|
667
|
+
}
|
|
668
|
+
|
|
591
669
|
// ── 进程托管 ──
|
|
592
670
|
|
|
593
|
-
|
|
671
|
+
/** 拉起冷却(per-instance,防崩溃循环;多实例并行拉起互不阻塞) */
|
|
672
|
+
const _lastSpawnAt = new Map();
|
|
594
673
|
const SPAWN_COOLDOWN_MS = 60_000;
|
|
595
674
|
|
|
596
675
|
const delay = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
597
676
|
|
|
598
677
|
/**
|
|
599
|
-
* 确保 mihomo
|
|
678
|
+
* 确保 mihomo 实例运行(订阅 = 接管授权):
|
|
600
679
|
* - 未配置订阅 → no-subscription,不拉起不占位不碰任何现有代理(用户可自用 Verge)
|
|
601
680
|
* - 控制端已可用(Verge / 上次拉起的 mihomo)→ adopted,不动(force 模式跳过)
|
|
602
681
|
* - 端口被 clash 内核签名进程占用 → 清理后拉起(接管);非内核程序 → port-busy 不抢
|
|
603
682
|
* - 拉起冷却期内 → cooldown(force 绕过)
|
|
604
683
|
* - 解压 + 生成配置 + detached 拉起 + 等 API → started / failed
|
|
605
684
|
*
|
|
685
|
+
* 多实例(一条订阅 = 一个实例):
|
|
686
|
+
* index=1 → 9097/7897 + 根目录(向后兼容);index=n → 9097+(n-1)/7897+(n-1) + i{n} 子目录
|
|
687
|
+
* 显式传入 apiPort/mixedPort 时优先(CLI 兼容),否则由 index 推导
|
|
688
|
+
*
|
|
606
689
|
* @returns {Promise<{status:'no-subscription'|'adopted'|'started'|'port-busy'|'cooldown'|'no-vendor'|'failed', error?:string, bin?:string}>}
|
|
607
690
|
*/
|
|
608
691
|
export async function ensureMihomoRunning({
|
|
609
692
|
subscriptions,
|
|
610
693
|
force = false,
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
694
|
+
index = 1,
|
|
695
|
+
apiPort,
|
|
696
|
+
secret,
|
|
697
|
+
mixedPort,
|
|
614
698
|
log = () => {},
|
|
615
699
|
} = {}) {
|
|
700
|
+
const spec = instanceSpec(index);
|
|
701
|
+
apiPort = apiPort || spec.apiPort;
|
|
702
|
+
secret = secret || spec.secret;
|
|
703
|
+
mixedPort = mixedPort || spec.mixedPort;
|
|
704
|
+
const paths = runtimePaths(spec.index);
|
|
705
|
+
|
|
616
706
|
// 1) 订阅 = 接管授权: 无订阅不拉起、不占位、不碰任何现有代理(用户可自用 Verge)
|
|
617
707
|
const subs = normalizeSubscriptions(subscriptions || readStoredSubscriptions());
|
|
618
708
|
if (!subs.length) {
|
|
619
|
-
log(
|
|
709
|
+
log(`⏭ 实例${spec.index} 未配置订阅,不拉起`);
|
|
620
710
|
return { status: "no-subscription" };
|
|
621
711
|
}
|
|
622
712
|
|
|
623
713
|
// 2) 控制端已可用 → 认领(force=订阅接管模式跳过,用于换掉外来内核)
|
|
624
714
|
if (!force && (await controllerAlive(apiPort, secret))) return { status: "adopted" };
|
|
625
715
|
|
|
626
|
-
// 3) 冷却期(force=用户主动订阅动作,绕过)
|
|
627
|
-
const gap = Date.now() - _lastSpawnAt;
|
|
716
|
+
// 3) 冷却期(per-instance,force=用户主动订阅动作,绕过)
|
|
717
|
+
const gap = Date.now() - (_lastSpawnAt.get(spec.index) || 0);
|
|
628
718
|
if (!force && gap < SPAWN_COOLDOWN_MS) {
|
|
629
|
-
return { status: "cooldown", error:
|
|
719
|
+
return { status: "cooldown", error: `实例${spec.index} 拉起冷却中 ${Math.ceil((SPAWN_COOLDOWN_MS - gap) / 1000)}s` };
|
|
630
720
|
}
|
|
631
721
|
|
|
632
722
|
// 4) 端口占用 → 仅清理 clash 内核签名占用者(Verge 内核等),其余不抢
|
|
@@ -642,27 +732,30 @@ export async function ensureMihomoRunning({
|
|
|
642
732
|
}
|
|
643
733
|
}
|
|
644
734
|
|
|
645
|
-
// 5) 落位 +
|
|
646
|
-
_lastSpawnAt
|
|
735
|
+
// 5) 落位 + 生成配置(bin/ui 共享,config/log/providers 按实例目录隔离)
|
|
736
|
+
_lastSpawnAt.set(spec.index, Date.now());
|
|
647
737
|
const coreR = await ensureBinary({ log });
|
|
648
738
|
if (!coreR.ok) return { status: "no-vendor", error: coreR.error };
|
|
649
739
|
const uiR = await ensureUI({ log });
|
|
650
740
|
if (!uiR.ok) log(`⚠ 面板就位失败(不影响代理): ${uiR.error}`);
|
|
651
|
-
|
|
741
|
+
ensureInstanceUI(spec.index); // 子目录实例建 ui 符号链接(内核限制: external-ui 须在 -d 内)
|
|
742
|
+
const cfgR = writeConfig({ subscriptions: subs, apiPort, secret, mixedPort, index: spec.index });
|
|
652
743
|
if (!cfgR.ok) return { status: "failed", error: `config.yaml 写入失败: ${cfgR.error}` };
|
|
653
744
|
|
|
654
|
-
// 6) detached 拉起(daemon
|
|
655
|
-
const paths = runtimePaths();
|
|
745
|
+
// 6) detached 拉起(daemon 退出不影响代理),日志追加到实例 run.log
|
|
656
746
|
let logFd;
|
|
657
747
|
try {
|
|
658
748
|
logFd = openSync(paths.logFile, "a");
|
|
659
749
|
} catch {
|
|
660
750
|
logFd = "ignore";
|
|
661
751
|
}
|
|
662
|
-
const child = spawn(coreR.bin, ["-d",
|
|
752
|
+
const child = spawn(coreR.bin, ["-d", paths.dir, "-f", paths.configFile], {
|
|
663
753
|
detached: true,
|
|
664
754
|
stdio: ["ignore", logFd, logFd],
|
|
665
755
|
windowsHide: true,
|
|
756
|
+
// 显式指定 cwd:不传会继承父进程工作目录,若在 npm 包目录内启动 tt-help,
|
|
757
|
+
// detached 常驻的 mihomo 会锁死包目录,导致 npm install -g 重命名时 EBUSY
|
|
758
|
+
cwd: paths.dir,
|
|
666
759
|
});
|
|
667
760
|
child.unref();
|
|
668
761
|
|
|
@@ -675,13 +768,13 @@ export async function ensureMihomoRunning({
|
|
|
675
768
|
return { status: "failed", error: "API 已就绪但监听进程非本次拉起的内置内核(端口被旧内核抢占)" };
|
|
676
769
|
}
|
|
677
770
|
log(`✓ 内置 mihomo 已拉起 (pid ${child.pid}),面板: http://127.0.0.1:${apiPort}/ui/`);
|
|
678
|
-
return { status: "started", pid: child.pid, bin: coreR.bin };
|
|
771
|
+
return { status: "started", pid: child.pid, bin: coreR.bin, index: spec.index, apiPort, mixedPort };
|
|
679
772
|
}
|
|
680
773
|
if (child.exitCode !== null) {
|
|
681
774
|
return { status: "failed", error: `mihomo 进程退出 (code ${child.exitCode}),见 ${paths.logFile}` };
|
|
682
775
|
}
|
|
683
776
|
}
|
|
684
|
-
return { status: "failed", error:
|
|
777
|
+
return { status: "failed", error: `实例${spec.index} 启动后 API 20s 未就绪` };
|
|
685
778
|
}
|
|
686
779
|
|
|
687
780
|
/**
|
|
@@ -691,16 +784,20 @@ export async function ensureMihomoRunning({
|
|
|
691
784
|
*/
|
|
692
785
|
export async function applySubscriptions(
|
|
693
786
|
subscriptions,
|
|
694
|
-
{
|
|
787
|
+
{ index = 1, apiPort, secret, mixedPort, log = () => {} } = {},
|
|
695
788
|
) {
|
|
789
|
+
const spec = instanceSpec(index);
|
|
790
|
+
apiPort = apiPort || spec.apiPort;
|
|
791
|
+
secret = secret || spec.secret;
|
|
792
|
+
mixedPort = mixedPort || spec.mixedPort;
|
|
696
793
|
const subs = normalizeSubscriptions(subscriptions);
|
|
697
794
|
if (!subs.length) return { ok: false, error: "订阅列表为空" };
|
|
698
795
|
|
|
699
|
-
const cfgR = writeConfig({ subscriptions: subs, apiPort, secret, mixedPort });
|
|
796
|
+
const cfgR = writeConfig({ subscriptions: subs, apiPort, secret, mixedPort, index: spec.index });
|
|
700
797
|
if (!cfgR.ok) return { ok: false, error: cfgR.error };
|
|
701
798
|
|
|
702
799
|
if (!cfgR.changed) {
|
|
703
|
-
log(
|
|
800
|
+
log(`实例${spec.index} 订阅配置无变化,跳过热加载`);
|
|
704
801
|
return { ok: true, changed: false };
|
|
705
802
|
}
|
|
706
803
|
|
|
@@ -711,40 +808,43 @@ export async function applySubscriptions(
|
|
|
711
808
|
const res = await fetch(`http://127.0.0.1:${apiPort}/configs?force=true`, {
|
|
712
809
|
method: "PUT",
|
|
713
810
|
headers,
|
|
714
|
-
body: JSON.stringify({ path: runtimePaths().configFile }),
|
|
811
|
+
body: JSON.stringify({ path: runtimePaths(spec.index).configFile }),
|
|
715
812
|
signal: AbortSignal.timeout(15_000),
|
|
716
813
|
});
|
|
717
814
|
if (res.status !== 204) {
|
|
718
815
|
const t = await res.text().catch(() => "");
|
|
719
|
-
await rollbackConfig(cfgR.prevContent, { apiPort, secret });
|
|
816
|
+
await rollbackConfig(cfgR.prevContent, { index: spec.index, apiPort, secret });
|
|
720
817
|
return { ok: false, error: `热加载 HTTP ${res.status}: ${t.slice(0, 120)}(已回滚原配置)` };
|
|
721
818
|
}
|
|
722
819
|
} catch (e) {
|
|
723
|
-
await rollbackConfig(cfgR.prevContent, { apiPort, secret });
|
|
820
|
+
await rollbackConfig(cfgR.prevContent, { index: spec.index, apiPort, secret });
|
|
724
821
|
return { ok: false, error: `热加载失败: ${e.message}(已回滚盘上配置)` };
|
|
725
822
|
}
|
|
726
823
|
|
|
727
824
|
const v = await verifyProviders(subs, { apiPort, secret, log });
|
|
728
825
|
if (!v.ok && cfgR.prevContent) {
|
|
729
826
|
// 新订阅不可用(URL 失效/机场挂了)→ 回滚到上一个可用配置,保持代理不断
|
|
730
|
-
await rollbackConfig(cfgR.prevContent, { apiPort, secret });
|
|
731
|
-
log(
|
|
827
|
+
await rollbackConfig(cfgR.prevContent, { index: spec.index, apiPort, secret });
|
|
828
|
+
log(`⚠ 实例${spec.index} 新订阅不可用,已回滚到原配置(持久化未变更,代理保持可用)`);
|
|
732
829
|
return { ...v, rolledBack: true };
|
|
733
830
|
}
|
|
734
831
|
return v;
|
|
735
832
|
}
|
|
736
833
|
|
|
737
834
|
/** 回滚 config.yaml 并热加载(保持盘上配置与运行内核一致) */
|
|
738
|
-
async function rollbackConfig(content, {
|
|
835
|
+
async function rollbackConfig(content, { index = 1, apiPort, secret } = {}) {
|
|
739
836
|
try {
|
|
740
837
|
if (!content) return;
|
|
741
|
-
|
|
838
|
+
const spec = instanceSpec(index);
|
|
839
|
+
apiPort = apiPort || spec.apiPort;
|
|
840
|
+
secret = secret || spec.secret;
|
|
841
|
+
writeFileSync(runtimePaths(spec.index).configFile, content);
|
|
742
842
|
const headers = { "Content-Type": "application/json" };
|
|
743
843
|
if (secret) headers.Authorization = `Bearer ${secret}`;
|
|
744
844
|
await fetch(`http://127.0.0.1:${apiPort}/configs?force=true`, {
|
|
745
845
|
method: "PUT",
|
|
746
846
|
headers,
|
|
747
|
-
body: JSON.stringify({ path: runtimePaths().configFile }),
|
|
847
|
+
body: JSON.stringify({ path: runtimePaths(spec.index).configFile }),
|
|
748
848
|
signal: AbortSignal.timeout(15_000),
|
|
749
849
|
});
|
|
750
850
|
} catch {}
|
|
@@ -791,17 +891,23 @@ export async function verifyProviders(
|
|
|
791
891
|
}
|
|
792
892
|
|
|
793
893
|
/**
|
|
794
|
-
*
|
|
894
|
+
* 用户订阅应用入口(后台配置下发唯一路径,单实例粒度:一条订阅 = 一个实例)。
|
|
795
895
|
*
|
|
796
896
|
* 订阅 = 接管授权(2026-09-25 设计):
|
|
797
897
|
* - 内置内核在位 → 热应用(不中断代理;盘上配置一致则短路,顺便自愈崩溃残留)
|
|
798
898
|
* - 外来内核(Verge 等)占位或无控制端 → 清理端口内核 + 带订阅拉起内置 mihomo
|
|
799
899
|
* - 新订阅不可用(0 节点/热加载失败)→ 自动回滚上一个可用配置,代理不断
|
|
900
|
+
*
|
|
901
|
+
* @param {Array} subs 单条订阅(数组长度 1;多实例循环由 daemon 调度)
|
|
902
|
+
* @param {number} [opts.index] 实例序号(1-based),决定端口与运行目录
|
|
800
903
|
*/
|
|
801
904
|
export async function applyUserSubscriptions(
|
|
802
905
|
subs,
|
|
803
|
-
{
|
|
906
|
+
{ index = 1, apiPort, secret, log = () => {} } = {},
|
|
804
907
|
) {
|
|
908
|
+
const spec = instanceSpec(index);
|
|
909
|
+
apiPort = apiPort || spec.apiPort;
|
|
910
|
+
secret = secret || spec.secret;
|
|
805
911
|
subs = normalizeSubscriptions(subs);
|
|
806
912
|
if (!subs.length) return { ok: false, error: "订阅列表为空" };
|
|
807
913
|
|
|
@@ -810,11 +916,11 @@ export async function applyUserSubscriptions(
|
|
|
810
916
|
|
|
811
917
|
if (ours) {
|
|
812
918
|
// 热应用;盘上配置已一致时由 applySubscriptions 内部短路(顺便自愈崩溃残留)
|
|
813
|
-
return applySubscriptions(subs, { apiPort, secret, log });
|
|
919
|
+
return applySubscriptions(subs, { index: spec.index, apiPort, secret, log });
|
|
814
920
|
}
|
|
815
921
|
|
|
816
922
|
// 外来内核占位或无控制端 → 接管
|
|
817
|
-
const r = await ensureMihomoRunning({ subscriptions: subs, force: true, apiPort, secret, log });
|
|
923
|
+
const r = await ensureMihomoRunning({ subscriptions: subs, force: true, index: spec.index, apiPort, secret, log });
|
|
818
924
|
if (r.status !== "started") {
|
|
819
925
|
return { ok: false, error: `接管失败: ${r.status}${r.error ? ` ${r.error}` : ""}` };
|
|
820
926
|
}
|
package/src/cli/refresh-mini.js
CHANGED
|
@@ -102,7 +102,8 @@ function spawnClashDaemon() {
|
|
|
102
102
|
const child = spawn(process.execPath, [mainJs, "clash-daemon"], {
|
|
103
103
|
detached: true,
|
|
104
104
|
stdio: "ignore",
|
|
105
|
-
cwd
|
|
105
|
+
// cwd 不能指向包内目录:detached 常驻进程会锁死包目录,导致 npm install -g 重命名时 EBUSY
|
|
106
|
+
cwd: os.homedir(),
|
|
106
107
|
});
|
|
107
108
|
child.unref();
|
|
108
109
|
spawnedDaemonPid = child.pid;
|
package/src/watch/public/app.js
CHANGED
|
@@ -4795,7 +4795,7 @@ async function wdSaveProxySettings() {
|
|
|
4795
4795
|
alert(
|
|
4796
4796
|
"已保存 " +
|
|
4797
4797
|
(d.subscriptions ? d.subscriptions.length : lines.length) +
|
|
4798
|
-
" 条订阅\n\n客户端将在下次心跳时热应用(无需重启,约 15~30 秒后刷新页面可见订阅用量)。",
|
|
4798
|
+
" 条订阅\n\n每条订阅将启动独立 mihomo 实例(代理端口依次 7897、7898、7899…)。\n客户端将在下次心跳时热应用(无需重启,约 15~30 秒后刷新页面可见订阅用量)。",
|
|
4799
4799
|
);
|
|
4800
4800
|
} catch (e) {
|
|
4801
4801
|
alert("保存失败: " + e.message);
|
|
@@ -786,7 +786,7 @@
|
|
|
786
786
|
style="display:none;position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.6);z-index:9999;align-items:center;justify-content:center">
|
|
787
787
|
<div style="background:#1e1e2e;border:1px solid #333;border-radius:12px;padding:24px;width:90%;max-width:560px">
|
|
788
788
|
<div style="font-size:16px;color:#fbbf24;margin-bottom:4px">🛜 代理订阅配置 — <span id="wdProxyUserId"></span></div>
|
|
789
|
-
<div style="color:#888;font-size:12px;margin-bottom:12px">每行一个订阅地址(http/https
|
|
789
|
+
<div style="color:#888;font-size:12px;margin-bottom:12px">每行一个订阅地址(http/https),<b style="color:#fbbf24">一条订阅 = 一个独立 mihomo 实例</b>:代理端口依次 127.0.0.1:7897、7898、7899…(API 端口 9097 起,同步 +1),各命令用 --proxy 参数指定使用哪个。<br>保存后客户端下次心跳时自动热应用(无需重启),应用成功后此处可看到剩余流量与到期时间;临期(<7天)或超量(剩余<10%)会标红。</div>
|
|
790
790
|
<textarea id="wdProxySubsText" rows="6" placeholder="https://example.com/api/v1/client/subscribe?token=xxxx\nhttps://another-airport.com/sub?token=yyyy"
|
|
791
791
|
style="width:100%;box-sizing:border-box;padding:10px;background:#16161e;border:1px solid #333;border-radius:8px;color:#e0e0e0;font-size:12px;font-family:monospace;resize:vertical"></textarea>
|
|
792
792
|
<div style="display:flex;justify-content:flex-end;gap:8px;margin-top:16px">
|
package/src/watchdog/upgrader.js
CHANGED
|
@@ -225,6 +225,8 @@ function spawnChild(args, { logFd, hide = false } = {}) {
|
|
|
225
225
|
detached: true,
|
|
226
226
|
stdio: ["ignore", logFd || "ignore", logFd || "ignore"],
|
|
227
227
|
windowsHide: hide,
|
|
228
|
+
// cwd 固定到用户主目录:不传会继承父进程 CWD,若从包目录内启动会把包目录锁死(npm install EBUSY)
|
|
229
|
+
cwd: homeDir,
|
|
228
230
|
});
|
|
229
231
|
child.unref();
|
|
230
232
|
// 防 spawn 失败(如目标脚本缺失)触发未监听的 'error' 事件静默崩溃;日志落盘可查
|
|
@@ -559,7 +561,8 @@ function spawnNewWatchdog(cliPath, args, logFd) {
|
|
|
559
561
|
const child = spawn(
|
|
560
562
|
"cmd.exe",
|
|
561
563
|
["/c", "start", "cmd", "/k", process.execPath, cliPath, ...args],
|
|
562
|
-
|
|
564
|
+
// cwd 固定到用户主目录:防止新版 watchdog 继承包目录 CWD 锁死包目录(npm install EBUSY)
|
|
565
|
+
{ detached: true, stdio: "ignore", windowsHide: true, cwd: homeDir },
|
|
563
566
|
);
|
|
564
567
|
child.unref();
|
|
565
568
|
child.on("error", (err) => {
|