feat: improve mac dock behavior (#15334)

#### PR Dependency Tree


* **PR #15334** 👈

This tree was auto-generated by
[Charcoal](https://github.com/danerwilliams/charcoal)

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

* **Enhancements**
* Improved main-window restoration for deep links, second-instance
launches, tray/menu actions, and when recordings finish.
* Refined macOS Dock show/hide behavior with throttling for smoother
window visibility.
* Updated close-to-tray/close-to-background handling to better manage
the app’s window lifecycle.
* Ensured popup/dock visibility is consistent when opening new windows.
* Updated window behavior settings display so tray-related options
render correctly across platforms.
* **Localization**
* Updated Simplified Chinese wording for menubar window behavior title.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
DarkSky
2026-07-23 23:25:55 +08:00
committed by GitHub
parent 8001451fd5
commit 0d889bc643
9 changed files with 219 additions and 133 deletions
@@ -7,7 +7,6 @@ import { logger } from './logger';
import { uiSubjects } from './ui';
import {
addTabWithUrl,
getMainWindow,
loadUrlInActiveTab,
openUrlInHiddenWindow,
showMainWindow,
@@ -74,13 +73,8 @@ export function setupDeepLink(app: App) {
// on windows & linux, we need to listen for the second-instance event
app.on('second-instance', (event, commandLine) => {
getMainWindow()
.then(window => {
if (!window) {
logger.error('main window is not ready');
return;
}
window.show();
showMainWindow()
.then(() => {
const url = commandLine.pop();
if (url?.startsWith(`${protocol}://`)) {
event.preventDefault();
@@ -149,11 +143,12 @@ async function handleAffineUrl(url: string) {
? await openUrlInHiddenWindow(urlObj)
: await loadUrlInActiveTab(url);
const main = await getMainWindow();
if (main && hiddenWindow) {
if (hiddenWindow) {
// when hidden window closed, the main window will be hidden somehow
hiddenWindow.on('close', () => {
main.show();
void showMainWindow().catch(e => {
logger.error('Failed to restore main window:', e);
});
});
}
}
@@ -32,7 +32,7 @@ import {
MeetingSettingsSchema,
} from '../shared-state-schema';
import { globalStateStorage } from '../shared-storage/storage';
import { getMainWindow } from '../windows-manager';
import { showMainWindow } from '../windows-manager';
import { popupManager } from '../windows-manager/popup';
import { isAppNameAllowed } from './allow-list';
import { RecordingCoordinator } from './coordinator';
@@ -512,15 +512,9 @@ export async function startRecording(
export async function stopRecording(id: number) {
const job = await recordingCoordinator.stop(id);
if (job?.phase === 'recorded') {
void getMainWindow()
.then(mainWindow => {
if (mainWindow) {
mainWindow.show();
}
})
.catch(err => {
logger.error('failed to bring up the window', err);
});
void showMainWindow().catch(err => {
logger.error('failed to bring up the window', err);
});
}
return serializeRecordingStatus(getCurrentRecordingStatus());
}
@@ -25,7 +25,7 @@ import {
} from '../recording/feature';
import { MenubarStateKey, MenubarStateSchema } from '../shared-state-schema';
import { globalStateStorage } from '../shared-storage/storage';
import { getMainWindow } from '../windows-manager';
import { showMainWindow } from '../windows-manager';
import { icons } from './icons';
export interface TrayMenuConfigItem {
label: string;
@@ -43,12 +43,10 @@ interface TrayMenuProvider {
getConfig(): TrayMenuConfig;
}
function showMainWindow() {
getMainWindow()
.then(w => {
w.show();
})
.catch(err => logger.error('Failed to show main window:', err));
function activateMainWindow() {
void showMainWindow().catch(err => {
logger.error('Failed to show main window:', err);
});
}
function buildMenuConfig(config: TrayMenuConfig): MenuItemConstructorOptions[] {
@@ -109,7 +107,7 @@ class TrayState implements Disposable {
icon: icons.journal,
click: () => {
logger.info('User action: Open Journal');
showMainWindow();
activateMainWindow();
applicationMenuSubjects.openJournal$.next();
},
},
@@ -118,7 +116,7 @@ class TrayState implements Disposable {
icon: icons.page,
click: () => {
logger.info('User action: New Page');
showMainWindow();
activateMainWindow();
applicationMenuSubjects.newPageAction$.next('page');
},
},
@@ -127,7 +125,7 @@ class TrayState implements Disposable {
icon: icons.edgeless,
click: () => {
logger.info('User action: New Edgeless');
showMainWindow();
activateMainWindow();
applicationMenuSubjects.newPageAction$.next('edgeless');
},
},
@@ -232,7 +230,7 @@ class TrayState implements Disposable {
items.push({
label: `Meetings Settings...`,
click: () => {
showMainWindow();
activateMainWindow();
applicationMenuSubjects.openInSettingModal$.next({
activeTab: 'meetings',
});
@@ -257,19 +255,13 @@ class TrayState implements Disposable {
label: 'Open AFFiNE',
click: () => {
logger.info('User action: Open AFFiNE');
getMainWindow()
.then(w => {
w.show();
})
.catch(err => {
logger.error('Failed to open AFFiNE:', err);
});
activateMainWindow();
},
},
{
label: 'Menubar settings...',
click: () => {
showMainWindow();
activateMainWindow();
applicationMenuSubjects.openInSettingModal$.next({
activeTab: 'appearance',
scrollAnchor: 'menubar',
@@ -279,7 +271,7 @@ class TrayState implements Disposable {
{
label: `About ${app.getName()}`,
click: () => {
showMainWindow();
activateMainWindow();
applicationMenuSubjects.openInSettingModal$.next({
activeTab: 'about',
});
@@ -329,7 +321,7 @@ class TrayState implements Disposable {
TraySettingsState.value.enabled &&
TraySettingsState.value.openOnLeftClick
) {
showMainWindow();
activateMainWindow();
} else {
this.tray?.popUpContextMenu();
}
@@ -14,6 +14,7 @@ import type { NamespaceHandlers } from '../type';
import {
activateView,
addTab,
closeMainWindowToBackground,
closeTab,
ensureTabLoaded,
getMainWindow,
@@ -104,8 +105,7 @@ export const uiHandlers = {
TraySettingsState.value.enabled &&
TraySettingsState.value.closeToTray
) {
const window = await getMainWindow();
window?.hide();
await closeMainWindowToBackground();
} else {
app.quit();
}
@@ -1,6 +1,6 @@
import { join } from 'node:path';
import { BrowserWindow, nativeTheme } from 'electron';
import { app, BrowserWindow, nativeTheme } from 'electron';
import electronWindowState from 'electron-window-state';
import { BehaviorSubject, map, shareReplay } from 'rxjs';
@@ -17,6 +17,8 @@ import { buildWebPreferences } from '../web-preferences';
const IS_DEV: boolean =
process.env.NODE_ENV === 'development' && !process.env.CI;
const DOCK_VISIBILITY_THROTTLE_MS = 1100;
const FULL_SCREEN_EXIT_TIMEOUT_MS = 2000;
const TraySettingsState = {
$: globalStateStorage.watch<MenubarStateSchema>(MenubarStateKey).pipe(
@@ -44,7 +46,26 @@ export class MainWindowManager {
mainWindowReady: Promise<BrowserWindow> | undefined;
mainWindow$ = new BehaviorSubject<BrowserWindow | undefined>(undefined);
private backgroundRequested = false;
private hiddenMacWindow: BrowserWindow | undefined;
private lastDockShowAt = 0;
private pendingDockHide: ReturnType<typeof setTimeout> | undefined;
private constructor() {
const traySettingsSubscription = TraySettingsState.$.subscribe(state => {
if (!state.enabled || !state.closeToTray) {
void this.ensureDockVisible().catch(err => {
logger.error('Failed to restore Dock visibility:', err);
});
}
});
beforeAppQuit(() => {
traySettingsSubscription.unsubscribe();
this.cancelPendingDockHide();
this.cleanupWindows();
});
}
get mainWindow() {
return this.mainWindow$.value;
@@ -66,6 +87,7 @@ export class MainWindowManager {
}
private cleanupWindows() {
this.cancelPendingDockHide();
closeAllWindows();
this.mainWindowReady = undefined;
this.mainWindow$.next(undefined);
@@ -73,6 +95,81 @@ export class MainWindowManager {
this.hiddenMacWindow = undefined;
}
private cancelPendingDockHide() {
if (this.pendingDockHide) {
clearTimeout(this.pendingDockHide);
this.pendingDockHide = undefined;
}
}
private shouldHideDock() {
const settings = TraySettingsState.value;
return (
isMacOS() &&
this.backgroundRequested &&
settings.enabled &&
settings.closeToTray
);
}
private async showDock() {
this.cancelPendingDockHide();
if (app.isReady() && app.dock && !app.dock.isVisible()) {
await app.dock.show();
this.lastDockShowAt = Date.now();
}
}
private scheduleDockHide(mainWindow: BrowserWindow) {
this.cancelPendingDockHide();
if (!app.dock || !this.shouldHideDock()) {
return;
}
const hideDock = () => {
this.pendingDockHide = undefined;
if (
this.shouldHideDock() &&
!mainWindow.isDestroyed() &&
!mainWindow.isVisible()
) {
app.dock?.hide();
}
};
const delay =
DOCK_VISIBILITY_THROTTLE_MS - (Date.now() - this.lastDockShowAt);
if (delay > 0) {
this.pendingDockHide = setTimeout(hideDock, delay);
} else {
hideDock();
}
}
private async hideMainWindow(mainWindow: BrowserWindow) {
this.backgroundRequested = true;
this.cancelPendingDockHide();
if (mainWindow.isFullScreen()) {
await new Promise<void>(resolve => {
const done = () => {
clearTimeout(timeout);
mainWindow.removeListener('leave-full-screen', done);
mainWindow.removeListener('closed', done);
resolve();
};
const timeout = setTimeout(done, FULL_SCREEN_EXIT_TIMEOUT_MS);
mainWindow.once('leave-full-screen', done);
mainWindow.once('closed', done);
mainWindow.setFullScreen(false);
});
}
if (this.backgroundRequested && !mainWindow.isDestroyed()) {
mainWindow.hide();
this.scheduleDockHide(mainWindow);
}
}
private async createMainWindow() {
logger.info('create window');
const mainWindowState = electronWindowState({
@@ -132,10 +229,6 @@ export class MainWindowManager {
uiSubjects.onFullScreen$.next(mainWindow.isFullScreen());
});
beforeAppQuit(() => {
this.cleanupWindows();
});
mainWindow.on('close', e => {
// TODO(@pengx17): gracefully close the app, for example, ask user to save unsaved changes
e.preventDefault();
@@ -151,25 +244,9 @@ export class MainWindowManager {
this.mainWindow$.next(undefined);
}
} else {
// hide window on macOS
// application quit will be handled by closing the hidden window
//
// explanation:
// - closing the top window (by clicking close button or CMD-w)
// - will be captured in "close" event here
// - hiding the app to make the app open faster when user click the app icon
// - quit the app by "cmd+q" or right click on the dock icon and select "quit"
// - all browser windows will capture the "close" event
// - the hidden window will close all windows
// - "window-all-closed" event will be emitted and eventually quit the app
if (mainWindow.isFullScreen()) {
mainWindow.once('leave-full-screen', () => {
mainWindow.hide();
});
mainWindow.setFullScreen(false);
} else {
mainWindow.hide();
}
void this.hideMainWindow(mainWindow).catch(err => {
logger.error('Failed to hide main window:', err);
});
}
});
@@ -230,18 +307,42 @@ export class MainWindowManager {
if (IS_DEV) {
// do not gain focus in dev mode
this.backgroundRequested = false;
await this.showDock();
mainWindow.showInactive();
} else if (
!TraySettingsState.value.enabled ||
!TraySettingsState.value.startMinimized
) {
mainWindow.show();
await this.showMainWindow();
}
this.preventMacAppQuit();
return mainWindow;
}
async showMainWindow() {
this.backgroundRequested = false;
const mainWindow = await this.ensureMainWindow();
await this.showDock();
if (mainWindow.isMinimized()) {
mainWindow.restore();
}
mainWindow.show();
mainWindow.focus();
return mainWindow;
}
async closeMainWindowToBackground() {
await this.hideMainWindow(await this.ensureMainWindow());
}
async ensureDockVisible() {
if (!this.shouldHideDock()) {
await this.showDock();
}
}
}
export async function initAndShowMainWindow() {
@@ -253,12 +354,15 @@ export async function getMainWindow() {
}
export async function showMainWindow() {
const window = await getMainWindow();
if (!window) return;
if (window.isMinimized()) {
window.restore();
}
window.focus();
return MainWindowManager.instance.showMainWindow();
}
export async function closeMainWindowToBackground() {
return MainWindowManager.instance.closeMainWindowToBackground();
}
export async function ensureDockVisible() {
return MainWindowManager.instance.ensureDockVisible();
}
const getWindowAdditionalArguments = async () => {
@@ -1,17 +1,14 @@
import { join } from 'node:path';
import { setTimeout } from 'node:timers/promises';
import {
app,
BrowserWindow,
type BrowserWindowConstructorOptions,
} from 'electron';
import { BrowserWindow, type BrowserWindowConstructorOptions } from 'electron';
import { BehaviorSubject } from 'rxjs';
import { popupViewUrl } from '../../shared/internal-origin';
import { logger } from '../logger';
import type { MainEventRegister, NamespaceHandlers } from '../type';
import { buildWebPreferences } from '../web-preferences';
import { ensureDockVisible } from './main-window';
import { getCurrentDisplay } from './utils';
type PopupWindowType = 'notification' | 'recording';
@@ -97,8 +94,7 @@ abstract class PopupWindow {
}),
});
// it seems that the dock will disappear when popup windows are shown
await app.dock?.show();
await ensureDockVisible();
// required to make the window transparent
browserWindow.setBackgroundColor('#00000000');
@@ -8,9 +8,7 @@ const ensureDirSync = vi.fn();
const resolveExistingPathInBase = vi.fn(
async (_base: string, filepath: string) => filepath
);
const getMainWindow = vi.fn(async () => ({
show: vi.fn(),
}));
const showMainWindow = vi.fn(async () => undefined);
const storageState = new Map<string, unknown>();
const watchSubjects = new Map<string, BehaviorSubject<unknown>>();
@@ -103,7 +101,7 @@ beforeEach(() => {
}));
vi.doMock('../../src/main/windows-manager', () => ({
getMainWindow,
showMainWindow,
}));
vi.doMock('../../src/main/windows-manager/popup', () => ({
@@ -245,6 +243,7 @@ describe('recording feature', () => {
await stopPromise;
subscription.unsubscribe();
expect(showMainWindow).toHaveBeenCalledOnce();
expect(getCurrentRecordingStatus()).toMatchObject({
id: started!.id,
status: 'pending_import',
@@ -80,43 +80,47 @@ const MenubarSetting = () => {
/>
</SettingRow>
</SettingWrapper>
{traySetting.enabled && !environment.isMacOs ? (
{traySetting.enabled ? (
<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>
{!environment.isMacOs ? (
<>
<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>
</>
) : null}
<SettingRow
name={t[
'com.affine.appearanceSettings.menubar.windowBehavior.closeToTray.toggle'
@@ -130,21 +134,23 @@ const MenubarSetting = () => {
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>
{!environment.isMacOs ? (
<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>
) : null}
</SettingWrapper>
) : null}
</>
@@ -246,7 +246,7 @@
"com.affine.appearanceSettings.menubar.title": "菜单栏",
"com.affine.appearanceSettings.menubar.toggle": "启用菜单栏应用",
"com.affine.appearanceSettings.menubar.description": "在托盘中显示菜单栏应用程序,以便快速访问 AFFiNE 或会议记录。",
"com.affine.appearanceSettings.menubar.windowBehavior.title": "Windows行为",
"com.affine.appearanceSettings.menubar.windowBehavior.title": "窗口行为",
"com.affine.appearanceSettings.menubar.windowBehavior.openOnLeftClick.toggle": "点击托盘图标时启动",
"com.affine.appearanceSettings.menubar.windowBehavior.openOnLeftClick.description": "点击AFFiNE的系统托盘图标时启动AFFiNE。",
"com.affine.appearanceSettings.menubar.windowBehavior.minimizeToTray.toggle": "最小化到托盘",