diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 529ed55d03f..c5507c6fb03 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -75,6 +75,7 @@ import { } from "./updateMachine.ts"; import { isArm64HostRunningIntelBuild, resolveDesktopRuntimeInfo } from "./runtimeArch.ts"; import { resolveDesktopAppBranding } from "./appBranding.ts"; +import { bindFirstRevealTrigger, type RevealSubscription } from "./windowReveal.ts"; syncShellEnvironment(); @@ -1998,16 +1999,17 @@ function createWindow(): BrowserWindow { emitUpdateState(); }); - let initialRevealScheduled = false; - const revealInitialWindow = () => { - if (initialRevealScheduled) { - return; - } - initialRevealScheduled = true; - revealWindow(window); - }; - - window.once("ready-to-show", revealInitialWindow); + // On Linux/Wayland with `show: false`, Electron's `ready-to-show` only + // fires after `show()` is called, deadlocking the standard "wait for + // ready, then show" pattern. Add `did-finish-load` as a Linux-only + // fallback so the window still surfaces once the renderer has loaded + // the page. Other platforms keep the no-flash `ready-to-show` path, + // since `did-finish-load` typically fires before the first paint there. + const revealSubscribers: RevealSubscription[] = [(fire) => window.once("ready-to-show", fire)]; + if (process.platform === "linux") { + revealSubscribers.push((fire) => window.webContents.once("did-finish-load", fire)); + } + bindFirstRevealTrigger(revealSubscribers, () => revealWindow(window)); if (isDevelopment) { void window.loadURL(resolveDesktopDevServerUrl()); diff --git a/apps/desktop/src/windowReveal.test.ts b/apps/desktop/src/windowReveal.test.ts new file mode 100644 index 00000000000..88285fec0cb --- /dev/null +++ b/apps/desktop/src/windowReveal.test.ts @@ -0,0 +1,74 @@ +import { EventEmitter } from "node:events"; + +import { describe, expect, it, vi } from "vitest"; + +import { bindFirstRevealTrigger } from "./windowReveal.ts"; + +describe("bindFirstRevealTrigger", () => { + it("reveals when the first trigger fires", () => { + const window = new EventEmitter(); + const webContents = new EventEmitter(); + const reveal = vi.fn(); + + bindFirstRevealTrigger( + [ + (fire) => window.once("ready-to-show", fire), + (fire) => webContents.once("did-finish-load", fire), + ], + reveal, + ); + + window.emit("ready-to-show"); + + expect(reveal).toHaveBeenCalledTimes(1); + }); + + it("reveals when only the fallback trigger fires (Wayland deadlock case)", () => { + const window = new EventEmitter(); + const webContents = new EventEmitter(); + const reveal = vi.fn(); + + bindFirstRevealTrigger( + [ + (fire) => window.once("ready-to-show", fire), + (fire) => webContents.once("did-finish-load", fire), + ], + reveal, + ); + + webContents.emit("did-finish-load"); + + expect(reveal).toHaveBeenCalledTimes(1); + }); + + it("only reveals once when multiple triggers fire", () => { + const window = new EventEmitter(); + const webContents = new EventEmitter(); + const reveal = vi.fn(); + + bindFirstRevealTrigger( + [ + (fire) => window.once("ready-to-show", fire), + (fire) => webContents.once("did-finish-load", fire), + ], + reveal, + ); + + webContents.emit("did-finish-load"); + window.emit("ready-to-show"); + + expect(reveal).toHaveBeenCalledTimes(1); + }); + + it("subscribers using `once` ignore re-emitted events after reveal", () => { + const window = new EventEmitter(); + const reveal = vi.fn(); + + bindFirstRevealTrigger([(fire) => window.once("ready-to-show", fire)], reveal); + + window.emit("ready-to-show"); + window.emit("ready-to-show"); + + expect(reveal).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/desktop/src/windowReveal.ts b/apps/desktop/src/windowReveal.ts new file mode 100644 index 00000000000..8faf65aeb15 --- /dev/null +++ b/apps/desktop/src/windowReveal.ts @@ -0,0 +1,28 @@ +export type RevealSubscription = (listener: () => void) => void; + +/** + * Wire a reveal callback to fire exactly once, on whichever of the provided + * event subscribers fires first. Each subscriber is responsible for binding + * its own event source. + * + * Used by the desktop main window's first-paint reveal logic. The standard + * Electron pattern is to wait for `ready-to-show` before calling `show()`, + * but on Linux/Wayland with `show: false`, `ready-to-show` only fires after + * `show()` is called, deadlocking that pattern. Subscribing to both + * `ready-to-show` and `did-finish-load` (or any other "renderer is alive" + * signal) lets the window surface reliably across platforms. + */ +export function bindFirstRevealTrigger( + subscribers: readonly RevealSubscription[], + reveal: () => void, +): void { + let revealed = false; + const fire = () => { + if (revealed) return; + revealed = true; + reveal(); + }; + for (const subscribe of subscribers) { + subscribe(fire); + } +}