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.
This commit is contained in:
程序员阿江(Relakkes) 2026-06-03 09:06:05 +08:00
parent 000cfd4e9e
commit 3f1312d40d
7 changed files with 115 additions and 25 deletions

View File

@ -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()

View File

@ -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)
})
})

View File

@ -17,7 +17,7 @@ export function acquireSingleInstanceLock(
}
app.on('second-instance', () => {
showMainWindow(getMainWindow())
showMainWindow(getMainWindow(), app)
})
return true

View File

@ -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 {

View File

@ -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,

View File

@ -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)

View File

@ -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<Pick<Display, 'bounds' | 'workArea'>>,
): 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<Pick<Display, 'bounds' | 'workArea'>>,
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()