mirror of
https://github.com/hellodigua/ChatLab.git
synced 2026-10-26 02:04:37 +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 }>
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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' } }))
|
||||
})
|
||||
@@ -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<typeof configSchema>
|
||||
@@ -62,3 +67,4 @@ export type ApiConfig = z.infer<typeof apiConfigSchema>
|
||||
export type LocaleConfig = z.infer<typeof localeConfigSchema>
|
||||
export type UiConfig = z.infer<typeof uiConfigSchema>
|
||||
export type CliConfig = z.infer<typeof cliConfigSchema>
|
||||
export type DesktopConfig = z.infer<typeof desktopConfigSchema>
|
||||
@@ -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 })
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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<DesktopCloseBehavior>('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' },
|
||||
])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -141,6 +173,25 @@ const toolsPanelPositionOptions = computed(() => [
|
||||
</div>
|
||||
<USwitch v-model="openAtLogin" :disabled="!isPackaged" @update:model-value="handleAutoLaunchChange" />
|
||||
</div>
|
||||
<template v-if="isWindowsDesktop">
|
||||
<div class="border-t border-gray-200 dark:border-gray-700"></div>
|
||||
<div class="flex items-center justify-between p-4">
|
||||
<div class="flex-1 pr-4">
|
||||
<p class="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{{ t('settings.basic.closeBehavior.label') }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="w-64">
|
||||
<UTabs
|
||||
:model-value="desktopCloseBehavior === 'ask' ? undefined : desktopCloseBehavior"
|
||||
size="sm"
|
||||
class="gap-0"
|
||||
:items="desktopCloseBehaviorOptions"
|
||||
@update:model-value="handleDesktopCloseBehaviorChange"
|
||||
></UTabs>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -49,6 +49,11 @@
|
||||
"openAtLoginDesc": "システム起動時に ChatLab を自動的に実行します",
|
||||
"devModeHint": "開発モードでは利用できません"
|
||||
},
|
||||
"closeBehavior": {
|
||||
"label": "メインウィンドウを閉じるとき",
|
||||
"background": "バックグラウンドで実行",
|
||||
"quit": "アプリを終了"
|
||||
},
|
||||
"toolsPanel": {
|
||||
"positionLabel": "ツールパネルの位置",
|
||||
"positionDesc": "クイックツールパネルの表示位置を選択します",
|
||||
|
||||
@@ -49,6 +49,11 @@
|
||||
"openAtLoginDesc": "系统启动时自动运行 ChatLab",
|
||||
"devModeHint": "开发模式下不可用"
|
||||
},
|
||||
"closeBehavior": {
|
||||
"label": "关闭主窗口时",
|
||||
"background": "后台运行",
|
||||
"quit": "退出应用"
|
||||
},
|
||||
"toolsPanel": {
|
||||
"positionLabel": "快捷工具入口位置",
|
||||
"positionDesc": "选择快捷工具面板的显示位置",
|
||||
|
||||
@@ -49,6 +49,11 @@
|
||||
"openAtLoginDesc": "系統啟動時自動執行 ChatLab",
|
||||
"devModeHint": "開發模式下不可用"
|
||||
},
|
||||
"closeBehavior": {
|
||||
"label": "關閉主視窗時",
|
||||
"background": "背景執行",
|
||||
"quit": "結束應用程式"
|
||||
},
|
||||
"toolsPanel": {
|
||||
"positionLabel": "快捷工具入口位置",
|
||||
"positionDesc": "選擇快捷工具面板的顯示位置",
|
||||
|
||||
@@ -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<DesktopCloseBehavior> {
|
||||
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<boolean> {
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -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<DesktopCloseBehavior> {
|
||||
return 'ask'
|
||||
}
|
||||
|
||||
async setDesktopCloseBehavior(_behavior: DesktopCloseBehavior): Promise<{ success: boolean; error?: string }> {
|
||||
return { success: false, error: 'Not available in CLI Web' }
|
||||
}
|
||||
|
||||
async getAnalyticsEnabled(): Promise<boolean> {
|
||||
try {
|
||||
const resp = await fetchWithAuth('/_web/telemetry/enabled')
|
||||
|
||||
@@ -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<DesktopCloseBehavior> {
|
||||
return window.api.app.getDesktopCloseBehavior()
|
||||
}
|
||||
|
||||
setDesktopCloseBehavior(behavior: DesktopCloseBehavior): Promise<{ success: boolean; error?: string }> {
|
||||
return window.api.app.setDesktopCloseBehavior(behavior)
|
||||
}
|
||||
|
||||
getAnalyticsEnabled(): Promise<boolean> {
|
||||
return window.api.app.getAnalyticsEnabled()
|
||||
}
|
||||
|
||||
@@ -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<boolean>
|
||||
setOpenAtLogin(enabled: boolean): Promise<{ success: boolean; error?: string }>
|
||||
getDesktopCloseBehavior(): Promise<DesktopCloseBehavior>
|
||||
setDesktopCloseBehavior(behavior: DesktopCloseBehavior): Promise<{ success: boolean; error?: string }>
|
||||
|
||||
getAnalyticsEnabled(): Promise<boolean>
|
||||
setAnalyticsEnabled(enabled: boolean): Promise<{ success: boolean }>
|
||||
|
||||
Reference in new issue
Block a user