[linux apps] fix drag-n-drop & opening files

This commit is contained in:
olcxja 2026-10-08 13:59:08 +02:00
commit f4e770a7db
3 changed files with 80 additions and 5 deletions

View file

@ -46,6 +46,8 @@ 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).
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).
@ -59,8 +61,8 @@ Some apps draw their window icon themselves, which Wayland does not allow — Mi
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.
- **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. Your home folders are linked back into the app through that mount, so a file you drag onto the app, or pick in its own dialog, opens at its real path too. 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. There is no view of your home here, so a drop from your desktop or a file picked in a dialog cannot reach the app — only what the launcher hands over does, as that copy.
- **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.

View file

@ -16,7 +16,8 @@ src/ main process (Node/Electron backend)
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, the isolation level
(none/relaxed/isolated/auto) and the handed-document path
(none/relaxed/isolated/auto), the handed-document path and
the relaxed home redirects
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
@ -89,6 +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/<app>/`, 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/<user>/…` 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/<entry>`, 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.
- 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 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.

View file

@ -61,6 +61,72 @@ 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])
// 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
// not a path anything can be given, so it is skipped too.
function hostHomeEntries() {
const home = os.homedir()
let names
try {
names = fs.readdirSync(home)
} catch {
return []
}
return names.filter(name => !OWNED_HOME_NAMES.has(name) && fs.existsSync(path.join(home, name)))
}
// The launcher's own links are the ones pointing into the relaxed view.
function isFsRootLink(file) {
let target
try {
target = fs.readlinkSync(file)
} catch {
return false
}
return target.startsWith(FS_ROOT + path.sep)
}
function clearHomeRedirects(root) {
const dir = path.join(root, 'home')
let names
try {
names = fs.readdirSync(dir)
} catch {
return
}
for (const name of names) {
if (!isFsRootLink(path.join(dir, name))) continue
try {
fs.rmSync(path.join(dir, name), { force: true })
} catch {}
}
}
// Relaxed is the one level where the real filesystem is reachable (through
// FS_ROOT) - and there the app's home is mounted over the real one, so a path
// the desktop hands over names a folder the app cannot see. A link into that
// 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 }) {
clearHomeRedirects(root)
if (!relaxed) return []
const made = []
for (const name of hostHomeEntries()) {
const file = path.join(root, 'home', name)
if (fs.existsSync(file)) continue
try {
fs.symlinkSync(path.join(FS_ROOT, os.homedir(), name), file)
made.push(name)
} catch {}
}
return made
}
function copyFromHost(src, dst, { refresh = false } = {}) {
try {
if (!refresh && fs.existsSync(dst)) return false
@ -350,6 +416,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 sandboxed = backend === 'bwrap'
const home = sandboxed ? `/home/${linuxUser()}` : path.join(root, 'home')
const env = { ...process.env }
@ -367,7 +434,7 @@ function buildLinuxEnv({ game, backend = 'env', fsRoot = false }) {
// the app's own environment comes last: it overrides everything above
for (const [k, v] of Object.entries(game.env || {})) env[k] = v
// an opened link or document leaves for the host desktop (see openrelay.js)
return { env, root, home, prefixName, prefixDir: prefixPath(prefixName), routing: urlRouting({ root, home, sandboxed, fsRoot }) }
return { env, root, home, prefixName, prefixDir: prefixPath(prefixName), homeRedirects, routing: urlRouting({ root, home, sandboxed, fsRoot }) }
}
// Where a handed document goes inside the app's own home - a live bind, so a
@ -475,12 +542,13 @@ async function runLinuxGame({ game, sandbox, onExit, openFile, handoff = false,
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 { env, root, home, homeRedirects, routing } = buildLinuxEnv({ game, backend: chosen.backend, fsRoot: chosen.level === 'relaxed' })
const argList = parseArgs(game.args)
const handed = await handedPathFor({ openFile, level: chosen.level, root, home, onProgress })
if (handed) argList.push(handed)
const header = [isolationLine(chosen)]
if (homeRedirects.length) header.push(`[plwl] home redirects: ${homeRedirects.length} entries through ${FS_ROOT}`)
if (game.forceX11) header.push('[plwl] x11 forced (XWayland)')
let cmd = exe
@ -497,6 +565,9 @@ async function runLinuxGame({ game, sandbox, onExit, openFile, handoff = false,
module.exports = {
DESKTOP_SEEDS,
hostHomeEntries,
syncHomeRedirects,
clearHomeRedirects,
HANDOFF_DIR,
STAGE_NOTICE_BYTES,
SHIM_REL,