mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-19 13:33:35 +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'
|
} from './services/notifications'
|
||||||
import { installApplicationMenu } from './services/menu'
|
import { installApplicationMenu } from './services/menu'
|
||||||
import { acquireSingleInstanceLock } from './services/singleInstance'
|
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 { ElectronUpdaterService } from './services/updater'
|
||||||
import { createUpdateSmokeUpdaterFromEnv } from './services/updateSmoke'
|
import { createUpdateSmokeUpdaterFromEnv } from './services/updateSmoke'
|
||||||
import { ElectronTerminalService, type TerminalSpawnInput } from './services/terminal'
|
import { ElectronTerminalService, type TerminalSpawnInput } from './services/terminal'
|
||||||
@ -161,7 +161,7 @@ function unsupported(name: string): never {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function emitNotificationAction(payload: unknown) {
|
function emitNotificationAction(payload: unknown) {
|
||||||
showMainWindow(mainWindow)
|
showMainWindow(mainWindow, app)
|
||||||
mainWindow?.webContents.send(ELECTRON_EVENT_CHANNELS.notificationAction, payload)
|
mainWindow?.webContents.send(ELECTRON_EVENT_CHANNELS.notificationAction, payload)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -314,8 +314,6 @@ async function createMainWindow() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
writeWindowSmokeSnapshot(mainWindow, 'after-create')
|
writeWindowSmokeSnapshot(mainWindow, 'after-create')
|
||||||
showMainWindow(mainWindow)
|
|
||||||
writeWindowSmokeSnapshot(mainWindow, 'after-initial-show')
|
|
||||||
|
|
||||||
const entry = rendererEntry()
|
const entry = rendererEntry()
|
||||||
if (/^https?:\/\//.test(entry)) {
|
if (/^https?:\/\//.test(entry)) {
|
||||||
@ -325,7 +323,7 @@ async function createMainWindow() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
restoreWindowMaximized(mainWindow, restoredState)
|
restoreWindowMaximized(mainWindow, restoredState)
|
||||||
showMainWindow(mainWindow)
|
showMainWindow(mainWindow, app)
|
||||||
writeWindowSmokeSnapshot(mainWindow, 'after-final-show')
|
writeWindowSmokeSnapshot(mainWindow, 'after-final-show')
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -341,18 +339,20 @@ app.whenReady().then(async () => {
|
|||||||
console.error('[desktop] failed to start Electron server sidecar', error)
|
console.error('[desktop] failed to start Electron server sidecar', error)
|
||||||
})
|
})
|
||||||
await installApplicationMenu(app, () => mainWindow)
|
await installApplicationMenu(app, () => mainWindow)
|
||||||
trayController = await installTray({
|
if (shouldInstallTray(process.platform)) {
|
||||||
app,
|
trayController = await installTray({
|
||||||
desktopRoot: appRoot(),
|
app,
|
||||||
show: () => showMainWindow(mainWindow),
|
desktopRoot: appRoot(),
|
||||||
quit: () => {
|
show: () => showMainWindow(mainWindow, app),
|
||||||
isQuitting = true
|
quit: () => {
|
||||||
app.quit()
|
isQuitting = true
|
||||||
},
|
app.quit()
|
||||||
}).catch(error => {
|
},
|
||||||
console.error('[desktop] failed to create Electron tray', error)
|
}).catch(error => {
|
||||||
return null
|
console.error('[desktop] failed to create Electron tray', error)
|
||||||
})
|
return null
|
||||||
|
})
|
||||||
|
}
|
||||||
await createMainWindow()
|
await createMainWindow()
|
||||||
scheduleNotificationSmoke({
|
scheduleNotificationSmoke({
|
||||||
env: process.env,
|
env: process.env,
|
||||||
@ -362,7 +362,7 @@ app.whenReady().then(async () => {
|
|||||||
|
|
||||||
app.on('activate', () => {
|
app.on('activate', () => {
|
||||||
if (mainWindow) {
|
if (mainWindow) {
|
||||||
showMainWindow(mainWindow)
|
showMainWindow(mainWindow, app)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
void createMainWindow()
|
void createMainWindow()
|
||||||
|
|||||||
@ -30,13 +30,29 @@ describe('Electron single-instance service', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('registers a second-instance focus handler after acquiring the lock', () => {
|
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 = {
|
const app = {
|
||||||
requestSingleInstanceLock: vi.fn(() => true),
|
requestSingleInstanceLock: vi.fn(() => true),
|
||||||
quit: vi.fn(),
|
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))
|
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', () => {
|
app.on('second-instance', () => {
|
||||||
showMainWindow(getMainWindow())
|
showMainWindow(getMainWindow(), app)
|
||||||
})
|
})
|
||||||
|
|
||||||
return true
|
return true
|
||||||
|
|||||||
@ -2,7 +2,7 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
|||||||
import path from 'node:path'
|
import path from 'node:path'
|
||||||
import { tmpdir } from 'node:os'
|
import { tmpdir } from 'node:os'
|
||||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||||
import { installTray, resolveTrayIconPath } from './tray'
|
import { installTray, resolveTrayIconPath, shouldInstallTray } from './tray'
|
||||||
|
|
||||||
const trayMocksKey = '__electronTrayMocks'
|
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 () => {
|
it('installs tray handlers that show the app, quit explicitly, and dispose cleanly', async () => {
|
||||||
const root = mkdtempSync(path.join(tmpdir(), 'electron-tray-install-'))
|
const root = mkdtempSync(path.join(tmpdir(), 'electron-tray-install-'))
|
||||||
try {
|
try {
|
||||||
|
|||||||
@ -20,6 +20,10 @@ export function resolveTrayIconPath(desktopRoot: string): string {
|
|||||||
return resolved
|
return resolved
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function shouldInstallTray(platform = process.platform): boolean {
|
||||||
|
return platform !== 'darwin'
|
||||||
|
}
|
||||||
|
|
||||||
export async function installTray({
|
export async function installTray({
|
||||||
app,
|
app,
|
||||||
desktopRoot,
|
desktopRoot,
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import path from 'node:path'
|
|||||||
import { tmpdir } from 'node:os'
|
import { tmpdir } from 'node:os'
|
||||||
import { describe, expect, it, vi } from 'vitest'
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
import {
|
import {
|
||||||
|
clampWindowStateToVisibleWorkArea,
|
||||||
captureWindowState,
|
captureWindowState,
|
||||||
hideWindowSafely,
|
hideWindowSafely,
|
||||||
hasMeaningfulIntersection,
|
hasMeaningfulIntersection,
|
||||||
@ -62,17 +63,44 @@ describe('Electron window service', () => {
|
|||||||
app as never,
|
app as never,
|
||||||
[{ bounds: { x: 0, y: 0, width: 1440, height: 900 }, workArea: { x: 0, y: 0, width: 1440, height: 860 } }],
|
[{ bounds: { x: 0, y: 0, width: 1440, height: 900 }, workArea: { x: 0, y: 0, width: 1440, height: 860 } }],
|
||||||
{},
|
{},
|
||||||
|
'linux',
|
||||||
)).toEqual(state)
|
)).toEqual(state)
|
||||||
expect(readWindowState(
|
expect(readWindowState(
|
||||||
app as never,
|
app as never,
|
||||||
[{ bounds: { x: 3000, y: 3000, width: 1440, height: 900 }, workArea: { x: 3000, y: 3000, width: 1440, height: 860 } }],
|
[{ bounds: { x: 3000, y: 3000, width: 1440, height: 900 }, workArea: { x: 3000, y: 3000, width: 1440, height: 860 } }],
|
||||||
{},
|
{},
|
||||||
|
'linux',
|
||||||
)).toBeNull()
|
)).toBeNull()
|
||||||
} finally {
|
} finally {
|
||||||
rmSync(tmp, { recursive: true, force: true })
|
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', () => {
|
it('does not capture minimized windows', () => {
|
||||||
const window = {
|
const window = {
|
||||||
isMinimized: () => true,
|
isMinimized: () => true,
|
||||||
@ -106,9 +134,13 @@ describe('Electron window service', () => {
|
|||||||
restore: vi.fn(),
|
restore: vi.fn(),
|
||||||
focus: 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.show).toHaveBeenCalledTimes(1)
|
||||||
expect(window.restore).toHaveBeenCalledTimes(1)
|
expect(window.restore).toHaveBeenCalledTimes(1)
|
||||||
expect(window.focus).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(
|
export function readWindowState(
|
||||||
app: App,
|
app: App,
|
||||||
displays: Array<Pick<Display, 'bounds' | 'workArea'>>,
|
displays: Array<Pick<Display, 'bounds' | 'workArea'>>,
|
||||||
env: NodeJS.ProcessEnv = process.env,
|
env: NodeJS.ProcessEnv = process.env,
|
||||||
|
platform = process.platform,
|
||||||
): StoredWindowState | null {
|
): StoredWindowState | null {
|
||||||
const statePath = windowStatePath(app, env)
|
const statePath = windowStatePath(app, env)
|
||||||
if (!existsSync(statePath)) return null
|
if (!existsSync(statePath)) return null
|
||||||
@ -70,7 +95,9 @@ export function readWindowState(
|
|||||||
const parsed = JSON.parse(readFileSync(statePath, 'utf-8')) as StoredWindowState
|
const parsed = JSON.parse(readFileSync(statePath, 'utf-8')) as StoredWindowState
|
||||||
if (!isPersistableWindowState(parsed)) return null
|
if (!isPersistableWindowState(parsed)) return null
|
||||||
if (!isWindowStateVisibleOnAnyDisplay(parsed, displays)) return null
|
if (!isWindowStateVisibleOnAnyDisplay(parsed, displays)) return null
|
||||||
return parsed
|
return platform === 'darwin'
|
||||||
|
? clampWindowStateToVisibleWorkArea(parsed, displays)
|
||||||
|
: parsed
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`[desktop] failed to read Electron window state ${statePath}:`, error)
|
console.error(`[desktop] failed to read Electron window state ${statePath}:`, error)
|
||||||
return null
|
return null
|
||||||
@ -147,8 +174,13 @@ export function toggleWindowFullScreen(window: BrowserWindow, platform = process
|
|||||||
window.setFullScreen(!window.isFullScreen())
|
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
|
if (!window) return
|
||||||
|
app?.show?.()
|
||||||
if (!window.isVisible()) window.show()
|
if (!window.isVisible()) window.show()
|
||||||
if (window.isMinimized()) window.restore()
|
if (window.isMinimized()) window.restore()
|
||||||
window.focus()
|
window.focus()
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user