Compare commits

...

4 commits

Author SHA1 Message Date
1bbc71166e (i hope) support for ALL xdg- dialogs 2026-10-07 10:29:07 +02:00
9748b98163 improve native isolation 2026-10-07 08:26:57 +02:00
3f57682a9b try to improve link handling 2026-10-07 01:46:26 +02:00
2ff8920225 support opening links from linux apps 2026-10-07 00:57:55 +02:00
17 changed files with 632 additions and 90 deletions

View file

@ -1,6 +1,6 @@
# PLWL — Portable Linux Wine Launcher
A portable launcher for Windows games and applications on Linux — and for native Linux apps, run with their own isolated home (bubblewrap when available).
A portable launcher for Windows games and applications on Linux — and for native Linux apps, run with their own home and a chosen isolation level (bubblewrap when available).
## Portable layout
@ -44,21 +44,30 @@ The entry point is found automatically in the picked folder: an ELF binary or `.
A native app runs with its **own home folder**: `HOME`, `XDG_CONFIG_HOME`, `XDG_DATA_HOME`, `XDG_CACHE_HOME` and `XDG_STATE_HOME` point into `files/wineprefixes/<prefix>/linux_pfx/`, so config, cache and state land there instead of in your `~`. The prefix select in the app's config picks which one; **Open data folder** takes you to it. `linux_pfx` is machine-local — a LAN sync never pushes it and never deletes it.
When **bubblewrap** is installed *and* usable (PLWL probes it with a real run, not `which bwrap` — Ubuntu's AppArmor profile kills it even when it is installed), the app also gets its own namespaces: `/` is mounted read-only, so only **its home folder and its own app folder stay writable** — anything else fails with `EROFS`. The app folder is included on purpose: portable apps keep their logs and data next to themselves (Prism Launcher refuses to start otherwise). System trees are never handed out writable, not even when the app itself lives in one. Screens, audio (X11/Wayland, PulseAudio/PipeWire), gamepads and networking keep working. `XDG_RUNTIME_DIR` stays the host's, so the session bus is shared — the sandbox is not airtight, and the UI says so rather than pretending.
When **bubblewrap** is installed *and* usable (PLWL probes it with a real run, not `which bwrap` — Ubuntu's AppArmor profile kills it even when it is installed), the two sandboxed levels also give the app its own namespaces: `/` is mounted read-only, so only **its home folder and its own app folder stay writable** — anything else fails with `EROFS`. Relaxed adds exactly one mount on top of that profile (the writable host filesystem described below); everything else on this page is the same at both levels. The app folder is included on purpose: portable apps keep their logs and data next to themselves (Prism Launcher refuses to start otherwise). System trees are never handed out writable, not even when the app itself lives in one. Screens, audio (X11/Wayland, PulseAudio/PipeWire), gamepads and networking keep working. `XDG_RUNTIME_DIR` stays the host's, so the session bus is shared — the sandbox is not airtight, and the UI says so rather than pretending.
The redirected home starts with a **copy of your desktop preferences** — `kdeglobals`, the GTK 3/4 settings and `user-dirs.dirs` — so the app keeps your theme instead of falling back to its built-in light look. The copy keeps following your host as long as the app has not changed it: a theme change arrives on the next launch, and the moment the app writes its own version, PLWL never touches it again. Your own **fonts, icons, themes and colour schemes are not copied but mounted read-only** inside the sandbox and added to `XDG_DATA_DIRS`, so they follow the host live and nothing can be written back to your `~`.
**Anything the app wants to open is opened by your own desktop.** A **link** goes to the host's xdg-desktop-portal, so it lands in the browser you actually chose instead of one resolved in here, where the default association and its `.desktop` file are behind the redirected home — that used to mean a system browser you never picked, or nothing at all. Both routes reach the portal: `xdg-open` itself (what Qt, Prism and Electron call) and GIO's in-process resolution (`gtk_show_uri`). A **folder or a document** (Prism's *Open Pack Folder*, a text file, a drop) takes the other route: the app leaves the target in its own home, PLWL picks it up, translates the path back out of the sandbox and opens it with your `xdg-open` — so it arrives in your file manager, and a scheme nothing handles gets your desktop's own *Choose an application* dialog rather than a guess. On a machine without a portal nothing is changed — a link then behaves exactly as it did before. A KDE-native app that resolves the link in-process through KIO (Dolphin, Kate) is the one path still left out: it asks KDE's own handler database, which has no answer in here (measured: `kde-open` opens nothing and reports success).
Every prefix in the **Wine prefixes** tab has a **Reset Linux config** button: it deletes the desktop preferences PLWL copied in (and the hashes that track them), so the next app start seeds a clean copy from your system — the way back when an app has hijacked a seeded file with its own theme. App data in `linux_pfx` is never touched, and an app running from that prefix blocks the reset.
Imported trees get **normalised permissions**: every file gets the owner's `rwx` and an `x` mirrored from its `r`, because sources come from archives, FAT sticks or read-only backups where those bits are simply not there.
Some apps draw their window icon themselves, which Wayland does not allow — Minecraft (LWJGL 3.4+) is the known case: its taskbar icon turns into the generic Wayland one. The per-app **Force X11 (XWayland)** switch moves the app *and the programs it launches* back to X11, where the icon works again.
Per app you choose the **Isolation** policy: *Automatic* (bwrap when it works, plain run when it does not), *Off*, or *Required* (refuse to start instead of running unsandboxed). The backend actually used is always visible: a badge on the library card, a `[plwl] isolation: …` line in the app log, and a status card in Settings with **Check again**. When bwrap is missing, PLWL says so once instead of quietly launching without it.
Per app you choose the **Isolation** level — what the app gets besides its own home folder:
- **None** — a plain run. The app sees your real filesystem exactly as any other program of yours does: what a shortcut hands it works, including into an app that is already running.
- **Relaxed** — the sandbox *plus* your whole filesystem mounted writable at `/tmp/plwl-fs-root`. A document handed over behaves natively — it opens where the app was told it is, it can be saved, and it also reaches an app that is already running, because the mount is there from the first second of every run. The price is real and the UI says it: at this level the app can change anything your user can.
- **Isolated** — the sandbox without that mount. The system stays read-only, and a document handed over is **copied** first, into `plwl-handoff` inside the app's own home folder; the app works on that copy (so the original cannot be touched), and a copy big enough to be noticed shows a progress popup in the launcher.
- **Automatic** (default) — Relaxed where bubblewrap works, None where it does not.
An explicit Relaxed or Isolated on a machine without a working bubblewrap **refuses to start** instead of quietly dropping the promise; Automatic is the one level allowed to weaken itself. What the app really ran as is always visible: a badge on the library card, a `[plwl] isolation: …` line in the app log, and a status card in Settings with **Check again**. When bwrap is missing, PLWL says so once instead of quietly launching without it.
## "Open with PLWL"
The desktop entry registers `.exe/.msi/.bat/.cmd/.lnk` in file managers. Opening one shows a popup to pick the **Wine/Proton build** and **prefix** for that run (defaults preselected, nothing imported). A second instance hands the path to the running one. App shortcuts work as **Open With** targets too: the app receives the file as the Windows path it would get on Windows — `C:\...` inside its prefix, `Z:\...` otherwise. A file handed to an already running app goes to that instance instead of a second tracked run.
The desktop entry registers `.exe/.msi/.bat/.cmd/.lnk` in file managers. Opening one shows a popup to pick the **Wine/Proton build** and **prefix** for that run (defaults preselected, nothing imported). A second instance hands the path to the running one. App shortcuts work as **Open With** targets too: the app receives the file as the Windows path it would get on Windows — `C:\...` inside its prefix, `Z:\...` otherwise. A file handed to an already running app goes to that instance instead of a second tracked run. A **Linux app shortcut** is an Open With target the same way, and what the app is handed depends on its isolation level: at **None** the plain absolute path, at **Relaxed** the same file through the writable host mount (`/tmp/plwl-fs-root/…`) — which is also the only shape an app that is *already running* can resolve, since a mount cannot be added to a live sandbox — and at **Isolated** a copy in `plwl-handoff` inside the app's own home folder, so the original stays untouched.
## LAN syncing

View file

@ -15,11 +15,16 @@ src/ main process (Node/Electron backend)
lansync.js LAN sync handlers (identity, devices, re-roll, sync + progress)
runner.js spawns wine/proton (env, prefix, playtime, logs) and owns
spawnTracked() - the log/playtime/exit tail every run shares
linuxrunner.js native Linux apps: redirected HOME/XDG, bwrap or plain run
linuxrunner.js native Linux apps: redirected HOME/XDG, the isolation level
(none/relaxed/isolated/auto) and the handed-document path
openrelay.js the launcher side of the hand-off: the request folder an app
leaves what it wants opened in, watched while it runs
linuxapp.js native app detection (ELF/AppImage/.desktop) + icon lookup
sandbox.js bubblewrap probe (functional, cached) + buildBwrapArgs
(+ FS_ROOT, the one mount the relaxed level adds)
library.js apps registry (apps.json) + settings + playtime files, plus
the "which prefix does this app use" / in-use guards
the "which prefix does this app use" / in-use guards and
the isolation-mode gate on the way in
wines.js wine/proton build scan, registry, portable symlink healing
prefixes.js prefix registry (files/wineprefixes)
symlinks.js relative symlink rewiring + executable-bit restoration
@ -67,12 +72,13 @@ Renderer never touches Node/Electron directly — everything goes through the `w
- Registries (apps.json, wines.json, prefixes.json) are rewritten whole via writeJson(); playtime lives in its own tiny file so the registry is not rewritten while a game runs.
- Deleting user data needs a confirm modal; best-effort cleanup is try/catch-and-ignore with a comment saying why it is safe to ignore.
- Windows apps run through runGame()/buildEnv() — never spawn wine directly elsewhere (prefix handling, Proton env and playtime live there).
- Native Linux apps run through linuxrunner.runLinuxGame()/buildLinuxEnv() the same way: HOME and the XDG folders are redirected into `{prefix}/linux_pfx/`, and bwrap (when the probe says it works) forces the same layout with mounts. Never spawn a native app elsewhere. `game.kind` is absent on old entries — that means Windows; `game.isolation` is `auto` (bwrap when usable, plain run otherwise), `off` or `require` (refuse to start instead of quietly weakening). The chosen backend always lands in the log and the UI (a silent downgrade is the failure mode to avoid).
- Under bwrap ONLY two things stay writable: the redirected home and the app's own folder (`writableAppDir`: inside files/apps, or the folder the app was imported from — never `/usr`, `/etc`, `/bin`… and never files/wines). Portable apps write logs and data next to themselves and a read-only app folder makes them refuse to start (Prism Launcher: "couldn't create a log file - Read-only file system"). Everything else is `EROFS` by design.
- Native Linux apps run through linuxrunner.runLinuxGame()/buildLinuxEnv() the same way: HOME and the XDG folders are redirected into `{prefix}/linux_pfx/`, and bwrap (when the probe says it works) forces the same layout with mounts. Never spawn a native app elsewhere. `game.kind` is absent on old entries — that means Windows; `game.isolation` is one of four levels, resolved by linuxrunner.resolveLevel: `none` (plain run), `relaxed` (bwrap + the whole host filesystem mounted writable at sandbox.FS_ROOT) and `isolated` (bwrap with the stock read-only profile), with `auto` (default) meaning relaxed where bwrap works and none where it does not. Only `auto` may weaken itself — an explicit relaxed/isolated answers `sandbox-required` instead ("no silent downgrade"), and library.js maps the values launcher 0.5.x wrote (`off` → none, `require` → isolated) both when reading the registry and when a patch is saved. The level always lands in the log and the UI. resolveLevel awaits the probe when nothing has measured yet: a shortcut boots HEADLESS, so no renderer ran `sandbox:status` — without that wait `auto` would read an empty cache and quietly pick `none` (and an explicit level would refuse a machine that can sandbox perfectly well).
- Under bwrap ONLY two things stay writable: the redirected home and the app's own folder (`writableAppDir`: inside files/apps, or the folder the app was imported from — never `/usr`, `/etc`, `/bin`… and never files/wines). Portable apps write logs and data next to themselves and a read-only app folder makes them refuse to start (Prism Launcher: "couldn't create a log file - Read-only file system"). Everything else is `EROFS` by design — at the isolated level. Relaxed is the deliberate exception and says so in the UI: `FS_ROOT` is the same filesystem, mounted writable, so "everything else" is only unreachable through the real paths.
- Two pipes feed one log in spawnTracked: neither may end the stream (pipe(ws, { end: false }), closed when both sources drained, at the latest LOG_DRAIN_MS after the exit) and the write stream carries an error listener. Ending it at `exit` while a pipe still had data killed the whole launcher with ERR_STREAM_WRITE_AFTER_END.
- A prefix or wine build a running app uses cannot be renamed/deleted (wine holds files open there): the guards sit next to the handlers in src/ipc/, use the games service's running list plus library.js helpers (prefixNameForGame/gamesUsingPrefix/is*InUse) and answer `{ ok:false, error:'inUse'|'hasApps', apps:[names] }` for the toast.
- Long operations stream progress through the single import:progress slot as `{ phase: 'count'|'copy'|'remove', files, bytes, totalFiles, totalBytes }` (runWithProgress for deletes/imports). Emit 'count' (a treeSize walk) first, copy/remove through the fsutil helpers.
- Long operations stream progress through the single import:progress slot as `{ phase: 'count'|'copy'|'remove', files, bytes, totalFiles, totalBytes }` (runWithProgress for deletes/imports). Emit 'count' (a treeSize walk) first, copy/remove through the fsutil helpers. An isolated hand-off has its own slot, handoff:progress (`{ phase: 'copy'|'done'|'error', name, bytes, totalBytes, notice }`), because it can start before the window exists: main.js gets the event first and, when `notice` is set (a copy above linuxrunner.STAGE_NOTICE_BYTES), puts the launcher window up so the renderer's popup can be seen; below that the copy is over before a window could paint. Copies of one file go through fsutil.copyFileProgress (streamed, throttled by progressGate, `.part` + rename).
- CSS: reuse the existing variables/classes (.button, .field-label, .custom-select, form-card); spacing comes from --button-margin and flex gaps, not per-element magic numbers.
- The blanket `transform: translateZ(0)` (crisper small glyphs) makes every element its own stacking context, so a select's own z-index cannot escape the wrapper it sits in: a list opened inside `.linux-only`, `.win-only` or an override row was painted UNDER the field label and textarea that follow it (measuring with `elementsFromPoint` over the open list returned the label). CustomSelect lifts the chain instead of the host — `lift()` adds `.select-lift` to every STATIC ancestor while the list is open, and `close()`/`closeAll()` take it off again; positioned ancestors are skipped, so the fixed modal overlay keeps stacking over every view on its own.
### Native Linux apps
@ -83,7 +89,12 @@ Renderer never touches Node/Electron directly — everything goes through the `w
- SESSION_MANAGER is dropped in the sandbox: the session socket sits in the private `/tmp`, so the pointer led nowhere (Qt: "Could not open network socket").
- Imports normalise permissions (fsutil.chmodTree): owner `rwx`, `x` mirrored from `r` (0644 → 0755). Sources are archives, FAT sticks and read-only backups.
- **Reset Linux config** on every prefix row removes exactly what PLWL seeded (DESKTOP_SEEDS, the pulse cookie, `home/.Xauthority`, `.seeded.json`) plus the folders that existed only for the seed, so the next start seeds clean. syncDesktopConfig can only follow a copy it can still prove is its own; the reset does not argue. App data (`data/`, `cache/`, `state/`, `config/<app>/`, the rest of `home/`) is never touched, and a running app from that prefix blocks it (the usual `inUse` guard).
- The user's real `~` is deliberately invisible in the sandbox (HOME is bound straight to `{prefix}/linux_pfx/home`), so absolute paths an app wrote into its config before the import (Prism's instance `JavaPath`) do not resolve — that is what `isolation: 'off'` is for.
- With HOME bound straight to `{prefix}/linux_pfx/home`, the user's real `~` is invisible to the app — one reason absolute paths an app wrote into its config before the import (Prism's instance `JavaPath`) do not resolve. `relaxed` does not change that shadowing, but it does make the real filesystem reachable through `FS_ROOT`/home/… — and `none` gives the app the real paths outright, which is what those two levels are for.
- A clicked link belongs to the host desktop, so it leaves through `org.freedesktop.portal.OpenURI`: the `xdg-open` BINARY is replaced inside the sandbox (a written shim `--ro-bind` over `/usr/bin/xdg-open`) and Qt execs it (measured — `QGenericUnixServices` picks `xdg-open` first, and reaches the portal itself only under `/.flatpak-info`/`SNAP`, which a bwrap profile does not have); Electron does the same. GIO resolves an URI IN-PROCESS, which no shim can reach: for that the prefix seeds `home/.plwl/open-url` (launcher-written, like the shim) plus a desktop entry and a `mimeapps.list` naming it the default for `x-scheme-handler/http|https`, `text/html`, `x-scheme-handler/file` and `inode/directory`. Without the seed GIO falls back to the host's `/usr/share` list — measured: it offered `brave-origin.desktop`, and `gio open` answered "The specified location is not supported". Measured end to end (both backends): the portal launches the host's `helium.desktop` with the exact URI. `GIO_USE_PORTALS=1` was measured and REJECTED: it opened NOTHING and still reported success (GIO's portal path wants a sandbox marker this launcher does not fake). A KDE-native app resolving the link in-process through KIO (`kde-open`) stays uncovered — KDE ignores the seeded list (measured).
- The portal is not told about a FOLDER or a document, which is what "Open Pack Folder" or a drop asks for: it cannot see a path from in here and answers `Response` 2 for `file://` (measured, while https answered 0). The shim leaves those in `home/.plwl/open-request` (written as `.part` and renamed, so nothing ever reads half a request) — a live bind of the app's home, so it reaches an app that is ALREADY running — and the launcher opens it out here with the host's own `xdg-open` (openrelay.js: `createOpenRelay` watches that folder for exactly as long as the app runs, consumes each request once, and quits watching on exit). The target is rewritten on the way out — the private `$HOME` back to `{prefix}/linux_pfx/home`, `FS_ROOT` back to `/` at relaxed — so the host opens the REAL folder and never a path that only exists inside. Nothing in the sandbox can start a host program itself: measured, the host's own `xdg-open` run in there ends in `kde-open`, which exits 0 and opens no window at all. A scheme nothing on the host claims ends in the desktop's own "choose an application" dialog, which is the point of handing it out instead of guessing.
- The portal's `Response` is NOT read back: it is a signal addressed to the caller's own bus connection, so a separate `gdbus monitor` never receives it (measured: 15 calls, 15 timeouts, while the portal did open what it was given) and the readback only cost the app 3 s per click before giving up. Only whether the call went out is trusted, so a machine without a portal needs no special case any more: the call fails, and the request file is what still lands. (0.5.x had the host's own script parked inside the sandbox as the shim's fallback; measured, that route opens nothing.)
- A file handed over (a shortcut's `--file %f`, a drop on it) is the one path the sandbox cannot see by itself: a file out of the user's home is buried under the prefix home, one out of `/tmp` sits behind the private tmpfs, and the rest of the machine is read-only. Per-file `--bind` (what 0.5.0 did) is the wrong tool: a mount cannot be added to a namespace that is ALREADY running, so a hand-off into a running app could never be served that way. `handedPathFor` instead names the document in the one shape the chosen level can reach, and the app is told nothing else: `none` → the real path; `relaxed` → `FS_ROOT` + the real path; `isolated` → a copy in `{prefix}/linux_pfx/home/plwl-handoff/<name>`, which is a live bind of the app's home, so it reaches a running app too (measured end to end in test/linux-e2e.js: both levels hand a document to a sandbox that is already up). Copies are one per name and refreshed on every open (the folder cannot grow), written through a `.part` name and renamed so nothing ever reads a half-written document, and the app's edits stay in the copy — that IS the level's promise. A handed path that is gone fails the hand-off instead of launching an app with no document.
- `FS_ROOT` (`/tmp/plwl-fs-root`) is where relaxed mounts the host filesystem, and it has to live under the private `/tmp`: bubblewrap creates the mount point itself, and the root of a read-only root cannot be written to (measured on 0.12.0: `Can't create file /PLWL_FS_ROOT: Read-only file system`, for a `--dir` before the `--ro-bind / /` too). Measured also: through it the real `~` reads, a write lands on the real file, and a file created AFTER a sandboxed run started is visible inside it — that last one is what makes a hand-off into a running app possible at all.
- Not built: a private or overlay `/etc` (`buildBwrapArgs` accepts `etcRoot`, nothing sets it; `--ro-bind / /` leaves `/etc` read-only, which is all we promise today), `--tmpfs /home` against OTHER users' homes (`hideHome` is plumbed, no UI) and `dataMode` — app data always lands in `{prefix}/linux_pfx`.
- Measured, not assumed (Debian 13, kernel 6.12, `bwrap` 0.12.0): the functional probe passes here, no firejail/proot/fuse-overlayfs on the box, `/etc` is 16 MB and its `machine-id` is read-only even on the host.
@ -129,11 +140,13 @@ node test/lansync-pending-links.test.js # links to files created later, startup
node test/lansync-consent.test.js # accept / decline / timeout gate
node test/lansync-token.test.js # long-sync token expiry (sliding TTL)
node test/bench-sync.js # timing: cold vs cached sync
node test/linux-sandbox.test.js # bwrap argv profile + auto/off/require choice
node test/linux-sandbox.test.js # bwrap argv profile + the four levels, handed paths, mode migration
node test/linux-env.test.js # redirected HOME/XDG_* for both backends
node test/linux-import.test.js # ELF/AppImage/.desktop detection + icons
node test/linux-links.test.js # link opening: shim binds + mime seed
node test/linux-open.test.js # what an app opens: the script + the launcher relay
node test/linux-sync.test.js # linux_pfx stays out of a sync
node test/linux-e2e.js # REAL run: plain + bwrap (/etc read-only, home in prefix)
node test/linux-e2e.js # REAL runs: plain, relaxed (+ its mount), isolated, hand-off into a running app
npm start # click through the flow you touched
# Repairing / testing a real instance (test/ scripts are gitignored helpers):

View file

@ -93,10 +93,15 @@
"config.openData": "Open data folder",
"config.linuxKindValue": "Linux",
"config.isolation": "Isolation",
"config.isolation.auto": "Automatic (strongest available)",
"config.isolation.off": "Off (plain run)",
"config.isolation.require": "Required (refuse to run without bwrap)",
"config.isolation.bwrap": "bwrap {0} (strong)",
"config.isolation.auto": "Automatic",
"config.isolation.none": "None",
"config.isolation.relaxed": "Relaxed",
"config.isolation.isolated": "Isolated",
"config.isolationHint.auto": "Relaxed where bubblewrap works; on a machine without it the app runs without isolation.",
"config.isolationHint.none": "No sandbox: the app sees, and may change, everything your user can.",
"config.isolationHint.relaxed": "The whole disk is mounted writable as /tmp/plwl-fs-root.",
"config.isolationHint.isolated": "The system is read-only.",
"config.isolation.bwrap": "bwrap {0} (available)",
"config.isolation.env": "no sandbox: {0}",
"config.forceX11": "Force X11 (XWayland)",
"config.forceX11Hint": "Runs the app through XWayland instead of Wayland. Useful when a window cannot carry its own icon or misbehaves on Wayland.",
@ -109,7 +114,7 @@
"config.overrides.remove": "Remove",
"config.overrides.hint": "Wine module names, e.g. dinput8. native,builtin (the default) loads a DLL shipped with the game and falls back to Wine's builtin; disabled blocks the module.",
"sandbox.title": "Linux app isolation",
"sandbox.hint": "Native apps always run with a redirected home folder. With bubblewrap they get their own namespaces too: only their home folder and their own app folder stay writable, the rest of the system is read-only.",
"sandbox.hint": "Native apps always run with a redirected home folder, so their config and cache stay in the prefix. The isolation level says what else they get: None is no sandbox at all, Relaxed mounts the whole disk writable, Isolated writes nothing outside the app's own folders and copies an opened file into its home folder.",
"sandbox.status": "Status",
"sandbox.check": "Check again",
"sandbox.rechecked": "Isolation checked again",
@ -118,13 +123,17 @@
"sandbox.reason.timeout": "not responding",
"sandbox.reason.failed": "not working",
"sandbox.warnTitle": "No sandbox for Linux apps",
"sandbox.warnBody": "bubblewrap is {0}. Linux apps will run with only a redirected home folder - they can still read the rest of your system, and write to anything your user can.",
"sandbox.warnBody": "bubblewrap is {0}. Apps left on Automatic run as plain programs (None), while apps set to Relaxed or Isolated refuse to start instead of pretending the sandbox is there.",
"sandbox.dismiss": "Got it",
"sandbox.badgeStrong": "bwrap",
"sandbox.badgeWeak": "no sandbox",
"sandbox.badgeOff": "isolation off",
"sandbox.badgeRequired": "bwrap required",
"sandbox.badgeNone": "No isolation",
"sandbox.badgeRelaxed": "Relaxed",
"sandbox.badgeIsolated": "Isolated",
"sandbox.badgeRequired": "Bwrap required",
"handoff.copying": "Copying into the app: {0}",
"handoff.copyNote": "{0} / {1} - the app works on a copy inside its own home folder (plwl-handoff); edits in the copy never come back.",
"handoff.failed": "Could not copy {0} into the app: {1}",
"sandbox.required": "This app needs a working bubblewrap sandbox: {0}",
"sandbox.linkOpener": "PLWL link opener",
"config.save": "Save changes",
"config.shortcut": "Create shortcut",
"config.shortcutCreated": "Shortcut for {0} created (menu + desktop)",

View file

@ -93,10 +93,15 @@
"config.openData": "Otwórz folder danych",
"config.linuxKindValue": "Linux",
"config.isolation": "Izolacja",
"config.isolation.auto": "Automatyczna (najmocniejsza dostępna)",
"config.isolation.off": "Wyłączona (zwykłe uruchomienie)",
"config.isolation.require": "Wymagana (odmów bez bwrap)",
"config.isolation.bwrap": "bwrap {0} (mocna)",
"config.isolation.auto": "Automatyczna",
"config.isolation.none": "None",
"config.isolation.relaxed": "Relaxed",
"config.isolation.isolated": "Isolated",
"config.isolationHint.auto": "Relaxed, gdy bubblewrap działa; na maszynie bez bwrap aplikacja uruchamia bez izolacji.",
"config.isolationHint.none": "Bez sandboxa: aplikacja widzi i może zmienić wszystko, do czego ma prawo Twój użytkownik.",
"config.isolationHint.relaxed": "Cały dysk podmontowany zapisywalnie jako /tmp/plwl-fs-root.",
"config.isolationHint.isolated": "System tylko do odczytu.",
"config.isolation.bwrap": "bwrap {0} (dostępny)",
"config.isolation.env": "brak sandboxa: {0}",
"config.forceX11": "Wymuś X11 (XWayland)",
"config.forceX11Hint": "Uruchamia aplikację przez XWayland zamiast Waylanda. Przydatne, gdy okno nie potrafi mieć własnej ikony albo źle działa na Waylandzie.",
@ -109,7 +114,7 @@
"config.overrides.remove": "Usuń",
"config.overrides.hint": "Nazwy modułów Wine, np. dinput8. native,builtin (domyślne) wczytuje DLL dołączony do gry, a gdy go nie ma — wbudowany Wine; disabled blokuje moduł.",
"sandbox.title": "Izolacja aplikacji Linux",
"sandbox.hint": "Natywne aplikacje zawsze działają z przekierowanym folderem domowym. Z bubblewrap dostają dodatkowo własne przestrzenie nazw: zapisywać można tylko w swoim folderze domowym i w swoim folderze aplikacji, reszta systemu jest tylko do odczytu.",
"sandbox.hint": "Natywne aplikacje zawsze działają z przekierowanym folderem domowym - ich config i cache zostają w prefiksie. Poziom izolacji mówi, co jeszcze dostają: None to brak sandboxa, Relaxed daje cały dysk zapisywalny, a Isolated nie pozwala zapisać niczego poza folderem aplikacji i kopiuje otwierane pliki do jej folderu domowego.",
"sandbox.status": "Status",
"sandbox.check": "Sprawdź ponownie",
"sandbox.rechecked": "Sprawdzono izolację ponownie",
@ -118,13 +123,17 @@
"sandbox.reason.timeout": "nie odpowiada",
"sandbox.reason.failed": "nie działa",
"sandbox.warnTitle": "Brak sandboxa dla aplikacji Linux",
"sandbox.warnBody": "bubblewrap {0}. Aplikacje linuksowe będą działać tylko z przekierowanym folderem domowym - nadal mogą czytać resztę systemu i pisać wszędzie, gdzie pozwala Twój użytkownik.",
"sandbox.warnBody": "bubblewrap {0}. Aplikacje ustawione na Automatyczną uruchomią się jak zwykłe programy (None), a te ustawione jawnie na Relaxed albo Isolated odmówią startu, zamiast udawać, że sandbox działa.",
"sandbox.dismiss": "Rozumiem",
"sandbox.badgeStrong": "bwrap",
"sandbox.badgeWeak": "bez sandboxa",
"sandbox.badgeOff": "izolacja wyłączona",
"sandbox.badgeRequired": "wymaga bwrap",
"sandbox.badgeNone": "Bez izolacji",
"sandbox.badgeRelaxed": "Relaxed",
"sandbox.badgeIsolated": "Isolated",
"sandbox.badgeRequired": "Wymaga bwrap",
"handoff.copying": "Kopiowanie do aplikacji: {0}",
"handoff.copyNote": "{0} / {1} - aplikacja pracuje na kopii w swoim folderze domowym (plwl-handoff); zmiany w kopii nie wracają do oryginału.",
"handoff.failed": "Nie udało się skopiować {0} do aplikacji: {1}",
"sandbox.required": "Ta aplikacja wymaga działającego sandboxa bubblewrap: {0}",
"sandbox.linkOpener": "PLWL - otwieranie linków",
"config.save": "Zapisz zmiany",
"config.shortcut": "Utwórz skrót",
"config.shortcutCreated": "Skrót dla {0} utworzony (menu + pulpit)",

View file

@ -335,11 +335,24 @@ class CustomSelect {
if (this.disabled) return
const wasOpen = this.host.classList.contains('open')
CustomSelect.closeAll()
if (!wasOpen) this.host.classList.add('open')
if (!wasOpen) {
this.host.classList.add('open')
this.lift()
}
}
// the transform on every element (see style.css) makes each one its own
// stacking context, so an ancestor wrapper would still paint the open list
// under the rows that follow it; positioned ancestors already stack right
lift() {
for (let el = this.host.parentElement; el && el !== document.body; el = el.parentElement) {
if (getComputedStyle(el).position === 'static') el.classList.add('select-lift')
}
}
close() {
this.host.classList.remove('open')
CustomSelect.unlift()
}
setValue(v) {
@ -358,6 +371,11 @@ class CustomSelect {
static closeAll() {
qsa('.custom-select.open').forEach(el => el.classList.remove('open'))
CustomSelect.unlift()
}
static unlift() {
qsa('.select-lift').forEach(el => el.classList.remove('select-lift'))
}
}
@ -434,12 +452,21 @@ function isolationStatusText(status) {
return t('config.isolation.env', t('sandbox.reason.' + (status.reason || 'failed')))
}
function isolationBadge(game) {
// The badge names the level the app would really run at, not the policy:
// automatic is a choice, and what it resolves to depends on this machine.
function isolationLevel(game) {
const policy = (game && game.isolation) || 'auto'
if (policy === 'off') return `<span class="badge">${t('sandbox.badgeOff')}</span>`
if (sandboxStrong()) return `<span class="badge primary">${t('sandbox.badgeStrong')}</span>`
if (policy === 'require') return `<span class="badge">${t('sandbox.badgeRequired')}</span>`
return `<span class="badge">${t('sandbox.badgeWeak')}</span>`
if (policy === 'none') return 'none'
if (policy === 'auto') return sandboxStrong() ? 'relaxed' : 'none'
return sandboxStrong() ? policy : 'unavailable'
}
function isolationBadge(game) {
const level = isolationLevel(game)
if (level === 'none') return `<span class="badge">${t('sandbox.badgeNone')}</span>`
if (level === 'unavailable') return `<span class="badge">${t('sandbox.badgeRequired')}</span>`
if (level === 'isolated') return `<span class="badge primary">${t('sandbox.badgeIsolated')}</span>`
return `<span class="badge primary">${t('sandbox.badgeRelaxed')}</span>`
}
// Portable apps show files/-relative paths; external apps keep absolute ones.
@ -936,6 +963,36 @@ function wireOpenWith() {
window.plwl.openwithTake().then(handleOpenWith)
}
// --- isolated hand-off -------------------------------------------------------
// A document handed to an isolated app is copied in first. Only copies big
// enough to be noticed arrive here (main decides, and puts a window up so this
// popup can be seen at all).
function wireHandoffProgress() {
window.plwl.onHandoffProgress(data => {
if (!data || !data.notice) return
if (data.phase === 'copy') return showHandoffCopy(data)
if (data.phase !== 'done' && data.phase !== 'error') return
// only ever close our own modal: another one may have opened over it
if (qs('#modal').querySelector('#ho-progress')) closeModal()
if (data.phase === 'error') toast(t('handoff.failed', data.name, data.error || ''), 'error')
})
}
function showHandoffCopy(data) {
const modal = qs('#modal').querySelector('#ho-progress')
? qs('#modal')
: showModal(`
<h2>${t('handoff.copying', data.name)}</h2>
<div class="progress-row"><progress id="ho-progress"></progress><span class="progress-pct"></span></div>
<p class="subtitle" id="ho-status"></p>
`)
renderProgressBar(modal.querySelector('#ho-progress'), data.bytes, data.totalBytes)
const pct = modal.querySelector('.progress-pct')
pct.textContent = data.totalBytes ? Math.round(((data.bytes || 0) / data.totalBytes) * 100) + '%' : ''
modal.querySelector('#ho-status').textContent = t('handoff.copyNote', formatBytes(data.bytes), formatBytes(data.totalBytes))
}
// Import step 2: register the app in place, nothing is copied.
async function doPrefixImport(prefix, srcDir) {
const name = srcDir.split('/').filter(Boolean).pop()
@ -1314,14 +1371,24 @@ async function openGameConfig(id) {
const isolationOptions = () => [
{ value: 'auto', label: t('config.isolation.auto') },
{ value: 'off', label: t('config.isolation.off') },
{ value: 'require', label: t('config.isolation.require') }
{ value: 'none', label: t('config.isolation.none') },
{ value: 'relaxed', label: t('config.isolation.relaxed') },
{ value: 'isolated', label: t('config.isolation.isolated') }
]
// The level names are one word each - what they mean has to be spelled out
// right under the select, because the price of relaxed is not visible anywhere
// else in the UI.
function renderIsolationHint(value) {
qs('#gc-isolation-hint').textContent = t(`config.isolationHint.${value || 'auto'}`)
}
function renderIsolationOptions(selected) {
const opts = isolationOptions()
if (!selects['gc-isolation']) selects['gc-isolation'] = new CustomSelect(qs('#gc-isolation'), opts, selected)
else selects['gc-isolation'].update(opts, selected)
if (!selects['gc-isolation']) {
selects['gc-isolation'] = new CustomSelect(qs('#gc-isolation'), opts, selected, renderIsolationHint)
} else selects['gc-isolation'].update(opts, selected)
renderIsolationHint(selected)
}
async function createAppShortcut() {
@ -2096,6 +2163,7 @@ async function boot() {
wireEvents()
wireRealtime()
wireOpenWith()
wireHandoffProgress()
wireLanSync()
qs('#loading').remove()
qs('#main').style.display = 'flex'

View file

@ -206,6 +206,7 @@
<div class="linux-only" style="display:none">
<label class="field-label" data-t="config.isolation"></label>
<span id="gc-isolation"></span>
<p class="subtitle" id="gc-isolation-hint"></p>
<p class="subtitle" id="gc-isolation-status"></p>
<label class="check-label">

View file

@ -358,6 +358,15 @@ select {
z-index: 50;
transform: none;
}
/* the blanket "transform: translateZ(0)" above makes every element its own
stacking context, which traps the open list inside its wrapper (a select in
a .linux-only or .form-card row painted under the next label); app.js adds
this class to the ancestors while a select is open so the whole chain lifts
over the rows below, then removes it again */
.select-lift {
position: relative;
z-index: 50;
}
.custom-select-options {
position: absolute;
top: calc(100% + 0.4rem);

View file

@ -199,6 +199,49 @@ async function chmodTree(root) {
return { files, dirs }
}
// One file, streamed, with byte progress: copyFile says nothing until a
// multi-gigabyte copy is over. The bytes land on a .part name and are renamed
// at the end, so nothing can ever read a half-written document.
async function copyFileProgress(src, dst, onProgress) {
const stat = await fsp.stat(src)
if (!stat.isFile()) throw new Error('not a file: ' + src)
const gate = onProgress ? progressGate(onProgress) : null
await fsp.mkdir(path.dirname(dst), { recursive: true })
const part = dst + '.part'
let bytes = 0
try {
await new Promise((resolve, reject) => {
const input = fs.createReadStream(src)
const output = fs.createWriteStream(part)
const fail = err => {
input.destroy()
output.destroy()
reject(err)
}
input.on('error', fail)
output.on('error', fail)
input.on('data', chunk => {
bytes += chunk.length
if (gate) gate({ done: 0, bytes }, false)
})
output.on('finish', resolve)
input.pipe(output)
})
await fsp.chmod(part, stat.mode & 0o777)
await fsp.rename(part, dst)
} catch (err) {
try {
await fsp.rm(part, { force: true })
} catch {}
throw err
}
if (gate) {
gate({ done: 1, bytes: stat.size }, true)
gate.done()
}
return { files: 1, bytes: stat.size }
}
// Delete deepest-first with progress through onProgress; symlinks are
// unlinked, never followed.
async function removeTree(root, onProgress) {
@ -266,5 +309,5 @@ async function removeTree(root, onProgress) {
return { files, dirs, bytes }
}
module.exports = { pathExists, dedupeDir, copyTree, treeSize, removeTree, chmodTree, progressGate, mapLimit }
module.exports = { pathExists, dedupeDir, copyTree, copyFileProgress, treeSize, removeTree, chmodTree, progressGate, mapLimit }

View file

@ -3,6 +3,7 @@
const { runGame, runTool } = require('../runner')
const { runLinuxGame } = require('../linuxrunner')
const { importGame, importGameFromPrefix } = require('../importer')
const { prefixLinuxRoot } = require('../prefixes')
const library = require('../library')
function killProcessGroup(pid) {
@ -23,7 +24,7 @@ function sendToWindow(win, channel, data) {
// onGamesChanged fires in the main process when the running set changes.
const STARTUP_GRACE_MS = 60000
function createGamesService({ getWindow, onGamesChanged, sandbox }) {
function createGamesService({ getWindow, onGamesChanged, sandbox, onHandoffProgress, openRelay }) {
const running = new Map()
const startedAt = new Map()
const killRequests = new Set()
@ -50,6 +51,7 @@ function createGamesService({ getWindow, onGamesChanged, sandbox }) {
if (!entry.pid && age < STARTUP_GRACE_MS) continue // may still be starting
running.delete(id)
startedAt.delete(id)
if (entry.openRoot && openRelay) openRelay.unwatch(entry.openRoot)
}
}
@ -85,8 +87,10 @@ function createGamesService({ getWindow, onGamesChanged, sandbox }) {
}
function handleExit(id) {
const entry = running.get(id)
running.delete(id)
startedAt.delete(id)
if (entry && entry.openRoot && openRelay) openRelay.unwatch(entry.openRoot)
sendToWindow(getWindow(), 'games:changed')
notifyChanged()
if (onGameExit) onGameExit(id)
@ -98,6 +102,20 @@ function createGamesService({ getWindow, onGamesChanged, sandbox }) {
return payload => runLinuxGame({ game, sandbox, ...payload })
}
// where a native app leaves what it wants opened on the host (openrelay.js)
function openRootFor(game) {
return library.isLinuxGame(game) ? prefixLinuxRoot(library.prefixNameForGame(game)) : ''
}
// An isolated hand-off copies the document before the app starts, which is
// long enough to be worth reporting. The window may not exist yet (a
// shortcut boots headless), so the main process gets the event first and
// decides whether to put a window in front of the user.
function handoffProgress(data) {
if (typeof onHandoffProgress === 'function') onHandoffProgress(data)
sendToWindow(getWindow(), 'handoff:progress', data)
}
async function start(win, id, openFile) {
const game = library.getGame(id)
if (!game) return { ok: false, error: 'game-not-found' }
@ -116,16 +134,20 @@ function createGamesService({ getWindow, onGamesChanged, sandbox }) {
// a document into a running app is a handoff, not a tracked run
if (!openFile) return { ok: false, error: 'already-running' }
try {
await run({ openFile, handoff: true })
await run({ openFile, handoff: true, onProgress: handoffProgress })
return { ok: true, pid: null, logFile: null }
} catch (err) {
return { ok: false, error: String((err && err.message) || err) }
}
}
running.set(id, { pid: null, prefix: library.prefixNameForGame(game), wine: wine ? wine.id : '' })
const openRoot = openRootFor(game)
running.set(id, { pid: null, prefix: library.prefixNameForGame(game), wine: wine ? wine.id : '', openRoot })
// the app may ask for a folder the moment it paints its window, so the
// request file is watched before the run starts
if (openRoot && openRelay) openRelay.watch(openRoot)
startedAt.set(id, Date.now())
try {
const res = await run({ onExit: () => handleExit(id), openFile })
const res = await run({ onExit: () => handleExit(id), openFile, onProgress: handoffProgress })
// stop requested while still starting: kill and clear
if (killRequests.has(id)) {
killRequests.delete(id)

View file

@ -51,10 +51,10 @@ function extractGameIcon(id, exePath, kind) {
// --- registration -----------------------------------------------------------
function registerIpc({ getWindow, onGamesChanged }) {
function registerIpc({ getWindow, onGamesChanged, onHandoffProgress, openRelay }) {
// one probe per launcher, cached: it decides bwrap vs plain run
const sandbox = createSandboxProbe()
const games = createGamesService({ getWindow, onGamesChanged, sandbox })
const games = createGamesService({ getWindow, onGamesChanged, sandbox, onHandoffProgress, openRelay })
// sandbox --------------------------------------------------------------------
ipcMain.handle('sandbox:status', async () => sandbox.status() || (await sandbox.probe()))

View file

@ -38,6 +38,8 @@ function loadGames() {
g[k] = usePrefix && isPrefixRel(g[k]) ? path.join(usePrefix, g[k]) : resolvePortablePath(g[k])
}
if (g.iconSource) g.iconSource = resolvePortablePath(g.iconSource)
// an entry written by an older launcher keeps its meaning
if (g.isolation !== undefined) g.isolation = isolationMode(g.isolation)
const ps = readPlaySeconds(g.id)
if (ps != null) g.playSeconds = ps
}
@ -114,17 +116,26 @@ function isLinuxGame(game) {
return !!game && game.kind === 'linux'
}
const ISOLATION_MODES = ['auto', 'off', 'require']
// none = plain run, relaxed = bwrap + the whole host filesystem mounted rw,
// isolated = bwrap + handed documents copied in, auto = relaxed where bwrap
// works and none where it does not.
const ISOLATION_MODES = ['auto', 'none', 'relaxed', 'isolated']
// Modes written by launcher 0.5.x: `off` was the plain run, `require` was
// "bwrap or refuse" - which is exactly what the strictest level means now.
const LEGACY_ISOLATION_MODES = { off: 'none', require: 'isolated' }
// One known mode, always: an unknown value would otherwise reach the runner
// and pick something nobody asked for.
function isolationMode(value) {
const mode = LEGACY_ISOLATION_MODES[value] || value
return ISOLATION_MODES.includes(mode) ? mode : 'auto'
}
// Only known values reach the registry; anything else falls back to the safe
// default (a typo must not silently disable the sandbox).
function sanitizeLinuxPatch(patch) {
if (!patch) return patch
const out = { ...patch }
if (out.kind !== undefined) out.kind = out.kind === 'linux' ? 'linux' : 'windows'
if (out.isolation !== undefined) {
out.isolation = ISOLATION_MODES.includes(out.isolation) ? out.isolation : 'auto'
}
if (out.isolation !== undefined) out.isolation = isolationMode(out.isolation)
if (out.forceX11 !== undefined) out.forceX11 = !!out.forceX11
return out
}
@ -211,7 +222,7 @@ function newGame({ id, name, exePath, workdir, mode, kind }) {
dllOverrides: [],
// Proton-only: UMU by default, or the build's steam.exe launcher
disableUmu: false,
// Linux-only: how hard to try the bwrap sandbox before falling back
// Linux-only: none, relaxed, isolated, or auto (relaxed where bwrap works)
isolation: 'auto',
// Linux-only: force XWayland, where a window can still carry its icon
forceX11: false,

View file

@ -252,13 +252,8 @@ function findIconFile(spec, baseDir) {
}
module.exports = {
ELF_MAGIC,
isElfBuffer,
isElfSync,
findLinuxExe,
parseDesktopEntry,
readDesktopEntry,
desktopExecPath,
desktopFileFor,
findIconFile,
pngOrSvg

View file

@ -10,9 +10,12 @@ const os = require('os')
const path = require('path')
const { DIRS, readJson, writeJson } = require('./paths')
const { prefixPath, prefixLinuxRoot } = require('./prefixes')
const { prefixNameForGame } = require('./library')
const { prefixNameForGame, loadSettings } = require('./library')
const i18n = require('./i18n')
const { spawnTracked, parseArgs } = require('./runner')
const { buildBwrapArgs, SHARE_ROOT } = require('./sandbox')
const { buildBwrapArgs, SHARE_ROOT, FS_ROOT } = require('./sandbox')
const { PLWL_DIR, requestPath } = require('./openrelay')
const { copyFileProgress } = require('./fsutil')
const DATA_DIRS = ['home', 'config', 'data', 'cache', 'state']
@ -104,6 +107,27 @@ const DESKTOP_SEEDS = [
// host without an import.
const DATA_SHARES = ['fonts', 'icons', 'themes', 'color-schemes']
// A clicked link is the host desktop's business: in here there is no default
// browser at all, because the association (mimeapps.list) and the .desktop
// file it names live in the host's own home. Handing the URI to the host's
// portal is the only route that ends in the browser the user actually chose.
const PORTAL_BUS = 'org.freedesktop.portal.Desktop'
const PORTAL_METHOD = 'org.freedesktop.portal.OpenURI.OpenURI'
const OPEN_BINS = ['/usr/bin/xdg-open', '/bin/xdg-open', '/usr/local/bin/xdg-open']
// Launcher-owned files, kept in the redirected home: the app never edits them,
// and each is rewritten whenever it differs. The prefix root is not mounted in
// the sandbox, so nothing a link needs may live there.
const SHIM_NAME = 'xdg-open'
const OPENER_NAME = 'open-url'
const OPENER_DESKTOP = 'plwl-url-opener.desktop'
const SHIM_REL = path.join('home', PLWL_DIR, SHIM_NAME)
const OPENER_REL = path.join('home', PLWL_DIR, OPENER_NAME)
const MIME_REL = path.join('config', 'mimeapps.list')
const DESKTOP_REL = path.join('data', 'applications', OPENER_DESKTOP)
// What an opened thing in an app resolves to. Nothing else about the app's
// file types is touched: the rest of the MIME lists stay the system's.
const OPEN_TYPES = ['x-scheme-handler/http', 'x-scheme-handler/https', 'text/html', 'x-scheme-handler/file', 'inode/directory']
function hostShareDir() {
return path.join(os.homedir(), '.local', 'share')
}
@ -112,6 +136,135 @@ function dataShares() {
return DATA_SHARES.map(name => path.join(hostShareDir(), name)).filter(dir => fs.existsSync(dir))
}
function readText(file) {
try {
return fs.readFileSync(file, 'utf8')
} catch {
return ''
}
}
function writeIfChanged(file, text, mode) {
try {
fs.mkdirSync(path.dirname(file), { recursive: true })
if (readText(file) !== text) fs.writeFileSync(file, text)
fs.chmodSync(file, mode)
return file
} catch {
return ''
}
}
// The one script both hand-offs run: it takes xdg-open's place inside a
// sandbox and it is what the seeded desktop entry starts. A URI with a scheme
// belongs to the host's portal, which also puts up its own "choose an
// application" when nothing claims that scheme; everything else - every local
// document, folder or plain path - is left in the request file for the
// launcher (see openrelay.js), with the path rewritten first, because the
// private home and the whole-disk mount are the two names the host answers to
// differently. Nothing in here starts a host program itself: measured, the
// host's own xdg-open *inside* the sandbox ends in kde-open, which exits 0 and
// opens no window at all. The portal's answer is not read back either: it is a
// signal addressed to the caller's own bus connection, so a separate monitor
// never receives it (measured: 15 calls, 15 timeouts, while the portal did
// open what it was given) - only whether the call itself went out can be used.
function handOffScript({ hostHome, fsRoot, request }) {
const cases = [
`file://"$HOME") arg="file://${hostHome}" ;;`,
`file://"$HOME"/*) arg="file://${hostHome}\${arg#file://"$HOME"}" ;;`,
...(fsRoot
? [
`file://${FS_ROOT}) arg="file:///" ;;`,
`file://${FS_ROOT}/*) arg="file:///\${arg#file://${FS_ROOT}/}" ;;`
]
: []),
`"$HOME") arg="${hostHome}" ;;`,
`"$HOME"/*) arg="${hostHome}\${arg#$HOME}" ;;`,
...(fsRoot ? [`${FS_ROOT}) arg="/" ;;`, `${FS_ROOT}/*) arg="/\${arg#${FS_ROOT}/}" ;;`] : [])
].join('\n ')
return `#!/bin/sh
# Written by PLWL: what an app opens belongs to the host desktop.
arg="$1"
[ -n "$arg" ] || exit 1
# the portal owns a link from here on, chooser and all; only a call that does
# not go out at all (no portal on this machine) falls back to the launcher
open_portal() {
command -v gdbus >/dev/null 2>&1 || return 1
gdbus call --session --dest ${PORTAL_BUS} \\
--object-path /org/freedesktop/portal/desktop \\
--method ${PORTAL_METHOD} "" "$1" "{}" >/dev/null 2>&1
}
# a local document is not the portal's business - it cannot see this path
case "$arg" in
file:*) ;;
*://*|mailto:*) open_portal "$arg" && exit 0 ;;
esac
case "$arg" in
${cases}
esac
printf '%s\\n' "$arg" > "${request}.part" && mv "${request}.part" "${request}" || exit 1
`
}
// The stand-in for xdg-open inside the sandbox.
function writeUrlShim(root, opts) {
return writeIfChanged(path.join(root, SHIM_REL), handOffScript(opts), 0o755)
}
// What the seeded desktop entry runs. Same hand-off: a browser started in here
// would be the wrong one, with an empty profile growing inside the prefix.
function writeUrlOpener(root, opts) {
return writeIfChanged(path.join(root, OPENER_REL), handOffScript(opts), 0o755)
}
// The entry's display name. A translation that is not loaded answers with its
// own key, and that would end up in a chooser dialog, so it is never used.
function linkOpenerName() {
const text = i18n.translate(loadSettings().language, 'sandbox.linkOpener')
return text === 'sandbox.linkOpener' ? 'PLWL' : text
}
// GIO resolves an URI in-process (gtk_show_uri, `gio open`) against the MIME
// lists, and the host's default browser is not among the choices in here: it
// would open a system browser, or none at all. Naming our opener as the
// default for exactly these types leaves that resolution alone and changes
// only its answer - including the two a local document comes in as (measured:
// Qt hands xdg-open a file:// URI, GIO resolves a folder by inode/directory).
// The portal itself is not reachable this way - GIO looks for a sandbox marker
// this launcher does not fake (measured: GIO_USE_PORTALS opens nothing).
function writeMimeSeed(root, home) {
const desktop = [
'[Desktop Entry]',
'Type=Application',
`Name=${linkOpenerName()}`,
`Exec="${path.join(home, PLWL_DIR, OPENER_NAME)}" %u`,
`MimeType=${OPEN_TYPES.join(';')};`,
'NoDisplay=true',
'Terminal=false',
''
].join('\n')
writeIfChanged(path.join(root, DESKTOP_REL), desktop, 0o644)
const mime = ['[Default Applications]', ...OPEN_TYPES.map(type => `${type}=${OPENER_DESKTOP};`), ''].join('\n')
return writeIfChanged(path.join(root, MIME_REL), mime, 0o644)
}
// Everything an opened thing needs: the hand-off script, the association
// naming it as the default, and - in a sandbox - xdg-open replaced by the same
// script. A machine without a portal needs no special case: the script's call
// is what fails there, and the request file is what still lands.
function urlRouting({ root, home, sandboxed, fsRoot = false }) {
const target = OPEN_BINS.find(bin => fs.existsSync(bin)) || ''
const opts = { hostHome: path.join(root, 'home'), fsRoot, request: requestPath(home) }
if (!writeUrlOpener(root, opts)) return null
writeMimeSeed(root, home)
const shim = sandboxed && target ? writeUrlShim(root, opts) : ''
return { target, shim }
}
function hashOf(file) {
try {
return crypto.createHash('sha1').update(fs.readFileSync(file)).digest('hex')
@ -240,7 +393,7 @@ function withHostShare(env, entries) {
// HOME and the XDG folders point at the prefix (plain run) or at the bind
// targets inside the sandbox - the two must agree or the app writes outside.
function buildLinuxEnv({ game, backend = 'env' }) {
function buildLinuxEnv({ game, backend = 'env', fsRoot = false }) {
const prefixName = prefixNameForGame(game)
const root = ensureLinuxPfx(prefixName)
syncDesktopConfig(root)
@ -262,24 +415,63 @@ function buildLinuxEnv({ game, backend = 'env' }) {
if (game.forceX11) Object.assign(env, X11_ENV)
// the app's own environment comes last: it overrides everything above
for (const [k, v] of Object.entries(game.env || {})) env[k] = v
return { env, root, home, prefixName, prefixDir: prefixPath(prefixName) }
// an opened link or document must leave for the host desktop; `home` is where
// the seeded desktop entry has to find the opener, so it is resolved first
return { env, root, home, prefixName, prefixDir: prefixPath(prefixName), routing: urlRouting({ root, home, sandboxed, fsRoot }) }
}
// Where a handed document goes inside the app's own home. The prefix home is
// a live bind of a host folder, so a copy written here is visible to an app
// that is already running - the one route a running namespace can be reached
// through at all.
const HANDOFF_DIR = 'plwl-handoff'
// Copies above this are worth a popup: below it the copy is over before a
// window could paint, and the launcher would flash for nothing.
const STAGE_NOTICE_BYTES = 50 * 1000 * 1000
// The probe measures once and answers from its cache afterwards. It is awaited
// here because a shortcut boots HEADLESS: no renderer ran, so nothing else
// would have measured, and "auto" would quietly pick the weaker level.
async function levelStatus(sandbox) {
if (!sandbox || typeof sandbox.status !== 'function') return null
const cached = sandbox.status()
if (cached) return cached
if (typeof sandbox.probe !== 'function') return null
try {
return await sandbox.probe()
} catch {
return null
}
}
// bwrap when it works, plain run otherwise - unless the app demands a sandbox.
function pickBackend({ game, sandbox }) {
// `auto` is the only level allowed to take a weaker one: a machine that cannot
// sandbox runs the app plain, while an explicit relaxed/isolated refuses
// instead of quietly dropping the promise. (Values written by launcher 0.5.x
// are mapped to these four by library.js.)
async function resolveLevel({ game, sandbox }) {
const policy = (game && game.isolation) || 'auto'
const status = sandbox && typeof sandbox.status === 'function' ? sandbox.status() : null
const status = await levelStatus(sandbox)
const available = !!status && status.backend === 'bwrap'
if (policy === 'off') return { ok: true, backend: 'env', policy, status }
if (available) return { ok: true, backend: 'bwrap', policy, status }
if (policy === 'require') return { ok: false, error: 'sandbox-required', policy, status }
return { ok: true, backend: 'env', policy, status }
if (policy === 'none') return { ok: true, level: 'none', backend: 'env', status }
if (policy === 'auto') {
return { ok: true, level: available ? 'relaxed' : 'none', backend: available ? 'bwrap' : 'env', status }
}
if (!available) return { ok: false, error: 'sandbox-required', level: policy, backend: 'env', status }
return { ok: true, level: policy, backend: 'bwrap', status }
}
function isolationLine({ backend, status }) {
if (backend === 'bwrap') return `[plwl] isolation: bwrap ${(status && status.version) || ''}`.trim()
const reason = (status && status.reason) || 'missing'
return `[plwl] isolation: env-only (reason: ${reason})`
// The line the app log and the UI both quote: a silent downgrade is the one
// failure this whole setting exists to avoid.
function isolationLine({ level, status }) {
const version = (status && status.version) || ''
if (level === 'none') {
const reason = (status && status.reason) || ''
return reason ? `[plwl] isolation: none (no sandbox: ${reason})` : '[plwl] isolation: none (plain run)'
}
const bwrap = version ? `bwrap ${version}` : 'bwrap'
if (level === 'relaxed') return `[plwl] isolation: relaxed (${bwrap}, host filesystem at ${FS_ROOT})`
return `[plwl] isolation: isolated (${bwrap})`
}
// "Open with": a native app takes the file where it is (the sandbox sees /).
@ -291,6 +483,41 @@ function linuxPathFor(file) {
return abs
}
// The document a shortcut was handed, in the one shape the chosen level can
// reach - and the app is told nothing else, so a second hand-off names the
// same file the same way.
// none the host path, exactly what a shell would name
// relaxed the same file through the whole-host mount every run carries
// isolated a copy in the app's own home, refreshed on every open (one copy
// per name, so the folder cannot grow; the app's edits stay in the
// copy - that is this level's promise)
// Returns '' when there is nothing to hand over, so argv stays honest.
async function handedPathFor({ openFile, level, root, home, onProgress }) {
if (!openFile) return ''
const abs = linuxPathFor(openFile)
if (level === 'relaxed') return path.join(FS_ROOT, abs)
if (level !== 'isolated') return abs
const name = path.basename(abs)
if (!name || name === '.' || name === '..') return ''
const stat = fs.statSync(abs)
if (!stat.isFile()) return ''
const total = stat.size
const notice = total >= STAGE_NOTICE_BYTES
const emit = (phase, bytes, error) => {
if (onProgress) onProgress({ phase, name, bytes: bytes || 0, totalBytes: total, notice, error: error || '' })
}
emit('copy', 0)
try {
await copyFileProgress(abs, path.join(root, 'home', HANDOFF_DIR, name), p => emit('copy', p.bytes))
} catch (err) {
emit('error', 0, String((err && err.message) || err))
throw err
}
emit('done', total)
return path.join(home, HANDOFF_DIR, name)
}
function bwrapSource() {
const devices = ['/dev/shm', '/dev/dri', '/dev/snd', '/dev/input'].filter(d => fs.existsSync(d))
const x11 = fs.existsSync('/tmp/.X11-unix') ? '/tmp/.X11-unix' : ''
@ -304,16 +531,17 @@ function bwrapSource() {
}
}
async function runLinuxGame({ game, sandbox, onExit, openFile, handoff = false }) {
const chosen = pickBackend({ game, sandbox })
async function runLinuxGame({ game, sandbox, onExit, openFile, handoff = false, onProgress }) {
const chosen = await resolveLevel({ game, sandbox })
if (!chosen.ok) throw new Error(chosen.error)
const exe = game.exePath
const workdir = game.workdir || path.dirname(exe)
const { env, root, home, routing } = buildLinuxEnv({ game, backend: chosen.backend, fsRoot: chosen.level === 'relaxed' })
const argList = parseArgs(game.args)
if (openFile) argList.push(linuxPathFor(openFile))
const handed = await handedPathFor({ openFile, level: chosen.level, root, home, onProgress })
if (handed) argList.push(handed)
const { env, root } = buildLinuxEnv({ game, backend: chosen.backend })
const header = [isolationLine(chosen)]
if (game.forceX11) header.push('[plwl] x11 forced (XWayland)')
@ -322,7 +550,7 @@ async function runLinuxGame({ game, sandbox, onExit, openFile, handoff = false }
if (chosen.backend === 'bwrap') {
const source = bwrapSource()
cmd = 'bwrap'
args = [...buildBwrapArgs({ dataRoot: root, user: source.user, devices: source.devices, x11: source.x11, runUser: source.runUser, shares: source.shares, appDir: writableAppDir(game) }), '--', exe, ...argList]
args = [...buildBwrapArgs({ dataRoot: root, user: source.user, devices: source.devices, x11: source.x11, runUser: source.runUser, shares: source.shares, appDir: writableAppDir(game), fsRoot: chosen.level === 'relaxed', urlOpener: routing }), '--', exe, ...argList]
header.push(`[plwl] HOME=${env.HOME}`)
}
@ -330,20 +558,25 @@ async function runLinuxGame({ game, sandbox, onExit, openFile, handoff = false }
}
module.exports = {
DATA_DIRS,
XDG_LAYOUT,
SYSTEM_DIRS,
DESKTOP_SEEDS,
DATA_SHARES,
X11_ENV,
HANDOFF_DIR,
STAGE_NOTICE_BYTES,
SHIM_REL,
OPENER_REL,
MIME_REL,
DESKTOP_REL,
writeUrlShim,
writeUrlOpener,
writeMimeSeed,
urlRouting,
writableAppDir,
ensureLinuxPfx,
resetSeededConfig,
dataShares,
syncDesktopConfig,
buildLinuxEnv,
pickBackend,
resolveLevel,
isolationLine,
linuxPathFor,
handedPathFor,
runLinuxGame
}

View file

@ -9,6 +9,7 @@ const { createMainWindow } = require('./window')
const { setAppIcon } = require('./appicon')
const { parseArgv } = require('./argv')
const openwith = require('./openwith')
const { createOpenRelay } = require('./openrelay')
const lansync = require('./lansync')
const path = require('path')
const fs = require('fs')
@ -154,6 +155,15 @@ function loadLangSafe() {
}
}
// An isolated hand-off copies the document before the app starts. When that
// copy is big enough to be noticed (linuxrunner decides) the user gets a
// window with the progress in it - a shortcut boots headless, so without this
// a 2 GB document would look like nothing happening at all.
function onHandoffProgress(data) {
if (!data || !data.notice || data.phase !== 'copy') return
if (!win || win.isDestroyed() || !win.isVisible()) showMainWindow()
}
function showMainWindow() {
// the user is present now - headless auto-quit must never fire again
headless = false
@ -226,7 +236,10 @@ app.whenReady().then(async () => {
ensureDirs()
await i18n.init()
await wines.refreshWines()
const { games } = registerIpc({ getWindow, onGamesChanged: rebuildTrayMenu })
// a folder or document a sandboxed app asked for is opened here, by the
// host's own xdg-open (see openrelay.js)
const openRelay = createOpenRelay({ onError: (err, target) => trace(`openRelay ${target}: ${err.message}`) })
const { games } = registerIpc({ getWindow, onGamesChanged: rebuildTrayMenu, onHandoffProgress, openRelay })
gamesService = games
// the tray comes first: nothing may keep it from coming up

85
src/openrelay.js Normal file
View file

@ -0,0 +1,85 @@
'use strict'
// What a sandboxed app asks to open: a folder, a document, a link the portal
// refused. The app cannot start anything out here itself - measured, the host's
// own xdg-open *inside* the sandbox ends in kde-open, which exits 0 and opens no
// window - so the hand-off script leaves the (already host-rewritten) path in a
// request file inside the app's home, which is a live bind, and the launcher
// opens it here with the host's own xdg-open.
const fs = require('fs')
const path = require('path')
const { spawn } = require('child_process')
// The hand-off protocol, in one place: the folder PLWL owns inside an app's
// home (the shim and the opener are written there too) and the file the
// launcher watches.
const PLWL_DIR = '.plwl'
const REQUEST_NAME = 'open-request'
const DRAIN_DEBOUNCE_MS = 60
// `homeDir` is that home as the host sees it - the prefix's own `home`, which
// is a live bind, so what the app writes is here the moment it lands.
const requestPath = homeDir => path.join(homeDir, PLWL_DIR, REQUEST_NAME)
const requestFile = root => requestPath(path.join(root, 'home'))
// detached: the opened program is the desktop's child, not the launcher's - it
// must outlive a launcher that quits to the tray and never hold it open
function openOnHost(target) {
spawn('xdg-open', [target], { detached: true, stdio: 'ignore' }).unref()
}
function createOpenRelay({ open = openOnHost, onError = null } = {}) {
const watchers = new Map()
// the request is consumed before it is opened: a second click while the first
// program starts writes a fresh file, so nothing is opened twice and nothing
// is dropped
function drain(root) {
let target = ''
try {
target = fs.readFileSync(requestFile(root), 'utf8').trim().split('\n')[0]
fs.rmSync(requestFile(root), { force: true })
} catch {
return
}
if (!target) return
try {
open(target)
} catch (err) {
if (onError) onError(err, target)
}
}
// watch from the moment the app starts: a request written before the watcher
// was up (the app's first window) must not be missed, so drain once first
function watch(root) {
if (!root || watchers.has(root)) return
const file = requestFile(root)
// best effort: a missing folder only means nothing was ever asked for
try {
fs.mkdirSync(path.dirname(file), { recursive: true })
} catch {}
let timer = null
const watcher = fs.watch(path.dirname(file), () => {
clearTimeout(timer)
timer = setTimeout(() => drain(root), DRAIN_DEBOUNCE_MS)
})
watcher.on('error', () => unwatch(root))
watchers.set(root, watcher)
drain(root)
}
function unwatch(root) {
const watcher = watchers.get(root)
if (!watcher) return
try {
watcher.close()
} catch {}
watchers.delete(root)
}
return { watch, unwatch, drain, requestFile }
}
module.exports = { createOpenRelay, PLWL_DIR, REQUEST_NAME, requestFile, requestPath }

View file

@ -15,6 +15,8 @@ function singleSlot(channel) {
}
const onImportProgress = singleSlot('import:progress')
// "Open with" into an isolated app: the document is copied first
const onHandoffProgress = singleSlot('handoff:progress')
// "Open with PLWL": files handed over via --open (Exec %f).
const onOpenWith = singleSlot('openwith:file')
// one sync (and one pending consent request, enforced server-side) at a time
@ -84,6 +86,7 @@ contextBridge.exposeInMainWorld('plwl', {
shellOpenPath: p => ipcRenderer.invoke('shell:openPath', p),
shellShowItemInFolder: p => ipcRenderer.invoke('shell:showItemInFolder', p),
onImportProgress: onImportProgress,
onHandoffProgress: onHandoffProgress,
onGamesChanged: cb => ipcRenderer.on('games:changed', () => cb()),
lansyncGet: () => ipcRenderer.invoke('lansync:get'),
lansyncDevices: () => ipcRenderer.invoke('lansync:devices'),

View file

@ -99,6 +99,14 @@ function createSandboxProbe({ timeoutMs = PROBE_TIMEOUT_MS } = {}) {
// prefix is mounted over ~, so a link back into ~ would point at itself.
const SHARE_ROOT = '/tmp/.plwl-host'
// Relaxed isolation hands the app the whole host filesystem as ONE writable
// mount. It has to sit under the private /tmp: bubblewrap creates a mount
// point itself, and the root of a read-only root cannot be written (measured:
// "Can't create file /PLWL_FS_ROOT: Read-only file system"). Being there, one
// path string works in every run - which is what a hand-off into an app that
// is ALREADY running needs, since a mount cannot be added to a live namespace.
const FS_ROOT = '/tmp/plwl-fs-root'
// The bwrap profile for a native Linux app. Pure: the caller decides which
// devices and host folders exist on this machine, so the argv is testable
// as-is.
@ -114,7 +122,9 @@ function buildBwrapArgs({
hideHome = false,
etcRoot = '',
unshareNet = false,
appDir = ''
appDir = '',
fsRoot = false,
urlOpener = null
}) {
const xdgDir = sub => path.join(home, sub)
return [
@ -132,8 +142,17 @@ function buildBwrapArgs({
'--tmpfs', '/tmp',
// X11 sockets live in /tmp, which the tmpfs above just emptied
...(x11 ? ['--ro-bind', x11, x11] : []),
// relaxed: the host filesystem, whole and writable. Nothing is hidden in
// here, so a document handed over later can be opened - and saved - where
// the app was told it is. The price is the filesystem promise itself: an
// app at this level may write anywhere on the machine.
...(fsRoot ? ['--bind', '/', FS_ROOT] : []),
// the host's theme data, read-only: fonts, icons, colour schemes
...shares.flatMap(dir => ['--ro-bind', dir, path.join(SHARE_ROOT, path.basename(dir))]),
// an opened link or document belongs to the host desktop: xdg-open is
// replaced by the launcher's hand-off script (the portal for a link, the
// launcher itself for a document - see linuxrunner.js)
...(urlOpener && urlOpener.shim ? ['--ro-bind', urlOpener.shim, urlOpener.target] : []),
// hard profile: nothing of anybody's /home is even readable
...(hideHome ? ['--tmpfs', '/home', '--tmpfs', '/root', '--tmpfs', '/mnt', '--tmpfs', '/media'] : []),
// the app's own folder stays writable (portable apps keep logs/data next
@ -157,4 +176,4 @@ function buildBwrapArgs({
]
}
module.exports = { createSandboxProbe, buildBwrapArgs, SHARE_ROOT, PROBE_TIMEOUT_MS }
module.exports = { createSandboxProbe, buildBwrapArgs, SHARE_ROOT, FS_ROOT }