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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tt-help-cli-ycl",
3
- "version": "1.4.157",
3
+ "version": "1.4.158",
4
4
  "description": "TikTok user & video data scraper - extract ttSeller, verified, locationCreated from HTML source",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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 r = await runtime.ensureMihomoRunning({
303
- subscriptions: runtime.readStoredSubscriptions(),
304
- log: (m) => this._log(m),
305
- });
306
- this._runtimeStatus = { ...r, at: Date.now() };
307
- if (r.status === "started") {
308
- this._log(`✓ 内置 mihomo 已就绪,下轮发现将接管`);
309
- } else if (r.status === "no-subscription") {
310
- // 无订阅不接管(不拉起不占位),防刷屏降频打日志
311
- if (Date.now() - (this._lastNoSubLogAt || 0) > 300000) {
312
- this._log(`⏭ 未配置订阅,内置 mihomo 不参与(后台配置订阅后自动接管)`);
313
- this._lastNoSubLogAt = Date.now();
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 r = await runtime
410
- .ensureMihomoRunning({
411
- subscriptions: runtime.readStoredSubscriptions(),
412
- log: (m) => this._log(m),
413
- })
414
- .catch((e) => ({ status: "failed", error: e.message }));
415
- this._runtimeStatus = { ...r, at: Date.now() };
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} 未运行${r.status === "started" ? "(内置内核已自动拉起)" : ""}`,
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
- * 幂等: 与运行时配置比对,无变化不动作;成功后持久化到 ~/.tt-help.json
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
- // 清空订阅: 回收内置 mihomo + 清持久化 → 回到"无订阅不接管"态(用户可回退到 Verge)
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
- const r = await runtime.applyUserSubscriptions(subs, { log: (m) => this._log(m) });
974
- if (r.ok) {
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
- if (r.providers) c._subsSnapshot = r.providers;
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
- c._applyResult = { ok: false, error: r.error, at: Date.now() };
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 });
@@ -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);
@@ -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
- export function runtimePaths() {
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
- dir: RUNTIME_DIR,
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(RUNTIME_DIR, "config.yaml"),
44
- logFile: join(RUNTIME_DIR, "run.log"),
60
+ configFile: join(spec.dir, "config.yaml"),
61
+ logFile: join(spec.dir, "run.log"),
45
62
  stampFile: join(RUNTIME_DIR, ".installed"),
46
- providersDir: join(RUNTIME_DIR, "providers"),
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. 自建服务器公网(201 watch 静态路由 /scripts/vendor,免鉴权)
66
- * 3. 自建服务器内网(农场机器直连)
67
- * 4. GitHub Releases(仅海外/兜底;农场不可达属预期)
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(RUNTIME_DIR, { recursive: true });
424
- mkdirSync(join(RUNTIME_DIR, "providers"), { recursive: true });
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
- /** 回收我们拉起的内置 mihomo(清空订阅时调用;外来内核不动) */
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 (const port of [apiPort, mixedPort]) {
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
- let _lastSpawnAt = 0;
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
- apiPort = DISCOVER_DEFAULTS.port,
612
- secret = DISCOVER_DEFAULTS.secret,
613
- mixedPort = DISCOVER_DEFAULTS.proxyPort,
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("⏭ 未配置订阅,内置 mihomo 不参与(后台配置订阅后自动接管)");
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: `拉起冷却中 ${Math.ceil((SPAWN_COOLDOWN_MS - gap) / 1000)}s` };
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 = Date.now();
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
- const cfgR = writeConfig({ subscriptions: subs, apiPort, secret, mixedPort });
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 退出不影响代理),日志追加到 run.log
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", RUNTIME_DIR, "-f", paths.configFile], {
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: "mihomo 启动后 API 20s 未就绪" };
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
- { apiPort = DISCOVER_DEFAULTS.port, secret = DISCOVER_DEFAULTS.secret, mixedPort = DISCOVER_DEFAULTS.proxyPort, log = () => {} } = {},
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, { apiPort = DISCOVER_DEFAULTS.port, secret = DISCOVER_DEFAULTS.secret } = {}) {
835
+ async function rollbackConfig(content, { index = 1, apiPort, secret } = {}) {
739
836
  try {
740
837
  if (!content) return;
741
- writeFileSync(runtimePaths().configFile, content);
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
- { apiPort = DISCOVER_DEFAULTS.port, secret = DISCOVER_DEFAULTS.secret, log = () => {} } = {},
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
  }
@@ -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: path.dirname(mainJs),
105
+ // cwd 不能指向包内目录:detached 常驻进程会锁死包目录,导致 npm install -g 重命名时 EBUSY
106
+ cwd: os.homedir(),
106
107
  });
107
108
  child.unref();
108
109
  spawnedDaemonPid = child.pid;
@@ -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)。保存后客户端下次心跳时自动热应用(无需重启 mihomo),<br>应用成功后此处可看到剩余流量与到期时间;临期(&lt;7天)或超量(剩余&lt;10%)会标红。</div>
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>保存后客户端下次心跳时自动热应用(无需重启),应用成功后此处可看到剩余流量与到期时间;临期(&lt;7天)或超量(剩余&lt;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">
@@ -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
- { detached: true, stdio: "ignore", windowsHide: true },
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) => {