mirror of
https://github.com/hellodigua/ChatLab.git
synced 2026-10-25 17:54:34 +08:00
feat(electron): add Windows close-to-tray behavior
This commit is contained in:
1 parent
b7f59a75ea
commit
35880228b7
27 files changed
+496
-16
No files matched your search
@@ -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:
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,6 +11,17 @@ export default {
|
||||
error: '错误',
|
||||
},
|
||||
|
||||
windowClose: {
|
||||
title: '关闭 ChatLab',
|
||||
message: '关闭主窗口后要执行什么操作?',
|
||||
detail: '选择后台运行后,ChatLab 会保留在系统托盘中。',
|
||||
background: '后台运行',
|
||||
quitApp: '退出应用',
|
||||
cancel: '取消',
|
||||
remember: '记住我的选择',
|
||||
showApp: '显示 ChatLab',
|
||||
},
|
||||
|
||||
// ===== P0: 更新弹窗 =====
|
||||
update: {
|
||||
newVersionTitle: '发现新版本 v{{version}}',
|
||||
|
||||
@@ -12,6 +12,17 @@ export default {
|
||||
error: '錯誤',
|
||||
},
|
||||
|
||||
windowClose: {
|
||||
title: '關閉 ChatLab',
|
||||
message: '關閉主視窗後要執行什麼操作?',
|
||||
detail: '選擇背景執行後,ChatLab 會保留在系統匣中。',
|
||||
background: '背景執行',
|
||||
quitApp: '結束應用程式',
|
||||
cancel: '取消',
|
||||
remember: '記住我的選擇',
|
||||
showApp: '顯示 ChatLab',
|
||||
},
|
||||
|
||||
// ===== P0: 更新彈窗 =====
|
||||
update: {
|
||||
newVersionTitle: '發現新版本 v{{version}}',
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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')
|
||||
}
|
||||
@@ -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)
|
||||
},
|
||||
},
|
||||
}
|
||||
Vendored
+3
-1
@@ -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 }>
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user