diff --git a/apps/desktop/scripts/run-tests.mjs b/apps/desktop/scripts/run-tests.mjs index 1aa40351..92653109 100644 --- a/apps/desktop/scripts/run-tests.mjs +++ b/apps/desktop/scripts/run-tests.mjs @@ -60,6 +60,7 @@ const behaviorTests = [ ".test-dist/tests/display.test.js", ".test-dist/tests/preference-patch.test.js", ".test-dist/tests/plugin-agent-activity.test.js", + ".test-dist/tests/linux-ozone-startup.test.js", ".test-dist/tests/pet-window-wayland-predicate.test.js", ".test-dist/tests/pet-window-mouse-forwarding-predicate.test.js", ]; diff --git a/apps/desktop/src/check-packaging-contract.ts b/apps/desktop/src/check-packaging-contract.ts index f47cb444..25d2a3ba 100644 --- a/apps/desktop/src/check-packaging-contract.ts +++ b/apps/desktop/src/check-packaging-contract.ts @@ -90,6 +90,7 @@ const localeSources = new Map([ const agentSetupSource = readFileSync(join(appDir, "src", "agent-setup.ts"), "utf8"); const loggerSource = readFileSync(join(appDir, "src", "logger.ts"), "utf8"); const mainSource = readFileSync(join(appDir, "src", "main.ts"), "utf8"); +const linuxOzoneStartupSource = readFileSync(join(appDir, "src", "linux-ozone-startup.ts"), "utf8"); const appStateSource = readFileSync(join(appDir, "src", "app-state.ts"), "utf8"); const lifecycleSource = readFileSync(join(appDir, "src", "lifecycle.ts"), "utf8"); const localIpcSourceForLogging = readFileSync(join(appDir, "src", "local-ipc.ts"), "utf8"); @@ -112,6 +113,8 @@ assert.match(mainSource, /isLinux && !allowWayland[\s\S]*?appendSwitch\("ozone-p assert.match(mainSource, /if \(process\.platform === "linux"\) \{\n\s*app\.commandLine\.appendSwitch\("password-store", "gnome-libsecret"\);\n\s*\} else \{\n\s*app\.commandLine\.appendSwitch\("password-store", "basic"\);\n\s*\}/, "Linux desktop must gate password-store as gnome-libsecret on Linux with basic only in the non-Linux else branch; an unconditional switch would disable Electron safeStorage and break plugin secret saves with 'Secret storage encryption is unavailable on this system'."); const passwordStoreValues = [...mainSource.matchAll(/appendSwitch\("password-store",\s*"([^"]+)"\)/g)].map((match) => match[1]); assert.deepStrictEqual(passwordStoreValues, ["gnome-libsecret", "basic"], "desktop must set password-store exactly twice (gnome-libsecret on Linux, basic otherwise); any extra or unconditional switch would override the backend and re-break plugin secret saves."); +assert.match(mainSource, /getLinuxX11RelaunchArgs[\s\S]*?app\.relaunch\(\{ args: linuxX11RelaunchArgs \}\)[\s\S]*?app\.exit\(0\)/, "Linux must relaunch the browser process itself with the X11 Ozone argument before creating windows."); +assert.match(linuxOzoneStartupSource, /removeOzonePlatformArgs\(argv\.slice\(1\)\)[\s\S]*?"--ozone-platform=x11"/, "Linux relaunch must replace conflicting Ozone arguments while preserving application arguments."); assert.match(mainSource, /OPENPETS_ALLOW_WAYLAND/, "Linux X11 override must support the OPENPETS_ALLOW_WAYLAND opt-out escape hatch."); assert.match(traySource, /t\("tray\.openLogsFolder"\)/, "desktop tray must expose user-sendable logs for bug reports."); assert.match(enCatalogSource, /Open Logs Folder/, "English catalog must keep the user-sendable logs label."); diff --git a/apps/desktop/src/codemap.md b/apps/desktop/src/codemap.md index ed91a1f1..763187ae 100644 --- a/apps/desktop/src/codemap.md +++ b/apps/desktop/src/codemap.md @@ -63,6 +63,12 @@ plugin-sdk-bridge.ts → plugin-sdk-routes.ts → plugin-pet-registry.ts └── pet-motion-engine.ts tick() calculates interpolated target vectors for spawned/default pets ``` +**Linux Ozone bootstrap**: before acquiring the single-instance lock, +`main.ts` uses the pure planner in `linux-ozone-startup.ts` to relaunch once +with `--ozone-platform=x11` in the browser process argv. The relaunched process +then registers the same switch for GPU/renderer children. The +`OPENPETS_ALLOW_WAYLAND=1` escape hatch bypasses this relaunch. + **Agent Setup Flow**: ``` windows.ts (IPC handlers) diff --git a/apps/desktop/src/linux-ozone-startup.ts b/apps/desktop/src/linux-ozone-startup.ts new file mode 100644 index 00000000..51b96f7b --- /dev/null +++ b/apps/desktop/src/linux-ozone-startup.ts @@ -0,0 +1,31 @@ +export function getLinuxX11RelaunchArgs( + platform: NodeJS.Platform, + allowWayland: boolean, + argv: readonly string[], +): string[] | null { + if (platform !== "linux" || allowWayland || hasX11OzoneArg(argv)) return null; + return [...removeOzonePlatformArgs(argv.slice(1)), "--ozone-platform=x11"]; +} + +function hasX11OzoneArg(argv: readonly string[]): boolean { + for (let index = 1; index < argv.length; index += 1) { + const arg = argv[index]; + if (arg === "--ozone-platform") return argv[index + 1] === "x11"; + if (arg.startsWith("--ozone-platform=")) return arg.slice("--ozone-platform=".length) === "x11"; + } + return false; +} + +function removeOzonePlatformArgs(args: readonly string[]): string[] { + const result: string[] = []; + for (let index = 0; index < args.length; index += 1) { + const arg = args[index]; + if (arg === "--ozone-platform") { + if (index + 1 < args.length && !args[index + 1].startsWith("--")) index += 1; + continue; + } + if (arg.startsWith("--ozone-platform=")) continue; + result.push(arg); + } + return result; +} diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 3b5d8ce1..abd174f0 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -7,6 +7,7 @@ import { createAppIcon } from "./assets.js"; import { setLocaleFromPreference } from "./i18n/index.js"; import { installDefaultPetDisplayHandlers, shouldOpenDefaultPetOnLaunch, showDefaultPet } from "./default-pet-controller.js"; import { installAppLifecycle } from "./lifecycle.js"; +import { getLinuxX11RelaunchArgs } from "./linux-ozone-startup.js"; import { startLanController } from "./lan-controller.js"; import { debug, error as logError, getLogFilePath, info, initializeLogger, warn } from "./logger.js"; import { startLocalIpcServer } from "./local-ipc.js"; @@ -52,19 +53,23 @@ const allowWayland = process.env.OPENPETS_ALLOW_WAYLAND === "1"; const hasExplicitOzonePlatformArg = process.argv.some( (arg) => arg === "--ozone-platform" || arg.startsWith("--ozone-platform="), ); -// When OPENPETS_ALLOW_WAYLAND=1 we deliberately do NOT append an ozone-platform -// switch: Electron honours the system default (typically wayland on a Wayland -// session, or any explicit --ozone-platform the user passed) and we warn at -// startup that positioning/gravity/walkabout/drag are unsupported there. -if (isLinux && !allowWayland) { - // Force x11 even if the user passed --ozone-platform=wayland or auto; - // we overwrite any pre-existing switch so nothing silently slips through. +// app.commandLine updates child launches but can be too late for the already +// running browser process's Ozone initialization. Relaunch once with x11 in +// the real argv so browser, GPU, and renderer processes select one backend. +const linuxX11RelaunchArgs = getLinuxX11RelaunchArgs(process.platform, allowWayland, process.argv); +if (linuxX11RelaunchArgs) { + app.relaunch({ args: linuxX11RelaunchArgs }); + app.exit(0); +} else if (isLinux && !allowWayland) { + // Keep the command-line registry aligned for child processes and diagnostics. app.commandLine.appendSwitch("ozone-platform", "x11"); } -const gotSingleInstanceLock = app.requestSingleInstanceLock(); +const gotSingleInstanceLock = linuxX11RelaunchArgs ? false : app.requestSingleInstanceLock(); -if (!gotSingleInstanceLock) { +if (linuxX11RelaunchArgs) { + // The replacement browser process owns startup. +} else if (!gotSingleInstanceLock) { app.quit(); } else { installAppLifecycle(); diff --git a/apps/desktop/tests/linux-ozone-startup.test.ts b/apps/desktop/tests/linux-ozone-startup.test.ts new file mode 100644 index 00000000..7c4fa7c0 --- /dev/null +++ b/apps/desktop/tests/linux-ozone-startup.test.ts @@ -0,0 +1,36 @@ +import assert from "node:assert/strict"; + +import { getLinuxX11RelaunchArgs } from "../src/linux-ozone-startup.js"; + +assert.deepEqual( + getLinuxX11RelaunchArgs("linux", false, ["/opt/OpenPets/openpets", "--user-data-dir=/tmp/profile"]), + ["--user-data-dir=/tmp/profile", "--ozone-platform=x11"], + "Linux startup should relaunch the browser process itself with the X11 backend", +); +assert.equal( + getLinuxX11RelaunchArgs("linux", false, ["/opt/OpenPets/openpets", "--ozone-platform=x11"]), + null, + "an already-correct X11 process must not relaunch again", +); +assert.deepEqual( + getLinuxX11RelaunchArgs("linux", false, ["/opt/OpenPets/openpets", "--ozone-platform", "wayland", "--inspect=9229"]), + ["--inspect=9229", "--ozone-platform=x11"], + "the forced backend should replace conflicting split-form Ozone arguments without dropping unrelated arguments", +); +assert.deepEqual( + getLinuxX11RelaunchArgs("linux", false, ["/opt/OpenPets/openpets", "--ozone-platform=auto"]), + ["--ozone-platform=x11"], + "the forced backend should replace conflicting equals-form Ozone arguments", +); +assert.equal( + getLinuxX11RelaunchArgs("linux", true, ["/opt/OpenPets/openpets", "--ozone-platform=wayland"]), + null, + "the explicit native Wayland escape hatch must not relaunch", +); +assert.equal( + getLinuxX11RelaunchArgs("darwin", false, ["/Applications/OpenPets.app/Contents/MacOS/OpenPets"]), + null, + "non-Linux startup must remain unchanged", +); + +console.log("Linux Ozone startup validation passed."); diff --git a/docs/desktop.md b/docs/desktop.md index 83eb819f..e5b746d1 100644 --- a/docs/desktop.md +++ b/docs/desktop.md @@ -43,14 +43,20 @@ Key files: `main.ts` (entry/bootstrap), `lifecycle.ts` (app events + cleanup), ## Linux display backend (Ozone/Wayland) -On Linux, `main.ts` appends `--ozone-platform=x11` **before** `app` is ready, so -the app always runs under x11/XWayland. This is required because OpenPets pets -depend on programmatic top-level window positioning (`setPosition`/`setBounds`) -and z-order control (`setAlwaysOnTop`); native Wayland forbids clients from -positioning or restacking their own toplevels, which silently breaks motion, -gravity, walkabout, drag, and always-on-top stacking. The forcing is -unconditional (it overrides even an explicit `--ozone-platform=wayland`) so a -mistaken launch flag cannot disable pet movement. +On Linux, `main.ts` ensures the browser process itself starts with +`--ozone-platform=x11`. If the initial process lacks that argument, +`linux-ozone-startup.ts` performs one early relaunch with conflicting Ozone +arguments replaced; the relaunched process also registers the switch through +`app.commandLine` for child processes and diagnostics. This avoids mixing a +Wayland browser process with X11 GPU/renderers, which can leave every OpenPets +window invisible. + +OpenPets requires x11/XWayland because pets depend on programmatic top-level +window positioning (`setPosition`/`setBounds`) and z-order control +(`setAlwaysOnTop`); native Wayland forbids clients from positioning or +restacking their own toplevels, which silently breaks motion, gravity, +walkabout, drag, and always-on-top stacking. The forcing overrides even an +explicit `--ozone-platform=wayland` unless the escape hatch is enabled. The escape hatch is the environment variable `OPENPETS_ALLOW_WAYLAND=1`: when set, the app honors the system default backend (or an explicit @@ -64,8 +70,9 @@ decision (platform + `--ozone-platform` + `XDG_SESSION_TYPE`/`WAYLAND_DISPLAY`) is factored into `computeEffectiveWaylandBackend()` in `wayland-backend.ts`; `pet-window.ts` delegates to it and owns only the cache. -The x11-forcing branch and the `OPENPETS_ALLOW_WAYLAND` opt-out are asserted by -`check-packaging-contract.ts`, so this behavior cannot silently regress. +The browser-process relaunch, x11 child-process switch, and +`OPENPETS_ALLOW_WAYLAND` opt-out are protected by behavior and packaging +contract tests so this behavior cannot silently regress. On Windows, the shell silently strips `HWND_TOPMOST` from other windows when an app enters fullscreen (browser video, games) and never restores it — and no diff --git a/docs/development.md b/docs/development.md index cac935d6..4aa0556c 100644 --- a/docs/development.md +++ b/docs/development.md @@ -121,6 +121,10 @@ and the runbooks under `web/docs/`. mounted macOS checkout (platform-specific `node_modules`). - Use the VM to validate Linux/Wayland renderer, tray, pet-window drag, IPC, plugin, and packaging behavior. +- Linux defaults to a one-time early browser-process relaunch with + `--ozone-platform=x11`; seeing two short-lived startup PIDs is expected. Set + `OPENPETS_ALLOW_WAYLAND=1` only when intentionally testing the documented + native-Wayland limitations. - **WSL** cross-platform IPC (WSL client → Windows host over private TCP) is part of the protocol — see [ipc.md](ipc.md).