clash-kit 1.0.0
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/README.md +110 -0
- package/bin/index.js +56 -0
- package/index.js +135 -0
- package/lib/api.js +106 -0
- package/lib/commands/init.js +58 -0
- package/lib/commands/proxy.js +40 -0
- package/lib/commands/start.js +22 -0
- package/lib/commands/status.js +85 -0
- package/lib/commands/stop.js +28 -0
- package/lib/commands/sub.js +78 -0
- package/lib/commands/sysproxy.js +24 -0
- package/lib/commands/test.js +70 -0
- package/lib/commands/tun.js +95 -0
- package/lib/kernel.js +152 -0
- package/lib/subscription.js +68 -0
- package/lib/sysnet.js +52 -0
- package/lib/sysproxy.js +111 -0
- package/lib/tun.js +126 -0
- package/package.json +43 -0
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { select, input } from '@inquirer/prompts'
|
|
2
|
+
import path from 'path'
|
|
3
|
+
import fs from 'fs'
|
|
4
|
+
import { fileURLToPath } from 'url'
|
|
5
|
+
import chalk from 'chalk'
|
|
6
|
+
import * as sub from '../subscription.js'
|
|
7
|
+
|
|
8
|
+
const __filename = fileURLToPath(import.meta.url)
|
|
9
|
+
const __dirname = path.dirname(__filename)
|
|
10
|
+
|
|
11
|
+
async function handleAddSubscription(url, name) {
|
|
12
|
+
const profiles = sub.listProfiles()
|
|
13
|
+
// 没有找到可选的订阅,或没找到 config.yaml
|
|
14
|
+
// __dirname here is lib/commands. Config is at ROOT/config.yaml. so join ../../config.yaml
|
|
15
|
+
const isFirst = profiles.length === 0 || !fs.existsSync(path.join(__dirname, '../../config.yaml'))
|
|
16
|
+
|
|
17
|
+
await sub.downloadSubscription(url, name)
|
|
18
|
+
|
|
19
|
+
if (!isFirst) return
|
|
20
|
+
console.log(`检测到这是第一个订阅,正在自动切换到 ${name}...`)
|
|
21
|
+
await sub.useProfile(name)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export async function manageSub(options) {
|
|
25
|
+
if (options.add) {
|
|
26
|
+
if (!options.name) {
|
|
27
|
+
console.error('错误: 添加订阅时必须指定名称 (-n)')
|
|
28
|
+
return
|
|
29
|
+
}
|
|
30
|
+
try {
|
|
31
|
+
await handleAddSubscription(options.add, options.name)
|
|
32
|
+
} catch (err) {
|
|
33
|
+
console.error(err.message)
|
|
34
|
+
}
|
|
35
|
+
} else if (options.list) {
|
|
36
|
+
const profiles = sub.listProfiles()
|
|
37
|
+
console.log('可用订阅:')
|
|
38
|
+
profiles.forEach(p => console.log(`- ${p}`))
|
|
39
|
+
} else if (options.use) {
|
|
40
|
+
try {
|
|
41
|
+
await sub.useProfile(options.use)
|
|
42
|
+
} catch (err) {
|
|
43
|
+
console.error(err.message)
|
|
44
|
+
}
|
|
45
|
+
} else {
|
|
46
|
+
// 交互式模式
|
|
47
|
+
const profiles = sub.listProfiles()
|
|
48
|
+
|
|
49
|
+
const action = await select({
|
|
50
|
+
message: '请选择操作:',
|
|
51
|
+
choices: [
|
|
52
|
+
{ name: '切换订阅', value: 'switch' },
|
|
53
|
+
{ name: '添加订阅', value: 'add' },
|
|
54
|
+
],
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
if (action === 'switch') {
|
|
58
|
+
if (profiles.length === 0) {
|
|
59
|
+
console.log('暂无订阅,请先添加')
|
|
60
|
+
return
|
|
61
|
+
}
|
|
62
|
+
const profile = await select({
|
|
63
|
+
message: '选择要使用的订阅:',
|
|
64
|
+
choices: profiles.map(p => ({ name: p, value: p })),
|
|
65
|
+
})
|
|
66
|
+
await sub.useProfile(profile)
|
|
67
|
+
} else if (action === 'add') {
|
|
68
|
+
const url = await input({ message: '请输入订阅链接:' })
|
|
69
|
+
const name = await input({ message: '请输入订阅名称:' })
|
|
70
|
+
|
|
71
|
+
try {
|
|
72
|
+
await handleAddSubscription(url, name)
|
|
73
|
+
} catch (err) {
|
|
74
|
+
console.error(err.message)
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { select } from '@inquirer/prompts'
|
|
2
|
+
import * as sysproxy from '../sysproxy.js'
|
|
3
|
+
|
|
4
|
+
export async function setSysProxy(action) {
|
|
5
|
+
if (action === 'on') {
|
|
6
|
+
await sysproxy.enableSystemProxy()
|
|
7
|
+
} else if (action === 'off') {
|
|
8
|
+
await sysproxy.disableSystemProxy()
|
|
9
|
+
} else {
|
|
10
|
+
// 交互式选择
|
|
11
|
+
const answer = await select({
|
|
12
|
+
message: '请选择系统代理操作:',
|
|
13
|
+
choices: [
|
|
14
|
+
{ name: '开启系统代理', value: 'on' },
|
|
15
|
+
{ name: '关闭系统代理', value: 'off' },
|
|
16
|
+
],
|
|
17
|
+
})
|
|
18
|
+
if (answer === 'on') {
|
|
19
|
+
await sysproxy.enableSystemProxy()
|
|
20
|
+
} else {
|
|
21
|
+
await sysproxy.disableSystemProxy()
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import chalk from 'chalk'
|
|
2
|
+
import * as api from '../api.js'
|
|
3
|
+
|
|
4
|
+
export async function test() {
|
|
5
|
+
try {
|
|
6
|
+
const proxies = await api.getProxies()
|
|
7
|
+
// 默认测速 Proxy 组的所有节点
|
|
8
|
+
const group = proxies['Proxy'] || Object.values(proxies).find(p => p.type === 'Selector')
|
|
9
|
+
|
|
10
|
+
if (!group) {
|
|
11
|
+
console.error('找不到 Proxy 组')
|
|
12
|
+
return
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
console.log(`\n当前测速目标组: [${group.name}]`)
|
|
16
|
+
console.log(`包含节点数: ${group.all.length}`)
|
|
17
|
+
console.log('注意: 测速针对的是当前 Clash 正在运行的配置。')
|
|
18
|
+
console.log('如果刚切换了订阅,请确保看到"配置已热重载生效"提示,或手动重启 clash start。\n')
|
|
19
|
+
|
|
20
|
+
const results = []
|
|
21
|
+
const concurrency = 10
|
|
22
|
+
const queue = [...group.all]
|
|
23
|
+
const total = group.all.length
|
|
24
|
+
let completed = 0
|
|
25
|
+
|
|
26
|
+
console.log(chalk.gray(`准备并发测速 (并发数: ${concurrency})...`))
|
|
27
|
+
|
|
28
|
+
const worker = async () => {
|
|
29
|
+
while (queue.length > 0) {
|
|
30
|
+
const name = queue.shift()
|
|
31
|
+
if (!name) break
|
|
32
|
+
|
|
33
|
+
try {
|
|
34
|
+
const delay = await api.getProxyDelay(name)
|
|
35
|
+
completed++
|
|
36
|
+
const progress = `[${completed}/${total}]`
|
|
37
|
+
|
|
38
|
+
if (delay > 0) {
|
|
39
|
+
const color = delay < 200 ? chalk.green : delay < 800 ? chalk.yellow : chalk.red
|
|
40
|
+
console.log(`${chalk.gray(progress)} ${chalk.cyan(name)}: ${color(delay + 'ms')}`)
|
|
41
|
+
results.push({ name, delay })
|
|
42
|
+
} else {
|
|
43
|
+
console.log(`${chalk.gray(progress)} ${chalk.cyan(name)}: ${chalk.red('超时')}`)
|
|
44
|
+
results.push({ name, delay: 99999 })
|
|
45
|
+
}
|
|
46
|
+
} catch (err) {
|
|
47
|
+
completed++
|
|
48
|
+
results.push({ name, delay: 99999 })
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
await Promise.all(Array.from({ length: Math.min(concurrency, total) }, () => worker()))
|
|
54
|
+
|
|
55
|
+
console.log(chalk.bold.blue('\n=== 测速结果 (Top 5) ==='))
|
|
56
|
+
results.sort((a, b) => a.delay - b.delay)
|
|
57
|
+
results.slice(0, 5).forEach((r, i) => {
|
|
58
|
+
let delayInfo
|
|
59
|
+
if (r.delay === 99999) {
|
|
60
|
+
delayInfo = chalk.red('超时')
|
|
61
|
+
} else {
|
|
62
|
+
const color = r.delay < 200 ? chalk.green : r.delay < 800 ? chalk.yellow : chalk.red
|
|
63
|
+
delayInfo = color(`${r.delay}ms`)
|
|
64
|
+
}
|
|
65
|
+
console.log(`${chalk.gray(i + 1 + '.')} ${chalk.bold(r.name)}: ${delayInfo}`)
|
|
66
|
+
})
|
|
67
|
+
} catch (err) {
|
|
68
|
+
console.error(err.message)
|
|
69
|
+
}
|
|
70
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { select } from '@inquirer/prompts'
|
|
2
|
+
import chalk from 'chalk'
|
|
3
|
+
import * as tun from '../tun.js'
|
|
4
|
+
import * as sysnet from '../sysnet.js'
|
|
5
|
+
import { main as startClashService } from '../../index.js'
|
|
6
|
+
|
|
7
|
+
export async function setTun(action) {
|
|
8
|
+
try {
|
|
9
|
+
let shouldRestart = false
|
|
10
|
+
|
|
11
|
+
if (action === 'on') {
|
|
12
|
+
if (process.platform !== 'win32') {
|
|
13
|
+
const hasPerm = tun.checkTunPermissions()
|
|
14
|
+
const isRoot = process.getuid && process.getuid() === 0
|
|
15
|
+
|
|
16
|
+
if (!hasPerm && !isRoot) {
|
|
17
|
+
console.log(chalk.yellow('检测到内核缺少 SUID 权限,当前也非 Root 用户,TUN 模式可能无法启动。'))
|
|
18
|
+
const confirm = await select({
|
|
19
|
+
message: '是否自动授予内核 SUID 权限 (推荐)?',
|
|
20
|
+
choices: [
|
|
21
|
+
{ name: '是 (仅需输入一次 sudo 密码)', value: true },
|
|
22
|
+
{ name: '否 (之后需要 sudo clash start)', value: false },
|
|
23
|
+
],
|
|
24
|
+
})
|
|
25
|
+
if (confirm) {
|
|
26
|
+
tun.setupPermissions()
|
|
27
|
+
console.log(chalk.green('权限设置成功!'))
|
|
28
|
+
shouldRestart = true
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
console.log('正在开启 TUN 模式...')
|
|
34
|
+
await tun.enableTun()
|
|
35
|
+
sysnet.setDNS(['223.5.5.5', '114.114.114.114'])
|
|
36
|
+
console.log(chalk.green('TUN 模式配置已开启'))
|
|
37
|
+
|
|
38
|
+
if (shouldRestart) {
|
|
39
|
+
console.log(chalk.yellow('因为更改了权限,正在重启 Clash 服务以应用更改...'))
|
|
40
|
+
startClashService()
|
|
41
|
+
} else {
|
|
42
|
+
console.log(chalk.gray('提示: 如果 TUN 模式未生效,请尝试运行 "clash start" 重启服务。'))
|
|
43
|
+
}
|
|
44
|
+
} else if (action === 'off') {
|
|
45
|
+
console.log('正在关闭 TUN 模式...')
|
|
46
|
+
await tun.disableTun()
|
|
47
|
+
sysnet.setDNS([]) // 恢复系统默认 DNS
|
|
48
|
+
console.log(chalk.green('TUN 模式配置已关闭'))
|
|
49
|
+
console.log(chalk.gray('配置已热重载。'))
|
|
50
|
+
} else {
|
|
51
|
+
const isEnabled = await tun.isTunEnabled()
|
|
52
|
+
const answer = await select({
|
|
53
|
+
message: `请选择 TUN 模式操作 (当前状态: ${isEnabled ? chalk.green('开启') : chalk.gray('关闭')}):`,
|
|
54
|
+
choices: [
|
|
55
|
+
{ name: '开启 TUN 模式', value: 'on' },
|
|
56
|
+
{ name: '关闭 TUN 模式', value: 'off' },
|
|
57
|
+
],
|
|
58
|
+
})
|
|
59
|
+
if (answer === 'on') {
|
|
60
|
+
if (process.platform !== 'win32') {
|
|
61
|
+
const hasPerm = tun.checkTunPermissions()
|
|
62
|
+
const isRoot = process.getuid && process.getuid() === 0
|
|
63
|
+
if (!hasPerm && !isRoot) {
|
|
64
|
+
console.log(chalk.yellow('提示: 建议授予内核 SUID 权限以避免 sudo 启动。'))
|
|
65
|
+
const confirm = await select({
|
|
66
|
+
message: '是否授予权限?',
|
|
67
|
+
choices: [
|
|
68
|
+
{ name: '是', value: true },
|
|
69
|
+
{ name: '否', value: false },
|
|
70
|
+
],
|
|
71
|
+
})
|
|
72
|
+
if (confirm) {
|
|
73
|
+
tun.setupPermissions()
|
|
74
|
+
shouldRestart = true
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
await tun.enableTun()
|
|
79
|
+
sysnet.setDNS(['223.5.5.5', '114.114.114.114'])
|
|
80
|
+
console.log(chalk.green('TUN 模式配置已开启'))
|
|
81
|
+
|
|
82
|
+
if (shouldRestart) {
|
|
83
|
+
console.log(chalk.yellow('正在重启 Clash 服务...'))
|
|
84
|
+
startClashService()
|
|
85
|
+
}
|
|
86
|
+
} else {
|
|
87
|
+
await tun.disableTun()
|
|
88
|
+
sysnet.setDNS([])
|
|
89
|
+
console.log(chalk.green('TUN 模式配置已关闭'))
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
} catch (err) {
|
|
93
|
+
console.error(chalk.red(`设置 TUN 模式失败: ${err.message}`))
|
|
94
|
+
}
|
|
95
|
+
}
|
package/lib/kernel.js
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import axios from 'axios'
|
|
2
|
+
import fs from 'fs'
|
|
3
|
+
import path from 'path'
|
|
4
|
+
import os from 'os'
|
|
5
|
+
import zlib from 'zlib'
|
|
6
|
+
import AdmZip from 'adm-zip'
|
|
7
|
+
import { fileURLToPath } from 'url'
|
|
8
|
+
import ora from 'ora'
|
|
9
|
+
import cliProgress from 'cli-progress'
|
|
10
|
+
import chalk from 'chalk'
|
|
11
|
+
|
|
12
|
+
const __filename = fileURLToPath(import.meta.url)
|
|
13
|
+
const __dirname = path.dirname(__filename)
|
|
14
|
+
|
|
15
|
+
const MIHOMO_VERSION_URL = 'https://github.com/MetaCubeX/mihomo/releases/latest/download/version.txt'
|
|
16
|
+
|
|
17
|
+
const PLATFORM_MAP = {
|
|
18
|
+
'win32-x64': 'mihomo-windows-amd64-compatible',
|
|
19
|
+
'win32-ia32': 'mihomo-windows-386',
|
|
20
|
+
'win32-arm64': 'mihomo-windows-arm64',
|
|
21
|
+
'darwin-x64': 'mihomo-darwin-amd64-compatible',
|
|
22
|
+
'darwin-arm64': 'mihomo-darwin-arm64',
|
|
23
|
+
'linux-x64': 'mihomo-linux-amd64-compatible',
|
|
24
|
+
'linux-arm64': 'mihomo-linux-arm64',
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export async function downloadClash(targetDir) {
|
|
28
|
+
const platform = os.platform()
|
|
29
|
+
const arch = os.arch()
|
|
30
|
+
const key = `${platform}-${arch}`
|
|
31
|
+
const name = PLATFORM_MAP[key]
|
|
32
|
+
|
|
33
|
+
if (!name) {
|
|
34
|
+
throw new Error(`不支持的平台: ${platform}-${arch}`)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// 1. 获取最新版本
|
|
38
|
+
const spinner = ora('正在获取最新 Mihomo 版本信息...').start()
|
|
39
|
+
let version
|
|
40
|
+
try {
|
|
41
|
+
const { data } = await axios.get(MIHOMO_VERSION_URL)
|
|
42
|
+
version = data.trim()
|
|
43
|
+
spinner.succeed(`检测到最新版本: ${version}`)
|
|
44
|
+
} catch (e) {
|
|
45
|
+
spinner.fail(`获取版本信息失败: ${e.message}`)
|
|
46
|
+
throw new Error(`获取版本信息失败: ${e.message}`)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// 2. 构建下载 URL
|
|
50
|
+
const isWin = platform === 'win32'
|
|
51
|
+
const urlExt = isWin ? 'zip' : 'gz'
|
|
52
|
+
const downloadUrl = `https://github.com/MetaCubeX/mihomo/releases/download/${version}/${name}-${version}.${urlExt}`
|
|
53
|
+
const tempFileName = `mihomo-temp.${urlExt}`
|
|
54
|
+
const tempPath = path.join(targetDir, tempFileName)
|
|
55
|
+
const targetBinName = `clash-meta${isWin ? '.exe' : ''}`
|
|
56
|
+
const targetBinPath = path.join(targetDir, targetBinName)
|
|
57
|
+
|
|
58
|
+
// 3. 下载文件
|
|
59
|
+
console.log(`正在下载: ${downloadUrl}`)
|
|
60
|
+
const bar = new cliProgress.SingleBar(
|
|
61
|
+
{
|
|
62
|
+
format: '下载进度 | {bar} | {percentage}% | {valueFormatted}/{totalFormatted} MB',
|
|
63
|
+
hideCursor: true,
|
|
64
|
+
},
|
|
65
|
+
cliProgress.Presets.shades_classic
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
try {
|
|
69
|
+
let started = false
|
|
70
|
+
let totalMB = '0.0'
|
|
71
|
+
const response = await axios({
|
|
72
|
+
url: downloadUrl,
|
|
73
|
+
method: 'GET',
|
|
74
|
+
responseType: 'arraybuffer',
|
|
75
|
+
onDownloadProgress: progressEvent => {
|
|
76
|
+
if (progressEvent.total) {
|
|
77
|
+
const loadedMB = (progressEvent.loaded / 1024 / 1024).toFixed(1)
|
|
78
|
+
if (!started) {
|
|
79
|
+
totalMB = (progressEvent.total / 1024 / 1024).toFixed(1)
|
|
80
|
+
bar.start(progressEvent.total, 0, {
|
|
81
|
+
valueFormatted: '0.0',
|
|
82
|
+
totalFormatted: totalMB,
|
|
83
|
+
})
|
|
84
|
+
started = true
|
|
85
|
+
}
|
|
86
|
+
bar.update(progressEvent.loaded, {
|
|
87
|
+
valueFormatted: loadedMB,
|
|
88
|
+
totalFormatted: totalMB,
|
|
89
|
+
})
|
|
90
|
+
}
|
|
91
|
+
},
|
|
92
|
+
})
|
|
93
|
+
bar.stop()
|
|
94
|
+
|
|
95
|
+
fs.writeFileSync(tempPath, response.data)
|
|
96
|
+
spinner.start(chalk.green('下载完成,正在解压...'))
|
|
97
|
+
} catch (e) {
|
|
98
|
+
bar.stop()
|
|
99
|
+
throw new Error(`下载失败: ${e.message}`)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// 4. 解压文件
|
|
103
|
+
try {
|
|
104
|
+
if (isWin) {
|
|
105
|
+
// ZIP 解压
|
|
106
|
+
const zip = new AdmZip(tempPath)
|
|
107
|
+
const zipEntries = zip.getEntries()
|
|
108
|
+
// 查找可执行文件(通常名字包含 mihoo 或 name)
|
|
109
|
+
const entry = zipEntries.find(e => e.entryName.includes(name) || e.entryName.endsWith('.exe'))
|
|
110
|
+
if (!entry) {
|
|
111
|
+
throw new Error('压缩包中未找到可执行文件')
|
|
112
|
+
}
|
|
113
|
+
zip.extractEntryTo(entry, targetDir, false, true, false, targetBinName)
|
|
114
|
+
} else {
|
|
115
|
+
// GZ 解压
|
|
116
|
+
const fileContents = fs.readFileSync(tempPath)
|
|
117
|
+
const unzipped = zlib.gunzipSync(fileContents)
|
|
118
|
+
fs.writeFileSync(targetBinPath, unzipped)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// 清理临时文件
|
|
122
|
+
fs.unlinkSync(tempPath)
|
|
123
|
+
spinner.succeed(chalk.green(`解压完成: ${targetBinPath}`))
|
|
124
|
+
} catch (e) {
|
|
125
|
+
spinner.fail(chalk.red(`解压失败: ${e.message}`))
|
|
126
|
+
throw new Error(`解压失败: ${e.message}`)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// 5. 设置权限
|
|
130
|
+
if (!isWin) {
|
|
131
|
+
spinner.start('正在设置执行权限...')
|
|
132
|
+
fs.chmodSync(targetBinPath, 0o755)
|
|
133
|
+
spinner.succeed(chalk.green('设置执行权限完成'))
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return targetBinPath
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function testDownload() {
|
|
140
|
+
// 测试下载功能
|
|
141
|
+
const rootDir = path.join(__dirname, '..')
|
|
142
|
+
downloadClash(rootDir)
|
|
143
|
+
.then(binPath => {
|
|
144
|
+
console.log(`Mihomo 内核已下载并解压到: ${binPath}`)
|
|
145
|
+
})
|
|
146
|
+
.catch(err => {
|
|
147
|
+
console.error(`下载失败: ${err.message}`)
|
|
148
|
+
})
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// 如果直接运行此文件,则执行测试
|
|
152
|
+
if (import.meta.url === `file://${process.argv[1]}`) testDownload()
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import fs from 'fs'
|
|
2
|
+
import path from 'path'
|
|
3
|
+
import axios from 'axios'
|
|
4
|
+
import { fileURLToPath } from 'url'
|
|
5
|
+
import ora from 'ora'
|
|
6
|
+
import { reloadConfig } from './api.js'
|
|
7
|
+
|
|
8
|
+
const __filename = fileURLToPath(import.meta.url)
|
|
9
|
+
const __dirname = path.dirname(__filename)
|
|
10
|
+
|
|
11
|
+
const PROFILES_DIR = path.join(__dirname, '../profiles')
|
|
12
|
+
const CONFIG_PATH = path.join(__dirname, '../config.yaml')
|
|
13
|
+
const CURRENT_PROFILE_PATH = path.join(__dirname, '../.current_profile')
|
|
14
|
+
|
|
15
|
+
// 确保 profiles 目录存在
|
|
16
|
+
if (!fs.existsSync(PROFILES_DIR)) {
|
|
17
|
+
fs.mkdirSync(PROFILES_DIR)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export async function downloadSubscription(url, name) {
|
|
21
|
+
const spinner = ora(`正在下载订阅 ${name}...`).start()
|
|
22
|
+
try {
|
|
23
|
+
const res = await axios.get(url, { responseType: 'text' })
|
|
24
|
+
const filePath = path.join(PROFILES_DIR, `${name}.yaml`)
|
|
25
|
+
fs.writeFileSync(filePath, res.data)
|
|
26
|
+
spinner.succeed(`订阅 ${name} 下载成功`)
|
|
27
|
+
return filePath
|
|
28
|
+
} catch (err) {
|
|
29
|
+
spinner.fail(`下载订阅失败: ${err.message}`)
|
|
30
|
+
throw new Error(`下载订阅失败: ${err.message}`)
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function listProfiles() {
|
|
35
|
+
return fs
|
|
36
|
+
.readdirSync(PROFILES_DIR)
|
|
37
|
+
.filter(f => f.endsWith('.yaml'))
|
|
38
|
+
.map(f => f.replace('.yaml', ''))
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function getCurrentProfile() {
|
|
42
|
+
if (fs.existsSync(CURRENT_PROFILE_PATH)) {
|
|
43
|
+
return fs.readFileSync(CURRENT_PROFILE_PATH, 'utf8').trim()
|
|
44
|
+
}
|
|
45
|
+
return null
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export async function useProfile(name) {
|
|
49
|
+
const source = path.join(PROFILES_DIR, `${name}.yaml`)
|
|
50
|
+
if (!fs.existsSync(source)) {
|
|
51
|
+
throw new Error(`配置文件 ${name} 不存在`)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const spinner = ora(`正在切换到配置 ${name}...`).start()
|
|
55
|
+
|
|
56
|
+
// 直接复制配置文件,保持原样
|
|
57
|
+
fs.copyFileSync(source, CONFIG_PATH)
|
|
58
|
+
fs.writeFileSync(CURRENT_PROFILE_PATH, name)
|
|
59
|
+
|
|
60
|
+
// 尝试热重载
|
|
61
|
+
try {
|
|
62
|
+
await reloadConfig(CONFIG_PATH)
|
|
63
|
+
spinner.succeed('Clash 配置已切换并热重载生效')
|
|
64
|
+
} catch (err) {
|
|
65
|
+
// 忽略错误,可能是 Clash 未运行
|
|
66
|
+
spinner.warn('配置已切换,但 Clash 未运行或无法连接,将在下次启动时生效')
|
|
67
|
+
}
|
|
68
|
+
}
|
package/lib/sysnet.js
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { execSync } from 'child_process'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 获取 macOS 当前主要的网络服务名称 (Wi-Fi, Ethernet 等)
|
|
5
|
+
*/
|
|
6
|
+
function getMainNetworkService() {
|
|
7
|
+
try {
|
|
8
|
+
// 这是一个简单的方法,通常第一行是默认路由接口
|
|
9
|
+
// networksetup -listnetworkserviceorder
|
|
10
|
+
// 更精准的方法是 route get default 但输出解析较复杂
|
|
11
|
+
// 这里我们先假设最常见的几种情况
|
|
12
|
+
const output = execSync('networksetup -listallnetworkservices', { encoding: 'utf-8' })
|
|
13
|
+
const services = output.split('\n').filter(s => s && !s.includes('An asterisk'))
|
|
14
|
+
|
|
15
|
+
// 优先尝试 Wi-Fi 和 Ethernet
|
|
16
|
+
const wifi = services.find(s => s === 'Wi-Fi')
|
|
17
|
+
const ethernet = services.find(s => s.includes('Ethernet') || s.includes('LAN'))
|
|
18
|
+
|
|
19
|
+
// 简单粗暴:优先返回 Wi-Fi,其次 Ethernet,最后第一个
|
|
20
|
+
// TODO: 更严谨的做法是检测哪个接口有 IP 且是 default route
|
|
21
|
+
return wifi || ethernet || services[0]
|
|
22
|
+
} catch (e) {
|
|
23
|
+
console.error('获取网络服务失败:', e)
|
|
24
|
+
return null
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* 设置系统 DNS
|
|
30
|
+
* @param {string[]} servers DNS 服务器列表
|
|
31
|
+
*/
|
|
32
|
+
export function setDNS(servers) {
|
|
33
|
+
if (process.platform !== 'darwin') return // 目前仅支持 macOS
|
|
34
|
+
|
|
35
|
+
const service = getMainNetworkService()
|
|
36
|
+
if (!service) return
|
|
37
|
+
|
|
38
|
+
try {
|
|
39
|
+
if (servers.length === 0) {
|
|
40
|
+
// 恢复默认 (Empty 会让 macOS 使用 DHCP 下发的 DNS)
|
|
41
|
+
console.log(`正在恢复系统 DNS (${service})...`)
|
|
42
|
+
execSync(`sudo networksetup -setdnsservers "${service}" "Empty"`)
|
|
43
|
+
} else {
|
|
44
|
+
console.log(`正在设置系统 DNS (${service}) -> ${servers.join(' ')}...`)
|
|
45
|
+
execSync(`sudo networksetup -setdnsservers "${service}" ${servers.join(' ')}`)
|
|
46
|
+
}
|
|
47
|
+
} catch (e) {
|
|
48
|
+
// 忽略错误,通常是因为需要 sudo 但在非交互模式下失败
|
|
49
|
+
// 但我们的 CLI 主要是用户手动执行的。
|
|
50
|
+
console.warn(`设置 DNS 失败 (可能需要 sudo): ${e.message}`)
|
|
51
|
+
}
|
|
52
|
+
}
|
package/lib/sysproxy.js
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import ora from 'ora'
|
|
2
|
+
import { triggerManualProxy } from '@mihomo-party/sysproxy'
|
|
3
|
+
import { execSync } from 'child_process'
|
|
4
|
+
import * as api from './api.js'
|
|
5
|
+
|
|
6
|
+
const defaultBypass = (() => {
|
|
7
|
+
switch (process.platform) {
|
|
8
|
+
case 'linux':
|
|
9
|
+
return ['localhost', '127.0.0.1', '192.168.0.0/16', '10.0.0.0/8', '172.16.0.0/12', '::1']
|
|
10
|
+
case 'darwin':
|
|
11
|
+
return [
|
|
12
|
+
'127.0.0.1',
|
|
13
|
+
'192.168.0.0/16',
|
|
14
|
+
'10.0.0.0/8',
|
|
15
|
+
'172.16.0.0/12',
|
|
16
|
+
'localhost',
|
|
17
|
+
'*.local',
|
|
18
|
+
'*.crashlytics.com',
|
|
19
|
+
'<local>',
|
|
20
|
+
]
|
|
21
|
+
case 'win32':
|
|
22
|
+
return [
|
|
23
|
+
'localhost',
|
|
24
|
+
'127.*',
|
|
25
|
+
'192.168.*',
|
|
26
|
+
'10.*',
|
|
27
|
+
'172.16.*',
|
|
28
|
+
'172.17.*',
|
|
29
|
+
'172.18.*',
|
|
30
|
+
'172.19.*',
|
|
31
|
+
'172.20.*',
|
|
32
|
+
'172.21.*',
|
|
33
|
+
'172.22.*',
|
|
34
|
+
'172.23.*',
|
|
35
|
+
'172.24.*',
|
|
36
|
+
'172.25.*',
|
|
37
|
+
'172.26.*',
|
|
38
|
+
'172.27.*',
|
|
39
|
+
'172.28.*',
|
|
40
|
+
'172.29.*',
|
|
41
|
+
'172.30.*',
|
|
42
|
+
'172.31.*',
|
|
43
|
+
'<local>',
|
|
44
|
+
]
|
|
45
|
+
default:
|
|
46
|
+
return ['localhost', '127.0.0.1', '192.168.0.0/16', '10.0.0.0/8', '172.16.0.0/12', '::1']
|
|
47
|
+
}
|
|
48
|
+
})()
|
|
49
|
+
|
|
50
|
+
export async function enableSystemProxy() {
|
|
51
|
+
const spinner = ora('正在等待 Clash API 就绪以设置系统代理...').start()
|
|
52
|
+
try {
|
|
53
|
+
const config = await api.getConfig()
|
|
54
|
+
const port = config['mixed-port'] || config['port']
|
|
55
|
+
|
|
56
|
+
if (!port) {
|
|
57
|
+
throw new Error('未找到 HTTP 代理端口配置 (port 或 mixed-port)')
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// 默认代理地址为 127.0.0.1
|
|
61
|
+
const host = '127.0.0.1'
|
|
62
|
+
const bypass = defaultBypass.join(',')
|
|
63
|
+
|
|
64
|
+
// 开启系统代理
|
|
65
|
+
triggerManualProxy(true, host, port, bypass)
|
|
66
|
+
spinner.succeed(`系统代理已开启: ${host}:${port}`)
|
|
67
|
+
return true
|
|
68
|
+
} catch (err) {
|
|
69
|
+
if (spinner && spinner.isSpinning) spinner.fail(`开启系统代理失败: ${err.message}`)
|
|
70
|
+
else console.error(err.message)
|
|
71
|
+
return false
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export async function disableSystemProxy() {
|
|
76
|
+
try {
|
|
77
|
+
// 关闭系统代理
|
|
78
|
+
triggerManualProxy(false, '', 0, '')
|
|
79
|
+
|
|
80
|
+
console.log('系统代理已关闭')
|
|
81
|
+
return true
|
|
82
|
+
} catch (err) {
|
|
83
|
+
console.error(`关闭系统代理失败: ${err.message}`)
|
|
84
|
+
return false
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export async function getSystemProxyStatus() {
|
|
89
|
+
// 使用 macOS networksetup 命令获取系统代理状态
|
|
90
|
+
if (process.platform === 'darwin') {
|
|
91
|
+
try {
|
|
92
|
+
const output = execSync('networksetup -getwebproxy Wi-Fi', { encoding: 'utf-8' })
|
|
93
|
+
const enabled = output.includes('Enabled: Yes')
|
|
94
|
+
if (enabled) {
|
|
95
|
+
const serverMatch = output.match(/Server: (.+)/)
|
|
96
|
+
const portMatch = output.match(/Port: (\d+)/)
|
|
97
|
+
const server = serverMatch ? serverMatch[1].trim() : ''
|
|
98
|
+
const port = portMatch ? portMatch[1].trim() : ''
|
|
99
|
+
return { enabled: true, server, port }
|
|
100
|
+
}
|
|
101
|
+
return { enabled: false }
|
|
102
|
+
} catch (e) {
|
|
103
|
+
return { enabled: false, error: e.message }
|
|
104
|
+
}
|
|
105
|
+
} else if (process.platform === 'win32') {
|
|
106
|
+
// Windows 可以通过注册表查询,这里简化处理
|
|
107
|
+
return { enabled: false, error: 'Windows 暂不支持获取状态' }
|
|
108
|
+
} else {
|
|
109
|
+
return { enabled: false, error: '不支持的平台' }
|
|
110
|
+
}
|
|
111
|
+
}
|