mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-16 13:03:31 +08:00
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:
parent
000cfd4e9e
commit
3f1312d40d
@ -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()
|
||||
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@ -17,7 +17,7 @@ export function acquireSingleInstanceLock(
|
||||
}
|
||||
|
||||
app.on('second-instance', () => {
|
||||
showMainWindow(getMainWindow())
|
||||
showMainWindow(getMainWindow(), app)
|
||||
})
|
||||
|
||||
return true
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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()
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user