Compare commits

...

9 Commits

29 changed files with 997 additions and 1256 deletions

View File

@ -66,9 +66,9 @@
"cron-validator": "^1.4.0", "cron-validator": "^1.4.0",
"dayjs": "^1.11.19", "dayjs": "^1.11.19",
"driver.js": "^1.3.6", "driver.js": "^1.3.6",
"electron": "^37.10.0", "electron": "37.10.0",
"electron-builder": "26.0.12", "electron-builder": "26.0.12",
"electron-vite": "^4.0.1", "electron-vite": "4.0.1",
"electron-window-state": "^5.0.3", "electron-window-state": "^5.0.3",
"eslint": "9.39.2", "eslint": "9.39.2",
"eslint-plugin-react": "^7.37.5", "eslint-plugin-react": "^7.37.5",

637
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@ -6,8 +6,12 @@ import { getAppConfig } from './app'
import { defaultControledMihomoConfig } from '../utils/template' import { defaultControledMihomoConfig } from '../utils/template'
import { deepMerge } from '../utils/merge' import { deepMerge } from '../utils/merge'
import { existsSync } from 'fs' import { existsSync } from 'fs'
import { createLogger } from '../utils/logger'
const controledMihomoLogger = createLogger('ControledMihomo')
let controledMihomoConfig: Partial<IMihomoConfig> // mihomo.yaml let controledMihomoConfig: Partial<IMihomoConfig> // mihomo.yaml
let controledMihomoWriteQueue: Promise<void> = Promise.resolve()
export async function getControledMihomoConfig(force = false): Promise<Partial<IMihomoConfig>> { export async function getControledMihomoConfig(force = false): Promise<Partial<IMihomoConfig>> {
if (force || !controledMihomoConfig) { if (force || !controledMihomoConfig) {
@ -23,7 +27,7 @@ export async function getControledMihomoConfig(force = false): Promise<Partial<I
'utf-8' 'utf-8'
) )
} catch (error) { } catch (error) {
console.error('Failed to create mihomo.yaml file:', error) controledMihomoLogger.error('Failed to create mihomo.yaml file', error)
} }
} }
@ -36,29 +40,32 @@ export async function getControledMihomoConfig(force = false): Promise<Partial<I
} }
export async function patchControledMihomoConfig(patch: Partial<IMihomoConfig>): Promise<void> { export async function patchControledMihomoConfig(patch: Partial<IMihomoConfig>): Promise<void> {
const { controlDns = true, controlSniff = true } = await getAppConfig() controledMihomoWriteQueue = controledMihomoWriteQueue.then(async () => {
const { controlDns = true, controlSniff = true } = await getAppConfig()
if (patch.hosts) { if (patch.hosts) {
controledMihomoConfig.hosts = patch.hosts controledMihomoConfig.hosts = patch.hosts
} }
if (patch.dns?.['nameserver-policy']) { if (patch.dns?.['nameserver-policy']) {
controledMihomoConfig.dns = controledMihomoConfig.dns || {} controledMihomoConfig.dns = controledMihomoConfig.dns || {}
controledMihomoConfig.dns['nameserver-policy'] = patch.dns['nameserver-policy'] controledMihomoConfig.dns['nameserver-policy'] = patch.dns['nameserver-policy']
} }
controledMihomoConfig = deepMerge(controledMihomoConfig, patch) controledMihomoConfig = deepMerge(controledMihomoConfig, patch)
// 从不接管状态恢复 // 从不接管状态恢复
if (controlDns) { if (controlDns) {
// 确保 DNS 配置包含所有必要的默认字段,特别是新增的 fallback 等 // 确保 DNS 配置包含所有必要的默认字段,特别是新增的 fallback 等
controledMihomoConfig.dns = deepMerge( controledMihomoConfig.dns = deepMerge(
defaultControledMihomoConfig.dns || {}, defaultControledMihomoConfig.dns || {},
controledMihomoConfig.dns || {} controledMihomoConfig.dns || {}
) )
} }
if (controlSniff && !controledMihomoConfig.sniffer) { if (controlSniff && !controledMihomoConfig.sniffer) {
controledMihomoConfig.sniffer = defaultControledMihomoConfig.sniffer controledMihomoConfig.sniffer = defaultControledMihomoConfig.sniffer
} }
await generateProfile() await generateProfile()
await writeFile(controledMihomoConfigPath(), stringify(controledMihomoConfig), 'utf-8') await writeFile(controledMihomoConfigPath(), stringify(controledMihomoConfig), 'utf-8')
})
await controledMihomoWriteQueue
} }

View File

@ -54,9 +54,12 @@ export async function addOverrideItem(item: Partial<IOverrideItem>): Promise<voi
export async function removeOverrideItem(id: string): Promise<void> { export async function removeOverrideItem(id: string): Promise<void> {
const config = await getOverrideConfig() const config = await getOverrideConfig()
const item = await getOverrideItem(id) const item = await getOverrideItem(id)
config.items = config.items?.filter((item) => item.id !== id) if (!item) return
config.items = config.items?.filter((i) => i.id !== id)
await setOverrideConfig(config) await setOverrideConfig(config)
await rm(overridePath(id, item?.ext || 'js')) if (existsSync(overridePath(id, item.ext))) {
await rm(overridePath(id, item.ext))
}
} }
export async function createOverride(item: Partial<IOverrideItem>): Promise<IOverrideItem> { export async function createOverride(item: Partial<IOverrideItem>): Promise<IOverrideItem> {

View File

@ -14,10 +14,13 @@ import { app } from 'electron'
import { mihomoUpgradeConfig } from '../core/mihomoApi' import { mihomoUpgradeConfig } from '../core/mihomoApi'
import i18next from 'i18next' import i18next from 'i18next'
import { createLogger } from '../utils/logger'
const profileLogger = createLogger('Profile')
let profileConfig: IProfileConfig let profileConfig: IProfileConfig
let profileConfigWriteQueue: Promise<void> = Promise.resolve() let profileConfigWriteQueue: Promise<void> = Promise.resolve()
let targetProfileId: string | null = null let changeProfileQueue: Promise<void> = Promise.resolve()
export async function getProfileConfig(force = false): Promise<IProfileConfig> { export async function getProfileConfig(force = false): Promise<IProfileConfig> {
if (force || !profileConfig) { if (force || !profileConfig) {
@ -60,37 +63,27 @@ export async function getProfileItem(id: string | undefined): Promise<IProfileIt
} }
export async function changeCurrentProfile(id: string): Promise<void> { export async function changeCurrentProfile(id: string): Promise<void> {
const { current } = await getProfileConfig() // 使用队列确保 profile 切换串行执行,避免竞态条件
changeProfileQueue = changeProfileQueue.then(async () => {
const { current } = await getProfileConfig()
if (current === id) return
if (current === id && targetProfileId !== id) { try {
return await updateProfileConfig((config) => {
} config.current = id
return config
targetProfileId = id })
await restartCore()
try { } catch (e) {
await updateProfileConfig((config) => { // 回滚配置
config.current = id
return config
})
if (targetProfileId !== id) {
return
}
await restartCore()
if (targetProfileId === id) {
targetProfileId = null
}
} catch (e) {
if (targetProfileId === id) {
await updateProfileConfig((config) => { await updateProfileConfig((config) => {
config.current = current config.current = current
return config return config
}) })
targetProfileId = null
throw e throw e
} }
} })
await changeProfileQueue
} }
export async function updateProfileItem(item: IProfileItem): Promise<void> { export async function updateProfileItem(item: IProfileItem): Promise<void> {
@ -320,16 +313,16 @@ export async function setProfileStr(id: string, content: string): Promise<void>
const { generateProfile } = await import('../core/factory') const { generateProfile } = await import('../core/factory')
await generateProfile() await generateProfile()
await mihomoUpgradeConfig() await mihomoUpgradeConfig()
console.log('[Profile] Config reloaded successfully using mihomoUpgradeConfig') profileLogger.info('Config reloaded successfully using mihomoUpgradeConfig')
} catch (error) { } catch (error) {
console.error('[Profile] Failed to reload config with mihomoUpgradeConfig:', error) profileLogger.error('Failed to reload config with mihomoUpgradeConfig', error)
try { try {
console.log('[Profile] Falling back to restart core') profileLogger.info('Falling back to restart core')
const { restartCore } = await import('../core/manager') const { restartCore } = await import('../core/manager')
await restartCore() await restartCore()
console.log('[Profile] Core restarted successfully') profileLogger.info('Core restarted successfully')
} catch (restartError) { } catch (restartError) {
console.error('[Profile] Failed to restart core:', restartError) profileLogger.error('Failed to restart core', restartError)
throw restartError throw restartError
} }
} }
@ -365,12 +358,16 @@ export async function getProfile(id: string | undefined): Promise<IMihomoConfig>
// attachment;filename=xxx.yaml; filename*=UTF-8''%xx%xx%xx // attachment;filename=xxx.yaml; filename*=UTF-8''%xx%xx%xx
function parseFilename(str: string): string { function parseFilename(str: string): string {
if (str.match(/filename\*=.*''/)) { if (str.match(/filename\*=.*''/)) {
const filename = decodeURIComponent(str.split(/filename\*=.*''/)[1]) const parts = str.split(/filename\*=.*''/)
return filename if (parts[1]) {
} else { return decodeURIComponent(parts[1])
const filename = str.split('filename=')[1] }
return filename
} }
const parts = str.split('filename=')
if (parts[1]) {
return parts[1].replace(/^["']|["']$/g, '')
}
return 'Remote File'
} }
// subscription-userinfo: upload=1234; download=2234; total=1024000; expire=2218532293 // subscription-userinfo: upload=1234; download=2234; total=1024000; expire=2218532293

View File

@ -21,9 +21,12 @@ import { deepMerge } from '../utils/merge'
import vm from 'vm' import vm from 'vm'
import { existsSync, writeFileSync } from 'fs' import { existsSync, writeFileSync } from 'fs'
import path from 'path' import path from 'path'
import { createLogger } from '../utils/logger'
let runtimeConfigStr: string const factoryLogger = createLogger('Factory')
let runtimeConfig: IMihomoConfig
let runtimeConfigStr: string = ''
let runtimeConfig: IMihomoConfig = {} as IMihomoConfig
// 辅助函数:处理带偏移量的规则 // 辅助函数:处理带偏移量的规则
function processRulesWithOffset(ruleStrings: string[], currentRules: string[], isAppend = false) { function processRulesWithOffset(ruleStrings: string[], currentRules: string[], isAppend = false) {
@ -132,7 +135,7 @@ export async function generateProfile(): Promise<string | undefined> {
} }
} }
} catch (error) { } catch (error) {
console.error('读取或应用规则文件时出错:', error) factoryLogger.error('Failed to read or apply rule file', error)
} }
const profile = deepMerge(currentProfile, controledMihomoConfig) const profile = deepMerge(currentProfile, controledMihomoConfig)

View File

@ -33,7 +33,7 @@ import {
import chokidar from 'chokidar' import chokidar from 'chokidar'
import { readFile, rm, writeFile } from 'fs/promises' import { readFile, rm, writeFile } from 'fs/promises'
import { promisify } from 'util' import { promisify } from 'util'
import { mainWindow } from '..' import { mainWindow } from '../window'
import path from 'path' import path from 'path'
import os from 'os' import os from 'os'
import { createWriteStream, existsSync } from 'fs' import { createWriteStream, existsSync } from 'fs'
@ -977,7 +977,10 @@ async function getOriginDNS(): Promise<void> {
async function setDNS(dns: string): Promise<void> { async function setDNS(dns: string): Promise<void> {
const service = await getDefaultService() const service = await getDefaultService()
const execPromise = promisify(exec) const execPromise = promisify(exec)
await execPromise(`networksetup -setdnsservers "${service}" ${dns}`) // networksetup 需要 root 权限,通过 osascript 请求管理员权限执行
const shell = `networksetup -setdnsservers "${service}" ${dns}`
const command = `do shell script "${shell}" with administrator privileges`
await execPromise(`osascript -e '${command}'`)
} }
async function setPublicDNS(): Promise<void> { async function setPublicDNS(): Promise<void> {

View File

@ -1,12 +1,15 @@
import axios, { AxiosInstance } from 'axios' import axios, { AxiosInstance } from 'axios'
import { getAppConfig, getControledMihomoConfig } from '../config' import { getAppConfig, getControledMihomoConfig } from '../config'
import { mainWindow } from '..' import { mainWindow } from '../window'
import WebSocket from 'ws' import WebSocket from 'ws'
import { tray } from '../resolve/tray' import { tray } from '../resolve/tray'
import { calcTraffic } from '../utils/calc' import { calcTraffic } from '../utils/calc'
import { getRuntimeConfig } from './factory' import { getRuntimeConfig } from './factory'
import { floatingWindow } from '../resolve/floatingWindow' import { floatingWindow } from '../resolve/floatingWindow'
import { getMihomoIpcPath } from './manager' import { getMihomoIpcPath } from './manager'
import { createLogger } from '../utils/logger'
const mihomoApiLogger = createLogger('MihomoApi')
let axiosIns: AxiosInstance = null! let axiosIns: AxiosInstance = null!
let currentIpcPath: string = '' let currentIpcPath: string = ''
@ -30,7 +33,7 @@ export const getAxios = async (force: boolean = false): Promise<AxiosInstance> =
} }
currentIpcPath = dynamicIpcPath currentIpcPath = dynamicIpcPath
console.log(`[mihomoApi] Creating axios instance with path: ${dynamicIpcPath}`) mihomoApiLogger.info(`Creating axios instance with path: ${dynamicIpcPath}`)
axiosIns = axios.create({ axiosIns = axios.create({
baseURL: `http://localhost`, baseURL: `http://localhost`,
@ -44,9 +47,9 @@ export const getAxios = async (force: boolean = false): Promise<AxiosInstance> =
}, },
(error) => { (error) => {
if (error.code === 'ENOENT') { if (error.code === 'ENOENT') {
console.debug(`[mihomoApi] Pipe not ready: ${error.config?.socketPath}`) mihomoApiLogger.debug(`Pipe not ready: ${error.config?.socketPath}`)
} else { } else {
console.error(`[mihomoApi] Axios error with path ${dynamicIpcPath}:`, error.message) mihomoApiLogger.error(`Axios error with path ${dynamicIpcPath}: ${error.message}`)
} }
if (error.response && error.response.data) { if (error.response && error.response.data) {
@ -191,28 +194,28 @@ export const mihomoUpgradeUI = async (): Promise<void> => {
} }
export const mihomoUpgradeConfig = async (): Promise<void> => { export const mihomoUpgradeConfig = async (): Promise<void> => {
console.log('[mihomoApi] mihomoUpgradeConfig called') mihomoApiLogger.info('mihomoUpgradeConfig called')
try { try {
const instance = await getAxios() const instance = await getAxios()
console.log('[mihomoApi] axios instance obtained') mihomoApiLogger.info('axios instance obtained')
const { diffWorkDir = false } = await getAppConfig() const { diffWorkDir = false } = await getAppConfig()
const { current } = await import('../config').then((mod) => mod.getProfileConfig(true)) const { current } = await import('../config').then((mod) => mod.getProfileConfig(true))
const { mihomoWorkConfigPath } = await import('../utils/dirs') const { mihomoWorkConfigPath } = await import('../utils/dirs')
const configPath = diffWorkDir ? mihomoWorkConfigPath(current) : mihomoWorkConfigPath('work') const configPath = diffWorkDir ? mihomoWorkConfigPath(current) : mihomoWorkConfigPath('work')
console.log('[mihomoApi] config path:', configPath) mihomoApiLogger.info(`config path: ${configPath}`)
const { existsSync } = await import('fs') const { existsSync } = await import('fs')
if (!existsSync(configPath)) { if (!existsSync(configPath)) {
console.log('[mihomoApi] config file does not exist, generating...') mihomoApiLogger.info('config file does not exist, generating...')
const { generateProfile } = await import('./factory') const { generateProfile } = await import('./factory')
await generateProfile() await generateProfile()
} }
const response = await instance.put('/configs?force=true', { const response = await instance.put('/configs?force=true', {
path: configPath path: configPath
}) })
console.log('[mihomoApi] config upgrade request completed', response?.status || 'no status') mihomoApiLogger.info(`config upgrade request completed ${response?.status || 'no status'}`)
} catch (error) { } catch (error) {
console.error('[mihomoApi] Failed to upgrade config:', error) mihomoApiLogger.error('Failed to upgrade config', error)
throw error throw error
} }
} }
@ -255,7 +258,7 @@ const mihomoTraffic = async (): Promise<void> => {
const dynamicIpcPath = getMihomoIpcPath() const dynamicIpcPath = getMihomoIpcPath()
const wsUrl = `ws+unix:${dynamicIpcPath}:/traffic` const wsUrl = `ws+unix:${dynamicIpcPath}:/traffic`
console.log(`[mihomoApi] Creating traffic WebSocket with URL: ${wsUrl}`) mihomoApiLogger.info(`Creating traffic WebSocket with URL: ${wsUrl}`)
mihomoTrafficWs = new WebSocket(wsUrl) mihomoTrafficWs = new WebSocket(wsUrl)
mihomoTrafficWs.onmessage = async (e): Promise<void> => { mihomoTrafficWs.onmessage = async (e): Promise<void> => {
@ -286,7 +289,7 @@ const mihomoTraffic = async (): Promise<void> => {
} }
mihomoTrafficWs.onerror = (error): void => { mihomoTrafficWs.onerror = (error): void => {
console.error(`[mihomoApi] Traffic WebSocket error:`, error) mihomoApiLogger.error('Traffic WebSocket error', error)
if (mihomoTrafficWs) { if (mihomoTrafficWs) {
mihomoTrafficWs.close() mihomoTrafficWs.close()
mihomoTrafficWs = null mihomoTrafficWs = null

View File

@ -5,13 +5,17 @@ import { getAppConfig } from '../config'
export async function subStoreSubs(): Promise<ISubStoreSub[]> { export async function subStoreSubs(): Promise<ISubStoreSub[]> {
const { useCustomSubStore = false, customSubStoreUrl = '' } = await getAppConfig() const { useCustomSubStore = false, customSubStoreUrl = '' } = await getAppConfig()
const baseUrl = useCustomSubStore ? customSubStoreUrl : `http://127.0.0.1:${subStorePort}` 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 return res.data.data
} }
export async function subStoreCollections(): Promise<ISubStoreSub[]> { export async function subStoreCollections(): Promise<ISubStoreSub[]> {
const { useCustomSubStore = false, customSubStoreUrl = '' } = await getAppConfig() const { useCustomSubStore = false, customSubStoreUrl = '' } = await getAppConfig()
const baseUrl = useCustomSubStore ? customSubStoreUrl : `http://127.0.0.1:${subStorePort}` 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 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,46 @@
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 { registerIpcMainHandlers } from './utils/ipc'
import windowStateKeeper from 'electron-window-state' import { getAppConfig, patchAppConfig } from './config'
import { app, shell, BrowserWindow, Menu, dialog, Notification, powerMonitor } from 'electron'
import { addProfileItem, getAppConfig, patchAppConfig } from './config'
import { import {
quitWithoutCore,
startCore, startCore,
stopCore,
checkAdminRestartForTun, checkAdminRestartForTun,
checkHighPrivilegeCore, checkHighPrivilegeCore,
restartAsAdmin, restartAsAdmin,
initAdminStatus initAdminStatus,
checkAdminPrivileges
} from './core/manager' } from './core/manager'
import { triggerSysProxy } from './sys/sysproxy' import { createTray } from './resolve/tray'
import icon from '../../resources/icon.png?asset'
import { createTray, hideDockIcon, showDockIcon } from './resolve/tray'
import { init, initBasic, safeShowErrorBox } from './utils/init' import { init, initBasic, safeShowErrorBox } from './utils/init'
import { join } from 'path'
import { initShortcut } from './resolve/shortcut' 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 { initProfileUpdater } from './core/profileUpdater'
import { existsSync } from 'fs'
import { exePath } from './utils/dirs'
import { startMonitor } from './resolve/trafficMonitor' import { startMonitor } from './resolve/trafficMonitor'
import { showFloatingWindow } from './resolve/floatingWindow' import { showFloatingWindow } from './resolve/floatingWindow'
import { initI18n } from '../shared/i18n' import { initI18n } from '../shared/i18n'
import i18next from 'i18next' import i18next from 'i18next'
import { logger } from './utils/logger' import { logger } from './utils/logger'
import { createLogger } from './utils/logger'
import { initWebdavBackupScheduler } from './resolve/backup' import { initWebdavBackupScheduler } from './resolve/backup'
async function fixUserDataPermissions(): Promise<void> { const mainLogger = createLogger('Main')
if (process.platform !== 'darwin') return import {
createWindow,
mainWindow,
showMainWindow,
triggerMainWindow,
closeMainWindow
} from './window'
import { handleDeepLink } from './deeplink'
import {
fixUserDataPermissions,
setupPlatformSpecifics,
setupAppLifecycle,
getSystemLanguage
} from './lifecycle'
const userDataPath = app.getPath('userData') export { mainWindow, showMainWindow, triggerMainWindow, closeMainWindow }
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
const gotTheLock = app.requestSingleInstanceLock() const gotTheLock = app.requestSingleInstanceLock()
if (!gotTheLock) { if (!gotTheLock) {
app.quit() app.quit()
} }
@ -72,85 +54,67 @@ initApp().catch((e) => {
app.quit() app.quit()
}) })
export function customRelaunch(): void { setupPlatformSpecifics()
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'
})
}
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> { async function checkHighPrivilegeCoreEarly(): Promise<void> {
if (process.platform !== 'win32') { if (process.platform !== 'win32') return
return
}
try { try {
await initBasic() await initBasic()
const { checkAdminPrivileges } = await import('./core/manager')
const isCurrentAppAdmin = await checkAdminPrivileges() const isCurrentAppAdmin = await checkAdminPrivileges()
if (isCurrentAppAdmin) return
if (isCurrentAppAdmin) {
console.log('Current app is running as administrator, skipping privilege check')
return
}
const hasHighPrivilegeCore = await checkHighPrivilegeCore() 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 { try {
const appConfig = await getAppConfig() await restartAsAdmin(false)
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 {
process.exit(0) process.exit(0)
} catch (error) {
safeShowErrorBox('common.error.adminRequired', `${error}`)
process.exit(1)
} }
} else {
process.exit(0)
} }
} catch (e) { } catch (e) {
console.error('Failed to check high privilege core:', e) mainLogger.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) {
mainLogger.warn('Failed to read hardware acceleration config', e)
}
}
initHardwareAcceleration()
setupAppLifecycle()
app.on('second-instance', async (_event, commandline) => { app.on('second-instance', async (_event, commandline) => {
showMainWindow() showMainWindow()
const url = commandline.pop() const url = commandline.pop()
@ -164,58 +128,16 @@ app.on('open-url', async (_event, url) => {
await handleDeepLink(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 () => { app.whenReady().then(async () => {
// Set app user model id for windows
electronApp.setAppUserModelId('party.mihomo.app') electronApp.setAppUserModelId('party.mihomo.app')
await initBasic() await initBasic()
await checkHighPrivilegeCoreEarly() await checkHighPrivilegeCoreEarly()
await initAdminStatus() await initAdminStatus()
try { try {
await init() await init()
const appConfig = await getAppConfig() const appConfig = await getAppConfig()
// 如果配置中没有语言设置,则使用系统语言
if (!appConfig.language) { if (!appConfig.language) {
const systemLanguage = getSystemLanguage() const systemLanguage = getSystemLanguage()
await patchAppConfig({ language: systemLanguage }) await patchAppConfig({ language: systemLanguage })
@ -231,28 +153,27 @@ app.whenReady().then(async () => {
const [startPromise] = await startCore() const [startPromise] = await startCore()
startPromise.then(async () => { startPromise.then(async () => {
await initProfileUpdater() await initProfileUpdater()
await initWebdavBackupScheduler() // 初始化 WebDAV 定时备份任务 await initWebdavBackupScheduler()
// 上次是否为了开启 TUN 而重启
await checkAdminRestartForTun() await checkAdminRestartForTun()
}) })
} catch (e) { } catch (e) {
safeShowErrorBox('mihomo.error.coreStartFailed', `${e}`) safeShowErrorBox('mihomo.error.coreStartFailed', `${e}`)
} }
try { try {
await startMonitor() await startMonitor()
} catch { } catch {
// ignore // 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) => { app.on('browser-window-created', (_, window) => {
optimizer.watchWindowShortcuts(window) optimizer.watchWindowShortcuts(window)
}) })
const { showFloatingWindow: showFloating = false, disableTray = false } = await getAppConfig() const { showFloatingWindow: showFloating = false, disableTray = false } = await getAppConfig()
registerIpcMainHandlers() registerIpcMainHandlers()
await createWindow() await createWindow()
if (showFloating) { if (showFloating) {
try { try {
await showFloatingWindow() await showFloatingWindow()
@ -260,192 +181,14 @@ app.whenReady().then(async () => {
await logger.error('Failed to create floating window on startup', error) await logger.error('Failed to create floating window on startup', error)
} }
} }
if (!disableTray) { if (!disableTray) {
await createTray() await createTray()
} }
await initShortcut() await initShortcut()
app.on('activate', function () {
// On macOS it's common to re-create a window in the app when the app.on('activate', () => {
// dock icon is clicked and there are no other windows open.
showMainWindow() 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()
}
}

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

@ -0,0 +1,74 @@
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], {
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()
await 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 { app, globalShortcut, ipcMain, Notification } from 'electron'
import { mainWindow, triggerMainWindow } from '..' import { mainWindow, triggerMainWindow } from '../window'
import { import {
getAppConfig, getAppConfig,
getControledMihomoConfig, getControledMihomoConfig,

View File

@ -5,7 +5,7 @@ import * as chromeRequest from '../utils/chromeRequest'
import AdmZip from 'adm-zip' import AdmZip from 'adm-zip'
import { getControledMihomoConfig } from '../config' import { getControledMihomoConfig } from '../config'
import { existsSync } from 'fs' import { existsSync } from 'fs'
import { mainWindow } from '..' import { mainWindow } from '../window'
import { floatingWindow } from './floatingWindow' import { floatingWindow } from './floatingWindow'
import { t } from 'i18next' import { t } from 'i18next'

View File

@ -23,7 +23,7 @@ import {
getTrayIconStatus, getTrayIconStatus,
calculateTrayIconStatus calculateTrayIconStatus
} from '../core/mihomoApi' } 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 { app, clipboard, ipcMain, Menu, nativeImage, shell, Tray } from 'electron'
import { dataDir, logDir, mihomoCoreDir, mihomoWorkDir } from '../utils/dirs' import { dataDir, logDir, mihomoCoreDir, mihomoWorkDir } from '../utils/dirs'
import { triggerSysProxy } from '../sys/sysproxy' import { triggerSysProxy } from '../sys/sysproxy'
@ -540,8 +540,8 @@ export function updateTrayIconImmediate(sysProxyEnabled: boolean, tunEnabled: bo
} else if (process.platform === 'linux') { } else if (process.platform === 'linux') {
tray.setImage(iconPath) tray.setImage(iconPath)
} }
} catch (error) { } catch {
console.error('更新托盘图标失败:', error) // Failed to update tray icon
} }
}) })
} }
@ -563,7 +563,7 @@ export async function updateTrayIcon(): Promise<void> {
} else if (process.platform === 'linux') { } else if (process.platform === 'linux') {
tray.setImage(iconPath) tray.setImage(iconPath)
} }
} catch (error) { } catch {
console.error('更新托盘图标失败:', error) // Failed to update tray icon
} }
} }

View File

@ -2,7 +2,7 @@ import { exec } from 'child_process'
import { promisify } from 'util' import { promisify } from 'util'
import { getAppConfig, patchControledMihomoConfig } from '../config' import { getAppConfig, patchControledMihomoConfig } from '../config'
import { patchMihomoConfig } from '../core/mihomoApi' import { patchMihomoConfig } from '../core/mihomoApi'
import { mainWindow } from '..' import { mainWindow } from '../window'
import { ipcMain, net } from 'electron' import { ipcMain, net } from 'electron'
import { getDefaultDevice } from '../core/manager' import { getDefaultDevice } from '../core/manager'

View File

@ -10,47 +10,52 @@ import axios from 'axios'
import fs from 'fs' import fs from 'fs'
import { proxyLogger } from '../utils/logger' import { proxyLogger } from '../utils/logger'
let defaultBypass: string[]
let triggerSysProxyTimer: NodeJS.Timeout | null = null let triggerSysProxyTimer: NodeJS.Timeout | null = null
const helperSocketPath = '/tmp/mihomo-party-helper.sock' const helperSocketPath = '/tmp/mihomo-party-helper.sock'
if (process.platform === 'linux') const defaultBypass: string[] = (() => {
defaultBypass = ['localhost', '127.0.0.1', '192.168.0.0/16', '10.0.0.0/8', '172.16.0.0/12', '::1'] switch (process.platform) {
if (process.platform === 'darwin') case 'linux':
defaultBypass = [ return ['localhost', '127.0.0.1', '192.168.0.0/16', '10.0.0.0/8', '172.16.0.0/12', '::1']
'127.0.0.1', case 'darwin':
'192.168.0.0/16', return [
'10.0.0.0/8', '127.0.0.1',
'172.16.0.0/12', '192.168.0.0/16',
'localhost', '10.0.0.0/8',
'*.local', '172.16.0.0/12',
'*.crashlytics.com', 'localhost',
'<local>' '*.local',
] '*.crashlytics.com',
if (process.platform === 'win32') '<local>'
defaultBypass = [ ]
'localhost', case 'win32':
'127.*', return [
'192.168.*', 'localhost',
'10.*', '127.*',
'172.16.*', '192.168.*',
'172.17.*', '10.*',
'172.18.*', '172.16.*',
'172.19.*', '172.17.*',
'172.20.*', '172.18.*',
'172.21.*', '172.19.*',
'172.22.*', '172.20.*',
'172.23.*', '172.21.*',
'172.24.*', '172.22.*',
'172.25.*', '172.23.*',
'172.26.*', '172.24.*',
'172.27.*', '172.25.*',
'172.28.*', '172.26.*',
'172.29.*', '172.27.*',
'172.30.*', '172.28.*',
'172.31.*', '172.29.*',
'<local>' '172.30.*',
] '172.31.*',
'<local>'
]
default:
return ['localhost', '127.0.0.1', '192.168.0.0/16', '10.0.0.0/8', '172.16.0.0/12', '::1']
}
})()
export async function triggerSysProxy(enable: boolean): Promise<void> { export async function triggerSysProxy(enable: boolean): Promise<void> {
if (net.isOnline()) { if (net.isOnline()) {

View File

@ -9,6 +9,9 @@ import { join } from 'path'
import { existsSync, rmSync } from 'fs' import { existsSync, rmSync } from 'fs'
import { createGunzip } from 'zlib' import { createGunzip } from 'zlib'
import { stopCore } from '../core/manager' import { stopCore } from '../core/manager'
import { createLogger } from './logger'
const log = createLogger('GitHub')
export interface GitHubTag { export interface GitHubTag {
name: string name: string
@ -60,13 +63,13 @@ export async function getGitHubTags(
const cache = versionCache.get(cacheKey)! const cache = versionCache.get(cacheKey)!
// 检查缓存是否过期 // 检查缓存是否过期
if (Date.now() - cache.timestamp < CACHE_EXPIRY) { if (Date.now() - cache.timestamp < CACHE_EXPIRY) {
console.log(`[GitHub] Returning cached tags for ${owner}/${repo}`) log.debug(`Returning cached tags for ${owner}/${repo}`)
return cache.data return cache.data
} }
} }
try { try {
console.log(`[GitHub] Fetching tags for ${owner}/${repo}`) log.debug(`Fetching tags for ${owner}/${repo}`)
const response = await chromeRequest.get<GitHubTag[]>( const response = await chromeRequest.get<GitHubTag[]>(
`${GITHUB_API_CONFIG.BASE_URL}/repos/${owner}/${repo}/tags?per_page=${GITHUB_API_CONFIG.TAGS_PER_PAGE}`, `${GITHUB_API_CONFIG.BASE_URL}/repos/${owner}/${repo}/tags?per_page=${GITHUB_API_CONFIG.TAGS_PER_PAGE}`,
{ {
@ -85,10 +88,10 @@ export async function getGitHubTags(
timestamp: Date.now() timestamp: Date.now()
}) })
console.log(`[GitHub] Successfully fetched ${response.data.length} tags for ${owner}/${repo}`) log.debug(`Successfully fetched ${response.data.length} tags for ${owner}/${repo}`)
return response.data return response.data
} catch (error) { } catch (error) {
console.error(`[GitHub] Failed to fetch tags for ${owner}/${repo}:`, error) log.error(`Failed to fetch tags for ${owner}/${repo}`, error)
if (error instanceof Error) { if (error instanceof Error) {
throw new Error(`GitHub API error: ${error.message}`) throw new Error(`GitHub API error: ${error.message}`)
} }
@ -105,7 +108,7 @@ export function clearVersionCache(owner: string, repo: string): void {
const cacheKey = `${owner}/${repo}` const cacheKey = `${owner}/${repo}`
const hasCache = versionCache.has(cacheKey) const hasCache = versionCache.has(cacheKey)
versionCache.delete(cacheKey) versionCache.delete(cacheKey)
console.log(`[GitHub] Cache ${hasCache ? 'cleared' : 'not found'} for ${owner}/${repo}`) log.debug(`Cache ${hasCache ? 'cleared' : 'not found'} for ${owner}/${repo}`)
} }
/** /**
@ -115,16 +118,16 @@ export function clearVersionCache(owner: string, repo: string): void {
*/ */
async function downloadGitHubAsset(url: string, outputPath: string): Promise<void> { async function downloadGitHubAsset(url: string, outputPath: string): Promise<void> {
try { try {
console.log(`[GitHub] Downloading asset from ${url}`) log.debug(`Downloading asset from ${url}`)
const response = await chromeRequest.get(url, { const response = await chromeRequest.get(url, {
responseType: 'arraybuffer', responseType: 'arraybuffer',
timeout: 30000 timeout: 30000
}) })
await writeFile(outputPath, Buffer.from(response.data as Buffer)) await writeFile(outputPath, Buffer.from(response.data as Buffer))
console.log(`[GitHub] Successfully downloaded asset to ${outputPath}`) log.debug(`Successfully downloaded asset to ${outputPath}`)
} catch (error) { } catch (error) {
console.error(`[GitHub] Failed to download asset from ${url}:`, error) log.error(`Failed to download asset from ${url}`, error)
if (error instanceof Error) { if (error instanceof Error) {
throw new Error(`Download error: ${error.message}`) throw new Error(`Download error: ${error.message}`)
} }
@ -138,7 +141,7 @@ async function downloadGitHubAsset(url: string, outputPath: string): Promise<voi
*/ */
export async function installMihomoCore(version: string): Promise<void> { export async function installMihomoCore(version: string): Promise<void> {
try { try {
console.log(`[GitHub] Installing mihomo core version ${version}`) log.info(`Installing mihomo core version ${version}`)
const plat = platform() const plat = platform()
const arch = process.arch const arch = process.arch
@ -163,7 +166,7 @@ export async function installMihomoCore(version: string): Promise<void> {
// 如果目标文件已存在,先停止核心 // 如果目标文件已存在,先停止核心
if (existsSync(targetPath)) { if (existsSync(targetPath)) {
console.log('[GitHub] Stopping core before extracting new core file') log.debug('Stopping core before extracting new core file')
// 先停止核心 // 先停止核心
await stopCore(true) await stopCore(true)
} }
@ -173,26 +176,26 @@ export async function installMihomoCore(version: string): Promise<void> {
// 解压文件 // 解压文件
if (urlExt === 'zip') { if (urlExt === 'zip') {
console.log(`[GitHub] Extracting ZIP file ${tempZip}`) log.debug(`Extracting ZIP file ${tempZip}`)
const zip = new AdmZip(tempZip) const zip = new AdmZip(tempZip)
const entries = zip.getEntries() const entries = zip.getEntries()
const entry = entries.find((e) => e.entryName.includes(exeFile)) const entry = entries.find((e) => e.entryName.includes(exeFile))
if (entry) { if (entry) {
zip.extractEntryTo(entry, coreDir, false, true, false, targetFile) zip.extractEntryTo(entry, coreDir, false, true, false, targetFile)
console.log(`[GitHub] Successfully extracted ${exeFile} to ${targetPath}`) log.debug(`Successfully extracted ${exeFile} to ${targetPath}`)
} else { } else {
throw new Error(`Executable file not found in zip: ${exeFile}`) throw new Error(`Executable file not found in zip: ${exeFile}`)
} }
} else { } else {
// 处理.gz 文件 // 处理.gz 文件
console.log(`[GitHub] Extracting GZ file ${tempZip}`) log.debug(`Extracting GZ file ${tempZip}`)
const readStream = createReadStream(tempZip) const readStream = createReadStream(tempZip)
const writeStream = createWriteStream(targetPath) const writeStream = createWriteStream(targetPath)
await new Promise<void>((resolve, reject) => { await new Promise<void>((resolve, reject) => {
const onError = (error: Error) => { const onError = (error: Error) => {
console.error('[GitHub] Gzip decompression failed:', error.message) log.error('Gzip decompression failed', error)
reject(new Error(`Gzip decompression failed: ${error.message}`)) reject(new Error(`Gzip decompression failed: ${error.message}`))
} }
@ -200,12 +203,12 @@ export async function installMihomoCore(version: string): Promise<void> {
.pipe(createGunzip().on('error', onError)) .pipe(createGunzip().on('error', onError))
.pipe(writeStream) .pipe(writeStream)
.on('finish', () => { .on('finish', () => {
console.log('[GitHub] Gunzip finished') log.debug('Gunzip finished')
try { try {
execSync(`chmod 755 ${targetPath}`) execSync(`chmod 755 ${targetPath}`)
console.log('[GitHub] Chmod binary finished') log.debug('Chmod binary finished')
} catch (chmodError) { } catch (chmodError) {
console.warn('[GitHub] Failed to chmod binary:', chmodError) log.warn('Failed to chmod binary', chmodError)
} }
resolve() resolve()
}) })
@ -214,12 +217,12 @@ export async function installMihomoCore(version: string): Promise<void> {
} }
// 清理临时文件 // 清理临时文件
console.log(`[GitHub] Cleaning up temporary file ${tempZip}`) log.debug(`Cleaning up temporary file ${tempZip}`)
rmSync(tempZip) rmSync(tempZip)
console.log(`[GitHub] Successfully installed mihomo core version ${version}`) log.info(`Successfully installed mihomo core version ${version}`)
} catch (error) { } catch (error) {
console.error('[GitHub] Failed to install mihomo core:', error) log.error('Failed to install mihomo core', error)
throw new Error( throw new Error(
`Failed to install core: ${error instanceof Error ? error.message : String(error)}` `Failed to install core: ${error instanceof Error ? error.message : String(error)}`
) )

View File

@ -213,8 +213,12 @@ async function cleanup(): Promise<void> {
// logs // logs
const { maxLogDays = 7 } = await getAppConfig() const { maxLogDays = 7 } = await getAppConfig()
const logs = await readdir(logDir()) const logs = await readdir(logDir())
const datePattern = /^\d{4}-\d{2}-\d{2}/
for (const log of logs) { for (const log of logs) {
const date = new Date(log.split('.')[0]) const match = log.match(datePattern)
if (!match) continue
const date = new Date(match[0])
if (isNaN(date.getTime())) continue
const diff = Date.now() - date.getTime() const diff = Date.now() - date.getTime()
if (diff > maxLogDays * 24 * 60 * 60 * 1000) { if (diff > maxLogDays * 24 * 60 * 60 * 1000) {
try { try {

View File

@ -104,7 +104,7 @@ import {
updateTrayIconImmediate updateTrayIconImmediate
} from '../resolve/tray' } from '../resolve/tray'
import { registerShortcut } from '../resolve/shortcut' import { registerShortcut } from '../resolve/shortcut'
import { closeMainWindow, mainWindow, showMainWindow, triggerMainWindow } from '..' import { closeMainWindow, mainWindow, showMainWindow, triggerMainWindow } from '../window'
import { import {
applyTheme, applyTheme,
fetchThemes, fetchThemes,

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

@ -0,0 +1,177 @@
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, silentStart = false, autoQuitWithoutCore = false, autoQuitWithoutCoreDelay = 60 } = 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, { silentStart, autoQuitWithoutCore, autoQuitWithoutCoreDelay })
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'))
}
}
interface WindowConfig {
silentStart: boolean
autoQuitWithoutCore: boolean
autoQuitWithoutCoreDelay: number
}
function setupWindowEvents(
window: BrowserWindow,
windowState: ReturnType<typeof windowStateKeeper>,
config: WindowConfig
): void {
const { silentStart, autoQuitWithoutCore, autoQuitWithoutCoreDelay } = config
window.on('ready-to-show', () => {
if (autoQuitWithoutCore && !window.isVisible()) {
scheduleQuitWithoutCore(autoQuitWithoutCoreDelay)
}
// 开发模式下始终显示窗口
if (!silentStart || is.dev) {
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 () => {
await 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 ] as const
// 允许的 send channels 白名单 // 允许的 send channels 白名单
const validSendChannels = [ const validSendChannels = ['updateTrayMenu', 'updateFloatingWindow', 'trayIconUpdate'] as const
'updateTrayMenu',
'updateFloatingWindow',
'trayIconUpdate'
] as const
type InvokeChannel = (typeof validInvokeChannels)[number] type InvokeChannel = (typeof validInvokeChannels)[number]
type ListenChannel = (typeof validListenChannels)[number] type ListenChannel = (typeof validListenChannels)[number]

View File

@ -33,16 +33,13 @@ import { platform } from '@renderer/utils/init'
import { TitleBarOverlayOptions } from 'electron' import { TitleBarOverlayOptions } from 'electron'
import SubStoreCard from '@renderer/components/sider/substore-card' import SubStoreCard from '@renderer/components/sider/substore-card'
import MihomoIcon from './components/base/mihomo-icon' 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 'driver.js/dist/driver.css'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
let navigate: NavigateFunction let navigate: NavigateFunction
let driverInstance: ReturnType<typeof driver> | null = null
export function getDriver(): ReturnType<typeof driver> | null { export { getDriver }
return driverInstance
}
const App: React.FC = () => { const App: React.FC = () => {
const { t } = useTranslation() const { t } = useTranslation()
@ -74,11 +71,13 @@ const App: React.FC = () => {
const siderWidthValueRef = useRef(siderWidthValue) const siderWidthValueRef = useRef(siderWidthValue)
const [resizing, setResizing] = useState(false) const [resizing, setResizing] = useState(false)
const resizingRef = useRef(resizing) const resizingRef = useRef(resizing)
const tourInitialized = useRef(false)
const sensors = useSensors(useSensor(PointerSensor)) const sensors = useSensors(useSensor(PointerSensor))
const { setTheme, systemTheme } = useTheme() const { setTheme, systemTheme } = useTheme()
navigate = useNavigate() navigate = useNavigate()
const location = useLocation() const location = useLocation()
const page = useRoutes(routes) const page = useRoutes(routes)
const setTitlebar = (): void => { const setTitlebar = (): void => {
if (!useWindowFrame && platform !== 'darwin') { if (!useWindowFrame && platform !== 'darwin') {
const options = { height: 48 } as TitleBarOverlayOptions const options = { height: 48 } as TitleBarOverlayOptions
@ -103,186 +102,10 @@ const App: React.FC = () => {
}, [siderWidthValue, resizing]) }, [siderWidthValue, resizing])
useEffect(() => { useEffect(() => {
driverInstance = driver({ if (!tourInitialized.current) {
showProgress: true, tourInitialized.current = true
nextBtnText: t('common.next'), createTourDriver(t, navigate)
prevBtnText: t('common.prev'), startTourIfNeeded()
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()
} }
}, [t]) }, [t])

View File

@ -421,80 +421,80 @@ const RuleListItemBase: React.FC<RuleListItemProps> = ({
onMoveDown, onMoveDown,
onRemove onRemove
}) => { }) => {
let bgColorClass = 'bg-content2' let bgColorClass = 'bg-content2'
let textStyleClass = '' let textStyleClass = ''
if (isDeleted) { if (isDeleted) {
bgColorClass = 'bg-danger-50 opacity-70' bgColorClass = 'bg-danger-50 opacity-70'
textStyleClass = 'line-through text-foreground-500' textStyleClass = 'line-through text-foreground-500'
} else if (isPrependOrAppend) { } else if (isPrependOrAppend) {
bgColorClass = 'bg-success-50' bgColorClass = 'bg-success-50'
} }
return ( return (
<div className={`flex items-center gap-2 p-2 rounded-lg ${bgColorClass}`}> <div className={`flex items-center gap-2 p-2 rounded-lg ${bgColorClass}`}>
<div className="flex flex-col"> <div className="flex flex-col">
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<Chip size="sm" variant="flat"> <Chip size="sm" variant="flat">
{rule.type} {rule.type}
</Chip> </Chip>
{/* 显示附加参数 */} {/* 显示附加参数 */}
<div className="flex gap-1"> <div className="flex gap-1">
{rule.additionalParams && {rule.additionalParams &&
rule.additionalParams.length > 0 && rule.additionalParams.length > 0 &&
rule.additionalParams.map((param, idx) => ( rule.additionalParams.map((param, idx) => (
<Chip key={idx} size="sm" variant="flat" color="secondary"> <Chip key={idx} size="sm" variant="flat" color="secondary">
{param} {param}
</Chip> </Chip>
))} ))}
</div>
</div> </div>
</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>
) <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) => { const RuleListItem = memo(RuleListItemBase, (prevProps, nextProps) => {
@ -596,7 +596,9 @@ const EditRulesModal: React.FC<Props> = (props) => {
if (Array.isArray(parsed['proxy-groups'])) { if (Array.isArray(parsed['proxy-groups'])) {
groups.push( groups.push(
...((parsed['proxy-groups'] as Array<Record<string, unknown>>) ...((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[]) .filter(Boolean) as string[])
) )
} }
@ -604,7 +606,9 @@ const EditRulesModal: React.FC<Props> = (props) => {
if (Array.isArray(parsed['proxies'])) { if (Array.isArray(parsed['proxies'])) {
groups.push( groups.push(
...((parsed['proxies'] as Array<Record<string, unknown>>) ...((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[]) .filter(Boolean) as string[])
) )
} }

View File

@ -125,6 +125,8 @@ export const ProfileConfigProvider: React.FC<{ children: ReactNode }> = ({ child
} }
window.electron.ipcRenderer.on('profileConfigUpdated', handler) window.electron.ipcRenderer.on('profileConfigUpdated', handler)
return (): void => { return (): void => {
// 清理待处理任务,防止内存泄漏
targetProfileId.current = null
window.electron.ipcRenderer.removeListener('profileConfigUpdated', handler) 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 { HiOutlineAdjustmentsHorizontal } from 'react-icons/hi2'
import { Dropdown, DropdownTrigger, DropdownMenu, DropdownItem } from '@heroui/react' import { Dropdown, DropdownTrigger, DropdownMenu, DropdownItem } from '@heroui/react'
import { includesIgnoreCase } from '@renderer/utils/includes' 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 { useTranslation } from 'react-i18next'
import { IoMdPause, IoMdPlay } from 'react-icons/io' import { IoMdPause, IoMdPlay } from 'react-icons/io'

View File

@ -707,8 +707,8 @@ const Mihomo: React.FC = () => {
max={65535} max={65535}
min={0} min={0}
onValueChange={(v) => { onValueChange={(v) => {
const port = parseInt(v) const port = v === '' ? 0 : parseInt(v)
if (!isNaN(port)) { if (!isNaN(port) && port >= 0 && port <= 65535) {
setMixedPortInput(port) setMixedPortInput(port)
patchAppConfig({ showMixedPort: port }) patchAppConfig({ showMixedPort: port })
setIsManualPortChange(true) setIsManualPortChange(true)
@ -768,8 +768,8 @@ const Mihomo: React.FC = () => {
max={65535} max={65535}
min={0} min={0}
onValueChange={(v) => { onValueChange={(v) => {
const port = parseInt(v) const port = v === '' ? 0 : parseInt(v)
if (!isNaN(port)) { if (!isNaN(port) && port >= 0 && port <= 65535) {
setSocksPortInput(port) setSocksPortInput(port)
patchAppConfig({ showSocksPort: port }) patchAppConfig({ showSocksPort: port })
setIsManualPortChange(true) setIsManualPortChange(true)
@ -829,8 +829,8 @@ const Mihomo: React.FC = () => {
max={65535} max={65535}
min={0} min={0}
onValueChange={(v) => { onValueChange={(v) => {
const port = parseInt(v) const port = v === '' ? 0 : parseInt(v)
if (!isNaN(port)) { if (!isNaN(port) && port >= 0 && port <= 65535) {
setHttpPortInput(port) setHttpPortInput(port)
patchAppConfig({ showHttpPort: port }) patchAppConfig({ showHttpPort: port })
setIsManualPortChange(true) setIsManualPortChange(true)
@ -891,8 +891,8 @@ const Mihomo: React.FC = () => {
max={65535} max={65535}
min={0} min={0}
onValueChange={(v) => { onValueChange={(v) => {
const port = parseInt(v) const port = v === '' ? 0 : parseInt(v)
if (!isNaN(port)) { if (!isNaN(port) && port >= 0 && port <= 65535) {
setRedirPortInput(port) setRedirPortInput(port)
patchAppConfig({ showRedirPort: port }) patchAppConfig({ showRedirPort: port })
setIsManualPortChange(true) setIsManualPortChange(true)
@ -954,8 +954,8 @@ const Mihomo: React.FC = () => {
max={65535} max={65535}
min={0} min={0}
onValueChange={(v) => { onValueChange={(v) => {
const port = parseInt(v) const port = v === '' ? 0 : parseInt(v)
if (!isNaN(port)) { if (!isNaN(port) && port >= 0 && port <= 65535) {
setTproxyPortInput(port) setTproxyPortInput(port)
patchAppConfig({ showTproxyPort: port }) patchAppConfig({ showTproxyPort: port })
setIsManualPortChange(true) setIsManualPortChange(true)

View File

@ -113,8 +113,7 @@ export const convertMrsRuleset = (path: string, behavior: string): Promise<strin
export const getRuntimeConfig = (): Promise<IMihomoConfig> => invoke('getRuntimeConfig') export const getRuntimeConfig = (): Promise<IMihomoConfig> => invoke('getRuntimeConfig')
export const getRuntimeConfigStr = (): Promise<string> => invoke('getRuntimeConfigStr') export const getRuntimeConfigStr = (): Promise<string> => invoke('getRuntimeConfigStr')
export const getRuleStr = (id: string): Promise<string> => invoke('getRuleStr', id) export const getRuleStr = (id: string): Promise<string> => invoke('getRuleStr', id)
export const setRuleStr = (id: string, str: string): Promise<void> => export const setRuleStr = (id: string, str: string): Promise<void> => invoke('setRuleStr', id, str)
invoke('setRuleStr', id, str)
export const getFilePath = (ext: string[]): Promise<string[] | undefined> => export const getFilePath = (ext: string[]): Promise<string[] | undefined> =>
invoke('getFilePath', ext) invoke('getFilePath', ext)
export const readTextFile = (filePath: string): Promise<string> => invoke('readTextFile', filePath) 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') export const quitWithoutCore = (): Promise<void> => invoke('quitWithoutCore')
// System // System
export const triggerSysProxy = (enable: boolean): Promise<void> => export const triggerSysProxy = (enable: boolean): Promise<void> => invoke('triggerSysProxy', enable)
invoke('triggerSysProxy', enable)
export const checkTunPermissions = (): Promise<boolean> => invoke('checkTunPermissions') export const checkTunPermissions = (): Promise<boolean> => invoke('checkTunPermissions')
export const grantTunPermissions = (): Promise<void> => invoke('grantTunPermissions') export const grantTunPermissions = (): Promise<void> => invoke('grantTunPermissions')
export const manualGrantCorePermition = (): Promise<void> => invoke('manualGrantCorePermition') export const manualGrantCorePermition = (): Promise<void> => invoke('manualGrantCorePermition')
@ -168,8 +166,7 @@ export const clearMihomoVersionCache = (): Promise<void> => invoke('clearMihomoV
// Backup // Backup
export const webdavBackup = (): Promise<boolean> => invoke('webdavBackup') export const webdavBackup = (): Promise<boolean> => invoke('webdavBackup')
export const webdavRestore = (filename: string): Promise<void> => export const webdavRestore = (filename: string): Promise<void> => invoke('webdavRestore', filename)
invoke('webdavRestore', filename)
export const listWebdavBackups = (): Promise<string[]> => invoke('listWebdavBackups') export const listWebdavBackups = (): Promise<string[]> => invoke('listWebdavBackups')
export const webdavDelete = (filename: string): Promise<void> => invoke('webdavDelete', filename) export const webdavDelete = (filename: string): Promise<void> => invoke('webdavDelete', filename)
export const reinitWebdavBackupScheduler = (): Promise<void> => export const reinitWebdavBackupScheduler = (): Promise<void> =>
@ -180,10 +177,8 @@ export const importLocalBackup = (): Promise<boolean> => invoke('importLocalBack
// SubStore // SubStore
export const startSubStoreFrontendServer = (): Promise<void> => export const startSubStoreFrontendServer = (): Promise<void> =>
invoke('startSubStoreFrontendServer') invoke('startSubStoreFrontendServer')
export const stopSubStoreFrontendServer = (): Promise<void> => export const stopSubStoreFrontendServer = (): Promise<void> => invoke('stopSubStoreFrontendServer')
invoke('stopSubStoreFrontendServer') export const startSubStoreBackendServer = (): Promise<void> => invoke('startSubStoreBackendServer')
export const startSubStoreBackendServer = (): Promise<void> =>
invoke('startSubStoreBackendServer')
export const stopSubStoreBackendServer = (): Promise<void> => invoke('stopSubStoreBackendServer') export const stopSubStoreBackendServer = (): Promise<void> => invoke('stopSubStoreBackendServer')
export const downloadSubStore = (): Promise<void> => invoke('downloadSubStore') export const downloadSubStore = (): Promise<void> => invoke('downloadSubStore')
export const subStorePort = (): Promise<number> => invoke('subStorePort') export const subStorePort = (): Promise<number> => invoke('subStorePort')
@ -201,10 +196,11 @@ export const writeTheme = (theme: string, css: string): Promise<void> =>
invoke('writeTheme', theme, css) invoke('writeTheme', theme, css)
let applyThemeRunning = false let applyThemeRunning = false
const applyThemeWaitList: string[] = [] let pendingTheme: string | null = null
export async function applyTheme(theme: string): Promise<void> { export async function applyTheme(theme: string): Promise<void> {
if (applyThemeRunning) { if (applyThemeRunning) {
applyThemeWaitList.push(theme) pendingTheme = theme
return return
} }
applyThemeRunning = true applyThemeRunning = true
@ -212,11 +208,10 @@ export async function applyTheme(theme: string): Promise<void> {
await invoke<void>('applyTheme', theme) await invoke<void>('applyTheme', theme)
} finally { } finally {
applyThemeRunning = false applyThemeRunning = false
if (applyThemeWaitList.length > 0) { if (pendingTheme !== null) {
const nextTheme = applyThemeWaitList.shift() const nextTheme = pendingTheme
if (nextTheme) { pendingTheme = null
await applyTheme(nextTheme) await applyTheme(nextTheme)
}
} }
} }
} }

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()
}
}