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

View File

@@ -7,7 +7,6 @@ import { logger } from './logger';
import { uiSubjects } from './ui'; import { uiSubjects } from './ui';
import { import {
addTabWithUrl, addTabWithUrl,
getMainWindow,
loadUrlInActiveTab, loadUrlInActiveTab,
openUrlInHiddenWindow, openUrlInHiddenWindow,
showMainWindow, showMainWindow,
@@ -74,13 +73,8 @@ export function setupDeepLink(app: App) {
// on windows & linux, we need to listen for the second-instance event // on windows & linux, we need to listen for the second-instance event
app.on('second-instance', (event, commandLine) => { app.on('second-instance', (event, commandLine) => {
getMainWindow() showMainWindow()
.then(window => { .then(() => {
if (!window) {
logger.error('main window is not ready');
return;
}
window.show();
const url = commandLine.pop(); const url = commandLine.pop();
if (url?.startsWith(`${protocol}://`)) { if (url?.startsWith(`${protocol}://`)) {
event.preventDefault(); event.preventDefault();
@@ -149,11 +143,12 @@ async function handleAffineUrl(url: string) {
? await openUrlInHiddenWindow(urlObj) ? await openUrlInHiddenWindow(urlObj)
: await loadUrlInActiveTab(url); : await loadUrlInActiveTab(url);
const main = await getMainWindow(); if (hiddenWindow) {
if (main && hiddenWindow) {
// when hidden window closed, the main window will be hidden somehow // when hidden window closed, the main window will be hidden somehow
hiddenWindow.on('close', () => { hiddenWindow.on('close', () => {
main.show(); void showMainWindow().catch(e => {
logger.error('Failed to restore main window:', e);
});
}); });
} }
} }

View File

@@ -32,7 +32,7 @@ import {
MeetingSettingsSchema, MeetingSettingsSchema,
} from '../shared-state-schema'; } from '../shared-state-schema';
import { globalStateStorage } from '../shared-storage/storage'; import { globalStateStorage } from '../shared-storage/storage';
import { getMainWindow } from '../windows-manager'; import { showMainWindow } from '../windows-manager';
import { popupManager } from '../windows-manager/popup'; import { popupManager } from '../windows-manager/popup';
import { isAppNameAllowed } from './allow-list'; import { isAppNameAllowed } from './allow-list';
import { RecordingCoordinator } from './coordinator'; import { RecordingCoordinator } from './coordinator';
@@ -512,13 +512,7 @@ export async function startRecording(
export async function stopRecording(id: number) { export async function stopRecording(id: number) {
const job = await recordingCoordinator.stop(id); const job = await recordingCoordinator.stop(id);
if (job?.phase === 'recorded') { if (job?.phase === 'recorded') {
void getMainWindow() void showMainWindow().catch(err => {
.then(mainWindow => {
if (mainWindow) {
mainWindow.show();
}
})
.catch(err => {
logger.error('failed to bring up the window', err); logger.error('failed to bring up the window', err);
}); });
} }

View File

@@ -25,7 +25,7 @@ import {
} from '../recording/feature'; } from '../recording/feature';
import { MenubarStateKey, MenubarStateSchema } from '../shared-state-schema'; import { MenubarStateKey, MenubarStateSchema } from '../shared-state-schema';
import { globalStateStorage } from '../shared-storage/storage'; import { globalStateStorage } from '../shared-storage/storage';
import { getMainWindow } from '../windows-manager'; import { showMainWindow } from '../windows-manager';
import { icons } from './icons'; import { icons } from './icons';
export interface TrayMenuConfigItem { export interface TrayMenuConfigItem {
label: string; label: string;
@@ -43,12 +43,10 @@ interface TrayMenuProvider {
getConfig(): TrayMenuConfig; getConfig(): TrayMenuConfig;
} }
function showMainWindow() { function activateMainWindow() {
getMainWindow() void showMainWindow().catch(err => {
.then(w => { logger.error('Failed to show main window:', err);
w.show(); });
})
.catch(err => logger.error('Failed to show main window:', err));
} }
function buildMenuConfig(config: TrayMenuConfig): MenuItemConstructorOptions[] { function buildMenuConfig(config: TrayMenuConfig): MenuItemConstructorOptions[] {
@@ -109,7 +107,7 @@ class TrayState implements Disposable {
icon: icons.journal, icon: icons.journal,
click: () => { click: () => {
logger.info('User action: Open Journal'); logger.info('User action: Open Journal');
showMainWindow(); activateMainWindow();
applicationMenuSubjects.openJournal$.next(); applicationMenuSubjects.openJournal$.next();
}, },
}, },
@@ -118,7 +116,7 @@ class TrayState implements Disposable {
icon: icons.page, icon: icons.page,
click: () => { click: () => {
logger.info('User action: New Page'); logger.info('User action: New Page');
showMainWindow(); activateMainWindow();
applicationMenuSubjects.newPageAction$.next('page'); applicationMenuSubjects.newPageAction$.next('page');
}, },
}, },
@@ -127,7 +125,7 @@ class TrayState implements Disposable {
icon: icons.edgeless, icon: icons.edgeless,
click: () => { click: () => {
logger.info('User action: New Edgeless'); logger.info('User action: New Edgeless');
showMainWindow(); activateMainWindow();
applicationMenuSubjects.newPageAction$.next('edgeless'); applicationMenuSubjects.newPageAction$.next('edgeless');
}, },
}, },
@@ -232,7 +230,7 @@ class TrayState implements Disposable {
items.push({ items.push({
label: `Meetings Settings...`, label: `Meetings Settings...`,
click: () => { click: () => {
showMainWindow(); activateMainWindow();
applicationMenuSubjects.openInSettingModal$.next({ applicationMenuSubjects.openInSettingModal$.next({
activeTab: 'meetings', activeTab: 'meetings',
}); });
@@ -257,19 +255,13 @@ class TrayState implements Disposable {
label: 'Open AFFiNE', label: 'Open AFFiNE',
click: () => { click: () => {
logger.info('User action: Open AFFiNE'); logger.info('User action: Open AFFiNE');
getMainWindow() activateMainWindow();
.then(w => {
w.show();
})
.catch(err => {
logger.error('Failed to open AFFiNE:', err);
});
}, },
}, },
{ {
label: 'Menubar settings...', label: 'Menubar settings...',
click: () => { click: () => {
showMainWindow(); activateMainWindow();
applicationMenuSubjects.openInSettingModal$.next({ applicationMenuSubjects.openInSettingModal$.next({
activeTab: 'appearance', activeTab: 'appearance',
scrollAnchor: 'menubar', scrollAnchor: 'menubar',
@@ -279,7 +271,7 @@ class TrayState implements Disposable {
{ {
label: `About ${app.getName()}`, label: `About ${app.getName()}`,
click: () => { click: () => {
showMainWindow(); activateMainWindow();
applicationMenuSubjects.openInSettingModal$.next({ applicationMenuSubjects.openInSettingModal$.next({
activeTab: 'about', activeTab: 'about',
}); });
@@ -329,7 +321,7 @@ class TrayState implements Disposable {
TraySettingsState.value.enabled && TraySettingsState.value.enabled &&
TraySettingsState.value.openOnLeftClick TraySettingsState.value.openOnLeftClick
) { ) {
showMainWindow(); activateMainWindow();
} else { } else {
this.tray?.popUpContextMenu(); this.tray?.popUpContextMenu();
} }

View File

@@ -14,6 +14,7 @@ import type { NamespaceHandlers } from '../type';
import { import {
activateView, activateView,
addTab, addTab,
closeMainWindowToBackground,
closeTab, closeTab,
ensureTabLoaded, ensureTabLoaded,
getMainWindow, getMainWindow,
@@ -104,8 +105,7 @@ export const uiHandlers = {
TraySettingsState.value.enabled && TraySettingsState.value.enabled &&
TraySettingsState.value.closeToTray TraySettingsState.value.closeToTray
) { ) {
const window = await getMainWindow(); await closeMainWindowToBackground();
window?.hide();
} else { } else {
app.quit(); app.quit();
} }

View File

@@ -1,6 +1,6 @@
import { join } from 'node:path'; import { join } from 'node:path';
import { BrowserWindow, nativeTheme } from 'electron'; import { app, BrowserWindow, nativeTheme } from 'electron';
import electronWindowState from 'electron-window-state'; import electronWindowState from 'electron-window-state';
import { BehaviorSubject, map, shareReplay } from 'rxjs'; import { BehaviorSubject, map, shareReplay } from 'rxjs';
@@ -17,6 +17,8 @@ import { buildWebPreferences } from '../web-preferences';
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 DOCK_VISIBILITY_THROTTLE_MS = 1100;
const FULL_SCREEN_EXIT_TIMEOUT_MS = 2000;
const TraySettingsState = { const TraySettingsState = {
$: globalStateStorage.watch<MenubarStateSchema>(MenubarStateKey).pipe( $: globalStateStorage.watch<MenubarStateSchema>(MenubarStateKey).pipe(
@@ -44,7 +46,26 @@ export class MainWindowManager {
mainWindowReady: Promise<BrowserWindow> | undefined; mainWindowReady: Promise<BrowserWindow> | undefined;
mainWindow$ = new BehaviorSubject<BrowserWindow | undefined>(undefined); mainWindow$ = new BehaviorSubject<BrowserWindow | undefined>(undefined);
private backgroundRequested = false;
private hiddenMacWindow: BrowserWindow | undefined; 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() { get mainWindow() {
return this.mainWindow$.value; return this.mainWindow$.value;
@@ -66,6 +87,7 @@ export class MainWindowManager {
} }
private cleanupWindows() { private cleanupWindows() {
this.cancelPendingDockHide();
closeAllWindows(); closeAllWindows();
this.mainWindowReady = undefined; this.mainWindowReady = undefined;
this.mainWindow$.next(undefined); this.mainWindow$.next(undefined);
@@ -73,6 +95,81 @@ export class MainWindowManager {
this.hiddenMacWindow = undefined; 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() { private async createMainWindow() {
logger.info('create window'); logger.info('create window');
const mainWindowState = electronWindowState({ const mainWindowState = electronWindowState({
@@ -132,10 +229,6 @@ export class MainWindowManager {
uiSubjects.onFullScreen$.next(mainWindow.isFullScreen()); uiSubjects.onFullScreen$.next(mainWindow.isFullScreen());
}); });
beforeAppQuit(() => {
this.cleanupWindows();
});
mainWindow.on('close', e => { mainWindow.on('close', e => {
// 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();
@@ -151,25 +244,9 @@ export class MainWindowManager {
this.mainWindow$.next(undefined); this.mainWindow$.next(undefined);
} }
} else { } else {
// hide window on macOS void this.hideMainWindow(mainWindow).catch(err => {
// application quit will be handled by closing the hidden window logger.error('Failed to hide main window:', err);
//
// 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();
}
} }
}); });
@@ -230,18 +307,42 @@ export class MainWindowManager {
if (IS_DEV) { if (IS_DEV) {
// do not gain focus in dev mode // do not gain focus in dev mode
this.backgroundRequested = false;
await this.showDock();
mainWindow.showInactive(); mainWindow.showInactive();
} else if ( } else if (
!TraySettingsState.value.enabled || !TraySettingsState.value.enabled ||
!TraySettingsState.value.startMinimized !TraySettingsState.value.startMinimized
) { ) {
mainWindow.show(); await this.showMainWindow();
} }
this.preventMacAppQuit(); this.preventMacAppQuit();
return mainWindow; 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() { export async function initAndShowMainWindow() {
@@ -253,12 +354,15 @@ export async function getMainWindow() {
} }
export async function showMainWindow() { export async function showMainWindow() {
const window = await getMainWindow(); return MainWindowManager.instance.showMainWindow();
if (!window) return; }
if (window.isMinimized()) {
window.restore(); export async function closeMainWindowToBackground() {
} return MainWindowManager.instance.closeMainWindowToBackground();
window.focus(); }
export async function ensureDockVisible() {
return MainWindowManager.instance.ensureDockVisible();
} }
const getWindowAdditionalArguments = async () => { const getWindowAdditionalArguments = async () => {

View File

@@ -1,17 +1,14 @@
import { join } from 'node:path'; import { join } from 'node:path';
import { setTimeout } from 'node:timers/promises'; import { setTimeout } from 'node:timers/promises';
import { import { BrowserWindow, type BrowserWindowConstructorOptions } from 'electron';
app,
BrowserWindow,
type BrowserWindowConstructorOptions,
} from 'electron';
import { BehaviorSubject } from 'rxjs'; import { BehaviorSubject } from 'rxjs';
import { popupViewUrl } from '../../shared/internal-origin'; import { popupViewUrl } from '../../shared/internal-origin';
import { logger } from '../logger'; import { logger } from '../logger';
import type { MainEventRegister, NamespaceHandlers } from '../type'; import type { MainEventRegister, NamespaceHandlers } from '../type';
import { buildWebPreferences } from '../web-preferences'; import { buildWebPreferences } from '../web-preferences';
import { ensureDockVisible } from './main-window';
import { getCurrentDisplay } from './utils'; import { getCurrentDisplay } from './utils';
type PopupWindowType = 'notification' | 'recording'; type PopupWindowType = 'notification' | 'recording';
@@ -97,8 +94,7 @@ abstract class PopupWindow {
}), }),
}); });
// it seems that the dock will disappear when popup windows are shown await ensureDockVisible();
await app.dock?.show();
// required to make the window transparent // required to make the window transparent
browserWindow.setBackgroundColor('#00000000'); browserWindow.setBackgroundColor('#00000000');

View File

@@ -8,9 +8,7 @@ const ensureDirSync = vi.fn();
const resolveExistingPathInBase = vi.fn( const resolveExistingPathInBase = vi.fn(
async (_base: string, filepath: string) => filepath async (_base: string, filepath: string) => filepath
); );
const getMainWindow = vi.fn(async () => ({ const showMainWindow = vi.fn(async () => undefined);
show: vi.fn(),
}));
const storageState = new Map<string, unknown>(); const storageState = new Map<string, unknown>();
const watchSubjects = new Map<string, BehaviorSubject<unknown>>(); const watchSubjects = new Map<string, BehaviorSubject<unknown>>();
@@ -103,7 +101,7 @@ beforeEach(() => {
})); }));
vi.doMock('../../src/main/windows-manager', () => ({ vi.doMock('../../src/main/windows-manager', () => ({
getMainWindow, showMainWindow,
})); }));
vi.doMock('../../src/main/windows-manager/popup', () => ({ vi.doMock('../../src/main/windows-manager/popup', () => ({
@@ -245,6 +243,7 @@ describe('recording feature', () => {
await stopPromise; await stopPromise;
subscription.unsubscribe(); subscription.unsubscribe();
expect(showMainWindow).toHaveBeenCalledOnce();
expect(getCurrentRecordingStatus()).toMatchObject({ expect(getCurrentRecordingStatus()).toMatchObject({
id: started!.id, id: started!.id,
status: 'pending_import', status: 'pending_import',

View File

@@ -80,13 +80,15 @@ const MenubarSetting = () => {
/> />
</SettingRow> </SettingRow>
</SettingWrapper> </SettingWrapper>
{traySetting.enabled && !environment.isMacOs ? ( {traySetting.enabled ? (
<SettingWrapper <SettingWrapper
id="windowBehavior" id="windowBehavior"
title={t[ title={t[
'com.affine.appearanceSettings.menubar.windowBehavior.title' 'com.affine.appearanceSettings.menubar.windowBehavior.title'
]()} ]()}
> >
{!environment.isMacOs ? (
<>
<SettingRow <SettingRow
name={t[ name={t[
'com.affine.appearanceSettings.menubar.windowBehavior.openOnLeftClick.toggle' 'com.affine.appearanceSettings.menubar.windowBehavior.openOnLeftClick.toggle'
@@ -117,6 +119,8 @@ const MenubarSetting = () => {
} }
/> />
</SettingRow> </SettingRow>
</>
) : null}
<SettingRow <SettingRow
name={t[ name={t[
'com.affine.appearanceSettings.menubar.windowBehavior.closeToTray.toggle' 'com.affine.appearanceSettings.menubar.windowBehavior.closeToTray.toggle'
@@ -130,6 +134,7 @@ const MenubarSetting = () => {
onChange={checked => traySettingService.setCloseToTray(checked)} onChange={checked => traySettingService.setCloseToTray(checked)}
/> />
</SettingRow> </SettingRow>
{!environment.isMacOs ? (
<SettingRow <SettingRow
name={t[ name={t[
'com.affine.appearanceSettings.menubar.windowBehavior.startMinimized.toggle' 'com.affine.appearanceSettings.menubar.windowBehavior.startMinimized.toggle'
@@ -145,6 +150,7 @@ const MenubarSetting = () => {
} }
/> />
</SettingRow> </SettingRow>
) : null}
</SettingWrapper> </SettingWrapper>
) : null} ) : null}
</> </>

View File

@@ -246,7 +246,7 @@
"com.affine.appearanceSettings.menubar.title": "菜单栏", "com.affine.appearanceSettings.menubar.title": "菜单栏",
"com.affine.appearanceSettings.menubar.toggle": "启用菜单栏应用", "com.affine.appearanceSettings.menubar.toggle": "启用菜单栏应用",
"com.affine.appearanceSettings.menubar.description": "在托盘中显示菜单栏应用程序,以便快速访问 AFFiNE 或会议记录。", "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.toggle": "点击托盘图标时启动",
"com.affine.appearanceSettings.menubar.windowBehavior.openOnLeftClick.description": "点击AFFiNE的系统托盘图标时启动AFFiNE。", "com.affine.appearanceSettings.menubar.windowBehavior.openOnLeftClick.description": "点击AFFiNE的系统托盘图标时启动AFFiNE。",
"com.affine.appearanceSettings.menubar.windowBehavior.minimizeToTray.toggle": "最小化到托盘", "com.affine.appearanceSettings.menubar.windowBehavior.minimizeToTray.toggle": "最小化到托盘",