From c1f7a862aa8caa3fec8d82792332938375f4b083 Mon Sep 17 00:00:00 2001 From: xmk23333 Date: Thu, 1 Jan 2026 09:35:45 +0800 Subject: [PATCH] refactor: split main process into modules and extract tour logic --- src/main/core/manager.ts | 2 +- src/main/core/mihomoApi.ts | 2 +- src/main/core/subStoreApi.ts | 8 +- src/main/deeplink.ts | 32 ++ src/main/index.ts | 406 ++++-------------- src/main/lifecycle.ts | 75 ++++ src/main/resolve/shortcut.ts | 2 +- src/main/resolve/theme.ts | 2 +- src/main/resolve/tray.ts | 2 +- src/main/sys/ssid.ts | 2 +- src/main/utils/ipc.ts | 2 +- src/main/window.ts | 173 ++++++++ src/preload/index.ts | 6 +- src/renderer/src/App.tsx | 193 +-------- .../components/profiles/edit-rules-modal.tsx | 146 ++++--- src/renderer/src/hooks/use-profile-config.tsx | 2 + src/renderer/src/pages/connections.tsx | 3 +- src/renderer/src/utils/ipc.ts | 15 +- src/renderer/src/utils/tour.ts | 195 +++++++++ 19 files changed, 654 insertions(+), 614 deletions(-) create mode 100644 src/main/deeplink.ts create mode 100644 src/main/lifecycle.ts create mode 100644 src/main/window.ts create mode 100644 src/renderer/src/utils/tour.ts diff --git a/src/main/core/manager.ts b/src/main/core/manager.ts index a6a36c6..aa21757 100644 --- a/src/main/core/manager.ts +++ b/src/main/core/manager.ts @@ -33,7 +33,7 @@ import { import chokidar from 'chokidar' import { readFile, rm, writeFile } from 'fs/promises' import { promisify } from 'util' -import { mainWindow } from '..' +import { mainWindow } from '../window' import path from 'path' import os from 'os' import { createWriteStream, existsSync } from 'fs' diff --git a/src/main/core/mihomoApi.ts b/src/main/core/mihomoApi.ts index 2f9ae0d..a372551 100644 --- a/src/main/core/mihomoApi.ts +++ b/src/main/core/mihomoApi.ts @@ -1,6 +1,6 @@ import axios, { AxiosInstance } from 'axios' import { getAppConfig, getControledMihomoConfig } from '../config' -import { mainWindow } from '..' +import { mainWindow } from '../window' import WebSocket from 'ws' import { tray } from '../resolve/tray' import { calcTraffic } from '../utils/calc' diff --git a/src/main/core/subStoreApi.ts b/src/main/core/subStoreApi.ts index 778f755..f3c87ab 100644 --- a/src/main/core/subStoreApi.ts +++ b/src/main/core/subStoreApi.ts @@ -5,13 +5,17 @@ import { getAppConfig } from '../config' export async function subStoreSubs(): Promise { const { useCustomSubStore = false, customSubStoreUrl = '' } = await getAppConfig() const baseUrl = useCustomSubStore ? customSubStoreUrl : `http://127.0.0.1:${subStorePort}` - const res = await chromeRequest.get<{ data: ISubStoreSub[] }>(`${baseUrl}/api/subs`, { responseType: 'json' }) + const res = await chromeRequest.get<{ data: ISubStoreSub[] }>(`${baseUrl}/api/subs`, { + responseType: 'json' + }) return res.data.data } export async function subStoreCollections(): Promise { const { useCustomSubStore = false, customSubStoreUrl = '' } = await getAppConfig() const baseUrl = useCustomSubStore ? customSubStoreUrl : `http://127.0.0.1:${subStorePort}` - const res = await chromeRequest.get<{ data: ISubStoreSub[] }>(`${baseUrl}/api/collections`, { responseType: 'json' }) + const res = await chromeRequest.get<{ data: ISubStoreSub[] }>(`${baseUrl}/api/collections`, { + responseType: 'json' + }) return res.data.data } diff --git a/src/main/deeplink.ts b/src/main/deeplink.ts new file mode 100644 index 0000000..b12c354 --- /dev/null +++ b/src/main/deeplink.ts @@ -0,0 +1,32 @@ +import { Notification } from 'electron' +import i18next from 'i18next' +import { addProfileItem } from './config' +import { mainWindow } from './window' +import { safeShowErrorBox } from './utils/init' + +export async function handleDeepLink(url: string): Promise { + if (!url.startsWith('clash://') && !url.startsWith('mihomo://')) return + + const urlObj = new URL(url) + switch (urlObj.host) { + case 'install-config': { + try { + const profileUrl = urlObj.searchParams.get('url') + const profileName = urlObj.searchParams.get('name') + if (!profileUrl) { + throw new Error(i18next.t('profiles.error.urlParamMissing')) + } + await addProfileItem({ + type: 'remote', + name: profileName ?? undefined, + url: profileUrl + }) + mainWindow?.webContents.send('profileConfigUpdated') + new Notification({ title: i18next.t('profiles.notification.importSuccess') }).show() + } catch (e) { + safeShowErrorBox('profiles.error.importFailed', `${url}\n${e}`) + } + break + } + } +} diff --git a/src/main/index.ts b/src/main/index.ts index fafaf9a..d100546 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1,64 +1,43 @@ -import { electronApp, optimizer, is } from '@electron-toolkit/utils' +import { electronApp, optimizer } from '@electron-toolkit/utils' +import { app, dialog } from 'electron' 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 { getAppConfig, patchAppConfig } from './config' import { - quitWithoutCore, startCore, - stopCore, checkAdminRestartForTun, checkHighPrivilegeCore, restartAsAdmin, - initAdminStatus + initAdminStatus, + checkAdminPrivileges } from './core/manager' -import { triggerSysProxy } from './sys/sysproxy' -import icon from '../../resources/icon.png?asset' -import { createTray, hideDockIcon, showDockIcon } from './resolve/tray' +import { createTray } from './resolve/tray' import { init, initBasic, safeShowErrorBox } from './utils/init' -import { join } from 'path' import { initShortcut } from './resolve/shortcut' -import { spawn, exec } from 'child_process' -import { promisify } from 'util' -import { stat } from 'fs/promises' import { initProfileUpdater } from './core/profileUpdater' -import { existsSync } from 'fs' -import { exePath } from './utils/dirs' import { startMonitor } from './resolve/trafficMonitor' import { showFloatingWindow } from './resolve/floatingWindow' import { initI18n } from '../shared/i18n' import i18next from 'i18next' import { logger } from './utils/logger' import { initWebdavBackupScheduler } from './resolve/backup' +import { + createWindow, + mainWindow, + showMainWindow, + triggerMainWindow, + closeMainWindow +} from './window' +import { handleDeepLink } from './deeplink' +import { + fixUserDataPermissions, + setupPlatformSpecifics, + setupAppLifecycle, + getSystemLanguage +} from './lifecycle' -async function fixUserDataPermissions(): Promise { - if (process.platform !== 'darwin') return - - const userDataPath = app.getPath('userData') - if (!existsSync(userDataPath)) return - - try { - const stats = await stat(userDataPath) - 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" "${userDataPath}"`) - await execPromise(`chmod -R u+rwX "${userDataPath}"`) - } - } - } catch { - // ignore - } -} - -let quitTimeout: NodeJS.Timeout | null = null -export let mainWindow: BrowserWindow | null = null +export { mainWindow, showMainWindow, triggerMainWindow, closeMainWindow } const gotTheLock = app.requestSingleInstanceLock() - if (!gotTheLock) { app.quit() } @@ -72,85 +51,67 @@ initApp().catch((e) => { app.quit() }) -export function customRelaunch(): void { - const script = `while kill -0 ${process.pid} 2>/dev/null; do - sleep 0.1 -done -${process.argv.join(' ')} & disown -exit -` - spawn('sh', ['-c', `"${script}"`], { - shell: true, - detached: true, - stdio: 'ignore' - }) -} +setupPlatformSpecifics() -if (process.platform === 'linux') { - app.relaunch = customRelaunch -} - -if (process.platform === 'win32' && !exePath().startsWith('C')) { - // https://github.com/electron/electron/issues/43278 - // https://github.com/electron/electron/issues/36698 - app.commandLine.appendSwitch('in-process-gpu') -} - -// 运行内核检测 async function checkHighPrivilegeCoreEarly(): Promise { - if (process.platform !== 'win32') { - return - } + if (process.platform !== 'win32') return try { await initBasic() - - const { checkAdminPrivileges } = await import('./core/manager') const isCurrentAppAdmin = await checkAdminPrivileges() - - if (isCurrentAppAdmin) { - console.log('Current app is running as administrator, skipping privilege check') - return - } + if (isCurrentAppAdmin) return const hasHighPrivilegeCore = await checkHighPrivilegeCore() - if (hasHighPrivilegeCore) { + 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 { - 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 { - // Windows 平台重启应用获取管理员权限 - await restartAsAdmin(false) - process.exit(0) - } catch (error) { - safeShowErrorBox('common.error.adminRequired', `${error}`) - process.exit(1) - } - } else { + await restartAsAdmin(false) process.exit(0) + } catch (error) { + safeShowErrorBox('common.error.adminRequired', `${error}`) + process.exit(1) } + } else { + process.exit(0) } } catch (e) { console.error('Failed to check high privilege core:', e) } } +async function initHardwareAcceleration(): Promise { + try { + await initBasic() + const { disableHardwareAcceleration = false } = await getAppConfig() + if (disableHardwareAcceleration) { + app.disableHardwareAcceleration() + } + } catch (e) { + console.warn('Failed to read hardware acceleration config:', e) + } +} + +initHardwareAcceleration() +setupAppLifecycle() + app.on('second-instance', async (_event, commandline) => { showMainWindow() const url = commandline.pop() @@ -164,58 +125,16 @@ app.on('open-url', async (_event, url) => { await handleDeepLink(url) }) -app.on('before-quit', async (e) => { - e.preventDefault() - triggerSysProxy(false) - await stopCore() - app.exit() -}) - -powerMonitor.on('shutdown', async () => { - triggerSysProxy(false) - await stopCore() - app.exit() -}) - -// 获取系统语言 -function getSystemLanguage(): 'zh-CN' | 'en-US' { - const locale = app.getLocale() - return locale.startsWith('zh') ? 'zh-CN' : 'en-US' -} - -// 硬件加速设置 -async function initHardwareAcceleration(): Promise { - try { - await initBasic() - const { disableHardwareAcceleration = false } = await getAppConfig() - if (disableHardwareAcceleration) { - app.disableHardwareAcceleration() - } - } catch (e) { - console.warn('Failed to read hardware acceleration config:', e) - } -} - -initHardwareAcceleration() - -// This method will be called when Electron has finished -// initialization and is ready to create browser windows. -// Some APIs can only be used after this event occurs. app.whenReady().then(async () => { - // Set app user model id for windows electronApp.setAppUserModelId('party.mihomo.app') await initBasic() - await checkHighPrivilegeCoreEarly() - await initAdminStatus() try { await init() - const appConfig = await getAppConfig() - // 如果配置中没有语言设置,则使用系统语言 if (!appConfig.language) { const systemLanguage = getSystemLanguage() await patchAppConfig({ language: systemLanguage }) @@ -231,28 +150,27 @@ app.whenReady().then(async () => { const [startPromise] = await startCore() startPromise.then(async () => { await initProfileUpdater() - await initWebdavBackupScheduler() // 初始化 WebDAV 定时备份任务 - // 上次是否为了开启 TUN 而重启 + await initWebdavBackupScheduler() await checkAdminRestartForTun() }) } catch (e) { safeShowErrorBox('mihomo.error.coreStartFailed', `${e}`) } + try { await startMonitor() } catch { // ignore } - // Default open or close DevTools by F12 in development - // and ignore CommandOrControl + R in production. - // see https://github.com/alex8088/electron-toolkit/tree/master/packages/utils app.on('browser-window-created', (_, window) => { optimizer.watchWindowShortcuts(window) }) + const { showFloatingWindow: showFloating = false, disableTray = false } = await getAppConfig() registerIpcMainHandlers() await createWindow() + if (showFloating) { try { await showFloatingWindow() @@ -260,192 +178,14 @@ app.whenReady().then(async () => { await logger.error('Failed to create floating window on startup', error) } } + if (!disableTray) { await createTray() } + await initShortcut() - app.on('activate', function () { - // On macOS it's common to re-create a window in the app when the - // dock icon is clicked and there are no other windows open. + + app.on('activate', () => { showMainWindow() }) }) - -async function handleDeepLink(url: string): Promise { - if (!url.startsWith('clash://') && !url.startsWith('mihomo://')) return - - const urlObj = new URL(url) - switch (urlObj.host) { - case 'install-config': { - try { - const profileUrl = urlObj.searchParams.get('url') - const profileName = urlObj.searchParams.get('name') - if (!profileUrl) { - throw new Error(i18next.t('profiles.error.urlParamMissing')) - } - await addProfileItem({ - type: 'remote', - name: profileName ?? undefined, - url: profileUrl - }) - mainWindow?.webContents.send('profileConfigUpdated') - new Notification({ title: i18next.t('profiles.notification.importSuccess') }).show() - break - } catch (e) { - safeShowErrorBox('profiles.error.importFailed', `${url}\n${e}`) - } - } - } -} - -export async function createWindow(): Promise { - const { useWindowFrame = false } = await getAppConfig() - const mainWindowState = windowStateKeeper({ - defaultWidth: 800, - defaultHeight: 600, - file: 'window-state.json' - }) - // https://github.com/electron/electron/issues/16521#issuecomment-582955104 - Menu.setApplicationMenu(null) - mainWindow = new BrowserWindow({ - minWidth: 800, - minHeight: 600, - width: mainWindowState.width, - height: mainWindowState.height, - x: mainWindowState.x, - y: mainWindowState.y, - show: false, - frame: useWindowFrame, - fullscreenable: false, - titleBarStyle: useWindowFrame ? 'default' : 'hidden', - titleBarOverlay: useWindowFrame - ? false - : { - height: 49 - }, - autoHideMenuBar: true, - ...(process.platform === 'linux' ? { icon: icon } : {}), - webPreferences: { - preload: join(__dirname, '../preload/index.js'), - spellcheck: false, - sandbox: false, - devTools: true - } - }) - mainWindowState.manage(mainWindow) - mainWindow.on('ready-to-show', async () => { - const { - silentStart = false, - autoQuitWithoutCore = false, - autoQuitWithoutCoreDelay = 60 - } = await getAppConfig() - if (autoQuitWithoutCore && !mainWindow?.isVisible()) { - if (quitTimeout) { - clearTimeout(quitTimeout) - } - quitTimeout = setTimeout(async () => { - await quitWithoutCore() - }, autoQuitWithoutCoreDelay * 1000) - } - if (!silentStart) { - if (quitTimeout) { - clearTimeout(quitTimeout) - } - mainWindow?.show() - mainWindow?.focusOnWebView() - } - }) - mainWindow.webContents.on('did-fail-load', () => { - mainWindow?.webContents.reload() - }) - - mainWindow.on('show', () => { - showDockIcon() - }) - - mainWindow.on('close', async (event) => { - event.preventDefault() - mainWindow?.hide() - const { - autoQuitWithoutCore = false, - autoQuitWithoutCoreDelay = 60, - useDockIcon = true - } = await getAppConfig() - if (!useDockIcon) { - hideDockIcon() - } - if (autoQuitWithoutCore) { - if (quitTimeout) { - clearTimeout(quitTimeout) - } - quitTimeout = setTimeout(async () => { - await quitWithoutCore() - }, autoQuitWithoutCoreDelay * 1000) - } - }) - - mainWindow.on('resized', () => { - if (mainWindow) mainWindowState.saveState(mainWindow) - }) - - mainWindow.on('move', () => { - if (mainWindow) mainWindowState.saveState(mainWindow) - }) - - mainWindow.on('session-end', async () => { - triggerSysProxy(false) - await stopCore() - }) - - mainWindow.webContents.setWindowOpenHandler((details) => { - shell.openExternal(details.url) - return { action: 'deny' } - }) - - // 在开发模式下自动打开 DevTools - if (is.dev) { - mainWindow.webContents.openDevTools() - } - - // HMR for renderer base on electron-vite cli. - // Load the remote URL for development or the local html file for production. - if (is.dev && process.env['ELECTRON_RENDERER_URL']) { - mainWindow.loadURL(process.env['ELECTRON_RENDERER_URL']) - } else { - mainWindow.loadFile(join(__dirname, '../renderer/index.html')) - } -} - -export function triggerMainWindow(force?: boolean): void { - if (mainWindow) { - getAppConfig() - .then(({ triggerMainWindowBehavior = 'toggle' }) => { - if (force === true || triggerMainWindowBehavior === 'toggle') { - if (mainWindow?.isVisible()) { - closeMainWindow() - } else { - showMainWindow() - } - } else { - showMainWindow() - } - }) - .catch(showMainWindow) - } -} - -export function showMainWindow(): void { - if (mainWindow) { - if (quitTimeout) { - clearTimeout(quitTimeout) - } - mainWindow.show() - mainWindow.focusOnWebView() - } -} - -export function closeMainWindow(): void { - if (mainWindow) { - mainWindow.close() - } -} diff --git a/src/main/lifecycle.ts b/src/main/lifecycle.ts new file mode 100644 index 0000000..fe2046d --- /dev/null +++ b/src/main/lifecycle.ts @@ -0,0 +1,75 @@ +import { app, powerMonitor } from 'electron' +import { spawn, exec } from 'child_process' +import { promisify } from 'util' +import { stat } from 'fs/promises' +import { existsSync } from 'fs' +import { stopCore } from './core/manager' +import { triggerSysProxy } from './sys/sysproxy' +import { exePath } from './utils/dirs' + +export function customRelaunch(): void { + const script = `while kill -0 ${process.pid} 2>/dev/null; do + sleep 0.1 +done +${process.argv.join(' ')} & disown +exit +` + spawn('sh', ['-c', `"${script}"`], { + shell: true, + detached: true, + stdio: 'ignore' + }) +} + +export async function fixUserDataPermissions(): Promise { + if (process.platform !== 'darwin') return + + const userDataPath = app.getPath('userData') + if (!existsSync(userDataPath)) return + + try { + const stats = await stat(userDataPath) + 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" "${userDataPath}"`) + await execPromise(`chmod -R u+rwX "${userDataPath}"`) + } + } + } catch { + // ignore + } +} + +export function setupPlatformSpecifics(): void { + if (process.platform === 'linux') { + app.relaunch = customRelaunch + } + + if (process.platform === 'win32' && !exePath().startsWith('C')) { + app.commandLine.appendSwitch('in-process-gpu') + } +} + +export function setupAppLifecycle(): void { + app.on('before-quit', async (e) => { + e.preventDefault() + triggerSysProxy(false) + await stopCore() + app.exit() + }) + + powerMonitor.on('shutdown', async () => { + triggerSysProxy(false) + await stopCore() + app.exit() + }) +} + +export function getSystemLanguage(): 'zh-CN' | 'en-US' { + const locale = app.getLocale() + return locale.startsWith('zh') ? 'zh-CN' : 'en-US' +} diff --git a/src/main/resolve/shortcut.ts b/src/main/resolve/shortcut.ts index beb3aa5..cdb5f23 100644 --- a/src/main/resolve/shortcut.ts +++ b/src/main/resolve/shortcut.ts @@ -1,5 +1,5 @@ import { app, globalShortcut, ipcMain, Notification } from 'electron' -import { mainWindow, triggerMainWindow } from '..' +import { mainWindow, triggerMainWindow } from '../window' import { getAppConfig, getControledMihomoConfig, diff --git a/src/main/resolve/theme.ts b/src/main/resolve/theme.ts index 3734eb2..889b164 100644 --- a/src/main/resolve/theme.ts +++ b/src/main/resolve/theme.ts @@ -5,7 +5,7 @@ import * as chromeRequest from '../utils/chromeRequest' import AdmZip from 'adm-zip' import { getControledMihomoConfig } from '../config' import { existsSync } from 'fs' -import { mainWindow } from '..' +import { mainWindow } from '../window' import { floatingWindow } from './floatingWindow' import { t } from 'i18next' diff --git a/src/main/resolve/tray.ts b/src/main/resolve/tray.ts index 9369405..62adc06 100644 --- a/src/main/resolve/tray.ts +++ b/src/main/resolve/tray.ts @@ -23,7 +23,7 @@ import { getTrayIconStatus, calculateTrayIconStatus } from '../core/mihomoApi' -import { mainWindow, showMainWindow, triggerMainWindow } from '..' +import { mainWindow, showMainWindow, triggerMainWindow } from '../window' import { app, clipboard, ipcMain, Menu, nativeImage, shell, Tray } from 'electron' import { dataDir, logDir, mihomoCoreDir, mihomoWorkDir } from '../utils/dirs' import { triggerSysProxy } from '../sys/sysproxy' diff --git a/src/main/sys/ssid.ts b/src/main/sys/ssid.ts index 6ca2d45..823d089 100644 --- a/src/main/sys/ssid.ts +++ b/src/main/sys/ssid.ts @@ -2,7 +2,7 @@ import { exec } from 'child_process' import { promisify } from 'util' import { getAppConfig, patchControledMihomoConfig } from '../config' import { patchMihomoConfig } from '../core/mihomoApi' -import { mainWindow } from '..' +import { mainWindow } from '../window' import { ipcMain, net } from 'electron' import { getDefaultDevice } from '../core/manager' diff --git a/src/main/utils/ipc.ts b/src/main/utils/ipc.ts index f4db684..5ce319d 100644 --- a/src/main/utils/ipc.ts +++ b/src/main/utils/ipc.ts @@ -104,7 +104,7 @@ import { updateTrayIconImmediate } from '../resolve/tray' import { registerShortcut } from '../resolve/shortcut' -import { closeMainWindow, mainWindow, showMainWindow, triggerMainWindow } from '..' +import { closeMainWindow, mainWindow, showMainWindow, triggerMainWindow } from '../window' import { applyTheme, fetchThemes, diff --git a/src/main/window.ts b/src/main/window.ts new file mode 100644 index 0000000..1e819f2 --- /dev/null +++ b/src/main/window.ts @@ -0,0 +1,173 @@ +import { BrowserWindow, Menu, shell } from 'electron' +import { is } from '@electron-toolkit/utils' +import windowStateKeeper from 'electron-window-state' +import { join } from 'path' +import { getAppConfig } from './config' +import { quitWithoutCore, stopCore } from './core/manager' +import { triggerSysProxy } from './sys/sysproxy' +import { hideDockIcon, showDockIcon } from './resolve/tray' +import icon from '../resources/icon.png?asset' + +export let mainWindow: BrowserWindow | null = null +let quitTimeout: NodeJS.Timeout | null = null + +export async function createWindow(): Promise { + const { useWindowFrame = false } = await getAppConfig() + const mainWindowState = windowStateKeeper({ + defaultWidth: 800, + defaultHeight: 600, + file: 'window-state.json' + }) + + Menu.setApplicationMenu(null) + mainWindow = new BrowserWindow({ + minWidth: 800, + minHeight: 600, + width: mainWindowState.width, + height: mainWindowState.height, + x: mainWindowState.x, + y: mainWindowState.y, + show: false, + frame: useWindowFrame, + fullscreenable: false, + titleBarStyle: useWindowFrame ? 'default' : 'hidden', + titleBarOverlay: useWindowFrame + ? false + : { + height: 49 + }, + autoHideMenuBar: true, + ...(process.platform === 'linux' ? { icon: icon } : {}), + webPreferences: { + preload: join(__dirname, '../preload/index.js'), + spellcheck: false, + sandbox: false, + devTools: true + } + }) + + mainWindowState.manage(mainWindow) + setupWindowEvents(mainWindow, mainWindowState) + + if (is.dev) { + mainWindow.webContents.openDevTools() + } + + if (is.dev && process.env['ELECTRON_RENDERER_URL']) { + mainWindow.loadURL(process.env['ELECTRON_RENDERER_URL']) + } else { + mainWindow.loadFile(join(__dirname, '../renderer/index.html')) + } +} + +function setupWindowEvents( + window: BrowserWindow, + windowState: ReturnType +): void { + window.on('ready-to-show', async () => { + const { + silentStart = false, + autoQuitWithoutCore = false, + autoQuitWithoutCoreDelay = 60 + } = await getAppConfig() + + if (autoQuitWithoutCore && !window.isVisible()) { + scheduleQuitWithoutCore(autoQuitWithoutCoreDelay) + } + + if (!silentStart) { + clearQuitTimeout() + window.show() + window.focusOnWebView() + } + }) + + window.webContents.on('did-fail-load', () => { + window.webContents.reload() + }) + + window.on('show', () => { + showDockIcon() + }) + + window.on('close', async (event) => { + event.preventDefault() + window.hide() + + const { + autoQuitWithoutCore = false, + autoQuitWithoutCoreDelay = 60, + useDockIcon = true + } = await getAppConfig() + + if (!useDockIcon) { + hideDockIcon() + } + + if (autoQuitWithoutCore) { + scheduleQuitWithoutCore(autoQuitWithoutCoreDelay) + } + }) + + window.on('resized', () => { + windowState.saveState(window) + }) + + window.on('move', () => { + windowState.saveState(window) + }) + + window.on('session-end', async () => { + triggerSysProxy(false) + await stopCore() + }) + + window.webContents.setWindowOpenHandler((details) => { + shell.openExternal(details.url) + return { action: 'deny' } + }) +} + +function scheduleQuitWithoutCore(delaySeconds: number): void { + clearQuitTimeout() + quitTimeout = setTimeout(async () => { + await quitWithoutCore() + }, delaySeconds * 1000) +} + +export function clearQuitTimeout(): void { + if (quitTimeout) { + clearTimeout(quitTimeout) + quitTimeout = null + } +} + +export function triggerMainWindow(force?: boolean): void { + if (!mainWindow) return + + getAppConfig() + .then(({ triggerMainWindowBehavior = 'toggle' }) => { + if (force === true || triggerMainWindowBehavior === 'toggle') { + if (mainWindow?.isVisible()) { + closeMainWindow() + } else { + showMainWindow() + } + } else { + showMainWindow() + } + }) + .catch(showMainWindow) +} + +export function showMainWindow(): void { + if (mainWindow) { + clearQuitTimeout() + mainWindow.show() + mainWindow.focusOnWebView() + } +} + +export function closeMainWindow(): void { + mainWindow?.close() +} diff --git a/src/preload/index.ts b/src/preload/index.ts index bc01309..21b0a82 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -163,11 +163,7 @@ const validListenChannels = [ ] as const // 允许的 send channels 白名单 -const validSendChannels = [ - 'updateTrayMenu', - 'updateFloatingWindow', - 'trayIconUpdate' -] as const +const validSendChannels = ['updateTrayMenu', 'updateFloatingWindow', 'trayIconUpdate'] as const type InvokeChannel = (typeof validInvokeChannels)[number] type ListenChannel = (typeof validListenChannels)[number] diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index e6e7460..4ddef18 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -33,16 +33,13 @@ import { platform } from '@renderer/utils/init' import { TitleBarOverlayOptions } from 'electron' import SubStoreCard from '@renderer/components/sider/substore-card' import MihomoIcon from './components/base/mihomo-icon' -import { driver } from 'driver.js' +import { createTourDriver, getDriver, startTourIfNeeded } from '@renderer/utils/tour' import 'driver.js/dist/driver.css' import { useTranslation } from 'react-i18next' let navigate: NavigateFunction -let driverInstance: ReturnType | null = null -export function getDriver(): ReturnType | null { - return driverInstance -} +export { getDriver } const App: React.FC = () => { const { t } = useTranslation() @@ -74,11 +71,13 @@ const App: React.FC = () => { const siderWidthValueRef = useRef(siderWidthValue) const [resizing, setResizing] = useState(false) const resizingRef = useRef(resizing) + const tourInitialized = useRef(false) const sensors = useSensors(useSensor(PointerSensor)) const { setTheme, systemTheme } = useTheme() navigate = useNavigate() const location = useLocation() const page = useRoutes(routes) + const setTitlebar = (): void => { if (!useWindowFrame && platform !== 'darwin') { const options = { height: 48 } as TitleBarOverlayOptions @@ -103,186 +102,10 @@ const App: React.FC = () => { }, [siderWidthValue, resizing]) useEffect(() => { - driverInstance = driver({ - showProgress: true, - nextBtnText: t('common.next'), - prevBtnText: t('common.prev'), - doneBtnText: t('common.done'), - progressText: '{{current}} / {{total}}', - overlayOpacity: 0.9, - steps: [ - { - element: 'none', - popover: { - title: t('guide.welcome.title'), - description: t('guide.welcome.description'), - side: 'over', - align: 'center' - } - }, - { - element: '.side', - popover: { - title: t('guide.sider.title'), - description: t('guide.sider.description'), - side: 'right', - align: 'center' - } - }, - { - element: '.sysproxy-card', - popover: { - title: t('guide.card.title'), - description: t('guide.card.description'), - side: 'right', - align: 'start' - } - }, - { - element: '.main', - popover: { - title: t('guide.main.title'), - description: t('guide.main.description'), - side: 'left', - align: 'center' - } - }, - { - element: '.profile-card', - popover: { - title: t('guide.profile.title'), - description: t('guide.profile.description'), - side: 'right', - align: 'start', - onNextClick: async (): Promise => { - navigate('/profiles') - setTimeout(() => { - driverInstance?.moveNext() - }, 0) - } - } - }, - { - element: '.profiles-sticky', - popover: { - title: t('guide.import.title'), - description: t('guide.import.description'), - side: 'bottom', - align: 'start' - } - }, - { - element: '.substore-import', - popover: { - title: t('guide.substore.title'), - description: t('guide.substore.description'), - side: 'bottom', - align: 'start' - } - }, - { - element: '.new-profile', - popover: { - title: t('guide.localProfile.title'), - description: t('guide.localProfile.description'), - side: 'bottom', - align: 'start' - } - }, - { - element: '.sysproxy-card', - popover: { - title: t('guide.sysproxy.title'), - description: t('guide.sysproxy.description'), - side: 'right', - align: 'start', - onNextClick: async (): Promise => { - navigate('/sysproxy') - setTimeout(() => { - driverInstance?.moveNext() - }, 0) - } - } - }, - { - element: '.sysproxy-settings', - popover: { - title: t('guide.sysproxySetting.title'), - description: t('guide.sysproxySetting.description'), - side: 'top', - align: 'start' - } - }, - { - element: '.tun-card', - popover: { - title: t('guide.tun.title'), - description: t('guide.tun.description'), - side: 'right', - align: 'start', - onNextClick: async (): Promise => { - navigate('/tun') - setTimeout(() => { - driverInstance?.moveNext() - }, 0) - } - } - }, - { - element: '.tun-settings', - popover: { - title: t('guide.tunSetting.title'), - description: t('guide.tunSetting.description'), - side: 'bottom', - align: 'start' - } - }, - { - element: '.override-card', - popover: { - title: t('guide.override.title'), - description: t('guide.override.description'), - side: 'right', - align: 'center' - } - }, - { - element: '.dns-card', - popover: { - title: t('guide.dns.title'), - description: t('guide.dns.description'), - side: 'right', - align: 'center', - onNextClick: async (): Promise => { - navigate('/profiles') - setTimeout(() => { - driverInstance?.moveNext() - }, 0) - } - } - }, - { - element: 'none', - popover: { - title: t('guide.end.title'), - description: t('guide.end.description'), - side: 'top', - align: 'center', - onNextClick: async (): Promise => { - navigate('/profiles') - setTimeout(() => { - driverInstance?.destroy() - }, 0) - } - } - } - ] - }) - - const tourShown = window.localStorage.getItem('tourShown') - if (!tourShown) { - window.localStorage.setItem('tourShown', 'true') - driverInstance.drive() + if (!tourInitialized.current) { + tourInitialized.current = true + createTourDriver(t, navigate) + startTourIfNeeded() } }, [t]) diff --git a/src/renderer/src/components/profiles/edit-rules-modal.tsx b/src/renderer/src/components/profiles/edit-rules-modal.tsx index 6acebfe..6eb1a31 100644 --- a/src/renderer/src/components/profiles/edit-rules-modal.tsx +++ b/src/renderer/src/components/profiles/edit-rules-modal.tsx @@ -421,80 +421,80 @@ const RuleListItemBase: React.FC = ({ onMoveDown, onRemove }) => { - let bgColorClass = 'bg-content2' - let textStyleClass = '' + let bgColorClass = 'bg-content2' + let textStyleClass = '' - if (isDeleted) { - bgColorClass = 'bg-danger-50 opacity-70' - textStyleClass = 'line-through text-foreground-500' - } else if (isPrependOrAppend) { - bgColorClass = 'bg-success-50' - } + if (isDeleted) { + bgColorClass = 'bg-danger-50 opacity-70' + textStyleClass = 'line-through text-foreground-500' + } else if (isPrependOrAppend) { + bgColorClass = 'bg-success-50' + } - return ( -
-
-
- - {rule.type} - - {/* 显示附加参数 */} -
- {rule.additionalParams && - rule.additionalParams.length > 0 && - rule.additionalParams.map((param, idx) => ( - - {param} - - ))} -
+ return ( +
+
+
+ + {rule.type} + + {/* 显示附加参数 */} +
+ {rule.additionalParams && + rule.additionalParams.length > 0 && + rule.additionalParams.map((param, idx) => ( + + {param} + + ))}
-
-
- {rule.type === 'MATCH' ? rule.proxy : rule.payload} -
- {rule.proxy && rule.type !== 'MATCH' && ( -
- {rule.proxy} -
- )} -
-
- - - -
- ) +
+
+ {rule.type === 'MATCH' ? rule.proxy : rule.payload} +
+ {rule.proxy && rule.type !== 'MATCH' && ( +
+ {rule.proxy} +
+ )} +
+
+ + + +
+
+ ) } const RuleListItem = memo(RuleListItemBase, (prevProps, nextProps) => { @@ -596,7 +596,9 @@ const EditRulesModal: React.FC = (props) => { if (Array.isArray(parsed['proxy-groups'])) { groups.push( ...((parsed['proxy-groups'] as Array>) - .map((group) => (group && typeof group['name'] === 'string' ? (group['name'] as string) : '')) + .map((group) => + group && typeof group['name'] === 'string' ? (group['name'] as string) : '' + ) .filter(Boolean) as string[]) ) } @@ -604,7 +606,9 @@ const EditRulesModal: React.FC = (props) => { if (Array.isArray(parsed['proxies'])) { groups.push( ...((parsed['proxies'] as Array>) - .map((proxy) => (proxy && typeof proxy['name'] === 'string' ? (proxy['name'] as string) : '')) + .map((proxy) => + proxy && typeof proxy['name'] === 'string' ? (proxy['name'] as string) : '' + ) .filter(Boolean) as string[]) ) } diff --git a/src/renderer/src/hooks/use-profile-config.tsx b/src/renderer/src/hooks/use-profile-config.tsx index 434798c..73ede17 100644 --- a/src/renderer/src/hooks/use-profile-config.tsx +++ b/src/renderer/src/hooks/use-profile-config.tsx @@ -125,6 +125,8 @@ export const ProfileConfigProvider: React.FC<{ children: ReactNode }> = ({ child } window.electron.ipcRenderer.on('profileConfigUpdated', handler) return (): void => { + // 清理待处理任务,防止内存泄漏 + targetProfileId.current = null window.electron.ipcRenderer.removeListener('profileConfigUpdated', handler) } }, []) diff --git a/src/renderer/src/pages/connections.tsx b/src/renderer/src/pages/connections.tsx index 126f02c..f1edf50 100644 --- a/src/renderer/src/pages/connections.tsx +++ b/src/renderer/src/pages/connections.tsx @@ -15,7 +15,8 @@ import { MdViewList, MdTableChart } from 'react-icons/md' import { HiOutlineAdjustmentsHorizontal } from 'react-icons/hi2' import { Dropdown, DropdownTrigger, DropdownMenu, DropdownItem } from '@heroui/react' import { includesIgnoreCase } from '@renderer/utils/includes' -import { differenceWith, unionWith } from 'lodash' +import differenceWith from 'lodash/differenceWith' +import unionWith from 'lodash/unionWith' import { useTranslation } from 'react-i18next' import { IoMdPause, IoMdPlay } from 'react-icons/io' diff --git a/src/renderer/src/utils/ipc.ts b/src/renderer/src/utils/ipc.ts index a615514..e4d9c45 100644 --- a/src/renderer/src/utils/ipc.ts +++ b/src/renderer/src/utils/ipc.ts @@ -113,8 +113,7 @@ export const convertMrsRuleset = (path: string, behavior: string): Promise => invoke('getRuntimeConfig') export const getRuntimeConfigStr = (): Promise => invoke('getRuntimeConfigStr') export const getRuleStr = (id: string): Promise => invoke('getRuleStr', id) -export const setRuleStr = (id: string, str: string): Promise => - invoke('setRuleStr', id, str) +export const setRuleStr = (id: string, str: string): Promise => invoke('setRuleStr', id, str) export const getFilePath = (ext: string[]): Promise => invoke('getFilePath', ext) export const readTextFile = (filePath: string): Promise => invoke('readTextFile', filePath) @@ -130,8 +129,7 @@ export const startMonitor = (): Promise => invoke('startMonitor') export const quitWithoutCore = (): Promise => invoke('quitWithoutCore') // System -export const triggerSysProxy = (enable: boolean): Promise => - invoke('triggerSysProxy', enable) +export const triggerSysProxy = (enable: boolean): Promise => invoke('triggerSysProxy', enable) export const checkTunPermissions = (): Promise => invoke('checkTunPermissions') export const grantTunPermissions = (): Promise => invoke('grantTunPermissions') export const manualGrantCorePermition = (): Promise => invoke('manualGrantCorePermition') @@ -168,8 +166,7 @@ export const clearMihomoVersionCache = (): Promise => invoke('clearMihomoV // Backup export const webdavBackup = (): Promise => invoke('webdavBackup') -export const webdavRestore = (filename: string): Promise => - invoke('webdavRestore', filename) +export const webdavRestore = (filename: string): Promise => invoke('webdavRestore', filename) export const listWebdavBackups = (): Promise => invoke('listWebdavBackups') export const webdavDelete = (filename: string): Promise => invoke('webdavDelete', filename) export const reinitWebdavBackupScheduler = (): Promise => @@ -180,10 +177,8 @@ export const importLocalBackup = (): Promise => invoke('importLocalBack // SubStore export const startSubStoreFrontendServer = (): Promise => invoke('startSubStoreFrontendServer') -export const stopSubStoreFrontendServer = (): Promise => - invoke('stopSubStoreFrontendServer') -export const startSubStoreBackendServer = (): Promise => - invoke('startSubStoreBackendServer') +export const stopSubStoreFrontendServer = (): Promise => invoke('stopSubStoreFrontendServer') +export const startSubStoreBackendServer = (): Promise => invoke('startSubStoreBackendServer') export const stopSubStoreBackendServer = (): Promise => invoke('stopSubStoreBackendServer') export const downloadSubStore = (): Promise => invoke('downloadSubStore') export const subStorePort = (): Promise => invoke('subStorePort') diff --git a/src/renderer/src/utils/tour.ts b/src/renderer/src/utils/tour.ts new file mode 100644 index 0000000..12cdd51 --- /dev/null +++ b/src/renderer/src/utils/tour.ts @@ -0,0 +1,195 @@ +import { driver } from 'driver.js' +import { TFunction } from 'i18next' +import { NavigateFunction } from 'react-router-dom' + +let driverInstance: ReturnType | null = null + +export function getDriver(): ReturnType | null { + return driverInstance +} + +export function createTourDriver(t: TFunction, navigate: NavigateFunction): void { + driverInstance = driver({ + showProgress: true, + nextBtnText: t('common.next'), + prevBtnText: t('common.prev'), + doneBtnText: t('common.done'), + progressText: '{{current}} / {{total}}', + overlayOpacity: 0.9, + steps: [ + { + element: 'none', + popover: { + title: t('guide.welcome.title'), + description: t('guide.welcome.description'), + side: 'over', + align: 'center' + } + }, + { + element: '.side', + popover: { + title: t('guide.sider.title'), + description: t('guide.sider.description'), + side: 'right', + align: 'center' + } + }, + { + element: '.sysproxy-card', + popover: { + title: t('guide.card.title'), + description: t('guide.card.description'), + side: 'right', + align: 'start' + } + }, + { + element: '.main', + popover: { + title: t('guide.main.title'), + description: t('guide.main.description'), + side: 'left', + align: 'center' + } + }, + { + element: '.profile-card', + popover: { + title: t('guide.profile.title'), + description: t('guide.profile.description'), + side: 'right', + align: 'start', + onNextClick: async (): Promise => { + navigate('/profiles') + setTimeout(() => { + driverInstance?.moveNext() + }, 0) + } + } + }, + { + element: '.profiles-sticky', + popover: { + title: t('guide.import.title'), + description: t('guide.import.description'), + side: 'bottom', + align: 'start' + } + }, + { + element: '.substore-import', + popover: { + title: t('guide.substore.title'), + description: t('guide.substore.description'), + side: 'bottom', + align: 'start' + } + }, + { + element: '.new-profile', + popover: { + title: t('guide.localProfile.title'), + description: t('guide.localProfile.description'), + side: 'bottom', + align: 'start' + } + }, + { + element: '.sysproxy-card', + popover: { + title: t('guide.sysproxy.title'), + description: t('guide.sysproxy.description'), + side: 'right', + align: 'start', + onNextClick: async (): Promise => { + navigate('/sysproxy') + setTimeout(() => { + driverInstance?.moveNext() + }, 0) + } + } + }, + { + element: '.sysproxy-settings', + popover: { + title: t('guide.sysproxySetting.title'), + description: t('guide.sysproxySetting.description'), + side: 'top', + align: 'start' + } + }, + { + element: '.tun-card', + popover: { + title: t('guide.tun.title'), + description: t('guide.tun.description'), + side: 'right', + align: 'start', + onNextClick: async (): Promise => { + navigate('/tun') + setTimeout(() => { + driverInstance?.moveNext() + }, 0) + } + } + }, + { + element: '.tun-settings', + popover: { + title: t('guide.tunSetting.title'), + description: t('guide.tunSetting.description'), + side: 'bottom', + align: 'start' + } + }, + { + element: '.override-card', + popover: { + title: t('guide.override.title'), + description: t('guide.override.description'), + side: 'right', + align: 'center' + } + }, + { + element: '.dns-card', + popover: { + title: t('guide.dns.title'), + description: t('guide.dns.description'), + side: 'right', + align: 'center', + onNextClick: async (): Promise => { + navigate('/profiles') + setTimeout(() => { + driverInstance?.moveNext() + }, 0) + } + } + }, + { + element: 'none', + popover: { + title: t('guide.end.title'), + description: t('guide.end.description'), + side: 'top', + align: 'center', + onNextClick: async (): Promise => { + navigate('/profiles') + setTimeout(() => { + driverInstance?.destroy() + }, 0) + } + } + } + ] + }) +} + +export function startTourIfNeeded(): void { + const tourShown = window.localStorage.getItem('tourShown') + if (!tourShown && driverInstance) { + window.localStorage.setItem('tourShown', 'true') + driverInstance.drive() + } +}