diff --git a/README.md b/README.md index e6309a2..cb56bc5 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,7 @@ A native app runs with its **own home folder**: `HOME`, `XDG_CONFIG_HOME`, `XDG_ 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. -A file your desktop hands the app — a drag-and-drop from your file manager, a file picked in a file dialog — is named by its **real path** (`/home/you/Downloads/mod.jar`), and inside the sandbox that name is the app's own redirected home. At **Relaxed** your home folders are linked back into the app at those very paths, through the writable mount, so the drop opens where it lies and a save lands on your real file; at **Isolated** there is no view to link into, and what reaches the app there is only what the launcher hands it, as a copy (see the levels below). +A file your desktop hands the app — a drag-and-drop from your file manager, a file picked in a file dialog — is named by its **real path** (`/home/you/Downloads/mod.jar`), and inside the sandbox that name is the app's own redirected home. At **Relaxed** your home folders are linked back into the app at those very paths, through the writable mount, so the drop opens where it lies and a save lands on your real file; the one name left alone is the folder the app itself lives in, which keeps its plain path (a link there turned a launcher app's own instances into a `/tmp` path, which Prism Launcher refuses to see as permanent). At **Isolated** there is no view to link into, and what reaches the app there is only what the launcher hands it, as a copy (see the levels below). 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 `~`. diff --git a/development.md b/development.md index 9dbfcf9..ad45b0e 100644 --- a/development.md +++ b/development.md @@ -90,7 +90,7 @@ 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//`, the rest of `home/`) is never touched, and a running app from that prefix blocks it (the usual `inUse` guard). -- A path the desktop hands the app (a drag-and-drop's `text/uri-list`, a file picked in a dialog - `QT_QPA_PLATFORMTHEME=xdgdesktopportal` on this desktop, so the chooser is the host portal's and answers with a real path) is a real `/home//…` path, and under bwrap that name is the app's own redirected home. A mount cannot fix it (a mount cannot be added to a namespace that is already running) and binding the host home in would hand the app the user's real folders at every level: `syncHomeRedirects` links the host home's own entries (never `.config`/`.local`/`.cache`/`.plwl`, never a name the app already has, never a link resolving nowhere) into the one view the level has - at relaxed to `FS_ROOT/`, which is the real folder, live and writable (measured: the real file reads through the link, and a write through it lands on it), and at the other levels there is no view to point at, so the links are taken off again before the app starts. Only the launcher's own links are removed (matched by their `FS_ROOT` target), and an isolated app gets no home folders at all: what reaches it there is the hand-off copy. The links outlive a run (a hand-off into an app that is already running is still using them); the next start at a level that cannot serve them clears them. +- A path the desktop hands the app (a drag-and-drop's `text/uri-list`, a file picked in a dialog - `QT_QPA_PLATFORMTHEME=xdgdesktopportal` on this desktop, so the chooser is the host portal's and answers with a real path) is a real `/home//…` path, and under bwrap that name is the app's own redirected home. A mount cannot fix it (a mount cannot be added to a namespace that is already running) and binding the host home in would hand the app the user's real folders at every level: `syncHomeRedirects` links the host home's own entries (never a name the profile mounts itself - `.plwl` and the XDG folders, which a link would turn into bwrap's `Can't mount on symlink destination` on the first relaxed run of a fresh prefix, measured; never a name the app already has, never a link resolving nowhere) into the one view the level has - at relaxed to `FS_ROOT/`, which is the real folder, live and writable (measured: the real file reads through the link, and a write through it lands on it), and at the other levels there is no view to point at, so the links are taken off again before the app starts. The name the app's own folder sits under is never linked either: a portable bundle resolves itself with `readlink -f "$0"`, so the link answered for the app's whole tree - instances included - with `FS_ROOT`, and Prism Launcher read that as "Your instance folder is in a temporary folder: '/tmp'!" on every start (measured on 11.0.2 on the device whose launcher lives in the home; the same app on a launcher outside `~` kept its real path and stayed quiet - the folder is bound at its real path anyway). Only the launcher's own links are removed (matched by their `FS_ROOT` target), and an isolated app gets no home folders at all: what reaches it there is the hand-off copy. The links outlive a run (a hand-off into an app that is already running is still using them); the next start at a level that cannot serve them clears them. - 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 over the app bundle's own `bin/xdg-open` too — that one sits first in the app's PATH, and its sharun wrapper dies on a closed stderr (`SIGPIPE`) before it ever delegates, which is exactly what Minecraft does after `Runtime.exec`; the name the bundle links to that same file, `gio-launch-desktop`, is answered by the shim, since bwrap refuses to mount over a symlink; a `-`-prefixed argument exits 2, because Java probes for `xdg-open` by running it with one) 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 games service owns a run through the entry object it created: a start that failed, and the exit of a PREVIOUS run of the same app (stopped, then relaunched — SIGKILL is asynchronous), may not clear the new run's entry or close its request watcher (`forget`/`handleExit` compare that object). A start still in flight is never pruned, however long its isolated hand-off copy takes, because pruning it would drop the watcher mid-start; Stop during that window only records a kill request (`killRequests`), which the start fulfils the moment it has a pid — clearing the state there instead would let a second launch race past the first. Measured in test/games-service.test.js. diff --git a/package.json b/package.json index 77e81e2..89f4169 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "PLWL", "productName": "PLWL", - "version": "0.5.3", + "version": "0.5.4", "description": "Portable Linux Wine Launcher", "main": "src/main.js", "scripts": { diff --git a/src/linuxrunner.js b/src/linuxrunner.js index 3561359..ffd2701 100644 --- a/src/linuxrunner.js +++ b/src/linuxrunner.js @@ -27,9 +27,23 @@ const XDG_LAYOUT = { XDG_STATE_HOME: ['state', path.join('.local', 'state')] } -// Write access goes to the folder the app was imported from and nowhere else -// outside its home: a portable app writes its logs next to itself. -const SYSTEM_DIRS = ['/bin', '/sbin', '/lib', '/lib32', '/lib64', '/libx32', '/usr', '/etc', '/boot', '/proc', '/sys', '/dev', '/run'] + +// Every folder the app is addressed by - the tree it was imported as, the one +// it runs in, and the one its binary sits in (a bundle may keep that in bin/, +// or start an app from a folder of its own) - each as stated and as it really +// is, since an app imported from the home may sit behind a link of its own. +function appShapes(game) { + const dirs = [game && game.path, game && game.workdir, game && game.exePath && path.dirname(game.exePath)] + const shapes = new Set() + for (const dir of dirs) { + if (!dir) continue + shapes.add(path.resolve(dir)) + try { + shapes.add(fs.realpathSync(dir)) + } catch {} + } + return [...shapes] +} function writableAppDir(game) { const dir = (game && (game.path || (game.exePath && path.dirname(game.exePath)))) || '' @@ -63,7 +77,7 @@ function linuxUser() { // The names the app owns in its home: the redirected XDG folders and the // launcher's own folder. The host home never gets to shadow them. -const OWNED_HOME_NAMES = new Set([PLWL_DIR]) +const OWNED_HOME_NAMES = new Set([PLWL_DIR, '.config', '.local', '.cache']) // The host home's own entries - what the desktop can hand an app (a drop from // the file manager, a file picked in a dialog). A link resolving nowhere is @@ -112,15 +126,22 @@ function clearHomeRedirects(root) { // view gives exactly those names back, at their real paths; the app's own // entries are never touched. No other level has a view to point at, so the // links come off again there - they would only dangle. -function syncHomeRedirects(root, { relaxed }) { +// One name is never linked: the one the app's own folder sits under. A portable +// bundle resolves itself with `readlink -f "$0"`, so a link there answers with +// FS_ROOT - a path under /tmp - and Prism Launcher reads that as "Your instance +// folder is in a temporary folder" on every start (measured in a real profile). +function syncHomeRedirects(root, { relaxed, appDirs = [] }) { clearHomeRedirects(root) if (!relaxed) return [] + const home = os.homedir() const made = [] for (const name of hostHomeEntries()) { + const real = path.join(home, name) + if (appDirs.some(dir => dir === real || dir.startsWith(real + path.sep))) continue const file = path.join(root, 'home', name) if (fs.existsSync(file)) continue try { - fs.symlinkSync(path.join(FS_ROOT, os.homedir(), name), file) + fs.symlinkSync(path.join(FS_ROOT, real), file) made.push(name) } catch {} } @@ -446,7 +467,7 @@ function buildLinuxEnv({ game, backend = 'env', fsRoot = false }) { const prefixName = prefixNameForGame(game) const root = ensureLinuxPfx(prefixName) syncDesktopConfig(root) - const homeRedirects = syncHomeRedirects(root, { relaxed: backend === 'bwrap' && fsRoot }) + const homeRedirects = syncHomeRedirects(root, { relaxed: backend === 'bwrap' && fsRoot, appDirs: appShapes(game) }) const sandboxed = backend === 'bwrap' const home = sandboxed ? `/home/${linuxUser()}` : path.join(root, 'home') const env = { ...process.env } @@ -615,5 +636,6 @@ module.exports = { resolveLevel, isolationLine, handedPathFor, - runLinuxGame + runLinuxGame, + appShapes }