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?.() },
])
)
}
+4 -3
View File
@@ -18,19 +18,20 @@ test('rejects unknown session landing tabs', () => {
assert.throws(() => configSchema.parse({ ui: { default_session_tab: 'unknown' } }))
})
test('defaults Windows close behavior to asking the user', () => {
test('defaults Windows close behavior to running in the background', () => {
const config = configSchema.parse({})
assert.equal(config.desktop.close_behavior, 'ask')
assert.equal(config.desktop.close_behavior, 'background')
})
test('accepts supported Windows close behaviors and rejects invalid values', () => {
for (const closeBehavior of ['ask', 'background', 'quit']) {
for (const closeBehavior of ['background', 'quit']) {
assert.equal(
configSchema.parse({ desktop: { close_behavior: closeBehavior } }).desktop.close_behavior,
closeBehavior
)
}
assert.throws(() => configSchema.parse({ desktop: { close_behavior: 'ask' } }))
assert.throws(() => configSchema.parse({ desktop: { close_behavior: 'hide-forever' } }))
})
+1 -1
View File
@@ -47,7 +47,7 @@ export const cliConfigSchema = z.object({
})
export const desktopConfigSchema = z.object({
close_behavior: z.enum(['ask', 'background', 'quit']).default('ask'),
close_behavior: z.enum(['background', 'quit']).default('background'),
})
export const configSchema = z.object({
+1 -1
View File
@@ -666,7 +666,7 @@ export interface UiConfig {
summary_strategy?: 'brief' | 'standard'
}
export type DesktopCloseBehavior = 'ask' | 'background' | 'quit'
export type DesktopCloseBehavior = 'background' | 'quit'
// ==================== 匿名使用统计 ====================
@@ -35,8 +35,8 @@ const openAtLogin = ref(false)
const isPackaged = ref(true)
const isWindowsDesktop =
IS_ELECTRON && typeof navigator !== 'undefined' && navigator.platform.toLowerCase().includes('win')
const desktopCloseBehavior = ref<DesktopCloseBehavior>('ask')
let savedDesktopCloseBehavior: DesktopCloseBehavior = 'ask'
const desktopCloseBehavior = ref<DesktopCloseBehavior>('background')
let savedDesktopCloseBehavior: DesktopCloseBehavior = 'background'
onMounted(async () => {
if (!IS_ELECTRON) {
@@ -55,7 +55,7 @@ onMounted(async () => {
savedDesktopCloseBehavior = await usePlatformService().getDesktopCloseBehavior()
desktopCloseBehavior.value = savedDesktopCloseBehavior
} catch {
desktopCloseBehavior.value = 'ask'
desktopCloseBehavior.value = 'background'
}
}
})
@@ -70,7 +70,7 @@ async function handleAutoLaunchChange(enabled: boolean) {
}
async function handleDesktopCloseBehaviorChange(value: string | number) {
if (value !== 'ask' && value !== 'background' && value !== 'quit') return
if (value !== 'background' && value !== 'quit') return
const nextBehavior: DesktopCloseBehavior = value
desktopCloseBehavior.value = nextBehavior
@@ -183,7 +183,7 @@ const desktopCloseBehaviorOptions = computed(() => [
</div>
<div class="w-64">
<UTabs
:model-value="desktopCloseBehavior === 'ask' ? undefined : desktopCloseBehavior"
:model-value="desktopCloseBehavior"
size="sm"
class="gap-0"
:items="desktopCloseBehaviorOptions"
+1 -1
View File
@@ -42,7 +42,7 @@ export class BrowserPlatformAdapter implements PlatformAdapter {
}
async getDesktopCloseBehavior(): Promise<DesktopCloseBehavior> {
return 'ask'
return 'background'
}
async setDesktopCloseBehavior(_behavior: DesktopCloseBehavior): Promise<{ success: boolean; error?: string }> {
+1 -1
View File
@@ -49,7 +49,7 @@ export class CliWebPlatformAdapter implements PlatformAdapter {
}
async getDesktopCloseBehavior(): Promise<DesktopCloseBehavior> {
return 'ask'
return 'background'
}
async setDesktopCloseBehavior(_behavior: DesktopCloseBehavior): Promise<{ success: boolean; error?: string }> {