diff --git a/src/main/index.ts b/src/main/index.ts index 1ab36849..7a1e79f5 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -3,6 +3,7 @@ import { app, BrowserWindow } from 'electron' import installExtension, { REDUX_DEVTOOLS } from 'electron-devtools-installer' import { registerIpc } from './ipc' +import { configManager } from './services/ConfigManager' import { registerZoomShortcut } from './services/ShortcutService' import { TrayService } from './services/TrayService' import { windowService } from './services/WindowService' @@ -64,6 +65,12 @@ if (!app.requestSingleInstanceLock()) { app.isQuitting = true }) + app.on('window-all-closed', () => { + if (!configManager.isTray()) { + app.quit() + } + }) + // In this file you can include the rest of your app"s specific main process // code. You can also put them in separate files and require them here. } diff --git a/src/main/ipc.ts b/src/main/ipc.ts index 89d3900b..70b5eec2 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -35,6 +35,11 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) { configManager.setLanguage(language) }) + // tray + ipcMain.handle('app:set-tray', (_, isActive: boolean) => { + configManager.setTray(isActive) + }) + // theme ipcMain.handle('app:set-theme', (_, theme: ThemeMode) => { configManager.setTheme(theme) diff --git a/src/main/services/ConfigManager.ts b/src/main/services/ConfigManager.ts index 2c638da3..ab7f4404 100644 --- a/src/main/services/ConfigManager.ts +++ b/src/main/services/ConfigManager.ts @@ -4,6 +4,7 @@ import Store from 'electron-store' export class ConfigManager { private store: Store + private subscribers: Map void>> = new Map() constructor() { this.store = new Store() @@ -24,6 +25,39 @@ export class ConfigManager { setTheme(theme: ThemeMode) { this.store.set('theme', theme) } + + isTray(): boolean { + return !!this.store.get('tray', false) + } + + setTray(value: boolean) { + this.store.set('tray', value) + this.notifySubscribers('tray', value) + } + + subscribe(key: string, callback: (newValue: T) => void) { + if (!this.subscribers.has(key)) { + this.subscribers.set(key, []) + } + this.subscribers.get(key)!.push(callback) + } + + unsubscribe(key: string, callback: (newValue: T) => void) { + const subscribers = this.subscribers.get(key) + if (subscribers) { + this.subscribers.set( + key, + subscribers.filter((subscriber) => subscriber !== callback) + ) + } + } + + private notifySubscribers(key: string, newValue: T) { + const subscribers = this.subscribers.get(key) + if (subscribers) { + subscribers.forEach((subscriber) => subscriber(newValue)) + } + } } export const configManager = new ConfigManager() diff --git a/src/main/services/TrayService.ts b/src/main/services/TrayService.ts index 1e09aeaf..80011c15 100644 --- a/src/main/services/TrayService.ts +++ b/src/main/services/TrayService.ts @@ -10,7 +10,8 @@ export class TrayService { private tray: Tray | null = null constructor() { - this.createTray() + this.updateTray() + this.watchTrayChanges() } private createTray() { @@ -69,6 +70,25 @@ export class TrayService { }) } + private updateTray() { + if (configManager.isTray()) { + this.createTray() + } else { + this.destroyTray() + } + } + + private destroyTray() { + if (this.tray) { + this.tray.destroy() + this.tray = null + } + } + + private watchTrayChanges() { + configManager.subscribe('tray', () => this.updateTray()) + } + private quit() { app.quit() } diff --git a/src/main/services/WindowService.ts b/src/main/services/WindowService.ts index febcd64e..4b0de753 100644 --- a/src/main/services/WindowService.ts +++ b/src/main/services/WindowService.ts @@ -1,4 +1,5 @@ import { is } from '@electron-toolkit/utils' +import { isTilingWindowManager } from '@main/utils/is-tiling-window-manager' import { app, BrowserWindow, Menu, MenuItem, shell } from 'electron' import windowStateKeeper from 'electron-window-state' import { join } from 'path' @@ -166,6 +167,10 @@ export class WindowService { private setupWindowLifecycleEvents(mainWindow: BrowserWindow) { mainWindow.on('close', (event) => { + if (!configManager.isTray() && isTilingWindowManager()) { + app.quit() + } + if (!app.isQuitting) { event.preventDefault() mainWindow.hide() diff --git a/src/main/utils/is-tiling-window-manager.ts b/src/main/utils/is-tiling-window-manager.ts new file mode 100644 index 00000000..c481d158 --- /dev/null +++ b/src/main/utils/is-tiling-window-manager.ts @@ -0,0 +1,12 @@ +function isTilingWindowManager() { + if (process.platform !== 'linux') { + return false + } + + const desktopEnv = process.env.XDG_CURRENT_DESKTOP?.toLowerCase() + const tilingSystems = ['hyprland', 'i3', 'sway', 'bspwm', 'dwm', 'awesome', 'qtile', 'herbstluftwm', 'xmonad'] + + return tilingSystems.some((system) => desktopEnv?.includes(system)) +} + +export { isTilingWindowManager } diff --git a/src/preload/index.d.ts b/src/preload/index.d.ts index 4a3834df..f0e01c46 100644 --- a/src/preload/index.d.ts +++ b/src/preload/index.d.ts @@ -19,6 +19,7 @@ declare global { openWebsite: (url: string) => void setProxy: (proxy: string | undefined) => void setLanguage: (theme: LanguageVarious) => void + setTray: (isActive: boolean) => void setTheme: (theme: 'light' | 'dark') => void minApp: (options: { url: string; windowOptions?: Electron.BrowserWindowConstructorOptions }) => void reload: () => void diff --git a/src/preload/index.ts b/src/preload/index.ts index 561e0b93..39be2265 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -9,6 +9,7 @@ const api = { setProxy: (proxy: string) => ipcRenderer.invoke('app:proxy', proxy), checkForUpdate: () => ipcRenderer.invoke('app:check-for-update'), setLanguage: (lang: string) => ipcRenderer.invoke('app:set-language', lang), + setTray: (isActive: boolean) => ipcRenderer.invoke('app:set-tray', isActive), setTheme: (theme: 'light' | 'dark') => ipcRenderer.invoke('app:set-theme', theme), openWebsite: (url: string) => ipcRenderer.invoke('open:website', url), minApp: (url: string) => ipcRenderer.invoke('minapp', url), diff --git a/src/renderer/src/hooks/useSettings.ts b/src/renderer/src/hooks/useSettings.ts index 47e93766..0080d46a 100644 --- a/src/renderer/src/hooks/useSettings.ts +++ b/src/renderer/src/hooks/useSettings.ts @@ -4,6 +4,7 @@ import { setSendMessageShortcut as _setSendMessageShortcut, setTheme, setTopicPosition, + setTray, setWindowStyle } from '@renderer/store/settings' import { ThemeMode } from '@renderer/types' @@ -17,6 +18,9 @@ export function useSettings() { setSendMessageShortcut(shortcut: SendMessageShortcut) { dispatch(_setSendMessageShortcut(shortcut)) }, + setTray(isActive: boolean) { + dispatch(setTray(isActive)) + }, setTheme(theme: ThemeMode) { dispatch(setTheme(theme)) }, diff --git a/src/renderer/src/i18n/locales/en-us.json b/src/renderer/src/i18n/locales/en-us.json index 7aa5965f..efe83d52 100644 --- a/src/renderer/src/i18n/locales/en-us.json +++ b/src/renderer/src/i18n/locales/en-us.json @@ -336,6 +336,7 @@ "about.license.button": "License", "about.contact.button": "Email", "proxy.title": "Proxy Address", + "tray.title": "Minimize to tray instead of closing", "theme.title": "Theme", "theme.dark": "Dark", "theme.light": "Light", diff --git a/src/renderer/src/i18n/locales/zh-cn.json b/src/renderer/src/i18n/locales/zh-cn.json index 6cb98e67..7625d713 100644 --- a/src/renderer/src/i18n/locales/zh-cn.json +++ b/src/renderer/src/i18n/locales/zh-cn.json @@ -324,6 +324,7 @@ "about.license.button": "查看", "about.contact.button": "邮件", "proxy.title": "代理地址", + "tray.title": "最小化到托盘而不是关闭", "theme.title": "主题", "theme.dark": "深色主题", "theme.light": "浅色主题", diff --git a/src/renderer/src/i18n/locales/zh-tw.json b/src/renderer/src/i18n/locales/zh-tw.json index cd04ea0e..b9e7eed7 100644 --- a/src/renderer/src/i18n/locales/zh-tw.json +++ b/src/renderer/src/i18n/locales/zh-tw.json @@ -324,6 +324,7 @@ "about.license.button": "查看", "about.contact.button": "郵件", "proxy.title": "代理地址", + "tray.title": "最小化到系統列而不是關閉", "theme.title": "主題", "theme.dark": "深色主題", "theme.light": "淺色主題", diff --git a/src/renderer/src/pages/settings/GeneralSettings.tsx b/src/renderer/src/pages/settings/GeneralSettings.tsx index 0e9234cf..fa14b76a 100644 --- a/src/renderer/src/pages/settings/GeneralSettings.tsx +++ b/src/renderer/src/pages/settings/GeneralSettings.tsx @@ -16,17 +16,24 @@ const GeneralSettings: FC = () => { const { language, proxyUrl: storeProxyUrl, + setTheme, theme, + setTray, + tray, windowStyle, topicPosition, showTopicTime, clickAssistantToShowTopic, - setTheme, setWindowStyle, setTopicPosition } = useSettings() const [proxyUrl, setProxyUrl] = useState(storeProxyUrl) + const updateTray = (value: boolean) => { + setTray(value) + window.api.setTray(value) + } + const dispatch = useAppDispatch() const { t } = useTranslation() @@ -121,6 +128,11 @@ const GeneralSettings: FC = () => { /> + + {t('settings.tray.title')} + updateTray(checked)} /> + + {topicPosition === 'left' && ( <> diff --git a/src/renderer/src/store/settings.ts b/src/renderer/src/store/settings.ts index ede88886..a9165af0 100644 --- a/src/renderer/src/store/settings.ts +++ b/src/renderer/src/store/settings.ts @@ -13,6 +13,7 @@ export interface SettingsState { showMessageDivider: boolean messageFont: 'system' | 'serif' showInputEstimatedTokens: boolean + tray: boolean theme: ThemeMode windowStyle: 'transparent' | 'opaque' fontSize: number @@ -99,6 +100,9 @@ const settingsSlice = createSlice({ setShowInputEstimatedTokens: (state, action: PayloadAction) => { state.showInputEstimatedTokens = action.payload }, + setTray: (state, action: PayloadAction) => { + state.tray = action.payload + }, setTheme: (state, action: PayloadAction) => { state.theme = action.payload }, @@ -166,6 +170,7 @@ export const { setShowMessageDivider, setMessageFont, setShowInputEstimatedTokens, + setTray, setTheme, setFontSize, setWindowStyle,