From fea89ad88e36b6ce2dc2af2b379d657cfcea0727 Mon Sep 17 00:00:00 2001 From: t4we fan Date: Mon, 24 Aug 2026 17:48:32 +0800 Subject: [PATCH] fix(desktop): stabilize macOS window recovery --- .../src/electron-shell-generation.ts | 23 ++++++++ .../src/profile-create-window.ts | 6 +- .../tests/electron-runtime.spec.ts | 57 +++++++++++++++++++ .../tests/profile-create-window.spec.ts | 37 +++++++++++- 4 files changed, 117 insertions(+), 6 deletions(-) diff --git a/dsh-plugin-desktop/src/electron-shell-generation.ts b/dsh-plugin-desktop/src/electron-shell-generation.ts index 4d65f618ee..9ed442e639 100644 --- a/dsh-plugin-desktop/src/electron-shell-generation.ts +++ b/dsh-plugin-desktop/src/electron-shell-generation.ts @@ -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?.() diff --git a/dsh-plugin-desktop/src/profile-create-window.ts b/dsh-plugin-desktop/src/profile-create-window.ts index 1a769e5e84..bb5ce6e236 100644 --- a/dsh-plugin-desktop/src/profile-create-window.ts +++ b/dsh-plugin-desktop/src/profile-create-window.ts @@ -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`. diff --git a/dsh-plugin-desktop/tests/electron-runtime.spec.ts b/dsh-plugin-desktop/tests/electron-runtime.spec.ts index 0d0cf2cbad..a147c7d34d 100644 --- a/dsh-plugin-desktop/tests/electron-runtime.spec.ts +++ b/dsh-plugin-desktop/tests/electron-runtime.spec.ts @@ -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') diff --git a/dsh-plugin-desktop/tests/profile-create-window.spec.ts b/dsh-plugin-desktop/tests/profile-create-window.spec.ts index 95cb865bdf..04d7de7131 100644 --- a/dsh-plugin-desktop/tests/profile-create-window.spec.ts +++ b/dsh-plugin-desktop/tests/profile-create-window.spec.ts @@ -6,8 +6,10 @@ const electron = vi.hoisted(() => { loadURL: ReturnType show: ReturnType focus: ReturnType + restore: ReturnType close: ReturnType isDestroyed: ReturnType + isMinimized: ReturnType loadFile: ReturnType once: ReturnType on: ReturnType @@ -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()