fix(electron): default Windows close to background

Remove the one-time close prompt and remembered-choice path so Windows close behavior is always explicit and predictable. New installations now minimize to the system tray by default, while users can still choose to quit from settings.
This commit is contained in:
digua committed 2026-08-07 20:29:37 +08:00
1 parent 35880228b7
commit 3db40375b6
15 files changed
+44 -215

No files matched your search

+1 -7
View File
@@ -12,14 +12,8 @@ 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',
windowsTray: {
quitApp: 'Quit ChatLab',
cancel: 'Cancel',
remember: 'Remember my choice',
showApp: 'Show ChatLab',
},
+1 -7
View File
@@ -12,14 +12,8 @@ export default {
error: 'エラー',
},
windowClose: {
title: 'ChatLab を閉じる',
message: 'メインウィンドウを閉じた後の動作を選択してください。',
detail: 'バックグラウンドで実行すると、ChatLab はシステムトレイから開けます。',
background: 'バックグラウンドで実行',
windowsTray: {
quitApp: 'ChatLab を終了',
cancel: 'キャンセル',
remember: '選択を記憶する',
showApp: 'ChatLab を表示',
},
+1 -7
View File
@@ -11,14 +11,8 @@ export default {
error: '错误',
},
windowClose: {
title: '关闭 ChatLab',
message: '关闭主窗口后要执行什么操作?',
detail: '选择后台运行后,ChatLab 会保留在系统托盘中。',
background: '后台运行',
windowsTray: {
quitApp: '退出应用',
cancel: '取消',
remember: '记住我的选择',
showApp: '显示 ChatLab',
},
+1 -7
View File
@@ -12,14 +12,8 @@ export default {
error: '錯誤',
},
windowClose: {
title: '關閉 ChatLab',
message: '關閉主視窗後要執行什麼操作?',
detail: '選擇背景執行後,ChatLab 會保留在系統匣中。',
background: '背景執行',
windowsTray: {
quitApp: '結束應用程式',
cancel: '取消',
remember: '記住我的選擇',
showApp: '顯示 ChatLab',
},
+1 -1
View File
@@ -230,7 +230,7 @@ export function registerWindowHandlers(ctx: IpcContext): void {
})
ipcMain.handle('app:setDesktopCloseBehavior', (_, behavior: DesktopCloseBehavior) => {
if (behavior !== 'ask' && behavior !== 'background' && behavior !== 'quit') {
if (behavior !== 'background' && behavior !== 'quit') {
return { success: false, error: 'Unsupported desktop close behavior' }
}
@@ -1,83 +1,22 @@
import assert from 'node:assert/strict'
import { describe, it } from 'node:test'
import test from 'node:test'
import type { DesktopCloseBehavior } from '@openchatlab/shared-types'
import { WindowsCloseController } from './close-behavior'
import { applyWindowsCloseBehavior } from './close-behavior'
interface HarnessOptions {
preference?: DesktopCloseBehavior
promptResult?: { action: 'background' | 'quit' | 'cancel'; remember: boolean }
}
function createHarness(options: HarnessOptions = {}) {
function applyBehavior(preference: DesktopCloseBehavior): string[] {
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 }
},
applyWindowsCloseBehavior({
readPreference: () => preference,
enterBackground: () => calls.push('background'),
quit: () => calls.push('quit'),
onError: (error) => calls.push(`error:${String(error)}`),
})
return {
calls,
controller,
holdPrompt: () => {
waitForPrompt = true
},
releasePrompt: () => resolvePrompt?.(),
}
return calls
}
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'])
})
test('applies the configured Windows close behavior', () => {
assert.deepEqual(applyBehavior('background'), ['background'])
assert.deepEqual(applyBehavior('quit'), ['quit'])
})
+9 -71
View File
@@ -1,82 +1,20 @@
import type { DesktopCloseBehavior } from '@openchatlab/shared-types'
export type WindowsCloseAction = Exclude<DesktopCloseBehavior, 'ask'> | 'cancel'
export interface WindowsClosePromptResult {
action: WindowsCloseAction
remember: boolean
}
interface WindowsCloseControllerDependencies {
interface WindowsCloseBehaviorDependencies {
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
export function applyWindowsCloseBehavior(dependencies: WindowsCloseBehaviorDependencies): void {
try {
if (dependencies.readPreference() === 'background') {
dependencies.enterBackground()
} else {
dependencies.quit()
}
}
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)
} catch (error) {
dependencies.onError(error)
}
}
+6 -31
View File
@@ -1,10 +1,9 @@
import { app, shell, BrowserWindow, nativeTheme, dialog } from 'electron'
import { app, shell, BrowserWindow, nativeTheme } from 'electron'
import { is, platform } from '@electron-toolkit/utils'
import { loadConfig, setConfigField } from '@openchatlab/config'
import { loadConfig } from '@openchatlab/config'
import { applyCurrentTitleBarOverlay, getTitleBarOverlayOptions, resetCurrentTitleBarOverlayColor } from './titlebar'
import { WindowsCloseController } from './close-behavior'
import { applyWindowsCloseBehavior } from './close-behavior'
import { ensureWindowsTray } from './windows-tray'
import { t } from '../i18n'
import { logger } from '../logger'
type AppWithQuitFlag = typeof app & { isQuiting?: boolean }
@@ -85,31 +84,9 @@ export function requestAppQuit(): void {
app.quit()
}
function createWindowsCloseController(win: BrowserWindow): WindowsCloseController {
return new WindowsCloseController({
function handleWindowsClose(win: BrowserWindow): void {
applyWindowsCloseBehavior({
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()
@@ -122,8 +99,6 @@ function createWindowsCloseController(win: BrowserWindow): WindowsCloseControlle
}
function registerMainWindowEvents(win: BrowserWindow): void {
const windowsCloseController = platform.isWindows ? createWindowsCloseController(win) : null
win.webContents.on('did-finish-load', () => {
setTimeout(() => {
currentMainWindow?.webContents.send('app-started')
@@ -153,7 +128,7 @@ function registerMainWindowEvents(win: BrowserWindow): void {
if (platform.isWindows && !appWithQuitFlag.isQuiting) {
event.preventDefault()
void windowsCloseController?.requestClose()
handleWindowsClose(win)
}
})
}
+2 -2
View File
@@ -18,9 +18,9 @@ export function ensureWindowsTray(win: BrowserWindow, onQuit: () => void): void
tray.setContextMenu(
Menu.buildFromTemplate([
{ label: t('windowClose.showApp'), click: restoreMainWindow },
{ label: t('windowsTray.showApp'), click: restoreMainWindow },
{ type: 'separator' },
{ label: t('windowClose.quitApp'), click: () => quitHandler?.() },
{ label: t('windowsTray.quitApp'), click: () => quitHandler?.() },
])
)
}