diff --git a/apps/desktop/electron-builder.yml b/apps/desktop/electron-builder.yml index 9914bdfa..f797eb6c 100644 --- a/apps/desktop/electron-builder.yml +++ b/apps/desktop/electron-builder.yml @@ -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: diff --git a/apps/desktop/main/i18n/locales/en-US.ts b/apps/desktop/main/i18n/locales/en-US.ts index de6b991c..1f995c96 100644 --- a/apps/desktop/main/i18n/locales/en-US.ts +++ b/apps/desktop/main/i18n/locales/en-US.ts @@ -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', diff --git a/apps/desktop/main/i18n/locales/ja-JP.ts b/apps/desktop/main/i18n/locales/ja-JP.ts index fd3198ba..8e410d3b 100644 --- a/apps/desktop/main/i18n/locales/ja-JP.ts +++ b/apps/desktop/main/i18n/locales/ja-JP.ts @@ -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}} が見つかりました', diff --git a/apps/desktop/main/i18n/locales/zh-CN.ts b/apps/desktop/main/i18n/locales/zh-CN.ts index 82db05d3..0ed27ff5 100644 --- a/apps/desktop/main/i18n/locales/zh-CN.ts +++ b/apps/desktop/main/i18n/locales/zh-CN.ts @@ -11,6 +11,17 @@ export default { error: '错误', }, + windowClose: { + title: '关闭 ChatLab', + message: '关闭主窗口后要执行什么操作?', + detail: '选择后台运行后,ChatLab 会保留在系统托盘中。', + background: '后台运行', + quitApp: '退出应用', + cancel: '取消', + remember: '记住我的选择', + showApp: '显示 ChatLab', + }, + // ===== P0: 更新弹窗 ===== update: { newVersionTitle: '发现新版本 v{{version}}', diff --git a/apps/desktop/main/i18n/locales/zh-TW.ts b/apps/desktop/main/i18n/locales/zh-TW.ts index a0d9f021..08953fc1 100644 --- a/apps/desktop/main/i18n/locales/zh-TW.ts +++ b/apps/desktop/main/i18n/locales/zh-TW.ts @@ -12,6 +12,17 @@ export default { error: '錯誤', }, + windowClose: { + title: '關閉 ChatLab', + message: '關閉主視窗後要執行什麼操作?', + detail: '選擇背景執行後,ChatLab 會保留在系統匣中。', + background: '背景執行', + quitApp: '結束應用程式', + cancel: '取消', + remember: '記住我的選擇', + showApp: '顯示 ChatLab', + }, + // ===== P0: 更新彈窗 ===== update: { newVersionTitle: '發現新版本 v{{version}}', diff --git a/apps/desktop/main/index.ts b/apps/desktop/main/index.ts index 2ee1746d..d2ad2a9a 100644 --- a/apps/desktop/main/index.ts +++ b/apps/desktop/main/index.ts @@ -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() diff --git a/apps/desktop/main/ipc/window.ts b/apps/desktop/main/ipc/window.ts index 6d2d05c8..c605e6fb 100644 --- a/apps/desktop/main/ipc/window.ts +++ b/apps/desktop/main/ipc/window.ts @@ -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', () => { // 手动检查更新(即使是预发布版本也会提示) diff --git a/apps/desktop/main/window/close-behavior.test.ts b/apps/desktop/main/window/close-behavior.test.ts new file mode 100644 index 00000000..31847471 --- /dev/null +++ b/apps/desktop/main/window/close-behavior.test.ts @@ -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((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']) + }) +}) diff --git a/apps/desktop/main/window/close-behavior.ts b/apps/desktop/main/window/close-behavior.ts new file mode 100644 index 00000000..3f8023bb --- /dev/null +++ b/apps/desktop/main/window/close-behavior.ts @@ -0,0 +1,82 @@ +import type { DesktopCloseBehavior } from '@openchatlab/shared-types' + +export type WindowsCloseAction = Exclude | 'cancel' + +export interface WindowsClosePromptResult { + action: WindowsCloseAction + remember: boolean +} + +interface WindowsCloseControllerDependencies { + readPreference: () => DesktopCloseBehavior + savePreference: (preference: Exclude) => void + prompt: () => Promise + enterBackground: () => void + quit: () => void + onError: (error: unknown) => void +} + +export class WindowsCloseController { + private handling = false + + constructor(private readonly dependencies: WindowsCloseControllerDependencies) {} + + async requestClose(): Promise { + 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 { + return this.dependencies.prompt() + } + + private savePreference(preference: Exclude): 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) + } +} diff --git a/apps/desktop/main/window/main-window.ts b/apps/desktop/main/window/main-window.ts index b20ffd2c..f84aa0e5 100644 --- a/apps/desktop/main/window/main-window.ts +++ b/apps/desktop/main/window/main-window.ts @@ -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() } }) } diff --git a/apps/desktop/main/window/windows-tray.ts b/apps/desktop/main/window/windows-tray.ts new file mode 100644 index 00000000..07f705eb --- /dev/null +++ b/apps/desktop/main/window/windows-tray.ts @@ -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') +} diff --git a/apps/desktop/preload/apis/core.ts b/apps/desktop/preload/apis/core.ts index 8a5246e5..757081b8 100644 --- a/apps/desktop/preload/apis/core.ts +++ b/apps/desktop/preload/apis/core.ts @@ -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 => { + return ipcRenderer.invoke('app:getDesktopCloseBehavior') + }, + setDesktopCloseBehavior: (behavior: DesktopCloseBehavior): Promise<{ success: boolean; error?: string }> => { + return ipcRenderer.invoke('app:setDesktopCloseBehavior', behavior) + }, }, } diff --git a/apps/desktop/preload/index.d.ts b/apps/desktop/preload/index.d.ts index 12431c1c..c968f576 100644 --- a/apps/desktop/preload/index.d.ts +++ b/apps/desktop/preload/index.d.ts @@ -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 getOpenAtLogin: () => Promise setOpenAtLogin: (enabled: boolean) => Promise<{ success: boolean; error?: string }> + getDesktopCloseBehavior: () => Promise + setDesktopCloseBehavior: (behavior: DesktopCloseBehavior) => Promise<{ success: boolean; error?: string }> } } diff --git a/packages/config/src/index.ts b/packages/config/src/index.ts index e41c299a..8cc75839 100644 --- a/packages/config/src/index.ts +++ b/packages/config/src/index.ts @@ -7,8 +7,17 @@ export { loadConfig, getConfigPath, getConfigDir, writeConfigField } from './loader' export { setConfigField, ConfigSetError } from './set-config-field' export type { ConfigSetResult, ConfigSetErrorReason } from './set-config-field' -export { configSchema, DEFAULT_API_PORT } from './schema' -export type { ChatLabConfig, LlmConfig, DataConfig, ApiConfig, LocaleConfig, UiConfig, CliConfig } from './schema' +export { configSchema, desktopConfigSchema, DEFAULT_API_PORT } from './schema' +export type { + ChatLabConfig, + LlmConfig, + DataConfig, + ApiConfig, + LocaleConfig, + UiConfig, + CliConfig, + DesktopConfig, +} from './schema' export { loadAuthProfiles, getApiKeyByProfile, diff --git a/packages/config/src/schema.test.ts b/packages/config/src/schema.test.ts index 84d535b1..d86f82b0 100644 --- a/packages/config/src/schema.test.ts +++ b/packages/config/src/schema.test.ts @@ -17,3 +17,20 @@ test('normalizes the released overview preference to insights', () => { test('rejects unknown session landing tabs', () => { assert.throws(() => configSchema.parse({ ui: { default_session_tab: 'unknown' } })) }) + +test('defaults Windows close behavior to asking the user', () => { + const config = configSchema.parse({}) + + assert.equal(config.desktop.close_behavior, 'ask') +}) + +test('accepts supported Windows close behaviors and rejects invalid values', () => { + for (const closeBehavior of ['ask', 'background', 'quit']) { + assert.equal( + configSchema.parse({ desktop: { close_behavior: closeBehavior } }).desktop.close_behavior, + closeBehavior + ) + } + + assert.throws(() => configSchema.parse({ desktop: { close_behavior: 'hide-forever' } })) +}) diff --git a/packages/config/src/schema.ts b/packages/config/src/schema.ts index 6b2591bd..1d3a5734 100644 --- a/packages/config/src/schema.ts +++ b/packages/config/src/schema.ts @@ -46,6 +46,10 @@ export const cliConfigSchema = z.object({ allow_sql: z.boolean().default(true), }) +export const desktopConfigSchema = z.object({ + close_behavior: z.enum(['ask', 'background', 'quit']).default('ask'), +}) + export const configSchema = z.object({ llm: llmConfigSchema.default({}), data: dataConfigSchema.default({}), @@ -53,6 +57,7 @@ export const configSchema = z.object({ locale: localeConfigSchema.default({}), ui: uiConfigSchema.default({}), cli: cliConfigSchema.default({}), + desktop: desktopConfigSchema.default({}), }) export type ChatLabConfig = z.infer @@ -62,3 +67,4 @@ export type ApiConfig = z.infer export type LocaleConfig = z.infer export type UiConfig = z.infer export type CliConfig = z.infer +export type DesktopConfig = z.infer diff --git a/packages/config/src/set-config-field.test.ts b/packages/config/src/set-config-field.test.ts index 86407634..a7d3bd97 100644 --- a/packages/config/src/set-config-field.test.ts +++ b/packages/config/src/set-config-field.test.ts @@ -45,6 +45,13 @@ describe('cli config section', () => { }) describe('setConfigField', () => { + it('persists the Windows close behavior', () => { + const result = setConfigField('desktop.close_behavior', 'background') + + assert.deepEqual(result, { section: 'desktop', key: 'close_behavior', value: 'background' }) + assert.equal(loadConfig().desktop.close_behavior, 'background') + }) + it('writes boolean value and round-trips via loadConfig', () => { const result = setConfigField('cli.allow_raw', 'true') assert.deepEqual(result, { section: 'cli', key: 'allow_raw', value: true }) diff --git a/packages/shared-types/index.ts b/packages/shared-types/index.ts index b7043942..96315ce7 100644 --- a/packages/shared-types/index.ts +++ b/packages/shared-types/index.ts @@ -666,6 +666,8 @@ export interface UiConfig { summary_strategy?: 'brief' | 'standard' } +export type DesktopCloseBehavior = 'ask' | 'background' | 'quit' + // ==================== 匿名使用统计 ==================== export type AnalyticsAppType = 'desktop' | 'cli' | 'cli_web' | 'web_wasm' diff --git a/src/components/common/Settings/BasicSettingsTab.vue b/src/components/common/Settings/BasicSettingsTab.vue index c32e02b8..e9d697a0 100644 --- a/src/components/common/Settings/BasicSettingsTab.vue +++ b/src/components/common/Settings/BasicSettingsTab.vue @@ -11,6 +11,7 @@ import UITabs from '@/components/UI/Tabs.vue' import { usePlatformService } from '@/services' import { IS_ELECTRON } from '@/utils/platform' import { INSIGHT_CARD_THEMES, type InsightCardThemeId } from '@/utils/insight-card-theme' +import type { DesktopCloseBehavior } from '@openchatlab/shared-types' const { t } = useI18n() @@ -32,6 +33,10 @@ const { locale, defaultSessionTab, insightCardTheme } = storeToRefs(settingsStor // Auto Launch const openAtLogin = ref(false) const isPackaged = ref(true) +const isWindowsDesktop = + IS_ELECTRON && typeof navigator !== 'undefined' && navigator.platform.toLowerCase().includes('win') +const desktopCloseBehavior = ref('ask') +let savedDesktopCloseBehavior: DesktopCloseBehavior = 'ask' onMounted(async () => { if (!IS_ELECTRON) { @@ -44,6 +49,15 @@ onMounted(async () => { } catch { isPackaged.value = false } + + if (isWindowsDesktop) { + try { + savedDesktopCloseBehavior = await usePlatformService().getDesktopCloseBehavior() + desktopCloseBehavior.value = savedDesktopCloseBehavior + } catch { + desktopCloseBehavior.value = 'ask' + } + } }) async function handleAutoLaunchChange(enabled: boolean) { @@ -55,6 +69,19 @@ async function handleAutoLaunchChange(enabled: boolean) { } } +async function handleDesktopCloseBehaviorChange(value: string | number) { + if (value !== 'ask' && value !== 'background' && value !== 'quit') return + + const nextBehavior: DesktopCloseBehavior = value + desktopCloseBehavior.value = nextBehavior + const result = await usePlatformService().setDesktopCloseBehavior(nextBehavior) + if (result.success) { + savedDesktopCloseBehavior = nextBehavior + } else { + desktopCloseBehavior.value = savedDesktopCloseBehavior + } +} + // Color Mode const colorMode = useColorMode({ emitAuto: true, @@ -103,6 +130,11 @@ const toolsPanelPositionOptions = computed(() => [ { label: t('settings.basic.toolsPanel.positionHeader'), value: 'header' }, { label: t('settings.basic.toolsPanel.positionSide'), value: 'side' }, ]) + +const desktopCloseBehaviorOptions = computed(() => [ + { label: t('settings.basic.closeBehavior.background'), value: 'background' }, + { label: t('settings.basic.closeBehavior.quit'), value: 'quit' }, +]) diff --git a/src/i18n/locales/en-US/settings.json b/src/i18n/locales/en-US/settings.json index e7cfba6a..f97a49b2 100644 --- a/src/i18n/locales/en-US/settings.json +++ b/src/i18n/locales/en-US/settings.json @@ -49,6 +49,11 @@ "openAtLoginDesc": "Automatically start ChatLab when the system boots", "devModeHint": "Not available in dev mode" }, + "closeBehavior": { + "label": "When closing the main window", + "background": "Run in background", + "quit": "Quit app" + }, "toolsPanel": { "positionLabel": "Tools Panel Position", "positionDesc": "Choose where the quick tools panel appears", diff --git a/src/i18n/locales/ja-JP/settings.json b/src/i18n/locales/ja-JP/settings.json index 560b68fa..7e22437b 100644 --- a/src/i18n/locales/ja-JP/settings.json +++ b/src/i18n/locales/ja-JP/settings.json @@ -49,6 +49,11 @@ "openAtLoginDesc": "システム起動時に ChatLab を自動的に実行します", "devModeHint": "開発モードでは利用できません" }, + "closeBehavior": { + "label": "メインウィンドウを閉じるとき", + "background": "バックグラウンドで実行", + "quit": "アプリを終了" + }, "toolsPanel": { "positionLabel": "ツールパネルの位置", "positionDesc": "クイックツールパネルの表示位置を選択します", diff --git a/src/i18n/locales/zh-CN/settings.json b/src/i18n/locales/zh-CN/settings.json index 2357b5da..418648c5 100644 --- a/src/i18n/locales/zh-CN/settings.json +++ b/src/i18n/locales/zh-CN/settings.json @@ -49,6 +49,11 @@ "openAtLoginDesc": "系统启动时自动运行 ChatLab", "devModeHint": "开发模式下不可用" }, + "closeBehavior": { + "label": "关闭主窗口时", + "background": "后台运行", + "quit": "退出应用" + }, "toolsPanel": { "positionLabel": "快捷工具入口位置", "positionDesc": "选择快捷工具面板的显示位置", diff --git a/src/i18n/locales/zh-TW/settings.json b/src/i18n/locales/zh-TW/settings.json index 53c64d48..cf99d1ec 100644 --- a/src/i18n/locales/zh-TW/settings.json +++ b/src/i18n/locales/zh-TW/settings.json @@ -49,6 +49,11 @@ "openAtLoginDesc": "系統啟動時自動執行 ChatLab", "devModeHint": "開發模式下不可用" }, + "closeBehavior": { + "label": "關閉主視窗時", + "background": "背景執行", + "quit": "結束應用程式" + }, "toolsPanel": { "positionLabel": "快捷工具入口位置", "positionDesc": "選擇快捷工具面板的顯示位置", diff --git a/src/services/platform/browser.ts b/src/services/platform/browser.ts index 2de90d53..6d9e361b 100644 --- a/src/services/platform/browser.ts +++ b/src/services/platform/browser.ts @@ -6,7 +6,7 @@ import type { PlatformAdapter, RemoteConfigResult, } from './types' -import type { AnalyticsEventName } from '@openchatlab/shared-types' +import type { AnalyticsEventName, DesktopCloseBehavior } from '@openchatlab/shared-types' declare const __APP_VERSION__: string @@ -41,6 +41,14 @@ export class BrowserPlatformAdapter implements PlatformAdapter { return { success: false, error: 'Open at login is not available in Web WASM' } } + async getDesktopCloseBehavior(): Promise { + return 'ask' + } + + async setDesktopCloseBehavior(_behavior: DesktopCloseBehavior): Promise<{ success: boolean; error?: string }> { + return { success: false, error: 'Desktop close behavior is not available in Web WASM' } + } + async getAnalyticsEnabled(): Promise { return false } diff --git a/src/services/platform/cli-web.ts b/src/services/platform/cli-web.ts index 1f9d67d5..787e1dd4 100644 --- a/src/services/platform/cli-web.ts +++ b/src/services/platform/cli-web.ts @@ -12,7 +12,7 @@ import type { CheckUpdateResult, PerformUpdateResult, } from './types' -import type { AnalyticsEventName } from '@openchatlab/shared-types' +import type { AnalyticsEventName, DesktopCloseBehavior } from '@openchatlab/shared-types' import { fetchWithAuth } from '../utils/http' import { reportError } from '../log-report' @@ -48,6 +48,14 @@ export class CliWebPlatformAdapter implements PlatformAdapter { return { success: false, error: 'Not available in CLI Web' } } + async getDesktopCloseBehavior(): Promise { + return 'ask' + } + + async setDesktopCloseBehavior(_behavior: DesktopCloseBehavior): Promise<{ success: boolean; error?: string }> { + return { success: false, error: 'Not available in CLI Web' } + } + async getAnalyticsEnabled(): Promise { try { const resp = await fetchWithAuth('/_web/telemetry/enabled') diff --git a/src/services/platform/electron.ts b/src/services/platform/electron.ts index e1afffc4..aa9fd0ce 100644 --- a/src/services/platform/electron.ts +++ b/src/services/platform/electron.ts @@ -2,7 +2,7 @@ * ElectronPlatformAdapter — wrap window.api */ -import type { AnalyticsEventName } from '@openchatlab/shared-types' +import type { AnalyticsEventName, DesktopCloseBehavior } from '@openchatlab/shared-types' import type { PlatformAdapter, OpenDialogOptions, OpenDialogResult, RemoteConfigResult } from './types' export class ElectronPlatformAdapter implements PlatformAdapter { @@ -26,6 +26,14 @@ export class ElectronPlatformAdapter implements PlatformAdapter { return window.api.app.setOpenAtLogin(enabled) } + getDesktopCloseBehavior(): Promise { + return window.api.app.getDesktopCloseBehavior() + } + + setDesktopCloseBehavior(behavior: DesktopCloseBehavior): Promise<{ success: boolean; error?: string }> { + return window.api.app.setDesktopCloseBehavior(behavior) + } + getAnalyticsEnabled(): Promise { return window.api.app.getAnalyticsEnabled() } diff --git a/src/services/platform/types.ts b/src/services/platform/types.ts index 09d3df2c..0bd33327 100644 --- a/src/services/platform/types.ts +++ b/src/services/platform/types.ts @@ -1,4 +1,4 @@ -import type { AnalyticsEventName } from '@openchatlab/shared-types' +import type { AnalyticsEventName, DesktopCloseBehavior } from '@openchatlab/shared-types' /** * PlatformAdapter — 平台能力领域适配器接口 @@ -46,6 +46,8 @@ export interface PlatformAdapter { getOpenAtLogin(): Promise setOpenAtLogin(enabled: boolean): Promise<{ success: boolean; error?: string }> + getDesktopCloseBehavior(): Promise + setDesktopCloseBehavior(behavior: DesktopCloseBehavior): Promise<{ success: boolean; error?: string }> getAnalyticsEnabled(): Promise setAnalyticsEnabled(enabled: boolean): Promise<{ success: boolean }>