clash-kit 1.1.0 → 1.1.2
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/.gitignore +30 -21
- package/LICENSE.md +8 -8
- package/README.md +133 -124
- package/bin/dns-helper +0 -0
- package/bin/index.js +96 -85
- package/default.yaml +46 -46
- package/index.js +204 -218
- package/lib/api.js +178 -116
- package/lib/commands/init.js +127 -127
- package/lib/commands/proxy.js +73 -73
- package/lib/commands/restart.js +10 -0
- package/lib/commands/start.js +30 -30
- package/lib/commands/status.js +105 -85
- package/lib/commands/stop.js +67 -30
- package/lib/commands/sub.js +81 -81
- package/lib/commands/sysproxy.js +60 -24
- package/lib/commands/test.js +70 -70
- package/lib/commands/tun.js +123 -95
- package/lib/kernel.js +197 -178
- package/lib/port.js +115 -115
- package/lib/subscription.js +128 -125
- package/lib/sysnet.js +73 -52
- package/lib/sysproxy.js +133 -145
- package/lib/tun.js +150 -126
- package/package.json +50 -48
package/lib/kernel.js
CHANGED
|
@@ -1,178 +1,197 @@
|
|
|
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
|
-
import { execSync } from 'child_process'
|
|
12
|
-
|
|
13
|
-
const __filename = fileURLToPath(import.meta.url)
|
|
14
|
-
const __dirname = path.dirname(__filename)
|
|
15
|
-
|
|
16
|
-
const MIHOMO_VERSION_URL = 'https://github.com/MetaCubeX/mihomo/releases/latest/download/version.txt'
|
|
17
|
-
|
|
18
|
-
const PLATFORM_MAP = {
|
|
19
|
-
'win32-x64': 'mihomo-windows-amd64-compatible',
|
|
20
|
-
'win32-ia32': 'mihomo-windows-386',
|
|
21
|
-
'win32-arm64': 'mihomo-windows-arm64',
|
|
22
|
-
'darwin-x64': 'mihomo-darwin-amd64-compatible',
|
|
23
|
-
'darwin-arm64': 'mihomo-darwin-arm64',
|
|
24
|
-
'linux-x64': 'mihomo-linux-amd64-compatible',
|
|
25
|
-
'linux-arm64': 'mihomo-linux-arm64',
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
export async function downloadClash(targetDir) {
|
|
29
|
-
const platform = os.platform()
|
|
30
|
-
const arch = os.arch()
|
|
31
|
-
const key = `${platform}-${arch}`
|
|
32
|
-
const name = PLATFORM_MAP[key]
|
|
33
|
-
|
|
34
|
-
if (!name) {
|
|
35
|
-
throw new Error(`不支持的平台: ${platform}-${arch}`)
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
// 1. 获取最新版本
|
|
39
|
-
const spinner = ora('正在获取最新 Mihomo 版本信息...').start()
|
|
40
|
-
let version
|
|
41
|
-
try {
|
|
42
|
-
const { data } = await axios.get(MIHOMO_VERSION_URL)
|
|
43
|
-
version = data.trim()
|
|
44
|
-
spinner.succeed(`检测到最新版本: ${version}`)
|
|
45
|
-
} catch (e) {
|
|
46
|
-
spinner.fail(`获取版本信息失败: ${e.message}`)
|
|
47
|
-
throw new Error(`获取版本信息失败: ${e.message}`)
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
// 2. 构建下载 URL
|
|
51
|
-
const isWin = platform === 'win32'
|
|
52
|
-
const urlExt = isWin ? 'zip' : 'gz'
|
|
53
|
-
const downloadUrl = `https://github.com/MetaCubeX/mihomo/releases/download/${version}/${name}-${version}.${urlExt}`
|
|
54
|
-
const tempFileName = `mihomo-temp.${urlExt}`
|
|
55
|
-
const tempPath = path.join(targetDir, tempFileName)
|
|
56
|
-
const targetBinName = `clash-kit${isWin ? '.exe' : ''}`
|
|
57
|
-
const targetBinPath = path.join(targetDir, targetBinName)
|
|
58
|
-
|
|
59
|
-
// 3. 下载文件
|
|
60
|
-
console.log(`正在下载: ${downloadUrl}`)
|
|
61
|
-
const bar = new cliProgress.SingleBar(
|
|
62
|
-
{
|
|
63
|
-
format: '下载进度 | {bar} | {percentage}% | {valueFormatted}/{totalFormatted} MB',
|
|
64
|
-
hideCursor: true,
|
|
65
|
-
},
|
|
66
|
-
cliProgress.Presets.shades_classic
|
|
67
|
-
)
|
|
68
|
-
|
|
69
|
-
try {
|
|
70
|
-
let started = false
|
|
71
|
-
let totalMB = '0.0'
|
|
72
|
-
const response = await axios({
|
|
73
|
-
url: downloadUrl,
|
|
74
|
-
method: 'GET',
|
|
75
|
-
responseType: 'arraybuffer',
|
|
76
|
-
onDownloadProgress: progressEvent => {
|
|
77
|
-
if (progressEvent.total) {
|
|
78
|
-
const loadedMB = (progressEvent.loaded / 1024 / 1024).toFixed(1)
|
|
79
|
-
if (!started) {
|
|
80
|
-
totalMB = (progressEvent.total / 1024 / 1024).toFixed(1)
|
|
81
|
-
bar.start(progressEvent.total, 0, {
|
|
82
|
-
valueFormatted: '0.0',
|
|
83
|
-
totalFormatted: totalMB,
|
|
84
|
-
})
|
|
85
|
-
started = true
|
|
86
|
-
}
|
|
87
|
-
bar.update(progressEvent.loaded, {
|
|
88
|
-
valueFormatted: loadedMB,
|
|
89
|
-
totalFormatted: totalMB,
|
|
90
|
-
})
|
|
91
|
-
}
|
|
92
|
-
},
|
|
93
|
-
})
|
|
94
|
-
bar.stop()
|
|
95
|
-
|
|
96
|
-
fs.writeFileSync(tempPath, response.data)
|
|
97
|
-
spinner.start(chalk.green('下载完成,正在解压...'))
|
|
98
|
-
} catch (e) {
|
|
99
|
-
bar.stop()
|
|
100
|
-
throw new Error(`下载失败: ${e.message}`)
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
// 4. 解压文件
|
|
104
|
-
try {
|
|
105
|
-
if (isWin) {
|
|
106
|
-
// ZIP 解压
|
|
107
|
-
const zip = new AdmZip(tempPath)
|
|
108
|
-
const zipEntries = zip.getEntries()
|
|
109
|
-
// 查找可执行文件(通常名字包含 mihoo 或 name)
|
|
110
|
-
const entry = zipEntries.find(e => e.entryName.includes(name) || e.entryName.endsWith('.exe'))
|
|
111
|
-
if (!entry) {
|
|
112
|
-
throw new Error('压缩包中未找到可执行文件')
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
// 1. 解压文件 (使用原文件名)
|
|
116
|
-
zip.extractEntryTo(entry, targetDir, false, true)
|
|
117
|
-
|
|
118
|
-
// 2. 重命名为目标文件名
|
|
119
|
-
const extractedPath = path.join(targetDir, entry.entryName)
|
|
120
|
-
if (targetBinName !== entry.entryName) {
|
|
121
|
-
// 确保目标文件不存在
|
|
122
|
-
if (fs.existsSync(targetBinPath)) {
|
|
123
|
-
fs.unlinkSync(targetBinPath)
|
|
124
|
-
}
|
|
125
|
-
fs.renameSync(extractedPath, targetBinPath)
|
|
126
|
-
}
|
|
127
|
-
} else {
|
|
128
|
-
// GZ 解压
|
|
129
|
-
const fileContents = fs.readFileSync(tempPath)
|
|
130
|
-
const unzipped = zlib.gunzipSync(fileContents)
|
|
131
|
-
fs.writeFileSync(targetBinPath, unzipped)
|
|
132
|
-
}
|
|
133
|
-
} catch (e) {
|
|
134
|
-
spinner.fail(chalk.red(`解压失败: ${e.message}`))
|
|
135
|
-
throw new Error(`解压失败: ${e.message}`)
|
|
136
|
-
} finally {
|
|
137
|
-
// 清理临时文件
|
|
138
|
-
fs.unlinkSync(tempPath)
|
|
139
|
-
spinner.succeed(chalk.green(`解压完成: ${targetBinPath}`))
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
// 5. 设置权限
|
|
143
|
-
if (!isWin) {
|
|
144
|
-
spinner.start('正在设置执行权限...')
|
|
145
|
-
fs.chmodSync(targetBinPath, 0o755)
|
|
146
|
-
spinner.succeed(chalk.green('设置执行权限完成'))
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
return targetBinPath
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
export function
|
|
153
|
-
try {
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
execSync(
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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
|
+
import { execSync } from 'child_process'
|
|
12
|
+
|
|
13
|
+
const __filename = fileURLToPath(import.meta.url)
|
|
14
|
+
const __dirname = path.dirname(__filename)
|
|
15
|
+
|
|
16
|
+
const MIHOMO_VERSION_URL = 'https://github.com/MetaCubeX/mihomo/releases/latest/download/version.txt'
|
|
17
|
+
|
|
18
|
+
const PLATFORM_MAP = {
|
|
19
|
+
'win32-x64': 'mihomo-windows-amd64-compatible',
|
|
20
|
+
'win32-ia32': 'mihomo-windows-386',
|
|
21
|
+
'win32-arm64': 'mihomo-windows-arm64',
|
|
22
|
+
'darwin-x64': 'mihomo-darwin-amd64-compatible',
|
|
23
|
+
'darwin-arm64': 'mihomo-darwin-arm64',
|
|
24
|
+
'linux-x64': 'mihomo-linux-amd64-compatible',
|
|
25
|
+
'linux-arm64': 'mihomo-linux-arm64',
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export async function downloadClash(targetDir) {
|
|
29
|
+
const platform = os.platform()
|
|
30
|
+
const arch = os.arch()
|
|
31
|
+
const key = `${platform}-${arch}`
|
|
32
|
+
const name = PLATFORM_MAP[key]
|
|
33
|
+
|
|
34
|
+
if (!name) {
|
|
35
|
+
throw new Error(`不支持的平台: ${platform}-${arch}`)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// 1. 获取最新版本
|
|
39
|
+
const spinner = ora('正在获取最新 Mihomo 版本信息...').start()
|
|
40
|
+
let version
|
|
41
|
+
try {
|
|
42
|
+
const { data } = await axios.get(MIHOMO_VERSION_URL)
|
|
43
|
+
version = data.trim()
|
|
44
|
+
spinner.succeed(`检测到最新版本: ${version}`)
|
|
45
|
+
} catch (e) {
|
|
46
|
+
spinner.fail(`获取版本信息失败: ${e.message}`)
|
|
47
|
+
throw new Error(`获取版本信息失败: ${e.message}`)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// 2. 构建下载 URL
|
|
51
|
+
const isWin = platform === 'win32'
|
|
52
|
+
const urlExt = isWin ? 'zip' : 'gz'
|
|
53
|
+
const downloadUrl = `https://github.com/MetaCubeX/mihomo/releases/download/${version}/${name}-${version}.${urlExt}`
|
|
54
|
+
const tempFileName = `mihomo-temp.${urlExt}`
|
|
55
|
+
const tempPath = path.join(targetDir, tempFileName)
|
|
56
|
+
const targetBinName = `clash-kit${isWin ? '.exe' : ''}`
|
|
57
|
+
const targetBinPath = path.join(targetDir, targetBinName)
|
|
58
|
+
|
|
59
|
+
// 3. 下载文件
|
|
60
|
+
console.log(`正在下载: ${downloadUrl}`)
|
|
61
|
+
const bar = new cliProgress.SingleBar(
|
|
62
|
+
{
|
|
63
|
+
format: '下载进度 | {bar} | {percentage}% | {valueFormatted}/{totalFormatted} MB',
|
|
64
|
+
hideCursor: true,
|
|
65
|
+
},
|
|
66
|
+
cliProgress.Presets.shades_classic
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
try {
|
|
70
|
+
let started = false
|
|
71
|
+
let totalMB = '0.0'
|
|
72
|
+
const response = await axios({
|
|
73
|
+
url: downloadUrl,
|
|
74
|
+
method: 'GET',
|
|
75
|
+
responseType: 'arraybuffer',
|
|
76
|
+
onDownloadProgress: progressEvent => {
|
|
77
|
+
if (progressEvent.total) {
|
|
78
|
+
const loadedMB = (progressEvent.loaded / 1024 / 1024).toFixed(1)
|
|
79
|
+
if (!started) {
|
|
80
|
+
totalMB = (progressEvent.total / 1024 / 1024).toFixed(1)
|
|
81
|
+
bar.start(progressEvent.total, 0, {
|
|
82
|
+
valueFormatted: '0.0',
|
|
83
|
+
totalFormatted: totalMB,
|
|
84
|
+
})
|
|
85
|
+
started = true
|
|
86
|
+
}
|
|
87
|
+
bar.update(progressEvent.loaded, {
|
|
88
|
+
valueFormatted: loadedMB,
|
|
89
|
+
totalFormatted: totalMB,
|
|
90
|
+
})
|
|
91
|
+
}
|
|
92
|
+
},
|
|
93
|
+
})
|
|
94
|
+
bar.stop()
|
|
95
|
+
|
|
96
|
+
fs.writeFileSync(tempPath, response.data)
|
|
97
|
+
spinner.start(chalk.green('下载完成,正在解压...'))
|
|
98
|
+
} catch (e) {
|
|
99
|
+
bar.stop()
|
|
100
|
+
throw new Error(`下载失败: ${e.message}`)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// 4. 解压文件
|
|
104
|
+
try {
|
|
105
|
+
if (isWin) {
|
|
106
|
+
// ZIP 解压
|
|
107
|
+
const zip = new AdmZip(tempPath)
|
|
108
|
+
const zipEntries = zip.getEntries()
|
|
109
|
+
// 查找可执行文件(通常名字包含 mihoo 或 name)
|
|
110
|
+
const entry = zipEntries.find(e => e.entryName.includes(name) || e.entryName.endsWith('.exe'))
|
|
111
|
+
if (!entry) {
|
|
112
|
+
throw new Error('压缩包中未找到可执行文件')
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// 1. 解压文件 (使用原文件名)
|
|
116
|
+
zip.extractEntryTo(entry, targetDir, false, true)
|
|
117
|
+
|
|
118
|
+
// 2. 重命名为目标文件名
|
|
119
|
+
const extractedPath = path.join(targetDir, entry.entryName)
|
|
120
|
+
if (targetBinName !== entry.entryName) {
|
|
121
|
+
// 确保目标文件不存在
|
|
122
|
+
if (fs.existsSync(targetBinPath)) {
|
|
123
|
+
fs.unlinkSync(targetBinPath)
|
|
124
|
+
}
|
|
125
|
+
fs.renameSync(extractedPath, targetBinPath)
|
|
126
|
+
}
|
|
127
|
+
} else {
|
|
128
|
+
// GZ 解压
|
|
129
|
+
const fileContents = fs.readFileSync(tempPath)
|
|
130
|
+
const unzipped = zlib.gunzipSync(fileContents)
|
|
131
|
+
fs.writeFileSync(targetBinPath, unzipped)
|
|
132
|
+
}
|
|
133
|
+
} catch (e) {
|
|
134
|
+
spinner.fail(chalk.red(`解压失败: ${e.message}`))
|
|
135
|
+
throw new Error(`解压失败: ${e.message}`)
|
|
136
|
+
} finally {
|
|
137
|
+
// 清理临时文件
|
|
138
|
+
fs.unlinkSync(tempPath)
|
|
139
|
+
spinner.succeed(chalk.green(`解压完成: ${targetBinPath}`))
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// 5. 设置权限
|
|
143
|
+
if (!isWin) {
|
|
144
|
+
spinner.start('正在设置执行权限...')
|
|
145
|
+
fs.chmodSync(targetBinPath, 0o755)
|
|
146
|
+
spinner.succeed(chalk.green('设置执行权限完成'))
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return targetBinPath
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export function getClashProcessInfo() {
|
|
153
|
+
try {
|
|
154
|
+
let pid
|
|
155
|
+
if (process.platform === 'win32') {
|
|
156
|
+
const command = "tasklist | findstr clash-kit.exe"
|
|
157
|
+
const output = execSync(command, { encoding: 'utf-8' })
|
|
158
|
+
const match = output.match(/(\d+)/)
|
|
159
|
+
pid = match ? match[0] : null
|
|
160
|
+
} else {
|
|
161
|
+
const command = "pgrep -f clash-kit"
|
|
162
|
+
pid = execSync(command, { encoding: 'utf-8' }).trim()
|
|
163
|
+
}
|
|
164
|
+
return pid ? { pid } : null
|
|
165
|
+
} catch (e) {
|
|
166
|
+
// pgrep/findstr throws an error if no process is found
|
|
167
|
+
return null
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export function killClashProcess() {
|
|
172
|
+
try {
|
|
173
|
+
if (process.platform === 'win32') {
|
|
174
|
+
execSync('taskkill /F /IM clash-kit.exe', { stdio: 'ignore' })
|
|
175
|
+
} else {
|
|
176
|
+
execSync('pkill -f clash-kit', { stdio: 'ignore' })
|
|
177
|
+
}
|
|
178
|
+
return true
|
|
179
|
+
} catch (e) {
|
|
180
|
+
return false
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function testDownload() {
|
|
185
|
+
// 测试下载功能
|
|
186
|
+
const rootDir = path.join(__dirname, '..')
|
|
187
|
+
downloadClash(rootDir)
|
|
188
|
+
.then(binPath => {
|
|
189
|
+
console.log(`Mihomo 内核已下载并解压到: ${binPath}`)
|
|
190
|
+
})
|
|
191
|
+
.catch(err => {
|
|
192
|
+
console.error(`下载失败: ${err.message}`)
|
|
193
|
+
})
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// 如果直接运行此文件,则执行测试
|
|
197
|
+
if (import.meta.url === `file://${process.argv[1]}`) testDownload()
|
package/lib/port.js
CHANGED
|
@@ -1,115 +1,115 @@
|
|
|
1
|
-
import net from 'net'
|
|
2
|
-
import { execSync } from 'child_process'
|
|
3
|
-
import os from 'os'
|
|
4
|
-
|
|
5
|
-
/**
|
|
6
|
-
* 检查端口是否被占用 (true = 空闲, false = 被占用)
|
|
7
|
-
* @param {number} port
|
|
8
|
-
* @returns {Promise<boolean>}
|
|
9
|
-
*/
|
|
10
|
-
export function isPortOpen(port) {
|
|
11
|
-
return new Promise((resolve) => {
|
|
12
|
-
const server = net.createServer()
|
|
13
|
-
server.once('error', (err) => {
|
|
14
|
-
resolve(false) // 端口被占用
|
|
15
|
-
})
|
|
16
|
-
server.once('listening', () => {
|
|
17
|
-
server.close()
|
|
18
|
-
resolve(true) // 端口可用
|
|
19
|
-
})
|
|
20
|
-
server.listen(port, '127.0.0.1')
|
|
21
|
-
})
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
/**
|
|
25
|
-
* 获取占用端口的进程信息
|
|
26
|
-
* @param {number} port
|
|
27
|
-
* @returns {string|null} 进程名称(PID),例如 "mihomo (PID: 1234)"
|
|
28
|
-
*/
|
|
29
|
-
export function getPortOccupier(port) {
|
|
30
|
-
try {
|
|
31
|
-
const platform = os.platform()
|
|
32
|
-
if (platform === 'win32') {
|
|
33
|
-
// Windows 实现
|
|
34
|
-
try {
|
|
35
|
-
const output = execSync(`netstat -ano | findstr :${port}`).toString()
|
|
36
|
-
const lines = output.trim().split('\n')
|
|
37
|
-
if(lines.length > 0) {
|
|
38
|
-
const parts = lines[0].trim().split(/\s+/)
|
|
39
|
-
const pid = parts[parts.length - 1]
|
|
40
|
-
try {
|
|
41
|
-
const tasklist = execSync(`tasklist /fi "pid eq ${pid}" /fo csv /nh`).toString().trim()
|
|
42
|
-
// "Image Name","PID","Session Name","Session#","Mem Usage"
|
|
43
|
-
// "node.exe","24424","Console","1","38,824 K"
|
|
44
|
-
const match = tasklist.match(/^"([^"]+)"/);
|
|
45
|
-
if (match) return `${match[1]} (PID: ${pid})`
|
|
46
|
-
} catch (e) {}
|
|
47
|
-
return `PID: ${pid}`
|
|
48
|
-
}
|
|
49
|
-
} catch (e) { return null }
|
|
50
|
-
} else {
|
|
51
|
-
// macOS / Linux
|
|
52
|
-
// lsof output format: COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME
|
|
53
|
-
const output = execSync(`lsof -i :${port} -sTCP:LISTEN -P -n`).toString().trim()
|
|
54
|
-
const lines = output.split('\n')
|
|
55
|
-
if (lines.length > 1) {
|
|
56
|
-
const parts = lines[1].trim().split(/\s+/)
|
|
57
|
-
return `${parts[0]} (PID: ${parts[1]})`
|
|
58
|
-
}
|
|
59
|
-
}
|
|
60
|
-
} catch (e) {
|
|
61
|
-
// lsof 找不到时会返回非 0 退出码,抛出错误
|
|
62
|
-
return null
|
|
63
|
-
}
|
|
64
|
-
return null
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
/**
|
|
68
|
-
* 寻找可用端口
|
|
69
|
-
* @param {number} startPort
|
|
70
|
-
* @returns {Promise<number>}
|
|
71
|
-
*/
|
|
72
|
-
export function findAvailablePort(startPort) {
|
|
73
|
-
return new Promise((resolve, reject) => {
|
|
74
|
-
const server = net.createServer()
|
|
75
|
-
server.on('error', (err) => {
|
|
76
|
-
if (startPort <= 65535) {
|
|
77
|
-
// 核心逻辑:如果报错,递归尝试 +1 的端口
|
|
78
|
-
resolve(findAvailablePort(startPort + 1))
|
|
79
|
-
} else {
|
|
80
|
-
reject(err)
|
|
81
|
-
}
|
|
82
|
-
})
|
|
83
|
-
server.on('listening', () => {
|
|
84
|
-
server.close(() => {
|
|
85
|
-
resolve(startPort)
|
|
86
|
-
})
|
|
87
|
-
})
|
|
88
|
-
server.listen(startPort, '127.0.0.1')
|
|
89
|
-
})
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
/**
|
|
93
|
-
* 从配置值中提取端口号
|
|
94
|
-
* @param {string|number} val - 例如 9090, ":9090", "127.0.0.1:9090"
|
|
95
|
-
* @returns {number|null}
|
|
96
|
-
*/
|
|
97
|
-
export function extractPort(val) {
|
|
98
|
-
if (!val) return null
|
|
99
|
-
if (typeof val === 'number') return val
|
|
100
|
-
if (typeof val === 'string') {
|
|
101
|
-
// 移除空白
|
|
102
|
-
val = val.trim()
|
|
103
|
-
// 如果是纯数字字符串
|
|
104
|
-
if (/^\d+$/.test(val)) return parseInt(val, 10)
|
|
105
|
-
|
|
106
|
-
// 如果包含冒号,取最后一部分
|
|
107
|
-
if (val.includes(':')) {
|
|
108
|
-
const parts = val.split(':')
|
|
109
|
-
const lastPart = parts[parts.length - 1]
|
|
110
|
-
const port = parseInt(lastPart, 10)
|
|
111
|
-
return isNaN(port) ? null : port
|
|
112
|
-
}
|
|
113
|
-
}
|
|
114
|
-
return null
|
|
115
|
-
}
|
|
1
|
+
import net from 'net'
|
|
2
|
+
import { execSync } from 'child_process'
|
|
3
|
+
import os from 'os'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* 检查端口是否被占用 (true = 空闲, false = 被占用)
|
|
7
|
+
* @param {number} port
|
|
8
|
+
* @returns {Promise<boolean>}
|
|
9
|
+
*/
|
|
10
|
+
export function isPortOpen(port) {
|
|
11
|
+
return new Promise((resolve) => {
|
|
12
|
+
const server = net.createServer()
|
|
13
|
+
server.once('error', (err) => {
|
|
14
|
+
resolve(false) // 端口被占用
|
|
15
|
+
})
|
|
16
|
+
server.once('listening', () => {
|
|
17
|
+
server.close()
|
|
18
|
+
resolve(true) // 端口可用
|
|
19
|
+
})
|
|
20
|
+
server.listen(port, '127.0.0.1')
|
|
21
|
+
})
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* 获取占用端口的进程信息
|
|
26
|
+
* @param {number} port
|
|
27
|
+
* @returns {string|null} 进程名称(PID),例如 "mihomo (PID: 1234)"
|
|
28
|
+
*/
|
|
29
|
+
export function getPortOccupier(port) {
|
|
30
|
+
try {
|
|
31
|
+
const platform = os.platform()
|
|
32
|
+
if (platform === 'win32') {
|
|
33
|
+
// Windows 实现
|
|
34
|
+
try {
|
|
35
|
+
const output = execSync(`netstat -ano | findstr :${port}`).toString()
|
|
36
|
+
const lines = output.trim().split('\n')
|
|
37
|
+
if(lines.length > 0) {
|
|
38
|
+
const parts = lines[0].trim().split(/\s+/)
|
|
39
|
+
const pid = parts[parts.length - 1]
|
|
40
|
+
try {
|
|
41
|
+
const tasklist = execSync(`tasklist /fi "pid eq ${pid}" /fo csv /nh`).toString().trim()
|
|
42
|
+
// "Image Name","PID","Session Name","Session#","Mem Usage"
|
|
43
|
+
// "node.exe","24424","Console","1","38,824 K"
|
|
44
|
+
const match = tasklist.match(/^"([^"]+)"/);
|
|
45
|
+
if (match) return `${match[1]} (PID: ${pid})`
|
|
46
|
+
} catch (e) {}
|
|
47
|
+
return `PID: ${pid}`
|
|
48
|
+
}
|
|
49
|
+
} catch (e) { return null }
|
|
50
|
+
} else {
|
|
51
|
+
// macOS / Linux
|
|
52
|
+
// lsof output format: COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME
|
|
53
|
+
const output = execSync(`lsof -i :${port} -sTCP:LISTEN -P -n`).toString().trim()
|
|
54
|
+
const lines = output.split('\n')
|
|
55
|
+
if (lines.length > 1) {
|
|
56
|
+
const parts = lines[1].trim().split(/\s+/)
|
|
57
|
+
return `${parts[0]} (PID: ${parts[1]})`
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
} catch (e) {
|
|
61
|
+
// lsof 找不到时会返回非 0 退出码,抛出错误
|
|
62
|
+
return null
|
|
63
|
+
}
|
|
64
|
+
return null
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* 寻找可用端口
|
|
69
|
+
* @param {number} startPort
|
|
70
|
+
* @returns {Promise<number>}
|
|
71
|
+
*/
|
|
72
|
+
export function findAvailablePort(startPort) {
|
|
73
|
+
return new Promise((resolve, reject) => {
|
|
74
|
+
const server = net.createServer()
|
|
75
|
+
server.on('error', (err) => {
|
|
76
|
+
if (startPort <= 65535) {
|
|
77
|
+
// 核心逻辑:如果报错,递归尝试 +1 的端口
|
|
78
|
+
resolve(findAvailablePort(startPort + 1))
|
|
79
|
+
} else {
|
|
80
|
+
reject(err)
|
|
81
|
+
}
|
|
82
|
+
})
|
|
83
|
+
server.on('listening', () => {
|
|
84
|
+
server.close(() => {
|
|
85
|
+
resolve(startPort)
|
|
86
|
+
})
|
|
87
|
+
})
|
|
88
|
+
server.listen(startPort, '127.0.0.1')
|
|
89
|
+
})
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* 从配置值中提取端口号
|
|
94
|
+
* @param {string|number} val - 例如 9090, ":9090", "127.0.0.1:9090"
|
|
95
|
+
* @returns {number|null}
|
|
96
|
+
*/
|
|
97
|
+
export function extractPort(val) {
|
|
98
|
+
if (!val) return null
|
|
99
|
+
if (typeof val === 'number') return val
|
|
100
|
+
if (typeof val === 'string') {
|
|
101
|
+
// 移除空白
|
|
102
|
+
val = val.trim()
|
|
103
|
+
// 如果是纯数字字符串
|
|
104
|
+
if (/^\d+$/.test(val)) return parseInt(val, 10)
|
|
105
|
+
|
|
106
|
+
// 如果包含冒号,取最后一部分
|
|
107
|
+
if (val.includes(':')) {
|
|
108
|
+
const parts = val.split(':')
|
|
109
|
+
const lastPart = parts[parts.length - 1]
|
|
110
|
+
const port = parseInt(lastPart, 10)
|
|
111
|
+
return isNaN(port) ? null : port
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return null
|
|
115
|
+
}
|