refactor: split main process into modules and extract tour logic

This commit is contained in:
xmk23333 2026-01-01 09:35:45 +08:00
parent 3f1d1f84a1
commit c1f7a862aa
19 changed files with 654 additions and 614 deletions

View File

@ -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'

View File

@ -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'

View File

@ -5,13 +5,17 @@ import { getAppConfig } from '../config'
export async function subStoreSubs(): Promise<ISubStoreSub[]> {
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<ISubStoreSub[]> {
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
}

32
src/main/deeplink.ts Normal file
View File

@ -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<void> {
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
}
}
}

View File

@ -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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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()
}
}

75
src/main/lifecycle.ts Normal file
View File

@ -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<void> {
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'
}

View File

@ -1,5 +1,5 @@
import { app, globalShortcut, ipcMain, Notification } from 'electron'
import { mainWindow, triggerMainWindow } from '..'
import { mainWindow, triggerMainWindow } from '../window'
import {
getAppConfig,
getControledMihomoConfig,

View File

@ -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'

View File

@ -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'

View File

@ -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'

View File

@ -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,

173
src/main/window.ts Normal file
View File

@ -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<void> {
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<typeof windowStateKeeper>
): 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()
}

View File

@ -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]

View File

@ -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<typeof driver> | null = null
export function getDriver(): ReturnType<typeof driver> | 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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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])

View File

@ -421,80 +421,80 @@ const RuleListItemBase: React.FC<RuleListItemProps> = ({
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 (
<div className={`flex items-center gap-2 p-2 rounded-lg ${bgColorClass}`}>
<div className="flex flex-col">
<div className="flex items-center gap-1">
<Chip size="sm" variant="flat">
{rule.type}
</Chip>
{/* 显示附加参数 */}
<div className="flex gap-1">
{rule.additionalParams &&
rule.additionalParams.length > 0 &&
rule.additionalParams.map((param, idx) => (
<Chip key={idx} size="sm" variant="flat" color="secondary">
{param}
</Chip>
))}
</div>
return (
<div className={`flex items-center gap-2 p-2 rounded-lg ${bgColorClass}`}>
<div className="flex flex-col">
<div className="flex items-center gap-1">
<Chip size="sm" variant="flat">
{rule.type}
</Chip>
{/* 显示附加参数 */}
<div className="flex gap-1">
{rule.additionalParams &&
rule.additionalParams.length > 0 &&
rule.additionalParams.map((param, idx) => (
<Chip key={idx} size="sm" variant="flat" color="secondary">
{param}
</Chip>
))}
</div>
</div>
<div className="flex-1 min-w-0">
<div className={`font-medium truncate ${textStyleClass}`}>
{rule.type === 'MATCH' ? rule.proxy : rule.payload}
</div>
{rule.proxy && rule.type !== 'MATCH' && (
<div className={`text-sm text-foreground-500 truncate ${textStyleClass}`}>
{rule.proxy}
</div>
)}
</div>
<div className="flex gap-1">
<Button
size="sm"
variant="light"
onPress={() => originalIndex !== -1 && onMoveUp(originalIndex)}
isIconOnly
isDisabled={originalIndex === -1 || originalIndex === 0 || isDeleted}
>
<IoMdArrowUp className="text-lg" />
</Button>
<Button
size="sm"
variant="light"
onPress={() => originalIndex !== -1 && onMoveDown(originalIndex)}
isIconOnly
isDisabled={originalIndex === -1 || originalIndex === rulesLength - 1 || isDeleted}
>
<IoMdArrowDown className="text-lg" />
</Button>
<Button
size="sm"
color={originalIndex !== -1 && isDeleted ? 'success' : 'danger'}
variant="light"
onPress={() => originalIndex !== -1 && onRemove(originalIndex)}
isIconOnly
>
{originalIndex !== -1 && isDeleted ? (
<IoMdUndo className="text-lg" />
) : (
<IoMdTrash className="text-lg" />
)}
</Button>
</div>
</div>
)
<div className="flex-1 min-w-0">
<div className={`font-medium truncate ${textStyleClass}`}>
{rule.type === 'MATCH' ? rule.proxy : rule.payload}
</div>
{rule.proxy && rule.type !== 'MATCH' && (
<div className={`text-sm text-foreground-500 truncate ${textStyleClass}`}>
{rule.proxy}
</div>
)}
</div>
<div className="flex gap-1">
<Button
size="sm"
variant="light"
onPress={() => originalIndex !== -1 && onMoveUp(originalIndex)}
isIconOnly
isDisabled={originalIndex === -1 || originalIndex === 0 || isDeleted}
>
<IoMdArrowUp className="text-lg" />
</Button>
<Button
size="sm"
variant="light"
onPress={() => originalIndex !== -1 && onMoveDown(originalIndex)}
isIconOnly
isDisabled={originalIndex === -1 || originalIndex === rulesLength - 1 || isDeleted}
>
<IoMdArrowDown className="text-lg" />
</Button>
<Button
size="sm"
color={originalIndex !== -1 && isDeleted ? 'success' : 'danger'}
variant="light"
onPress={() => originalIndex !== -1 && onRemove(originalIndex)}
isIconOnly
>
{originalIndex !== -1 && isDeleted ? (
<IoMdUndo className="text-lg" />
) : (
<IoMdTrash className="text-lg" />
)}
</Button>
</div>
</div>
)
}
const RuleListItem = memo(RuleListItemBase, (prevProps, nextProps) => {
@ -596,7 +596,9 @@ const EditRulesModal: React.FC<Props> = (props) => {
if (Array.isArray(parsed['proxy-groups'])) {
groups.push(
...((parsed['proxy-groups'] as Array<Record<string, unknown>>)
.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> = (props) => {
if (Array.isArray(parsed['proxies'])) {
groups.push(
...((parsed['proxies'] as Array<Record<string, unknown>>)
.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[])
)
}

View File

@ -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)
}
}, [])

View File

@ -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'

View File

@ -113,8 +113,7 @@ export const convertMrsRuleset = (path: string, behavior: string): Promise<strin
export const getRuntimeConfig = (): Promise<IMihomoConfig> => invoke('getRuntimeConfig')
export const getRuntimeConfigStr = (): Promise<string> => invoke('getRuntimeConfigStr')
export const getRuleStr = (id: string): Promise<string> => invoke('getRuleStr', id)
export const setRuleStr = (id: string, str: string): Promise<void> =>
invoke('setRuleStr', id, str)
export const setRuleStr = (id: string, str: string): Promise<void> => invoke('setRuleStr', id, str)
export const getFilePath = (ext: string[]): Promise<string[] | undefined> =>
invoke('getFilePath', ext)
export const readTextFile = (filePath: string): Promise<string> => invoke('readTextFile', filePath)
@ -130,8 +129,7 @@ export const startMonitor = (): Promise<void> => invoke('startMonitor')
export const quitWithoutCore = (): Promise<void> => invoke('quitWithoutCore')
// System
export const triggerSysProxy = (enable: boolean): Promise<void> =>
invoke('triggerSysProxy', enable)
export const triggerSysProxy = (enable: boolean): Promise<void> => invoke('triggerSysProxy', enable)
export const checkTunPermissions = (): Promise<boolean> => invoke('checkTunPermissions')
export const grantTunPermissions = (): Promise<void> => invoke('grantTunPermissions')
export const manualGrantCorePermition = (): Promise<void> => invoke('manualGrantCorePermition')
@ -168,8 +166,7 @@ export const clearMihomoVersionCache = (): Promise<void> => invoke('clearMihomoV
// Backup
export const webdavBackup = (): Promise<boolean> => invoke('webdavBackup')
export const webdavRestore = (filename: string): Promise<void> =>
invoke('webdavRestore', filename)
export const webdavRestore = (filename: string): Promise<void> => invoke('webdavRestore', filename)
export const listWebdavBackups = (): Promise<string[]> => invoke('listWebdavBackups')
export const webdavDelete = (filename: string): Promise<void> => invoke('webdavDelete', filename)
export const reinitWebdavBackupScheduler = (): Promise<void> =>
@ -180,10 +177,8 @@ export const importLocalBackup = (): Promise<boolean> => invoke('importLocalBack
// SubStore
export const startSubStoreFrontendServer = (): Promise<void> =>
invoke('startSubStoreFrontendServer')
export const stopSubStoreFrontendServer = (): Promise<void> =>
invoke('stopSubStoreFrontendServer')
export const startSubStoreBackendServer = (): Promise<void> =>
invoke('startSubStoreBackendServer')
export const stopSubStoreFrontendServer = (): Promise<void> => invoke('stopSubStoreFrontendServer')
export const startSubStoreBackendServer = (): Promise<void> => invoke('startSubStoreBackendServer')
export const stopSubStoreBackendServer = (): Promise<void> => invoke('stopSubStoreBackendServer')
export const downloadSubStore = (): Promise<void> => invoke('downloadSubStore')
export const subStorePort = (): Promise<number> => invoke('subStorePort')

View File

@ -0,0 +1,195 @@
import { driver } from 'driver.js'
import { TFunction } from 'i18next'
import { NavigateFunction } from 'react-router-dom'
let driverInstance: ReturnType<typeof driver> | null = null
export function getDriver(): ReturnType<typeof driver> | 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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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()
}
}