From 6a9edd86659feaf2bb68326dd219cb4e0f3ea2b4 Mon Sep 17 00:00:00 2001 From: xmk23333 Date: Wed, 7 Jan 2026 14:58:46 +0800 Subject: [PATCH] refactor: split manager.ts into permissions, process, dns modules --- build/linux/postinst | 12 + build/linux/postuninst | 7 + electron-builder.yml | 4 + src/main/core/dns.ts | 76 ++++ src/main/core/manager.ts | 809 +++++------------------------------ src/main/core/permissions.ts | 408 ++++++++++++++++++ src/main/core/process.ts | 139 ++++++ 7 files changed, 746 insertions(+), 709 deletions(-) create mode 100644 src/main/core/dns.ts create mode 100644 src/main/core/permissions.ts create mode 100644 src/main/core/process.ts diff --git a/build/linux/postinst b/build/linux/postinst index 4196776..8fdd08a 100644 --- a/build/linux/postinst +++ b/build/linux/postinst @@ -25,3 +25,15 @@ fi if hash update-desktop-database 2>/dev/null; then update-desktop-database /usr/share/applications || true fi + +# Update icon cache for GNOME/GTK environments +if hash gtk-update-icon-cache 2>/dev/null; then + for dir in /usr/share/icons/hicolor /usr/share/icons/gnome; do + [ -d "$dir" ] && gtk-update-icon-cache -f -t "$dir" 2>/dev/null || true + done +fi + +# Refresh GNOME Shell icon cache +if hash update-icon-caches 2>/dev/null; then + update-icon-caches /usr/share/icons/* 2>/dev/null || true +fi diff --git a/build/linux/postuninst b/build/linux/postuninst index 7d6ea0c..288c309 100644 --- a/build/linux/postuninst +++ b/build/linux/postuninst @@ -15,6 +15,13 @@ case "$1" in if hash update-desktop-database 2>/dev/null; then update-desktop-database /usr/share/applications || true fi + + # Update icon cache + if hash gtk-update-icon-cache 2>/dev/null; then + for dir in /usr/share/icons/hicolor /usr/share/icons/gnome; do + [ -d "$dir" ] && gtk-update-icon-cache -f -t "$dir" 2>/dev/null || true + done + fi ;; *) # others diff --git a/electron-builder.yml b/electron-builder.yml index 111a3fd..60d12c1 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -59,7 +59,11 @@ linux: desktop: entry: Name: Clash Party + GenericName: Proxy Client + Comment: A GUI client based on Mihomo MimeType: 'x-scheme-handler/clash;x-scheme-handler/mihomo' + Keywords: proxy;clash;mihomo;vpn; + StartupWMClass: clash-party target: - deb - rpm diff --git a/src/main/core/dns.ts b/src/main/core/dns.ts new file mode 100644 index 0000000..b380e5a --- /dev/null +++ b/src/main/core/dns.ts @@ -0,0 +1,76 @@ +import { exec } from 'child_process' +import { promisify } from 'util' +import { net } from 'electron' +import { getAppConfig, patchAppConfig } from '../config' + +const execPromise = promisify(exec) + +let setPublicDNSTimer: NodeJS.Timeout | null = null +let recoverDNSTimer: NodeJS.Timeout | null = null + +export async function getDefaultDevice(): Promise { + 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 { + 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 { + 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 { + const service = await getDefaultService() + // networksetup 需要 root 权限,通过 osascript 请求管理员权限执行 + const shell = `networksetup -setdnsservers "${service}" ${dns}` + const command = `do shell script "${shell}" with administrator privileges` + await execPromise(`osascript -e '${command}'`) +} + +export async function setPublicDNS(): Promise { + 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) + } +} + +export async function recoverDNS(): Promise { + 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) + } +} diff --git a/src/main/core/manager.ts b/src/main/core/manager.ts index f80f840..0ff5503 100644 --- a/src/main/core/manager.ts +++ b/src/main/core/manager.ts @@ -1,4 +1,4 @@ -import { ChildProcess, exec, execFile, spawn } from 'child_process' +import { ChildProcess, execFile, spawn } from 'child_process' import { dataDir, coreLogPath, @@ -13,11 +13,10 @@ import { generateProfile } from './factory' import { getAppConfig, getControledMihomoConfig, - patchAppConfig, patchControledMihomoConfig, manageSmartOverride } from '../config' -import { app, ipcMain, net } from 'electron' +import { app, ipcMain } from 'electron' import { startMihomoTraffic, startMihomoConnections, @@ -43,36 +42,35 @@ 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] +// 拆分模块 +import { getSessionAdminStatus } from './permissions' +import { + cleanupSocketFile, + cleanupWindowsNamedPipes, + validateWindowsPipeAccess, + waitForCoreReady +} from './process' +import { setPublicDNS, recoverDNS } from './dns' -function isValidCoreName(core: string): core is AllowedCore { - return ALLOWED_CORES.includes(core as AllowedCore) -} +// 重新导出权限相关函数 +export { + initAdminStatus, + getSessionAdminStatus, + checkAdminPrivileges, + checkMihomoCorePermissions, + checkHighPrivilegeCore, + grantTunPermissions, + restartAsAdmin, + requestTunPermissions, + showTunPermissionDialog, + showErrorDialog, + checkTunPermissions, + manualGrantCorePermition +} from './permissions' -// 路径检查 -function validateCorePath(corePath: string): void { - if (corePath.includes('..')) { - throw new Error('Invalid core path: directory traversal detected') - } +export { getDefaultDevice } from './dns' - 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, "'\\''") + "'" -} +const execFilePromise = promisify(execFile) chokidar.watch(path.join(mihomoCoreDir(), 'meta-update'), {}).on('unlinkDir', async () => { try { @@ -90,75 +88,79 @@ export const getMihomoIpcPath = (): string => { 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}` - } + return isAdmin + ? `\\\\.\\pipe\\MihomoParty\\mihomo-admin-${sessionId}-${processId}` + : `\\\\.\\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[]> { + // 合并配置读取,避免多次 await + const [appConfig, mihomoConfig] = await Promise.all([ + getAppConfig(), + getControledMihomoConfig() + ]) + 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')) + } = appConfig + + const { 'log-level': logLevel, tun } = mihomoConfig + + // 清理旧进程 + const pidPath = path.join(dataDir(), 'core.pid') + if (existsSync(pidPath)) { + const pid = parseInt(await readFile(pidPath, 'utf-8')) try { process.kill(pid, 'SIGINT') } catch { // ignore } finally { - await rm(path.join(dataDir(), 'core.pid')) + await rm(pidPath) } } - const { tun } = await getControledMihomoConfig() + const corePath = mihomoCorePath(core) // 管理 Smart 内核覆写配置 await manageSmartOverride() - // generateProfile 返回实际使用的 current,确保内核工作目录与配置文件一致 + // generateProfile 返回实际使用的 current const current = await generateProfile() - await checkProfile(current) + await checkProfile(current, core, diffWorkDir) await stopCore() - await cleanupSocketFile() if (tun?.enable && autoSetDNS) { try { await setPublicDNS() } catch (error) { - await managerLogger.error('set dns failed', error) + managerLogger.error('set dns failed', error) } } // 获取动态 IPC 路径 const dynamicIpcPath = getMihomoIpcPath() - await managerLogger.info(`Using IPC path: ${dynamicIpcPath}`) + 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' }) @@ -166,43 +168,47 @@ export async function startCore(detached = false): Promise[]> { corePath, ['-d', diffWorkDir ? mihomoProfileWorkDir(current) : mihomoWorkDir(), ctlParam, dynamicIpcPath], { - detached: 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( + managerLogger.info( `Core process detached successfully on ${process.platform}, PID: ${child.pid}` ) child.unref() - return new Promise((resolve) => { - resolve([new Promise(() => {})]) - }) + return [new Promise(() => {})] } + child.on('close', async (code, signal) => { - await managerLogger.info(`Core closed, code: ${code}, signal: ${signal}`) + managerLogger.info(`Core closed, code: ${code}, signal: ${signal}`) if (isRestarting) { - await managerLogger.info('Core closed during restart, skipping auto-restart') + managerLogger.info('Core closed during restart, skipping auto-restart') return } if (retry) { - await managerLogger.info('Try Restart Core') + 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') @@ -210,34 +216,36 @@ export async function startCore(detached = false): Promise[]> { reject(i18next.t('tun.error.tunPermissionDenied')) } - if ( + const isControllerError = (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 (isControllerError) { + managerLogger.error('External controller listen error detected:', str) if (process.platform === 'win32') { - await managerLogger.info('Attempting Windows pipe cleanup and retry...') + managerLogger.info('Attempting Windows pipe cleanup and retry...') try { await cleanupWindowsNamedPipes() - await new Promise((resolve) => setTimeout(resolve, 2000)) + await new Promise((r) => setTimeout(r, 2000)) } catch (cleanupError) { - await managerLogger.error('Pipe cleanup failed:', cleanupError) + managerLogger.error('Pipe cleanup failed:', cleanupError) } } reject(i18next.t('mihomo.error.externalControllerListenError')) } - if ( + const isApiReady = (process.platform !== 'win32' && str.includes('RESTful API unix listening at')) || (process.platform === 'win32' && str.includes('RESTful API pipe listening at')) - ) { + + if (isApiReady) { resolve([ - new Promise((resolve) => { - child.stdout?.on('data', async (data) => { + new Promise((innerResolve) => { + child.stdout?.on('data', async (innerData) => { if ( - data.toString().toLowerCase().includes('start initial compatible provider default') + innerData.toString().toLowerCase().includes('start initial compatible provider default') ) { try { mainWindow?.webContents.send('groupsUpdated') @@ -247,15 +255,13 @@ export async function startCore(detached = false): Promise[]> { // ignore } await patchMihomoConfig({ 'log-level': logLevel }) - resolve() + innerResolve() } }) }) ]) await waitForCoreReady() - - // 强制刷新 axios 实例以使用新的管道路径 await getAxios(true) await startMihomoTraffic() await startMihomoConnections() @@ -273,141 +279,31 @@ export async function stopCore(force = false): Promise { await recoverDNS() } } catch (error) { - await managerLogger.error('recover dns failed', error) + 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) + managerLogger.warn('Failed to refresh axios instance:', error) } - // 清理 Socket 文件 await cleanupSocketFile() } -async function cleanupSocketFile(): Promise { - if (process.platform === 'win32') { - await cleanupWindowsNamedPipes() - } else { - await cleanupUnixSockets() - } -} - -// Windows 命名管道清理 -async function cleanupWindowsNamedPipes(): Promise { - 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 { - 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 { - 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 { - // 防止并发重启 if (isRestarting) { - await managerLogger.info('Core restart already in progress, skipping duplicate request') + managerLogger.info('Core restart already in progress, skipping duplicate request') return } @@ -415,7 +311,7 @@ export async function restartCore(): Promise { try { await startCore() } catch (e) { - await managerLogger.error('restart core failed', e) + managerLogger.error('restart core failed', e) throw e } finally { isRestarting = false @@ -425,7 +321,7 @@ export async function restartCore(): Promise { export async function keepCoreAlive(): Promise { try { await startCore(true) - if (child && child.pid) { + if (child?.pid) { await writeFile(path.join(dataDir(), 'core.pid'), child.pid.toString()) } } catch (e) { @@ -434,28 +330,30 @@ export async function keepCoreAlive(): Promise { } export async function quitWithoutCore(): Promise { - await managerLogger.info(`Starting lightweight mode on platform: ${process.platform}`) + managerLogger.info(`Starting lightweight mode on platform: ${process.platform}`) try { await startCore(true) - if (child && child.pid) { + if (child?.pid) { await writeFile(path.join(dataDir(), 'core.pid'), child.pid.toString()) - await managerLogger.info(`Core started in lightweight mode with PID: ${child.pid}`) + managerLogger.info(`Core started in lightweight mode with PID: ${child.pid}`) } } catch (e) { - await managerLogger.error('Failed to start core in lightweight mode:', e) + 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') + managerLogger.info('Exiting main process, core will continue running in background') app.exit() } -async function checkProfile(current: string | undefined): Promise { - const { core = 'mihomo', diffWorkDir = false } = await getAppConfig() +async function checkProfile( + current: string | undefined, + core: string = 'mihomo', + diffWorkDir: boolean = false +): Promise { const corePath = mihomoCorePath(core) - const execFilePromise = promisify(execFile) try { await execFilePromise(corePath, [ @@ -466,12 +364,12 @@ async function checkProfile(current: string | undefined): Promise { mihomoTestDir() ]) } catch (error) { - await managerLogger.error('Profile check failed', error) + 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) + managerLogger.info('Profile check stdout', stdout) + managerLogger.info('Profile check stderr', stderr) const errorLines = stdout .split('\n') @@ -498,515 +396,8 @@ async function checkProfile(current: string | undefined): Promise { } } -export async function checkTunPermissions(): Promise { - return checkMihomoCorePermissions() -} - -export async function grantTunPermissions(): Promise { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - // 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 { - if (process.platform === 'win32') { - await restartAsAdmin() - } else { - const hasPermissions = await checkMihomoCorePermissions() - if (!hasPermissions) { - await grantTunPermissions() - } - } -} - +// 权限检查入口(从 permissions.ts 调用) export async function checkAdminRestartForTun(): Promise { - 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 { - 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 { - return grantTunPermissions() -} - -export async function getDefaultDevice(): Promise { - 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 { - 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 { - 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 { - const service = await getDefaultService() - const execPromise = promisify(exec) - // networksetup 需要 root 权限,通过 osascript 请求管理员权限执行 - const shell = `networksetup -setdnsservers "${service}" ${dns}` - const command = `do shell script "${shell}" with administrator privileges` - await execPromise(`osascript -e '${command}'`) -} - -async function setPublicDNS(): Promise { - 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 { - 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) - } + const { checkAdminRestartForTun: check } = await import('./permissions') + await check(restartCore) } diff --git a/src/main/core/permissions.ts b/src/main/core/permissions.ts new file mode 100644 index 0000000..5fd745a --- /dev/null +++ b/src/main/core/permissions.ts @@ -0,0 +1,408 @@ +import { exec, execFile } from 'child_process' +import { promisify } from 'util' +import { stat } from 'fs/promises' +import { existsSync } from 'fs' +import { app, dialog, ipcMain } from 'electron' +import { getAppConfig, patchControledMihomoConfig } from '../config' +import { mihomoCorePath, mihomoCoreDir } from '../utils/dirs' +import { managerLogger } from '../utils/logger' +import i18next from '../../shared/i18n' +import path from 'path' + +const execPromise = promisify(exec) +const execFilePromise = promisify(execFile) + +// 内核名称白名单 +const ALLOWED_CORES = ['mihomo', 'mihomo-alpha', 'mihomo-smart'] as const +type AllowedCore = (typeof ALLOWED_CORES)[number] + +export function isValidCoreName(core: string): core is AllowedCore { + return ALLOWED_CORES.includes(core as AllowedCore) +} + +export 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, "'\\''") + "'" +} + +// 会话管理员状态缓存 +let sessionAdminStatus: boolean | null = null + +export async function initAdminStatus(): Promise { + if (process.platform === 'win32' && sessionAdminStatus === null) { + sessionAdminStatus = await checkAdminPrivileges().catch(() => false) + } +} + +export function getSessionAdminStatus(): boolean { + if (process.platform !== 'win32') { + return true + } + return sessionAdminStatus ?? false +} + +export async function checkAdminPrivileges(): Promise { + if (process.platform !== 'win32') { + return true + } + + try { + await execPromise('chcp 65001 >nul 2>&1 && fltmc', { encoding: 'utf8' }) + managerLogger.info('Admin privileges confirmed via fltmc') + return true + } catch (fltmcError: unknown) { + const errorCode = (fltmcError as { code?: number })?.code || 0 + managerLogger.debug(`fltmc failed with code ${errorCode}, trying net session as fallback`) + + try { + await execPromise('chcp 65001 >nul 2>&1 && net session', { encoding: 'utf8' }) + managerLogger.info('Admin privileges confirmed via net session') + return true + } catch (netSessionError: unknown) { + const netErrorCode = (netSessionError as { code?: number })?.code || 0 + managerLogger.debug( + `Both fltmc and net session failed, no admin privileges. Error codes: fltmc=${errorCode}, net=${netErrorCode}` + ) + return false + } + } +} + +export async function checkMihomoCorePermissions(): Promise { + const { core = 'mihomo' } = await getAppConfig() + const corePath = mihomoCorePath(core) + + try { + if (process.platform === 'win32') { + return await checkAdminPrivileges() + } + + if (process.platform === 'darwin' || process.platform === 'linux') { + const stats = await stat(corePath) + return (stats.mode & 0o4000) !== 0 && stats.uid === 0 + } + } catch { + return false + } + + return false +} + +export async function checkHighPrivilegeCore(): Promise { + try { + const { core = 'mihomo' } = await getAppConfig() + const corePath = mihomoCorePath(core) + + managerLogger.info(`Checking high privilege core: ${corePath}`) + + if (process.platform === 'win32') { + if (!existsSync(corePath)) { + managerLogger.info('Core file does not exist') + return false + } + + const hasHighPrivilegeProcess = await checkHighPrivilegeMihomoProcess() + if (hasHighPrivilegeProcess) { + managerLogger.info('Found high privilege mihomo process running') + return true + } + + const isAdmin = await checkAdminPrivileges() + managerLogger.info(`Current process admin privileges: ${isAdmin}`) + return isAdmin + } + + if (process.platform === 'darwin' || process.platform === 'linux') { + managerLogger.info('Non-Windows platform, skipping high privilege core check') + return false + } + } catch (error) { + managerLogger.error('Failed to check high privilege core', error) + return false + } + + return false +} + +async function checkHighPrivilegeMihomoProcess(): Promise { + const mihomoExecutables = + process.platform === 'win32' + ? ['mihomo.exe', 'mihomo-alpha.exe', 'mihomo-smart.exe'] + : ['mihomo', 'mihomo-alpha', 'mihomo-smart'] + + try { + if (process.platform === 'win32') { + 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) { + 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 -NoProfile -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) + managerLogger.info(`Process ${pid} info: ${processInfo.substring(0, 200)}`) + + if (processJson.Name.includes('mihomo') && processJson.Path === null) { + return true + } + } catch { + managerLogger.info(`Cannot get info for process ${pid}, might be high privilege`) + } + } + } + } + } catch (error) { + managerLogger.error(`Failed to check ${executable} processes`, error) + } + } + } else { + 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 + 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] + managerLogger.info(`${executable} process running as user: ${user}`) + + if (user === 'root') { + return true + } + } + } + } + } catch { + // ignore + } + } + + if (!foundProcesses) { + managerLogger.info('No mihomo processes found running') + } + } + } catch (error) { + managerLogger.error('Failed to check high privilege mihomo process', error) + } + + return false +} + +export async function grantTunPermissions(): Promise { + 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) + + 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') + } +} + +export async function restartAsAdmin(forTun: boolean = true): Promise { + 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("', '") + + const command = + restartArgs.length > 0 + ? `powershell -NoProfile -Command "Start-Process -FilePath '${escapedExePath}' -ArgumentList '${argsString}' -Verb RunAs"` + : `powershell -NoProfile -Command "Start-Process -FilePath '${escapedExePath}' -Verb RunAs"` + + managerLogger.info('Restarting as administrator with command', command) + + exec(command, { windowsHide: true }, (error, _stdout, stderr) => { + if (error) { + managerLogger.error('PowerShell execution error', error) + managerLogger.error('stderr', stderr) + } else { + managerLogger.info('PowerShell command executed successfully') + } + }) + + app.quit() + } catch (error) { + managerLogger.error('Failed to restart as administrator', error) + throw new Error(`Failed to restart as administrator: ${error}`) + } +} + +export async function requestTunPermissions(): Promise { + if (process.platform === 'win32') { + await restartAsAdmin() + } else { + const hasPermissions = await checkMihomoCorePermissions() + if (!hasPermissions) { + await grantTunPermissions() + } + } +} + +export async function showTunPermissionDialog(): Promise { + managerLogger.info('Preparing TUN permission dialog...') + + 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') || '取消' + + const choice = dialog.showMessageBoxSync({ + type: 'warning', + title, + message, + buttons: [confirmText, cancelText], + defaultId: 0, + cancelId: 1 + }) + + managerLogger.info(`TUN permission dialog choice: ${choice}`) + return choice === 0 +} + +export async function showErrorDialog(title: string, message: string): Promise { + const okText = i18next.t('common.confirm') || '确认' + + dialog.showMessageBoxSync({ + type: 'error', + title, + message, + buttons: [okText], + defaultId: 0 + }) +} + +export async function validateTunPermissionsOnStartup(_restartCore: () => Promise): Promise { + const { getControledMihomoConfig } = await import('../config') + const { tun } = await getControledMihomoConfig() + + if (!tun?.enable) { + return + } + + const hasPermissions = await checkMihomoCorePermissions() + + if (!hasPermissions) { + managerLogger.warn('TUN is enabled but insufficient permissions detected, prompting user...') + const confirmed = await showTunPermissionDialog() + if (confirmed) { + await restartAsAdmin() + return + } + + 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') + + managerLogger.info('TUN auto-disabled due to insufficient permissions') + } else { + managerLogger.info('TUN permissions validated successfully') + } +} + +export async function checkAdminRestartForTun(restartCore: () => Promise): Promise { + if (process.argv.includes('--admin-restart-for-tun')) { + 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() + + managerLogger.info('TUN mode auto-enabled after admin restart') + + const { mainWindow } = await import('../index') + mainWindow?.webContents.send('controledMihomoConfigUpdated') + ipcMain.emit('updateTrayMenu') + } else { + managerLogger.warn('Admin restart detected but no admin privileges found') + } + } + } catch (error) { + managerLogger.error('Failed to auto-enable TUN after admin restart', error) + } + } else { + await validateTunPermissionsOnStartup(restartCore) + } +} + +export function checkTunPermissions(): Promise { + return checkMihomoCorePermissions() +} + +export function manualGrantCorePermition(): Promise { + return grantTunPermissions() +} diff --git a/src/main/core/process.ts b/src/main/core/process.ts new file mode 100644 index 0000000..d199114 --- /dev/null +++ b/src/main/core/process.ts @@ -0,0 +1,139 @@ +import { exec } from 'child_process' +import { promisify } from 'util' +import { rm } from 'fs/promises' +import { existsSync } from 'fs' +import { managerLogger } from '../utils/logger' +import { getAxios } from './mihomoApi' + +const execPromise = promisify(exec) + +// 常量 +const CORE_READY_MAX_RETRIES = 30 +const CORE_READY_RETRY_INTERVAL_MS = 500 + +export async function cleanupSocketFile(): Promise { + if (process.platform === 'win32') { + await cleanupWindowsNamedPipes() + } else { + await cleanupUnixSockets() + } +} + +export async function cleanupWindowsNamedPipes(): Promise { + try { + 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()) { + 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) { + await terminateProcess(pid) + } + } + } catch (parseError) { + managerLogger.warn('Failed to parse process list JSON:', parseError) + await fallbackTextParsing(stdout) + } + } + } catch (error) { + managerLogger.warn('Failed to check mihomo processes:', error) + } + + await new Promise((resolve) => setTimeout(resolve, 1000)) + } catch (error) { + managerLogger.error('Windows named pipe cleanup failed:', error) + } +} + +async function terminateProcess(pid: number): Promise { + try { + process.kill(pid, 0) + process.kill(pid, 'SIGTERM') + managerLogger.info(`Terminated process ${pid} to free pipe`) + } catch (error: unknown) { + if ((error as { code?: string })?.code !== 'ESRCH') { + managerLogger.warn(`Failed to terminate process ${pid}:`, error) + } + } +} + +async function fallbackTextParsing(stdout: string): Promise { + 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) { + await terminateProcess(pid) + } + } + } +} + +export async function cleanupUnixSockets(): Promise { + 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) + managerLogger.info(`Cleaned up socket file: ${socketPath}`) + } + } catch (error) { + managerLogger.warn(`Failed to cleanup socket file ${socketPath}:`, error) + } + } + } catch (error) { + managerLogger.error('Unix socket cleanup failed:', error) + } +} + +export async function validateWindowsPipeAccess(pipePath: string): Promise { + try { + managerLogger.info(`Validating pipe access for: ${pipePath}`) + managerLogger.info(`Pipe validation completed for: ${pipePath}`) + } catch (error) { + managerLogger.error('Windows pipe validation failed:', error) + } +} + +export async function waitForCoreReady(): Promise { + for (let i = 0; i < CORE_READY_MAX_RETRIES; i++) { + try { + const axios = await getAxios(true) + await axios.get('/') + managerLogger.info( + `Core ready after ${i + 1} attempts (${(i + 1) * CORE_READY_RETRY_INTERVAL_MS}ms)` + ) + return + } catch { + if (i === 0) { + managerLogger.info('Waiting for core to be ready...') + } + + if (i === CORE_READY_MAX_RETRIES - 1) { + managerLogger.warn( + `Core not ready after ${CORE_READY_MAX_RETRIES} attempts, proceeding anyway` + ) + return + } + + await new Promise((resolve) => setTimeout(resolve, CORE_READY_RETRY_INTERVAL_MS)) + } + } +}