diff --git a/fluxer_desktop/src/main/MacFullScreenHide.test.mjs b/fluxer_desktop/src/main/MacFullScreenHide.test.mjs new file mode 100644 index 000000000..f8480fbba --- /dev/null +++ b/fluxer_desktop/src/main/MacFullScreenHide.test.mjs @@ -0,0 +1,117 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import assert from 'node:assert/strict'; +import {EventEmitter} from 'node:events'; +import {readFileSync} from 'node:fs'; +import {createRequire} from 'node:module'; +import {describe, test} from 'node:test'; +import {fileURLToPath} from 'node:url'; +import vm from 'node:vm'; + +const require = createRequire(import.meta.url); +const esbuild = require('esbuild'); + +const sourcePath = fileURLToPath(new URL('./MacFullScreenHide.ts', import.meta.url)); +const transformedSource = esbuild.transformSync(readFileSync(sourcePath, 'utf8'), { + loader: 'ts', + format: 'cjs', + platform: 'node', + target: 'node20', +}).code; + +function loadMacFullScreenHide() { + const module = {exports: {}}; + const context = vm.createContext({module, exports: module.exports, process: {platform: 'darwin'}}); + vm.runInContext(transformedSource, context, {filename: sourcePath}); + return module.exports; +} + +class FakeWindow extends EventEmitter { + fullScreen; + visible = true; + destroyed = false; + calls = []; + + constructor(fullScreen) { + super(); + this.fullScreen = fullScreen; + } + + isDestroyed() { + return this.destroyed; + } + + isFullScreen() { + return this.fullScreen; + } + + setFullScreen(flag) { + this.calls.push(`setFullScreen(${flag})`); + } + + hide() { + this.calls.push('hide'); + this.visible = false; + } + + finishLeavingFullScreen() { + this.fullScreen = false; + this.emit('leave-full-screen'); + } +} + +describe('MacFullScreenHide', () => { + test('hides a windowed window at once', () => { + const {hideWindowLeavingFullScreen} = loadMacFullScreenHide(); + const window = new FakeWindow(false); + hideWindowLeavingFullScreen(window, 'darwin'); + assert.deepEqual(window.calls, ['hide']); + }); + + test('leaves full screen on macOS and hides only after the transition ends', () => { + const {hideWindowLeavingFullScreen} = loadMacFullScreenHide(); + const window = new FakeWindow(true); + hideWindowLeavingFullScreen(window, 'darwin'); + assert.deepEqual(window.calls, ['setFullScreen(false)']); + assert.equal(window.visible, true); + window.finishLeavingFullScreen(); + assert.deepEqual(window.calls, ['setFullScreen(false)', 'hide']); + assert.equal(window.visible, false); + }); + + test('a second close during the transition does not queue another exit', () => { + const {hideWindowLeavingFullScreen} = loadMacFullScreenHide(); + const window = new FakeWindow(true); + hideWindowLeavingFullScreen(window, 'darwin'); + hideWindowLeavingFullScreen(window, 'darwin'); + window.finishLeavingFullScreen(); + assert.deepEqual(window.calls, ['setFullScreen(false)', 'hide']); + assert.equal(window.listenerCount('leave-full-screen'), 0); + }); + + test('showing the window during the transition cancels the pending hide', () => { + const {hideWindowLeavingFullScreen, cancelPendingFullScreenHide} = loadMacFullScreenHide(); + const window = new FakeWindow(true); + hideWindowLeavingFullScreen(window, 'darwin'); + cancelPendingFullScreenHide(window); + window.finishLeavingFullScreen(); + assert.deepEqual(window.calls, ['setFullScreen(false)']); + assert.equal(window.visible, true); + }); + + test('does not touch a window destroyed before the transition ends', () => { + const {hideWindowLeavingFullScreen} = loadMacFullScreenHide(); + const window = new FakeWindow(true); + hideWindowLeavingFullScreen(window, 'darwin'); + window.destroyed = true; + window.finishLeavingFullScreen(); + assert.deepEqual(window.calls, ['setFullScreen(false)']); + }); + + test('hides directly on other platforms', () => { + const {hideWindowLeavingFullScreen} = loadMacFullScreenHide(); + const window = new FakeWindow(true); + hideWindowLeavingFullScreen(window, 'win32'); + assert.deepEqual(window.calls, ['hide']); + }); +}); diff --git a/fluxer_desktop/src/main/MacFullScreenHide.ts b/fluxer_desktop/src/main/MacFullScreenHide.ts new file mode 100644 index 000000000..8b9fe3775 --- /dev/null +++ b/fluxer_desktop/src/main/MacFullScreenHide.ts @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +export interface FullScreenHideableWindow { + isDestroyed(): boolean; + isFullScreen(): boolean; + setFullScreen(flag: boolean): void; + hide(): void; + once(event: 'leave-full-screen', listener: () => void): unknown; + removeListener(event: 'leave-full-screen', listener: () => void): unknown; +} + +const pendingHides = new WeakMap void>(); + +export function hideWindowLeavingFullScreen( + window: FullScreenHideableWindow, + platform: NodeJS.Platform = process.platform, +): void { + if (platform !== 'darwin' || !window.isFullScreen()) { + window.hide(); + return; + } + if (pendingHides.has(window)) return; + const hide = () => { + pendingHides.delete(window); + if (!window.isDestroyed()) window.hide(); + }; + pendingHides.set(window, hide); + window.once('leave-full-screen', hide); + window.setFullScreen(false); +} + +export function cancelPendingFullScreenHide(window: FullScreenHideableWindow): void { + const hide = pendingHides.get(window); + if (!hide) return; + pendingHides.delete(window); + window.removeListener('leave-full-screen', hide); +} diff --git a/fluxer_desktop/src/main/Window.ts b/fluxer_desktop/src/main/Window.ts index 6e7d539a9..2c67b32d4 100644 --- a/fluxer_desktop/src/main/Window.ts +++ b/fluxer_desktop/src/main/Window.ts @@ -27,6 +27,7 @@ import {isNativeGatewayAvailable} from '@electron/main/GatewaySocketNativeBounda import {shouldDisableV8CodeCache} from '@electron/main/LaunchOptions'; import {getDesktopLocalAppAuthorization} from '@electron/main/LocalAppProtocolAuthorization'; import {isLocalAppRendererDocumentURL, isLocalAppURL} from '@electron/main/LocalAppURL'; +import {cancelPendingFullScreenHide, hideWindowLeavingFullScreen} from '@electron/main/MacFullScreenHide'; import {t} from '@electron/main/MainI18n'; import {MainWindowRevealGate, MainWindowRevealReason} from '@electron/main/MainWindowReveal'; import {signalMainWindowCreated, signalMainWindowReady} from '@electron/main/ModuleBootHandoff'; @@ -874,7 +875,7 @@ export function createWindow(options: CreateWindowOptions = {}): BrowserWindow { ? 'Window close hid the app. Use Quit to terminate the process' : 'Window close hid the app to the tray. Use Quit to terminate the process', ); - mainWindow?.hide(); + if (mainWindow) hideWindowLeavingFullScreen(mainWindow); refreshDesktopTrayMenu(); } }); @@ -1140,6 +1141,7 @@ export function showWindow(): void { return; } if (mainWindow) { + cancelPendingFullScreenHide(mainWindow); if (mainWindow.isMinimized()) { mainWindow.restore(); } @@ -1180,7 +1182,7 @@ export function showWindow(): void { export function hideWindow(): void { if (mainWindow) { - mainWindow.hide(); + hideWindowLeavingFullScreen(mainWindow); } }