Skip to content
Draft
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
1 change: 1 addition & 0 deletions apps/desktop/scripts/run-tests.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
];
Expand Down
3 changes: 3 additions & 0 deletions apps/desktop/src/check-packaging-contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand All @@ -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.");
Expand Down
6 changes: 6 additions & 0 deletions apps/desktop/src/codemap.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
31 changes: 31 additions & 0 deletions apps/desktop/src/linux-ozone-startup.ts
Original file line number Diff line number Diff line change
@@ -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;
}
23 changes: 14 additions & 9 deletions apps/desktop/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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();
Expand Down
36 changes: 36 additions & 0 deletions apps/desktop/tests/linux-ozone-startup.test.ts
Original file line number Diff line number Diff line change
@@ -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.");
27 changes: 17 additions & 10 deletions docs/desktop.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
4 changes: 4 additions & 0 deletions docs/development.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

Expand Down