mihomo-cli 1.0.0-alpha.1 → 1.0.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/src/config.js CHANGED
@@ -1,6 +1,7 @@
1
1
  const path = require('path');
2
2
  const fs = require('fs');
3
3
  const os = require('os');
4
+ const yaml = require('js-yaml');
4
5
  const { execSync } = require('child_process');
5
6
 
6
7
  const IS_PKG = typeof process.pkg !== 'undefined';
@@ -36,6 +37,7 @@ const PATHS = {
36
37
  userDataDir: USER_DATA_DIR,
37
38
  mihomoBinary: path.join(DIRS.core, 'mihomo'),
38
39
  settingsFile: path.join(USER_DATA_DIR, 'settings.json'),
40
+ subsCacheFile: path.join(USER_DATA_DIR, 'subs-cache.json'),
39
41
  configFile: path.join(DIRS.runtime, 'config.yaml'),
40
42
  logFile: path.join(DIRS.logs, 'mihomo.log'),
41
43
  pidFile: path.join(DIRS.runtime, 'pid'),
@@ -95,11 +97,96 @@ function writeSettings(settings) {
95
97
  return merged;
96
98
  }
97
99
 
100
+ // GitHub 镜像配置
101
+ const DEFAULT_GITHUB_MIRROR = 'https://v6.gh-proxy.org/';
102
+ const AVAILABLE_MIRRORS = [
103
+ 'v6.gh-proxy.org',
104
+ 'gh-proxy.org',
105
+ 'hk.gh-proxy.org',
106
+ 'cdn.gh-proxy.org',
107
+ 'edgeone.gh-proxy.org',
108
+ ];
109
+
110
+ function getGitHubMirror() {
111
+ const settings = readSettings();
112
+ // 空字符串或 false 表示禁用镜像
113
+ if (settings.githubMirror === '' || settings.githubMirror === false) {
114
+ return null;
115
+ }
116
+ return settings.githubMirror || DEFAULT_GITHUB_MIRROR;
117
+ }
118
+
119
+ function setGitHubMirror(mirror) {
120
+ // mirror 取值:
121
+ // - 完整 URL: 'https://hk.gh-proxy.org/'
122
+ // - 短域名: 'hk.gh-proxy.org'
123
+ // - '' 或 false: 禁用镜像
124
+ // - null 或 undefined: 恢复默认
125
+
126
+ if (mirror === null || mirror === undefined) {
127
+ const settings = readSettings();
128
+ delete settings.githubMirror;
129
+ writeSettings(settings);
130
+ return DEFAULT_GITHUB_MIRROR;
131
+ }
132
+
133
+ if (mirror === '' || mirror === false) {
134
+ writeSettings({ githubMirror: '' });
135
+ return null;
136
+ }
137
+
138
+ let mirrorUrl = mirror;
139
+ if (!mirrorUrl.startsWith('http')) {
140
+ mirrorUrl = 'https://' + mirrorUrl;
141
+ }
142
+ if (!mirrorUrl.endsWith('/')) {
143
+ mirrorUrl += '/';
144
+ }
145
+
146
+ writeSettings({ githubMirror: mirrorUrl });
147
+ return mirrorUrl;
148
+ }
149
+
150
+ // 订阅缓存读写(动态数据:流量、用户名等)
151
+ function readSubsCache() {
152
+ ensureDirs();
153
+ if (fs.existsSync(PATHS.subsCacheFile)) {
154
+ try {
155
+ const content = fs.readFileSync(PATHS.subsCacheFile, 'utf8');
156
+ return JSON.parse(content);
157
+ } catch (e) {
158
+ return {};
159
+ }
160
+ }
161
+ return {};
162
+ }
163
+
164
+ function writeSubsCache(cache) {
165
+ ensureDirs();
166
+ fs.writeFileSync(PATHS.subsCacheFile, JSON.stringify(cache, null, 2), { mode: 0o600 });
167
+ }
168
+
169
+ function saveSubCache(subName, data) {
170
+ const cache = readSubsCache();
171
+ cache[subName] = { ...cache[subName], ...data };
172
+ writeSubsCache(cache);
173
+ }
174
+
98
175
  function getSubscriptions() {
99
176
  const settings = readSettings();
100
177
  return settings.subscriptions || [];
101
178
  }
102
179
 
180
+ // 获取合并了缓存数据的订阅列表
181
+ function getSubscriptionsWithCache() {
182
+ const subs = getSubscriptions();
183
+ const cache = readSubsCache();
184
+ return subs.map(s => ({
185
+ ...s,
186
+ ...(cache[s.name] || {}),
187
+ }));
188
+ }
189
+
103
190
  function addSubscription(url, name) {
104
191
  if (name === undefined) name = 'default';
105
192
  const settings = readSettings();
@@ -113,6 +200,22 @@ function addSubscription(url, name) {
113
200
  writeSettings({ subscriptions: subs });
114
201
  }
115
202
 
203
+ function setDefaultSubscription(name) {
204
+ const settings = readSettings();
205
+ const subs = settings.subscriptions || [];
206
+ const idx = subs.findIndex(s => s.name === name);
207
+ if (idx < 0) {
208
+ return false;
209
+ }
210
+ if (idx === 0) {
211
+ return true; // 已经是第一个
212
+ }
213
+ const [sub] = subs.splice(idx, 1);
214
+ subs.unshift(sub);
215
+ writeSettings({ subscriptions: subs });
216
+ return true;
217
+ }
218
+
116
219
  function getSubRawConfigPath(subName) {
117
220
  return path.join(DIRS.subs, subName + '.yaml');
118
221
  }
@@ -180,19 +283,23 @@ const BASE_CONFIG = {
180
283
  },
181
284
  };
182
285
 
183
- function buildConfig(subRawContent, mode) {
184
- const yaml = require('js-yaml');
185
-
186
- let baseConfig;
286
+ function parseYamlOrJson(content, errorMsg) {
287
+ if (!content || !content.trim()) {
288
+ throw new Error((errorMsg || '内容') + '为空');
289
+ }
187
290
  try {
188
- baseConfig = yaml.load(subRawContent);
189
- } catch (e) {
190
- try {
191
- baseConfig = JSON.parse(subRawContent);
192
- } catch (e2) {
193
- throw new Error('订阅内容格式错误,无法解析为 YAML 或 JSON');
194
- }
291
+ const result = yaml.load(content);
292
+ if (result !== undefined) return result;
293
+ } catch (e) {}
294
+ try {
295
+ return JSON.parse(content);
296
+ } catch (e2) {
297
+ throw new Error((errorMsg || '内容') + '格式错误,无法解析为 YAML 或 JSON');
195
298
  }
299
+ }
300
+
301
+ function buildConfig(subRawContent, mode) {
302
+ const baseConfig = parseYamlOrJson(subRawContent, '订阅内容');
196
303
 
197
304
  if (!baseConfig) {
198
305
  throw new Error('订阅内容为空');
@@ -216,7 +323,6 @@ function buildConfig(subRawContent, mode) {
216
323
  }
217
324
 
218
325
  function writeMihomoConfig(configObj) {
219
- const yaml = require('js-yaml');
220
326
  ensureDirs();
221
327
  const content = yaml.dump(configObj, {
222
328
  indent: 2,
@@ -236,7 +342,6 @@ function getConfigInfo() {
236
342
  }
237
343
 
238
344
  try {
239
- const yaml = require('js-yaml');
240
345
  const content = fs.readFileSync(PATHS.configFile, 'utf8');
241
346
  const cfg = yaml.load(content);
242
347
 
@@ -255,17 +360,7 @@ function getConfigInfo() {
255
360
  }
256
361
 
257
362
  function rmrf(dir) {
258
- if (!fs.existsSync(dir)) return;
259
- const stat = fs.statSync(dir);
260
- if (stat.isDirectory()) {
261
- const files = fs.readdirSync(dir);
262
- for (const f of files) {
263
- rmrf(path.join(dir, f));
264
- }
265
- fs.rmdirSync(dir);
266
- } else {
267
- fs.unlinkSync(dir);
268
- }
363
+ fs.rmSync(dir, { recursive: true, force: true });
269
364
  }
270
365
 
271
366
  function resetUserData(options) {
@@ -309,16 +404,26 @@ module.exports = {
309
404
  ensureDirs,
310
405
  readSettings,
311
406
  writeSettings,
407
+ readSubsCache,
408
+ writeSubsCache,
409
+ saveSubCache,
312
410
  maskUrl,
313
411
  getSubscriptions,
412
+ getSubscriptionsWithCache,
314
413
  addSubscription,
414
+ setDefaultSubscription,
315
415
  getSubRawConfigPath,
316
416
  saveSubRawConfig,
317
417
  readSubRawConfig,
318
418
  hasKernel,
319
419
  getKernelVersion,
420
+ getGitHubMirror,
421
+ setGitHubMirror,
422
+ DEFAULT_GITHUB_MIRROR,
423
+ AVAILABLE_MIRRORS,
320
424
  TUN_CONFIG,
321
425
  BASE_CONFIG,
426
+ parseYamlOrJson,
322
427
  buildConfig,
323
428
  writeMihomoConfig,
324
429
  hasConfig,
package/src/kernel.js CHANGED
@@ -6,13 +6,11 @@ const { compareVersions } = require('compare-versions');
6
6
  const config = require('./config');
7
7
 
8
8
  const GITHUB_REPO = 'MetaCubeX/mihomo';
9
- const GEODATA_REPO = 'MetaCubeX/meta-rules-dat';
10
9
 
11
- const GITHUB_DOWNLOAD_MIRROR = 'https://gh-proxy.org/';
12
-
13
- function withMirror(url) {
14
- if (GITHUB_DOWNLOAD_MIRROR && url.startsWith('https://github.com/')) {
15
- return GITHUB_DOWNLOAD_MIRROR + url;
10
+ function withMirror(url, overrideMirror) {
11
+ const mirror = overrideMirror !== undefined ? overrideMirror : config.getGitHubMirror();
12
+ if (mirror && url.startsWith('https://github.com/')) {
13
+ return mirror + url;
16
14
  }
17
15
  return url;
18
16
  }
@@ -23,11 +21,6 @@ const HTTP_CLIENT = axios.create({
23
21
  maxContentLength: 200 * 1024 * 1024,
24
22
  });
25
23
 
26
- const GEODATA_FILES = [
27
- { name: 'geosite-lite.dat', targetName: 'geosite.dat', url: 'https://cdn.jsdelivr.net/gh/MetaCubeX/meta-rules-dat@release/geosite-lite.dat' },
28
- { name: 'country-lite.mmdb', targetName: 'Country.mmdb', url: 'https://cdn.jsdelivr.net/gh/MetaCubeX/meta-rules-dat@release/country-lite.mmdb' },
29
- ];
30
-
31
24
  function getArch() {
32
25
  const arch = process.arch;
33
26
  if (arch === 'arm64') return 'arm64';
@@ -88,57 +81,6 @@ async function getLatestRelease(repo) {
88
81
  return releases[0];
89
82
  }
90
83
 
91
- async function downloadFile(url, destPath, progressCallback) {
92
- if (progressCallback) {
93
- progressCallback('下载 ' + path.basename(destPath));
94
- }
95
-
96
- const response = await HTTP_CLIENT({
97
- method: 'get',
98
- url: url,
99
- responseType: 'stream',
100
- timeout: 180000,
101
- });
102
-
103
- const writer = fs.createWriteStream(destPath);
104
- response.data.pipe(writer);
105
-
106
- await new Promise((resolve, reject) => {
107
- writer.on('finish', resolve);
108
- writer.on('error', reject);
109
- });
110
-
111
- return destPath;
112
- }
113
-
114
- async function downloadGeodata(progressCallback) {
115
- config.ensureDirs();
116
- const dataDir = config.DIRS.data;
117
-
118
- const results = [];
119
- for (const file of GEODATA_FILES) {
120
- const destPath = path.join(dataDir, file.name);
121
- const targetPath = file.targetName ? path.join(dataDir, file.targetName) : destPath;
122
- try {
123
- await downloadFile(file.url, destPath, progressCallback);
124
-
125
- if (file.targetName && destPath !== targetPath) {
126
- if (fs.existsSync(targetPath)) {
127
- fs.unlinkSync(targetPath);
128
- }
129
- fs.renameSync(destPath, targetPath);
130
- results.push({ name: file.targetName, success: true });
131
- } else {
132
- results.push({ name: file.name, success: true });
133
- }
134
- } catch (e) {
135
- results.push({ name: file.name, success: false, error: e.message });
136
- }
137
- }
138
-
139
- return results;
140
- }
141
-
142
84
  async function checkUpdate() {
143
85
  const currentVersion = config.getKernelVersion();
144
86
  const latest = await getLatestRelease(GITHUB_REPO);
@@ -190,7 +132,7 @@ function findBinaryInDir(dir) {
190
132
  return null;
191
133
  }
192
134
 
193
- async function downloadKernel(progressCallback) {
135
+ async function downloadKernel(progressCallback, mirror) {
194
136
  config.ensureDirs();
195
137
 
196
138
  const latest = await getLatestRelease(GITHUB_REPO);
@@ -208,7 +150,7 @@ async function downloadKernel(progressCallback) {
208
150
  throw new Error('未找到匹配的内核文件\n 平台: ' + platform + ', 架构: ' + arch + hint);
209
151
  }
210
152
 
211
- const downloadUrl = withMirror(asset.browser_download_url);
153
+ const downloadUrl = withMirror(asset.browser_download_url, mirror);
212
154
  const tempPath = path.join(config.DIRS.core, asset.name);
213
155
 
214
156
  if (progressCallback) {
@@ -293,5 +235,4 @@ module.exports = {
293
235
  findMatchingAsset,
294
236
  checkUpdate,
295
237
  downloadKernel,
296
- downloadGeodata,
297
238
  };
package/src/process.js CHANGED
@@ -100,14 +100,13 @@ function clearPid() {
100
100
  return;
101
101
  }
102
102
  if (isPidFileOwnedByRoot()) {
103
- console.log(' PID 文件由 root 创建,需要管理员权限删除');
104
103
  try {
105
104
  execSync('sudo rm -f "' + config.PATHS.pidFile + '" 2>/dev/null', {
106
105
  stdio: 'inherit',
107
106
  timeout: 10000,
108
107
  });
109
108
  } catch (e) {
110
- console.log(' 提示: 请手动运行 "sudo rm ' + config.PATHS.pidFile + '"');
109
+ // 忽略失败,后续操作可能会检测到问题
111
110
  }
112
111
  } else {
113
112
  try {
@@ -186,14 +185,10 @@ function cleanupAll(forceSudo) {
186
185
  let failedPids = [];
187
186
 
188
187
  if (needsSudo) {
189
- console.log(' 发现 ' + pids.length + ' 个 mihomo 进程(部分需要 root 权限)');
190
- console.log(' 正在请求管理员权限终止进程...');
191
188
  const success = killAllMihomo(true);
192
189
  if (success) {
193
190
  killedCount = pids.length;
194
191
  } else {
195
- console.log(' 部分进程可能需要手动清理');
196
- console.log(' 手动清理命令: sudo pkill -9 mihomo');
197
192
  failedPids = pids;
198
193
  }
199
194
  } else {
@@ -246,8 +241,8 @@ function createTunLaunchScript() {
246
241
  'sleep 0.2\n' +
247
242
  'rm -f "${PID_FILE}" 2>/dev/null || true\n' +
248
243
  '\n' +
249
- '# 清空日志\n' +
250
- 'echo "=== TUN 启动: $(date) ===" > "${LOG_FILE}"\n' +
244
+ '# 写入启动标记\n' +
245
+ 'echo "=== TUN 启动: $(date) ===" >> "${LOG_FILE}"\n' +
251
246
  '\n' +
252
247
  '# 启动\n' +
253
248
  'cd /tmp\n' +
@@ -259,7 +254,6 @@ function createTunLaunchScript() {
259
254
  'for i in 1 2 3 4 5; do\n' +
260
255
  ' sleep 0.4\n' +
261
256
  ' if kill -0 ${NEW_PID} 2>/dev/null; then\n' +
262
- ' echo "TUN 启动成功, PID ${NEW_PID}"\n' +
263
257
  ' exit 0\n' +
264
258
  ' fi\n' +
265
259
  'done\n' +
@@ -353,6 +347,7 @@ async function startMixedMode(staleState) {
353
347
  }
354
348
 
355
349
  config.ensureDirs();
350
+ rotateAndCleanupLogs();
356
351
 
357
352
  const binary = config.PATHS.mihomoBinary;
358
353
  if (!fs.existsSync(binary)) {
@@ -406,6 +401,7 @@ async function startMixedMode(staleState) {
406
401
 
407
402
  async function startTunMode(staleState) {
408
403
  config.ensureDirs();
404
+ rotateAndCleanupLogs();
409
405
 
410
406
  const binary = config.PATHS.mihomoBinary;
411
407
  if (!fs.existsSync(binary)) {
@@ -474,10 +470,152 @@ function stop(wasTunMode) {
474
470
  return { success: true, killed: result.killed };
475
471
  }
476
472
 
473
+ function rotateAndCleanupLogs() {
474
+ rotateLog();
475
+ cleanupOldLogs(7);
476
+ }
477
+
477
478
  function getLogPath() {
478
479
  return config.PATHS.logFile;
479
480
  }
480
481
 
482
+ function rotateLog() {
483
+ const logFile = config.PATHS.logFile;
484
+ if (!fs.existsSync(logFile)) {
485
+ return null;
486
+ }
487
+
488
+ const stat = fs.statSync(logFile);
489
+ if (stat.size === 0) {
490
+ return null;
491
+ }
492
+
493
+ const timestamp = new Date().toISOString()
494
+ .replace(/T/, '_')
495
+ .replace(/:/g, '-')
496
+ .replace(/\..+/, '');
497
+
498
+ const rotatedName = `mihomo.${timestamp}.log`;
499
+ const rotatedPath = path.join(config.DIRS.logs, rotatedName);
500
+
501
+ fs.renameSync(logFile, rotatedPath);
502
+ return rotatedPath;
503
+ }
504
+
505
+ function cleanupOldLogs(maxAgeDays) {
506
+ if (maxAgeDays === undefined) maxAgeDays = 7;
507
+ const logsDir = config.DIRS.logs;
508
+
509
+ if (!fs.existsSync(logsDir)) {
510
+ return { deleted: 0, errors: 0 };
511
+ }
512
+
513
+ const files = fs.readdirSync(logsDir);
514
+ const now = Date.now();
515
+ const maxAgeMs = maxAgeDays * 24 * 60 * 60 * 1000;
516
+
517
+ let deleted = 0;
518
+ let errors = 0;
519
+
520
+ for (const file of files) {
521
+ if (!file.match(/^mihomo\.\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2}\.log$/)) {
522
+ continue;
523
+ }
524
+
525
+ try {
526
+ const filePath = path.join(logsDir, file);
527
+ const stat = fs.statSync(filePath);
528
+ const ageMs = now - stat.mtimeMs;
529
+
530
+ if (ageMs > maxAgeMs) {
531
+ fs.unlinkSync(filePath);
532
+ deleted++;
533
+ }
534
+ } catch (e) {
535
+ errors++;
536
+ }
537
+ }
538
+
539
+ return { deleted, errors };
540
+ }
541
+
542
+ function listLogs() {
543
+ const logsDir = config.DIRS.logs;
544
+ const result = {
545
+ current: null,
546
+ archives: [],
547
+ };
548
+
549
+ if (fs.existsSync(config.PATHS.logFile)) {
550
+ const stat = fs.statSync(config.PATHS.logFile);
551
+ result.current = {
552
+ name: 'mihomo.log (当前)',
553
+ path: config.PATHS.logFile,
554
+ size: stat.size,
555
+ mtime: stat.mtime,
556
+ isCurrent: true,
557
+ };
558
+ }
559
+
560
+ if (!fs.existsSync(logsDir)) {
561
+ return result;
562
+ }
563
+
564
+ const files = fs.readdirSync(logsDir);
565
+ for (const file of files) {
566
+ const match = file.match(/^mihomo\.(\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2})\.log$/);
567
+ if (!match) continue;
568
+
569
+ try {
570
+ const filePath = path.join(logsDir, file);
571
+ const stat = fs.statSync(filePath);
572
+ result.archives.push({
573
+ name: file,
574
+ timestamp: match[1],
575
+ path: filePath,
576
+ size: stat.size,
577
+ mtime: stat.mtime,
578
+ isCurrent: false,
579
+ });
580
+ } catch (e) {
581
+ // ignore
582
+ }
583
+ }
584
+
585
+ result.archives.sort((a, b) => b.mtime - a.mtime);
586
+ return result;
587
+ }
588
+
589
+ function getLogPathByName(name) {
590
+ const logsDir = config.DIRS.logs;
591
+
592
+ // 处理部分匹配(用户只输入时间戳部分)
593
+ let targetName = name;
594
+ if (!name.endsWith('.log')) {
595
+ targetName = 'mihomo.' + name + '.log';
596
+ }
597
+ if (!targetName.startsWith('mihomo.')) {
598
+ targetName = 'mihomo.' + targetName;
599
+ }
600
+
601
+ const filePath = path.join(logsDir, targetName);
602
+ if (fs.existsSync(filePath)) {
603
+ return filePath;
604
+ }
605
+
606
+ // 尝试模糊匹配
607
+ if (fs.existsSync(logsDir)) {
608
+ const files = fs.readdirSync(logsDir);
609
+ for (const file of files) {
610
+ if (file.includes(name)) {
611
+ return path.join(logsDir, file);
612
+ }
613
+ }
614
+ }
615
+
616
+ return null;
617
+ }
618
+
481
619
  function readLog(lines) {
482
620
  if (lines === undefined) lines = 100;
483
621
  if (!fs.existsSync(config.PATHS.logFile)) {
@@ -528,5 +666,9 @@ module.exports = {
528
666
  getLogPath,
529
667
  readLog,
530
668
  clearLog,
669
+ rotateLog,
670
+ cleanupOldLogs,
671
+ listLogs,
672
+ getLogPathByName,
531
673
  openUrl,
532
674
  };