diff --git a/packages/frontend/apps/electron/src/main/deep-link.ts b/packages/frontend/apps/electron/src/main/deep-link.ts index 56bef034af..b2d18be848 100644 --- a/packages/frontend/apps/electron/src/main/deep-link.ts +++ b/packages/frontend/apps/electron/src/main/deep-link.ts @@ -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); + }); }); } } diff --git a/packages/frontend/apps/electron/src/main/recording/feature.ts b/packages/frontend/apps/electron/src/main/recording/feature.ts index dd0121b16c..9afe2b37e1 100644 --- a/packages/frontend/apps/electron/src/main/recording/feature.ts +++ b/packages/frontend/apps/electron/src/main/recording/feature.ts @@ -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()); } diff --git a/packages/frontend/apps/electron/src/main/tray/index.ts b/packages/frontend/apps/electron/src/main/tray/index.ts index c295e3330c..c1376880e7 100644 --- a/packages/frontend/apps/electron/src/main/tray/index.ts +++ b/packages/frontend/apps/electron/src/main/tray/index.ts @@ -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(); } diff --git a/packages/frontend/apps/electron/src/main/ui/handlers.ts b/packages/frontend/apps/electron/src/main/ui/handlers.ts index ec2d052121..88dfec4173 100644 --- a/packages/frontend/apps/electron/src/main/ui/handlers.ts +++ b/packages/frontend/apps/electron/src/main/ui/handlers.ts @@ -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(); } 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 5efa1cf0bf..15765e20b2 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 @@ -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(MenubarStateKey).pipe( @@ -44,7 +46,26 @@ export class MainWindowManager { mainWindowReady: Promise | undefined; mainWindow$ = new BehaviorSubject(undefined); + private backgroundRequested = false; private hiddenMacWindow: BrowserWindow | undefined; + private lastDockShowAt = 0; + private pendingDockHide: ReturnType | 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(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 () => { diff --git a/packages/frontend/apps/electron/src/main/windows-manager/popup.ts b/packages/frontend/apps/electron/src/main/windows-manager/popup.ts index d189fef2c2..80da5783f0 100644 --- a/packages/frontend/apps/electron/src/main/windows-manager/popup.ts +++ b/packages/frontend/apps/electron/src/main/windows-manager/popup.ts @@ -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'); diff --git a/packages/frontend/apps/electron/test/main/recording-feature.spec.ts b/packages/frontend/apps/electron/test/main/recording-feature.spec.ts index 7fe0c7179a..a9220902eb 100644 --- a/packages/frontend/apps/electron/test/main/recording-feature.spec.ts +++ b/packages/frontend/apps/electron/test/main/recording-feature.spec.ts @@ -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(); const watchSubjects = new Map>(); @@ -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', 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 c7016e01c9..bdb1ff27a5 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 @@ -80,43 +80,47 @@ const MenubarSetting = () => { /> - {traySetting.enabled && !environment.isMacOs ? ( + {traySetting.enabled ? ( - - - traySettingService.setOpenOnLeftClick(checked) - } - /> - - - - traySettingService.setMinimizeToTray(checked) - } - /> - + {!environment.isMacOs ? ( + <> + + + traySettingService.setOpenOnLeftClick(checked) + } + /> + + + + traySettingService.setMinimizeToTray(checked) + } + /> + + + ) : null} { onChange={checked => traySettingService.setCloseToTray(checked)} /> - - - traySettingService.setStartMinimized(checked) - } - /> - + {!environment.isMacOs ? ( + + + traySettingService.setStartMinimized(checked) + } + /> + + ) : null} ) : null} diff --git a/packages/frontend/i18n/src/resources/zh-Hans.json b/packages/frontend/i18n/src/resources/zh-Hans.json index 787c28b04a..68b4282e97 100644 --- a/packages/frontend/i18n/src/resources/zh-Hans.json +++ b/packages/frontend/i18n/src/resources/zh-Hans.json @@ -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": "最小化到托盘",