feat(electron): add Windows close-to-tray behavior

This commit is contained in:
digua committed 2026-08-07 20:29:37 +08:00
1 parent b7f59a75ea
commit 35880228b7
27 files changed
+496 -16

No files matched your search

+2
View File
@@ -57,6 +57,8 @@ win:
extraResources:
- from: native/better_sqlite3.node
to: native/better_sqlite3.node
- from: build/icon.ico
to: tray-icon.ico
# NSIS 安装器配置
nsis:
+11
View File
@@ -12,6 +12,17 @@ export default {
error: 'Error',
},
windowClose: {
title: 'Close ChatLab',
message: 'What should happen after the main window closes?',
detail: 'Run in background keeps ChatLab available from the system tray.',
background: 'Run in Background',
quitApp: 'Quit ChatLab',
cancel: 'Cancel',
remember: 'Remember my choice',
showApp: 'Show ChatLab',
},
// ===== P0: Update dialogs =====
update: {
newVersionTitle: 'New version v{{version}} available',
+11
View File
@@ -12,6 +12,17 @@ export default {
error: 'エラー',
},
windowClose: {
title: 'ChatLab を閉じる',
message: 'メインウィンドウを閉じた後の動作を選択してください。',
detail: 'バックグラウンドで実行すると、ChatLab はシステムトレイから開けます。',
background: 'バックグラウンドで実行',
quitApp: 'ChatLab を終了',
cancel: 'キャンセル',
remember: '選択を記憶する',
showApp: 'ChatLab を表示',
},
// ===== P0: アップデートダイアログ =====
update: {
newVersionTitle: '新バージョン v{{version}} が見つかりました',
+11
View File
@@ -11,6 +11,17 @@ export default {
error: '错误',
},
windowClose: {
title: '关闭 ChatLab',
message: '关闭主窗口后要执行什么操作?',
detail: '选择后台运行后,ChatLab 会保留在系统托盘中。',
background: '后台运行',
quitApp: '退出应用',
cancel: '取消',
remember: '记住我的选择',
showApp: '显示 ChatLab',
},
// ===== P0: 更新弹窗 =====
update: {
newVersionTitle: '发现新版本 v{{version}}',
+11
View File
@@ -12,6 +12,17 @@ export default {
error: '錯誤',
},
windowClose: {
title: '關閉 ChatLab',
message: '關閉主視窗後要執行什麼操作?',
detail: '選擇背景執行後,ChatLab 會保留在系統匣中。',
background: '背景執行',
quitApp: '結束應用程式',
cancel: '取消',
remember: '記住我的選擇',
showApp: '顯示 ChatLab',
},
// ===== P0: 更新彈窗 =====
update: {
newVersionTitle: '發現新版本 v{{version}}',
+2
View File
@@ -10,6 +10,7 @@ import { initAnalytics } from './analytics'
import { logger } from './logger'
import { prepareDesktopRuntime } from './app/bootstrap'
import { createMainWindow, markAppQuitting } from './window/main-window'
import { destroyWindowsTray } from './window/windows-tray'
import { initLockManager, cleanupLockManager } from './security/lock-manager'
class MainProcess {
@@ -148,6 +149,7 @@ class MainProcess {
})
app.on('will-quit', () => {
destroyWindowsTray()
stopInternalServer().catch(() => {})
cleanup()
cleanupLockManager()
+24 -7
View File
@@ -4,6 +4,8 @@
import { ipcMain, app, dialog, clipboard, shell, nativeTheme } from 'electron'
import * as fs from 'fs/promises'
import { loadConfig, setConfigField } from '@openchatlab/config'
import type { DesktopCloseBehavior } from '@openchatlab/shared-types'
import type { IpcContext } from './types'
import { simulateUpdateDialog, manualCheckForUpdates } from '../update/manager'
import { t } from '../i18n'
@@ -13,10 +15,8 @@ import {
resetCurrentTitleBarOverlayColor,
} from '../window/titlebar'
import { getDesktopAppVersion } from '../runtime/compat'
type AppWithQuitFlag = typeof app & { isQuiting?: boolean }
// 通过类型扩展记录应用退出意图,避免使用 @ts-ignore。
const appWithQuitFlag = app as AppWithQuitFlag
import { requestAppQuit } from '../window/main-window'
import { destroyWindowsTray } from '../window/windows-tray'
const REMOTE_CONFIG_ALLOWED_DOMAINS = ['chatlab.fun', '1app.top']
const REMOTE_CONFIG_TIMEOUT_MS = 8000
@@ -69,9 +69,7 @@ export function registerWindowHandlers(ctx: IpcContext): void {
})
ipcMain.on('window-close', () => {
win.close()
appWithQuitFlag.isQuiting = true
app.quit()
requestAppQuit()
})
ipcMain.on('window-resize', (_, data) => {
@@ -226,6 +224,25 @@ export function registerWindowHandlers(ctx: IpcContext): void {
}
})
// ==================== Windows 关闭行为 ====================
ipcMain.handle('app:getDesktopCloseBehavior', () => {
return loadConfig().desktop.close_behavior
})
ipcMain.handle('app:setDesktopCloseBehavior', (_, behavior: DesktopCloseBehavior) => {
if (behavior !== 'ask' && behavior !== 'background' && behavior !== 'quit') {
return { success: false, error: 'Unsupported desktop close behavior' }
}
try {
setConfigField('desktop.close_behavior', behavior)
if (behavior !== 'background') destroyWindowsTray()
return { success: true }
} catch (error) {
return { success: false, error: error instanceof Error ? error.message : String(error) }
}
})
// ==================== 更新检查 ====================
ipcMain.on('check-update', () => {
// 手动检查更新(即使是预发布版本也会提示)
@@ -0,0 +1,83 @@
import assert from 'node:assert/strict'
import { describe, it } from 'node:test'
import type { DesktopCloseBehavior } from '@openchatlab/shared-types'
import { WindowsCloseController } from './close-behavior'
interface HarnessOptions {
preference?: DesktopCloseBehavior
promptResult?: { action: 'background' | 'quit' | 'cancel'; remember: boolean }
}
function createHarness(options: HarnessOptions = {}) {
const calls: string[] = []
let resolvePrompt: (() => void) | undefined
const promptGate = new Promise<void>((resolve) => {
resolvePrompt = resolve
})
let waitForPrompt = false
const controller = new WindowsCloseController({
readPreference: () => options.preference ?? 'ask',
savePreference: (preference) => calls.push(`save:${preference}`),
prompt: async () => {
calls.push('prompt')
if (waitForPrompt) await promptGate
return options.promptResult ?? { action: 'cancel', remember: false }
},
enterBackground: () => calls.push('background'),
quit: () => calls.push('quit'),
onError: (error) => calls.push(`error:${String(error)}`),
})
return {
calls,
controller,
holdPrompt: () => {
waitForPrompt = true
},
releasePrompt: () => resolvePrompt?.(),
}
}
describe('WindowsCloseController', () => {
it('applies remembered background and quit choices without prompting', async () => {
for (const preference of ['background', 'quit'] as const) {
const harness = createHarness({ preference })
await harness.controller.requestClose()
assert.deepEqual(harness.calls, [preference])
}
})
it('remembers the selected action only when the checkbox is enabled', async () => {
const remembered = createHarness({ promptResult: { action: 'background', remember: true } })
await remembered.controller.requestClose()
assert.deepEqual(remembered.calls, ['prompt', 'save:background', 'background'])
const oneTime = createHarness({ promptResult: { action: 'quit', remember: false } })
await oneTime.controller.requestClose()
assert.deepEqual(oneTime.calls, ['prompt', 'quit'])
})
it('keeps the window open when the prompt is cancelled', async () => {
const harness = createHarness({ promptResult: { action: 'cancel', remember: true } })
await harness.controller.requestClose()
assert.deepEqual(harness.calls, ['prompt'])
})
it('coalesces repeated close requests while the prompt is open', async () => {
const harness = createHarness({ promptResult: { action: 'background', remember: false } })
harness.holdPrompt()
const first = harness.controller.requestClose()
const second = harness.controller.requestClose()
assert.deepEqual(harness.calls, ['prompt'])
harness.releasePrompt()
await Promise.all([first, second])
assert.deepEqual(harness.calls, ['prompt', 'background'])
})
})
@@ -0,0 +1,82 @@
import type { DesktopCloseBehavior } from '@openchatlab/shared-types'
export type WindowsCloseAction = Exclude<DesktopCloseBehavior, 'ask'> | 'cancel'
export interface WindowsClosePromptResult {
action: WindowsCloseAction
remember: boolean
}
interface WindowsCloseControllerDependencies {
readPreference: () => DesktopCloseBehavior
savePreference: (preference: Exclude<DesktopCloseBehavior, 'ask'>) => void
prompt: () => Promise<WindowsClosePromptResult>
enterBackground: () => void
quit: () => void
onError: (error: unknown) => void
}
export class WindowsCloseController {
private handling = false
constructor(private readonly dependencies: WindowsCloseControllerDependencies) {}
async requestClose(): Promise<void> {
if (this.handling) return
this.handling = true
try {
const preference = this.readPreference()
const result = preference === 'ask' ? await this.prompt() : { action: preference, remember: false }
if (result.action === 'cancel') return
if (result.remember) {
try {
this.savePreference(result.action)
} catch (error) {
this.onError(error)
}
}
if (result.action === 'background') {
this.enterBackground()
} else {
this.quit()
}
} catch (error) {
this.onError(error)
} finally {
this.handling = false
}
}
private readPreference(): DesktopCloseBehavior {
try {
return this.dependencies.readPreference()
} catch (error) {
this.dependencies.onError(error)
return 'ask'
}
}
private prompt(): Promise<WindowsClosePromptResult> {
return this.dependencies.prompt()
}
private savePreference(preference: Exclude<DesktopCloseBehavior, 'ask'>): void {
this.dependencies.savePreference(preference)
}
private enterBackground(): void {
this.dependencies.enterBackground()
}
private quit(): void {
this.dependencies.quit()
}
private onError(error: unknown): void {
this.dependencies.onError(error)
}
}
+61 -1
View File
@@ -1,6 +1,11 @@
import { app, shell, BrowserWindow, nativeTheme } from 'electron'
import { app, shell, BrowserWindow, nativeTheme, dialog } from 'electron'
import { is, platform } from '@electron-toolkit/utils'
import { loadConfig, setConfigField } from '@openchatlab/config'
import { applyCurrentTitleBarOverlay, getTitleBarOverlayOptions, resetCurrentTitleBarOverlayColor } from './titlebar'
import { WindowsCloseController } from './close-behavior'
import { ensureWindowsTray } from './windows-tray'
import { t } from '../i18n'
import { logger } from '../logger'
type AppWithQuitFlag = typeof app & { isQuiting?: boolean }
@@ -75,7 +80,50 @@ export function markAppQuitting(): void {
appWithQuitFlag.isQuiting = true
}
export function requestAppQuit(): void {
markAppQuitting()
app.quit()
}
function createWindowsCloseController(win: BrowserWindow): WindowsCloseController {
return new WindowsCloseController({
readPreference: () => loadConfig().desktop.close_behavior,
savePreference: (preference) => {
setConfigField('desktop.close_behavior', preference)
},
prompt: async () => {
const result = await dialog.showMessageBox(win, {
type: 'question',
title: t('windowClose.title'),
message: t('windowClose.message'),
detail: t('windowClose.detail'),
buttons: [t('windowClose.background'), t('windowClose.quitApp'), t('windowClose.cancel')],
defaultId: 0,
cancelId: 2,
noLink: true,
checkboxLabel: t('windowClose.remember'),
checkboxChecked: false,
})
return {
action: result.response === 0 ? 'background' : result.response === 1 ? 'quit' : 'cancel',
remember: result.checkboxChecked,
}
},
enterBackground: () => {
ensureWindowsTray(win, requestAppQuit)
win.hide()
},
quit: requestAppQuit,
onError: (error) => {
logger.error(`Failed to handle Windows close request: ${error instanceof Error ? error.message : String(error)}`)
},
})
}
function registerMainWindowEvents(win: BrowserWindow): void {
const windowsCloseController = platform.isWindows ? createWindowsCloseController(win) : null
win.webContents.on('did-finish-load', () => {
setTimeout(() => {
currentMainWindow?.webContents.send('app-started')
@@ -90,10 +138,22 @@ function registerMainWindowEvents(win: BrowserWindow): void {
currentMainWindow?.webContents.send('windowState', false)
})
// Windows does not emit app.before-quit during system shutdown or restart.
// session-end only fires once the session can no longer be cancelled.
win.on('session-end', () => {
markAppQuitting()
})
win.on('close', (event) => {
if (platform.isMacOS && !appWithQuitFlag.isQuiting) {
event.preventDefault()
currentMainWindow?.hide()
return
}
if (platform.isWindows && !appWithQuitFlag.isQuiting) {
event.preventDefault()
void windowsCloseController?.requestClose()
}
})
}
+44
View File
@@ -0,0 +1,44 @@
import { app, BrowserWindow, Menu, Tray } from 'electron'
import { join } from 'path'
import { t } from '../i18n'
let tray: Tray | null = null
let targetWindow: BrowserWindow | null = null
let quitHandler: (() => void) | null = null
export function ensureWindowsTray(win: BrowserWindow, onQuit: () => void): void {
targetWindow = win
quitHandler = onQuit
if (!tray) {
tray = new Tray(resolveTrayIconPath())
tray.setToolTip('ChatLab')
tray.on('click', restoreMainWindow)
}
tray.setContextMenu(
Menu.buildFromTemplate([
{ label: t('windowClose.showApp'), click: restoreMainWindow },
{ type: 'separator' },
{ label: t('windowClose.quitApp'), click: () => quitHandler?.() },
])
)
}
export function destroyWindowsTray(): void {
tray?.destroy()
tray = null
targetWindow = null
quitHandler = null
}
function restoreMainWindow(): void {
if (!targetWindow || targetWindow.isDestroyed()) return
if (targetWindow.isMinimized()) targetWindow.restore()
targetWindow.show()
targetWindow.focus()
}
function resolveTrayIconPath(): string {
return app.isPackaged ? join(process.resourcesPath, 'tray-icon.ico') : join(__dirname, '../../build/icon.ico')
}
+7 -1
View File
@@ -2,7 +2,7 @@
* 核心 API - 基础 IPC 通信和系统功能
*/
import { ipcRenderer } from 'electron'
import type { AnalyticsEventName } from '@openchatlab/shared-types'
import type { AnalyticsEventName, DesktopCloseBehavior } from '@openchatlab/shared-types'
// Custom APIs for renderer
export const api = {
@@ -117,5 +117,11 @@ export const extendedApi = {
setOpenAtLogin: (enabled: boolean): Promise<{ success: boolean; error?: string }> => {
return ipcRenderer.invoke('app:setOpenAtLogin', enabled)
},
getDesktopCloseBehavior: (): Promise<DesktopCloseBehavior> => {
return ipcRenderer.invoke('app:getDesktopCloseBehavior')
},
setDesktopCloseBehavior: (behavior: DesktopCloseBehavior): Promise<{ success: boolean; error?: string }> => {
return ipcRenderer.invoke('app:setDesktopCloseBehavior', behavior)
},
},
}
+3 -1
View File
@@ -1,7 +1,7 @@
import { ElectronAPI } from '@electron-toolkit/preload'
import type { ImportProgress, ExportProgress } from '../../../src/types/base'
import type { TokenUsage, AgentRuntimeStatus, SerializedErrorInfo, SecurityApi } from '../shared/types'
import type { AnalyticsEventName, TimeFilter } from '@openchatlab/shared-types'
import type { AnalyticsEventName, DesktopCloseBehavior, TimeFilter } from '@openchatlab/shared-types'
// 迁移相关类型
interface MigrationInfo {
@@ -192,6 +192,8 @@ interface Api {
relaunch: () => Promise<void>
getOpenAtLogin: () => Promise<boolean>
setOpenAtLogin: (enabled: boolean) => Promise<{ success: boolean; error?: string }>
getDesktopCloseBehavior: () => Promise<DesktopCloseBehavior>
setDesktopCloseBehavior: (behavior: DesktopCloseBehavior) => Promise<{ success: boolean; error?: string }>
}
}