diff --git a/changelog.md b/changelog.md index 1644d6a5..cd36c6c0 100644 --- a/changelog.md +++ b/changelog.md @@ -5,6 +5,7 @@ ## 性能优化 (Performance) - 重构简化连接页 +- 优化软件启动和退出速度 # 1.9.5 diff --git a/src/main/core/admin.ts b/src/main/core/admin.ts index 10cb6844..24cd4469 100644 --- a/src/main/core/admin.ts +++ b/src/main/core/admin.ts @@ -1,32 +1,48 @@ -import { exec } from 'child_process' +import { execFile } from 'child_process' import { promisify } from 'util' import { managerLogger } from '../utils/logger' -const execPromise = promisify(exec) +const execFilePromise = promisify(execFile) + +// admin 状态在 Node 进程生命周期内不会变化,永久缓存 +let adminPrivilegePromise: Promise | null = null + +// 允许 lifecycle.ts 同步检测的结果直接填入缓存,避免重复跑一次 fltmc +export function primeAdminPrivilegesCache(value: boolean): void { + if (adminPrivilegePromise) return + adminPrivilegePromise = Promise.resolve(value) + managerLogger.info(`Admin privileges primed from sync check: ${value}`) +} 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`) + if (adminPrivilegePromise) return adminPrivilegePromise + adminPrivilegePromise = (async () => { try { - await execPromise('chcp 65001 >nul 2>&1 && net session', { encoding: 'utf8' }) - managerLogger.info('Admin privileges confirmed via net session') + await execFilePromise('fltmc', [], { windowsHide: true, timeout: 1500 }) + managerLogger.info('Admin privileges confirmed via fltmc') 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 + } 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 execFilePromise('net', ['session'], { windowsHide: true, timeout: 1500 }) + 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 + } } - } + })() + + return adminPrivilegePromise } diff --git a/src/main/core/manager.ts b/src/main/core/manager.ts index 64d1dade..410b2d36 100644 --- a/src/main/core/manager.ts +++ b/src/main/core/manager.ts @@ -308,7 +308,7 @@ function setupCoreListeners( if (process.platform === 'win32') { managerLogger.info('Attempting Windows pipe cleanup and retry...') try { - await cleanupWindowsNamedPipes() + await cleanupWindowsNamedPipes(true) await new Promise((r) => setTimeout(r, 2000)) } catch (cleanupError) { managerLogger.error('Pipe cleanup failed:', cleanupError) @@ -350,10 +350,12 @@ function setupCoreListeners( await waitForCoreReady() await getAxios(true) - await startMihomoTraffic() - await startMihomoConnections() - await startMihomoLogs() - await startMihomoMemory() + await Promise.all([ + startMihomoTraffic(), + startMihomoConnections(), + startMihomoLogs(), + startMihomoMemory() + ]) retry = 10 } }) @@ -380,12 +382,12 @@ export async function startCore(detached = false, skipStop = false): Promise { - try { - if (!force) { + if (!force && process.platform === 'darwin') { + try { await recoverDNS() + } catch (error) { + managerLogger.error('recover dns failed', error) } - } catch (error) { - managerLogger.error('recover dns failed', error) } if (child) { diff --git a/src/main/core/permissions.ts b/src/main/core/permissions.ts index dcbdb55e..a809b288 100644 --- a/src/main/core/permissions.ts +++ b/src/main/core/permissions.ts @@ -133,42 +133,65 @@ async function checkHighPrivilegeMihomoProcess(): Promise { 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)) + let stdout = '' + try { + const result = await execFilePromise('tasklist', ['/FO', 'CSV', '/NH'], { + windowsHide: true, + timeout: 3000, + maxBuffer: 4 * 1024 * 1024 + }) + stdout = result.stdout + } catch (error) { + managerLogger.error('Failed to list processes via tasklist', error) + return false + } - 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) + const candidatePids: { pid: string; image: string }[] = [] + for (const line of stdout.split('\n')) { + const match = line.match(/^"([^"]+)","(\d+)"/) + if (!match) continue + const image = match[1].toLowerCase() + if (mihomoExecutables.includes(image)) { + candidatePids.push({ pid: match[2], image }) } } + + if (candidatePids.length === 0) { + managerLogger.info('No mihomo processes found running') + return false + } + + managerLogger.info(`Found ${candidatePids.length} mihomo processes running`) + + const pidArgs = candidatePids.map(({ pid }) => pid).join(',') + try { + const { stdout: processInfo } = await execFilePromise( + 'powershell', + [ + '-NoProfile', + '-Command', + `Get-Process -Id ${pidArgs} -ErrorAction SilentlyContinue | Select-Object Name,Id,Path | ConvertTo-Json -Compress` + ], + { windowsHide: true, timeout: 4000, maxBuffer: 4 * 1024 * 1024 } + ) + + if (!processInfo.trim()) return false + + const parsed = JSON.parse(processInfo) + const list = Array.isArray(parsed) ? parsed : [parsed] + for (const proc of list) { + if ( + proc && + typeof proc.Name === 'string' && + proc.Name.toLowerCase().includes('mihomo') && + proc.Path === null + ) { + return true + } + } + } catch (error) { + managerLogger.info('PowerShell process inspection failed', error) + } } else { let foundProcesses = false diff --git a/src/main/core/process.ts b/src/main/core/process.ts index cce52ec5..114f77e5 100644 --- a/src/main/core/process.ts +++ b/src/main/core/process.ts @@ -1,4 +1,4 @@ -import { exec } from 'child_process' +import { exec, execFile } from 'child_process' import { promisify } from 'util' import { rm } from 'fs/promises' import { existsSync } from 'fs' @@ -6,6 +6,7 @@ import { managerLogger } from '../utils/logger' import { getAxios } from './mihomoApi' const execPromise = promisify(exec) +const execFilePromise = promisify(execFile) // 常量 const CORE_READY_MAX_RETRIES = 30 @@ -19,7 +20,38 @@ export async function cleanupSocketFile(): Promise { } } -export async function cleanupWindowsNamedPipes(): Promise { +// thorough=true 走 PowerShell 慢路径,仅在外部控制器监听冲突时使用 +export async function cleanupWindowsNamedPipes(thorough = false): Promise { + if (!thorough) { + try { + const { stdout } = await execFilePromise( + 'tasklist', + ['/FI', 'IMAGENAME eq mihomo*', '/FO', 'CSV', '/NH'], + { windowsHide: true, timeout: 1500, maxBuffer: 1 * 1024 * 1024 } + ) + + const pids: number[] = [] + for (const line of stdout.split('\n')) { + const match = line.match(/^"([^"]+)","(\d+)"/) + if (!match) continue + const pid = parseInt(match[2], 10) + if (!isNaN(pid) && pid !== process.pid) pids.push(pid) + } + + if (pids.length === 0) return + + for (const pid of pids) { + await terminateProcess(pid) + } + + // 给进程留出退出窗口,避免 pipe 占用导致后续启动失败 + await new Promise((resolve) => setTimeout(resolve, 200)) + } catch (error) { + managerLogger.warn('Lightweight pipe cleanup failed:', error) + } + return + } + try { try { const { stdout } = await execPromise( diff --git a/src/main/index.ts b/src/main/index.ts index 5ee0f65a..7b9608ca 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -15,7 +15,7 @@ import { initCoreWatcher } from './core/manager' import { createTray } from './resolve/tray' -import { init, initBasic, safeShowErrorBox } from './utils/init' +import { init, initBasic, safeShowErrorBox, startSubStoreServices } from './utils/init' import { initShortcut } from './resolve/shortcut' import { initProfileUpdater } from './core/profileUpdater' import { startMonitor } from './resolve/trafficMonitor' @@ -48,7 +48,7 @@ function getWindowsPowerShellMajorVersion(): number | null { try { const stdout = execFileSync('reg', ['query', key, '/v', 'PowerShellVersion'], { encoding: 'utf8', - timeout: 1000 + timeout: 800 }) const version = stdout.match(/PowerShellVersion\s+REG_\w+\s+([^\s]+)/)?.[1] const major = version ? parseInt(version.split('.')[0], 10) : NaN @@ -61,6 +61,7 @@ function getWindowsPowerShellMajorVersion(): number | null { return null } +// PowerShell 版本过低必须在 app 启动前提示并退出,因此保持同步执行 if (process.platform === 'win32') { try { const major = getWindowsPowerShellMajorVersion() @@ -103,50 +104,6 @@ initApp().catch((e) => { setupPlatformSpecifics() -async function checkHighPrivilegeCoreEarly(): Promise { - if (process.platform !== 'win32') return - - try { - await initBasic() - const isCurrentAppAdmin = await checkAdminPrivileges() - if (isCurrentAppAdmin) return - - const hasHighPrivilegeCore = await checkHighPrivilegeCore() - if (!hasHighPrivilegeCore) return - - try { - const appConfig = await getAppConfig() - const language = appConfig.language || (app.getLocale().startsWith('zh') ? 'zh-CN' : 'en-US') - await initI18n({ lng: language }) - } catch { - await initI18n({ lng: 'zh-CN' }) - } - - const choice = dialog.showMessageBoxSync({ - type: 'warning', - title: i18next.t('core.highPrivilege.title'), - message: i18next.t('core.highPrivilege.message'), - buttons: [i18next.t('common.confirm'), i18next.t('common.cancel')], - defaultId: 0, - cancelId: 1 - }) - - if (choice === 0) { - try { - await restartAsAdmin(false) - app.exit(0) - } catch (error) { - safeShowErrorBox('common.error.adminRequired', `${error}`) - app.exit(1) - } - } else { - app.exit(0) - } - } catch (e) { - mainLogger.error('Failed to check high privilege core', e) - } -} - async function initHardwareAcceleration(): Promise { try { await initBasic() @@ -177,23 +134,69 @@ app.on('open-url', async (_event, url) => { const initPromise = (async () => { await initBasic() - await checkHighPrivilegeCoreEarly() + + const adminPromise: Promise = + process.platform === 'win32' ? checkAdminPrivileges().catch(() => false) : Promise.resolve(true) + + const appConfigPromise = (async () => { + try { + const cfg = await getAppConfig() + if (!cfg.language) { + const systemLanguage = getSystemLanguage() + await patchAppConfig({ language: systemLanguage }) + cfg.language = systemLanguage + } + await initI18n({ lng: cfg.language }) + return cfg + } catch (e) { + safeShowErrorBox('common.error.initFailed', `${e}`) + app.quit() + throw e + } + })() + + await adminPromise await initAdminStatus() - try { - const appConfig = await getAppConfig() - if (!appConfig.language) { - const systemLanguage = getSystemLanguage() - await patchAppConfig({ language: systemLanguage }) - appConfig.language = systemLanguage + if (process.platform === 'win32') { + const isAdmin = await adminPromise + if (!isAdmin) { + try { + const hasHighPrivilegeCore = await checkHighPrivilegeCore() + if (hasHighPrivilegeCore) { + try { + await appConfigPromise + } catch { + await initI18n({ lng: 'zh-CN' }) + } + const choice = dialog.showMessageBoxSync({ + type: 'warning', + title: i18next.t('core.highPrivilege.title'), + message: i18next.t('core.highPrivilege.message'), + buttons: [i18next.t('common.confirm'), i18next.t('common.cancel')], + defaultId: 0, + cancelId: 1 + }) + + if (choice === 0) { + try { + await restartAsAdmin(false) + app.exit(0) + } catch (error) { + safeShowErrorBox('common.error.adminRequired', `${error}`) + app.exit(1) + } + } else { + app.exit(0) + } + } + } catch (e) { + mainLogger.error('Failed to check high privilege core', e) + } } - await initI18n({ lng: appConfig.language }) - return appConfig - } catch (e) { - safeShowErrorBox('common.error.initFailed', `${e}`) - app.quit() - throw e } + + return appConfigPromise })() app.whenReady().then(async () => { @@ -219,9 +222,15 @@ app.whenReady().then(async () => { const startPromises = await startCore() if (startPromises.length > 0) { startPromises[0].then(async () => { - await initProfileUpdater() - await initWebdavBackupScheduler() - await checkAdminRestartForTun() + await Promise.allSettled([ + initProfileUpdater().catch((e) => mainLogger.warn('Failed to init profile updater', e)), + initWebdavBackupScheduler().catch((e) => + mainLogger.warn('Failed to init webdav backup scheduler', e) + ), + checkAdminRestartForTun().catch((e) => + mainLogger.warn('Failed admin-restart-for-tun follow-up', e) + ) + ]) }) } coreStarted = true @@ -240,6 +249,10 @@ app.whenReady().then(async () => { await createWindowPromise + void startSubStoreServices().catch((e) => + mainLogger.warn('Failed to start sub-store services', e) + ) + const { showFloatingWindow: showFloating = false, disableTray = false } = appConfig const uiTasks: Promise[] = [initShortcut()] diff --git a/src/main/lifecycle.ts b/src/main/lifecycle.ts index e5681f73..2039cd2b 100644 --- a/src/main/lifecycle.ts +++ b/src/main/lifecycle.ts @@ -1,9 +1,10 @@ -import { spawn, exec, execSync } from 'child_process' +import { spawn, exec, execFileSync } from 'child_process' import { promisify } from 'util' import { stat } from 'fs/promises' import { existsSync } from 'fs' import { app, powerMonitor } from 'electron' import { stopCore, cleanupCoreWatcher } from './core/manager' +import { primeAdminPrivilegesCache } from './core/admin' import { triggerSysProxy, disableSysProxySync } from './sys/sysproxy' import { exePath } from './utils/dirs' @@ -55,15 +56,19 @@ export function setupPlatformSpecifics(): void { app.commandLine.appendSwitch('in-process-gpu') } - if (process.platform === 'win32' && isWindowsElevatedSync()) { - app.commandLine.appendSwitch('disable-gpu-sandbox') + if (process.platform === 'win32') { + const elevated = isWindowsElevatedSync() + if (elevated) { + primeAdminPrivilegesCache(true) + app.commandLine.appendSwitch('disable-gpu-sandbox') + } } } function isWindowsElevatedSync(): boolean { if (process.platform !== 'win32') return false try { - execSync('fltmc', { stdio: 'ignore', windowsHide: true }) + execFileSync('fltmc', [], { stdio: 'ignore', windowsHide: true, timeout: 800 }) return true } catch { return false @@ -107,7 +112,7 @@ export function setupAppLifecycle(): void { }), stopCore() ]).then(() => {}), - 3000 + 1200 ) } diff --git a/src/main/utils/init.ts b/src/main/utils/init.ts index da3206b5..cf69b4fc 100644 --- a/src/main/utils/init.ts +++ b/src/main/utils/init.ts @@ -147,7 +147,11 @@ async function killOldMihomoProcesses(): Promise { try { const execFilePromise = promisify(execFile) const coreNames = new Set(['mihomo.exe', 'mihomo-alpha.exe', 'mihomo-smart.exe']) - const { stdout } = await execFilePromise('tasklist', ['/FO', 'CSV', '/NH']) + const { stdout } = await execFilePromise('tasklist', ['/FO', 'CSV', '/NH'], { + windowsHide: true, + timeout: 3000, + maxBuffer: 4 * 1024 * 1024 + }) const pids = stdout .split('\n') @@ -445,13 +449,7 @@ export async function ensureRuntimeFiles(): Promise { export async function init(): Promise { const { sysProxy } = await getAppConfig() - const initTasks: Promise[] = [ - (async (): Promise => { - await ensureRuntimeFiles() - await Promise.all([startSubStoreFrontendServer(), startSubStoreBackendServer()]) - })(), - startSSIDCheck() - ] + const initTasks: Promise[] = [ensureRuntimeFiles(), startSSIDCheck()] initTasks.push( (async (): Promise => { @@ -469,3 +467,8 @@ export async function init(): Promise { await Promise.all(initTasks) initDeeplink() } + +export async function startSubStoreServices(): Promise { + await ensureRuntimeFiles() + await Promise.all([startSubStoreFrontendServer(), startSubStoreBackendServer()]) +}