mirror of
https://gh.catmak.name/https://github.com/mihomo-party-org/mihomo-party
synced 2025-12-26 20:50:30 +08:00
1012 lines
32 KiB
TypeScript
1012 lines
32 KiB
TypeScript
import { ChildProcess, exec, execFile, spawn } from 'child_process'
|
|
import {
|
|
dataDir,
|
|
coreLogPath,
|
|
mihomoCoreDir,
|
|
mihomoCorePath,
|
|
mihomoProfileWorkDir,
|
|
mihomoTestDir,
|
|
mihomoWorkConfigPath,
|
|
mihomoWorkDir
|
|
} from '../utils/dirs'
|
|
import { generateProfile } from './factory'
|
|
import {
|
|
getAppConfig,
|
|
getControledMihomoConfig,
|
|
getProfileConfig,
|
|
patchAppConfig,
|
|
patchControledMihomoConfig,
|
|
manageSmartOverride
|
|
} from '../config'
|
|
import { app, ipcMain, net } from 'electron'
|
|
import {
|
|
startMihomoTraffic,
|
|
startMihomoConnections,
|
|
startMihomoLogs,
|
|
startMihomoMemory,
|
|
stopMihomoConnections,
|
|
stopMihomoTraffic,
|
|
stopMihomoLogs,
|
|
stopMihomoMemory,
|
|
patchMihomoConfig,
|
|
getAxios
|
|
} from './mihomoApi'
|
|
import chokidar from 'chokidar'
|
|
import { readFile, rm, writeFile } from 'fs/promises'
|
|
import { promisify } from 'util'
|
|
import { mainWindow } from '..'
|
|
import path from 'path'
|
|
import os from 'os'
|
|
import { createWriteStream, existsSync } from 'fs'
|
|
import { uploadRuntimeConfig } from '../resolve/gistApi'
|
|
import { startMonitor } from '../resolve/trafficMonitor'
|
|
import { safeShowErrorBox } from '../utils/init'
|
|
import i18next from '../../shared/i18n'
|
|
import { managerLogger } from '../utils/logger'
|
|
|
|
// 内核名称白名单
|
|
const ALLOWED_CORES = ['mihomo', 'mihomo-alpha', 'mihomo-smart'] as const
|
|
type AllowedCore = (typeof ALLOWED_CORES)[number]
|
|
|
|
function isValidCoreName(core: string): core is AllowedCore {
|
|
return ALLOWED_CORES.includes(core as AllowedCore)
|
|
}
|
|
|
|
// 路径检查
|
|
function validateCorePath(corePath: string): void {
|
|
if (corePath.includes('..')) {
|
|
throw new Error('Invalid core path: directory traversal detected')
|
|
}
|
|
|
|
const dangerousChars = /[;&|`$(){}[\]<>'"\\]/
|
|
if (dangerousChars.test(path.basename(corePath))) {
|
|
throw new Error('Invalid core path: contains dangerous characters')
|
|
}
|
|
|
|
const normalizedPath = path.normalize(path.resolve(corePath))
|
|
const expectedDir = path.normalize(path.resolve(mihomoCoreDir()))
|
|
|
|
if (!normalizedPath.startsWith(expectedDir + path.sep) && normalizedPath !== expectedDir) {
|
|
throw new Error('Invalid core path: not in expected directory')
|
|
}
|
|
}
|
|
|
|
function shellEscape(arg: string): string {
|
|
return "'" + arg.replace(/'/g, "'\\''") + "'"
|
|
}
|
|
|
|
chokidar.watch(path.join(mihomoCoreDir(), 'meta-update'), {}).on('unlinkDir', async () => {
|
|
try {
|
|
await stopCore(true)
|
|
await startCore()
|
|
} catch (e) {
|
|
safeShowErrorBox('mihomo.error.coreStartFailed', `${e}`)
|
|
}
|
|
})
|
|
|
|
// 动态生成 IPC 路径
|
|
export const getMihomoIpcPath = (): string => {
|
|
if (process.platform === 'win32') {
|
|
const isAdmin = getSessionAdminStatus()
|
|
const sessionId = process.env.SESSIONNAME || process.env.USERNAME || 'default'
|
|
const processId = process.pid
|
|
|
|
if (isAdmin) {
|
|
return `\\\\.\\pipe\\MihomoParty\\mihomo-admin-${sessionId}-${processId}`
|
|
} else {
|
|
return `\\\\.\\pipe\\MihomoParty\\mihomo-user-${sessionId}-${processId}`
|
|
}
|
|
}
|
|
|
|
const uid = process.getuid?.() || 'unknown'
|
|
const processId = process.pid
|
|
|
|
return `/tmp/mihomo-party-${uid}-${processId}.sock`
|
|
}
|
|
|
|
const ctlParam = process.platform === 'win32' ? '-ext-ctl-pipe' : '-ext-ctl-unix'
|
|
|
|
let setPublicDNSTimer: NodeJS.Timeout | null = null
|
|
let recoverDNSTimer: NodeJS.Timeout | null = null
|
|
let child: ChildProcess
|
|
let retry = 10
|
|
let isRestarting = false
|
|
|
|
export async function startCore(detached = false): Promise<Promise<void>[]> {
|
|
const {
|
|
core = 'mihomo',
|
|
autoSetDNS = true,
|
|
diffWorkDir = false,
|
|
mihomoCpuPriority = 'PRIORITY_NORMAL'
|
|
} = await getAppConfig()
|
|
const { 'log-level': logLevel } = await getControledMihomoConfig()
|
|
if (existsSync(path.join(dataDir(), 'core.pid'))) {
|
|
const pid = parseInt(await readFile(path.join(dataDir(), 'core.pid'), 'utf-8'))
|
|
try {
|
|
process.kill(pid, 'SIGINT')
|
|
} catch {
|
|
// ignore
|
|
} finally {
|
|
await rm(path.join(dataDir(), 'core.pid'))
|
|
}
|
|
}
|
|
const { current } = await getProfileConfig(true)
|
|
const { tun } = await getControledMihomoConfig()
|
|
const corePath = mihomoCorePath(core)
|
|
|
|
// 管理 Smart 内核覆写配置
|
|
await manageSmartOverride()
|
|
|
|
await generateProfile()
|
|
await checkProfile()
|
|
await stopCore()
|
|
|
|
await cleanupSocketFile()
|
|
|
|
if (tun?.enable && autoSetDNS) {
|
|
try {
|
|
await setPublicDNS()
|
|
} catch (error) {
|
|
await managerLogger.error('set dns failed', error)
|
|
}
|
|
}
|
|
|
|
// 获取动态 IPC 路径
|
|
const dynamicIpcPath = getMihomoIpcPath()
|
|
await managerLogger.info(`Using IPC path: ${dynamicIpcPath}`)
|
|
|
|
if (process.platform === 'win32') {
|
|
await validateWindowsPipeAccess(dynamicIpcPath)
|
|
}
|
|
|
|
// 内核日志输出到独立的 core-日期.log 文件
|
|
const stdout = createWriteStream(coreLogPath(), { flags: 'a' })
|
|
const stderr = createWriteStream(coreLogPath(), { flags: 'a' })
|
|
|
|
child = spawn(
|
|
corePath,
|
|
['-d', diffWorkDir ? mihomoProfileWorkDir(current) : mihomoWorkDir(), ctlParam, dynamicIpcPath],
|
|
{
|
|
detached: detached,
|
|
stdio: detached ? 'ignore' : undefined
|
|
}
|
|
)
|
|
if (process.platform === 'win32' && child.pid) {
|
|
os.setPriority(child.pid, os.constants.priority[mihomoCpuPriority])
|
|
}
|
|
if (detached) {
|
|
await managerLogger.info(
|
|
`Core process detached successfully on ${process.platform}, PID: ${child.pid}`
|
|
)
|
|
child.unref()
|
|
return new Promise((resolve) => {
|
|
resolve([new Promise(() => {})])
|
|
})
|
|
}
|
|
child.on('close', async (code, signal) => {
|
|
await managerLogger.info(`Core closed, code: ${code}, signal: ${signal}`)
|
|
|
|
if (isRestarting) {
|
|
await managerLogger.info('Core closed during restart, skipping auto-restart')
|
|
return
|
|
}
|
|
|
|
if (retry) {
|
|
await managerLogger.info('Try Restart Core')
|
|
retry--
|
|
await restartCore()
|
|
} else {
|
|
await stopCore()
|
|
}
|
|
})
|
|
child.stdout?.pipe(stdout)
|
|
child.stderr?.pipe(stderr)
|
|
return new Promise((resolve, reject) => {
|
|
child.stdout?.on('data', async (data) => {
|
|
const str = data.toString()
|
|
if (str.includes('configure tun interface: operation not permitted')) {
|
|
patchControledMihomoConfig({ tun: { enable: false } })
|
|
mainWindow?.webContents.send('controledMihomoConfigUpdated')
|
|
ipcMain.emit('updateTrayMenu')
|
|
reject(i18next.t('tun.error.tunPermissionDenied'))
|
|
}
|
|
|
|
if (
|
|
(process.platform !== 'win32' && str.includes('External controller unix listen error')) ||
|
|
(process.platform === 'win32' && str.includes('External controller pipe listen error'))
|
|
) {
|
|
await managerLogger.error('External controller listen error detected:', str)
|
|
|
|
if (process.platform === 'win32') {
|
|
await managerLogger.info('Attempting Windows pipe cleanup and retry...')
|
|
try {
|
|
await cleanupWindowsNamedPipes()
|
|
await new Promise((resolve) => setTimeout(resolve, 2000))
|
|
} catch (cleanupError) {
|
|
await managerLogger.error('Pipe cleanup failed:', cleanupError)
|
|
}
|
|
}
|
|
|
|
reject(i18next.t('mihomo.error.externalControllerListenError'))
|
|
}
|
|
|
|
if (
|
|
(process.platform !== 'win32' && str.includes('RESTful API unix listening at')) ||
|
|
(process.platform === 'win32' && str.includes('RESTful API pipe listening at'))
|
|
) {
|
|
resolve([
|
|
new Promise((resolve) => {
|
|
child.stdout?.on('data', async (data) => {
|
|
if (
|
|
data.toString().toLowerCase().includes('start initial compatible provider default')
|
|
) {
|
|
try {
|
|
mainWindow?.webContents.send('groupsUpdated')
|
|
mainWindow?.webContents.send('rulesUpdated')
|
|
await uploadRuntimeConfig()
|
|
} catch {
|
|
// ignore
|
|
}
|
|
await patchMihomoConfig({ 'log-level': logLevel })
|
|
resolve()
|
|
}
|
|
})
|
|
})
|
|
])
|
|
|
|
await waitForCoreReady()
|
|
|
|
// 强制刷新 axios 实例以使用新的管道路径
|
|
await getAxios(true)
|
|
await startMihomoTraffic()
|
|
await startMihomoConnections()
|
|
await startMihomoLogs()
|
|
await startMihomoMemory()
|
|
retry = 10
|
|
}
|
|
})
|
|
})
|
|
}
|
|
|
|
export async function stopCore(force = false): Promise<void> {
|
|
try {
|
|
if (!force) {
|
|
await recoverDNS()
|
|
}
|
|
} catch (error) {
|
|
await managerLogger.error('recover dns failed', error)
|
|
}
|
|
|
|
if (child) {
|
|
child.removeAllListeners()
|
|
child.kill('SIGINT')
|
|
}
|
|
stopMihomoTraffic()
|
|
stopMihomoConnections()
|
|
stopMihomoLogs()
|
|
stopMihomoMemory()
|
|
|
|
// 强制刷新 axios
|
|
try {
|
|
await getAxios(true)
|
|
} catch (error) {
|
|
await managerLogger.warn('Failed to refresh axios instance:', error)
|
|
}
|
|
|
|
// 清理 Socket 文件
|
|
await cleanupSocketFile()
|
|
}
|
|
async function cleanupSocketFile(): Promise<void> {
|
|
if (process.platform === 'win32') {
|
|
await cleanupWindowsNamedPipes()
|
|
} else {
|
|
await cleanupUnixSockets()
|
|
}
|
|
}
|
|
|
|
// Windows 命名管道清理
|
|
async function cleanupWindowsNamedPipes(): Promise<void> {
|
|
try {
|
|
const execPromise = promisify(exec)
|
|
|
|
try {
|
|
const { stdout } = await execPromise(
|
|
`powershell -NoProfile -Command "[Console]::OutputEncoding = [System.Text.Encoding]::UTF8; Get-Process | Where-Object {$_.ProcessName -like '*mihomo*'} | Select-Object Id,ProcessName | ConvertTo-Json"`,
|
|
{ encoding: 'utf8' }
|
|
)
|
|
|
|
if (stdout.trim()) {
|
|
await managerLogger.info(`Found potential pipe-blocking processes: ${stdout}`)
|
|
|
|
try {
|
|
const processes = JSON.parse(stdout)
|
|
const processArray = Array.isArray(processes) ? processes : [processes]
|
|
|
|
for (const proc of processArray) {
|
|
const pid = proc.Id
|
|
if (pid && pid !== process.pid) {
|
|
try {
|
|
// 先检查进程是否存在
|
|
process.kill(pid, 0)
|
|
process.kill(pid, 'SIGTERM')
|
|
await managerLogger.info(`Terminated process ${pid} to free pipe`)
|
|
} catch (error: unknown) {
|
|
if ((error as { code?: string })?.code !== 'ESRCH') {
|
|
await managerLogger.warn(`Failed to terminate process ${pid}:`, error)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} catch (parseError) {
|
|
await managerLogger.warn('Failed to parse process list JSON:', parseError)
|
|
|
|
// 回退到文本解析
|
|
const lines = stdout.split('\n').filter((line) => line.includes('mihomo'))
|
|
for (const line of lines) {
|
|
const match = line.match(/(\d+)/)
|
|
if (match) {
|
|
const pid = parseInt(match[1])
|
|
if (pid !== process.pid) {
|
|
try {
|
|
process.kill(pid, 0)
|
|
process.kill(pid, 'SIGTERM')
|
|
await managerLogger.info(`Terminated process ${pid} to free pipe`)
|
|
} catch (error: unknown) {
|
|
if ((error as { code?: string })?.code !== 'ESRCH') {
|
|
await managerLogger.warn(`Failed to terminate process ${pid}:`, error)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} catch (error) {
|
|
await managerLogger.warn('Failed to check mihomo processes:', error)
|
|
}
|
|
|
|
await new Promise((resolve) => setTimeout(resolve, 1000))
|
|
} catch (error) {
|
|
await managerLogger.error('Windows named pipe cleanup failed:', error)
|
|
}
|
|
}
|
|
|
|
// Unix Socket 清理
|
|
async function cleanupUnixSockets(): Promise<void> {
|
|
try {
|
|
const socketPaths = [
|
|
'/tmp/mihomo-party.sock',
|
|
'/tmp/mihomo-party-admin.sock',
|
|
`/tmp/mihomo-party-${process.getuid?.() || 'user'}.sock`
|
|
]
|
|
|
|
for (const socketPath of socketPaths) {
|
|
try {
|
|
if (existsSync(socketPath)) {
|
|
await rm(socketPath)
|
|
await managerLogger.info(`Cleaned up socket file: ${socketPath}`)
|
|
}
|
|
} catch (error) {
|
|
await managerLogger.warn(`Failed to cleanup socket file ${socketPath}:`, error)
|
|
}
|
|
}
|
|
} catch (error) {
|
|
await managerLogger.error('Unix socket cleanup failed:', error)
|
|
}
|
|
}
|
|
|
|
// Windows 命名管道访问验证
|
|
async function validateWindowsPipeAccess(pipePath: string): Promise<void> {
|
|
try {
|
|
await managerLogger.info(`Validating pipe access for: ${pipePath}`)
|
|
await managerLogger.info(`Pipe validation completed for: ${pipePath}`)
|
|
} catch (error) {
|
|
await managerLogger.error('Windows pipe validation failed:', error)
|
|
}
|
|
}
|
|
|
|
export async function restartCore(): Promise<void> {
|
|
// 防止并发重启
|
|
if (isRestarting) {
|
|
await managerLogger.info('Core restart already in progress, skipping duplicate request')
|
|
return
|
|
}
|
|
|
|
isRestarting = true
|
|
try {
|
|
await startCore()
|
|
} catch (e) {
|
|
await managerLogger.error('restart core failed', e)
|
|
throw e
|
|
} finally {
|
|
isRestarting = false
|
|
}
|
|
}
|
|
|
|
export async function keepCoreAlive(): Promise<void> {
|
|
try {
|
|
await startCore(true)
|
|
if (child && child.pid) {
|
|
await writeFile(path.join(dataDir(), 'core.pid'), child.pid.toString())
|
|
}
|
|
} catch (e) {
|
|
safeShowErrorBox('mihomo.error.coreStartFailed', `${e}`)
|
|
}
|
|
}
|
|
|
|
export async function quitWithoutCore(): Promise<void> {
|
|
await managerLogger.info(`Starting lightweight mode on platform: ${process.platform}`)
|
|
|
|
try {
|
|
await startCore(true)
|
|
if (child && child.pid) {
|
|
await writeFile(path.join(dataDir(), 'core.pid'), child.pid.toString())
|
|
await managerLogger.info(`Core started in lightweight mode with PID: ${child.pid}`)
|
|
}
|
|
} catch (e) {
|
|
await managerLogger.error('Failed to start core in lightweight mode:', e)
|
|
safeShowErrorBox('mihomo.error.coreStartFailed', `${e}`)
|
|
}
|
|
|
|
await startMonitor(true)
|
|
await managerLogger.info('Exiting main process, core will continue running in background')
|
|
app.exit()
|
|
}
|
|
|
|
async function checkProfile(): Promise<void> {
|
|
const { core = 'mihomo', diffWorkDir = false } = await getAppConfig()
|
|
const { current } = await getProfileConfig()
|
|
const corePath = mihomoCorePath(core)
|
|
const execFilePromise = promisify(execFile)
|
|
|
|
try {
|
|
await execFilePromise(corePath, [
|
|
'-t',
|
|
'-f',
|
|
diffWorkDir ? mihomoWorkConfigPath(current) : mihomoWorkConfigPath('work'),
|
|
'-d',
|
|
mihomoTestDir()
|
|
])
|
|
} catch (error) {
|
|
await managerLogger.error('Profile check failed', error)
|
|
|
|
if (error instanceof Error && 'stdout' in error) {
|
|
const { stdout, stderr } = error as { stdout: string; stderr?: string }
|
|
await managerLogger.info('Profile check stdout', stdout)
|
|
await managerLogger.info('Profile check stderr', stderr)
|
|
|
|
const errorLines = stdout
|
|
.split('\n')
|
|
.filter((line) => line.includes('level=error') || line.includes('error'))
|
|
.map((line) => {
|
|
if (line.includes('level=error')) {
|
|
return line.split('level=error')[1]?.trim() || line
|
|
}
|
|
return line.trim()
|
|
})
|
|
.filter((line) => line.length > 0)
|
|
|
|
if (errorLines.length === 0) {
|
|
const allLines = stdout.split('\n').filter((line) => line.trim().length > 0)
|
|
throw new Error(`${i18next.t('mihomo.error.profileCheckFailed')}:\n${allLines.join('\n')}`)
|
|
} else {
|
|
throw new Error(
|
|
`${i18next.t('mihomo.error.profileCheckFailed')}:\n${errorLines.join('\n')}`
|
|
)
|
|
}
|
|
} else {
|
|
throw new Error(`${i18next.t('mihomo.error.profileCheckFailed')}: ${error}`)
|
|
}
|
|
}
|
|
}
|
|
|
|
export async function checkTunPermissions(): Promise<boolean> {
|
|
return checkMihomoCorePermissions()
|
|
}
|
|
|
|
export async function grantTunPermissions(): Promise<void> {
|
|
const { core = 'mihomo' } = await getAppConfig()
|
|
|
|
// 验证内核名称
|
|
if (!isValidCoreName(core)) {
|
|
throw new Error(`Invalid core name: ${core}. Allowed values: ${ALLOWED_CORES.join(', ')}`)
|
|
}
|
|
|
|
const corePath = mihomoCorePath(core)
|
|
|
|
// 验证路径
|
|
validateCorePath(corePath)
|
|
|
|
const execFilePromise = promisify(execFile)
|
|
|
|
if (process.platform === 'darwin') {
|
|
const escapedPath = shellEscape(corePath)
|
|
const script = `do shell script "chown root:admin ${escapedPath} && chmod +sx ${escapedPath}" with administrator privileges`
|
|
await execFilePromise('osascript', ['-e', script])
|
|
}
|
|
|
|
if (process.platform === 'linux') {
|
|
await execFilePromise('pkexec', ['chown', 'root:root', corePath])
|
|
await execFilePromise('pkexec', ['chmod', '+sx', corePath])
|
|
}
|
|
|
|
if (process.platform === 'win32') {
|
|
throw new Error('Windows platform requires running as administrator')
|
|
}
|
|
}
|
|
|
|
// 在应用启动时检测一次权限
|
|
let sessionAdminStatus: boolean | null = null
|
|
|
|
export async function initAdminStatus(): Promise<void> {
|
|
if (process.platform === 'win32' && sessionAdminStatus === null) {
|
|
sessionAdminStatus = await checkAdminPrivileges().catch(() => false)
|
|
}
|
|
}
|
|
|
|
export function getSessionAdminStatus(): boolean {
|
|
if (process.platform !== 'win32') {
|
|
return true
|
|
}
|
|
return sessionAdminStatus ?? false
|
|
}
|
|
|
|
// 等待内核完全启动并创建管道
|
|
async function waitForCoreReady(): Promise<void> {
|
|
const maxRetries = 30
|
|
const retryInterval = 500
|
|
|
|
for (let i = 0; i < maxRetries; i++) {
|
|
try {
|
|
const axios = await getAxios(true)
|
|
await axios.get('/')
|
|
await managerLogger.info(`Core ready after ${i + 1} attempts (${(i + 1) * retryInterval}ms)`)
|
|
return
|
|
} catch (error) {
|
|
if (i === 0) {
|
|
await managerLogger.info('Waiting for core to be ready...')
|
|
}
|
|
|
|
if (i === maxRetries - 1) {
|
|
await managerLogger.warn(`Core not ready after ${maxRetries} attempts, proceeding anyway`)
|
|
return
|
|
}
|
|
|
|
await new Promise((resolve) => setTimeout(resolve, retryInterval))
|
|
}
|
|
}
|
|
}
|
|
|
|
export async function checkAdminPrivileges(): Promise<boolean> {
|
|
if (process.platform !== 'win32') {
|
|
return true
|
|
}
|
|
|
|
const execPromise = promisify(exec)
|
|
|
|
try {
|
|
// fltmc 检测管理员权限
|
|
await execPromise('chcp 65001 >nul 2>&1 && fltmc', { encoding: 'utf8' })
|
|
await managerLogger.info('Admin privileges confirmed via fltmc')
|
|
return true
|
|
} catch (fltmcError: unknown) {
|
|
const errorCode = (fltmcError as { code?: number })?.code || 0
|
|
await managerLogger.debug(`fltmc failed with code ${errorCode}, trying net session as fallback`)
|
|
|
|
try {
|
|
// net session 备用
|
|
await execPromise('chcp 65001 >nul 2>&1 && net session', { encoding: 'utf8' })
|
|
await managerLogger.info('Admin privileges confirmed via net session')
|
|
return true
|
|
} catch (netSessionError: unknown) {
|
|
const netErrorCode = (netSessionError as { code?: number })?.code || 0
|
|
await managerLogger.debug(
|
|
`Both fltmc and net session failed, no admin privileges. Error codes: fltmc=${errorCode}, net=${netErrorCode}`
|
|
)
|
|
return false
|
|
}
|
|
}
|
|
}
|
|
|
|
// TUN 权限确认框
|
|
export async function showTunPermissionDialog(): Promise<boolean> {
|
|
const { dialog } = await import('electron')
|
|
const i18next = await import('i18next')
|
|
|
|
await managerLogger.info('Preparing TUN permission dialog...')
|
|
await managerLogger.info(`i18next available: ${typeof i18next.t === 'function'}`)
|
|
|
|
const title = i18next.t('tun.permissions.title') || '需要管理员权限'
|
|
const message =
|
|
i18next.t('tun.permissions.message') ||
|
|
'启用 TUN 模式需要管理员权限,是否现在重启应用获取权限?'
|
|
const confirmText = i18next.t('common.confirm') || '确认'
|
|
const cancelText = i18next.t('common.cancel') || '取消'
|
|
|
|
await managerLogger.info(
|
|
`Dialog texts - Title: "${title}", Message: "${message}", Confirm: "${confirmText}", Cancel: "${cancelText}"`
|
|
)
|
|
|
|
const choice = dialog.showMessageBoxSync({
|
|
type: 'warning',
|
|
title: title,
|
|
message: message,
|
|
buttons: [confirmText, cancelText],
|
|
defaultId: 0,
|
|
cancelId: 1
|
|
})
|
|
|
|
await managerLogger.info(`TUN permission dialog choice: ${choice}`)
|
|
|
|
return choice === 0
|
|
}
|
|
|
|
// 错误显示框
|
|
export async function showErrorDialog(title: string, message: string): Promise<void> {
|
|
const { dialog } = await import('electron')
|
|
const i18next = await import('i18next')
|
|
|
|
const okText = i18next.t('common.confirm') || '确认'
|
|
|
|
dialog.showMessageBoxSync({
|
|
type: 'error',
|
|
title: title,
|
|
message: message,
|
|
buttons: [okText],
|
|
defaultId: 0
|
|
})
|
|
}
|
|
|
|
export async function restartAsAdmin(forTun: boolean = true): Promise<void> {
|
|
if (process.platform !== 'win32') {
|
|
throw new Error('This function is only available on Windows')
|
|
}
|
|
|
|
const exePath = process.execPath
|
|
const args = process.argv.slice(1)
|
|
const restartArgs = forTun ? [...args, '--admin-restart-for-tun'] : args
|
|
|
|
try {
|
|
// 处理路径和参数的引号
|
|
const escapedExePath = exePath.replace(/'/g, "''")
|
|
const argsString = restartArgs.map((arg) => arg.replace(/'/g, "''")).join("', '")
|
|
|
|
let command: string
|
|
if (restartArgs.length > 0) {
|
|
command = `powershell -NoProfile -Command "Start-Process -FilePath '${escapedExePath}' -ArgumentList '${argsString}' -Verb RunAs"`
|
|
} else {
|
|
command = `powershell -NoProfile -Command "Start-Process -FilePath '${escapedExePath}' -Verb RunAs"`
|
|
}
|
|
|
|
await managerLogger.info('Restarting as administrator with command', command)
|
|
|
|
// 执行 PowerShell 命令
|
|
exec(command, { windowsHide: true }, async (error, _stdout, stderr) => {
|
|
if (error) {
|
|
await managerLogger.error('PowerShell execution error', error)
|
|
await managerLogger.error('stderr', stderr)
|
|
} else {
|
|
await managerLogger.info('PowerShell command executed successfully')
|
|
}
|
|
})
|
|
|
|
const { app } = await import('electron')
|
|
app.quit()
|
|
} catch (error) {
|
|
await managerLogger.error('Failed to restart as administrator', error)
|
|
throw new Error(`Failed to restart as administrator: ${error}`)
|
|
}
|
|
}
|
|
|
|
export async function checkMihomoCorePermissions(): Promise<boolean> {
|
|
const { core = 'mihomo' } = await getAppConfig()
|
|
const corePath = mihomoCorePath(core)
|
|
|
|
try {
|
|
if (process.platform === 'win32') {
|
|
// Windows 权限检查
|
|
return await checkAdminPrivileges()
|
|
}
|
|
|
|
if (process.platform === 'darwin' || process.platform === 'linux') {
|
|
const { stat } = await import('fs/promises')
|
|
const stats = await stat(corePath)
|
|
return (stats.mode & 0o4000) !== 0 && stats.uid === 0
|
|
}
|
|
} catch {
|
|
return false
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
// 检测高权限内核
|
|
export async function checkHighPrivilegeCore(): Promise<boolean> {
|
|
try {
|
|
const { core = 'mihomo' } = await getAppConfig()
|
|
const corePath = mihomoCorePath(core)
|
|
|
|
await managerLogger.info(`Checking high privilege core: ${corePath}`)
|
|
|
|
if (process.platform === 'win32') {
|
|
const { existsSync } = await import('fs')
|
|
if (!existsSync(corePath)) {
|
|
await managerLogger.info('Core file does not exist')
|
|
return false
|
|
}
|
|
|
|
const hasHighPrivilegeProcess = await checkHighPrivilegeMihomoProcess()
|
|
if (hasHighPrivilegeProcess) {
|
|
await managerLogger.info('Found high privilege mihomo process running')
|
|
return true
|
|
}
|
|
|
|
const isAdmin = await checkAdminPrivileges()
|
|
await managerLogger.info(`Current process admin privileges: ${isAdmin}`)
|
|
return isAdmin
|
|
}
|
|
|
|
if (process.platform === 'darwin' || process.platform === 'linux') {
|
|
await managerLogger.info('Non-Windows platform, skipping high privilege core check')
|
|
return false
|
|
}
|
|
} catch (error) {
|
|
await managerLogger.error('Failed to check high privilege core', error)
|
|
return false
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
async function checkHighPrivilegeMihomoProcess(): Promise<boolean> {
|
|
try {
|
|
if (process.platform === 'win32') {
|
|
const execPromise = promisify(exec)
|
|
|
|
const mihomoExecutables = ['mihomo.exe', 'mihomo-alpha.exe', 'mihomo-smart.exe']
|
|
|
|
for (const executable of mihomoExecutables) {
|
|
try {
|
|
const { stdout } = await execPromise(
|
|
`chcp 65001 >nul 2>&1 && tasklist /FI "IMAGENAME eq ${executable}" /FO CSV`,
|
|
{ encoding: 'utf8' }
|
|
)
|
|
const lines = stdout.split('\n').filter((line) => line.includes(executable))
|
|
|
|
if (lines.length > 0) {
|
|
await managerLogger.info(`Found ${lines.length} ${executable} processes running`)
|
|
|
|
for (const line of lines) {
|
|
const parts = line.split(',')
|
|
if (parts.length >= 2) {
|
|
const pid = parts[1].replace(/"/g, '').trim()
|
|
try {
|
|
const { stdout: processInfo } = await execPromise(
|
|
`powershell -Command "[Console]::OutputEncoding = [System.Text.Encoding]::UTF8; Get-Process -Id ${pid} | Select-Object Name,Id,Path,CommandLine | ConvertTo-Json"`,
|
|
{ encoding: 'utf8' }
|
|
)
|
|
const processJson = JSON.parse(processInfo)
|
|
await managerLogger.info(`Process ${pid} info: ${processInfo.substring(0, 200)}`)
|
|
|
|
if (processJson.Name.includes('mihomo') && processJson.Path === null) {
|
|
return true
|
|
}
|
|
} catch (error) {
|
|
await managerLogger.info(
|
|
`Cannot get info for process ${pid}, might be high privilege`
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} catch (error) {
|
|
await managerLogger.error(`Failed to check ${executable} processes`, error)
|
|
}
|
|
}
|
|
}
|
|
|
|
if (process.platform === 'darwin' || process.platform === 'linux') {
|
|
const execPromise = promisify(exec)
|
|
|
|
try {
|
|
const mihomoExecutables = ['mihomo', 'mihomo-alpha', 'mihomo-smart']
|
|
let foundProcesses = false
|
|
|
|
for (const executable of mihomoExecutables) {
|
|
try {
|
|
const { stdout } = await execPromise(`ps aux | grep ${executable} | grep -v grep`)
|
|
const lines = stdout
|
|
.split('\n')
|
|
.filter((line) => line.trim() && line.includes(executable))
|
|
|
|
if (lines.length > 0) {
|
|
foundProcesses = true
|
|
await managerLogger.info(`Found ${lines.length} ${executable} processes running`)
|
|
|
|
for (const line of lines) {
|
|
const parts = line.trim().split(/\s+/)
|
|
if (parts.length >= 1) {
|
|
const user = parts[0]
|
|
await managerLogger.info(`${executable} process running as user: ${user}`)
|
|
|
|
if (user === 'root') {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} catch (error) {
|
|
// ignore
|
|
}
|
|
}
|
|
|
|
if (!foundProcesses) {
|
|
await managerLogger.info('No mihomo processes found running')
|
|
}
|
|
} catch (error) {
|
|
await managerLogger.error('Failed to check mihomo processes on Unix', error)
|
|
}
|
|
}
|
|
} catch (error) {
|
|
await managerLogger.error('Failed to check high privilege mihomo process', error)
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
// TUN 模式获取权限
|
|
export async function requestTunPermissions(): Promise<void> {
|
|
if (process.platform === 'win32') {
|
|
await restartAsAdmin()
|
|
} else {
|
|
const hasPermissions = await checkMihomoCorePermissions()
|
|
if (!hasPermissions) {
|
|
await grantTunPermissions()
|
|
}
|
|
}
|
|
}
|
|
|
|
export async function checkAdminRestartForTun(): Promise<void> {
|
|
if (process.argv.includes('--admin-restart-for-tun')) {
|
|
await managerLogger.info('Detected admin restart for TUN mode, auto-enabling TUN...')
|
|
|
|
try {
|
|
if (process.platform === 'win32') {
|
|
const hasAdminPrivileges = await checkAdminPrivileges()
|
|
if (hasAdminPrivileges) {
|
|
await patchControledMihomoConfig({ tun: { enable: true }, dns: { enable: true } })
|
|
|
|
const { checkAutoRun, enableAutoRun } = await import('../sys/autoRun')
|
|
const autoRunEnabled = await checkAutoRun()
|
|
if (autoRunEnabled) {
|
|
await enableAutoRun()
|
|
}
|
|
|
|
await restartCore()
|
|
|
|
await managerLogger.info('TUN mode auto-enabled after admin restart')
|
|
|
|
const { mainWindow } = await import('../index')
|
|
mainWindow?.webContents.send('controledMihomoConfigUpdated')
|
|
ipcMain.emit('updateTrayMenu')
|
|
} else {
|
|
await managerLogger.warn('Admin restart detected but no admin privileges found')
|
|
}
|
|
}
|
|
} catch (error) {
|
|
await managerLogger.error('Failed to auto-enable TUN after admin restart', error)
|
|
}
|
|
} else {
|
|
// 检查 TUN 配置与权限的匹配,但不自动开启 TUN
|
|
await validateTunPermissionsOnStartup()
|
|
}
|
|
}
|
|
|
|
export async function validateTunPermissionsOnStartup(): Promise<void> {
|
|
try {
|
|
const { tun } = await getControledMihomoConfig()
|
|
|
|
if (!tun?.enable) {
|
|
return
|
|
}
|
|
|
|
const hasPermissions = await checkMihomoCorePermissions()
|
|
|
|
if (!hasPermissions) {
|
|
await managerLogger.warn(
|
|
'TUN is enabled but insufficient permissions detected, prompting user...'
|
|
)
|
|
const confirmed = await showTunPermissionDialog()
|
|
if (confirmed) {
|
|
await restartAsAdmin()
|
|
return
|
|
}
|
|
|
|
await managerLogger.warn('User declined admin restart, auto-disabling TUN...')
|
|
await patchControledMihomoConfig({ tun: { enable: false } })
|
|
|
|
const { mainWindow } = await import('../index')
|
|
mainWindow?.webContents.send('controledMihomoConfigUpdated')
|
|
ipcMain.emit('updateTrayMenu')
|
|
|
|
await managerLogger.info('TUN auto-disabled due to insufficient permissions')
|
|
} else {
|
|
await managerLogger.info('TUN permissions validated successfully')
|
|
}
|
|
} catch (error) {
|
|
await managerLogger.error('Failed to validate TUN permissions on startup', error)
|
|
}
|
|
}
|
|
|
|
export async function manualGrantCorePermition(): Promise<void> {
|
|
return grantTunPermissions()
|
|
}
|
|
|
|
export async function getDefaultDevice(): Promise<string> {
|
|
const execPromise = promisify(exec)
|
|
const { stdout: deviceOut } = await execPromise(`route -n get default`)
|
|
let device = deviceOut.split('\n').find((s) => s.includes('interface:'))
|
|
device = device?.trim().split(' ').slice(1).join(' ')
|
|
if (!device) throw new Error('Get device failed')
|
|
return device
|
|
}
|
|
|
|
async function getDefaultService(): Promise<string> {
|
|
const execPromise = promisify(exec)
|
|
const device = await getDefaultDevice()
|
|
const { stdout: order } = await execPromise(`networksetup -listnetworkserviceorder`)
|
|
const block = order.split('\n\n').find((s) => s.includes(`Device: ${device}`))
|
|
if (!block) throw new Error('Get networkservice failed')
|
|
for (const line of block.split('\n')) {
|
|
if (line.match(/^\(\d+\).*/)) {
|
|
return line.trim().split(' ').slice(1).join(' ')
|
|
}
|
|
}
|
|
throw new Error('Get service failed')
|
|
}
|
|
|
|
async function getOriginDNS(): Promise<void> {
|
|
const execPromise = promisify(exec)
|
|
const service = await getDefaultService()
|
|
const { stdout: dns } = await execPromise(`networksetup -getdnsservers "${service}"`)
|
|
if (dns.startsWith("There aren't any DNS Servers set on")) {
|
|
await patchAppConfig({ originDNS: 'Empty' })
|
|
} else {
|
|
await patchAppConfig({ originDNS: dns.trim().replace(/\n/g, ' ') })
|
|
}
|
|
}
|
|
|
|
async function setDNS(dns: string): Promise<void> {
|
|
const service = await getDefaultService()
|
|
const execPromise = promisify(exec)
|
|
await execPromise(`networksetup -setdnsservers "${service}" ${dns}`)
|
|
}
|
|
|
|
async function setPublicDNS(): Promise<void> {
|
|
if (process.platform !== 'darwin') return
|
|
if (net.isOnline()) {
|
|
const { originDNS } = await getAppConfig()
|
|
if (!originDNS) {
|
|
await getOriginDNS()
|
|
await setDNS('223.5.5.5')
|
|
}
|
|
} else {
|
|
if (setPublicDNSTimer) clearTimeout(setPublicDNSTimer)
|
|
setPublicDNSTimer = setTimeout(() => setPublicDNS(), 5000)
|
|
}
|
|
}
|
|
|
|
async function recoverDNS(): Promise<void> {
|
|
if (process.platform !== 'darwin') return
|
|
if (net.isOnline()) {
|
|
const { originDNS } = await getAppConfig()
|
|
if (originDNS) {
|
|
await setDNS(originDNS)
|
|
await patchAppConfig({ originDNS: undefined })
|
|
}
|
|
} else {
|
|
if (recoverDNSTimer) clearTimeout(recoverDNSTimer)
|
|
recoverDNSTimer = setTimeout(() => recoverDNS(), 5000)
|
|
}
|
|
}
|