import { mkdir, writeFile, rm, readdir, cp, stat, rename } from 'fs/promises' import { existsSync } from 'fs' import { exec } from 'child_process' import { promisify } from 'util' import path from 'path' import { app, dialog } from 'electron' import { startPacServer, startSubStoreBackendServer, startSubStoreFrontendServer } from '../resolve/server' import { triggerSysProxy } from '../sys/sysproxy' import { getAppConfig, getControledMihomoConfig, patchAppConfig, patchControledMihomoConfig } from '../config' import { startSSIDCheck } from '../sys/ssid' import i18next, { resources } from '../../shared/i18n' import { stringify } from './yaml' import { defaultConfig, defaultControledMihomoConfig, defaultOverrideConfig, defaultProfile, defaultProfileConfig } from './template' import { appConfigPath, controledMihomoConfigPath, dataDir, logDir, mihomoTestDir, mihomoWorkDir, overrideConfigPath, overrideDir, profileConfigPath, profilePath, profilesDir, resourcesFilesDir, rulesDir, subStoreDir, themesDir } from './dirs' import { initLogger } from './logger' let isInitBasicCompleted = false export function safeShowErrorBox(titleKey: string, message: string): void { let title: string try { title = i18next.t(titleKey) if (!title || title === titleKey) throw new Error('Translation not ready') } catch { const isZh = app.getLocale().startsWith('zh') const lang = isZh ? resources['zh-CN'].translation : resources['en-US'].translation title = lang[titleKey] || (isZh ? '错误' : 'Error') } dialog.showErrorBox(title, message) } async function fixDataDirPermissions(): Promise { if (process.platform !== 'darwin') return const dataDirPath = dataDir() if (!existsSync(dataDirPath)) return try { const stats = await stat(dataDirPath) const currentUid = process.getuid?.() || 0 if (stats.uid === 0 && currentUid !== 0) { const execPromise = promisify(exec) const username = process.env.USER || process.env.LOGNAME if (username) { await execPromise(`chown -R "${username}:staff" "${dataDirPath}"`) await execPromise(`chmod -R u+rwX "${dataDirPath}"`) } } } catch { // ignore } } async function isSourceNewer(sourcePath: string, targetPath: string): Promise { try { const [sourceStats, targetStats] = await Promise.all([stat(sourcePath), stat(targetPath)]) return sourceStats.mtime > targetStats.mtime } catch { return true } } async function initDirs(): Promise { await fixDataDirPermissions() const dirsToCreate = [ dataDir(), themesDir(), profilesDir(), overrideDir(), rulesDir(), mihomoWorkDir(), logDir(), mihomoTestDir(), subStoreDir() ] await Promise.all( dirsToCreate.map(async (dir) => { if (!existsSync(dir)) { await mkdir(dir, { recursive: true }) } }) ) } async function initConfig(): Promise { const configs = [ { path: appConfigPath(), content: defaultConfig, name: 'app config' }, { path: profileConfigPath(), content: defaultProfileConfig, name: 'profile config' }, { path: overrideConfigPath(), content: defaultOverrideConfig, name: 'override config' }, { path: profilePath('default'), content: defaultProfile, name: 'default profile' }, { path: controledMihomoConfigPath(), content: defaultControledMihomoConfig, name: 'mihomo config' } ] await Promise.all( configs.map(async (config) => { if (!existsSync(config.path)) { await writeFile(config.path, stringify(config.content)) } }) ) } async function killOldMihomoProcesses(): Promise { if (process.platform !== 'win32') return const execPromise = promisify(exec) try { const { stdout } = await execPromise( 'powershell -NoProfile -Command "Get-Process | Where-Object {$_.ProcessName -like \'*mihomo*\'} | Select-Object Id | ConvertTo-Json"', { encoding: 'utf8' } ) if (!stdout.trim()) return 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 initLogger.info(`Terminated old mihomo process ${pid}`) } catch { // 进程可能退出 } } } await new Promise((resolve) => setTimeout(resolve, 200)) } catch { // 忽略错误 } } async function initFiles(): Promise { await killOldMihomoProcesses() const copyFile = async (file: string): Promise => { const sourcePath = path.join(resourcesFilesDir(), file) if (!existsSync(sourcePath)) return const targets = [path.join(mihomoWorkDir(), file), path.join(mihomoTestDir(), file)] await Promise.all( targets.map(async (targetPath) => { const shouldCopy = !existsSync(targetPath) || (await isSourceNewer(sourcePath, targetPath)) if (!shouldCopy) return try { await cp(sourcePath, targetPath, { recursive: true, force: true }) } catch (error: unknown) { const code = (error as NodeJS.ErrnoException).code // 文件被占用或权限问题,如果目标已存在则跳过 if ( (code === 'EPERM' || code === 'EBUSY' || code === 'EACCES') && existsSync(targetPath) ) { await initLogger.warn(`Skipping ${file}: file is in use or permission denied`) return } throw error } }) ) } const files = [ 'country.mmdb', 'geoip.metadb', 'geoip.dat', 'geosite.dat', 'ASN.mmdb', 'sub-store.bundle.cjs', 'sub-store-frontend' ] const criticalFiles = ['country.mmdb', 'geoip.dat', 'geosite.dat'] const results = await Promise.allSettled(files.map(copyFile)) for (let i = 0; i < results.length; i++) { const result = results[i] if (result.status === 'rejected') { const file = files[i] await initLogger.error(`Failed to copy ${file}`, result.reason) if (criticalFiles.includes(file)) { throw new Error(`Failed to copy critical file ${file}: ${result.reason}`) } } } } async function cleanup(): Promise { const [dataFiles, logFiles] = await Promise.all([readdir(dataDir()), readdir(logDir())]) // 清理更新缓存 const cacheExtensions = ['.exe', '.pkg', '.7z'] const cacheCleanup = dataFiles .filter((file) => cacheExtensions.some((ext) => file.endsWith(ext))) .map((file) => rm(path.join(dataDir(), file)).catch(() => {})) // 清理过期日志 const { maxLogDays = 7 } = await getAppConfig() const maxAge = maxLogDays * 24 * 60 * 60 * 1000 const datePattern = /^\d{4}-\d{2}-\d{2}/ const logCleanup = logFiles .filter((log) => { const match = log.match(datePattern) if (!match) return false const date = new Date(match[0]) return !isNaN(date.getTime()) && Date.now() - date.getTime() > maxAge }) .map((log) => rm(path.join(logDir(), log)).catch(() => {})) await Promise.all([...cacheCleanup, ...logCleanup]) } async function migrateSubStoreFiles(): Promise { const oldJsPath = path.join(mihomoWorkDir(), 'sub-store.bundle.js') const newCjsPath = path.join(mihomoWorkDir(), 'sub-store.bundle.cjs') if (existsSync(oldJsPath) && !existsSync(newCjsPath)) { try { await rename(oldJsPath, newCjsPath) } catch (error) { await initLogger.error('Failed to rename sub-store.bundle.js to sub-store.bundle.cjs', error) } } } // 迁移:添加 substore 到侧边栏 async function migrateSiderOrder(): Promise { const { siderOrder = [], useSubStore = true } = await getAppConfig() if (useSubStore && !siderOrder.includes('substore')) { await patchAppConfig({ siderOrder: [...siderOrder, 'substore'] }) } } // 迁移:修复 appTheme async function migrateAppTheme(): Promise { const { appTheme = 'system' } = await getAppConfig() if (!['system', 'light', 'dark'].includes(appTheme)) { await patchAppConfig({ appTheme: 'system' }) } } // 迁移:envType 字符串转数组 async function migrateEnvType(): Promise { const { envType } = await getAppConfig() if (typeof envType === 'string') { await patchAppConfig({ envType: [envType] }) } } // 迁移:禁用托盘时必须显示悬浮窗 async function migrateTraySettings(): Promise { const { showFloatingWindow = false, disableTray = false } = await getAppConfig() if (!showFloatingWindow && disableTray) { await patchAppConfig({ disableTray: false }) } } // 迁移:移除加密密码 async function migrateRemovePassword(): Promise { const { encryptedPassword } = await getAppConfig() if (encryptedPassword) { await patchAppConfig({ encryptedPassword: undefined }) } } // 迁移:mihomo 配置默认值 async function migrateMihomoConfig(): Promise { const config = await getControledMihomoConfig() const patches: Partial = {} // skip-auth-prefixes if (!config['skip-auth-prefixes']) { patches['skip-auth-prefixes'] = ['127.0.0.1/32', '::1/128'] } else if ( config['skip-auth-prefixes'].length >= 1 && config['skip-auth-prefixes'][0] === '127.0.0.1/32' && !config['skip-auth-prefixes'].includes('::1/128') ) { patches['skip-auth-prefixes'] = [ '127.0.0.1/32', '::1/128', ...config['skip-auth-prefixes'].slice(1) ] } // 其他默认值 if (!config.authentication) patches.authentication = [] if (!config['bind-address']) patches['bind-address'] = '*' if (!config['lan-allowed-ips']) patches['lan-allowed-ips'] = ['0.0.0.0/0', '::/0'] if (!config['lan-disallowed-ips']) patches['lan-disallowed-ips'] = [] // tun device if (!config.tun?.device || (process.platform === 'darwin' && config.tun.device === 'Mihomo')) { patches.tun = { ...config.tun, device: process.platform === 'darwin' ? 'utun1500' : 'Mihomo' } } // 移除废弃配置 if (config['external-controller-unix']) patches['external-controller-unix'] = undefined if (config['external-controller-pipe']) patches['external-controller-pipe'] = undefined if (config['external-controller'] === undefined) patches['external-controller'] = '' if (Object.keys(patches).length > 0) { await patchControledMihomoConfig(patches) } } async function migration(): Promise { await Promise.all([ migrateSiderOrder(), migrateAppTheme(), migrateEnvType(), migrateTraySettings(), migrateRemovePassword(), migrateMihomoConfig() ]) } function initDeeplink(): void { if (process.defaultApp) { if (process.argv.length >= 2) { app.setAsDefaultProtocolClient('clash', process.execPath, [path.resolve(process.argv[1])]) app.setAsDefaultProtocolClient('mihomo', process.execPath, [path.resolve(process.argv[1])]) } } else { app.setAsDefaultProtocolClient('clash') app.setAsDefaultProtocolClient('mihomo') } } export async function initBasic(): Promise { if (isInitBasicCompleted) return await initDirs() await initConfig() await migration() await migrateSubStoreFiles() await initFiles() await cleanup() isInitBasicCompleted = true } export async function init(): Promise { const { sysProxy } = await getAppConfig() const initTasks: Promise[] = [ startSubStoreFrontendServer(), startSubStoreBackendServer(), startSSIDCheck() ] initTasks.push( (async (): Promise => { try { if (sysProxy.enable) { await startPacServer() } await triggerSysProxy(sysProxy.enable) } catch { // ignore } })() ) await Promise.all(initTasks) initDeeplink() }