← All Pull Requests

pup on Linux: one codebase, one zip (host-linux + spawn.entrypoints.linux) #7

Open opened by John Lauer
Merges linux-port → master · view live diff →

What this does

pup runs on Linux from the same codebase and the same release zip as Windows. Today Adom Bridge for Linux lists pup but answers unsupported_on_this_os, because the package has no Linux binary and no spawn.entrypoints.linux. With this branch the zip carries adom-pup-bridge.exe (Windows, unchanged) and adom-pup-bridge (Linux x86_64, glibc 2.30+), and bridge.json names each in spawn.entrypoints (ab 2.1.175+).

No Linux fork: the Linux code is one new crate behind the existing host traits, picked by cfg(target_os = "linux") in bridge/src/main.rs, the same way host-win and host-mac are.

The change

rust/host-linux (new, ~1,000 lines, 9 unit tests), implementing HostWindows, HostProcess, HostPaths, HostSecrets:

  • Windows through X11/EWMH with x11rb (pure Rust, no libX11): rows from _NET_CLIENT_LIST owned by _NET_WM_PID, tags in an _ADOM_PUP_TAGS X property (the SetProp equivalent, so a restarted pup re-adopts its windows), lower with _NET_RESTACK_WINDOW Below + ConfigureWindow Below, never _NET_ACTIVE_WINDOW except in the reason-gated raise.
  • Processes through /proc, a reaper thread per launched browser (exit codes, no zombies reading as alive), setsid so browsers outlive pup.
  • Chrome rewrites its process title on Linux (the browser's /proc/<pid>/cmdline is one space-joined string), so profile matching reads the joined form and skips --type= helpers. Without that, the orphan check ended Chrome's setuid sandbox helper instead of the browser and the relaunch hit a locked profile ("Profile error occurred" dialogs). Found and fixed during testing.
  • The browser's environment is scrubbed of the AppImage mount (Adom Bridge for Linux is an AppImage: LD_LIBRARY_PATH, GTK_*, GIO_*, PATH entries point into it).
  • Sandbox on Ubuntu 23.10+ (AppArmor blocks unprivileged user namespaces, so CfT dies "No usable sandbox"): run CfT under an installed setuid helper via CHROME_DEVEL_SANDBOX (Google Chrome's or the distro Chromium's), --no-sandbox only when none exists. Reported as pup_readiness.browserSandbox.
  • Secrets in the Secret Service (keytar's attributes), never prompting; when no keyring is unlocked, ~/.adom/pup-secrets.json (0600). credential_set now replies with storage.
  • Idle time from GNOME's IdleMonitor (Wayland) or the X screen saver; work area from _NET_WORKAREA.
  • Browser stderr goes to pup-browser.log (a failed start explains itself there).

Shared code (Windows behaviour unchanged):

  • host: three trait methods with defaults (browser_sandbox, secret_store; no behaviour change on Windows/macOS).
  • browser: Linux launch switches (--password-store=basic; --ozone-platform=x11 on a Wayland session so pup's windows live under XWayland and can be kept in the background); zip unpack fallbacks bsdtar/python3 with exec-bit restore on Linux; diskFreeMb via statvfs on unix (was null).
  • verbs: the extension lane's presented UA uses X11; Linux x86_64 + platform Linux on Linux (it said Windows NT). Windows string unchanged; macOS untouched (see notes).
  • tools/check-borders.sh: X11 / procfs / D-Bus names only in host-linux.
  • tools/rust-build.sh builds both targets; tools/rust-package.py (new) makes the one zip with spawn.entrypoints, platforms.linux.supported:true, both binaries mode 0755. The signing gate is unchanged (the exe is still signed before packaging). ship.sh pushes the new script.

Docs: linux/README.md (sub-README) with a card in the main README, Dependencies table, CHANGELOG release notes ("Unreleased: pup on Linux"), the contributing guide (build both targets, host-linux), footprint, the port plan's stale single-zip note, and the skills that were wrong or silent about Linux (pup Linux section, pup-browsers-and-chrome, pup-credentials, pup-import-logins, pup-taskbar-states).

Evidence

Linux, through Adom Bridge (Ubuntu 24.04, GNOME, Wayland, ab 2.1.185)

Local candidate zip served on the test machine's loopback and installed with bridge_install {manifestUrl: http://127.0.0.1:8899/manifest.json, force:true} (nothing published).

  • bridge_list: puppeteer running, supportedOnThisOs: true.
  • pup_readiness: ready, Chrome for Testing linux64 156 (matched to the installed Chrome), browserSandbox: "setuid helper /opt/google/chrome/chrome-sandbox", diskFreeMb real.
  • pup_open_window 1.76 s on the extension lane; pup_eval → Mozilla/5.0 (X11; Linux x86_64) ... Chrome/156.0.0.0 / Linux x86_64; pup_navigate 2.5 s; pup_screenshot 423 ms (1390x785, real render, wiki auto-signed from the desktop token); pup_status lane connected; vault set/list/delete; pup_close_window.
  • Background: EWMH stacking read after each step: the pup window at the bottom, the user's active app (KiCad, another thread's work) stayed active through open, navigate, new tab and close.
  • Bridge restart with a window open: re-adopted on the 15 s health tick, nothing relaunched. Restart → close → reopen and quit → reopen: one browser, no profile errors (run on a private Xvfb display).

Linux X11 (Debian 13, Cinnamon)

Pending: the X11 test machine is in use by another thread; results (including raise/lower) will be added here before this PR leaves draft.

Windows (Windows 11), this branch

  • Built with the ship's own path (x86_64-pc-windows-gnu, unsigned for this test).
  • Smoke in isolation (own USERPROFILE/LOCALAPPDATA/data dir, port 18852, profile linuxpr-smoke; the live Adom Bridge and live pup untouched): pup_open_window 4.2 s cold / 2.8 s warm, pup_eval (Windows UA unchanged), pup_navigate, pup_screenshot, pup_open_tab, pup_list_windows, pup_close_window. The foreground window never changed because of pup.
  • Unit tests: cargo test --workspace (Linux host) green. Windows-target unit tests through WSL interop all pass except pup-core data_dir_tests::retention_sweep_drops_old_then_oldest_over_cap_and_keeps_recent, which fails identically on unmodified master (pre-existing). tools/check-borders.sh OK.
  • Not run: tools/parity.py --via-ab, tools/ab-smoke.sh, tools/foreground-proof.sh (they need the candidate installed through a desktop's real ab; for the release step). macOS not compiled here (ring needs a macOS C toolchain); the shared changes are trait defaults plus a statvfs call.

Notes for the owner

  1. Zip size: 9.1 MB with both binaries (4.1 MB Windows-only before). Per-OS zips through the wiki manifest's platforms.linux.url would keep the Windows download at 4 MB if preferred.
  2. On Windows the default pup_browser_processes matches only pup-profiles\<name>, but under ab the profiles live in <ADOM_BRIDGE_DATA_DIR>\profiles\<name>, so the orphan/stray-lane checks may not match there. Not changed here.
  3. macOS also presents a Windows UA on the extension lane; left as is.
  4. wikiView:"public" on the extension lane returned wikiView:"public" but the page still rendered signed in (shared code; seen on Linux, not checked on Windows).
  5. Window recording on the extension lane uses ab's desktop_record_window_start, which ab for Linux does not have yet; pup answers that plainly.

Not merged, no release, no manifest moved.

🤖 Generated with Claude Code

Diff Skip to comments (7)

CHANGELOG.md+29
@@ -1,5 +1,34 @@ # Changelog — Puppeteer Bridge +## Unreleased: pup on Linux++pup now runs on Linux desktops from the same package as on Windows. The release zip carries both programs,+`adom-pup-bridge.exe` and `adom-pup-bridge` (x86_64, glibc 2.30+), and its `bridge.json` names each in+`spawn.entrypoints`, so Adom Bridge for Linux 2.1.175+ starts pup instead of answering `unsupported_on_this_os`.+Windows behaviour is unchanged.++- **A Linux host crate** (`rust/host-linux`) behind the same `host` traits as Windows: windows through X11/EWMH+  (found by `_NET_WM_PID`, tagged with an X property so a restarted pup re-adopts them, lowered without+  activation), processes through `/proc` with a reaper for exit codes, secrets in the desktop keyring through the+  Secret Service (or a 0600 file when no keyring is unlocked; `credential_set` says which), idle time from GNOME or+  the X screen saver. `tools/check-borders.sh` keeps X11, procfs and D-Bus inside it.+- **Background windows on Wayland**: pup's browser runs under XWayland (`--ozone-platform=x11`) so its windows can+  be kept at the bottom of the stack; the user's own apps stay native. Measured on Ubuntu 24.04 GNOME Wayland: the+  user's active window stayed active through open, navigate, new tab and close.+- **The browser sandbox on Ubuntu 23.10+**, where AppArmor blocks unprivileged user namespaces: Chrome for Testing+  runs under an installed setuid helper (Google Chrome's or the distro Chromium's); `--no-sandbox` only when there+  is none. `pup_readiness` reports `browserSandbox`.+- **A clean environment for the browser**: Adom Bridge for Linux is an AppImage, whose library and GTK paths point+  into its own mount; pup removes them before starting Chrome.+- **No keyring prompts**: `--password-store=basic` on Linux, like `--use-mock-keychain` on macOS.+- **The presented user agent is Linux's** (`X11; Linux x86_64`, platform `Linux`) on the extension lane, matching+  `navigator.platform`. It said Windows before.+- Chrome for Testing linux64 unpacks with `bsdtar`, `tar`, `unzip` or `python3` (exec bits restored);+  `pup_readiness.diskFreeMb` is measured on Linux and macOS (statvfs).+- A browser that fails to start explains itself in `~/.adom/bridge-logs/pup-browser.log`.+- Windows-only on Linux: taskbar identity, assisted click, importing saved passwords from Chrome or Edge, and window+  recording (it uses Adom Bridge's native recorder). Guide: [pup on Linux](linux/README.md).+ ## 2.0.443 — 2026-09-06 — no window left behind: closes verified against the OS, orphan windows swept on connect  Recreating John's windows across reinstalls left three Chrome windows nobody tracked, wearing old tiles:
README.md+14−4
@@ -2,7 +2,7 @@  **pup gives your AI a real browser on your desktop that it drives completely, in the background, signed in as you.** It opens real Chrome windows that never jump in front of your work, keeps one shared profile where your logins accumulate, fills saved passwords for the AI, and labels every window on the taskbar with the name of the AI thread that owns it. Ask your AI to "open it in pup" and it does. -pup is one small signed Windows program (about 8 MB) that [Adom Bridge](https://wiki.adom.inc/adom/adom-bridge) installs and keeps up to date for you. This page holds its source, its AI skills, and the guides below. It is open source under the MIT license.+pup is one small program for Windows and Linux (about 8 MB on Windows, signed) that [Adom Bridge](https://wiki.adom.inc/adom/adom-bridge) installs and keeps up to date for you. This page holds its source, its AI skills, and the guides below. It is open source under the MIT license.  ## Watch the two-minute tour @@ -49,6 +49,15 @@ Right-clicking a pup tile opens its jump list. Every window offers Annotate this  The dashboard lists every pup window grouped by the AI thread that owns it, with each tab, whether it is signed in, which profile it surfs on, and a mirror of the taskbar buttons. Every icon carries a "how this icon was chosen" note, so a wrong icon is never a mystery. The Say it panel lists phrases you can paste into any AI thread, and the gear opens Settings. Open it from the dock bar card, the jump list, or by asking your AI for "your dashboard in pup". Full tour: [The dashboard](docs/dashboard.md). +## Linux: pup on Linux desktops++<table>+<tr>+<td width="104"><a href="linux/README.md"><img src="linux/img/pup-linux-icon.png" width="112" alt="pup on Linux"></a><br><sub>&nbsp;&nbsp;&nbsp;&nbsp;Linux&nbsp;&nbsp;&nbsp;&nbsp;</sub></td>+<td>The same pup package runs on Linux desktops, on X11 and on Wayland, started and updated by Adom Bridge for Linux. <strong><a href="linux/README.md">pup on Linux</a></strong> explains how its windows stay behind your work on each kind of session, how the browser is sandboxed, and where logins are kept. Use it to set up a Linux machine for your AI, or to see what differs from Windows.<br><br><a href="linux/README.md">Requirements, behavior and troubleshooting</a></td>+</tr>+</table>+ ## Feature guides  | guide | what it covers |@@ -59,6 +68,7 @@ The dashboard lists every pup window grouped by the AI thread that owns it, with | [Settings](docs/settings.md) | Every setting in the Settings window, its default, and what it changes. | | [Logins, passwords and cards](docs/logins-and-cards.md) | pup's own vault, auto-capture and fill, reason-gated sign-in, importing from Chrome or Edge, Google accounts, Basic-Auth, cards. | | [Screenshots and recording](docs/screenshots-and-recording.md) | Window, element and full-page screenshots, recording with sound, and annotate. |+| [pup on Linux](linux/README.md) | Requirements, background windows on X11 and Wayland, the browser sandbox, logins, and what is Windows-only. | | [Install size and footprint](docs/install-and-footprint.md) | What lands on disk, measured, what cleans itself up, and how to remove pup completely. | | [pup or native browser?](docs/pup-vs-native-browser.md) | When the AI should use pup, when it should drive your own browser, and why both exist. | | [Open source: code and contributing](docs/open-source-and-contributing.md) | How the code is organized, how to build and test it, how to add a feature, and how to send it back. |@@ -80,8 +90,8 @@ The dashboard lists every pup window grouped by the AI thread that owns it, with  | dependency | required? | why | |---|---|---|-| Windows 10 or 11 | yes | pup is a native Windows program. A Mac port is in progress; Macs run the older build for now. |-| Adom Bridge 2.1 or later | yes | It installs, updates and starts pup, and carries every command from your AI to it. Hydrogen includes it. |+| Windows 10 or 11, or Linux (x86_64, glibc 2.30+, X11 or Wayland) | yes | pup is a native program for each. A Mac port is in progress; Macs run the older build for now. Linux details: [pup on Linux](linux/README.md). |+| Adom Bridge 2.1 or later (2.1.175 or later on Linux) | yes | It installs, updates and starts pup, and carries every command from your AI to it. Hydrogen includes it. | | A browser build | automatic | pup downloads its own Chrome build once (about 200 MB, 428 MB unpacked) and uses your installed Chrome or Edge until that finishes. |  ## Skills@@ -103,4 +113,4 @@ The skills teach any AI how to use pup well. The main `pup` skill is the start;  ## Open source -The complete source is in this page's **Files** tab: one Rust workspace of twelve crates, with every Windows call kept in a single crate so the Mac port only implements one interface. [Open source: code and contributing](docs/open-source-and-contributing.md) walks through the layout, the build, the tests and the release gates, and how to send a change back as a pull request on this page. Issues and ideas are welcome in the **Issues** tab.+The complete source is in this page's **Files** tab: one Rust workspace, with every operating-system call kept behind one interface: a Windows crate, a Linux crate and the Mac port each implement it, and the rest of pup is shared. [Open source: code and contributing](docs/open-source-and-contributing.md) walks through the layout, the build, the tests and the release gates, and how to send a change back as a pull request on this page. Issues and ideas are welcome in the **Issues** tab.
SKILL.md+20
@@ -582,6 +582,26 @@ Every `pup_*` verb works on macOS; only the OS-presentation layer differs: - **Recording** needs ffmpeg (`brew install ffmpeg`, the error hint says so; re-detects   without a restart). +## Linux differences, same verbs, same package++pup runs on Linux desktops (x86_64, X11 or Wayland) from the same package; Adom Bridge for Linux+2.1.175+ starts pup's Linux binary. Every everyday verb works (open, navigate, eval, click, type,+tabs, screenshots, vault, close, recovery after a restart). What differs:++- **Background = bottom of the window stack, never activated**, as on Windows. On a Wayland+  session pup's browser runs under XWayland so pup can keep its windows behind the user's apps;+  the user's own apps are untouched. `pup_raise_os_window` (with `foregroundReason`) activates.+- **`pup_readiness.browserSandbox`** says how Chrome is sandboxed (`namespaces`, a setuid helper,+  or `off: ...` with the fix). Ubuntu 23.10+ needs an installed Google Chrome or Chromium for its+  setuid helper; pup finds it by itself.+- **Saved passwords** go to the desktop keyring (Secret Service), or to `~/.adom/pup-secrets.json`+  (0600) when no keyring is unlocked; `credential_set` replies with `storage`.+- **Windows-only on Linux:** taskbar identity (tiles, badges, jump lists, progress, flash),+  `pup_assisted_click`, `pup_import_browser_logins`, and window recording (`pup_record_*` needs+  Adom Bridge's native recorder, not in the Linux Adom Bridge yet). Screenshots work.+- A failed browser start explains itself in `~/.adom/bridge-logs/pup-browser.log`.+  Full guide: `linux/README.md` on the wiki page.+ ## Taskbar preference: the USER can ask for "combined mode"  > **WINDOWS ONLY.** macOS has no taskbar; `pup_configure` stores the preference and returns
docs/PLAN-rust-port.md+4−3
@@ -425,9 +425,10 @@ warmup. The Rust port must make this whole class of failure impossible by constr   and the pkg publish step are untouched. - `tools/check-borders.sh` (section 4) and `cargo check --target aarch64-apple-darwin -p pup-host-mac`   run every ship, so a Windows leak or a broken mac stub fails the ship, not Kyle's afternoon.-- Manifests are single-zip today (ab wiki #111 caveat), so the exe zip is Windows only. When Kyle's-  crate compiles, the mac build needs either per-platform manifests in ab or a second page. That is-  an ab ask for later; it does not block anything here.+- Manifests were single-zip when this was written (ab wiki #111 caveat), so the exe zip was Windows only.+  ab 2.1.175 added `spawn.entrypoints` (one zip, a binary per OS) and per-OS packages in the wiki+  manifest (`platforms.<os>.url`). The Linux build uses the first: `tools/rust-package.py` puts the+  Windows exe and the Linux binary in one zip. The Mac build can do the same.  ## 8. Risks, stated plainly 
docs/install-and-footprint.md+2
@@ -12,6 +12,8 @@ What pup puts on your machine, measured, what cleans itself up, and how to remov  On a brand-new Windows machine (measured 2026-09-23): pup installed in 1 second, was usable at once on the system Edge, finished the browser download in 6 seconds and unpacking in 3, and opened its first window 24 seconds after a bare install. +On Linux the program is `adom-pup-bridge` in `~/.local/share/Adom Bridge/bridges-cache/puppeteer`, and the browser build, profiles and logs are in the same `~/.cache/puppeteer` and `~/.adom` folders. See [pup on Linux](../linux/README.md).+ ## What grows with use  Measured on a desktop used heavily for a month (2026-10-03):
docs/open-source-and-contributing.md+11−6
@@ -4,7 +4,7 @@ pup is open source under the MIT license. Its complete source is in this page's  ## How the code is organized -pup is one Rust workspace in `rust/`, built into a single Windows program. Twelve crates, each with one job:+pup is one Rust workspace in `rust/`, built into one program for Windows and one for Linux. Thirteen crates, each with one job:  | crate | lines | job | |---|---|---|@@ -19,25 +19,30 @@ pup is one Rust workspace in `rust/`, built into a single Windows program. Twelv | `core` | 390 | Shared types: settings, the reply envelope, caller identity, logging. | | `host` | 150 | The operating-system interface, as traits and nothing else. | | `host-win` | 570 | The Windows implementation of `host`, and the only crate allowed to call Windows directly. |+| `host-linux` | 1,000 | The Linux implementation: X11/XWayland windows (EWMH), `/proc`, the Secret Service, the Chromium sandbox choice. The only crate allowed to name X11, procfs or D-Bus. | | `host-mac` | 50 | The macOS implementation, in progress. | -The rule that keeps the port possible: every Windows call lives in `host-win` behind a trait in `host`. `tools/check-borders.sh` fails the build if a Windows or macOS API name appears anywhere else. The Mac port is one crate: implement `host-mac`.+The rule that keeps the ports possible: every Windows call lives in `host-win` behind a trait in `host`, every Linux call in `host-linux`. `tools/check-borders.sh` fails the build if a Windows, macOS or Linux API name appears anywhere else. `bridge/src/main.rs` picks the host for the target it is compiled for; nothing else knows which OS it runs on. The Mac port is one crate: implement `host-mac`.  The browser extensions that pup loads into its windows live in `src/extension*/` as plain JavaScript and are built into the program, along with the dashboard (`rust/dashboard/assets/`) and the command catalog.  ## Build and test -You need Rust with the Windows target. From Linux or WSL, install mingw-w64 for the linker; on Windows, use the stable toolchain.+Build on Linux or WSL: the Windows program cross-compiles with mingw-w64, the Linux program builds natively. Build the Linux one on a distribution no newer than the oldest you support (the program needs only the glibc it was linked against; glibc 2.30 today).  ```bash-rustup target add x86_64-pc-windows-gnu+rustup target add x86_64-pc-windows-gnu x86_64-unknown-linux-gnu python3 tools/gen-assets.py                      # embed extensions, dashboard and the command catalog-tools/check-borders.sh                           # Windows calls only in host-win+tools/check-borders.sh                           # OS calls only in host-win / host-linux / host-mac cd rust && cargo test --workspace                # unit tests, on any OS cargo build --release --target x86_64-pc-windows-gnu # -> rust/target/x86_64-pc-windows-gnu/release/adom-pup-bridge.exe+cargo build --release --target x86_64-unknown-linux-gnu+# -> rust/target/x86_64-unknown-linux-gnu/release/adom-pup-bridge ``` +`tools/rust-build.sh <version>` does all of this, signs the Windows program, and `tools/rust-package.py` puts both programs in one release zip whose `bridge.json` names each one in `spawn.entrypoints`.+ ## Add a feature  Most features are a new command or a change to one. To add `pup_something`:@@ -45,7 +50,7 @@ Most features are a new command or a change to one. To add `pup_something`: 1. Write the handler in the fitting file under `rust/verbs/src/` (`tabs.rs`, `input.rs`, `extras.rs`, ...): a function of `(ctx, caller, args)` that returns `ok(json!({...}))` or `err(code, message, hint)`. 2. Route it in `dispatch` in `rust/verbs/src/lib.rs` and add its name to `PHASE1_VERBS`. 3. Describe it for AIs: add an entry to `_STUBS` in `tools/gen-assets.py` with a summary, inputs, an example and a hint. The build stops if a routed command has no description, and the same list becomes both `pup_describe` and the manifest Adom Bridge reads, so they cannot drift apart.-4. If it needs the operating system, add a method to a trait in `host`, implement it in `host-win`, and give `host-mac` a stub.+4. If it needs the operating system, add a method to a trait in `host`, implement it in `host-win` and `host-linux`, and give `host-mac` a stub (or give the trait method a default that answers "not on this OS"). 5. Add a test, and run the build and tests above.  Good first changes: a clearer hint on a command that confused your AI, a new jump list task, a Settings row with a better explanation, a faster screenshot path. Open the folder in Claude Code and describe the change you want; the code's comments record why things are the way they are, often with the date and the problem that caused them.
linux/README.mdadded+72
@@ -0,0 +1,72 @@+# pup on Linux++pup runs on Linux desktops from the same package as on Windows. Adom Bridge for Linux installs it, starts it and updates it; your AI uses the same `pup_*` verbs and the same skills. Windows open in the background, stay behind your work, and are fully drivable: navigate, click, type, eval, screenshot.++![A pup window on Ubuntu 24.04 (GNOME, Wayland), captured with pup_screenshot through Adom Bridge](img/pup-linux-page.png)++## What you need++| requirement | details |+|---|---|+| A 64-bit x86 Linux desktop | glibc 2.30 or newer: Ubuntu 20.04+, Debian 11+, Fedora 32+ and their relatives. |+| An X11 or Wayland session | GNOME, Cinnamon, KDE and other EWMH window managers. |+| Adom Bridge for Linux 2.1.175 or later | It picks pup's Linux binary from the package (`spawn.entrypoints.linux`). |+| A browser build | Automatic. pup downloads Chrome for Testing for linux64 once (about 200 MB, about 420 MB unpacked) into `~/.cache/puppeteer`, at the version of your installed Google Chrome when you have one. |+| A Chromium sandbox | Usually nothing to do; see [The browser sandbox](#the-browser-sandbox). |++`pup_readiness` answers whether pup is ready and which browser it will use, as on Windows. On Linux it also reports `browserSandbox`.++## How windows stay in the background++pup never activates its own windows. A new window is created unfocused and placed at the bottom of the window stack, behind everything you are working on, and it stays there. Only `pup_raise_os_window` with a `foregroundReason` brings one to the front, with a caption saying why.++- **X11 sessions.** pup lowers its windows through the window manager (EWMH), the same rule as Windows' bottom-of-z-order.+- **Wayland sessions.** Wayland does not let one program list, lower or raise another program's windows. So pup starts its browser under XWayland (`--ozone-platform=x11`); pup's windows are then X11 windows that pup can find, tag and keep at the bottom, while your own apps stay native Wayland and are never touched. To run pup's browser as a native Wayland client instead (at the cost of background placement), set `PUP_OZONE_PLATFORM=wayland` in Adom Bridge's environment.++Measured on Ubuntu 24.04 (GNOME, Wayland): a `pup_open_window` returned in under 2 seconds with the window at the bottom of the stack, and the app the user was working in stayed the active window through the open, a navigate, a new tab and a close.++## The browser sandbox++Chrome sandboxes its renderers with Linux user namespaces. Ubuntu 23.10 and later block those for programs without their own AppArmor profile, which includes a Chrome for Testing in your home folder. pup handles it in this order:++| `browserSandbox` | when |+|---|---|+| `namespaces` | the system allows unprivileged user namespaces (most distributions) |+| `the browser's own setuid helper / AppArmor profile` | the browser is an installed package that brings its own (Google Chrome, distro Chromium) |+| `setuid helper <path>` | pup runs Chrome for Testing under an installed setuid sandbox helper, for example Google Chrome's `/opt/google/chrome/chrome-sandbox` |+| `off: ...` | none of the above: the browser runs with `--no-sandbox`. Installing Google Chrome or your distribution's Chromium package provides a helper and turns the sandbox back on |++## Logins and saved passwords++pup's vault keeps passwords in your desktop keyring through the Secret Service (GNOME Keyring, KWallet), with the same attributes keytar uses. pup never asks you to unlock a keyring: when no keyring is unlocked (common on desktops that sign in automatically), it keeps them in `~/.adom/pup-secrets.json` with mode 0600 instead, and `credential_set` says which one it used. pup's browser keeps its own cookies with `--password-store=basic`, so Chrome never raises a keyring prompt either.++## Where things live++| location | what it is |+|---|---|+| `~/.local/share/Adom Bridge/bridges-cache/puppeteer` | the program (`adom-pup-bridge`), put there by Adom Bridge |+| `~/.cache/puppeteer` | the browser build |+| `~/.adom/pup-profiles/adom-you` | the shared profile: cookies, logins and site data (or `<data dir>/profiles` when Adom Bridge gives pup a data directory) |+| `~/.adom/bridge-logs/puppeteer.log`, `pup-browser.log` | pup's log, and the browser's own error output (where a failed start explains itself) |+| `~/.adom/pup-secrets.json` | saved passwords when no keyring is unlocked |++## What is Windows-only++These answer plainly on Linux (or store the setting and paint nothing) instead of failing silently:++- **Taskbar identity**: per-thread tiles, favicon badges, jump lists, taskbar progress and flashing. On Linux the window title carries the page and the session.+- **Assisted click** (`pup_assisted_click`), which uses the Windows cursor and foreground rules.+- **Importing saved passwords from your own Chrome or Edge** (`pup_import_browser_logins`). Sign-in through pup's vault works.+- **Recording a window** (`pup_record_*`) uses Adom Bridge's native window recorder, which the Linux Adom Bridge does not include yet. Screenshots (`pup_screenshot`, element and full-page) work.++## Troubleshooting++- **`pup_open_window` says the browser did not start.** Read the end of `~/.adom/bridge-logs/pup-browser.log`. "No usable sandbox" means none of the sandbox options above is available; "Missing X server or $DISPLAY" means Adom Bridge was started without the desktop session's `DISPLAY` and `XAUTHORITY`.+- **Which browser and sandbox will pup use?** `pup_readiness`: `browserKind`, `browserExecutablePath`, `browserSandbox`, `diskFreeMb`.+- **Snap and Flatpak browsers** are not used: their confinement refuses the profile folder pup keeps under `~/.adom`. pup uses Chrome for Testing, or a native Google Chrome, Chromium or Edge package.++## Tested on++| distribution | desktop | result |+|---|---|---|+| Ubuntu 24.04 | GNOME, Wayland | through Adom Bridge 2.1.185: readiness, open, eval, navigate, screenshot, tabs, list, close, vault, bridge restart with window recovery |
linux/img/pup-linux-icon.pngadded
⋯ 1 unchanged line ⋯
linux/img/pup-linux-page.pngadded
⋯ 1 unchanged line ⋯
rust/Cargo.lock+752−2
@@ -17,6 +17,7 @@ dependencies = [  "pup-core",  "pup-dashboard",  "pup-host",+ "pup-host-linux",  "pup-host-mac",  "pup-host-win",  "pup-session",@@ -51,6 +52,143 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16" +[[package]]+name = "async-broadcast"+version = "0.7.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532"+dependencies = [+ "event-listener",+ "event-listener-strategy",+ "futures-core",+ "pin-project-lite",+]++[[package]]+name = "async-channel"+version = "2.5.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2"+dependencies = [+ "concurrent-queue",+ "event-listener-strategy",+ "futures-core",+ "pin-project-lite",+]++[[package]]+name = "async-executor"+version = "1.14.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a"+dependencies = [+ "async-task",+ "concurrent-queue",+ "fastrand",+ "futures-lite",+ "pin-project-lite",+ "slab",+]++[[package]]+name = "async-io"+version = "2.6.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc"+dependencies = [+ "autocfg",+ "cfg-if",+ "concurrent-queue",+ "futures-io",+ "futures-lite",+ "parking",+ "polling",+ "rustix",+ "slab",+ "windows-sys 0.61.2",+]++[[package]]+name = "async-lock"+version = "3.4.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311"+dependencies = [+ "event-listener",+ "event-listener-strategy",+ "pin-project-lite",+]++[[package]]+name = "async-process"+version = "2.5.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75"+dependencies = [+ "async-channel",+ "async-io",+ "async-lock",+ "async-signal",+ "async-task",+ "blocking",+ "cfg-if",+ "event-listener",+ "futures-lite",+ "rustix",+]++[[package]]+name = "async-recursion"+version = "1.2.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "4f8abc12baad266b1c8cec146854c195b5864b4221d4b2ca7296a7ae82d9e451"+dependencies = [+ "proc-macro2",+ "quote",+ "syn 3.0.5",+]++[[package]]+name = "async-signal"+version = "0.2.14"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485"+dependencies = [+ "async-io",+ "async-lock",+ "atomic-waker",+ "cfg-if",+ "futures-core",+ "futures-io",+ "rustix",+ "signal-hook-registry",+ "slab",+ "windows-sys 0.61.2",+]++[[package]]+name = "async-task"+version = "4.7.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de"++[[package]]+name = "async-trait"+version = "0.1.92"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667"+dependencies = [+ "proc-macro2",+ "quote",+ "syn 3.0.5",+]++[[package]]+name = "atomic-waker"+version = "1.1.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"+ [[package]] name = "autocfg" version = "1.5.1"@@ -84,6 +222,25 @@ dependencies = [  "generic-array", ] +[[package]]+name = "blocking"+version = "1.7.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "a70e4329df6cb94385eed412ec92375c3cdd8a6e502493d1229b6414e4036dfa"+dependencies = [+ "async-channel",+ "async-task",+ "futures-io",+ "futures-lite",+ "piper",+]++[[package]]+name = "bumpalo"+version = "3.20.3"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"+ [[package]] name = "bytemuck" version = "1.25.2"@@ -136,6 +293,15 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" +[[package]]+name = "concurrent-queue"+version = "2.5.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973"+dependencies = [+ "crossbeam-utils",+]+ [[package]] name = "core-foundation" version = "0.10.1"@@ -203,6 +369,12 @@ dependencies = [  "cfg-if", ] +[[package]]+name = "crossbeam-utils"+version = "0.8.23"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6"+ [[package]] name = "crypto-common" version = "0.1.7"@@ -256,12 +428,49 @@ dependencies = [  "syn 3.0.5", ] +[[package]]+name = "endi"+version = "1.1.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099"++[[package]]+name = "enumflags2"+version = "0.7.12"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef"+dependencies = [+ "enumflags2_derive",+ "serde",+]++[[package]]+name = "enumflags2_derive"+version = "0.7.12"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827"+dependencies = [+ "proc-macro2",+ "quote",+ "syn 2.0.119",+]+ [[package]] name = "equivalent" version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +[[package]]+name = "errno"+version = "0.3.14"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"+dependencies = [+ "libc",+ "windows-sys 0.59.0",+]+ [[package]] name = "euclid" version = "0.22.14"@@ -271,6 +480,32 @@ dependencies = [  "num-traits", ] +[[package]]+name = "event-listener"+version = "5.4.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2"+dependencies = [+ "parking",+ "pin-project-lite",+]++[[package]]+name = "event-listener-strategy"+version = "0.5.4"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93"+dependencies = [+ "event-listener",+ "pin-project-lite",+]++[[package]]+name = "fastrand"+version = "2.5.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"+ [[package]] name = "fdeflate" version = "0.3.7"@@ -355,6 +590,49 @@ dependencies = [  "percent-encoding", ] +[[package]]+name = "futures-core"+version = "0.3.34"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e"++[[package]]+name = "futures-io"+version = "0.3.34"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed"++[[package]]+name = "futures-lite"+version = "2.6.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad"+dependencies = [+ "fastrand",+ "futures-core",+ "futures-io",+ "parking",+ "pin-project-lite",+]++[[package]]+name = "futures-task"+version = "0.3.34"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd"++[[package]]+name = "futures-util"+version = "0.3.34"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc"+dependencies = [+ "futures-core",+ "futures-task",+ "pin-project-lite",+ "slab",+]+ [[package]] name = "generic-array" version = "0.14.7"@@ -365,6 +643,16 @@ dependencies = [  "version_check", ] +[[package]]+name = "gethostname"+version = "1.1.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8"+dependencies = [+ "rustix",+ "windows-link",+]+ [[package]] name = "getrandom" version = "0.2.17"@@ -376,6 +664,17 @@ dependencies = [  "wasi", ] +[[package]]+name = "getrandom"+version = "0.4.3"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"+dependencies = [+ "cfg-if",+ "libc",+ "r-efi",+]+ [[package]] name = "gif" version = "0.14.2"@@ -403,6 +702,18 @@ version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +[[package]]+name = "hermit-abi"+version = "0.5.3"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284"++[[package]]+name = "hex"+version = "0.4.3"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"+ [[package]] name = "http" version = "1.5.0"@@ -579,6 +890,17 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]]+name = "js-sys"+version = "0.3.107"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "8dc09d2154098bbf4ef54eab6b117a3c24cbbffe94046b16ee6d6d4510584d3a"+dependencies = [+ "cfg-if",+ "futures-util",+ "wasm-bindgen",+]+ [[package]] name = "kurbo" version = "0.11.3"@@ -602,6 +924,12 @@ version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" +[[package]]+name = "linux-raw-sys"+version = "0.12.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"+ [[package]] name = "litemap" version = "0.8.3"@@ -620,6 +948,15 @@ version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" +[[package]]+name = "memoffset"+version = "0.9.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a"+dependencies = [+ "autocfg",+]+ [[package]] name = "miniz_oxide" version = "0.8.9"@@ -714,6 +1051,22 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]]+name = "ordered-stream"+version = "0.2.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50"+dependencies = [+ "futures-core",+ "pin-project-lite",+]++[[package]]+name = "parking"+version = "2.2.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba"+ [[package]] name = "percent-encoding" version = "2.3.2"@@ -726,6 +1079,23 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5be167a7af36ee22fe3115051bc51f6e6c7054c9348e28deb4f49bd6f705a315" +[[package]]+name = "pin-project-lite"+version = "0.2.17"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"++[[package]]+name = "piper"+version = "0.2.5"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1"+dependencies = [+ "atomic-waker",+ "fastrand",+ "futures-io",+]+ [[package]] name = "png" version = "0.17.16"@@ -752,6 +1122,20 @@ dependencies = [  "miniz_oxide 0.8.9", ] +[[package]]+name = "polling"+version = "3.11.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218"+dependencies = [+ "cfg-if",+ "concurrent-queue",+ "hermit-abi",+ "pin-project-lite",+ "rustix",+ "windows-sys 0.61.2",+]+ [[package]] name = "potential_utf" version = "0.1.6"@@ -770,6 +1154,15 @@ dependencies = [  "zerocopy", ] +[[package]]+name = "proc-macro-crate"+version = "3.5.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f"+dependencies = [+ "toml_edit",+]+ [[package]] name = "proc-macro2" version = "1.0.107"@@ -783,6 +1176,7 @@ dependencies = [ name = "pup-browser" version = "2.0.739" dependencies = [+ "libc",  "pup-core",  "pup-host",  "serde",@@ -833,6 +1227,18 @@ dependencies = [  "thiserror", ] +[[package]]+name = "pup-host-linux"+version = "2.0.739"+dependencies = [+ "libc",+ "pup-core",+ "pup-host",+ "serde_json",+ "x11rb",+ "zbus",+]+ [[package]] name = "pup-host-mac" version = "2.0.739"@@ -928,6 +1334,12 @@ dependencies = [  "proc-macro2", ] +[[package]]+name = "r-efi"+version = "6.0.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"+ [[package]] name = "rand" version = "0.8.8"@@ -955,7 +1367,7 @@ version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" dependencies = [- "getrandom",+ "getrandom 0.2.17", ]  [[package]]@@ -989,7 +1401,7 @@ checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" dependencies = [  "cc",  "cfg-if",- "getrandom",+ "getrandom 0.2.17",  "libc",  "untrusted",  "windows-sys 0.52.0",@@ -1001,6 +1413,19 @@ version = "0.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97" +[[package]]+name = "rustix"+version = "1.1.6"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "e5e76742e5294153d4dde1207dbec5bdd81eef6fabdc0d2118babadabfd858aa"+dependencies = [+ "bitflags 2.13.2",+ "errno",+ "libc",+ "linux-raw-sys",+ "windows-sys 0.59.0",+]+ [[package]] name = "rustls" version = "0.23.43"@@ -1036,6 +1461,12 @@ dependencies = [  "untrusted", ] +[[package]]+name = "rustversion"+version = "1.0.23"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"+ [[package]] name = "security-framework" version = "3.7.0"@@ -1103,6 +1534,17 @@ dependencies = [  "zmij", ] +[[package]]+name = "serde_repr"+version = "0.1.21"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906"+dependencies = [+ "proc-macro2",+ "quote",+ "syn 3.0.5",+]+ [[package]] name = "sha1" version = "0.10.7"@@ -1120,6 +1562,16 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" +[[package]]+name = "signal-hook-registry"+version = "1.4.8"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"+dependencies = [+ "errno",+ "libc",+]+ [[package]] name = "simd-adler32" version = "0.3.10"@@ -1141,6 +1593,12 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" +[[package]]+name = "slab"+version = "0.4.12"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"+ [[package]] name = "smallvec" version = "1.16.0"@@ -1211,6 +1669,19 @@ dependencies = [  "syn 2.0.119", ] +[[package]]+name = "tempfile"+version = "3.27.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"+dependencies = [+ "fastrand",+ "getrandom 0.4.3",+ "once_cell",+ "rustix",+ "windows-sys 0.59.0",+]+ [[package]] name = "thiserror" version = "1.0.69"@@ -1279,6 +1750,67 @@ dependencies = [  "zerovec", ] +[[package]]+name = "toml_datetime"+version = "1.1.2+spec-1.1.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "2b86d767906c6c42421dcba507eb9d203e779497710a47782a224bb871653053"+dependencies = [+ "serde_core",+]++[[package]]+name = "toml_edit"+version = "0.25.17+spec-1.1.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "e3641d5bbb5349a79e1020a242d251efbc546ad8048d133958323ce9c40a9c9c"+dependencies = [+ "indexmap",+ "toml_datetime",+ "toml_parser",+ "winnow",+]++[[package]]+name = "toml_parser"+version = "1.1.5+spec-1.1.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "baa693a8032d7e1cada7d0041e96126df243179ff061456783ac7f12bda4744c"+dependencies = [+ "winnow",+]++[[package]]+name = "tracing"+version = "0.1.44"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"+dependencies = [+ "pin-project-lite",+ "tracing-attributes",+ "tracing-core",+]++[[package]]+name = "tracing-attributes"+version = "0.1.31"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"+dependencies = [+ "proc-macro2",+ "quote",+ "syn 2.0.119",+]++[[package]]+name = "tracing-core"+version = "0.1.36"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"+dependencies = [+ "once_cell",+]+ [[package]] name = "ttf-parser" version = "0.25.1"@@ -1312,6 +1844,17 @@ version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" +[[package]]+name = "uds_windows"+version = "1.2.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e"+dependencies = [+ "memoffset",+ "tempfile",+ "windows-sys 0.61.2",+]+ [[package]] name = "unicode-ident" version = "1.0.24"@@ -1388,6 +1931,17 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" +[[package]]+name = "uuid"+version = "1.29.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "4a0e2d11620c50695fe12bbbfde96b3477fe606e7284c22d505c12ce51214514"+dependencies = [+ "js-sys",+ "serde_core",+ "wasm-bindgen",+]+ [[package]] name = "version_check" version = "0.9.5"@@ -1400,6 +1954,50 @@ version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" +[[package]]+name = "wasm-bindgen"+version = "0.2.130"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "a0181c14ddd6c7b8b0b0132a5f1ea2f32be0d893cd3ea97b6e3b28886b8a3d1c"+dependencies = [+ "cfg-if",+ "rustversion",+ "wasm-bindgen-macro",+ "wasm-bindgen-shared",+]++[[package]]+name = "wasm-bindgen-macro"+version = "0.2.130"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "6e931a02a39abdbf66966fc35902b5ed58344750a70885bed4ebb3d8eaf28ed9"+dependencies = [+ "quote",+ "wasm-bindgen-macro-support",+]++[[package]]+name = "wasm-bindgen-macro-support"+version = "0.2.130"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "2ca4b54280e455bd831e33a6b8e734baf432fe63fbc35c42ac14ce67a043a53a"+dependencies = [+ "bumpalo",+ "proc-macro2",+ "quote",+ "syn 3.0.5",+ "wasm-bindgen-shared",+]++[[package]]+name = "wasm-bindgen-shared"+version = "0.2.130"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "3b1f9c3798725016c6ee5f2f57869ec0831e8bbc0ab0310a86e787e97f41389e"+dependencies = [+ "unicode-ident",+]+ [[package]] name = "webpki-roots" version = "0.26.11"@@ -1424,6 +2022,12 @@ version = "0.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" +[[package]]+name = "windows-link"+version = "0.2.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"+ [[package]] name = "windows-sys" version = "0.52.0"@@ -1442,6 +2046,15 @@ dependencies = [  "windows-targets", ] +[[package]]+name = "windows-sys"+version = "0.61.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"+dependencies = [+ "windows-link",+]+ [[package]] name = "windows-targets" version = "0.52.6"@@ -1506,12 +2119,38 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]]+name = "winnow"+version = "1.0.4"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81"+dependencies = [+ "memchr",+]+ [[package]] name = "writeable" version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" +[[package]]+name = "x11rb"+version = "0.13.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414"+dependencies = [+ "gethostname",+ "rustix",+ "x11rb-protocol",+]++[[package]]+name = "x11rb-protocol"+version = "0.13.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd"+ [[package]] name = "xmlwriter" version = "0.1.0"@@ -1541,6 +2180,76 @@ dependencies = [  "synstructure", ] +[[package]]+name = "zbus"+version = "5.19.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "5db4be7c075cb421e4b7ee645541604239bd243ba7c357511f4ff3a74b555907"+dependencies = [+ "async-broadcast",+ "async-executor",+ "async-io",+ "async-lock",+ "async-process",+ "async-recursion",+ "async-task",+ "async-trait",+ "blocking",+ "enumflags2",+ "event-listener",+ "futures-core",+ "futures-lite",+ "hex",+ "libc",+ "ordered-stream",+ "rustix",+ "serde",+ "serde_repr",+ "tracing",+ "uds_windows",+ "uuid",+ "windows-sys 0.61.2",+ "winnow",+ "zbus_macros",+ "zbus_names",+ "zvariant",+]++[[package]]+name = "zbus_macros"+version = "5.19.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "2990635d09ade6df1868f72f8cac69a876a90981e8bd3c40b1be413f8dc88f40"+dependencies = [+ "proc-macro-crate",+ "proc-macro2",+ "quote",+ "syn 3.0.5",+ "zbus_names",+ "zvariant",+ "zvariant_utils",+]++[[package]]+name = "zbus_names"+version = "4.3.5"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "812a12be4ead7a59e18ea8f047d08add0066b73bc62fc1467066a2472ae088dd"+dependencies = [+ "serde",+ "winnow",+ "zvariant",+]++[[package]]+name = "zcheapstr"+version = "1.2.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "939f186d9ecebe044315a5cb6d6f78568bbc21152adc954d5a0c2f58bdbc9920"+dependencies = [+ "serde",+]+ [[package]] name = "zerocopy" version = "0.8.56"@@ -1647,3 +2356,44 @@ checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" dependencies = [  "zune-core", ]++[[package]]+name = "zvariant"+version = "5.15.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "80bf6d6cd6a4b601acb64cfb8284cdc643fcfa75ef26ae3c46ec8aa4e8a1fbb7"+dependencies = [+ "endi",+ "enumflags2",+ "serde",+ "winnow",+ "zcheapstr",+ "zvariant_derive",+ "zvariant_utils",+]++[[package]]+name = "zvariant_derive"+version = "5.15.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "ef2672456ba1cb54b5eaec136e63135b3c7495fb938e53b28a62e633496d486d"+dependencies = [+ "proc-macro-crate",+ "proc-macro2",+ "quote",+ "syn 3.0.5",+ "zvariant_utils",+]++[[package]]+name = "zvariant_utils"+version = "4.2.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "bad0294361a320b694a328460dc73add56c306150f5cb6bfafc44446120008a3"+dependencies = [+ "proc-macro2",+ "quote",+ "serde",+ "syn 3.0.5",+ "winnow",+]
rust/Cargo.toml+1−1
@@ -1,6 +1,6 @@ [workspace] resolver = "2"-members = ["core", "host", "host-win", "host-mac", "cdp", "browser", "session", "identity", "vault", "verbs", "dashboard", "bridge"]+members = ["core", "host", "host-win", "host-mac", "host-linux", "cdp", "browser", "session", "identity", "vault", "verbs", "dashboard", "bridge"]  # One version for the whole bridge, in lockstep with src/BRIDGE_VERSION (tools/ship.sh bumps both). [workspace.package]
rust/bridge/Cargo.toml+4−1
@@ -28,5 +28,8 @@ pup-host-win = { path = "../host-win" } [target.'cfg(target_os = "macos")'.dependencies] pup-host-mac = { path = "../host-mac" } -[target.'cfg(not(any(windows, target_os = "macos")))'.dependencies]+[target.'cfg(target_os = "linux")'.dependencies]+pup-host-linux = { path = "../host-linux" }++[target.'cfg(not(any(windows, target_os = "macos", target_os = "linux")))'.dependencies] pup-host-win = { path = "../host-win" }
rust/bridge/src/main.rs+3−1
@@ -16,7 +16,9 @@ use tiny_http::{Header, Method, Response, Server}; fn host() -> Arc<dyn Host> { Arc::new(pup_host_win::WinHost) } #[cfg(target_os = "macos")] fn host() -> Arc<dyn Host> { Arc::new(pup_host_mac::MacHost) }-#[cfg(not(any(windows, target_os = "macos")))]+#[cfg(target_os = "linux")]+fn host() -> Arc<dyn Host> { Arc::new(pup_host_linux::LinuxHost) }+#[cfg(not(any(windows, target_os = "macos", target_os = "linux")))] fn host() -> Arc<dyn Host> { Arc::new(pup_host_win::WinHost) }   // dev box: the stubbed impl, so the exe links and /health answers  fn json_response(status: u16, v: &Value) -> Response<std::io::Cursor<Vec<u8>>> {
rust/browser/Cargo.toml+3
@@ -12,3 +12,6 @@ thiserror.workspace = true ureq.workspace = true pup-core = { path = "../core" } pup-host = { path = "../host" }++[target.'cfg(unix)'.dependencies]+libc = "0.2"
rust/browser/src/lib.rs+54−4
@@ -171,6 +171,7 @@ impl Installer {             "browserKind": best.as_ref().map(|c| c.kind.clone()),             "browserSource": best.as_ref().map(|c| c.source.clone()),             "browserExecutablePath": best.as_ref().map(|c| c.executable_path.to_string_lossy().replace('\\', "/")),+            "browserSandbox": best.as_ref().and_then(|c| host.browser_sandbox(&c.executable_path)),             "candidates": cands.iter().map(|c| c.to_json()).collect::<Vec<_>>(),             "defaultBrowser": read_default(host).map(|d| json!({ "kind": d.get("kind"), "source": d.get("source"), "forced": d.get("forced").and_then(|f| f.as_bool()).unwrap_or(false), "pendingInstall": d.get("pendingInstall").and_then(|f| f.as_bool()).unwrap_or(false) })),             "nodeInstalled": true, "nodeVersion": format!("rust/{}", pup_core::version()),@@ -204,7 +205,15 @@ pub fn disk_free_mb(path: &Path) -> Option<u64> {         let ok = unsafe { GetDiskFreeSpaceExW(w.as_ptr(), &mut a, &mut b, &mut c) } != 0;         return if ok { Some(a / (1024 * 1024)) } else { None };     }-    #[cfg(not(windows))]+    #[cfg(unix)]+    {+        let mut p = path.to_path_buf(); while !p.exists() { if !p.pop() { break; } }+        let c = std::ffi::CString::new(p.as_os_str().to_string_lossy().as_bytes()).ok()?;+        let mut st: libc::statvfs = unsafe { std::mem::zeroed() };+        if unsafe { libc::statvfs(c.as_ptr(), &mut st) } != 0 { return None; }+        return Some((st.f_bavail as u64).saturating_mul(st.f_frsize as u64) / (1024 * 1024));+    }+    #[cfg(not(any(windows, unix)))]     { let _ = path; None } } @@ -339,13 +348,19 @@ pub fn prune_broken_cache_dirs(host: &dyn Host, keep_build: Option<&str>) -> Vec pub fn unpack_supervised(archive: &Path, dest: &Path, state: &Arc<Mutex<InstallState>>) -> Result<String, (String, String)> {     let mut methods: Vec<(&str, PathBuf, Vec<String>)> = vec![("tar", PathBuf::from("tar"), vec!["-xf".into(), archive.to_string_lossy().into(), "-C".into(), dest.to_string_lossy().into()])];     if !cfg!(windows) { methods.push(("unzip", PathBuf::from("unzip"), vec!["-q".into(), "-o".into(), archive.to_string_lossy().into(), "-d".into(), dest.to_string_lossy().into()])); }+    // Linux: GNU tar cannot read a zip and `unzip` is not on every desktop install; bsdtar and python3 are+    // the usual others. python3's zipfile drops the unix mode bits, so `restore_exec_bits` runs after it.+    if cfg!(target_os = "linux") {+        methods.insert(0, ("bsdtar", PathBuf::from("bsdtar"), vec!["-xf".into(), archive.to_string_lossy().into(), "-C".into(), dest.to_string_lossy().into()]));+        methods.push(("python3", PathBuf::from("python3"), vec!["-I".into(), "-c".into(), "import sys,zipfile; zipfile.ZipFile(sys.argv[1]).extractall(sys.argv[2])".into(), archive.to_string_lossy().into(), dest.to_string_lossy().into()]));+    }     let mut last = String::new();     for (name, cmd, args) in methods {         let _ = fs::remove_dir_all(dest);         fs::create_dir_all(dest).map_err(|e| ("chrome_install_permission".to_string(), e.to_string()))?;         set(state, |s| { s.unpack_method = Some(name.into()); });         match run_unpack(&cmd, &args, dest, state) {-            Ok(()) => return Ok(name.into()),+            Ok(()) => { restore_exec_bits(archive, dest); return Ok(name.into()); }             Err(e) => { last = format!("unpack via {name} {e}"); pup_core::log(format!("[chrome] {last} — trying the next unpack method")); }         }     }@@ -353,6 +368,28 @@ pub fn unpack_supervised(archive: &Path, dest: &Path, state: &Arc<Mutex<InstallS     Err(("chrome_unpack_failed".into(), last)) } +/// Unix: give every file the zip marks executable (or that is an ELF binary) its exec bits back. A no-op+/// where the unpacker kept them (tar, unzip) and on Windows.+fn restore_exec_bits(archive: &Path, dest: &Path) {+    #[cfg(unix)]+    {+        use std::os::unix::fs::PermissionsExt;+        fn walk(d: &Path, out: &mut Vec<PathBuf>) { if let Ok(rd) = fs::read_dir(d) { for e in rd.flatten() { let p = e.path(); if p.is_dir() { walk(&p, out); } else { out.push(p); } } } }+        let _ = archive;+        let mut files = Vec::new(); walk(dest, &mut files);+        for f in files {+            let Ok(m) = fs::metadata(&f) else { continue };+            if m.permissions().mode() & 0o111 != 0 { continue; }+            let mut head = [0u8; 4];+            let elf = fs::File::open(&f).and_then(|mut h| std::io::Read::read_exact(&mut h, &mut head)).map(|_| head == *b"\x7fELF").unwrap_or(false);+            let script = f.extension().map(|e| e == "sh").unwrap_or(false);+            if elf || script { let mut p = m.permissions(); p.set_mode(p.mode() | 0o755); let _ = fs::set_permissions(&f, p); }+        }+    }+    #[cfg(not(unix))]+    { let _ = (archive, dest); }+}+ fn run_unpack(cmd: &Path, args: &[String], dest: &Path, state: &Arc<Mutex<InstallState>>) -> Result<(), String> {     let mut c = std::process::Command::new(cmd);     c.args(args).stdin(std::process::Stdio::null()).stdout(std::process::Stdio::null()).stderr(std::process::Stdio::piped());@@ -530,9 +567,22 @@ pub fn set_stock_flags(on: bool) { STOCK_FLAGS.store(on, Ordering::SeqCst); } /// for Testing wants to use your confidential information…" (Kyle, 2026-10-20, via H). pup's profiles are /// isolated throwaways, so an in-memory key is right; the mock keychain's key is constant across launches, /// so cookies written under it still persist. Cookies encrypted under a real keychain key before this flag-/// are unreadable once (one re-login). Windows/Linux: nothing (DPAPI / basic store prompt nothing).+/// are unreadable once (one re-login). Windows: nothing (DPAPI prompts nothing).+/// Linux: `--password-store=basic`, the same reasoning as macOS: Chromium's OSCrypt otherwise asks GNOME+/// Keyring / KWallet for "Chrome Safe Storage", and an auto-login desktop leaves the login keyring locked, so+/// every launch would raise an "unlock your keyring" dialog. And on a Wayland session `--ozone-platform=x11`:+/// pup's windows run under XWayland, where pup can find, tag, move and lower them (Wayland gives no client a+/// way to restack or even list another client's windows), so a background window stays behind the user's+/// work. The user's own apps stay native Wayland and are never touched. pub fn platform_switches() -> Vec<String> {-    if cfg!(target_os = "macos") { vec!["--use-mock-keychain".into()] } else { vec![] }+    if cfg!(target_os = "macos") { return vec!["--use-mock-keychain".into()]; }+    if cfg!(target_os = "linux") {+        let mut v = vec!["--password-store=basic".to_string()];+        let wayland = std::env::var("WAYLAND_DISPLAY").map(|w| !w.is_empty()).unwrap_or(false) || std::env::var("XDG_SESSION_TYPE").map(|t| t == "wayland").unwrap_or(false);+        if wayland && std::env::var("PUP_OZONE_PLATFORM").map(|p| p != "wayland").unwrap_or(true) { v.push("--ozone-platform=x11".into()); }+        return v;+    }+    vec![] }  /// The switches every pup launch carries (server.js v2.0.293 merged form: ONE --disable-features).
rust/host-linux/Cargo.tomladded+16
@@ -0,0 +1,16 @@+[package]+name = "pup-host-linux"+version.workspace = true+edition.workspace = true+license.workspace = true+description = "The Linux implementation of pup-host: X11/XWayland window list, lower and tag (EWMH), /proc process table, Secret Service secrets, detached launch with the AppImage environment scrubbed."++[dependencies]+pup-host = { path = "../host" }+pup-core = { path = "../core" }+serde_json.workspace = true++[target.'cfg(target_os = "linux")'.dependencies]+libc = "0.2"+x11rb = { version = "0.13", features = ["screensaver"] }+zbus = { version = "5", default-features = false, features = ["blocking-api", "async-io"] }
rust/host-linux/src/lib.rsadded+601
@@ -0,0 +1,601 @@+//! pup-host-linux: the Linux host. Same traits as pup-host-win and pup-host-mac; each method names the+//! Windows counterpart it mirrors. The ONLY crate in the workspace that may name X11, EWMH, /proc or the+//! Secret Service (tools/check-borders.sh).+//!+//! The Linux model:+//!   - a "window handle" is the browser's X11 client window id. On an X11 session that is the real+//!     window; on a Wayland session pup launches its browser with `--ozone-platform=x11`+//!     (pup-browser's `platform_switches`), so pup's windows are XWayland windows that this crate can+//!     enumerate, tag, move and lower through the X server, while the user's own Wayland apps are+//!     never visible to it and never touched. Rows come from EWMH `_NET_CLIENT_LIST` and are owned+//!     by `_NET_WM_PID`.+//!   - "bottom of the z-order, never activated" = EWMH `_NET_RESTACK_WINDOW` (source: pager, detail:+//!     Below), plus a ConfigureWindow stack-mode Below for window managers without it. Nothing here+//!     ever sends `_NET_ACTIVE_WINDOW` except `raise_window`, which the verbs gate on a reason.+//!   - window tags: an X property `_ADOM_PUP_TAGS` on the window itself (Windows: SetProp), so a+//!     restarted bridge recognises its windows exactly as on Windows.+//!   - processes: /proc. A launched browser is our child (in its own session, so it outlives the+//!     bridge); a reaper thread waits on it so its exit code is readable and it never lingers as a zombie.+//!   - the browser inherits a CLEAN environment: Adom Bridge runs as an AppImage, whose LD_LIBRARY_PATH,+//!     GTK_*, GIO_*, GST_* and PATH entries point into its own mount; Chrome must not load those.+//!   - secrets: the freedesktop Secret Service (GNOME Keyring, KWallet) in keytar's attribute layout,+//!     never prompting; when there is no unlocked keyring (auto-login desktops leave the login keyring+//!     locked) a 0600 file under ~/.adom holds them instead.+//!   - taskbar-shaped work (identity, badges, jump lists) is ab's job through `AbClient`; nothing here+//!     invents a dock equivalent.++#![cfg(target_os = "linux")]++use pup_host::{HostPaths, HostProcess, HostSecrets, HostWindow, HostWindows};+use std::collections::HashMap;+use std::path::{Path, PathBuf};+use std::sync::Mutex;+use std::time::{Duration, Instant};++mod x11;+mod secrets;++pub struct LinuxHost;++/// The Windows class name the verbs crate asks for; here it means "any Chromium-family window".+const CHROME_CLASS: &str = "Chrome_WidgetWin_1";++fn is_chromium_class(class: &str) -> bool {+    let c = class.to_ascii_lowercase();+    c.contains("chrome") || c.contains("chromium") || c.contains("edge")+}++fn class_matches(class: &str, class_filter: Option<&str>) -> bool {+    match class_filter {+        None => true,+        Some(CHROME_CLASS) => is_chromium_class(class),+        Some(f) => class.eq_ignore_ascii_case(f) || (is_chromium_class(class) && is_chromium_class(f)),+    }+}++// ───────────────────────── /proc ─────────────────────────++/// (pid, ppid, rss KB, state char) for every process.+fn proc_table() -> Vec<(u32, u32, u64, char)> {+    let page_kb = (unsafe { libc::sysconf(libc::_SC_PAGESIZE) }.max(4096) as u64) / 1024;+    let Ok(rd) = std::fs::read_dir("/proc") else { return Vec::new() };+    rd.flatten().filter_map(|e| {+        let pid: u32 = e.file_name().to_str()?.parse().ok()?;+        let stat = std::fs::read_to_string(format!("/proc/{pid}/stat")).ok()?;+        let (state, ppid, rss_pages) = parse_stat(&stat)?;+        Some((pid, ppid, rss_pages * page_kb, state))+    }).collect()+}++/// /proc/<pid>/stat: "pid (comm) S ppid ... rss(24th field)". comm may hold spaces and parens, so split+/// after the LAST ')'.+fn parse_stat(stat: &str) -> Option<(char, u32, u64)> {+    let rest = &stat[stat.rfind(')')? + 1..];+    let f: Vec<&str> = rest.split_whitespace().collect();+    let state = f.first()?.chars().next()?;+    let ppid = f.get(1)?.parse().ok()?;+    let rss = f.get(21)?.parse::<i64>().ok()?.max(0) as u64;+    Some((state, ppid, rss))+}++fn cmdline(pid: u32) -> Vec<String> {+    std::fs::read(format!("/proc/{pid}/cmdline")).map(|b| b.split(|c| *c == 0).filter(|s| !s.is_empty()).map(|s| String::from_utf8_lossy(s).to_string()).collect()).unwrap_or_default()+}++fn exe_of(pid: u32) -> Option<PathBuf> { std::fs::read_link(format!("/proc/{pid}/exe")).ok() }++fn all_pids() -> Vec<u32> {+    std::fs::read_dir("/proc").map(|rd| rd.flatten().filter_map(|e| e.file_name().to_str()?.parse().ok()).collect()).unwrap_or_default()+}++/// The profile folder name in a `--user-data-dir=` argument that points into one of pup's profile roots:+/// the legacy `~/.adom/pup-profiles/<name>`, or `<ADOM_BRIDGE_DATA_DIR>/profiles/<name>` when ab hands pup a+/// data dir (pup-bridge#98).+fn pup_profile_of(args: &[String]) -> Option<String> {+    let data_profiles = pup_core::data_dir().map(|d| format!("{}/", d.join("profiles").to_string_lossy().trim_end_matches('/')));+    pup_profile_in(args, data_profiles.as_deref())+}++/// Chrome on Linux rewrites its own process title, so a browser's /proc cmdline is often ONE space-joined+/// string ("/path/chrome --user-data-dir=/x/profiles/adom-you --no-first-run ...") rather than separate+/// arguments; read the value out of either shape. Helper processes (`--type=`: zygote, renderer, GPU, and the+/// setuid sandbox wrapper that execs the zygote) are not browsers: ending the browser ends them.+fn pup_profile_in(args: &[String], data_profiles: Option<&str>) -> Option<String> {+    if args.iter().any(|a| a.starts_with("--type=") || a.contains(" --type=")) { return None; }+    const K: &str = "--user-data-dir=";+    let dir: &str = if let Some(a) = args.iter().find(|a| a.starts_with(K)) { &a[K.len()..] } else {+        let joined = args.iter().find(|a| a.contains(&format!(" {K}")))?;+        let v = &joined[joined.find(&format!(" {K}"))? + 1 + K.len()..];+        // the value runs to the next " --flag" (a profile path never contains that), or to the end+        match v.find(" --") { Some(i) => &v[..i], None => v }+    };+    let rest = if let Some(i) = dir.find("pup-profiles/") { &dir[i + "pup-profiles/".len()..] } else { dir.strip_prefix(data_profiles?)? };+    let name: String = rest.chars().take_while(|c| !matches!(c, '/' | '"' | '\'')).collect();+    if name.is_empty() { None } else { Some(name) }+}++// ───────────────────────── launched children ─────────────────────────++/// Pids pup itself ended through `Host::kill`, so recovery can tell a deliberate end from a crash or the+/// user closing the browser (Windows: the same KILLED list).+fn killed() -> &'static Mutex<Vec<u32>> {+    static K: std::sync::OnceLock<Mutex<Vec<u32>>> = std::sync::OnceLock::new();+    K.get_or_init(|| Mutex::new(Vec::new()))+}++/// Exit codes of children pup launched, recorded by their reaper thread. A signal death is 128 + signal,+/// the shell convention, so a crash (SIGSEGV = 139) never reads as a clean exit 0.+fn exits() -> &'static Mutex<HashMap<u32, u32>> {+    static E: std::sync::OnceLock<Mutex<HashMap<u32, u32>>> = std::sync::OnceLock::new();+    E.get_or_init(|| Mutex::new(HashMap::new()))+}++fn reap_in_background(mut child: std::process::Child) {+    let pid = child.id();+    let _ = std::thread::Builder::new().name(format!("reap-{pid}")).spawn(move || {+        if let Ok(st) = child.wait() {+            use std::os::unix::process::ExitStatusExt;+            let code = st.code().map(|c| c as u32).or_else(|| st.signal().map(|s| 128 + s as u32)).unwrap_or(1);+            exits().lock().unwrap().insert(pid, code);+        }+    });+}++/// Env vars an AppImage parent (Adom Bridge) points into its own mount. The browser gets them with every+/// entry under `$APPDIR` removed (and the var dropped when nothing is left), so Chrome loads the system's+/// GTK, GIO and libraries, not the bridge's.+const APPIMAGE_LIST_VARS: &[&str] = &["LD_LIBRARY_PATH", "PATH", "XDG_DATA_DIRS", "GST_PLUGIN_SYSTEM_PATH", "GST_PLUGIN_SYSTEM_PATH_1_0", "GST_PLUGIN_PATH", "PYTHONPATH", "PERLLIB", "QT_PLUGIN_PATH", "GSETTINGS_SCHEMA_DIR"];+const APPIMAGE_DROP_VARS: &[&str] = &["APPDIR", "APPIMAGE", "ARGV0", "OWD", "GTK_THEME", "GDK_PIXBUF_MODULE_FILE", "GDK_PIXBUF_MODULEDIR", "GTK_DATA_PREFIX", "GTK_EXE_PREFIX", "GTK_PATH", "GTK_IM_MODULE_FILE", "GIO_MODULE_DIR", "GIO_EXTRA_MODULES", "WEBKIT_EXEC_PATH", "WEBKIT_DISABLE_COMPOSITING_MODE", "LIBGL_DRIVERS_PATH", "__EGL_VENDOR_LIBRARY_DIRS", "ADOM_BRIDGE_EGL_ACTIVE"];++/// The environment changes (key, Some(new value) | None = remove) that scrub an AppImage parent's mount+/// out of a child's environment. Pure, so it is unit-tested.+fn appimage_scrub(env: &HashMap<String, String>) -> Vec<(String, Option<String>)> {+    let mut out = Vec::new();+    let Some(appdir) = env.get("APPDIR").filter(|d| !d.is_empty()).map(|d| d.trim_end_matches('/').to_string()) else { return out };+    for k in APPIMAGE_LIST_VARS {+        if let Some(v) = env.get(*k) {+            let kept: Vec<&str> = v.split(':').filter(|e| !e.is_empty() && !e.starts_with(&appdir)).collect();+            if kept.is_empty() { out.push((k.to_string(), None)); } else if kept.join(":") != *v { out.push((k.to_string(), Some(kept.join(":")))); }+        }+    }+    for k in APPIMAGE_DROP_VARS { if env.contains_key(*k) { out.push((k.to_string(), None)); } }+    // Anything else still pointing into the mount (a var we do not know by name).+    for (k, v) in env { if v.contains(&appdir) && !out.iter().any(|(o, _)| o == k) { out.push((k.clone(), None)); } }+    out+}++/// Chrome on Linux reports a failed start (no display, a missing library, a sandbox refusal) only on stderr,+/// so launched children append to `pup-browser.log` beside pup's own log (started fresh past 4 MB).+fn browser_stderr() -> std::process::Stdio {+    let dir = std::env::var("ADOM_BRIDGE_DATA_DIR").ok().filter(|d| !d.is_empty()).map(|d| PathBuf::from(d).join("logs"))+        .unwrap_or_else(|| LinuxHost.state_dir().join("bridge-logs"));+    let _ = std::fs::create_dir_all(&dir);+    let path = dir.join("pup-browser.log");+    if std::fs::metadata(&path).map(|m| m.len() > 4 << 20).unwrap_or(false) { let _ = std::fs::remove_file(&path); }+    match std::fs::OpenOptions::new().create(true).append(true).open(&path) { Ok(f) => f.into(), Err(_) => std::process::Stdio::null() }+}++// ───────────────────────── the Chromium sandbox ─────────────────────────++fn is_chromium_exe(exe: &Path) -> bool { matches!(exe.file_name().and_then(|f| f.to_str()), Some("chrome" | "chromium" | "chromium-browser" | "msedge" | "chrome-headless-shell")) }++/// Setuid-root sandbox helpers that system browser packages install. The helper protocol is Chromium's own+/// and stable, so any of them sandboxes any Chromium build, Chrome for Testing included.+const SANDBOX_HELPERS: &[&str] = &["/opt/google/chrome/chrome-sandbox", "/opt/google/chrome-beta/chrome-sandbox", "/opt/google/chrome-unstable/chrome-sandbox", "/usr/lib/chromium/chrome-sandbox", "/usr/lib64/chromium-browser/chrome-sandbox", "/usr/lib/chromium-browser/chrome-sandbox", "/opt/microsoft/msedge/msedge-sandbox"];++fn setuid_root(p: &Path) -> bool {+    use std::os::unix::fs::{MetadataExt, PermissionsExt};+    std::fs::metadata(p).map(|m| m.is_file() && m.uid() == 0 && m.permissions().mode() & 0o4000 != 0).unwrap_or(false)+}++#[derive(Debug, PartialEq)]+enum Sandbox { Namespaces, OwnHelper, Helper(PathBuf), Off(String) }++/// Can an unprivileged process make the user namespaces Chromium's default sandbox needs? Ubuntu 23.10++/// says no through AppArmor (kernel.apparmor_restrict_unprivileged_userns=1) for any binary without its own+/// AppArmor profile, which is every Chrome for Testing unpacked under a home folder; older Debian kernels+/// say no through kernel.unprivileged_userns_clone=0.+fn userns_allowed() -> bool {+    let read = |p: &str| std::fs::read_to_string(p).ok().map(|s| s.trim().to_string());+    if read("/proc/sys/kernel/apparmor_restrict_unprivileged_userns").as_deref() == Some("1") { return false; }+    if read("/proc/sys/kernel/unprivileged_userns_clone").as_deref() == Some("0") { return false; }+    if read("/proc/sys/user/max_user_namespaces").as_deref() == Some("0") { return false; }+    true+}++/// The exe's own packaged helper (`chrome-sandbox` beside it, setuid root), or an AppArmor profile that names+/// it (the distro's /etc/apparmor.d/chrome covers /opt/google/chrome/chrome), lets it sandbox itself.+fn self_sandboxed(exe: &Path) -> bool {+    let dir = exe.parent().unwrap_or(Path::new("/"));+    if setuid_root(&dir.join("chrome-sandbox")) || setuid_root(&dir.join("msedge-sandbox")) { return true; }+    let real = std::fs::canonicalize(exe).unwrap_or_else(|_| exe.to_path_buf());+    let real = real.to_string_lossy();+    std::fs::read_dir("/etc/apparmor.d").map(|rd| rd.flatten().any(|e| e.path().is_file() && std::fs::read_to_string(e.path()).map(|t| t.contains(real.as_ref()) && t.contains("userns")).unwrap_or(false))).unwrap_or(false)+}++fn sandbox_plan(exe: &Path) -> Sandbox {+    if userns_allowed() { return Sandbox::Namespaces; }+    if self_sandboxed(exe) { return Sandbox::OwnHelper; }+    if let Some(h) = SANDBOX_HELPERS.iter().map(PathBuf::from).find(|h| setuid_root(h)) { return Sandbox::Helper(h); }+    Sandbox::Off("this system blocks unprivileged user namespaces and no setuid Chromium sandbox helper is installed (installing Google Chrome or the distro's Chromium package provides one)".into())+}++// ───────────────────────── HostWindows ─────────────────────────++impl HostWindows for LinuxHost {+    // Windows: EnumWindows + GetWindowThreadProcessId, class Chrome_WidgetWin_1. Here: EWMH's managed client+    // list, owner = _NET_WM_PID, class = WM_CLASS. Hidden (minimized) windows are included, flagged !visible.+    fn windows_for_pids(&self, pids: &[u32], class_filter: Option<&str>) -> Vec<HostWindow> {+        x11::client_rows().into_iter()+            .filter(|r| pids.is_empty() || pids.contains(&r.pid))+            .filter(|r| class_matches(&r.class, class_filter))+            .filter(|r| r.rect.2 - r.rect.0 > 50 && r.rect.3 - r.rect.1 > 50)+            .map(|r| HostWindow { hwnd: r.id, pid: r.pid, title: r.title, class: r.class, rect: r.rect, visible: r.viewable && !r.hidden })+            .collect()+    }+    // Windows: every unowned captioned frame, visible or not. Here: the managed clients plus the root's+    // direct children that carry _NET_WM_PID (a newborn the window manager has not managed yet).+    fn frame_windows_all(&self, class_filter: Option<&str>) -> Vec<HostWindow> {+        x11::all_rows().into_iter()+            .filter(|r| class_matches(&r.class, class_filter))+            .map(|r| HostWindow { hwnd: r.id, pid: r.pid, title: r.title, class: r.class, rect: r.rect, visible: r.viewable && !r.hidden })+            .collect()+    }+    // Windows: IsWindow.+    fn is_alive(&self, hwnd: i64) -> bool { hwnd > 0 && x11::exists(hwnd) }+    // Windows: SetProp "AdomPupSid=<sid>". Here: the `_ADOM_PUP_TAGS` property, one tag per line.+    fn tag(&self, hwnd: i64, tag: &str) -> bool { hwnd > 0 && x11::add_tag(hwnd, tag) }+    fn has_tag(&self, hwnd: i64, tag: &str) -> bool { hwnd > 0 && x11::tags(hwnd).iter().any(|t| t == tag) }+    // Windows: powershell -WindowStyle Hidden -File <script> <tail>. Here: /bin/sh <script> <tail>.+    fn script_launch(&self, script: &Path, tail: &str, quote_for_relaunch: bool) -> (String, String) {+        let q = if quote_for_relaunch { "\\\"" } else { "\"" };+        ("/bin/sh".into(), format!("{q}{}{q} {tail}", script.display()))+    }+    // Windows: SetWindowPos HWND_BOTTOM | SWP_NOACTIVATE. Here: _NET_RESTACK_WINDOW Below (+ ConfigureWindow+    // Below). Never activates. The user's own windows (Wayland or X11) all end up above it.+    fn send_to_bottom_no_activate(&self, hwnd: i64) -> bool { hwnd > 0 && x11::lower(hwnd) }+    // Windows: ShowWindow(SW_HIDE / SW_SHOWNOACTIVATE). Unmapping another client's window is not a thing a+    // well-behaved X client does (Chrome would read it as closed), so hide is refused and show = lower.+    fn set_visible_no_activate(&self, hwnd: i64, visible: bool) -> bool { if visible { self.send_to_bottom_no_activate(hwnd) } else { false } }+    fn show_at_bottom_no_activate(&self, hwnd: i64) -> bool { self.send_to_bottom_no_activate(hwnd) }+    // Windows: a sub-ms spin that bottoms the window the instant it first shows. Here: wait for the map+    // (Viewable), lower it at once, and keep it lowered for 300 ms unless the user made it active.+    fn park_on_first_show(&self, hwnd: i64, timeout_ms: u64) -> Option<u64> {+        let t0 = Instant::now();+        loop {+            if x11::viewable(hwnd) { break; }+            if t0.elapsed().as_millis() as u64 > timeout_ms || !x11::exists(hwnd) { return None; }+            std::thread::sleep(Duration::from_millis(2));+        }+        let shown = t0.elapsed().as_millis() as u64;+        let hold = Instant::now();+        while hold.elapsed() < Duration::from_millis(300) {+            if x11::active_window() == hwnd { break; }+            x11::lower(hwnd);+            std::thread::sleep(Duration::from_millis(40));+        }+        Some(shown)+    }+    // Windows: ITaskbarList DeleteTab/AddTab. No equivalent a client may drive.+    fn taskbar_tab(&self, _hwnd: i64, _present: bool) -> bool { false }+    // Windows: SetWindowPos SWP_NOACTIVATE | SWP_NOZORDER. Here: ConfigureWindow x/y/w/h, no stack mode.+    fn set_bounds_no_activate(&self, hwnd: i64, left: i32, top: i32, width: i32, height: i32) -> bool { hwnd > 0 && x11::set_bounds(hwnd, left, top, width, height) }+    // Windows: GetForegroundWindow. Here: _NET_ACTIVE_WINDOW. On Wayland that is the active XWayland window,+    // or 0 while the user is in a native Wayland app (which is never a pup window).+    fn foreground(&self) -> i64 { x11::active_window() }+    // Windows: FlashWindowEx(FLASHW_STOP). Here: clear _NET_WM_STATE_DEMANDS_ATTENTION.+    fn stop_flash(&self, hwnd: i64) -> bool { hwnd > 0 && x11::clear_attention(hwnd) }+    // A GRANTED raise (the verbs gate it on foregroundReason): _NET_ACTIVE_WINDOW from a pager source,+    // which EWMH window managers honour.+    fn raise_window(&self, hwnd: i64) -> bool { hwnd > 0 && x11::activate(hwnd) }+    // Windows: GetLastInputInfo. Here: GNOME's IdleMonitor over D-Bus (covers Wayland), else the X+    // screen-saver extension (X11 sessions).+    fn idle_ms(&self) -> Option<u64> { secrets::gnome_idle_ms().or_else(x11::idle_ms) }+    // Windows: SPI_GETWORKAREA. Here: _NET_WORKAREA of the current desktop, else the root window.+    fn work_area(&self) -> Option<(i32, i32, i32, i32)> { x11::work_area().filter(|(_, _, w, h)| *w > 400 && *h > 300) }+    // Chrome under X11 reports CSS px at its own device scale; pup's rects come from CDP. Nothing to undo.+    fn dpi_scale(&self, _hwnd: i64) -> f64 { 1.0 }+}++// ───────────────────────── HostProcess ─────────────────────────++impl HostProcess for LinuxHost {+    // Windows: OpenProcess + GetExitCodeProcess == STILL_ACTIVE. A zombie (exited, not reaped) is not alive.+    fn alive(&self, pid: u32) -> bool {+        if pid == 0 { return false; }+        if exits().lock().unwrap().contains_key(&pid) { return false; }+        let r = unsafe { libc::kill(pid as i32, 0) };+        if !(r == 0 || std::io::Error::last_os_error().raw_os_error() == Some(libc::EPERM)) { return false; }+        match std::fs::read_to_string(format!("/proc/{pid}/stat")).ok().and_then(|s| parse_stat(&s)) { Some(('Z', _, _)) | Some(('X', _, _)) => false, _ => true }+    }+    // Windows: GlobalMemoryStatusEx. Here: /proc/meminfo MemTotal / MemAvailable.+    fn memory_status(&self) -> Option<(u64, u64)> {+        let m = std::fs::read_to_string("/proc/meminfo").ok()?;+        let kb = |key: &str| m.lines().find(|l| l.starts_with(key)).and_then(|l| l.split_whitespace().nth(1)).and_then(|v| v.parse::<u64>().ok());+        Some((kb("MemTotal:")? / 1024, kb("MemAvailable:").or_else(|| kb("MemFree:"))? / 1024))+    }+    // Windows: Toolhelp parent walk + GetProcessMemoryInfo. Here: /proc rss over pid and its descendants.+    fn process_tree_rss_mb(&self, pid: u32) -> Option<u64> {+        if pid == 0 { return None; }+        let table = proc_table();+        if !table.iter().any(|(p, ..)| *p == pid) { return None; }+        let (mut want, mut sum_kb, mut seen) = (vec![pid], 0u64, std::collections::HashSet::new());+        while let Some(p) = want.pop() {+            if !seen.insert(p) { continue; }+            for (cp, pp, rss, _) in &table { if *cp == p { sum_kb += rss; } if *pp == p { want.push(*cp); } }+        }+        Some(sum_kb / 1024)+    }+    // Windows: DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP. Here: its own session (setsid) so it outlives the+    // bridge, the AppImage environment scrubbed, a display guaranteed, and a reaper thread for the exit code.+    fn spawn_detached(&self, exe: &Path, args: &[String], cwd: Option<&Path>) -> std::io::Result<u32> {+        use std::os::unix::process::CommandExt;+        let mut c = std::process::Command::new(exe);+        c.args(args).stdin(std::process::Stdio::null()).stdout(std::process::Stdio::null()).stderr(browser_stderr());+        if let Some(d) = cwd { c.current_dir(d); }+        let env: HashMap<String, String> = std::env::vars().collect();+        for (k, v) in appimage_scrub(&env) { match v { Some(v) => { c.env(k, v); } None => { c.env_remove(k); } } }+        if env.get("DISPLAY").map(|d| d.is_empty()).unwrap_or(true) { c.env("DISPLAY", x11::display_name()); }+        if is_chromium_exe(exe) && !args.iter().any(|a| a == "--no-sandbox") {+            match sandbox_plan(exe) {+                Sandbox::Namespaces | Sandbox::OwnHelper => {}+                Sandbox::Helper(h) => { c.env("CHROME_DEVEL_SANDBOX", h); }+                Sandbox::Off(why) => { pup_core::log(format!("[sandbox] {}: {why}; launching with --no-sandbox", exe.display())); c.arg("--no-sandbox"); }+            }+        }+        unsafe { c.pre_exec(|| { libc::setsid(); Ok(()) }); }+        let child = c.spawn()?;+        let pid = child.id();+        reap_in_background(child);+        Ok(pid)+    }+    // Windows: TerminateProcess. The pid is remembered so recovery reads this as pup's own, deliberate end.+    fn kill(&self, pid: u32) -> bool {+        if pid == 0 || pid == std::process::id() { return false; }+        let ok = unsafe { libc::kill(pid as i32, libc::SIGKILL) == 0 };+        if ok { let mut k = killed().lock().unwrap(); if !k.contains(&pid) { k.push(pid); } }+        ok+    }+    // Every launched child is already watched (spawn_detached's reaper).+    fn watch_process(&self, _pid: u32) {}+    fn exit_code(&self, pid: u32) -> Option<u32> { exits().lock().unwrap().get(&pid).copied() }+    fn ended_by_pup(&self, pid: u32) -> bool { killed().lock().unwrap().contains(&pid) }+    // Windows: powershell -Command. Here: /bin/sh -c in its own process group, so a timeout ends the pipeline.+    fn run_shell_script(&self, script: &str, timeout: Duration) -> String {+        use std::os::unix::process::CommandExt;+        let mut child = match std::process::Command::new("/bin/sh").arg("-c").arg(script).process_group(0)+            .stdin(std::process::Stdio::null()).stdout(std::process::Stdio::piped()).stderr(std::process::Stdio::null()).spawn() { Ok(c) => c, Err(_) => return String::new() };+        // Read on a thread so a chatty script cannot block on a full pipe while we wait.+        let mut so = child.stdout.take();+        let reader = std::thread::spawn(move || { let mut out = String::new(); if let Some(s) = so.as_mut() { use std::io::Read; let _ = s.read_to_string(&mut out); } out });+        let deadline = Instant::now() + timeout;+        loop {+            match child.try_wait() {+                Ok(Some(_)) => break,+                Ok(None) if Instant::now() < deadline => std::thread::sleep(Duration::from_millis(50)),+                _ => { unsafe { libc::kill(-(child.id() as i32), libc::SIGKILL); } let _ = child.kill(); let _ = child.wait(); break; }+            }+        }+        reader.join().unwrap_or_default()+    }+    // Windows: HKCU/HKLM ...\Chrome\Extensions. Linux Chrome reads external extensions from JSON files named+    // <id>.json in these folders.+    fn external_extension_ids(&self) -> Vec<String> {+        let mut out = Vec::new();+        for d in ["/opt/google/chrome/extensions", "/usr/share/google-chrome/extensions", "/usr/share/chromium/extensions", "/opt/microsoft/msedge/extensions", "/usr/share/microsoft-edge/extensions"] {+            if let Ok(rd) = std::fs::read_dir(d) { for e in rd.flatten() {+                let n = e.file_name().to_string_lossy().to_string();+                if let Some(id) = n.strip_suffix(".json") { if id.len() == 32 && id.chars().all(|c| ('a'..='p').contains(&c)) && !out.contains(&id.to_string()) { out.push(id.to_string()); } }+            } }+        }+        out+    }+    // Windows: the App Paths registry key. Here: `<google chrome> --version`.+    fn installed_chrome_version(&self) -> Option<String> {+        let exe = self.system_browsers().into_iter().find(|(k, p)| k == "chrome" && p.to_string_lossy().contains("google"))?.1;+        let out = std::process::Command::new(&exe).arg("--version").stdin(std::process::Stdio::null()).stderr(std::process::Stdio::null()).output().ok()?;+        String::from_utf8_lossy(&out.stdout).split_whitespace().find(|w| w.split('.').count() == 4 && w.split('.').all(|p| !p.is_empty() && p.chars().all(|c| c.is_ascii_digit()))).map(|s| s.to_string())+    }+    // Windows: Toolhelp snapshot by image name. Here: the executable's file name ("chrome", "msedge"), read+    // from /proc/<pid>/exe (".exe" in the request is ignored, so callers can pass the Windows name).+    fn pids_for_image(&self, image: &str) -> Vec<u32> {+        let want = image.trim_end_matches(".exe").to_string();+        if want.is_empty() { return Vec::new(); }+        all_pids().into_iter().filter(|p| exe_of(*p).and_then(|e| e.file_name().map(|f| f.to_string_lossy().to_string())).map(|n| n == want).unwrap_or(false)).collect()+    }+    fn download_file(&self, url: &str, dest: &Path) -> Result<(), String> { pup_core::http_download(url, dest) }+    // Windows: Win32_Process command lines. Here: /proc/<pid>/cmdline naming `pup-profiles/<name>`.+    fn pup_browser_processes(&self) -> Vec<(u32, String)> {+        all_pids().into_iter().filter_map(|p| pup_profile_of(&cmdline(p)).map(|n| (p, n))).collect()+    }+    fn browser_sandbox(&self, exe: &Path) -> Option<String> {+        Some(match sandbox_plan(exe) {+            Sandbox::Namespaces => "namespaces".into(),+            Sandbox::OwnHelper => "the browser's own setuid helper / AppArmor profile".into(),+            Sandbox::Helper(h) => format!("setuid helper {}", h.display()),+            Sandbox::Off(why) => format!("off: {why}"),+        })+    }+    fn running_browser_executables(&self) -> Vec<PathBuf> {+        let mut out: Vec<PathBuf> = all_pids().into_iter().filter_map(exe_of)+            .filter(|e| matches!(e.file_name().and_then(|f| f.to_str()), Some("chrome" | "chromium" | "chromium-browser" | "msedge" | "chrome-headless-shell")))+            .collect();+        out.sort(); out.dedup(); out+    }+}++// ───────────────────────── HostPaths ─────────────────────────++/// Native (not snap, not Flatpak) Chromium-family installs, in preference order. Snap and Flatpak+/// browsers are skipped: their confinement refuses a `--user-data-dir` under a hidden home folder and the+/// extension files pup stages, so pup's profile cannot live where it must.+const CHROME_PATHS: &[&str] = &["/opt/google/chrome/chrome", "/opt/google/chrome-beta/chrome", "/usr/lib/chromium/chromium", "/usr/lib64/chromium-browser/chromium-browser", "/usr/lib/chromium-browser/chromium-browser"];+const EDGE_PATHS: &[&str] = &["/opt/microsoft/msedge/msedge"];++fn is_real_binary(p: &Path) -> bool {+    use std::os::unix::fs::PermissionsExt;+    let Ok(real) = std::fs::canonicalize(p) else { return false };+    if real.starts_with("/snap") || real.to_string_lossy().contains("/flatpak/") { return false; }+    std::fs::metadata(&real).map(|m| m.is_file() && m.permissions().mode() & 0o111 != 0).unwrap_or(false)+}++impl HostPaths for LinuxHost {+    fn home(&self) -> PathBuf { PathBuf::from(std::env::var("HOME").ok().filter(|h| !h.is_empty()).unwrap_or_else(|| "/tmp".into())) }+    fn browser_roots(&self) -> Vec<PathBuf> { vec![PathBuf::from("/opt/google"), PathBuf::from("/opt/microsoft"), PathBuf::from("/usr/lib"), PathBuf::from("/usr/lib64")] }+    // Windows: Program Files / LocalAppData. Here: Google Chrome (deb/rpm), else the distro's native+    // Chromium (Debian, Fedora), reported as kind "chrome"; Microsoft Edge as "edge".+    fn system_browsers(&self) -> Vec<(String, PathBuf)> {+        let mut out = Vec::new();+        if let Some(p) = CHROME_PATHS.iter().map(PathBuf::from).find(|p| is_real_binary(p)) { out.push(("chrome".to_string(), p)); }+        if let Some(p) = EDGE_PATHS.iter().map(PathBuf::from).find(|p| is_real_binary(p)) { out.push(("edge".to_string(), p)); }+        out+    }+    fn cft_platform(&self) -> &'static str { "linux64" }+    fn cft_exe_relative(&self) -> PathBuf { PathBuf::from("chrome-linux64").join("chrome") }+}++// ───────────────────────── HostSecrets ─────────────────────────++impl HostSecrets for LinuxHost {+    // Windows: CredReadW "service/account". Here: Secret Service items with keytar's attributes+    // {service, account}, or the 0600 fallback file.+    fn secret_get(&self, service: &str, account: &str) -> Option<String> { secrets::get(service, account) }+    fn secret_set(&self, service: &str, account: &str, secret: &str) -> Result<(), String> { secrets::set(self.state_dir(), service, account, secret) }+    fn secret_delete(&self, service: &str, account: &str) -> bool { secrets::delete(self.state_dir(), service, account) }+    fn secret_store(&self) -> String {+        match secrets::store_kind() {+            "secret-service" => "the desktop keyring (Secret Service)".into(),+            _ => format!("{} (mode 0600; this desktop has no unlocked keyring)", self.state_dir().join("pup-secrets.json").display()),+        }+    }+}++/// Where the secrets of this box live right now: "secret-service" or "file" (for pup_readiness/status).+pub fn secret_store_kind() -> &'static str { secrets::store_kind() }++#[cfg(test)]+mod tests {+    use super::*;++    #[test]+    fn class_filter_maps_the_windows_chrome_class_to_any_chromium() {+        assert!(class_matches("Google-chrome-for-testing", Some(CHROME_CLASS)));+        assert!(class_matches("Chromium", Some(CHROME_CLASS)));+        assert!(class_matches("Microsoft-edge", Some(CHROME_CLASS)));+        assert!(!class_matches("org.gnome.Nautilus", Some(CHROME_CLASS)));+        assert!(class_matches("anything", None));+    }++    #[test]+    fn stat_parses_comm_with_spaces_and_parens() {+        let s = "1234 (Web Content (x)) S 1000 1234 1234 0 -1 4194560 100 0 0 0 1 2 0 0 20 0 10 0 500 123456789 2048 18446744073709551615";+        assert_eq!(parse_stat(s), Some(('S', 1000, 2048)));+    }++    #[test]+    fn profile_name_from_user_data_dir() {+        let a = vec!["/x/chrome".to_string(), "--user-data-dir=/home/u/.adom/pup-profiles/adom-you".to_string(), "--no-first-run".to_string()];+        assert_eq!(pup_profile_of(&a).as_deref(), Some("adom-you"));+        assert_eq!(pup_profile_in(&["--user-data-dir=/home/u/other".to_string()], None), None);+        let d = vec!["--user-data-dir=/home/u/.local/share/x/puppeteer/profiles/iso-12".to_string()];+        assert_eq!(pup_profile_in(&d, Some("/home/u/.local/share/x/puppeteer/profiles/")).as_deref(), Some("iso-12"));+        assert_eq!(pup_profile_in(&d, Some("/elsewhere/profiles/")), None);+        // Chrome's rewritten process title: one space-joined string.+        let t = vec!["/home/u/.cache/puppeteer/chrome/linux64-156/chrome-linux64/chrome --user-data-dir=/home/u/.adom/pup-profiles/adom-you --no-first-run".to_string()];+        assert_eq!(pup_profile_in(&t, None).as_deref(), Some("adom-you"));+        // Helpers never count as the browser, the setuid sandbox wrapper included.+        let h = vec!["/opt/google/chrome/chrome-sandbox".to_string(), "/x/chrome".to_string(), "--type=zygote".to_string(), "--user-data-dir=/home/u/.adom/pup-profiles/adom-you".to_string()];+        assert_eq!(pup_profile_in(&h, None), None);+        let r = vec!["/x/chrome --type=renderer --user-data-dir=/home/u/.adom/pup-profiles/adom-you".to_string()];+        assert_eq!(pup_profile_in(&r, None), None);+    }++    #[test]+    fn appimage_environment_is_scrubbed() {+        let mut env = HashMap::new();+        env.insert("APPDIR".into(), "/tmp/.mount_adom-X".into());+        env.insert("LD_LIBRARY_PATH".into(), "/tmp/.mount_adom-X/usr/lib/:/tmp/.mount_adom-X/lib/:".into());+        env.insert("PATH".into(), "/tmp/.mount_adom-X/usr/bin/:/usr/bin:/bin".into());+        env.insert("GTK_PATH".into(), "/tmp/.mount_adom-X//usr/lib/gtk-3.0".into());+        env.insert("HOME".into(), "/home/u".into());+        env.insert("SOMETHING_NEW".into(), "/tmp/.mount_adom-X/share".into());+        let ch: HashMap<String, Option<String>> = appimage_scrub(&env).into_iter().collect();+        assert_eq!(ch.get("LD_LIBRARY_PATH"), Some(&None));+        assert_eq!(ch.get("PATH"), Some(&Some("/usr/bin:/bin".to_string())));+        assert_eq!(ch.get("GTK_PATH"), Some(&None));+        assert_eq!(ch.get("APPDIR"), Some(&None));+        assert_eq!(ch.get("SOMETHING_NEW"), Some(&None));+        assert!(!ch.contains_key("HOME"));+        // No AppImage parent: nothing changes.+        let plain: HashMap<String, String> = [("PATH".to_string(), "/usr/bin".to_string())].into();+        assert!(appimage_scrub(&plain).is_empty());+    }++    #[test]+    fn process_alive_kill_and_exit_codes() {+        let h = LinuxHost;+        assert!(h.alive(std::process::id()));+        let pid = h.spawn_detached(Path::new("/bin/sleep"), &["30".into()], None).unwrap();+        assert!(h.alive(pid));+        assert!(!h.ended_by_pup(pid));+        assert!(h.kill(pid));+        assert!(h.ended_by_pup(pid));+        let t = Instant::now();+        while h.alive(pid) && t.elapsed() < Duration::from_secs(3) { std::thread::sleep(Duration::from_millis(20)); }+        assert!(!h.alive(pid), "a killed child is reaped, never a live-looking zombie");+        assert_eq!(h.exit_code(pid), Some(128 + 9));+        let ok = h.spawn_detached(Path::new("/bin/true"), &[], None).unwrap();+        let t = Instant::now();+        while h.exit_code(ok).is_none() && t.elapsed() < Duration::from_secs(3) { std::thread::sleep(Duration::from_millis(20)); }+        assert_eq!(h.exit_code(ok), Some(0));+        assert!(!h.kill(0));+    }++    #[test]+    fn shell_script_runs_with_timeout() {+        let h = LinuxHost;+        assert_eq!(h.run_shell_script("echo hi", Duration::from_secs(5)).trim(), "hi");+        let t = Instant::now();+        let _ = h.run_shell_script("sleep 5 | cat", Duration::from_millis(300));+        assert!(t.elapsed() < Duration::from_secs(3));+    }++    #[test]+    fn proc_probes_answer() {+        let h = LinuxHost;+        let (total, free) = h.memory_status().expect("meminfo");+        assert!(total > 256 && free <= total);+        assert!(h.process_tree_rss_mb(std::process::id()).is_some());+        let me = exe_of(std::process::id()).unwrap();+        let name = me.file_name().unwrap().to_string_lossy().to_string();+        assert!(h.pids_for_image(&name).contains(&std::process::id()));+        assert!(h.pids_for_image(&format!("{name}.exe")).contains(&std::process::id()));+        for (_, p) in h.system_browsers() { assert!(is_real_binary(&p)); }+        assert_eq!(h.cft_exe_relative(), PathBuf::from("chrome-linux64/chrome"));+    }++    #[test]+    fn file_secret_store_round_trip() {+        let dir = std::env::temp_dir().join(format!("pup-secrets-test-{}", std::process::id()));+        secrets::file_set(&dir, "adom-pup-test", "unit@test", "s3cret").unwrap();+        assert_eq!(secrets::file_get(&dir, "adom-pup-test", "unit@test").as_deref(), Some("s3cret"));+        secrets::file_set(&dir, "adom-pup-test", "unit@test", "changed").unwrap();+        assert_eq!(secrets::file_get(&dir, "adom-pup-test", "unit@test").as_deref(), Some("changed"));+        use std::os::unix::fs::PermissionsExt;+        assert_eq!(std::fs::metadata(dir.join("pup-secrets.json")).unwrap().permissions().mode() & 0o777, 0o600);+        assert!(secrets::file_delete(&dir, "adom-pup-test", "unit@test"));+        assert!(secrets::file_get(&dir, "adom-pup-test", "unit@test").is_none());+        let _ = std::fs::remove_dir_all(&dir);+    }++    #[test]+    fn window_probes_do_not_panic_without_a_display() {+        // CI and WSL often have no X server: every probe answers empty instead of failing.+        let h = LinuxHost;+        let _ = h.windows_for_pids(&[], Some(CHROME_CLASS));+        let _ = h.frame_windows_all(None);+        let _ = h.foreground();+        let _ = h.work_area();+        assert!(!h.is_alive(0));+    }+}
rust/host-linux/src/secrets.rsadded+154
@@ -0,0 +1,154 @@+//! Secrets and the other session-bus probes.+//!+//! Secrets go to the freedesktop Secret Service (GNOME Keyring, KWallet's provider) with keytar's+//! attributes {service, account}, through a "plain" session (the bus is the user's own, local). pup never+//! asks the user to unlock a keyring: a write goes to the Secret Service only when its default collection+//! is already unlocked. Otherwise (no keyring daemon, or the auto-login desktop that leaves the login+//! keyring locked) it goes to `~/.adom/pup-secrets.json`, mode 0600, the same protection a Windows+//! Credential Manager entry has from other processes of the same user. Reads look in both.++use std::collections::HashMap;+use std::path::{Path, PathBuf};+use zbus::blocking::{Connection, Proxy};+use zbus::zvariant::{OwnedObjectPath, OwnedValue, Value};++const DEST: &str = "org.freedesktop.secrets";+const SCHEMA: &str = "org.freedesktop.Secret.Generic";++fn bus() -> Option<Connection> {+    static BUS: std::sync::OnceLock<Option<Connection>> = std::sync::OnceLock::new();+    BUS.get_or_init(|| if std::env::var("DBUS_SESSION_BUS_ADDRESS").is_ok() || std::env::var("XDG_RUNTIME_DIR").is_ok() { Connection::session().ok() } else { None }).clone()+}++fn svc_proxy(c: &Connection) -> Option<Proxy<'static>> {+    let p = Proxy::new(c, DEST, "/org/freedesktop/secrets", "org.freedesktop.Secret.Service").ok()?;+    Some(p)+}++fn open_session(svc: &Proxy) -> Option<OwnedObjectPath> {+    let (_out, path): (OwnedValue, OwnedObjectPath) = svc.call("OpenSession", &("plain", Value::from(""))).ok()?;+    Some(path)+}++fn attrs<'a>(service: &'a str, account: &'a str) -> HashMap<&'a str, &'a str> {+    HashMap::from([("service", service), ("account", account), ("xdg:schema", SCHEMA)])+}++/// Unlocked items matching keytar's attributes (locked ones are never unlocked by pup).+fn find_unlocked(svc: &Proxy, service: &str, account: &str) -> Vec<OwnedObjectPath> {+    let mut a = attrs(service, account);+    a.remove("xdg:schema");   // keytar-written items may lack it; match on service + account+    let r: Result<(Vec<OwnedObjectPath>, Vec<OwnedObjectPath>), _> = svc.call("SearchItems", &(a,));+    r.map(|(unlocked, _locked)| unlocked).unwrap_or_default()+}++fn default_collection_unlocked(c: &Connection, svc: &Proxy) -> Option<OwnedObjectPath> {+    let path: OwnedObjectPath = svc.call("ReadAlias", &("default",)).ok()?;+    if path.as_str() == "/" { return None; }+    let coll = Proxy::new(c, DEST, path.clone(), "org.freedesktop.Secret.Collection").ok()?;+    let locked: bool = coll.get_property("Locked").ok()?;+    if locked { None } else { Some(path) }+}++fn ss_get(service: &str, account: &str) -> Option<String> {+    let c = bus()?; let svc = svc_proxy(&c)?;+    let item = find_unlocked(&svc, service, account).into_iter().next()?;+    let session = open_session(&svc)?;+    let ip = Proxy::new(&c, DEST, item, "org.freedesktop.Secret.Item").ok()?;+    let (_s, _params, value, _ct): (OwnedObjectPath, Vec<u8>, Vec<u8>, String) = ip.call("GetSecret", &(session,)).ok()?;+    String::from_utf8(value).ok()+}++fn ss_set(service: &str, account: &str, secret: &str) -> bool {+    let Some(c) = bus() else { return false }; let Some(svc) = svc_proxy(&c) else { return false };+    let Some(coll_path) = default_collection_unlocked(&c, &svc) else { return false };+    let Some(session) = open_session(&svc) else { return false };+    let Ok(coll) = Proxy::new(&c, DEST, coll_path, "org.freedesktop.Secret.Collection") else { return false };+    let label = format!("{service}/{account}");+    let mut props: HashMap<&str, Value> = HashMap::new();+    props.insert("org.freedesktop.Secret.Item.Label", Value::from(label.as_str()));+    props.insert("org.freedesktop.Secret.Item.Attributes", Value::from(attrs(service, account)));+    let secret_struct = (session, Vec::<u8>::new(), secret.as_bytes().to_vec(), "text/plain; charset=utf8");+    let r: Result<(OwnedObjectPath, OwnedObjectPath), _> = coll.call("CreateItem", &(props, secret_struct, true));+    // A prompt path other than "/" means the service wants the user: pup does not prompt, so treat as failed.+    matches!(r, Ok((item, prompt)) if item.as_str() != "/" && prompt.as_str() == "/")+}++fn ss_delete(service: &str, account: &str) -> bool {+    let Some(c) = bus() else { return false }; let Some(svc) = svc_proxy(&c) else { return false };+    let mut any = false;+    for item in find_unlocked(&svc, service, account) {+        if let Ok(ip) = Proxy::new(&c, DEST, item, "org.freedesktop.Secret.Item") {+            let r: Result<OwnedObjectPath, _> = ip.call("Delete", &());+            if matches!(r, Ok(p) if p.as_str() == "/") { any = true; }+        }+    }+    any+}++// ── the 0600 file ──++fn file_path(dir: &Path) -> PathBuf { dir.join("pup-secrets.json") }++fn file_load(dir: &Path) -> HashMap<String, String> {+    std::fs::read_to_string(file_path(dir)).ok().and_then(|s| serde_json::from_str(&s).ok()).unwrap_or_default()+}++fn file_store(dir: &Path, m: &HashMap<String, String>) -> Result<(), String> {+    use std::io::Write;+    use std::os::unix::fs::OpenOptionsExt;+    std::fs::create_dir_all(dir).map_err(|e| e.to_string())?;+    let tmp = dir.join(format!("pup-secrets.json.{}.tmp", std::process::id()));+    let mut f = std::fs::OpenOptions::new().write(true).create(true).truncate(true).mode(0o600).open(&tmp).map_err(|e| e.to_string())?;+    f.write_all(serde_json::to_string(m).map_err(|e| e.to_string())?.as_bytes()).map_err(|e| e.to_string())?;+    f.sync_all().map_err(|e| e.to_string())?;+    std::fs::rename(&tmp, file_path(dir)).map_err(|e| e.to_string())+}++fn key(service: &str, account: &str) -> String { format!("{service}/{account}") }++static FILE_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());++pub fn file_get(dir: &Path, service: &str, account: &str) -> Option<String> { file_load(dir).get(&key(service, account)).cloned() }+pub fn file_set(dir: &Path, service: &str, account: &str, secret: &str) -> Result<(), String> {+    let _g = FILE_LOCK.lock().unwrap_or_else(|e| e.into_inner());+    let mut m = file_load(dir); m.insert(key(service, account), secret.to_string()); file_store(dir, &m)+}+pub fn file_delete(dir: &Path, service: &str, account: &str) -> bool {+    let _g = FILE_LOCK.lock().unwrap_or_else(|e| e.into_inner());+    let mut m = file_load(dir);+    if m.remove(&key(service, account)).is_none() { return false; }+    file_store(dir, &m).is_ok()+}++fn state_dir() -> PathBuf { PathBuf::from(std::env::var("HOME").unwrap_or_else(|_| "/tmp".into())).join(".adom") }++// ── what the host calls ──++pub fn get(service: &str, account: &str) -> Option<String> { ss_get(service, account).or_else(|| file_get(&state_dir(), service, account)) }++pub fn set(dir: PathBuf, service: &str, account: &str, secret: &str) -> Result<(), String> {+    if ss_set(service, account, secret) {+        // One home per secret: a stale copy in the file would win after the keyring locks again.+        let _ = file_delete(&dir, service, account);+        return Ok(());+    }+    file_set(&dir, service, account, secret)+}++pub fn delete(dir: PathBuf, service: &str, account: &str) -> bool {+    let a = ss_delete(service, account);+    let b = file_delete(&dir, service, account);+    a || b+}++pub fn store_kind() -> &'static str {+    match bus().and_then(|c| svc_proxy(&c).and_then(|s| default_collection_unlocked(&c, &s))) { Some(_) => "secret-service", None => "file" }+}++/// GNOME (Mutter) idle time over D-Bus: covers Wayland sessions, where the X server sees no native input.+pub fn gnome_idle_ms() -> Option<u64> {+    let c = bus()?;+    let p = Proxy::new(&c, "org.gnome.Mutter.IdleMonitor", "/org/gnome/Mutter/IdleMonitor/Core", "org.gnome.Mutter.IdleMonitor").ok()?;+    p.call::<_, _, u64>("GetIdletime", &()).ok()+}
rust/host-linux/src/x11.rsadded+214
@@ -0,0 +1,214 @@+//! X11 / EWMH through x11rb (pure Rust, no libX11). One lazily opened connection, reopened when it breaks.+//! On a Wayland session this is the XWayland server, which holds exactly pup's own browser windows+//! (pup launches them with --ozone-platform=x11) and whatever other X11 apps the user runs.++use std::sync::Mutex;+use x11rb::connection::Connection;+use x11rb::errors::ReplyError;+use x11rb::protocol::xproto::*;+use x11rb::rust_connection::RustConnection;+use x11rb::wrapper::ConnectionExt as _;++x11rb::atom_manager! {+    pub Atoms: AtomsCookie {+        _NET_CLIENT_LIST,+        _NET_CLIENT_LIST_STACKING,+        _NET_ACTIVE_WINDOW,+        _NET_WORKAREA,+        _NET_CURRENT_DESKTOP,+        _NET_WM_PID,+        _NET_WM_NAME,+        _NET_WM_STATE,+        _NET_WM_STATE_HIDDEN,+        _NET_WM_STATE_DEMANDS_ATTENTION,+        _NET_RESTACK_WINDOW,+        _NET_MOVERESIZE_WINDOW,+        UTF8_STRING,+        _ADOM_PUP_TAGS,+    }+}++struct X { conn: RustConnection, root: Window, atoms: Atoms }++static CONN: Mutex<Option<X>> = Mutex::new(None);++pub fn display_name() -> String { std::env::var("DISPLAY").ok().filter(|d| !d.is_empty()).unwrap_or_else(|| ":0".into()) }++/// When the last connect failed: probes do not retry for a few seconds, so a box with no X server answers+/// every window probe at once instead of paying a connect each time.+static LAST_FAIL: Mutex<Option<std::time::Instant>> = Mutex::new(None);++fn open() -> Option<X> {+    if LAST_FAIL.lock().unwrap_or_else(|e| e.into_inner()).map(|t| t.elapsed() < std::time::Duration::from_secs(5)).unwrap_or(false) { return None; }+    let r = open_now();+    if r.is_none() { *LAST_FAIL.lock().unwrap_or_else(|e| e.into_inner()) = Some(std::time::Instant::now()); }+    r+}++fn open_now() -> Option<X> {+    // No DISPLAY: use the local :0 socket only when it exists. Guessing ":0" lets x11rb fall back to TCP+    // localhost:6000, which can hang for minutes where a firewall drops it.+    let name = match std::env::var("DISPLAY").ok().filter(|d| !d.is_empty()) {+        Some(d) => d,+        None if std::path::Path::new("/tmp/.X11-unix/X0").exists() => ":0".to_string(),+        None => return None,+    };+    let (conn, screen) = x11rb::connect(Some(&name)).ok()?;+    let root = conn.setup().roots.get(screen)?.root;+    let atoms = Atoms::new(&conn).ok()?.reply().ok()?;+    Some(X { conn, root, atoms })+}++/// Run `f` on the shared connection. A broken connection (the X server restarted, XWayland was stopped+/// for idleness) is dropped and the next call reconnects.+fn with<T>(f: impl FnOnce(&X) -> Result<T, ReplyError>) -> Option<T> {+    let mut g = CONN.lock().unwrap_or_else(|e| e.into_inner());+    if g.is_none() { *g = open(); }+    let x = g.as_ref()?;+    match f(x) {+        Ok(v) => Some(v),+        Err(ReplyError::ConnectionError(_)) => { *g = None; None }+        Err(_) => None,+    }+}++fn prop32(x: &X, w: Window, atom: Atom) -> Result<Vec<u32>, ReplyError> {+    let r = x.conn.get_property(false, w, atom, AtomEnum::ANY, 0, 4096)?.reply()?;+    Ok(r.value32().map(|it| it.collect()).unwrap_or_default())+}++fn prop_bytes(x: &X, w: Window, atom: Atom) -> Result<Vec<u8>, ReplyError> {+    Ok(x.conn.get_property(false, w, atom, AtomEnum::ANY, 0, 16384)?.reply()?.value)+}++pub struct Row { pub id: i64, pub pid: u32, pub title: String, pub class: String, pub rect: (i32, i32, i32, i32), pub viewable: bool, pub hidden: bool }++fn row(x: &X, w: Window) -> Result<Row, ReplyError> {+    let pid = prop32(x, w, x.atoms._NET_WM_PID)?.first().copied().unwrap_or(0);+    let mut title = String::from_utf8_lossy(&prop_bytes(x, w, x.atoms._NET_WM_NAME)?).to_string();+    if title.is_empty() { title = String::from_utf8_lossy(&prop_bytes(x, w, AtomEnum::WM_NAME.into())?).to_string(); }+    // WM_CLASS = "instance\0Class\0"; the Class half is what docks group by.+    let wc = prop_bytes(x, w, AtomEnum::WM_CLASS.into())?;+    let parts: Vec<String> = wc.split(|b| *b == 0).filter(|s| !s.is_empty()).map(|s| String::from_utf8_lossy(s).to_string()).collect();+    let class = parts.get(1).or(parts.first()).cloned().unwrap_or_default();+    let g = x.conn.get_geometry(w)?.reply()?;+    let t = x.conn.translate_coordinates(w, x.root, 0, 0)?.reply()?;+    let a = x.conn.get_window_attributes(w)?.reply()?;+    let state = prop32(x, w, x.atoms._NET_WM_STATE)?;+    let (l, tp) = (t.dst_x as i32, t.dst_y as i32);+    Ok(Row { id: w as i64, pid, title, class, rect: (l, tp, l + g.width as i32, tp + g.height as i32), viewable: a.map_state == MapState::VIEWABLE, hidden: state.contains(&x.atoms._NET_WM_STATE_HIDDEN) })+}++fn client_ids(x: &X) -> Result<Vec<Window>, ReplyError> {+    let list = prop32(x, x.root, x.atoms._NET_CLIENT_LIST)?;+    if !list.is_empty() { return Ok(list); }+    // No EWMH window manager: the root's mapped children are the top-levels.+    Ok(x.conn.query_tree(x.root)?.reply()?.children)+}++/// The window manager's managed top-level windows.+pub fn client_rows() -> Vec<Row> {+    with(|x| { let ids = client_ids(x)?; Ok(ids.into_iter().filter_map(|w| row(x, w).ok()).collect()) }).unwrap_or_default()+}++/// Managed windows plus any direct child of the root that carries _NET_WM_PID (a newborn the window+/// manager has not managed yet), each once.+pub fn all_rows() -> Vec<Row> {+    with(|x| {+        let mut ids = client_ids(x)?;+        for c in x.conn.query_tree(x.root)?.reply()?.children { if !ids.contains(&c) { ids.push(c); } }+        Ok(ids.into_iter().filter_map(|w| row(x, w).ok()).filter(|r| r.pid > 0).collect())+    }).unwrap_or_default()+}++pub fn exists(w: i64) -> bool { with(|x| x.conn.get_window_attributes(w as Window)?.reply().map(|_| true)).unwrap_or(false) }++pub fn viewable(w: i64) -> bool { with(|x| Ok(x.conn.get_window_attributes(w as Window)?.reply()?.map_state == MapState::VIEWABLE)).unwrap_or(false) }++pub fn active_window() -> i64 {+    with(|x| Ok(prop32(x, x.root, x.atoms._NET_ACTIVE_WINDOW)?.first().copied().unwrap_or(0) as i64)).unwrap_or(0)+}++fn client_message(x: &X, w: Window, kind: Atom, data: [u32; 5]) -> Result<(), ReplyError> {+    let ev = ClientMessageEvent::new(32, w, kind, data);+    x.conn.send_event(false, x.root, EventMask::SUBSTRUCTURE_REDIRECT | EventMask::SUBSTRUCTURE_NOTIFY, ev)?;+    Ok(())+}++/// Bottom of the stack, never activated: EWMH restack request from a pager (source 2) with detail Below and+/// no sibling, then the plain ICCCM ConfigureWindow Below for window managers that ignore the former.+pub fn lower(w: i64) -> bool {+    with(|x| {+        let w = w as Window;+        x.conn.get_window_attributes(w)?.reply()?;+        client_message(x, w, x.atoms._NET_RESTACK_WINDOW, [2, 0, u32::from(StackMode::BELOW), 0, 0])?;+        x.conn.configure_window(w, &ConfigureWindowAux::new().stack_mode(StackMode::BELOW))?;+        x.conn.flush()?;+        Ok(true)+    }).unwrap_or(false)+}++/// A granted raise: _NET_ACTIVE_WINDOW from a pager (source 2), which window managers honour.+pub fn activate(w: i64) -> bool {+    with(|x| {+        let w = w as Window;+        x.conn.get_window_attributes(w)?.reply()?;+        client_message(x, w, x.atoms._NET_ACTIVE_WINDOW, [2, x11rb::CURRENT_TIME, 0, 0, 0])?;+        x.conn.configure_window(w, &ConfigureWindowAux::new().stack_mode(StackMode::ABOVE))?;+        x.conn.flush()?;+        Ok(true)+    }).unwrap_or(false)+}++pub fn set_bounds(w: i64, l: i32, t: i32, wd: i32, ht: i32) -> bool {+    with(|x| {+        x.conn.configure_window(w as Window, &ConfigureWindowAux::new().x(l).y(t).width(wd.max(1) as u32).height(ht.max(1) as u32))?;+        x.conn.flush()?;+        Ok(true)+    }).unwrap_or(false)+}++/// _NET_WM_STATE remove DEMANDS_ATTENTION (action 0 = remove).+pub fn clear_attention(w: i64) -> bool {+    with(|x| {+        client_message(x, w as Window, x.atoms._NET_WM_STATE, [0, x.atoms._NET_WM_STATE_DEMANDS_ATTENTION, 0, 1, 0])?;+        x.conn.flush()?;+        Ok(true)+    }).unwrap_or(false)+}++pub fn tags(w: i64) -> Vec<String> {+    with(|x| Ok(String::from_utf8_lossy(&prop_bytes(x, w as Window, x.atoms._ADOM_PUP_TAGS)?).lines().map(|s| s.to_string()).filter(|s| !s.is_empty()).collect())).unwrap_or_default()+}++pub fn add_tag(w: i64, tag: &str) -> bool {+    let mut cur = tags(w);+    if cur.iter().any(|t| t == tag) { return true; }+    // One sid tag per window: a re-tag (the window was adopted by another session) replaces the old sid.+    if let Some(k) = tag.split_once('=').map(|(k, _)| k.to_string()) { cur.retain(|t| !t.starts_with(&format!("{k}="))); }+    cur.push(tag.to_string());+    let body = cur.join("\n");+    with(|x| {+        x.conn.change_property8(PropMode::REPLACE, w as Window, x.atoms._ADOM_PUP_TAGS, x.atoms.UTF8_STRING, body.as_bytes())?;+        x.conn.flush()?;+        Ok(true)+    }).unwrap_or(false)+}++/// _NET_WORKAREA for the current desktop (x, y, w, h), else the root window's size.+pub fn work_area() -> Option<(i32, i32, i32, i32)> {+    with(|x| {+        let wa = prop32(x, x.root, x.atoms._NET_WORKAREA)?;+        let d = prop32(x, x.root, x.atoms._NET_CURRENT_DESKTOP)?.first().copied().unwrap_or(0) as usize;+        if let Some(c) = wa.chunks(4).nth(d).or(wa.chunks(4).next()).filter(|c| c.len() == 4) { return Ok(Some((c[0] as i32, c[1] as i32, c[2] as i32, c[3] as i32))); }+        let g = x.conn.get_geometry(x.root)?.reply()?;+        Ok(Some((0, 0, g.width as i32, g.height as i32)))+    }).flatten()+}++/// Milliseconds since the last X input (MIT-SCREEN-SAVER). On Wayland this sees only X11 input, so the+/// GNOME D-Bus probe is asked first.+pub fn idle_ms() -> Option<u64> {+    use x11rb::protocol::screensaver::ConnectionExt as _;+    with(|x| Ok(x.conn.screensaver_query_info(x.root)?.reply()?.ms_since_user_input as u64))+}
rust/host/src/lib.rs+7
@@ -149,6 +149,10 @@ pub trait HostProcess: Send + Sync {             match (it.next().and_then(|x| x.parse::<u32>().ok()), it.next()) { (Some(pid), Some(prof)) => Some((pid, prof.to_string())), _ => None }         }).collect()     }+    /// How a Chromium browser at `exe` is sandboxed when this host launches it, for pup_readiness. None where+    /// the OS sandbox always works (Windows, macOS). Linux: "namespaces", "setuid helper <path>", or+    /// "off: <why>" when neither is available and the browser runs with --no-sandbox.+    fn browser_sandbox(&self, exe: &Path) -> Option<String> { let _ = exe; None }     fn running_browser_executables(&self) -> Vec<PathBuf> {         self.run_shell_script("Get-CimInstance Win32_Process -Filter \"Name='chrome.exe' OR Name='chrome-headless-shell.exe'\" | ForEach-Object { $_.ExecutablePath }", std::time::Duration::from_secs(20))             .lines().map(|l| l.trim()).filter(|l| !l.is_empty()).map(PathBuf::from).collect()@@ -191,6 +195,9 @@ pub trait HostSecrets: Send + Sync {     fn secret_get(&self, service: &str, account: &str) -> Option<String>;     fn secret_set(&self, service: &str, account: &str, secret: &str) -> Result<(), String>;     fn secret_delete(&self, service: &str, account: &str) -> bool;+    /// Where a secret written now is kept, in words for a verb's reply ("the OS keychain" on Windows and macOS).+    /// Linux names the Secret Service keyring or pup's 0600 file, which it uses when no keyring is unlocked.+    fn secret_store(&self) -> String { "the OS keychain".into() } }  /// The whole host, as the binary hands it to every crate.
rust/verbs/src/lib.rs+10−2
@@ -373,6 +373,14 @@ pub fn launch_extdrive(ctx: &Ctx, url: &str) -> Result<u32, Value> { pub fn grease_for(major: &str) -> (&'static str, &'static str) {     match major { "152" => ("Not?A_Brand", "24"), _ => ("Not/A)Brand", "99") } }+/// The OS part of a reduced Chrome user agent, as real Chrome on this OS sends it. Linux: Chrome's reduced UA+/// says "X11; Linux x86_64" on X11 and Wayland alike; presenting a Windows UA from a Linux box contradicts+/// navigator.platform and the client hints, which is exactly what bot walls score. macOS keeps the Windows+/// token it always had (unchanged here; that build has its own owner).+pub fn ua_os_token() -> &'static str {+    if cfg!(target_os = "linux") { "X11; Linux x86_64" } else { "Windows NT 10.0; Win64; x64" }+}+ /// The identity the lane presents: the INSTALLED Chrome's version, in real Chrome's brand order. pub fn consumer_identity_strings(ctx: &Ctx) -> Option<Value> {     set_target_chrome_from_host(&*ctx.host);@@ -380,7 +388,7 @@ pub fn consumer_identity_strings(ctx: &Ctx) -> Option<Value> {     if full.is_empty() { return None; }     let major = full.split('.').next().unwrap_or("").to_string();     let (gb, gv) = grease_for(&major);-    let ua = format!("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/{major}.0.0.0 Safari/537.36");+    let ua = format!("Mozilla/5.0 ({}) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/{major}.0.0.0 Safari/537.36", ua_os_token());     let pinned = ctx.settings.get("consumerBrandsHeader", "");     let chua = if pinned.is_empty() { format!("\"Chromium\";v=\"{major}\", \"{gb}\";v=\"{gv}\", \"Google Chrome\";v=\"{major}\"") } else { pinned };     let chua_full = format!("\"Chromium\";v=\"{full}\", \"{gb}\";v=\"{gv}.0.0.0\", \"Google Chrome\";v=\"{full}\"");@@ -402,7 +410,7 @@ pub fn consumer_ua(cdp: &Cdp) -> Option<(String, Value)> {     // Modern Chrome uses a REDUCED UA (major only, minor/build/patch zeroed); mirror that in the UA header.     if !cft_full.is_empty() { ua = ua.replace(&format!("Chrome/{cft_full}"), &format!("Chrome/{major}.0.0.0")); }     let is_win = ua.contains("Windows");-    let (platform, platform_version) = if is_win { ("Windows", "15.0.0") } else { ("macOS", "14.6.0") };+    let (platform, platform_version) = if is_win { ("Windows", "15.0.0") } else if ua.contains("Linux") { ("Linux", "") } else { ("macOS", "14.6.0") };     let (gb, gv) = grease_for(&major);   // 2.0.543: real Chrome's order and GREASE (measured on the installed Chrome)     let meta = json!({         "brands": [ {"brand":"Chromium","version":major}, {"brand":gb,"version":gv}, {"brand":"Google Chrome","version":major} ],
rust/verbs/src/vault.rs+1−1
@@ -100,7 +100,7 @@ pub fn credentials(ctx: &Ctx) -> Value {  pub fn credential_set(ctx: &Ctx, args: &Value) -> Value {     let (h, u, p) = (args["host"].as_str().unwrap_or("").trim(), args["username"].as_str().unwrap_or("").trim(), args["password"].as_str().unwrap_or(""));-    match ctx.vault.set(&*ctx.host, h, u, p) { Ok(_) => ok(json!({ "success": true, "host": h, "username": u, "output": json!({ "ok": true, "host": h, "username": u }).to_string(), "_hint": "Stored in the OS keychain. The password was not echoed and will not be." })), Err(e) => err("credential_set_failed", e, Some("host, username and password are all required; host may be a pattern like *.example.com.")) }+    match ctx.vault.set(&*ctx.host, h, u, p) { Ok(_) => ok(json!({ "success": true, "host": h, "username": u, "output": json!({ "ok": true, "host": h, "username": u }).to_string(), "storage": ctx.host.secret_store(), "_hint": format!("Stored in {}. The password was not echoed and will not be.", ctx.host.secret_store()) })), Err(e) => err("credential_set_failed", e, Some("host, username and password are all required; host may be a pattern like *.example.com.")) } }  pub fn credential_delete(ctx: &Ctx, args: &Value) -> Value {
skills/pup-browsers-and-chrome/SKILL.md+20
@@ -45,6 +45,26 @@ adom-bridge-cli pup_readiness '{}' - **`pup_status` is NOT a readiness check** — it only lists open sessions. - AD-core **`bridge_readiness`** is the platform-level "is this bridge up?" probe. +## On Linux++- The browser build is Chrome for Testing **linux64** in `~/.cache/puppeteer` (same layout and the same+  installed-Chrome version match as on Windows). The download is unpacked with `bsdtar`, `tar`, `unzip` or+  `python3`, whichever the box has.+- System browsers pup can see: native Google Chrome (`/opt/google/chrome`), the distribution's Chromium+  (`/usr/lib/chromium`, `/usr/lib64/chromium-browser`) reported as kind `chrome`, and Microsoft Edge+  (`/opt/microsoft/msedge`). Snap and Flatpak browsers are skipped (their confinement refuses pup's+  profile folder under `~/.adom`). The extension lane needs Chrome for Testing, as on Windows.+- **`browserSandbox`** in `pup_readiness`: `namespaces`; `the browser's own setuid helper / AppArmor+  profile`; `setuid helper <path>` (Chrome for Testing run under an installed Chrome or Chromium+  `chrome-sandbox`, which is what Ubuntu 23.10+ needs because AppArmor blocks unprivileged user+  namespaces); or `off: <why>`, where the browser runs with `--no-sandbox` until a Chrome or Chromium+  package is installed.+- A browser that will not start explains itself in `~/.adom/bridge-logs/pup-browser.log` (the browser's+  own stderr): "No usable sandbox", "Missing X server or $DISPLAY", a missing library.+- Launch switches added on Linux: `--password-store=basic` (no keyring prompt), and on a Wayland session+  `--ozone-platform=x11` so pup's windows live under XWayland where pup can keep them in the background+  (`PUP_OZONE_PLATFORM=wayland` in Adom Bridge's environment turns that off).+ ## Pinning a browser — `pup_use`  pup auto-picks, but you can override (persists across restarts; launch still spawn-verifies + falls back):
skills/pup-credentials/SKILL.md+3−1
@@ -121,7 +121,9 @@ than 2.0.602, a typed password is NOT stored on the default lane. Verify a captu ## 1. The vault (how pup remembers logins)  - **Auto-capture:** when the user (or pup) signs into a site, pup grabs the credential and stores it-  in the **OS keychain** (Windows DPAPI / macOS Keychain / Linux libsecret). Passwords are NEVER+  in the **OS keychain** (Windows Credential Manager / macOS Keychain / the Linux desktop keyring through the+  Secret Service; on a Linux desktop with no unlocked keyring, `~/.adom/pup-secrets.json` at mode 0600, and+  `credential_set` replies with `storage` naming which). Passwords are NEVER   echoed back — `pup_credentials` lists host + username only. - **Autofill:** on a return visit pup fills the saved login in the **background** (no Chrome   dropdown, no foregrounding). Submitting is reason-gated (`pup_login {submit:true, submitReason}`)
skills/pup-import-logins/SKILL.md+3−3
@@ -69,10 +69,10 @@ printed. If the user is signed in under a different browser profile, the answer `nbrowser_profiles`; ask the user to check which profile they use. Per-site details are in `login-recipes/<domain>.md` on the pup-bridge wiki page. -## On a Mac+## On a Mac or Linux -The password import is Windows-only today (nb drives Chrome's export on Windows first). On a Mac,-`pup_import_browser_logins` answers `unsupported_platform`, and the `_loginTip` says so. What works on a Mac:+The password import is Windows-only today (nb drives Chrome's export on Windows first). On a Mac or a Linux desktop,+`pup_import_browser_logins` answers `unsupported_platform`, and the `_loginTip` says so. What works there:  - **Sign in once in the pup window.** Ask the user to sign in themselves in the pup window. pup saves the login as   they submit it, then fills it on that site from then on.
skills/pup-taskbar-states/SKILL.md+1−1
@@ -58,7 +58,7 @@ preview is DERIVED from the same sources listed here, so preview and reality can - `tiles` takes precedence over the aumid grouping/style options; those two rows only apply in   `aumid` mode (the dialog grays them out otherwise). - Windows with no owning AI thread (rare) fall back to aumid-style identity even in tiles mode.-- Everything is Windows-only; on other platforms these settings store but paint nothing.+- Everything is Windows-only; on macOS and Linux these settings store but paint nothing.  ## The foreground-window tradeoff (bounded, and it self-heals for real now) 
tools/check-borders.sh+9−2
@@ -1,12 +1,13 @@ #!/usr/bin/env bash # The host boundary of the Rust port (docs/PLAN-rust-port.md section 4): only rust/host-win may-# name Win32; only rust/host-mac may name macOS APIs. Everything else goes through pup-host traits+# name Win32; only rust/host-mac may name macOS APIs; only rust/host-linux may name X11, procfs or D-Bus. Everything else goes through pup-host traits # or ab's verbs. Runs in tools/ship.sh; a leak fails the ship. set -u cd "$(dirname "$0")/../rust" || exit 2 fail=0 win_pat='windows_sys|windows-sys|winapi|SetWindowPos|HWND_BOTTOM|SHGetPropertyStore|powershell|HKCU|user32|shell32|GetForegroundWindow|EnumWindows' mac_pat='osascript|NSWindow|NSApp|CGWindowList|AXUIElement|cocoa'+linux_pat='x11rb|xproto|_NET_[A-Z_]+|zbus|org\.freedesktop|/proc/' for d in core host cdp browser session identity vault wiki annotate record verbs dashboard bridge; do   hits=$(grep -rnE "$win_pat" "$d" --include='*.rs' --include='Cargo.toml' 2>/dev/null | grep -vE '^\S+:\s*//' | grep -v 'check-borders' | grep -v 'todo!(')   # bridge/main.rs may select the impl by target, nothing more@@ -17,5 +18,11 @@ for d in core host cdp browser session identity vault wiki annotate record verbs   hits=$(grep -rnE "$mac_pat" "$d" --include='*.rs' 2>/dev/null | grep -vE '^\S+:\s*//' | grep -v 'todo!(')   if [ -n "$hits" ]; then echo "MAC API LEAK outside host-mac in $d:"; echo "$hits" | head -5; fail=1; fi done-[ $fail -eq 0 ] && echo "borders OK: Win32 only in host-win, macOS only in host-mac"+# host-win's non-Windows dev stub reads /proc on purpose; it never ships, so it is not checked here.+for d in core host cdp browser session identity vault wiki annotate record verbs dashboard bridge host-mac; do+  hits=$(grep -rnE "$linux_pat" "$d" --include='*.rs' --include='Cargo.toml' 2>/dev/null | grep -vE '^\S+:\s*//' | grep -v 'todo!(')+  if [ "$d" = "bridge" ]; then hits=$(echo "$hits" | grep -vE 'pup_host_linux::LinuxHost|pup-host-linux' ); fi+  if [ -n "$hits" ]; then echo "LINUX API LEAK outside host-linux in $d:"; echo "$hits" | head -5; fail=1; fi+done+[ $fail -eq 0 ] && echo "borders OK: Win32 only in host-win, macOS only in host-mac, X11/procfs/D-Bus only in host-linux" exit $fail
tools/rust-build.sh+12−25
@@ -3,14 +3,16 @@ # #   tools/rust-build.sh <version>            # -> dist/<version>/adom-bridge-puppeteer-v<version>.zip #-# The zip is ab's bridge package: adom-pup-bridge.exe + bridge.json (spawn.kind exe) + BRIDGE_VERSION-# at the zip root. Nothing else: every browser asset, extension, icon and font is embedded in the exe.+# The zip is ab's bridge package: adom-pup-bridge.exe (Windows) + adom-pup-bridge (Linux x86_64) + bridge.json+# (spawn.kind exe, spawn.entrypoints per OS) + BRIDGE_VERSION at the zip root (tools/rust-package.py). Nothing+# else: every browser asset, extension, icon and font is embedded in each binary. # Gates before the build: border check (Win32 only in host-win), embedded assets regenerated, host # tests green. The exe version is include_str!(src/BRIDGE_VERSION), so write that first (ship.sh does). set -euo pipefail VER="${1:?usage: tools/rust-build.sh <version>}" REPO="$(cd "$(dirname "$0")/.." && pwd)"; cd "$REPO" ARCH="dist/${VER}"; ZIP="adom-bridge-puppeteer-v${VER}.zip"; EXE="rust/target/x86_64-pc-windows-gnu/release/adom-pup-bridge.exe"+ELF="rust/target/x86_64-unknown-linux-gnu/release/adom-pup-bridge" [ "$(cat src/BRIDGE_VERSION)" = "$VER" ] || { echo "src/BRIDGE_VERSION is $(cat src/BRIDGE_VERSION), not $VER (ship.sh writes it first)"; exit 1; }  echo "== rust 0. gates =="@@ -26,6 +28,13 @@ echo "== rust 1. release build (x86_64-pc-windows-gnu) ==" [ -f "$EXE" ] || { echo "   exe missing"; exit 1; } grep -q "Puppeteer Bridge v" "$EXE" || { echo "   exe lacks the boot banner string"; exit 1; } +echo "== rust 1a. release build (x86_64-unknown-linux-gnu) =="+# Same workspace, host-linux picked by cfg(target_os = "linux") in bridge/main.rs. Build on a glibc no newer than+# the oldest distro you support (the binary needs only the glibc symbols it was linked against, 2.30 today).+( cd rust && cargo build --release --target x86_64-unknown-linux-gnu 2>&1 | grep -E "^(error|warning: unused)|Finished" )+[ -f "$ELF" ] || { echo "   linux binary missing"; exit 1; }+grep -q "Puppeteer Bridge v" "$ELF" || { echo "   linux binary lacks the boot banner string"; exit 1; }+ echo "== rust 1b. sign as Adom Industries (never package an unsigned exe) ==" # Windows 11 Smart App Control blocks unsigned exes. The signing itself lives in a PRIVATE script outside this repo # (John's build process); this public build only calls it and checks the result. No script, no package.@@ -42,26 +51,4 @@ grep -q "Puppeteer Bridge v" "$EXE" || { echo "   the signed exe lacks the boot  echo "== rust 2. package ==" mkdir -p "$ARCH"-python3 - "$VER" "$EXE" "$ARCH/$ZIP" <<'PY'-import json, sys, zipfile, hashlib, os-ver, exe, out = sys.argv[1:4]-d = json.load(open('src/bridge.json'))-assert d['version'] == ver, f"src/bridge.json is {d['version']}, not {ver}"-d['spawn'] = { 'kind': 'exe', 'entrypoint': 'adom-pup-bridge.exe', 'port': d['spawn'].get('port', 8851), 'healthEndpoint': '/status', 'stopMethod': 'kill', 'killImageName': 'adom-pup-bridge.exe', 'persistent': True,-    '_hint': 'spawn.kind exe: one sha256-verified binary, no Node, no npm, no postinstall (docs/PLAN-rust-port.md). Port pinned low per ab#83 (WinNAT exclusion ranges in the ephemeral band).',-    '_portNote': d['spawn'].get('_portNote', '') }-d.pop('runtime', None)-d['dependencies'] = { 'browser': d.get('dependencies', {}).get('browser', 'Chromium-family: installed Chrome or Edge, else Chrome for Testing is fetched as a last resort.') }-# The exe is Windows-only today (host-mac is stubs). Say so in the package so a Mac on the insiders-# tier is not handed a Windows binary; the Node build still serves macOS on the public tier.-d.setdefault('platforms', {})-d['platforms'] = {'windows': {'supported': True}, 'macos': {'supported': False, 'reason': 'The Rust exe is Windows-only for now; host-mac is stubbed for the Mac port. macOS runs the Node build (public tier).'}, 'linux': {'supported': False, 'reason': 'not yet'}}-d['shell'] = 'rust'-d['builtFor'] = 'x86_64-pc-windows-gnu' -with zipfile.ZipFile(out, 'w', zipfile.ZIP_DEFLATED) as z:-    z.write(exe, 'adom-pup-bridge.exe')-    z.writestr('bridge.json', json.dumps(d, indent=2) + '\n')-    z.writestr('BRIDGE_VERSION', ver)-json.dump(d, open(os.path.join(os.path.dirname(out), 'bridge.json'), 'w'), indent=2)-print(f"   {out}  sha256={hashlib.sha256(open(out,'rb').read()).hexdigest()}  size={os.path.getsize(out)}  exe={os.path.getsize(exe)}")-PY+python3 tools/rust-package.py "$VER" "$EXE" "$ELF" "$ARCH/$ZIP"
tools/rust-package.pyadded+65
@@ -0,0 +1,65 @@+#!/usr/bin/env python3+"""rust-package.py: build pup's release zip from already-built binaries.++    tools/rust-package.py <version> <windows-exe> <linux-elf> <out.zip>++One zip serves Windows and Linux (ab 2.1.175+ picks `spawn.entrypoints.<os>`):++    adom-pup-bridge.exe   Windows x64 (signed by tools/rust-build.sh before it calls this)+    adom-pup-bridge       Linux x86_64 ELF (glibc 2.30+), stored with mode 0755+    bridge.json           spawn.kind exe + entrypoints {windows, linux}+    BRIDGE_VERSION++Everything else (extensions, dashboard, icons, fonts) is embedded in each binary. Writes bridge.json beside+the zip too and prints the sha256/size the manifest needs. tools/rust-build.sh is the normal caller.+"""+import hashlib, json, os, sys, zipfile++ver, exe, elf, out = sys.argv[1:5]+d = json.load(open('src/bridge.json'))+assert d['version'] == ver, f"src/bridge.json is {d['version']}, not {ver}"+for p in (exe, elf):+    assert os.path.isfile(p), f"missing binary {p}"+assert open(elf, 'rb').read(4) == b'\x7fELF', f"{elf} is not a Linux ELF binary"+assert open(exe, 'rb').read(2) == b'MZ', f"{exe} is not a Windows PE binary"++d['spawn'] = {+    'kind': 'exe', 'entrypoint': 'adom-pup-bridge.exe',+    'entrypoints': {'windows': 'adom-pup-bridge.exe', 'linux': 'adom-pup-bridge'},+    'port': d['spawn'].get('port', 8851), 'healthEndpoint': '/status', 'stopMethod': 'kill',+    'killImageName': 'adom-pup-bridge.exe', 'persistent': True,+    '_hint': 'spawn.kind exe: one sha256-verified binary per OS, no Node, no npm, no postinstall (docs/PLAN-rust-port.md). '+             'entrypoints (ab 2.1.175+) picks the binary for the running OS. Port pinned low per ab#83 (WinNAT exclusion ranges in the ephemeral band).',+    '_portNote': d['spawn'].get('_portNote', ''),+}+d.pop('runtime', None)+d['dependencies'] = {'browser': d.get('dependencies', {}).get('browser', 'Chromium-family: installed Chrome or Edge, else Chrome for Testing is fetched as a last resort.')}+d['platforms'] = {+    'windows': {'supported': True},+    'linux': {'supported': True, 'since': ver,+              'notes': 'x86_64, glibc 2.30+ (Ubuntu 20.04+, Debian 11+, Fedora 32+), X11 and Wayland desktops. pup runs Chrome for Testing '+                       '(linux64) under XWayland on a Wayland session so its windows stay in the background; the Chromium sandbox uses user '+                       "namespaces or an installed setuid helper (Google Chrome's or the distro Chromium's). Taskbar identity, jump lists, "+                       'overlay badges and assisted click are Windows-only and answer that plainly. Details: docs/linux.md.'},+    'macos': {'supported': False, 'reason': 'The Rust exe is not built for macOS yet; host-mac is the Mac port. macOS runs the Node build (public tier).'},+}+d['shell'] = 'rust'+d['builtFor'] = ['x86_64-pc-windows-gnu', 'x86_64-unknown-linux-gnu']++def add(z, src, name, mode):+    zi = zipfile.ZipInfo.from_file(src, name)+    zi.compress_type = zipfile.ZIP_DEFLATED+    zi.create_system = 3                      # unix, so external_attr's mode bits are honoured+    zi.external_attr = (0o100000 | mode) << 16+    with open(src, 'rb') as f:+        z.writestr(zi, f.read())++os.makedirs(os.path.dirname(out) or '.', exist_ok=True)+with zipfile.ZipFile(out, 'w', zipfile.ZIP_DEFLATED) as z:+    add(z, exe, 'adom-pup-bridge.exe', 0o755)+    add(z, elf, 'adom-pup-bridge', 0o755)+    z.writestr('bridge.json', json.dumps(d, indent=2) + '\n')+    z.writestr('BRIDGE_VERSION', ver)+json.dump(d, open(os.path.join(os.path.dirname(out) or '.', 'bridge.json'), 'w'), indent=2)+sha = hashlib.sha256(open(out, 'rb').read()).hexdigest()+print(f"   {out}  sha256={sha}  size={os.path.getsize(out)}  exe={os.path.getsize(exe)}  elf={os.path.getsize(elf)}")
tools/ship.sh+1−1
@@ -202,7 +202,7 @@ RUST_FILES=$(find rust -path rust/target -prune -o -type f \( -name '*.rs' -o -n adom-wiki repo push "$PAGE" \   --files src/bridge.json src/BRIDGE_VERSION src/*.js src/extension/*.js src/extension/*.json SKILL.md package.json \           skills/*/SKILL.md dev-skills/*/SKILL.md publish-skills/*/SKILL.md \-          $RUST_FILES $EMBED_FILES tools/rust-build.sh tools/check-borders.sh tools/gen-assets.py tools/list-embedded.py tools/sync-nbe.sh \+          $RUST_FILES $EMBED_FILES tools/rust-build.sh tools/rust-package.py tools/check-borders.sh tools/gen-assets.py tools/list-embedded.py tools/sync-nbe.sh \   -m "v${VER} (insiders): ${LOG}" >/dev/null 2>&1 || {     [ "${FORCE_PUSH:-0}" = "1" ] || { echo "   PUSH FAILED (set FORCE_PUSH=1 to retry)"; exit 1; }   }

Comments

John Lauer ·

Update: X11 results and one more fix

Linux X11 (Debian 13, Cinnamon), from a clean install, no Google Chrome on the machine

  • pup_readiness: ready, Chrome for Testing fetched by pup, browserSandbox: "namespaces" (no setuid helper needed there).
  • pup_open_window 2.0 s, pup_eval → X11; Linux x86_64, pup_navigate, pup_screenshot 424 ms, pup_open_tab.
  • Background with the user working: a terminal focused first, then two threads' pup windows opened, a tab opened and a page navigated. EWMH stacking after each step: both pup windows below the terminal, the terminal stayed the active window. pup's birth path logged "caught invisible ... bottomed in that instant" for each window.
  • pup_raise_os_window {foregroundReason} activated the window; pup_lower_os_window returned it to the bottom.
  • Restart of pup with a window open: re-adopted, nothing relaunched.

Fix in shared code (commit "Ended lane stays dead")

Closing the last window and opening again stalled ~20 s on Linux (21 to 42 s measured). Two causes in extdrive:

  1. An /extcmd long poll that was waiting when pup ended the lane's browser finished afterwards and stamped last_poll, so alive() read the dead lane as live and the next open took the warm path into a dead channel. Now a poll whose lane was ended while it waited returns {} and leaves the lane dead.
  2. Commands still queued for (or handed to) the ended browser kept their callers in run() until timeout, and IN_FLIGHT > 0 also reads as alive. kill_extdrive now answers them at once (fail_outstanding).

After the fix, measured on Debian X11: 8 of 8 close → reopen in 1.3 to 1.9 s.

Windows, same branch, isolated instance, 8 close → immediate-open cycles: 2.3 to 2.9 s, 0 stalls. Unmodified master in the same harness: 1 of 8 stalled 22.8 s. The Windows stall is intermittent and pre-existing; fail_outstanding did not fire in those runs, so I am not claiming this fixes the Windows case, only that the branch does not make it worse.

Marking ready for review. Not merged, no release, no manifest moved.

John Lauer ·

ON HOLD: please do not review-merge or hand this to an owner yet. John wants to try pup on a Linux desktop himself first. Treat this PR as a draft until then. The wiki has no "back to draft" action, so this comment stands in for one.

Fedora 44 (GNOME, Wayland, SELinux enforcing), from a clean machine with no Google Chrome

The binary was built from this branch (b84c5a2). pup was started with the GNOME session's environment (systemctl --user show-environment), which is what a desktop-started Adom Bridge inherits.

  • pup_readiness: ready, Chrome for Testing 156 linux64 fetched, browserSandbox: "namespaces".
  • pup_open_window took 2.1 s on the extension lane.
  • pup_eval returned X11; Linux x86_64 / Linux x86_64.
  • pup_navigate took 0.9 s; pup_screenshot and pup_open_tab worked; a second thread's window opened in 1.2 s.
  • Background: a user xterm (XWayland) was on top before the test. After every open, tab and second window, the xterm was still at the top of the stack with both pup windows below it. pup_raise_os_window (with a reason) put a pup window on top, and pup_lower_os_window put it back under the xterm.
  • Vault: credential_set answered storage: ~/.adom/pup-secrets.json (mode 0600; this desktop has no unlocked keyring). List and delete worked.
  • Close everything, then open, 4 cycles: 1.77 to 1.83 s each.
  • pup restart with a window open: the window was re-adopted (pup_list_windows count 1), and closing it left no browser process.
  • No SELinux AVC denials for chrome.

Owner follow-ups 2 and 3 in the description (Windows profile matching, macOS UA) are left for the pup owner on purpose. This PR does not change Windows or macOS behaviour.

John Lauer ·

For the pup owner

Two things this branch saw but deliberately did not change, because they are Windows/macOS behaviour (Windows and macOS are unchanged by this PR):

  1. Windows profile matching (Q3). On Windows the default pup_browser_processes matches only pup-profiles\<name> command lines, but under ab pup's profiles live in <ADOM_BRIDGE_DATA_DIR>\profiles\<name> (pup-bridge#98), so the orphan and stray-lane checks may never match there. On Linux host-linux matches both forms. Worth a look.
  2. macOS user agent (Q4). macOS also presents a "Windows NT 10.0" UA on the extension lane (consumer_identity_strings). This branch makes it OS-correct for Linux only and leaves macOS as it was. Your call.

Other notes:

  • Zip size: 9.1 MB with both binaries (4.1 MB Windows-only before). Per-OS zips through the wiki manifest's platforms.linux.url would keep the Windows download at 4 MB if preferred; this branch ships one zip with spawn.entrypoints.
  • The Linux build is tested unsigned; packaging and signing stay with the release step.
  • wikiView:"public" on the extension lane returned wikiView:"public" but the page still rendered signed in (shared code; seen on Linux, not checked on Windows).
  • Window recording on the extension lane uses ab's desktop_record_window_start, which ab for Linux does not have yet; pup answers that plainly.
  • Later on this branch: per-thread dock icons, progress and a tab-count badge on Linux (.desktop entry per thread, _NET_WM_ICON, com.canonical.Unity.LauncherEntry, XApp progress on Cinnamon). What each desktop shows is in linux/README.md. Windows code paths unchanged; the isolated Windows smoke passes.

(The wiki has no way to edit a PR description, so this section is posted here; it replaces "Notes for the owner" in the description.)

Not merged, no release, no manifest moved.

John Lauer ·

Shared fixes on this branch (both OSes): one commit each, take or drop separately

The Linux Ralph test found three gaps in shared code that behave the same on Windows. Each fix is its own commit on linux-port:

  1. pup_annotate {action:"cancel"} closes the overlay. The schema advertised cancel, but the verb ignored it and re-armed the toolbar. Cancel now takes the overlay's own exit path (the X button's): nothing is captured, and it answers cancelled:false when no overlay is open. Files: annotate.rs, annotate-overlay.js.
  2. pup_errors reports errors on the extension lane. It always answered count:0 there, because it read a store that only the CDP lane fills.
    • Two page-start scripts registered by the extension worker now capture errors. pup-errors-main.js forwards uncaught exceptions and unhandled rejections. pup-errors.js buffers them in the extension's isolated world, along with failed loads and HTTP error responses.
    • Nothing is wrapped and no page global is added.
    • The driver's console errors are merged in, one line per error.
    • pageCapture:false means the page predates the capture.
  3. pup_dialog answers on the extension lane. While pup drives a tab, Chrome cancels a page dialog the moment it opens, so pup_dialog always said "no dialog".
    • It now reports the dialog Chrome already handled (alreadyHandled, was).
    • It answers a dialog that is still open.
    • policy:"manual" pre-answers the next dialogs on the tab; policy:"auto" disarms and lists what was answered.
    • The verb schema documents this.

The CDP lane is unchanged.

Tested:

  • Linux: Debian 13 Cinnamon, hand-launched build, 24/24 checks pass (batchF).
  • Windows 11: isolated exe built at each commit, standard smoke plus a fix check. Each commit fixes only its own verb, and the foreground window never changed.
  • Close/reopen cycles: the intermittent ~23 s reopen stall appears at the same rate on the baseline build without these commits (1 of 12), so it predates them.

Release note: Chrome reloads the extension's service worker only when the manifest version changes. pup stamps that version with its own, so existing profiles pick these fixes up when the release bumps pup's version, not before.

(The wiki has no way to edit a PR description, so this is posted as a comment.)

Not merged, no release, no manifest moved.

John Lauer ·

Linux fixes from the Ralph test (Linux-only code; Windows unchanged)

  • 9600d0d, 9d1e0ef: a pup window no longer comes to the front by itself on Linux.
    • The cause: a DevTools mouse press (pup_click) makes Chrome activate its window, and so does a page's alert/confirm/prompt. The X11 window manager honours the activation; Windows' foreground lock refuses it.
    • What it did: on Debian 13 (Cinnamon) the user's terminal lost the keyboard for 42 s after one click, and for 21 s after one confirm().
    • The fix: a focus guard after the page-input verbs, plus a self-raise watcher. Together they lower a pup window that came to the front with no granted raise and no human input in the last 0.4 s, which hands the keyboard back.
    • Measured after: about 0.12 s per activation. A granted pup_raise_os_window stays in front until it is lowered.
  • 9f6b01f: pup_restart_browser brings the windows back on the extension lane. Recovery took the ended browser's X window as "alive on glass" (an X window outlives its process briefly). Linux is_alive now also requires a live _NET_WM_PID. After: the window is back at its page within 2 s.
  • 0b5c0ce: linux/README.md explains the above.

For the pup owner (not changed here):

  • pup_close catalog text says {} closes EVERY window on the machine across all threads. The code closes only the caller's default window; {all:true} closes the caller's windows, and everyone applies to human callers only. The code is the safe side; the doc should match it.
  • Windows reopen stall: the intermittent ~23 s close-to-reopen stall shows in 1 to 2 of 8 cycles on Windows, with or without this branch (baseline included).

Windows smoke + fix check + cycles were re-run on the head build: all pass, and the foreground never changed.

Not merged, no release, no manifest moved.

John Lauer ·

faccdee: a granted raise is recorded before the window moves (both OSes; its own commit, take or drop). pup_raise_os_window set raise_granted only after activating the window. A park re-assert running in between, which happens inside a window's 8 s birth grace (for example just after pup_restart_browser), therefore bottomed the granted raise: on Ubuntu 24.04 the window went back behind the user's app 0.8 s after the raise. The grant is now recorded first. After the fix the raise stayed in front until pup_lower_os_window. The Windows smoke passes. Not merged, no release.

John Lauer ·

More Linux fixes from the Ralph test (Linux-only behaviour; Windows unchanged, smoke re-run)

  • 13c9f99: pup survives an Xwayland restart. GNOME 46 starts Xwayland on demand and gives each start a new cookie file. pup kept the old XAUTHORITY, so after a restart every browser launch failed ("Authorization required ... Missing X server") until pup itself restarted. pup now uses the live cookie for every X connection and launch.
  • f9b4e68: pup_record_start records on the Linux extension lane. ab for Linux has no window recorder, and the extension lane had no fallback. On Linux it now polls DevTools screenshots at up to 10 fps and encodes with the existing encoder. Measured: 58 frames in 7.4 s, VP9 WebM, 1390x784.
  • db4cad6: the screen recorder reports GNOME's Share Screen dialog. On Wayland only a person can choose the screen, so nothing records until they press Share. pup said ok and later returned a 0-byte file. Now pup_screen_record_start answers blocked_on_user (share_dialog), and a stop with nothing captured answers nothing_recorded.

For the owner (shared, cosmetic): the recorder HUD shows Chrome's "unsupported command-line flag" bar, because of --use-fake-ui-for-media-stream and --auto-select-desktop-capture-source. The same flags are passed on Windows.

Not merged, no release.

Log in to comment.