feat: implement tray and minimize behaviors (#13851)

This PR introduces new window behaviors, which can be enabled when the
menubar setting is active:

New Features:
- Quick open from tray icon
- Minimize to tray
- Exit to tray
- Start minimized

These changes have not yet been tested on macOS.

<img width="645" height="479" alt="image"
src="https://github.com/user-attachments/assets/7bdd13d0-5322-45a4-8e71-85c081aa0c86"
/>


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Configurable menubar/tray behaviors: open on left-click, minimize to
tray, close to tray (exit to tray), and start minimized.

* **UI**
* Appearance settings add a Menubar → Window Behavior group with four
toggles; group shows only when menubar/tray is enabled (hidden on
macOS).

* **Settings**
* Tray settings persisted and exposed via the settings API with getters
and setters for each option.

* **Localization**
* Added translation keys and English strings for the new controls and
descriptions.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Peng Xiao <pengxiao@outlook.com>
This commit is contained in:
Martin Pauli
2025-11-06 21:10:15 +01:00
committed by GitHub
parent 9f6ea83ac1
commit 2bd9f1a353
8 changed files with 258 additions and 29 deletions
@@ -58,6 +58,10 @@ export type SpellCheckStateSchema = z.infer<typeof SpellCheckStateSchema>;
export const MenubarStateKey = 'menubarState' as const; export const MenubarStateKey = 'menubarState' as const;
export const MenubarStateSchema = z.object({ export const MenubarStateSchema = z.object({
enabled: z.boolean().default(true), 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<typeof MenubarStateSchema>; export type MenubarStateSchema = z.infer<typeof MenubarStateSchema>;
@@ -317,7 +317,14 @@ class TrayState implements Disposable {
logger.debug('User clicked on tray icon'); logger.debug('User clicked on tray icon');
this.update(); this.update();
if (!isMacOS()) { if (!isMacOS()) {
this.tray?.popUpContextMenu(); if (
TraySettingsState.value.enabled &&
TraySettingsState.value.openOnLeftClick
) {
showMainWindow();
} else {
this.tray?.popUpContextMenu();
}
} }
updateApplicationsPing$.next(Date.now()); updateApplicationsPing$.next(Date.now());
}; };
@@ -1,11 +1,14 @@
import { app, clipboard, nativeImage, nativeTheme } from 'electron'; import { app, clipboard, nativeImage, nativeTheme } from 'electron';
import { getLinkPreview } from 'link-preview-js'; import { getLinkPreview } from 'link-preview-js';
import { map, shareReplay } from 'rxjs';
import { isMacOS } from '../../shared/utils'; import { isMacOS } from '../../shared/utils';
import { persistentConfig } from '../config-storage/persist'; import { persistentConfig } from '../config-storage/persist';
import { logger } from '../logger'; import { logger } from '../logger';
import { openExternalSafely } from '../security/open-external'; import { openExternalSafely } from '../security/open-external';
import type { WorkbenchViewMeta } from '../shared-state-schema'; 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 type { NamespaceHandlers } from '../type';
import { import {
activateView, activateView,
@@ -34,6 +37,19 @@ import { getOrCreateCustomThemeWindow } from '../windows-manager/custom-theme-wi
import { getChallengeResponse } from './challenge'; import { getChallengeResponse } from './challenge';
import { uiSubjects } from './subject'; import { uiSubjects } from './subject';
const TraySettingsState = {
$: globalStateStorage.watch<MenubarStateSchema>(MenubarStateKey).pipe(
map(v => MenubarStateSchema.parse(v ?? {})),
shareReplay(1)
),
get value() {
return MenubarStateSchema.parse(
globalStateStorage.get(MenubarStateKey) ?? {}
);
},
};
export const uiHandlers = { export const uiHandlers = {
isMaximized: async () => { isMaximized: async () => {
const window = await getMainWindow(); const window = await getMainWindow();
@@ -48,7 +64,14 @@ export const uiHandlers = {
}, },
handleMinimizeApp: async () => { handleMinimizeApp: async () => {
const window = await getMainWindow(); const window = await getMainWindow();
window?.minimize(); if (
TraySettingsState.value.enabled &&
TraySettingsState.value.minimizeToTray
) {
window?.hide();
} else {
window?.minimize();
}
}, },
handleMaximizeApp: async () => { handleMaximizeApp: async () => {
const window = await getMainWindow(); const window = await getMainWindow();
@@ -69,7 +92,15 @@ export const uiHandlers = {
await handleWebContentsResize(e.sender); await handleWebContentsResize(e.sender);
}, },
handleCloseApp: async () => { handleCloseApp: async () => {
app.quit(); if (
TraySettingsState.value.enabled &&
TraySettingsState.value.closeToTray
) {
const window = await getMainWindow();
window?.hide();
} else {
app.quit();
}
}, },
handleHideApp: async () => { handleHideApp: async () => {
const window = await getMainWindow(); const window = await getMainWindow();
@@ -2,7 +2,7 @@ import { join } from 'node:path';
import { BrowserWindow, nativeTheme } from 'electron'; import { BrowserWindow, nativeTheme } from 'electron';
import electronWindowState from 'electron-window-state'; 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 { isLinux, isMacOS, isWindows, resourcesPath } from '../../shared/utils';
import { beforeAppQuit } from '../cleanup'; import { beforeAppQuit } from '../cleanup';
@@ -10,11 +10,26 @@ import { buildType } from '../config';
import { mainWindowOrigin } from '../constants'; import { mainWindowOrigin } from '../constants';
import { ensureHelperProcess } from '../helper-process'; import { ensureHelperProcess } from '../helper-process';
import { logger } from '../logger'; import { logger } from '../logger';
import { MenubarStateKey, MenubarStateSchema } from '../shared-state-schema';
import { globalStateStorage } from '../shared-storage/storage';
import { uiSubjects } from '../ui/subject'; import { uiSubjects } from '../ui/subject';
const IS_DEV: boolean = const IS_DEV: boolean =
process.env.NODE_ENV === 'development' && !process.env.CI; process.env.NODE_ENV === 'development' && !process.env.CI;
const TraySettingsState = {
$: globalStateStorage.watch<MenubarStateSchema>(MenubarStateKey).pipe(
map(v => MenubarStateSchema.parse(v ?? {})),
shareReplay(1)
),
get value() {
return MenubarStateSchema.parse(
globalStateStorage.get(MenubarStateKey) ?? {}
);
},
};
function closeAllWindows() { function closeAllWindows() {
BrowserWindow.getAllWindows().forEach(w => { BrowserWindow.getAllWindows().forEach(w => {
if (!w.isDestroyed()) { if (!w.isDestroyed()) {
@@ -125,9 +140,16 @@ export class MainWindowManager {
// TODO(@pengx17): gracefully close the app, for example, ask user to save unsaved changes // TODO(@pengx17): gracefully close the app, for example, ask user to save unsaved changes
e.preventDefault(); e.preventDefault();
if (!isMacOS()) { if (!isMacOS()) {
closeAllWindows(); if (
this.mainWindowReady = undefined; TraySettingsState.value.enabled &&
this.mainWindow$.next(undefined); TraySettingsState.value.closeToTray
) {
mainWindow.hide();
} else {
closeAllWindows();
this.mainWindowReady = undefined;
this.mainWindow$.next(undefined);
}
} else { } else {
// hide window on macOS // hide window on macOS
// application quit will be handled by closing the hidden window // application quit will be handled by closing the hidden window
@@ -209,7 +231,10 @@ export class MainWindowManager {
if (IS_DEV) { if (IS_DEV) {
// do not gain focus in dev mode // do not gain focus in dev mode
mainWindow.showInactive(); mainWindow.showInactive();
} else { } else if (
!TraySettingsState.value.enabled ||
!TraySettingsState.value.startMinimized
) {
mainWindow.show(); mainWindow.show();
} }
@@ -62,22 +62,92 @@ export const ThemeSettings = () => {
const MenubarSetting = () => { const MenubarSetting = () => {
const t = useI18n(); const t = useI18n();
const traySettingService = useService(TraySettingService); const traySettingService = useService(TraySettingService);
const { enabled } = useLiveData(traySettingService.setting$); const traySetting = useLiveData(traySettingService.settings$);
return ( return (
<SettingWrapper <>
id="menubar" <SettingWrapper
title={t['com.affine.appearanceSettings.menubar.title']()} id="menubar"
> title={t['com.affine.appearanceSettings.menubar.title']()}
<SettingRow
name={t['com.affine.appearanceSettings.menubar.toggle']()}
desc={t['com.affine.appearanceSettings.menubar.description']()}
> >
<Switch <SettingRow
checked={enabled} name={t['com.affine.appearanceSettings.menubar.toggle']()}
onChange={checked => traySettingService.setEnabled(checked)} desc={t['com.affine.appearanceSettings.menubar.description']()}
/> >
</SettingRow> <Switch
</SettingWrapper> checked={traySetting.enabled}
onChange={checked => traySettingService.setEnabled(checked)}
/>
</SettingRow>
</SettingWrapper>
{traySetting.enabled && !environment.isMacOs ? (
<SettingWrapper
id="windowBehavior"
title={t[
'com.affine.appearanceSettings.menubar.windowBehavior.title'
]()}
>
<SettingRow
name={t[
'com.affine.appearanceSettings.menubar.windowBehavior.openOnLeftClick.toggle'
]()}
desc={t[
'com.affine.appearanceSettings.menubar.windowBehavior.openOnLeftClick.description'
]()}
>
<Switch
checked={traySetting.openOnLeftClick}
onChange={checked =>
traySettingService.setOpenOnLeftClick(checked)
}
/>
</SettingRow>
<SettingRow
name={t[
'com.affine.appearanceSettings.menubar.windowBehavior.minimizeToTray.toggle'
]()}
desc={t[
'com.affine.appearanceSettings.menubar.windowBehavior.minimizeToTray.description'
]()}
>
<Switch
checked={traySetting.minimizeToTray}
onChange={checked =>
traySettingService.setMinimizeToTray(checked)
}
/>
</SettingRow>
<SettingRow
name={t[
'com.affine.appearanceSettings.menubar.windowBehavior.closeToTray.toggle'
]()}
desc={t[
'com.affine.appearanceSettings.menubar.windowBehavior.closeToTray.description'
]()}
>
<Switch
checked={traySetting.closeToTray}
onChange={checked => traySettingService.setCloseToTray(checked)}
/>
</SettingRow>
<SettingRow
name={t[
'com.affine.appearanceSettings.menubar.windowBehavior.startMinimized.toggle'
]()}
desc={t[
'com.affine.appearanceSettings.menubar.windowBehavior.startMinimized.description'
]()}
>
<Switch
checked={traySetting.startMinimized}
onChange={checked =>
traySettingService.setStartMinimized(checked)
}
/>
</SettingRow>
</SettingWrapper>
) : null}
</>
); );
}; };
@@ -3,26 +3,73 @@ import type {
MenubarStateSchema, MenubarStateSchema,
} from '@affine/electron/main/shared-state-schema'; } from '@affine/electron/main/shared-state-schema';
import { LiveData, Service } from '@toeverything/infra'; import { LiveData, Service } from '@toeverything/infra';
import { defaults } from 'lodash-es';
import type { GlobalStateService } from '../../storage'; import type { GlobalStateService } from '../../storage';
const MENUBAR_SETTING_KEY: typeof MenubarStateKey = 'menubarState'; 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 { export class TraySettingService extends Service {
constructor(private readonly globalStateService: GlobalStateService) { constructor(private readonly globalStateService: GlobalStateService) {
super(); super();
} }
setting$ = LiveData.from( readonly settings$ = LiveData.computed(get => {
this.globalStateService.globalState.watch<MenubarStateSchema>( const value = get(
MENUBAR_SETTING_KEY LiveData.from(
), this.globalStateService.globalState.watch<MenubarStateSchema>(
null MENUBAR_SETTING_KEY
).map(v => v ?? { enabled: true }); ),
undefined
)
);
return defaults(value, defaultTraySetting);
});
get settings() {
return this.settings$.value;
}
setEnabled(enabled: boolean) { setEnabled(enabled: boolean) {
this.globalStateService.globalState.set(MENUBAR_SETTING_KEY, { 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,
}); });
} }
} }
+36
View File
@@ -991,6 +991,42 @@ export function useAFFiNEI18N(): {
* `Display the menubar app in the tray for quick access to AFFiNE or meeting recordings.` * `Display the menubar app in the tray for quick access to AFFiNE or meeting recordings.`
*/ */
["com.affine.appearanceSettings.menubar.description"](): string; ["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 leftclicking 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` * `Theme`
*/ */
@@ -237,6 +237,15 @@
"com.affine.appearanceSettings.menubar.title": "Menubar", "com.affine.appearanceSettings.menubar.title": "Menubar",
"com.affine.appearanceSettings.menubar.toggle": "Enable menubar app", "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.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 leftclicking 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.theme.title": "Theme",
"com.affine.appearanceSettings.title": "Appearance settings", "com.affine.appearanceSettings.title": "Appearance settings",
"com.affine.appearanceSettings.translucentUI.description": "Use transparency effect on the sidebar.", "com.affine.appearanceSettings.translucentUI.description": "Use transparency effect on the sidebar.",