fix(desktop): stabilize macOS window recovery

This commit is contained in:
t4we fan
2026-08-24 17:48:32 +08:00
parent b13e1fa47e
commit fea89ad88e
4 changed files with 117 additions and 6 deletions
@@ -51,6 +51,7 @@ export class ElectronShellGeneration {
private mounted = false
private released = false
private attentionCount = 0
private cancelPendingFullscreenHide: (() => void) | undefined
private cleanupListeners: (() => void) | undefined
constructor(private readonly options: ElectronShellGenerationOptions) {}
@@ -78,9 +79,28 @@ export class ElectronShellGeneration {
if (applicationNeedsReveal(window, platform.platform)) this.show()
}
const clearAttention = (): void => { this.clearAttention() }
let fullscreenHidePending = false
const hideAfterFullscreen = (): void => {
if (!fullscreenHidePending) return
fullscreenHidePending = false
if (!window.isDestroyed()) window.hide()
}
const cancelPendingFullscreenHide = (): void => {
if (!fullscreenHidePending) return
fullscreenHidePending = false
window.off('leave-full-screen', hideAfterFullscreen)
}
this.cancelPendingFullscreenHide = cancelPendingFullscreenHide
const close = (event: Electron.Event): void => {
if (this.options.isQuitting()) return
event.preventDefault()
if (platform.platform === 'darwin' && window.isFullScreen()) {
if (fullscreenHidePending) return
fullscreenHidePending = true
window.once('leave-full-screen', hideAfterFullscreen)
window.setFullScreen(false)
return
}
window.hide()
}
const preserveBlankTitle = (event: Electron.Event): void => { event.preventDefault() }
@@ -172,6 +192,7 @@ export class ElectronShellGeneration {
window.off('focus', clearAttention)
window.off('page-title-updated', preserveBlankTitle)
window.off('ready-to-show', show)
cancelPendingFullscreenHide()
window.webContents.off('before-input-event', handleZoomShortcut)
window.webContents.off('will-frame-navigate', navigate)
window.webContents.off('will-redirect', redirect)
@@ -199,6 +220,7 @@ export class ElectronShellGeneration {
show(): void {
const window = this.window
if (window === undefined || window.isDestroyed()) return
this.cancelPendingFullscreenHide?.()
this.clearAttention()
revealApplication(window, this.options.platform.platform)
}
@@ -243,6 +265,7 @@ export class ElectronShellGeneration {
this.clearAttention()
this.window = undefined
this.tray = undefined
this.cancelPendingFullscreenHide = undefined
if (window === undefined) return
this.cleanupListeners?.()
@@ -2,6 +2,7 @@
import { BrowserWindow } from 'electron'
import { fileURLToPath } from 'node:url'
import { revealApplication } from './electron-reveal.ts'
import type { DesktopLocale } from './runtime.ts'
const PROFILE_CREATE_SCHEME = 'dsh-profile-create:'
@@ -79,8 +80,7 @@ export class ProfileCreateWindow {
open(): void {
const existing = this.window
if (existing !== undefined && !existing.isDestroyed()) {
existing.show()
existing.focus()
revealApplication(existing)
return
}
this.disposed = false
@@ -118,7 +118,7 @@ export class ProfileCreateWindow {
window.webContents.on('will-navigate', navigate)
window.webContents.on('will-redirect', navigate)
window.once('ready-to-show', () => {
if (!this.disposed && this.window === window && !window.isDestroyed()) window.show()
if (!this.disposed && this.window === window && !window.isDestroyed()) revealApplication(window)
})
window.on('closed', () => {
// Electron destroys webContents before emitting BrowserWindow's `closed`.
@@ -112,6 +112,7 @@ const electron = vi.hoisted(() => {
readonly isFocused = vi.fn(() => false)
readonly isVisible = vi.fn(() => false)
readonly isMinimized = vi.fn(() => false)
readonly isFullScreen = vi.fn(() => false)
readonly flashFrame = vi.fn()
readonly restore = vi.fn()
readonly show = vi.fn()
@@ -124,6 +125,7 @@ const electron = vi.hoisted(() => {
readonly loadURL = loadURL
readonly removeMenu = vi.fn()
readonly setBackgroundMaterial = vi.fn()
readonly setFullScreen = vi.fn()
}
class Tray {
@@ -870,6 +872,61 @@ describe('Electron desktop runtime', () => {
await release()
})
it('leaves macOS fullscreen before hiding and ignores repeated close events during the transition', async () => {
vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin')
const { ElectronDesktopRuntime } = await import('../src/electron-runtime.ts')
const runtime = new ElectronDesktopRuntime(async () => {})
const release = runtime.schedule(spec)
await runtime.mountScheduled()
const window = electron.browserWindows[0]
window?.isFullScreen.mockReturnValue(true)
const close = electron.browserWindowOn.mock.calls.find(([event]) => event === 'close')?.[1]
expect(close).toEqual(expect.any(Function))
const first = { preventDefault: vi.fn() }
const second = { preventDefault: vi.fn() }
close(first)
close(second)
expect(first.preventDefault).toHaveBeenCalledOnce()
expect(second.preventDefault).toHaveBeenCalledOnce()
expect(window?.setFullScreen).toHaveBeenCalledOnce()
expect(window?.setFullScreen).toHaveBeenCalledWith(false)
expect(window?.hide).not.toHaveBeenCalled()
const leaveFullscreen = window?.once.mock.calls.find(([event]) => event === 'leave-full-screen')?.[1]
expect(leaveFullscreen).toEqual(expect.any(Function))
leaveFullscreen()
expect(window?.hide).toHaveBeenCalledOnce()
await release()
})
it('cancels a pending fullscreen hide when the user explicitly reveals the macOS window', async () => {
vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin')
const { ElectronDesktopRuntime } = await import('../src/electron-runtime.ts')
const runtime = new ElectronDesktopRuntime(async () => {})
const release = runtime.schedule(spec)
await runtime.mountScheduled()
const window = electron.browserWindows[0]
window?.isFullScreen.mockReturnValue(true)
const close = electron.browserWindowOn.mock.calls.find(([event]) => event === 'close')?.[1]
close({ preventDefault: vi.fn() })
const leaveFullscreen = window?.once.mock.calls.find(([event]) => event === 'leave-full-screen')?.[1]
runtime.show()
leaveFullscreen()
expect(window?.off).toHaveBeenCalledWith('leave-full-screen', expect.any(Function))
expect(window?.hide).not.toHaveBeenCalled()
expect(window?.show).toHaveBeenCalledOnce()
expect(window?.focus).toHaveBeenCalledOnce()
await release()
})
it('flashes the Windows taskbar and clears attention on focus and release', async () => {
vi.spyOn(process, 'platform', 'get').mockReturnValue('win32')
const { ElectronDesktopRuntime } = await import('../src/electron-runtime.ts')
@@ -6,8 +6,10 @@ const electron = vi.hoisted(() => {
loadURL: ReturnType<typeof vi.fn>
show: ReturnType<typeof vi.fn>
focus: ReturnType<typeof vi.fn>
restore: ReturnType<typeof vi.fn>
close: ReturnType<typeof vi.fn>
isDestroyed: ReturnType<typeof vi.fn>
isMinimized: ReturnType<typeof vi.fn>
loadFile: ReturnType<typeof vi.fn>
once: ReturnType<typeof vi.fn>
on: ReturnType<typeof vi.fn>
@@ -22,8 +24,10 @@ const electron = vi.hoisted(() => {
executeJavaScript: vi.fn(async () => {}),
}
readonly isDestroyed = vi.fn(() => false)
readonly isMinimized = vi.fn(() => false)
readonly show = vi.fn()
readonly focus = vi.fn()
readonly restore = vi.fn()
readonly close = vi.fn()
readonly loadFile = vi.fn(async () => {})
readonly loadURL = vi.fn(async () => {})
@@ -35,10 +39,17 @@ const electron = vi.hoisted(() => {
windows.push(this as unknown as typeof windows[number])
}
}
return { BrowserWindow, windows }
return {
app: {
isHidden: vi.fn(() => false),
show: vi.fn(),
},
BrowserWindow,
windows,
}
})
vi.mock('electron', () => ({ BrowserWindow: electron.BrowserWindow }))
vi.mock('electron', () => ({ app: electron.app, BrowserWindow: electron.BrowserWindow }))
import {
ProfileCreateWindow,
@@ -46,7 +57,10 @@ import {
} from '../src/profile-create-window.ts'
describe('ProfileCreateWindow', () => {
beforeEach(() => { electron.windows.length = 0 })
beforeEach(() => {
electron.windows.length = 0
vi.clearAllMocks()
})
it('parses only local submit and cancel actions', () => {
expect(parseProfileCreateAction('dsh-profile-create://submit?name=work')).toEqual({ action: 'submit', name: 'work' })
@@ -83,6 +97,23 @@ describe('ProfileCreateWindow', () => {
expect(electron.windows[0]?.options).not.toHaveProperty('parent')
})
it('restores the hidden macOS application before showing the first profile window', () => {
vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin')
electron.app.isHidden.mockReturnValue(true)
const creator = new ProfileCreateWindow({ locale: 'en', onSubmit: async () => {} })
creator.open()
const window = electron.windows[0]
window?.isMinimized.mockReturnValue(true)
const ready = window?.once.mock.calls.find(([event]) => event === 'ready-to-show')?.[1]
ready()
expect(electron.app.show).toHaveBeenCalledOnce()
expect(window?.restore).toHaveBeenCalledOnce()
expect(window?.show).toHaveBeenCalledOnce()
expect(window?.focus).toHaveBeenCalledOnce()
})
it('does not access destroyed web contents from the closed callback', () => {
const creator = new ProfileCreateWindow({ locale: 'en', onSubmit: async () => {} })
creator.open()