From 6e06a4f6a5e49a67eb791a423f3f96a1db562e37 Mon Sep 17 00:00:00 2001 From: Alvin Unreal Date: Thu, 24 Sep 2026 22:42:07 +0200 Subject: [PATCH 1/5] fix(desktop): keep Linux pets out of window switchers without losing focus --- .slim/clonedeps.json | 50 ++- AGENTS.md | 11 +- apps/desktop/package.json | 3 +- apps/desktop/src/agent-pet-controller.ts | 4 +- apps/desktop/src/bootstrap.ts | 62 ++++ apps/desktop/src/check-packaging-contract.ts | 10 +- apps/desktop/src/codemap.md | 46 ++- apps/desktop/src/default-pet-controller.ts | 49 ++- apps/desktop/src/lan-pet-controller.ts | 6 +- apps/desktop/src/main.ts | 47 +-- apps/desktop/src/pet-window-gaze.ts | 2 - apps/desktop/src/pet-window-interaction.ts | 1 - .../src/pet-window-show-coordinator.ts | 21 ++ apps/desktop/src/pet-window-types.ts | 1 + apps/desktop/src/pet-window-x11-map-core.ts | 28 ++ apps/desktop/src/pet-window.ts | 78 +++- apps/desktop/src/plugin-pet-registry.ts | 8 +- apps/desktop/src/startup-backend-policy.ts | 191 ++++++++++ apps/desktop/src/x11-pet-window-state-core.ts | 43 +++ apps/desktop/src/x11-pet-window-state.ts | 345 ++++++++++++++++++ apps/desktop/src/x11.d.ts | 46 +++ .../tests/startup-backend-policy.test.ts | 185 ++++++++++ .../tests/x11-pet-window-state-core.test.ts | 68 ++++ docs/desktop.md | 95 +++-- docs/development.md | 18 + pnpm-lock.yaml | 9 + 26 files changed, 1321 insertions(+), 106 deletions(-) create mode 100644 apps/desktop/src/bootstrap.ts create mode 100644 apps/desktop/src/pet-window-show-coordinator.ts create mode 100644 apps/desktop/src/pet-window-x11-map-core.ts create mode 100644 apps/desktop/src/startup-backend-policy.ts create mode 100644 apps/desktop/src/x11-pet-window-state-core.ts create mode 100644 apps/desktop/src/x11-pet-window-state.ts create mode 100644 apps/desktop/src/x11.d.ts create mode 100644 apps/desktop/tests/startup-backend-policy.test.ts create mode 100644 apps/desktop/tests/x11-pet-window-state-core.test.ts diff --git a/.slim/clonedeps.json b/.slim/clonedeps.json index 0c5b0c2e..c6b664cc 100644 --- a/.slim/clonedeps.json +++ b/.slim/clonedeps.json @@ -1,6 +1,6 @@ { "version": "1.0.0", - "updatedAt": "2026-08-25T00:00:00.000Z", + "updatedAt": "2026-09-24T00:00:00.000Z", "dependencies": [ { "name": "electron", @@ -10,17 +10,37 @@ "commit": "51db52e1b56e0739944dfbc679a86f2c69a26e5f", "path": ".slim/clonedeps/repos/electron__electron", "packagePath": null, - "reason": "Inspect Electron BrowserWindow, Linux, and Wayland window geometry behavior used by OpenPets drag handling." + "reason": "Inspect Electron BrowserWindow behavior, including Linux window hints that affect taskbar, switcher, and focus visibility, alongside general Linux and Wayland geometry behavior." }, { "name": "kwin", "resolvedVersion": null, "repoUrl": "https://github.com/KDE/kwin", - "ref": "master", - "commit": "10273ea5f8c43f9a17825e9560f9616b23cef1ba", + "ref": "v5.27.11", + "commit": "c328a2fd746a8c838b3d6d3c47475d8191d05b7f", "path": ".slim/clonedeps/repos/KDE__kwin", "packagePath": null, - "reason": "Inspect KDE Wayland compositor handling of xdg toplevel movement, activation, and window geometry constraints." + "reason": "Inspect KDE Wayland handling of taskbar, switcher, and focus roles for xdg toplevels, alongside movement, activation, and window geometry constraints." + }, + { + "name": "mutter", + "resolvedVersion": "46.0", + "repoUrl": "https://github.com/GNOME/mutter", + "ref": "46.0", + "commit": "c4753689e3413cd9332d885dd0297b3b7d9ba9ca", + "path": ".slim/clonedeps/repos/GNOME__mutter", + "packagePath": null, + "reason": "Inspect GNOME Wayland handling of taskbar, switcher, and focus roles, alongside window movement, activation, and geometry constraints." + }, + { + "name": "gnome-shell", + "resolvedVersion": "46.0", + "repoUrl": "https://github.com/GNOME/gnome-shell", + "ref": "46.0", + "commit": "0463511457612ca87f7426b3b01356d1d85bee9b", + "path": ".slim/clonedeps/repos/GNOME__gnome-shell", + "packagePath": null, + "reason": "Inspect GNOME Shell taskbar, switcher, and focus behavior and its interaction with Mutter, alongside Linux desktop window movement and activation." }, { "name": "openclaw", @@ -31,6 +51,26 @@ "path": ".slim/clonedeps/repos/openclaw__openclaw", "packagePath": null, "reason": "Inspect the source-supported extension, lifecycle hook, and configuration contracts for the OpenPets OpenClaw integration." + }, + { + "name": "node-x11", + "resolvedVersion": "4.2.1", + "repoUrl": "https://github.com/sidorares/node-x11", + "ref": "v4.2.1", + "commit": "12bb53b3d78f592f05762784ec3be68cb011b8b8", + "path": ".slim/clonedeps/repos/sidorares__node-x11", + "packagePath": null, + "reason": "Inspect the Node.js X11 client and its support for setting or querying X11 window properties relevant to taskbar, switcher, and focus behavior." + }, + { + "name": "xserver", + "resolvedVersion": "xwayland-23.2.6", + "repoUrl": "https://gitlab.freedesktop.org/xorg/xserver.git", + "ref": "xwayland-23.2.6", + "commit": "db9cde0328aa1bd21210cf5472ca7901697b3713", + "path": ".slim/clonedeps/repos/xorg__xserver", + "packagePath": null, + "reason": "Inspect Xwayland and X server handling of X11 window properties relevant to Linux taskbar, switcher, and focus behavior." } ] } diff --git a/AGENTS.md b/AGENTS.md index d1fdf80b..b7dd853c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -227,6 +227,11 @@ FYI: third-parties/ folder contains other repos related to openpets, putting her Read-only dependency source repositories are available under `.slim/clonedeps/repos/` for inspection. Do not edit these clones. -- `.slim/clonedeps/repos/electron__electron/` — `electron/electron` at `v42.0.0`; inspect Electron BrowserWindow, Linux, and Wayland geometry behavior used by OpenPets drag handling. -- `.slim/clonedeps/repos/KDE__kwin/` — `KDE/kwin` at `master` (`10273ea5f8c43f9a17825e9560f9616b23cef1ba`); inspect KDE Wayland compositor handling of xdg toplevel movement, activation, and geometry constraints. -- `.slim/clonedeps/repos/openclaw__openclaw/` — `openclaw/openclaw` at `v2026.7.1-2` (`0790d9f593ad30c940ed93b5872a8cf6d6f3cf8c`); inspect the source-supported extension, lifecycle hook, and configuration contracts for the OpenPets OpenClaw integration. +- `.slim/clonedeps/repos/electron__electron/` — `electron/electron` at `v42.0.0`; inspect Electron BrowserWindow behavior, including Linux window hints that affect taskbar, switcher, and focus visibility, alongside general Linux and Wayland geometry behavior. +- `.slim/clonedeps/repos/KDE__kwin/` — `KDE/kwin` at `v5.27.11` (`c328a2fd746a8c838b3d6d3c47475d8191d05b7f`); inspect KDE Wayland handling of taskbar, switcher, and focus roles for xdg toplevels, alongside movement, activation, and window geometry constraints. +- `.slim/clonedeps/repos/GNOME__mutter/` — `GNOME/mutter` at `46.0` (`c4753689e3413cd9332d885dd0297b3b7d9ba9ca`); inspect GNOME Wayland handling of taskbar, switcher, and focus roles, alongside window movement, activation, and geometry constraints. +- `.slim/clonedeps/repos/GNOME__gnome-shell/` — `GNOME/gnome-shell` at `46.0` (`0463511457612ca87f7426b3b01356d1d85bee9b`); inspect GNOME Shell taskbar, switcher, and focus behavior and its interaction with Mutter, alongside Linux desktop window movement and activation. +- `.slim/clonedeps/repos/sidorares__node-x11/` — `sidorares/node-x11` at `v4.2.1` (`12bb53b3d78f592f05762784ec3be68cb011b8b8`); inspect the Node.js X11 client and its support for setting or querying X11 window properties relevant to taskbar, switcher, and focus behavior. +- `.slim/clonedeps/repos/xorg__xserver/` — `xorg/xserver` at `xwayland-23.2.6` (`db9cde0328aa1bd21210cf5472ca7901697b3713`); inspect Xwayland and X server handling of X11 window properties relevant to Linux taskbar, switcher, and focus behavior. + +The OpenClaw source clone recorded in `.slim/clonedeps.json` is not currently present under `.slim/clonedeps/repos/`. diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 4b13e3c0..cd397660 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -11,7 +11,7 @@ }, "author": "OpenPets", "type": "module", - "main": "dist/main.js", + "main": "dist/bootstrap.js", "scripts": { "dev": "pnpm build && electron .", "dev:electron": "pnpm build:main && electron .", @@ -67,6 +67,7 @@ "react-dom": "^19.2.6", "sharp": "^0.34.5", "undici": "^7.29.0", + "x11": "4.2.1", "yauzl": "3.3.0" } } diff --git a/apps/desktop/src/agent-pet-controller.ts b/apps/desktop/src/agent-pet-controller.ts index f6901aa7..6497d1d4 100644 --- a/apps/desktop/src/agent-pet-controller.ts +++ b/apps/desktop/src/agent-pet-controller.ts @@ -7,7 +7,7 @@ import { clampToTerminalBounds, getConfinementState, getEffectiveConfinementBoun import { defaultPetWindowSize, clampToVisibleWorkArea, getDefaultPetInitialPosition } from "./display.js"; import { debug, info } from "./logger.js"; import { transientDisplayMs, type OpenPetsReaction } from "./local-ipc-protocol.js"; -import { clearTransientReaction, createAgentPetWindow, getTransientDisplayDurationMs, getTransientReactionAnimationMs, loadExplicitPetContent, mergePetTransientDisplay, readWindowPosition, setPetReactionState, type PetShowMediaOptions, type PetTransientDisplay } from "./pet-window.js"; +import { clearTransientReaction, createAgentPetWindow, getTransientDisplayDurationMs, getTransientReactionAnimationMs, loadExplicitPetContent, mergePetTransientDisplay, readWindowPosition, setPetReactionState, showPetWindowInactive, type PetShowMediaOptions, type PetTransientDisplay } from "./pet-window.js"; import { focusTerminalWindow } from "./terminal-focus.js"; import { createPetTransientPresentation, type PetTransientPresentation } from "./pet-transient-presentation.js"; @@ -26,7 +26,7 @@ export function showAgentPet(petId: string): boolean { if (window.isMinimized()) window.restore(); // Pull the pet into its terminal window bounds if confinement is active. repositionConfinedPet(petId, window); - window.showInactive(); + showPetWindowInactive(window); const shownWin = agentPetWindows.get(petId); if (shownWin && !shownWin.isDestroyed()) { registerRoamingPet(petId, () => agentPetWindows.get(petId) ?? null); diff --git a/apps/desktop/src/bootstrap.ts b/apps/desktop/src/bootstrap.ts new file mode 100644 index 00000000..94457024 --- /dev/null +++ b/apps/desktop/src/bootstrap.ts @@ -0,0 +1,62 @@ +import { app } from "electron"; +import { spawn } from "node:child_process"; + +import { + planLinuxBackendBootstrap, + selectReplacementProcessMode, + superviseReplacement, + waitForReplacementBootstrap, +} from "./startup-backend-policy.js"; + +async function bootstrap(): Promise { + const plan = planLinuxBackendBootstrap(process.platform, process.argv, process.env); + if (plan.action === "relaunch") { + const executable = app.isPackaged && process.env.APPIMAGE ? process.env.APPIMAGE : process.execPath; + const mode = selectReplacementProcessMode(app.isPackaged); + const detach = mode === "detached"; + const child = spawn(executable, plan.args.slice(1), { + detached: detach, + stdio: detach ? ["ignore", "ignore", "ignore", "ipc"] : ["ignore", "inherit", "inherit", "ipc"], + }); + + await waitForReplacementBootstrap(child, () => child.kill()); + if (child.connected) child.disconnect(); + if (detach) { + child.unref(); + app.exit(0); + return; + } + + const exitCode = await superviseReplacement(child, process); + app.exit(exitCode); + return; + } + + await import("./main.js"); + await notifyParent({ type: "openpets-bootstrap-ready" }); +} + +await bootstrap().catch(async (error: unknown) => { + console.error("OpenPets startup bootstrap failed; refusing to start with an unintended Linux display backend.", error); + try { + await notifyParent({ + type: "openpets-bootstrap-failed", + message: error instanceof Error ? error.message : String(error), + }); + } catch (notifyError) { + console.error("Could not notify the original process about replacement startup failure.", notifyError); + } + app.exit(1); +}); + +type BootstrapHandoffMessage = { type: "openpets-bootstrap-ready" } | { type: "openpets-bootstrap-failed"; message: string }; + +function notifyParent(message: BootstrapHandoffMessage): Promise { + if (typeof process.send !== "function") return Promise.resolve(); + return new Promise((resolve, reject) => { + process.send?.(message, (error) => { + if (error) reject(error); + else resolve(); + }); + }); +} diff --git a/apps/desktop/src/check-packaging-contract.ts b/apps/desktop/src/check-packaging-contract.ts index 910bf673..19ad72e7 100644 --- a/apps/desktop/src/check-packaging-contract.ts +++ b/apps/desktop/src/check-packaging-contract.ts @@ -14,7 +14,7 @@ import { pickReactionMessage, reactionMessagePools } from "./reaction-messages.j const distDir = dirname(fileURLToPath(import.meta.url)); const appDir = dirname(distDir); const repoRoot = resolve(appDir, "../.."); -const packageJson = JSON.parse(readFileSync(join(appDir, "package.json"), "utf8")) as { scripts?: Record; dependencies?: Record; devDependencies?: Record; description?: string; author?: string }; +const packageJson = JSON.parse(readFileSync(join(appDir, "package.json"), "utf8")) as { main?: string; scripts?: Record; dependencies?: Record; devDependencies?: Record; description?: string; author?: string }; const rootPackageJson = JSON.parse(readFileSync(join(repoRoot, "package.json"), "utf8")) as { scripts?: Record }; const workspaceConfig = readFileSync(join(repoRoot, "pnpm-workspace.yaml"), "utf8"); const builderConfigPath = join(appDir, "electron-builder.yml"); @@ -22,6 +22,7 @@ const builderConfig = readFileSync(builderConfigPath, "utf8"); assert.equal(packageJson.description, "OpenPets tray-first desktop companion app."); assert.equal(packageJson.author, "OpenPets"); +assert.equal(packageJson.main, "dist/bootstrap.js", "Electron must enter through the early display-backend bootstrap."); assert.match(packageJson.scripts?.["dev:debug"] ?? "", /OPENPETS_LOG_LEVEL=debug OPENPETS_LOG_CONSOLE=1 pnpm dev/, "desktop debug dev script must enable verbose log mirroring."); assert.match(packageJson.scripts?.package ?? "", /node scripts\/clean-package-output\.cjs && electron-builder/); assert.match(packageJson.scripts?.["package:dir"] ?? "", /node scripts\/clean-package-output\.cjs && electron-builder --dir/); @@ -87,6 +88,8 @@ assert.equal(pickReactionMessage("success", () => 0), reactionMessagePools.succe assert.ok(existsSync(join(appDir, "scripts", "clean-package-output.cjs")), "package output cleanup helper must exist."); assert.ok(existsSync(join(appDir, "scripts", "check-windows-symlink-privilege.cjs")), "Windows package symlink preflight helper must exist."); assert.ok(existsSync(join(distDir, "main.js")), "desktop main build output must exist before packaging checks run."); +assert.ok(existsSync(join(distDir, "bootstrap.js")), "desktop startup bootstrap build output must exist before packaging checks run."); +assert.ok(existsSync(join(distDir, "startup-backend-policy.js")), "pure startup backend policy build output must exist before packaging checks run."); assert.ok(existsSync(join(repoRoot, "packages", "claude", "dist", "index.js")), "@open-pets/claude must be built before packaging."); assert.ok(existsSync(join(repoRoot, "packages", "client", "dist", "index.js")), "@open-pets/client must be built before packaging."); assert.ok(existsSync(join(repoRoot, "packages", "mcp", "dist", "index.js")), "@open-pets/mcp must be built before packaging."); @@ -110,6 +113,9 @@ function checkPackageOutput(outputDir: string, target: PackagingTarget): void { const appResourceDir = findPackagedAppResourceDir(outputDir); assert.ok(appResourceDir, "packaged app resources directory was not found."); assert.ok(existsSync(join(appResourceDir, "app.asar")), "packaged app.asar is missing."); + const packagedEntries = listPackage(join(appResourceDir, "app.asar"), { isPack: false }); + assert.ok(packagedEntries.includes("/dist/bootstrap.js"), "packaged app.asar is missing the Electron bootstrap entry."); + assert.ok(packagedEntries.includes("/dist/startup-backend-policy.js"), "packaged app.asar is missing the early backend policy module."); assertRendererBundled(join(appResourceDir, "app.asar")); assertBundledOfficialPlugins(appResourceDir, join(repoRoot, "plugins", "official")); const appContents = join(appResourceDir, "app.asar.unpacked"); @@ -150,7 +156,7 @@ function findPackagedAppResourceDir(outputDir: string): string | null { collectDirectories(outputDir, candidates, 4); for (const dir of candidates) { - if (existsSync(join(dir, "app.asar")) || existsSync(join(dir, "app", "dist", "main.js"))) { + if (existsSync(join(dir, "app.asar")) || existsSync(join(dir, "app", "dist", "bootstrap.js"))) { return dir; } } diff --git a/apps/desktop/src/codemap.md b/apps/desktop/src/codemap.md index 218b3839..9fd5ea45 100644 --- a/apps/desktop/src/codemap.md +++ b/apps/desktop/src/codemap.md @@ -24,18 +24,20 @@ Core TypeScript source for the OpenPets desktop application. Organized into: lif **Main Process Flow**: ``` -main.ts -├── lifecycle.ts (app events, cleanup) -├── logger.ts (structured logging init) -├── app-state.ts (state init) -├── pet-install-transaction.ts (startup recovery of interrupted pet commits) -├── codex-pet-migration.ts (safe legacy V2 marker repair) -├── plugin-service.ts (plugin state/runtime init, JS host wiring) -├── manager-check-in-service.ts (bundled weekly sync, local-week offer state, and pet snapshot) -├── tray.ts (tray creation) -├── local-ipc.ts (IPC server start) -├── control-center-route.ts (canonical route/target validation and dev-only startup routing) -└── windows.ts (UI handlers) +bootstrap.ts (Electron package entry) +├── startup-backend-policy.ts (pre-main platform/argv/environment plan; selects launch or replacement-process argv) +└── main.ts (application bootstrap after backend selection) + ├── lifecycle.ts (app events, cleanup) + ├── logger.ts (structured logging init) + ├── app-state.ts (state init) + ├── pet-install-transaction.ts (startup recovery of interrupted pet commits) + ├── codex-pet-migration.ts (safe legacy V2 marker repair) + ├── plugin-service.ts (plugin state/runtime init, JS host wiring) + ├── manager-check-in-service.ts (bundled weekly sync, local-week offer state, and pet snapshot) + ├── tray.ts (tray creation) + ├── local-ipc.ts (IPC server start) + ├── control-center-route.ts (canonical route/target validation and dev-only startup routing) + └── windows.ts (UI handlers) ``` **IPC Request Flow**: @@ -94,6 +96,14 @@ 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 ``` +X11/XWayland pet windows remain focusable for chat and plugin inputs. The +pre-show lifecycle subscribes to structure events and prepares `_NET_WM_STATE`; +after MapNotify it sends EWMH add-state requests and watches the property until +the standard taskbar/pager atoms, and the KDE switcher atom when advertised by +root `_NET_SUPPORTED`, are present. This is pet-window-only (not Control Center) +and does not cover experimental native Wayland. Property verification is not +proof of compositor UI behavior or no visible flash. + **Manager Check-in Flow**: ``` team-api-client.ts → manager-check-in-service.ts → manager-check-in-state.ts @@ -265,7 +275,9 @@ main.ts/settings → i18n.setLocaleFromPreference(system/user locale) ## Key Modules **Core**: -- `main.ts`: Entry, single-instance lock, bootstrap sequence, JavaScript plugin host construction, and dev-only Control Center route opening +- `bootstrap.ts`: Package-entry pre-main backend bootstrap; uses `startup-backend-policy.ts` and starts a normalized replacement with Node `child_process.spawn` when needed, waits up to 15 seconds for a bounded ready/failure IPC handoff, supervises unpackaged replacements with signal/exit propagation, and detaches packaged replacements +- `startup-backend-policy.ts`: Pure platform/argv/environment policy for normal X11, Wayland opt-out, and native layer-shell startup selection +- `main.ts`: Application bootstrap after backend selection, single-instance lock, JavaScript plugin host construction, and dev-only Control Center route opening - `lifecycle.ts`: App event handlers (quit, window-all-closed, second-instance) with logging; stops plugin service, IPC, and pet windows on quit - `state.ts`: Simple shell pause state - `app-state.ts`: Persistent JSON state with V1 schema, atomic writes, reaction animation overrides, validated waiting animation duration, persisted idle cursor-gaze preference, and host Pet Assistant personality preferences @@ -312,7 +324,11 @@ main.ts/settings → i18n.setLocaleFromPreference(system/user locale) - `renderer/`: Vite React/Tailwind Control Center shell for Dashboard, Pets, Integrations, Plugins, and Settings. **Pets**: -- `pet-window.ts`: Public pet-window lifecycle facade: transparent frameless window creation, HTML/CSS composition, sprite and transient presentation updates, companion launcher, attached chat styling, bubble suppression, status badges, validated atlas layout selection, and context-menu installation delegation +- `pet-window.ts`: Public pet-window lifecycle facade: transparent frameless window creation, HTML/CSS composition, sprite and transient presentation updates, companion launcher, attached chat styling, bubble suppression, status badges, validated atlas layout selection, context-menu installation delegation, and Linux X11 show/hide gating +- `x11-pet-window-state.ts`: X11 client adapter that subscribes to StructureNotify before show, waits for matching MapNotify, sends post-map EWMH state additions, and observes `_NET_WM_STATE` until standard exclusion atoms and the KDE switcher atom when advertised by root `_NET_SUPPORTED` are present +- `x11-pet-window-state-core.ts`: Electron-free EWMH atom/property helpers for merging existing state and checking required atoms +- `pet-window-x11-map-core.ts`: Electron-free matching/cancellation state for the target window's MapNotify transition +- `pet-window-show-coordinator.ts`: Generation-based readiness gate that prevents stale asynchronous completion from showing a pet after a later hide - `pet-window-interaction.ts`: Per-window interaction controller owning mouse passthrough, drag and renderer lifecycle IPC, recovery/watchdog timers, dragging state, and process-wide speech-completion subscriptions - `wayland-layer-backend.ts`: Electron adapter that owns the native helper process/socket, reconnect generations, timers, frame scheduling, `NativeImage` conversion, renderer pointer replay, drag/menu behavior, and patched pet-window methods - `wayland-layer-protocol.ts`: Electron-free layer-shell wire encoders, incremental helper-message decoder, transparent BGRA cropping, and pointer button/coordinate mapping @@ -321,7 +337,7 @@ main.ts/settings → i18n.setLocaleFromPreference(system/user locale) - `default-pet-chat.ts`: Host-side in-pet chat and Manager Check-in coordinator managing expanded/collapsed carrier states, IPC authorization, conversation transcript streams, Talk subscriptions, and the private check-in card lifecycle - `pet-transient-presentation.ts`: Reusable per-pet owner for transient display/badge state, transition-unique opaque render-composition tokens, independent display/badge timer guards, timer cleanup, and deterministic transition callbacks; default/agent controllers retain window/voice/lease role ownership - `pet-display-coordinator.ts`: Electron-free display/power listener lifecycle, independent topology debounce lanes, cache invalidation, ordered reclamp fanout, and resume recovery -- `default-pet-controller.ts`: Default pet visibility, position persistence, transient reactions, status badges, and the private weekly Manager Check-in offer/action-circle presentation +- `default-pet-controller.ts`: Default pet visibility, position persistence, persisted explicit Hide vs temporary window-manager-close hiding, transient reactions, status badges, and the private weekly Manager Check-in offer/action-circle presentation - `agent-pet-controller.ts`: Lease-triggered pet windows, dismissal tracking, transient displays, status badges, logging - `pet-motion-engine.ts`: Interpolated movement vector/tick engine for plugin-driven pet motion and target-following behavior - `built-in-pet.ts`: Built-in pet constant diff --git a/apps/desktop/src/default-pet-controller.ts b/apps/desktop/src/default-pet-controller.ts index aae75610..9292704e 100644 --- a/apps/desktop/src/default-pet-controller.ts +++ b/apps/desktop/src/default-pet-controller.ts @@ -6,10 +6,10 @@ import { defaultPetWindowSize, getAllDisplayKeys, getDefaultPetInitialPosition, import { motionMoveTo } from "./pet-motion-engine.js"; import { registerRoamingPet, unregisterRoamingPet } from "./pet-roaming-controller.js"; import { bindDefaultPetChatWindow, collapseDefaultPetChat, openDefaultPetCheckIn, unbindDefaultPetChatWindow } from "./default-pet-chat.js"; -import { debug, info } from "./logger.js"; +import { debug, info, warn } from "./logger.js"; import { t } from "./i18n/index.js"; import { transientDisplayMs, type OpenPetsReaction } from "./local-ipc-protocol.js"; -import { clearTransientReaction, createDefaultPetWindow, getSafeDefaultPetPosition, getTransientDisplayDurationMs, getTransientReactionAnimationMs, isPetWindowDragging, loadDefaultPetContent, mergePetTransientDisplay, readWindowPosition, recoverPetMouseInterop, setPetReactionState, type PetPluginBubbles, type PetShowMediaOptions, type PetStatusBadgeReaction, type PetTransientDisplay } from "./pet-window.js"; +import { clearTransientReaction, createDefaultPetWindow, getSafeDefaultPetPosition, getTransientDisplayDurationMs, getTransientReactionAnimationMs, hidePetWindow, isPetWindowDragging, loadDefaultPetContent, mergePetTransientDisplay, readWindowPosition, recoverPetMouseInterop, setPetReactionState, showPetWindowInactive, type PetPluginBubbles, type PetShowMediaOptions, type PetStatusBadgeReaction, type PetTransientDisplay } from "./pet-window.js"; import { PetBubbleArbiter, type ActiveBubble, type PetBubbleSink } from "./plugin-bubble-arbiter.js"; import { publishPluginPetEvent } from "./plugin-events-source.js"; import { composeVoiceActivityBadge, composeVoiceActivityDisplay } from "./voice-activity-slot.js"; @@ -99,7 +99,7 @@ function showDefaultPetWindow(source: "user" | "external-event"): void { window.restore(); } - window.showInactive(); + showPetWindowInactive(window); registerRoamingPet("default", getDefaultPetWindowForPlugins); } @@ -108,24 +108,46 @@ export function hideDefaultPet(): void { hideDefaultPetWindow(); } +export function hideDefaultPetTemporarily(): void { + if (hideDefaultPetWindow()) { + refreshTrayMenuAfterVisibilityChange(); + } +} + export function hideDefaultPetForLan(): void { hideDefaultPetWindow(); } -function hideDefaultPetWindow(): void { +function hideDefaultPetWindow(): boolean { if (!defaultPetWindow || defaultPetWindow.isDestroyed()) { debug("pet.default", "hide skipped", { reason: "no-window" }); - return; - } - if (!defaultPetWindow.isVisible()) { - return; + return false; } + const window = defaultPetWindow; + const wasVisible = window.isVisible(); + const hidePosition = wasVisible ? readWindowPosition(window) : null; + hidePetWindow(window); + unregisterRoamingPet("default"); + collapseDefaultPetChat(); + if (!hidePosition || window.isDestroyed()) return wasVisible; - const hidePosition = readWindowPosition(defaultPetWindow); - info("pet.default", "hide requested", { windowId: defaultPetWindow.id, position: hidePosition, petId: getAppStateSnapshot().preferences.defaultPetId }); + info("pet.default", "hide requested", { windowId: window.id, position: hidePosition, petId: getAppStateSnapshot().preferences.defaultPetId }); handlePositionChanged(hidePosition); - collapseDefaultPetChat(); - defaultPetWindow.hide(); + return wasVisible; +} + +function refreshTrayMenuAfterVisibilityChange(): void { + void import("./tray.js") + .then(({ refreshTrayMenu }) => refreshTrayMenu()) + .catch((error: unknown) => { + warn("pet.default", "tray menu refresh after pet visibility change failed", { + error: error instanceof Error ? error.message : String(error), + }); + }); +} + +function watchDefaultPetVisibility(window: BrowserWindow): void { + window.on("show", refreshTrayMenuAfterVisibilityChange); } export function getDefaultPetLanPosition(): { readonly x: number; readonly y: number } | null { @@ -406,12 +428,14 @@ function getOrCreateDefaultPetWindow(): BrowserWindow { pluginBubbles: getDefaultPetPluginBubbles(), onPositionChanged: handlePositionChanged, onHideRequested: hideDefaultPet, + onWindowCloseRequested: hideDefaultPetTemporarily, onBubbleDismissed: handleBubbleDismissed, onBubbleAction: (token, actionId) => defaultPetBubbleArbiter.handleAction(token, actionId), onBubbleSubmit: (token, values) => defaultPetBubbleArbiter.handleSubmit(token, values), onPetEvent: (name, payload) => publishPluginPetEvent("default", name, payload), onWindowReplaced: (replacement) => { defaultPetWindow = replacement; + watchDefaultPetVisibility(replacement); bindDefaultPetChatWindow(replacement); void loadDefaultPetContent(replacement, paused, getRenderedDisplay(), getRenderedBadge(), getCurrentDismissToken(), getDefaultPetPluginBubbles()); const replacementId = replacement.id; @@ -426,6 +450,7 @@ function getOrCreateDefaultPetWindow(): BrowserWindow { }, }, getCurrentDismissToken()); const createdWindow = defaultPetWindow; + watchDefaultPetVisibility(createdWindow); bindDefaultPetChatWindow(createdWindow); const windowId = createdWindow.id; info("pet.default", "created", { windowId, position, paused, petId: getAppStateSnapshot().preferences.defaultPetId }); diff --git a/apps/desktop/src/lan-pet-controller.ts b/apps/desktop/src/lan-pet-controller.ts index 4c25a0d3..43b45ce9 100644 --- a/apps/desktop/src/lan-pet-controller.ts +++ b/apps/desktop/src/lan-pet-controller.ts @@ -5,7 +5,7 @@ import { clampToVisibleWorkArea, defaultPetWindowSize, getDefaultPetInitialPosit import { debug, info, warn } from "./logger.js"; import { planLanPetPresence, resolveRenderableLanPetId } from "./lan-pet-presence.js"; import type { LanPetRecord, LanPoint } from "./lan-contract.js"; -import { createAgentPetWindow, getTransientDisplayDurationMs, loadExplicitPetContent, readWindowPosition, type PetTransientDisplay } from "./pet-window.js"; +import { createAgentPetWindow, getTransientDisplayDurationMs, loadExplicitPetContent, readWindowPosition, showPetWindowInactive, type PetTransientDisplay } from "./pet-window.js"; import type { OpenPetsReaction } from "./local-ipc-protocol.js"; import { registerRoamingPet, unregisterRoamingPet } from "./pet-roaming-controller.js"; @@ -103,7 +103,7 @@ function showLanVisitingPet(pet: LanPetRecord): void { const existing = visitingPetWindows.get(pet.ownerHost); if (existing && !existing.window.isDestroyed() && existing.renderedPetId === renderedPetId) { - existing.window.showInactive(); + showPetWindowInactive(existing.window); return; } if (existing) closeLanVisitingPet(pet.ownerHost); @@ -154,7 +154,7 @@ function showLanVisitingPet(pet: LanPetRecord): void { unregisterRoamingPet(motionHandleId); visitingPetWindows.delete(pet.ownerHost); }); - window.showInactive(); + showPetWindowInactive(window); registerRoamingPet(motionHandleId, () => { const current = visitingPetWindows.get(pet.ownerHost); return current?.window && !current.window.isDestroyed() ? current.window : null; diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 77788a5d..ead16ee0 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -46,6 +46,7 @@ import { reclampLanVisitingPetWindows } from "./lan-pet-controller.js"; import { reclampPluginPetWindows } from "./plugin-pet-registry.js"; import { PetDisplayCoordinator } from "./pet-display-coordinator.js"; import { prepareCaptureSessionBeforeReady, resolveCaptureSessionDir, startCaptureSession } from "./capture-session.js"; +import { readOzonePlatformSwitch } from "./startup-backend-policy.js"; let teamService: TeamService | null = null; let managerCheckInService: ManagerCheckInService | null = null; @@ -82,15 +83,10 @@ if (process.platform === "win32") { app.commandLine.appendSwitch("disable-features", "CalculateNativeWinOcclusion"); } -// OpenPets requires programmatic window positioning and z-ordering, which -// native Wayland compositors disallow for XDG-shell toplevels. To ensure -// gravity, drag, and always-on-top work correctly on all KDE/GNOME Linux -// desktops, we force the x11/XWayland backend. Users who explicitly need -// native Wayland can set OPENPETS_ALLOW_WAYLAND=1, but gravity, walkabout, -// and manual drag will not function under native Wayland. const isLinux = process.platform === "linux"; const allowWayland = process.env.OPENPETS_ALLOW_WAYLAND === "1"; const layerShellBackend = isLinux && process.env.OPENPETS_NATIVE_WAYLAND === "1"; +const requestedOzonePlatform = readOzonePlatformSwitch(process.argv); // In layer-shell mode the pet is carried by the native helper's overlay // surface; the hidden offscreen renderer only composites frames for it. The @@ -103,28 +99,6 @@ if (layerShellBackend) { app.commandLine.appendSwitch("disable-background-timer-throttling"); app.commandLine.appendSwitch("disable-backgrounding-occluded-windows"); } -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. -// -// In layer-shell mode the pet is a native Wayland layer-shell surface (owned by -// the helper) and the pet's own renderer is a hidden offscreen window, so -// Electron's ozone platform only matters for the Control Center and other -// ordinary windows. Under Niri, XWayland (x11) windows are unreliable (they may -// not map), so layer-shell mode forces native Wayland for those instead. -if (isLinux && !allowWayland && !layerShellBackend) { - // 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.appendSwitch("ozone-platform", "x11"); -} -if (layerShellBackend && !hasExplicitOzonePlatformArg) { - app.commandLine.appendSwitch("ozone-platform", "wayland"); -} - // Privileged schemes must be registered before app ready, in a single call. // Session media is served to