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); +}