diff --git a/packages/frontend/apps/electron/src/main/shared-state-schema.ts b/packages/frontend/apps/electron/src/main/shared-state-schema.ts index 922e909d71..9c6cc0d355 100644 --- a/packages/frontend/apps/electron/src/main/shared-state-schema.ts +++ b/packages/frontend/apps/electron/src/main/shared-state-schema.ts @@ -58,6 +58,10 @@ export type SpellCheckStateSchema = z.infer; export const MenubarStateKey = 'menubarState' as const; export const MenubarStateSchema = z.object({ enabled: z.boolean().default(true), + openOnLeftClick: z.boolean().default(false), + minimizeToTray: z.boolean().default(false), + closeToTray: z.boolean().default(false), + startMinimized: z.boolean().default(false), }); export type MenubarStateSchema = z.infer; diff --git a/packages/frontend/apps/electron/src/main/tray/index.ts b/packages/frontend/apps/electron/src/main/tray/index.ts index 40296e3cee..a0165ec3ba 100644 --- a/packages/frontend/apps/electron/src/main/tray/index.ts +++ b/packages/frontend/apps/electron/src/main/tray/index.ts @@ -317,7 +317,14 @@ class TrayState implements Disposable { logger.debug('User clicked on tray icon'); this.update(); if (!isMacOS()) { - this.tray?.popUpContextMenu(); + if ( + TraySettingsState.value.enabled && + TraySettingsState.value.openOnLeftClick + ) { + showMainWindow(); + } else { + this.tray?.popUpContextMenu(); + } } updateApplicationsPing$.next(Date.now()); }; diff --git a/packages/frontend/apps/electron/src/main/ui/handlers.ts b/packages/frontend/apps/electron/src/main/ui/handlers.ts index ef4ce03ba0..441fde6676 100644 --- a/packages/frontend/apps/electron/src/main/ui/handlers.ts +++ b/packages/frontend/apps/electron/src/main/ui/handlers.ts @@ -1,11 +1,14 @@ import { app, clipboard, nativeImage, nativeTheme } from 'electron'; import { getLinkPreview } from 'link-preview-js'; +import { map, shareReplay } from 'rxjs'; import { isMacOS } from '../../shared/utils'; import { persistentConfig } from '../config-storage/persist'; import { logger } from '../logger'; import { openExternalSafely } from '../security/open-external'; import type { WorkbenchViewMeta } from '../shared-state-schema'; +import { MenubarStateKey, MenubarStateSchema } from '../shared-state-schema'; +import { globalStateStorage } from '../shared-storage/storage'; import type { NamespaceHandlers } from '../type'; import { activateView, @@ -34,6 +37,19 @@ import { getOrCreateCustomThemeWindow } from '../windows-manager/custom-theme-wi import { getChallengeResponse } from './challenge'; import { uiSubjects } from './subject'; +const TraySettingsState = { + $: globalStateStorage.watch(MenubarStateKey).pipe( + map(v => MenubarStateSchema.parse(v ?? {})), + shareReplay(1) + ), + + get value() { + return MenubarStateSchema.parse( + globalStateStorage.get(MenubarStateKey) ?? {} + ); + }, +}; + export const uiHandlers = { isMaximized: async () => { const window = await getMainWindow(); @@ -48,7 +64,14 @@ export const uiHandlers = { }, handleMinimizeApp: async () => { const window = await getMainWindow(); - window?.minimize(); + if ( + TraySettingsState.value.enabled && + TraySettingsState.value.minimizeToTray + ) { + window?.hide(); + } else { + window?.minimize(); + } }, handleMaximizeApp: async () => { const window = await getMainWindow(); @@ -69,7 +92,15 @@ export const uiHandlers = { await handleWebContentsResize(e.sender); }, handleCloseApp: async () => { - app.quit(); + if ( + TraySettingsState.value.enabled && + TraySettingsState.value.closeToTray + ) { + const window = await getMainWindow(); + window?.hide(); + } else { + app.quit(); + } }, handleHideApp: async () => { const window = await getMainWindow(); diff --git a/packages/frontend/apps/electron/src/main/windows-manager/main-window.ts b/packages/frontend/apps/electron/src/main/windows-manager/main-window.ts index b67547e006..8fc5dfa72a 100644 --- a/packages/frontend/apps/electron/src/main/windows-manager/main-window.ts +++ b/packages/frontend/apps/electron/src/main/windows-manager/main-window.ts @@ -2,7 +2,7 @@ import { join } from 'node:path'; import { BrowserWindow, nativeTheme } from 'electron'; import electronWindowState from 'electron-window-state'; -import { BehaviorSubject } from 'rxjs'; +import { BehaviorSubject, map, shareReplay } from 'rxjs'; import { isLinux, isMacOS, isWindows, resourcesPath } from '../../shared/utils'; import { beforeAppQuit } from '../cleanup'; @@ -10,11 +10,26 @@ import { buildType } from '../config'; import { mainWindowOrigin } from '../constants'; import { ensureHelperProcess } from '../helper-process'; import { logger } from '../logger'; +import { MenubarStateKey, MenubarStateSchema } from '../shared-state-schema'; +import { globalStateStorage } from '../shared-storage/storage'; import { uiSubjects } from '../ui/subject'; const IS_DEV: boolean = process.env.NODE_ENV === 'development' && !process.env.CI; +const TraySettingsState = { + $: globalStateStorage.watch(MenubarStateKey).pipe( + map(v => MenubarStateSchema.parse(v ?? {})), + shareReplay(1) + ), + + get value() { + return MenubarStateSchema.parse( + globalStateStorage.get(MenubarStateKey) ?? {} + ); + }, +}; + function closeAllWindows() { BrowserWindow.getAllWindows().forEach(w => { if (!w.isDestroyed()) { @@ -125,9 +140,16 @@ export class MainWindowManager { // TODO(@pengx17): gracefully close the app, for example, ask user to save unsaved changes e.preventDefault(); if (!isMacOS()) { - closeAllWindows(); - this.mainWindowReady = undefined; - this.mainWindow$.next(undefined); + if ( + TraySettingsState.value.enabled && + TraySettingsState.value.closeToTray + ) { + mainWindow.hide(); + } else { + closeAllWindows(); + this.mainWindowReady = undefined; + this.mainWindow$.next(undefined); + } } else { // hide window on macOS // application quit will be handled by closing the hidden window @@ -209,7 +231,10 @@ export class MainWindowManager { if (IS_DEV) { // do not gain focus in dev mode mainWindow.showInactive(); - } else { + } else if ( + !TraySettingsState.value.enabled || + !TraySettingsState.value.startMinimized + ) { mainWindow.show(); } diff --git a/packages/frontend/core/src/desktop/dialogs/setting/general-setting/appearance/index.tsx b/packages/frontend/core/src/desktop/dialogs/setting/general-setting/appearance/index.tsx index b2e905d763..d7575a5400 100644 --- a/packages/frontend/core/src/desktop/dialogs/setting/general-setting/appearance/index.tsx +++ b/packages/frontend/core/src/desktop/dialogs/setting/general-setting/appearance/index.tsx @@ -62,22 +62,92 @@ export const ThemeSettings = () => { const MenubarSetting = () => { const t = useI18n(); const traySettingService = useService(TraySettingService); - const { enabled } = useLiveData(traySettingService.setting$); + const traySetting = useLiveData(traySettingService.settings$); + return ( - - + - traySettingService.setEnabled(checked)} - /> - - + + traySettingService.setEnabled(checked)} + /> + + + {traySetting.enabled && !environment.isMacOs ? ( + + + + traySettingService.setOpenOnLeftClick(checked) + } + /> + + + + traySettingService.setMinimizeToTray(checked) + } + /> + + + traySettingService.setCloseToTray(checked)} + /> + + + + traySettingService.setStartMinimized(checked) + } + /> + + + ) : null} + ); }; diff --git a/packages/frontend/core/src/modules/editor-setting/services/tray-settings.ts b/packages/frontend/core/src/modules/editor-setting/services/tray-settings.ts index 28797cc591..94cc60558d 100644 --- a/packages/frontend/core/src/modules/editor-setting/services/tray-settings.ts +++ b/packages/frontend/core/src/modules/editor-setting/services/tray-settings.ts @@ -3,26 +3,73 @@ import type { MenubarStateSchema, } from '@affine/electron/main/shared-state-schema'; import { LiveData, Service } from '@toeverything/infra'; +import { defaults } from 'lodash-es'; import type { GlobalStateService } from '../../storage'; const MENUBAR_SETTING_KEY: typeof MenubarStateKey = 'menubarState'; +const defaultTraySetting: MenubarStateSchema = { + enabled: true, + minimizeToTray: false, + closeToTray: false, + startMinimized: false, + openOnLeftClick: false, +}; + export class TraySettingService extends Service { constructor(private readonly globalStateService: GlobalStateService) { super(); } - setting$ = LiveData.from( - this.globalStateService.globalState.watch( - MENUBAR_SETTING_KEY - ), - null - ).map(v => v ?? { enabled: true }); + readonly settings$ = LiveData.computed(get => { + const value = get( + LiveData.from( + this.globalStateService.globalState.watch( + MENUBAR_SETTING_KEY + ), + undefined + ) + ); + return defaults(value, defaultTraySetting); + }); + + get settings() { + return this.settings$.value; + } setEnabled(enabled: boolean) { this.globalStateService.globalState.set(MENUBAR_SETTING_KEY, { - enabled, + ...this.settings$.value, + enabled: enabled, + }); + } + + setMinimizeToTray(minimizeToTray: boolean) { + this.globalStateService.globalState.set(MENUBAR_SETTING_KEY, { + ...this.settings$.value, + minimizeToTray: minimizeToTray, + }); + } + + setCloseToTray(closeToTray: boolean) { + this.globalStateService.globalState.set(MENUBAR_SETTING_KEY, { + ...this.settings$.value, + closeToTray: closeToTray, + }); + } + + setStartMinimized(startMinimized: boolean) { + this.globalStateService.globalState.set(MENUBAR_SETTING_KEY, { + ...this.settings$.value, + startMinimized: startMinimized, + }); + } + + setOpenOnLeftClick(openOnLeftClick: boolean) { + this.globalStateService.globalState.set(MENUBAR_SETTING_KEY, { + ...this.settings$.value, + openOnLeftClick: openOnLeftClick, }); } } diff --git a/packages/frontend/i18n/src/i18n.gen.ts b/packages/frontend/i18n/src/i18n.gen.ts index 740a497c1e..7b50d65376 100644 --- a/packages/frontend/i18n/src/i18n.gen.ts +++ b/packages/frontend/i18n/src/i18n.gen.ts @@ -991,6 +991,42 @@ export function useAFFiNEI18N(): { * `Display the menubar app in the tray for quick access to AFFiNE or meeting recordings.` */ ["com.affine.appearanceSettings.menubar.description"](): string; + /** + * `Window behavior` + */ + ["com.affine.appearanceSettings.menubar.windowBehavior.title"](): string; + /** + * `Quick open from tray icon` + */ + ["com.affine.appearanceSettings.menubar.windowBehavior.openOnLeftClick.toggle"](): string; + /** + * `Open AFFiNE when left‑clicking the tray icon.` + */ + ["com.affine.appearanceSettings.menubar.windowBehavior.openOnLeftClick.description"](): string; + /** + * `Minimize to tray` + */ + ["com.affine.appearanceSettings.menubar.windowBehavior.minimizeToTray.toggle"](): string; + /** + * `Minimize AFFiNE to the system tray.` + */ + ["com.affine.appearanceSettings.menubar.windowBehavior.minimizeToTray.description"](): string; + /** + * `Close to tray` + */ + ["com.affine.appearanceSettings.menubar.windowBehavior.closeToTray.toggle"](): string; + /** + * `Close AFFiNE to the system tray.` + */ + ["com.affine.appearanceSettings.menubar.windowBehavior.closeToTray.description"](): string; + /** + * `Start minimized` + */ + ["com.affine.appearanceSettings.menubar.windowBehavior.startMinimized.toggle"](): string; + /** + * `Start AFFiNE minimized to the system tray.` + */ + ["com.affine.appearanceSettings.menubar.windowBehavior.startMinimized.description"](): string; /** * `Theme` */ diff --git a/packages/frontend/i18n/src/resources/en.json b/packages/frontend/i18n/src/resources/en.json index a56ea20112..43a100e64c 100644 --- a/packages/frontend/i18n/src/resources/en.json +++ b/packages/frontend/i18n/src/resources/en.json @@ -237,6 +237,15 @@ "com.affine.appearanceSettings.menubar.title": "Menubar", "com.affine.appearanceSettings.menubar.toggle": "Enable menubar app", "com.affine.appearanceSettings.menubar.description": "Display the menubar app in the tray for quick access to AFFiNE or meeting recordings.", + "com.affine.appearanceSettings.menubar.windowBehavior.title": "Window behavior", + "com.affine.appearanceSettings.menubar.windowBehavior.openOnLeftClick.toggle": "Quick open from tray icon", + "com.affine.appearanceSettings.menubar.windowBehavior.openOnLeftClick.description": "Open AFFiNE when left‑clicking the tray icon.", + "com.affine.appearanceSettings.menubar.windowBehavior.minimizeToTray.toggle": "Minimize to tray", + "com.affine.appearanceSettings.menubar.windowBehavior.minimizeToTray.description": "Minimize AFFiNE to the system tray.", + "com.affine.appearanceSettings.menubar.windowBehavior.closeToTray.toggle": "Close to tray", + "com.affine.appearanceSettings.menubar.windowBehavior.closeToTray.description": "Close AFFiNE to the system tray.", + "com.affine.appearanceSettings.menubar.windowBehavior.startMinimized.toggle": "Start minimized", + "com.affine.appearanceSettings.menubar.windowBehavior.startMinimized.description": "Start AFFiNE minimized to the system tray.", "com.affine.appearanceSettings.theme.title": "Theme", "com.affine.appearanceSettings.title": "Appearance settings", "com.affine.appearanceSettings.translucentUI.description": "Use transparency effect on the sidebar.",