feat: add lightweight mode behavior toggle

This commit is contained in:
Memory
2026-06-19 23:52:54 +08:00
committed by GitHub
parent 8f7f00ebcd
commit a4e4c6186a
11 changed files with 148 additions and 51 deletions

View File

@@ -90,6 +90,31 @@ function hasCoreProcess(): boolean {
return Boolean(child && !child.killed && child.exitCode === null && child.signalCode === null)
}
async function stopPidFileCore(): Promise<void> {
const pidPath = path.join(dataDir(), 'core.pid')
if (!existsSync(pidPath)) return
const pidString = await readFile(pidPath, 'utf-8').catch(() => '')
const pid = parseInt(pidString.trim())
if (!isNaN(pid)) {
try {
process.kill(pid, 0)
process.kill(pid, 'SIGINT')
await new Promise((resolve) => setTimeout(resolve, 1000))
try {
process.kill(pid, 0)
process.kill(pid, 'SIGKILL')
} catch {
// ignore
}
} catch {
// ignore
}
}
await rm(pidPath).catch(() => {})
}
// 初始化核心文件监听
export function initCoreWatcher(): void {
if (coreWatcher) return
@@ -183,18 +208,8 @@ async function prepareCore(detached: boolean, skipStop = false): Promise<CoreCon
const { 'log-level': logLevel = 'info' as LogLevel, tun } = mihomoConfig
// 清理旧进程
const pidPath = path.join(dataDir(), 'core.pid')
if (existsSync(pidPath)) {
const pid = parseInt(await readFile(pidPath, 'utf-8'))
try {
process.kill(pid, 'SIGINT')
} catch {
// ignore
} finally {
await rm(pidPath)
}
}
// 清理轻量模式遗留的后台核心
await stopPidFileCore()
// 管理 Smart 内核覆写配置
await manageSmartOverride()
@@ -416,6 +431,7 @@ export async function stopCore(force = false): Promise<void> {
managerLogger.warn('Failed to refresh axios instance:', error)
}
await stopPidFileCore()
await cleanupSocketFile()
}
@@ -477,18 +493,7 @@ export async function keepCoreAlive(): Promise<void> {
// 退出但保持核心运行
export async function quitWithoutCore(): Promise<void> {
managerLogger.info(`Starting lightweight mode on platform: ${process.platform}`)
try {
await startCore(true)
if (child?.pid) {
await writeFile(path.join(dataDir(), 'core.pid'), child.pid.toString())
managerLogger.info(`Core started in lightweight mode with PID: ${child.pid}`)
}
} catch (e) {
managerLogger.error('Failed to start core in lightweight mode:', e)
safeShowErrorBox('mihomo.error.coreStartFailed', `${e}`)
}
await keepCoreAlive()
await startMonitor(true)
managerLogger.info('Exiting main process, core will continue running in background')
app.exit()

View File

@@ -116,6 +116,10 @@ export function setupAppLifecycle(): void {
)
}
app.on('window-all-closed', () => {
// Keep the app and tray alive when lightweight tray mode destroys the renderer window.
})
app.on('before-quit', async (e) => {
e.preventDefault()
await cleanupBeforeExit()

View File

@@ -39,6 +39,9 @@ export const defaultConfig: IAppConfig = {
connectionDirection: 'asc',
connectionOrderBy: 'time',
useSubStore: DEFAULT_USE_SUB_STORE,
autoQuitWithoutCore: false,
autoQuitWithoutCoreDelay: 60,
autoQuitWithoutCoreMode: 'core',
proxyDisplayMode: 'simple',
proxyDisplayOrder: 'default',
autoCheckUpdate: true,

View File

@@ -52,13 +52,26 @@ function ensureVisibleOnScreen(state: WindowState): WindowState {
export let mainWindow: BrowserWindow | null = null
let quitTimeout: NodeJS.Timeout | null = null
let createWindowPromise: Promise<void> | null = null
type AutoQuitWithoutCoreMode = NonNullable<IAppConfig['autoQuitWithoutCoreMode']>
export async function createWindow(): Promise<void> {
if (mainWindow && !mainWindow.isDestroyed()) return
if (createWindowPromise) return createWindowPromise
createWindowPromise = createWindowInternal().finally(() => {
createWindowPromise = null
})
return createWindowPromise
}
async function createWindowInternal(): Promise<void> {
const {
useWindowFrame = false,
silentStart = false,
autoQuitWithoutCore = false,
autoQuitWithoutCoreDelay = 60
autoQuitWithoutCoreDelay = 60,
autoQuitWithoutCoreMode = 'core'
} = await getAppConfig()
const savedState = ensureVisibleOnScreen(loadWindowState())
@@ -97,7 +110,8 @@ export async function createWindow(): Promise<void> {
setupWindowEvents(mainWindow, {
silentStart,
autoQuitWithoutCore,
autoQuitWithoutCoreDelay
autoQuitWithoutCoreDelay,
autoQuitWithoutCoreMode
})
if (is.dev) {
@@ -115,14 +129,16 @@ interface WindowConfig {
silentStart: boolean
autoQuitWithoutCore: boolean
autoQuitWithoutCoreDelay: number
autoQuitWithoutCoreMode: AutoQuitWithoutCoreMode
}
function setupWindowEvents(window: BrowserWindow, config: WindowConfig): void {
const { silentStart, autoQuitWithoutCore, autoQuitWithoutCoreDelay } = config
const { silentStart, autoQuitWithoutCore, autoQuitWithoutCoreDelay, autoQuitWithoutCoreMode } =
config
window.on('ready-to-show', () => {
if (autoQuitWithoutCore && !window.isVisible()) {
scheduleQuitWithoutCore(autoQuitWithoutCoreDelay)
scheduleQuitWithoutCore(autoQuitWithoutCoreDelay, autoQuitWithoutCoreMode)
}
// 开发模式下始终显示窗口
@@ -148,6 +164,7 @@ function setupWindowEvents(window: BrowserWindow, config: WindowConfig): void {
const {
autoQuitWithoutCore = false,
autoQuitWithoutCoreDelay = 60,
autoQuitWithoutCoreMode = 'core',
useDockIcon = true
} = await getAppConfig()
@@ -156,7 +173,13 @@ function setupWindowEvents(window: BrowserWindow, config: WindowConfig): void {
}
if (autoQuitWithoutCore) {
scheduleQuitWithoutCore(autoQuitWithoutCoreDelay)
scheduleQuitWithoutCore(autoQuitWithoutCoreDelay, autoQuitWithoutCoreMode)
}
})
window.on('closed', () => {
if (mainWindow === window) {
mainWindow = null
}
})
@@ -176,9 +199,20 @@ function setupWindowEvents(window: BrowserWindow, config: WindowConfig): void {
})
}
function scheduleQuitWithoutCore(delaySeconds: number): void {
function scheduleQuitWithoutCore(
delaySeconds: number,
mode: AutoQuitWithoutCoreMode = 'core'
): void {
clearQuitTimeout()
quitTimeout = setTimeout(async () => {
if (mode === 'tray') {
if (mainWindow && !mainWindow.isVisible()) {
mainWindow.destroy()
hideDockIcon()
}
return
}
await quitWithoutCore()
}, delaySeconds * 1000)
}
@@ -191,7 +225,10 @@ export function clearQuitTimeout(): void {
}
export function triggerMainWindow(force?: boolean): void {
if (!mainWindow) return
if (!mainWindow || mainWindow.isDestroyed()) {
showMainWindow()
return
}
getAppConfig()
.then(({ triggerMainWindowBehavior = 'toggle' }) => {
@@ -209,11 +246,20 @@ export function triggerMainWindow(force?: boolean): void {
}
export function showMainWindow(): void {
if (mainWindow) {
clearQuitTimeout()
if (mainWindow && !mainWindow.isDestroyed()) {
clearQuitTimeout()
mainWindow.show()
mainWindow.focusOnWebView()
return
}
void createWindow().then(() => {
clearQuitTimeout()
mainWindow?.show()
mainWindow?.focusOnWebView()
})
}
export function closeMainWindow(): void {

View File

@@ -69,6 +69,7 @@ const GeneralConfig: React.FC = () => {
useWindowFrame = false,
autoQuitWithoutCore = false,
autoQuitWithoutCoreDelay = 60,
autoQuitWithoutCoreMode = 'core',
customTheme = 'default.css',
envType = [platform === 'win32' ? 'powershell' : 'bash'],
autoCheckUpdate,
@@ -237,27 +238,49 @@ const GeneralConfig: React.FC = () => {
/>
</SettingItem>
{autoQuitWithoutCore && (
<SettingItem title={t('settings.autoQuitWithoutCoreDelay')} divider>
<div className="flex items-center gap-2">
<Input
<>
<SettingItem title={t('settings.autoQuitWithoutCoreMode')} divider>
<Tabs
size="sm"
className="w-25"
type="number"
value={autoQuitWithoutCoreDelay.toString()}
onValueChange={async (v: string) => {
const num = parseInt(v)
await patchAppConfig({ autoQuitWithoutCoreDelay: num })
color="primary"
selectedKey={autoQuitWithoutCoreMode}
onSelectionChange={async (key) => {
const mode = key as 'core' | 'tray'
await patchAppConfig({ autoQuitWithoutCoreMode: mode })
if (mode === 'core' && autoQuitWithoutCoreDelay < 5) {
await patchAppConfig({ autoQuitWithoutCoreDelay: 5 })
}
}}
onBlur={async (e) => {
let num = parseInt(e.target.value)
if (isNaN(num)) num = 5
if (num < 5) num = 5
await patchAppConfig({ autoQuitWithoutCoreDelay: num })
}}
/>
<span className="text-default-500">{t('common.seconds')}</span>
</div>
</SettingItem>
>
<Tab key="core" title={t('settings.autoQuitWithoutCoreModeCore')} />
<Tab key="tray" title={t('settings.autoQuitWithoutCoreModeTray')} />
</Tabs>
</SettingItem>
<SettingItem title={t('settings.autoQuitWithoutCoreDelay')} divider>
<div className="flex items-center gap-2">
<Input
size="sm"
className="w-25"
type="number"
value={autoQuitWithoutCoreDelay.toString()}
onValueChange={async (v: string) => {
const num = parseInt(v)
if (!isNaN(num)) {
await patchAppConfig({ autoQuitWithoutCoreDelay: num })
}
}}
onBlur={async (e) => {
const minDelay = autoQuitWithoutCoreMode === 'core' ? 5 : 0
let num = parseInt(e.target.value)
if (isNaN(num)) num = minDelay
if (num < minDelay) num = minDelay
await patchAppConfig({ autoQuitWithoutCoreDelay: num })
}}
/>
<span className="text-default-500">{t('common.seconds')}</span>
</div>
</SettingItem>
</>
)}
<SettingItem
title={t('settings.envType')}

View File

@@ -81,6 +81,9 @@
"settings.silentStart": "Silent Start",
"settings.autoQuitWithoutCore": "Auto Enable Light Mode",
"settings.autoQuitWithoutCoreTooltip": "Automatically enter light mode after closing window for specified time",
"settings.autoQuitWithoutCoreMode": "Light Mode Behavior",
"settings.autoQuitWithoutCoreModeCore": "Keep Core Only",
"settings.autoQuitWithoutCoreModeTray": "Close Renderer Only",
"settings.subscriptionTimeout": "Subscription Timeout",
"settings.autoQuitWithoutCoreDelay": "Light Mode Auto Enable Delay",
"settings.envType": "Environment Variable Type",

View File

@@ -78,6 +78,9 @@
"settings.silentStart": "اجرای بی‌صدا",
"settings.autoQuitWithoutCore": "ورود خودکار به حالت سبک",
"settings.autoQuitWithoutCoreTooltip": "ورود خودکار به حالت سبک پس از بستن پنجره برای زمان مشخص",
"settings.autoQuitWithoutCoreMode": "رفتار حالت سبک",
"settings.autoQuitWithoutCoreModeCore": "فقط هسته را نگه دار",
"settings.autoQuitWithoutCoreModeTray": "فقط رندرر را ببند",
"settings.subscriptionTimeout": "زمان انتظار اشتراک",
"settings.autoQuitWithoutCoreDelay": "تاخیر فعال‌سازی خودکار حالت سبک",
"settings.envType": "نوع متغیر محیطی",

View File

@@ -80,6 +80,9 @@
"settings.silentStart": "Тихий запуск",
"settings.autoQuitWithoutCore": "Автоматический облегчённый режим",
"settings.autoQuitWithoutCoreTooltip": "Автоматически переходить в облегчённый режим после закрытия окна",
"settings.autoQuitWithoutCoreMode": "Поведение облегчённого режима",
"settings.autoQuitWithoutCoreModeCore": "Оставить только ядро",
"settings.autoQuitWithoutCoreModeTray": "Закрыть только рендерер",
"settings.subscriptionTimeout": "Таймаут подписки",
"settings.autoQuitWithoutCoreDelay": "Задержка включения облегчённого режима",
"settings.envType": "Тип переменных окружения",

View File

@@ -81,6 +81,9 @@
"settings.silentStart": "静默启动",
"settings.autoQuitWithoutCore": "自动进入轻量模式",
"settings.autoQuitWithoutCoreTooltip": "关闭窗口后指定时间自动进入轻量模式",
"settings.autoQuitWithoutCoreMode": "轻量模式行为",
"settings.autoQuitWithoutCoreModeCore": "仅保留内核",
"settings.autoQuitWithoutCoreModeTray": "仅关闭渲染进程",
"settings.subscriptionTimeout": "订阅超时时间",
"settings.autoQuitWithoutCoreDelay": "轻量模式自动启用延迟",
"settings.envType": "环境变量类型",

View File

@@ -81,6 +81,9 @@
"settings.silentStart": "靜默啟動",
"settings.autoQuitWithoutCore": "自動進入輕量模式",
"settings.autoQuitWithoutCoreTooltip": "關閉窗口後指定時間自動進入輕量模式",
"settings.autoQuitWithoutCoreMode": "輕量模式行為",
"settings.autoQuitWithoutCoreModeCore": "僅保留內核",
"settings.autoQuitWithoutCoreModeTray": "僅關閉渲染進程",
"settings.subscriptionTimeout": "訂閱逾時時間",
"settings.autoQuitWithoutCoreDelay": "輕量模式自動啟用延遲",
"settings.envType": "環境變數類型",

View File

@@ -318,6 +318,7 @@ interface IAppConfig {
subStoreBackendUploadCron?: string
autoQuitWithoutCore?: boolean
autoQuitWithoutCoreDelay?: number
autoQuitWithoutCoreMode?: 'core' | 'tray'
useCustomSubStore?: boolean
useProxyInSubStore?: boolean
mihomoCpuPriority?: Priority