mirror of
https://gh.catmak.name/https://github.com/mihomo-party-org/mihomo-party
synced 2026-02-10 11:40:28 +08:00
refactor: split main process into modules and extract tour logic
This commit is contained in:
parent
3f1d1f84a1
commit
c1f7a862aa
@ -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'
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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
32
src/main/deeplink.ts
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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
75
src/main/lifecycle.ts
Normal 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'
|
||||
}
|
||||
@ -1,5 +1,5 @@
|
||||
import { app, globalShortcut, ipcMain, Notification } from 'electron'
|
||||
import { mainWindow, triggerMainWindow } from '..'
|
||||
import { mainWindow, triggerMainWindow } from '../window'
|
||||
import {
|
||||
getAppConfig,
|
||||
getControledMihomoConfig,
|
||||
|
||||
@ -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'
|
||||
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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'
|
||||
|
||||
|
||||
@ -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
173
src/main/window.ts
Normal 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()
|
||||
}
|
||||
@ -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]
|
||||
|
||||
@ -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])
|
||||
|
||||
|
||||
@ -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[])
|
||||
)
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}, [])
|
||||
|
||||
@ -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'
|
||||
|
||||
|
||||
@ -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')
|
||||
|
||||
195
src/renderer/src/utils/tour.ts
Normal file
195
src/renderer/src/utils/tour.ts
Normal 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()
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user