From 3f1312d40ded2dbf3c22e55f164f989fafb4f3ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Wed, 3 Jun 2026 09:06:05 +0800 Subject: [PATCH] fix(desktop): prevent macOS menu-bar artifacts on external displays Electron was showing the main BrowserWindow before renderer load completed, so macOS could paint a blank window surface behind the menu bar. Persisted y=0 window state could also restore the titlebar under the menu-bar work area on external displays. Delay the initial show until the renderer entry is loaded, clamp restored macOS bounds into the display workArea, skip the macOS status-bar tray, and unhide the app before focusing restored windows. Constraint: macOS external displays expose menu-bar and status-item chrome differently from Windows and Linux. Rejected: Remove the native application menu | it would break expected macOS menu behavior without fixing the pre-load window surface. Confidence: high Scope-risk: narrow Directive: Do not reintroduce pre-load main-window show without validating macOS external-display menu-bar rendering. Tested: cd desktop && bun test electron/services/windows.test.ts electron/services/tray.test.ts electron/services/singleInstance.test.ts electron/services/menu.test.ts Tested: bun run check:desktop Tested: bun run check:native Tested: git diff --check Not-tested: Live dual-external-monitor screenshot smoke. --- desktop/electron/main.ts | 36 +++++++++---------- .../electron/services/singleInstance.test.ts | 20 +++++++++-- desktop/electron/services/singleInstance.ts | 2 +- desktop/electron/services/tray.test.ts | 8 ++++- desktop/electron/services/tray.ts | 4 +++ desktop/electron/services/windows.test.ts | 34 +++++++++++++++++- desktop/electron/services/windows.ts | 36 +++++++++++++++++-- 7 files changed, 115 insertions(+), 25 deletions(-) diff --git a/desktop/electron/main.ts b/desktop/electron/main.ts index 15d072ea..352fa19b 100644 --- a/desktop/electron/main.ts +++ b/desktop/electron/main.ts @@ -13,7 +13,7 @@ import { } from './services/notifications' import { installApplicationMenu } from './services/menu' import { acquireSingleInstanceLock } from './services/singleInstance' -import { installTray, type TrayController } from './services/tray' +import { installTray, shouldInstallTray, type TrayController } from './services/tray' import { ElectronUpdaterService } from './services/updater' import { createUpdateSmokeUpdaterFromEnv } from './services/updateSmoke' import { ElectronTerminalService, type TerminalSpawnInput } from './services/terminal' @@ -161,7 +161,7 @@ function unsupported(name: string): never { } function emitNotificationAction(payload: unknown) { - showMainWindow(mainWindow) + showMainWindow(mainWindow, app) mainWindow?.webContents.send(ELECTRON_EVENT_CHANNELS.notificationAction, payload) } @@ -314,8 +314,6 @@ async function createMainWindow() { }) writeWindowSmokeSnapshot(mainWindow, 'after-create') - showMainWindow(mainWindow) - writeWindowSmokeSnapshot(mainWindow, 'after-initial-show') const entry = rendererEntry() if (/^https?:\/\//.test(entry)) { @@ -325,7 +323,7 @@ async function createMainWindow() { } restoreWindowMaximized(mainWindow, restoredState) - showMainWindow(mainWindow) + showMainWindow(mainWindow, app) writeWindowSmokeSnapshot(mainWindow, 'after-final-show') } @@ -341,18 +339,20 @@ app.whenReady().then(async () => { console.error('[desktop] failed to start Electron server sidecar', error) }) await installApplicationMenu(app, () => mainWindow) - trayController = await installTray({ - app, - desktopRoot: appRoot(), - show: () => showMainWindow(mainWindow), - quit: () => { - isQuitting = true - app.quit() - }, - }).catch(error => { - console.error('[desktop] failed to create Electron tray', error) - return null - }) + if (shouldInstallTray(process.platform)) { + trayController = await installTray({ + app, + desktopRoot: appRoot(), + show: () => showMainWindow(mainWindow, app), + quit: () => { + isQuitting = true + app.quit() + }, + }).catch(error => { + console.error('[desktop] failed to create Electron tray', error) + return null + }) + } await createMainWindow() scheduleNotificationSmoke({ env: process.env, @@ -362,7 +362,7 @@ app.whenReady().then(async () => { app.on('activate', () => { if (mainWindow) { - showMainWindow(mainWindow) + showMainWindow(mainWindow, app) return } void createMainWindow() diff --git a/desktop/electron/services/singleInstance.test.ts b/desktop/electron/services/singleInstance.test.ts index 35cdf81f..6966d184 100644 --- a/desktop/electron/services/singleInstance.test.ts +++ b/desktop/electron/services/singleInstance.test.ts @@ -30,13 +30,29 @@ describe('Electron single-instance service', () => { }) it('registers a second-instance focus handler after acquiring the lock', () => { + let secondInstanceHandler: (() => void) | undefined + const window = { + isVisible: () => false, + isMinimized: () => false, + show: vi.fn(), + restore: vi.fn(), + focus: vi.fn(), + } const app = { requestSingleInstanceLock: vi.fn(() => true), quit: vi.fn(), - on: vi.fn(), + show: vi.fn(), + on: vi.fn((_event: string, handler: () => void) => { + secondInstanceHandler = handler + }), } - expect(acquireSingleInstanceLock(app as never, () => null)).toBe(true) + expect(acquireSingleInstanceLock(app as never, () => window as never)).toBe(true) expect(app.on).toHaveBeenCalledWith('second-instance', expect.any(Function)) + + secondInstanceHandler?.() + expect(app.show).toHaveBeenCalledTimes(1) + expect(window.show).toHaveBeenCalledTimes(1) + expect(window.focus).toHaveBeenCalledTimes(1) }) }) diff --git a/desktop/electron/services/singleInstance.ts b/desktop/electron/services/singleInstance.ts index 026f620b..943decfe 100644 --- a/desktop/electron/services/singleInstance.ts +++ b/desktop/electron/services/singleInstance.ts @@ -17,7 +17,7 @@ export function acquireSingleInstanceLock( } app.on('second-instance', () => { - showMainWindow(getMainWindow()) + showMainWindow(getMainWindow(), app) }) return true diff --git a/desktop/electron/services/tray.test.ts b/desktop/electron/services/tray.test.ts index f342c93c..a4313581 100644 --- a/desktop/electron/services/tray.test.ts +++ b/desktop/electron/services/tray.test.ts @@ -2,7 +2,7 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' import path from 'node:path' import { tmpdir } from 'node:os' import { afterEach, describe, expect, it, vi } from 'vitest' -import { installTray, resolveTrayIconPath } from './tray' +import { installTray, resolveTrayIconPath, shouldInstallTray } from './tray' const trayMocksKey = '__electronTrayMocks' @@ -68,6 +68,12 @@ describe('Electron tray service', () => { } }) + it('skips the status-bar tray on macOS and keeps it on Windows and Linux', () => { + expect(shouldInstallTray('darwin')).toBe(false) + expect(shouldInstallTray('win32')).toBe(true) + expect(shouldInstallTray('linux')).toBe(true) + }) + it('installs tray handlers that show the app, quit explicitly, and dispose cleanly', async () => { const root = mkdtempSync(path.join(tmpdir(), 'electron-tray-install-')) try { diff --git a/desktop/electron/services/tray.ts b/desktop/electron/services/tray.ts index eb0b6a3b..fc85dd85 100644 --- a/desktop/electron/services/tray.ts +++ b/desktop/electron/services/tray.ts @@ -20,6 +20,10 @@ export function resolveTrayIconPath(desktopRoot: string): string { return resolved } +export function shouldInstallTray(platform = process.platform): boolean { + return platform !== 'darwin' +} + export async function installTray({ app, desktopRoot, diff --git a/desktop/electron/services/windows.test.ts b/desktop/electron/services/windows.test.ts index b2c1cadd..ae3336ca 100644 --- a/desktop/electron/services/windows.test.ts +++ b/desktop/electron/services/windows.test.ts @@ -3,6 +3,7 @@ import path from 'node:path' import { tmpdir } from 'node:os' import { describe, expect, it, vi } from 'vitest' import { + clampWindowStateToVisibleWorkArea, captureWindowState, hideWindowSafely, hasMeaningfulIntersection, @@ -62,17 +63,44 @@ describe('Electron window service', () => { app as never, [{ bounds: { x: 0, y: 0, width: 1440, height: 900 }, workArea: { x: 0, y: 0, width: 1440, height: 860 } }], {}, + 'linux', )).toEqual(state) expect(readWindowState( app as never, [{ bounds: { x: 3000, y: 3000, width: 1440, height: 900 }, workArea: { x: 3000, y: 3000, width: 1440, height: 860 } }], {}, + 'linux', )).toBeNull() } finally { rmSync(tmp, { recursive: true, force: true }) } }) + it('clamps restored macOS windows below the menu bar work area', () => { + const tmp = mkdtempSync(path.join(tmpdir(), 'electron-window-state-clamp-')) + try { + const app = fakeApp(tmp) + const state = { x: 620, y: 0, width: 1280, height: 820, maximized: false } + const display = { + bounds: { x: 0, y: 0, width: 2560, height: 1440 }, + workArea: { x: 0, y: 40, width: 2560, height: 1320 }, + } + writeWindowState(app as never, state, {}) + + expect(clampWindowStateToVisibleWorkArea(state, [display])).toEqual({ + ...state, + y: 40, + }) + expect(readWindowState(app as never, [display], {}, 'darwin')).toEqual({ + ...state, + y: 40, + }) + expect(readWindowState(app as never, [display], {}, 'linux')).toEqual(state) + } finally { + rmSync(tmp, { recursive: true, force: true }) + } + }) + it('does not capture minimized windows', () => { const window = { isMinimized: () => true, @@ -106,9 +134,13 @@ describe('Electron window service', () => { restore: vi.fn(), focus: vi.fn(), } + const app = { + show: vi.fn(), + } - showMainWindow(window as never) + showMainWindow(window as never, app) + expect(app.show).toHaveBeenCalledTimes(1) expect(window.show).toHaveBeenCalledTimes(1) expect(window.restore).toHaveBeenCalledTimes(1) expect(window.focus).toHaveBeenCalledTimes(1) diff --git a/desktop/electron/services/windows.ts b/desktop/electron/services/windows.ts index 7d5cf667..a3f01839 100644 --- a/desktop/electron/services/windows.ts +++ b/desktop/electron/services/windows.ts @@ -58,10 +58,35 @@ export function isWindowStateVisibleOnAnyDisplay( ) } +function clamp(value: number, min: number, max: number): number { + return Math.min(Math.max(value, min), max) +} + +export function clampWindowStateToVisibleWorkArea( + state: StoredWindowState, + displays: Array>, +): StoredWindowState { + const display = displays.find(candidate => + hasMeaningfulIntersection(state, candidate.workArea ?? candidate.bounds), + ) + if (!display) return state + + const workArea = display.workArea ?? display.bounds + const maxX = workArea.x + Math.max(0, workArea.width - state.width) + const maxY = workArea.y + Math.max(0, workArea.height - state.height) + + return { + ...state, + x: clamp(state.x, workArea.x, maxX), + y: clamp(state.y, workArea.y, maxY), + } +} + export function readWindowState( app: App, displays: Array>, env: NodeJS.ProcessEnv = process.env, + platform = process.platform, ): StoredWindowState | null { const statePath = windowStatePath(app, env) if (!existsSync(statePath)) return null @@ -70,7 +95,9 @@ export function readWindowState( const parsed = JSON.parse(readFileSync(statePath, 'utf-8')) as StoredWindowState if (!isPersistableWindowState(parsed)) return null if (!isWindowStateVisibleOnAnyDisplay(parsed, displays)) return null - return parsed + return platform === 'darwin' + ? clampWindowStateToVisibleWorkArea(parsed, displays) + : parsed } catch (error) { console.error(`[desktop] failed to read Electron window state ${statePath}:`, error) return null @@ -147,8 +174,13 @@ export function toggleWindowFullScreen(window: BrowserWindow, platform = process window.setFullScreen(!window.isFullScreen()) } -export function showMainWindow(window: BrowserWindow | null) { +type MacOsWindowVisibilityApp = { + show?: () => void +} + +export function showMainWindow(window: BrowserWindow | null, app?: MacOsWindowVisibilityApp) { if (!window) return + app?.show?.() if (!window.isVisible()) window.show() if (window.isMinimized()) window.restore() window.focus()