diff --git a/build/background.png b/build/background.png index 3ba2909..9eefc0c 100644 Binary files a/build/background.png and b/build/background.png differ diff --git a/build/icon.icns b/build/icon.icns index fab0010..4a32b95 100644 Binary files a/build/icon.icns and b/build/icon.icns differ diff --git a/build/icon.ico b/build/icon.ico index 93b62a3..09808bc 100644 Binary files a/build/icon.ico and b/build/icon.ico differ diff --git a/build/icon.png b/build/icon.png index 3219053..9c1ac3c 100644 Binary files a/build/icon.png and b/build/icon.png differ diff --git a/build/installerIcon.ico b/build/installerIcon.ico index 2d59d0b..1a79ca1 100644 Binary files a/build/installerIcon.ico and b/build/installerIcon.ico differ diff --git a/changelog.md b/changelog.md index cd17567..21befb1 100644 --- a/changelog.md +++ b/changelog.md @@ -3,7 +3,9 @@ ### 新功能 (Feat) - 增加关闭动画开关 - 增加订阅超时时间设置 - - + +### 优化 (Optimize) + - socket 管理防止内核通信失败 ### 样式调整 (Sytle) - 改进 logo 设计 diff --git a/src/main/core/manager.ts b/src/main/core/manager.ts index 2aa99c8..733e5e7 100644 --- a/src/main/core/manager.ts +++ b/src/main/core/manager.ts @@ -28,7 +28,8 @@ import { stopMihomoTraffic, stopMihomoLogs, stopMihomoMemory, - patchMihomoConfig + patchMihomoConfig, + getAxios } from './mihomoApi' import chokidar from 'chokidar' import { readFile, rm, writeFile } from 'fs/promises' @@ -52,8 +53,26 @@ chokidar.watch(path.join(mihomoCoreDir(), 'meta-update'), {}).on('unlinkDir', as } }) -export const mihomoIpcPath = - process.platform === 'win32' ? '\\\\.\\pipe\\MihomoParty\\mihomo' : '/tmp/mihomo-party.sock' +// 动态生成 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 @@ -93,6 +112,9 @@ export async function startCore(detached = false): Promise[]> { await generateProfile() await checkProfile() await stopCore() + + await cleanupSocketFile() + if (tun?.enable && autoSetDNS) { try { await setPublicDNS() @@ -100,6 +122,15 @@ export async function startCore(detached = false): Promise[]> { 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' }) @@ -111,9 +142,9 @@ export async function startCore(detached = false): Promise[]> { } child = spawn( corePath, - ['-d', diffWorkDir ? mihomoProfileWorkDir(current) : mihomoWorkDir(), ctlParam, mihomoIpcPath], + ['-d', diffWorkDir ? mihomoProfileWorkDir(current) : mihomoWorkDir(), ctlParam, dynamicIpcPath], { - detached: true, + detached: detached, stdio: detached ? 'ignore' : undefined, env: env } @@ -122,6 +153,7 @@ export async function startCore(detached = false): Promise[]> { 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(() => {})]) @@ -152,6 +184,18 @@ export async function startCore(detached = false): Promise[]> { 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')) } @@ -176,6 +220,11 @@ export async function startCore(detached = false): Promise[]> { }) }) ]) + + await waitForCoreReady() + + // 强制刷新 axios 实例以使用新的管道路径 + await getAxios(true) await startMihomoTraffic() await startMihomoConnections() await startMihomoLogs() @@ -203,6 +252,118 @@ export async function stopCore(force = false): Promise { 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 { + 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 -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, 'SIGTERM') + await managerLogger.info(`Terminated process ${pid} to free pipe`) + } catch (error) { + 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, 'SIGTERM') + await managerLogger.info(`Terminated process ${pid} to free pipe`) + } catch (error) { + 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 { @@ -218,7 +379,7 @@ export async function restartCore(): Promise { export async function keepCoreAlive(): Promise { try { - if (!child) await startCore(true) + await startCore(true) if (child && child.pid) { await writeFile(path.join(dataDir(), 'core.pid'), child.pid.toString()) } @@ -228,8 +389,21 @@ export async function keepCoreAlive(): Promise { } export async function quitWithoutCore(): Promise { - await keepCoreAlive() + 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() } @@ -330,6 +504,48 @@ export async function grantTunPermissions(): Promise { } } +// 在应用启动时检测一次权限 +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 (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 { if (process.platform !== 'win32') { return true @@ -338,20 +554,22 @@ export async function checkAdminPrivileges(): Promise { const execPromise = promisify(exec) try { - // 首先尝试 fltmc 命令检测管理员权限 - await execPromise('fltmc') + // fltmc 检测管理员权限 + await execPromise('chcp 65001 >nul 2>&1 && fltmc', { encoding: 'utf8' }) await managerLogger.info('Admin privileges confirmed via fltmc') return true - } catch (fltmcError) { - await managerLogger.info('fltmc failed, trying net session as fallback', fltmcError) + } catch (fltmcError: any) { + const errorCode = fltmcError?.code || 0 + await managerLogger.debug(`fltmc failed with code ${errorCode}, trying net session as fallback`) try { - // 如果 fltmc 失败,尝试 net session 命令作为备用检测方法 - await execPromise('net session') + // 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) { - await managerLogger.info('Both fltmc and net session failed, no admin privileges', netSessionError) + } catch (netSessionError: any) { + const netErrorCode = netSessionError?.code || 0 + await managerLogger.debug(`Both fltmc and net session failed, no admin privileges. Error codes: fltmc=${errorCode}, net=${netErrorCode}`) return false } } @@ -514,7 +732,7 @@ async function checkHighPrivilegeMihomoProcess(): Promise { for (const executable of mihomoExecutables) { try { - const { stdout } = await execPromise(`tasklist /FI "IMAGENAME eq ${executable}" /FO CSV`) + 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) { @@ -525,8 +743,11 @@ async function checkHighPrivilegeMihomoProcess(): Promise { if (parts.length >= 2) { const pid = parts[1].replace(/"/g, '').trim() try { - const { stdout: processInfo } = await execPromise(`powershell -Command "Get-Process -Id ${pid} | Select-Object Name,Id,Path,CommandLine | ConvertTo-Json"`) - const processJson = JSON.parse(processInfo) + 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) { diff --git a/src/main/core/mihomoApi.ts b/src/main/core/mihomoApi.ts index c5c2888..c37280d 100644 --- a/src/main/core/mihomoApi.ts +++ b/src/main/core/mihomoApi.ts @@ -6,9 +6,10 @@ import { tray } from '../resolve/tray' import { calcTraffic } from '../utils/calc' import { getRuntimeConfig } from './factory' import { floatingWindow } from '../resolve/floatingWindow' -import { mihomoIpcPath } from './manager' +import { getMihomoIpcPath } from './manager' let axiosIns: AxiosInstance = null! +let currentIpcPath: string = '' let mihomoTrafficWs: WebSocket | null = null let trafficRetry = 10 let mihomoMemoryWs: WebSocket | null = null @@ -19,11 +20,19 @@ let mihomoConnectionsWs: WebSocket | null = null let connectionsRetry = 10 export const getAxios = async (force: boolean = false): Promise => { - if (axiosIns && !force) return axiosIns + const dynamicIpcPath = getMihomoIpcPath() + + // 如路径改变 强制重新创建实例 + if (axiosIns && !force && currentIpcPath === dynamicIpcPath) { + return axiosIns + } + + currentIpcPath = dynamicIpcPath + console.log(`[mihomoApi] Creating axios instance with path: ${dynamicIpcPath}`) axiosIns = axios.create({ baseURL: `http://localhost`, - socketPath: mihomoIpcPath, + socketPath: dynamicIpcPath, timeout: 15000 }) @@ -32,6 +41,12 @@ export const getAxios = async (force: boolean = false): Promise = return response.data }, (error) => { + if (error.code === 'ENOENT') { + console.debug(`[mihomoApi] Pipe not ready: ${error.config?.socketPath}`) + } else { + console.error(`[mihomoApi] Axios error with path ${dynamicIpcPath}:`, error.message) + } + if (error.response && error.response.data) { return Promise.reject(error.response.data) } @@ -200,7 +215,11 @@ export const stopMihomoTraffic = (): void => { } const mihomoTraffic = async (): Promise => { - mihomoTrafficWs = new WebSocket(`ws+unix:${mihomoIpcPath}:/traffic`) + const dynamicIpcPath = getMihomoIpcPath() + const wsUrl = `ws+unix:${dynamicIpcPath}:/traffic` + + console.log(`[mihomoApi] Creating traffic WebSocket with URL: ${wsUrl}`) + mihomoTrafficWs = new WebSocket(wsUrl) mihomoTrafficWs.onmessage = async (e): Promise => { const data = e.data as string @@ -229,7 +248,8 @@ const mihomoTraffic = async (): Promise => { } } - mihomoTrafficWs.onerror = (): void => { + mihomoTrafficWs.onerror = (error): void => { + console.error(`[mihomoApi] Traffic WebSocket error:`, error) if (mihomoTrafficWs) { mihomoTrafficWs.close() mihomoTrafficWs = null @@ -252,7 +272,9 @@ export const stopMihomoMemory = (): void => { } const mihomoMemory = async (): Promise => { - mihomoMemoryWs = new WebSocket(`ws+unix:${mihomoIpcPath}:/memory`) + const dynamicIpcPath = getMihomoIpcPath() + const wsUrl = `ws+unix:${dynamicIpcPath}:/memory` + mihomoMemoryWs = new WebSocket(wsUrl) mihomoMemoryWs.onmessage = (e): void => { const data = e.data as string @@ -295,8 +317,10 @@ export const stopMihomoLogs = (): void => { const mihomoLogs = async (): Promise => { const { 'log-level': logLevel = 'info' } = await getControledMihomoConfig() + const dynamicIpcPath = getMihomoIpcPath() + const wsUrl = `ws+unix:${dynamicIpcPath}:/logs?level=${logLevel}` - mihomoLogsWs = new WebSocket(`ws+unix:${mihomoIpcPath}:/logs?level=${logLevel}`) + mihomoLogsWs = new WebSocket(wsUrl) mihomoLogsWs.onmessage = (e): void => { const data = e.data as string @@ -338,7 +362,9 @@ export const stopMihomoConnections = (): void => { } const mihomoConnections = async (): Promise => { - mihomoConnectionsWs = new WebSocket(`ws+unix:${mihomoIpcPath}:/connections`) + const dynamicIpcPath = getMihomoIpcPath() + const wsUrl = `ws+unix:${dynamicIpcPath}:/connections` + mihomoConnectionsWs = new WebSocket(wsUrl) mihomoConnectionsWs.onmessage = (e): void => { const data = e.data as string diff --git a/src/main/index.ts b/src/main/index.ts index 6efdc36..3ee3b3b 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -3,7 +3,7 @@ import { registerIpcMainHandlers } from './utils/ipc' import windowStateKeeper from 'electron-window-state' import { app, shell, BrowserWindow, Menu, dialog, Notification, powerMonitor } from 'electron' import { addProfileItem, getAppConfig, patchAppConfig } from './config' -import { quitWithoutCore, startCore, stopCore, checkAdminRestartForTun, checkHighPrivilegeCore, restartAsAdmin } from './core/manager' +import { quitWithoutCore, startCore, stopCore, checkAdminRestartForTun, checkHighPrivilegeCore, restartAsAdmin, initAdminStatus } from './core/manager' import { triggerSysProxy } from './sys/sysproxy' import icon from '../../resources/icon.png?asset' import { createTray, hideDockIcon, showDockIcon } from './resolve/tray' @@ -223,6 +223,8 @@ app.whenReady().then(async () => { await checkHighPrivilegeCoreEarly() + await initAdminStatus() + try { await init() diff --git a/src/renderer/src/components/base/mihomo-icon.tsx b/src/renderer/src/components/base/mihomo-icon.tsx index 1d254fe..767f1f0 100644 --- a/src/renderer/src/components/base/mihomo-icon.tsx +++ b/src/renderer/src/components/base/mihomo-icon.tsx @@ -1,30 +1,73 @@ -const SVGComponent = (props) => ( - - - - - - - - - -); -export default SVGComponent; +import React from 'react' +import { GenIcon } from 'react-icons' + +function MihomoIcon(props: any): React.JSX.Element { + return GenIcon({ + tag: 'svg', + attr: { viewBox: '0 0 120 150' }, + child: [ + { + tag: 'path', + attr: { + d: 'M123,105.41c0,15.99-13.44,20.46-20.54,20.6h-29.1c5.33-1.74,8.97-5.24,9.18-5.44.05-.05.28-.27.55-.56h19.31c1.44-.04,14.6-.78,14.6-14.6,0-9.22-3.85-21.88-34.64-29.67l-1.47-6.53c28.67,6.6,42.11,18.22,42.11,36.2Z', + fill: 'currentColor' + }, + child: [] + }, + { + tag: 'path', + attr: { + d: 'M81.1,37.6c-2.42.76-4.68,1.55-6.78,2.37l-1.33-5.92c2.05-.77,4.21-1.51,6.47-2.21.27,2.03.83,3.96,1.64,5.76Z', + fill: 'currentColor' + }, + child: [] + }, + { + tag: 'circle', + attr: { + cx: '53.83', + cy: '33.01', + r: '6', + fill: 'currentColor' + }, + child: [] + }, + { + tag: 'circle', + attr: { + cx: '33.83', + cy: '33.01', + r: '6', + fill: 'currentColor' + }, + child: [] + }, + { + tag: 'path', + attr: { + d: 'M67.53,126.01h-1.49c-1.83,0-3.4-1.54-3.19-3.36.17-1.52,1.4-2.64,2.98-2.64h1.7c4.3,0,8.3-1.9,11-5.3,2.7-3.3,3.6-7.6,2.7-11.8L60.13,8.91l-.4.4c-4.3,4.4-9.9,6.7-15.9,6.7s-11.6-2.3-15.8-6.6l-.5-.4L6.33,103.01c-.9,4.1,0,8.4,2.7,11.8s6.7,5.3,11,5.3h.59c1.83,0,3.4,1.54,3.19,3.36-.17,1.52-1.4,2.64-2.98,2.64h-.8c-6.1,0-11.8-2.7-15.7-7.5-3.8-4.9-5.2-11-3.8-17L22.93,2.31c.2-1.1,1-1.9,2.1-2.2,1-.3,2.2,0,3,.8l4.3,4.3c3,3.1,7.1,4.8,11.5,4.8s8.5-1.7,11.6-4.8l4.24-4.24c.37-.37.81-.68,1.31-.83,1.95-.58,3.56.68,3.86,2.16l22.3,99.3c1.3,5.9-.1,12.1-3.9,16.8-.4.5-1.4,1.5-1.4,1.5,0,0-6.26,6.1-14.3,6.1h0Z', + fill: 'currentColor' + }, + child: [] + }, + { + tag: 'path', + attr: { + d: 'M22.23,70.11c1.6-.3,3.2.7,3.5,2.4l9,45.1c.3,1.5,1.5,2.5,3,2.5,1.7,0,3-1.3,3-3v-34.1c0-1.7,1.3-3,3-3s3,1.3,3,3v34c0,1.6,1.4,3,3.1,3h1c1.5,0,2.7-1,3-2.5l9.1-45.1c.3-1.6,1.9-2.7,3.5-2.3,1.6.3,2.7,1.9,2.3,3.5l-9.1,45c-.7,4.3-4.5,7.4-8.9,7.4h-1c-2.3,0-4.5-.9-6.1-2.3-1.6,1.4-3.7,2.3-6,2.3-4.3,0-7.9-3-8.8-7.3l-9-45.1c-.3-1.6.8-3.2,2.4-3.5Z', + fill: 'currentColor' + }, + child: [] + }, + { + tag: 'path', + attr: { + d: 'M100.75,7.39c-11.85,0-21.5,9.64-21.5,21.5,0,1,.07,1.99.21,2.95.27,2.03.83,3.96,1.64,5.76,3.35,7.53,10.9,12.79,19.65,12.79,11.86,0,21.5-9.65,21.5-21.5s-9.64-21.5-21.5-21.5ZM100.75,45.39c-9.1,0-16.5-7.4-16.5-16.5s7.4-16.5,16.5-16.5,16.5,7.4,16.5,16.5-7.4,16.5-16.5,16.5Z', + fill: 'currentColor' + }, + child: [] + } + ] + })(props) +} + +export default MihomoIcon