Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 12 additions & 10 deletions apps/desktop/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down Expand Up @@ -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());
Expand Down
74 changes: 74 additions & 0 deletions apps/desktop/src/windowReveal.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
28 changes: 28 additions & 0 deletions apps/desktop/src/windowReveal.ts
Original file line number Diff line number Diff line change
@@ -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);
}
}
Loading