diff --git a/docs/development.md b/docs/development.md
index caba38adc9..437b635fec 100644
--- a/docs/development.md
+++ b/docs/development.md
@@ -311,6 +311,22 @@ frames non-stop, pinning ProMotion displays at 120Hz forever and draining the
battery while the app is idle — so do not re-add it. The probe's visibility
guards already prevent throttling from causing a false stall.
+### Desktop Linux hidden-window fallback
+
+On Wayland (seen on ChromeOS Linux) a window created with `show: false` can sit
+forever without a single compositor frame: the renderer loads and runs, but
+`ready-to-show` never fires, so the window is never shown and the app looks like
+it did not start while every process is alive. `showWindowWhenReady`
+(`packages/desktop/src/window/show-when-ready.ts`) shows the main window when
+the document finishes loading if the event has not arrived. The fallback is
+Linux-only; macOS and Windows keep waiting for `ready-to-show`.
+
+Run the startup regression on Linux with an isolated display:
+
+```bash
+xvfb-run -a npm run test:e2e:window-startup --workspace=@getpaseo/desktop
+```
+
### Daemon logs
Check `$PASEO_HOME/daemon.log` for daemon logs. The default level is `info`; set
diff --git a/packages/desktop/e2e/window-startup.e2e.mjs b/packages/desktop/e2e/window-startup.e2e.mjs
new file mode 100644
index 0000000000..e6654c09c2
--- /dev/null
+++ b/packages/desktop/e2e/window-startup.e2e.mjs
@@ -0,0 +1,48 @@
+import { mkdtemp, rm, writeFile } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+import { _electron as electron, expect } from "playwright/test";
+
+const root = await mkdtemp(path.join(tmpdir(), "paseo-window-startup-"));
+const modulePath = fileURLToPath(new URL("../dist/window/show-when-ready.js", import.meta.url));
+
+async function launchWindowWithoutHiddenFrames() {
+ const main = path.join(root, "main.cjs");
+ await writeFile(
+ main,
+ `const { app, BrowserWindow } = require("electron");
+ const { showWindowWhenReady } = require(${JSON.stringify(modulePath)});
+ app.setPath("userData", ${JSON.stringify(path.join(root, "user-data"))});
+ app.whenReady().then(async () => {
+ const win = global.mainWindow = new BrowserWindow({ show: false, paintWhenInitiallyHidden: false });
+ showWindowWhenReady(win, { showOnLoad: true });
+ await win.loadURL("data:text/html,
Desktop ready
");
+ global.visibleAfterLoad = win.isVisible();
+ });`,
+ );
+ const env = Object.fromEntries(
+ Object.entries(process.env).filter(([key]) => key !== "ELECTRON_RUN_AS_NODE"),
+ );
+ const displayArgs = process.platform === "linux" ? ["--ozone-platform=x11", "--disable-gpu"] : [];
+ return electron.launch({
+ args: ["--no-sandbox", ...displayArgs, main],
+ env,
+ });
+}
+
+async function expectLoadedDesktopIsShown(desktop) {
+ await expect.poll(() => desktop.evaluate(() => global.visibleAfterLoad)).toBe(true);
+ const page = await desktop.firstWindow();
+ await expect(page.getByRole("heading", { name: "Desktop ready" })).toBeVisible();
+}
+
+let desktop;
+try {
+ desktop = await launchWindowWithoutHiddenFrames();
+ await expectLoadedDesktopIsShown(desktop);
+ console.log("Loaded desktop is visible without hidden compositor frames");
+} finally {
+ await desktop?.close();
+ await rm(root, { recursive: true, force: true });
+}
diff --git a/packages/desktop/package.json b/packages/desktop/package.json
index 481fa05e64..e6e2dbd567 100644
--- a/packages/desktop/package.json
+++ b/packages/desktop/package.json
@@ -25,7 +25,8 @@
"verify:electron-cdp": "node ./scripts/verify-electron-cdp.mjs",
"test": "install-electron && vitest run --exclude \"e2e/**\"",
"typecheck": "tsgo --noEmit -p tsconfig.json",
- "test:e2e:lifecycle": "npm run build:main && node ./e2e/daemon-lifecycle.e2e.mjs"
+ "test:e2e:lifecycle": "npm run build:main && node ./e2e/daemon-lifecycle.e2e.mjs",
+ "test:e2e:window-startup": "npm run build:main && node ./e2e/window-startup.e2e.mjs"
},
"dependencies": {
"@getpaseo/cli": "*",
diff --git a/packages/desktop/src/main.ts b/packages/desktop/src/main.ts
index 63ab83281a..9acf329216 100644
--- a/packages/desktop/src/main.ts
+++ b/packages/desktop/src/main.ts
@@ -43,6 +43,7 @@ import {
buildStandardContextMenuItems,
} from "./window/window-manager.js";
import { setupDarwinCompositorWatchdog } from "./window/compositor-watchdog/index.js";
+import { showWindowWhenReady } from "./window/show-when-ready.js";
import { resolveDesktopWindowChromeMode, windowChromeModeArgument } from "./window/chrome.js";
import { registerDialogHandlers } from "./features/dialogs.js";
import {
@@ -773,8 +774,8 @@ async function createWindow(
registerBrowserWebviewNavigationGuards(contents);
});
- mainWindow.once("ready-to-show", () => {
- mainWindow.show();
+ showWindowWhenReady(mainWindow, {
+ showOnLoad: process.platform === "linux",
});
if (!app.isPackaged) {
diff --git a/packages/desktop/src/window/show-when-ready.test.ts b/packages/desktop/src/window/show-when-ready.test.ts
new file mode 100644
index 0000000000..756ad24cc2
--- /dev/null
+++ b/packages/desktop/src/window/show-when-ready.test.ts
@@ -0,0 +1,57 @@
+import { EventEmitter } from "node:events";
+import { describe, expect, it } from "vitest";
+import { showWindowWhenReady } from "./show-when-ready.js";
+
+class Window extends EventEmitter {
+ webContents = new EventEmitter();
+ shows = 0;
+ destroyed = false;
+ show() {
+ this.shows += 1;
+ }
+ isDestroyed() {
+ return this.destroyed;
+ }
+ close() {
+ this.destroyed = true;
+ this.emit("closed");
+ }
+}
+
+describe("showWindowWhenReady", () => {
+ it("shows a loaded Linux window even when no initial frame was painted", () => {
+ const win = new Window();
+ showWindowWhenReady(win, { showOnLoad: true });
+ expect(win.shows).toBe(0);
+ win.webContents.emit("did-finish-load");
+ expect(win.shows).toBe(1);
+ win.emit("ready-to-show");
+ expect(win.shows).toBe(1);
+ });
+
+ it("shows on the first frame without showing again after load", () => {
+ const win = new Window();
+ showWindowWhenReady(win, { showOnLoad: true });
+ win.emit("ready-to-show");
+ win.webContents.emit("did-finish-load");
+ expect(win.shows).toBe(1);
+ });
+
+ it("preserves waiting for the initial frame when show on load is disabled", () => {
+ const win = new Window();
+ showWindowWhenReady(win, { showOnLoad: false });
+ win.webContents.emit("did-finish-load");
+ expect(win.shows).toBe(0);
+ win.emit("ready-to-show");
+ expect(win.shows).toBe(1);
+ });
+
+ it("keeps a closed window closed when readiness arrives later", () => {
+ const win = new Window();
+ showWindowWhenReady(win, { showOnLoad: true });
+ win.close();
+ win.webContents.emit("did-finish-load");
+ win.emit("ready-to-show");
+ expect(win.shows).toBe(0);
+ });
+});
diff --git a/packages/desktop/src/window/show-when-ready.ts b/packages/desktop/src/window/show-when-ready.ts
new file mode 100644
index 0000000000..e49c8518af
--- /dev/null
+++ b/packages/desktop/src/window/show-when-ready.ts
@@ -0,0 +1,25 @@
+interface ReadyToShowWindow {
+ once(event: "ready-to-show", listener: () => void): unknown;
+ webContents: {
+ once(event: "did-finish-load", listener: () => void): unknown;
+ };
+ show(): void;
+ isDestroyed(): boolean;
+}
+
+export function showWindowWhenReady(
+ win: ReadyToShowWindow,
+ options: { showOnLoad: boolean },
+): void {
+ let shown = false;
+ const show = () => {
+ if (shown || win.isDestroyed()) return;
+ shown = true;
+ win.show();
+ };
+
+ win.once("ready-to-show", show);
+ // On Linux a hidden window can wait for compositor frames until it is shown.
+ // Loading the document is sufficient to reveal it without depending on a frame.
+ if (options.showOnLoad) win.webContents.once("did-finish-load", show);
+}