code cleanup
This commit is contained in:
parent
1bbc71166e
commit
bf5abeef73
6 changed files with 124 additions and 197 deletions
|
|
@ -72,7 +72,7 @@ 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.
|
- 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.
|
- 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).
|
- 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 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).
|
- 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"). library.js keeps exactly these four values, both when reading the registry and when a patch is saved (anything else falls back to `auto`), and 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.
|
- 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.
|
- 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.
|
- 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.
|
||||||
|
|
@ -83,7 +83,7 @@ Renderer never touches Node/Electron directly — everything goes through the `w
|
||||||
### Native Linux apps
|
### Native Linux apps
|
||||||
|
|
||||||
- Entry point detection (linuxapp.js): ELF, `.AppImage`, or a `.desktop` whose `Exec=` points at one; `lib/`, `share/` … are skipped. The icon comes from the `.desktop` `Icon=`, an image next to the program, or the system theme — `extractIconFromExe` is PE-only.
|
- Entry point detection (linuxapp.js): ELF, `.AppImage`, or a `.desktop` whose `Exec=` points at one; `lib/`, `share/` … are skipped. The icon comes from the `.desktop` `Icon=`, an image next to the program, or the system theme — `extractIconFromExe` is PE-only.
|
||||||
- The redirected home starts as a COPY of the desktop preferences (kdeglobals, kdedefaults/kdeglobals, gtk-3.0|gtk-4.0/settings.ini, qt5ct/qt6ct, Trolltech.conf, user-dirs.dirs). Without the seed Qt falls back to its built-in light palette (measured: `#eff0f1` against the host's `#202326`). The copy keeps following the host while the app has not touched it: the hashes of what PLWL wrote live in `{prefix}/linux_pfx/.seeded.json`, OUTSIDE the app's home, and a prefix from an older launcher is adopted on evidence (byte-identical with the host, or the copy is older than the host's file). The PulseAudio cookie is seeded once, because with a redirected home Pulse looks in `$HOME/.config/pulse/cookie` and the app otherwise comes up silent; `~/.Xauthority` is refreshed instead, since nothing exports XAUTHORITY to a sandboxed app.
|
- The redirected home starts as a COPY of the desktop preferences (kdeglobals, kdedefaults/kdeglobals, gtk-3.0|gtk-4.0/settings.ini, qt5ct/qt6ct, Trolltech.conf, user-dirs.dirs). Without the seed Qt falls back to its built-in light palette (measured: `#eff0f1` against the host's `#202326`). The copy keeps following the host while the app has not touched it: the hashes of what PLWL wrote live in `{prefix}/linux_pfx/.seeded.json`, OUTSIDE the app's home, and a copy with no marker entry (what an older launcher left) counts as ours and is refreshed. The PulseAudio cookie is seeded once, because with a redirected home Pulse looks in `$HOME/.config/pulse/cookie` and the app otherwise comes up silent; `~/.Xauthority` is refreshed instead, since nothing exports XAUTHORITY to a sandboxed app.
|
||||||
- Host fonts/icons/themes/colour schemes are not copied but `--ro-bind` under `/tmp/.plwl-host/…` and prepended to XDG_DATA_DIRS: live, and unable to write back into the user's `~`. Symlinks were measured and rejected — the prefix is mounted ON `~`, so a link to `~/.config/kdeglobals` points at itself (`Too many levels of symbolic links`); without bwrap the same link writes through into the real home.
|
- Host fonts/icons/themes/colour schemes are not copied but `--ro-bind` under `/tmp/.plwl-host/…` and prepended to XDG_DATA_DIRS: live, and unable to write back into the user's `~`. Symlinks were measured and rejected — the prefix is mounted ON `~`, so a link to `~/.config/kdeglobals` points at itself (`Too many levels of symbolic links`); without bwrap the same link writes through into the real home.
|
||||||
- `forceX11` (per app) sets XDG_SESSION_TYPE=x11 + QT_QPA_PLATFORM=xcb + GDK_BACKEND=x11 so the app and everything it launches falls back to XWayland, where window icons still exist at all (Wayland forbids them; a compiled GLFW probe showed XDG_SESSION_TYPE picks X11 vs Wayland, `GLFW_PLATFORM` does not exist). `game.env` still wins.
|
- `forceX11` (per app) sets XDG_SESSION_TYPE=x11 + QT_QPA_PLATFORM=xcb + GDK_BACKEND=x11 so the app and everything it launches falls back to XWayland, where window icons still exist at all (Wayland forbids them; a compiled GLFW probe showed XDG_SESSION_TYPE picks X11 vs Wayland, `GLFW_PLATFORM` does not exist). `game.env` still wins.
|
||||||
- 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").
|
- 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").
|
||||||
|
|
@ -91,6 +91,7 @@ Renderer never touches Node/Electron directly — everything goes through the `w
|
||||||
- **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).
|
- **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).
|
||||||
- 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.
|
- 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).
|
- 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.
|
||||||
- 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 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.)
|
- 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.
|
- 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.
|
||||||
|
|
@ -140,11 +141,12 @@ 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-consent.test.js # accept / decline / timeout gate
|
||||||
node test/lansync-token.test.js # long-sync token expiry (sliding TTL)
|
node test/lansync-token.test.js # long-sync token expiry (sliding TTL)
|
||||||
node test/bench-sync.js # timing: cold vs cached sync
|
node test/bench-sync.js # timing: cold vs cached sync
|
||||||
node test/linux-sandbox.test.js # bwrap argv profile + the four levels, handed paths, mode migration
|
node test/linux-sandbox.test.js # bwrap argv profile + the four levels, handed paths, mode gate
|
||||||
node test/linux-env.test.js # redirected HOME/XDG_* for both backends
|
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-import.test.js # ELF/AppImage/.desktop detection + icons
|
||||||
node test/linux-links.test.js # link opening: shim binds + mime seed
|
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-open.test.js # what an app opens: the script + the launcher relay
|
||||||
|
node test/games-service.test.js # run bookkeeping: the request watcher, Stop while starting
|
||||||
node test/linux-sync.test.js # linux_pfx stays out of a sync
|
node test/linux-sync.test.js # linux_pfx stays out of a sync
|
||||||
node test/linux-e2e.js # REAL runs: plain, relaxed (+ its mount), isolated, hand-off into a running app
|
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
|
npm start # click through the flow you touched
|
||||||
|
|
|
||||||
|
|
@ -22,11 +22,9 @@ function sendToWindow(win, channel, data) {
|
||||||
// Game-process state: id -> { pid, resolved prefix name and wine id }, frozen
|
// Game-process state: id -> { pid, resolved prefix name and wine id }, frozen
|
||||||
// at launch (the in-use guards must name what the process really locked).
|
// at launch (the in-use guards must name what the process really locked).
|
||||||
// onGamesChanged fires in the main process when the running set changes.
|
// onGamesChanged fires in the main process when the running set changes.
|
||||||
const STARTUP_GRACE_MS = 60000
|
|
||||||
|
|
||||||
function createGamesService({ getWindow, onGamesChanged, sandbox, onHandoffProgress, openRelay }) {
|
function createGamesService({ getWindow, onGamesChanged, sandbox, onHandoffProgress, openRelay }) {
|
||||||
const running = new Map()
|
const running = new Map()
|
||||||
const startedAt = new Map()
|
const starting = new Set()
|
||||||
const killRequests = new Set()
|
const killRequests = new Set()
|
||||||
let onGameExit = null
|
let onGameExit = null
|
||||||
const notifyChanged = () => {
|
const notifyChanged = () => {
|
||||||
|
|
@ -43,15 +41,20 @@ function createGamesService({ getWindow, onGamesChanged, sandbox, onHandoffProgr
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// a stale entry (no pid yet / dead pid) must not answer "already running"
|
// Everything a run owns once it is over: the entry and the watcher on the
|
||||||
|
// app's request folder (openrelay.js).
|
||||||
|
function forget(id) {
|
||||||
|
const entry = running.get(id)
|
||||||
|
running.delete(id)
|
||||||
|
starting.delete(id)
|
||||||
|
if (entry && entry.openRoot && openRelay) openRelay.unwatch(entry.openRoot)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A dead process must not answer "already running" - but a start that is
|
||||||
|
// still in flight owns its entry, however long a hand-off copy takes.
|
||||||
function prune() {
|
function prune() {
|
||||||
for (const [id, entry] of [...running]) {
|
for (const [id, entry] of [...running]) {
|
||||||
if (alive(entry.pid)) continue
|
if (!starting.has(id) && !alive(entry.pid)) forget(id)
|
||||||
const age = Date.now() - (startedAt.get(id) || 0)
|
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -86,11 +89,10 @@ function createGamesService({ getWindow, onGamesChanged, sandbox, onHandoffProgr
|
||||||
return require('../wines').resolveWine(game.wine || 'system')
|
return require('../wines').resolveWine(game.wine || 'system')
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleExit(id) {
|
// `entry` is the run that exited: a previous run of the same app (stopped and
|
||||||
const entry = running.get(id)
|
// then relaunched) must not take the new run's entry and watcher away.
|
||||||
running.delete(id)
|
function handleExit(id, entry) {
|
||||||
startedAt.delete(id)
|
if (running.get(id) === entry) forget(id)
|
||||||
if (entry && entry.openRoot && openRelay) openRelay.unwatch(entry.openRoot)
|
|
||||||
sendToWindow(getWindow(), 'games:changed')
|
sendToWindow(getWindow(), 'games:changed')
|
||||||
notifyChanged()
|
notifyChanged()
|
||||||
if (onGameExit) onGameExit(id)
|
if (onGameExit) onGameExit(id)
|
||||||
|
|
@ -141,48 +143,50 @@ function createGamesService({ getWindow, onGamesChanged, sandbox, onHandoffProgr
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const openRoot = openRootFor(game)
|
const openRoot = openRootFor(game)
|
||||||
running.set(id, { pid: null, prefix: library.prefixNameForGame(game), wine: wine ? wine.id : '', openRoot })
|
const entry = { pid: null, prefix: library.prefixNameForGame(game), wine: wine ? wine.id : '', openRoot }
|
||||||
|
running.set(id, entry)
|
||||||
// the app may ask for a folder the moment it paints its window, so the
|
// the app may ask for a folder the moment it paints its window, so the
|
||||||
// request file is watched before the run starts
|
// request file is watched before the run starts
|
||||||
if (openRoot && openRelay) openRelay.watch(openRoot)
|
if (openRoot && openRelay) openRelay.watch(openRoot)
|
||||||
startedAt.set(id, Date.now())
|
starting.add(id)
|
||||||
try {
|
try {
|
||||||
const res = await run({ onExit: () => handleExit(id), openFile, onProgress: handoffProgress })
|
const res = await run({ onExit: () => handleExit(id, entry), openFile, onProgress: handoffProgress })
|
||||||
// stop requested while still starting: kill and clear
|
// stop requested while still starting: kill and clear
|
||||||
if (killRequests.has(id)) {
|
if (killRequests.has(id)) {
|
||||||
killRequests.delete(id)
|
killRequests.delete(id)
|
||||||
if (res.pid) killProcessGroup(res.pid)
|
if (res.pid) killProcessGroup(res.pid)
|
||||||
running.delete(id)
|
forget(id)
|
||||||
startedAt.delete(id)
|
|
||||||
sendToWindow(win, 'games:changed')
|
sendToWindow(win, 'games:changed')
|
||||||
notifyChanged()
|
notifyChanged()
|
||||||
return { ok: true, pid: null, logFile: res.logFile || null }
|
return { ok: true, pid: null, logFile: res.logFile || null }
|
||||||
}
|
}
|
||||||
// no pid: never keep the entry, it would block the next launch
|
// no pid: never keep the entry, it would block the next launch
|
||||||
if (!res.pid) {
|
if (!res.pid) {
|
||||||
running.delete(id)
|
forget(id)
|
||||||
startedAt.delete(id)
|
|
||||||
return { ok: false, error: 'not-started' }
|
return { ok: false, error: 'not-started' }
|
||||||
}
|
}
|
||||||
const entry = running.get(id)
|
starting.delete(id)
|
||||||
if (entry) entry.pid = res.pid
|
entry.pid = res.pid
|
||||||
sendToWindow(win, 'games:changed')
|
sendToWindow(win, 'games:changed')
|
||||||
notifyChanged()
|
notifyChanged()
|
||||||
return { ok: true, pid: res.pid, logFile: res.logFile || null }
|
return { ok: true, pid: res.pid, logFile: res.logFile || null }
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
running.delete(id)
|
killRequests.delete(id)
|
||||||
startedAt.delete(id)
|
forget(id)
|
||||||
return { ok: false, error: String((err && err.message) || err) }
|
return { ok: false, error: String((err && err.message) || err) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stop always clears the state.
|
// Stop clears the state - except while a run is still starting: that start
|
||||||
|
// kills it the moment it has a pid, and clearing the entry here would let a
|
||||||
|
// second launch race past the running one.
|
||||||
async function kill(id) {
|
async function kill(id) {
|
||||||
const entry = running.get(id)
|
const entry = running.get(id)
|
||||||
if (entry) killProcessGroup(entry.pid)
|
if (entry && !entry.pid) killRequests.add(id)
|
||||||
else if (running.has(id)) killRequests.add(id)
|
else {
|
||||||
running.delete(id)
|
if (entry) killProcessGroup(entry.pid)
|
||||||
startedAt.delete(id)
|
forget(id)
|
||||||
|
}
|
||||||
sendToWindow(getWindow(), 'games:changed')
|
sendToWindow(getWindow(), 'games:changed')
|
||||||
notifyChanged()
|
notifyChanged()
|
||||||
return { ok: true }
|
return { ok: true }
|
||||||
|
|
|
||||||
|
|
@ -38,7 +38,6 @@ function loadGames() {
|
||||||
g[k] = usePrefix && isPrefixRel(g[k]) ? path.join(usePrefix, g[k]) : resolvePortablePath(g[k])
|
g[k] = usePrefix && isPrefixRel(g[k]) ? path.join(usePrefix, g[k]) : resolvePortablePath(g[k])
|
||||||
}
|
}
|
||||||
if (g.iconSource) g.iconSource = resolvePortablePath(g.iconSource)
|
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)
|
if (g.isolation !== undefined) g.isolation = isolationMode(g.isolation)
|
||||||
const ps = readPlaySeconds(g.id)
|
const ps = readPlaySeconds(g.id)
|
||||||
if (ps != null) g.playSeconds = ps
|
if (ps != null) g.playSeconds = ps
|
||||||
|
|
@ -116,19 +115,14 @@ function isLinuxGame(game) {
|
||||||
return !!game && game.kind === 'linux'
|
return !!game && game.kind === 'linux'
|
||||||
}
|
}
|
||||||
|
|
||||||
// none = plain run, relaxed = bwrap + the whole host filesystem mounted rw,
|
// none = plain run, relaxed = whole host filesystem mounted writable, isolated
|
||||||
// isolated = bwrap + handed documents copied in, auto = relaxed where bwrap
|
// = the stock read-only profile, auto = relaxed where bwrap works.
|
||||||
// works and none where it does not.
|
|
||||||
const ISOLATION_MODES = ['auto', 'none', 'relaxed', 'isolated']
|
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
|
// One known mode, always: an unknown value would otherwise reach the runner and
|
||||||
// and pick something nobody asked for.
|
// pick a level nobody asked for.
|
||||||
function isolationMode(value) {
|
function isolationMode(value) {
|
||||||
const mode = LEGACY_ISOLATION_MODES[value] || value
|
return ISOLATION_MODES.includes(value) ? value : 'auto'
|
||||||
return ISOLATION_MODES.includes(mode) ? mode : 'auto'
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function sanitizeLinuxPatch(patch) {
|
function sanitizeLinuxPatch(patch) {
|
||||||
|
|
|
||||||
|
|
@ -27,10 +27,8 @@ const XDG_LAYOUT = {
|
||||||
XDG_STATE_HOME: ['state', path.join('.local', 'state')]
|
XDG_STATE_HOME: ['state', path.join('.local', 'state')]
|
||||||
}
|
}
|
||||||
|
|
||||||
// Write access is handed out to the folder the app was imported from and to
|
// Write access goes to the folder the app was imported from and nowhere else
|
||||||
// nothing else outside its home: a portable app writes its logs and data next
|
// outside its home: a portable app writes its logs next to itself.
|
||||||
// to itself (Prism Launcher refuses to start otherwise), while system trees
|
|
||||||
// must never come back writable.
|
|
||||||
const SYSTEM_DIRS = ['/bin', '/sbin', '/lib', '/lib32', '/lib64', '/libx32', '/usr', '/etc', '/boot', '/proc', '/sys', '/dev', '/run']
|
const SYSTEM_DIRS = ['/bin', '/sbin', '/lib', '/lib32', '/lib64', '/libx32', '/usr', '/etc', '/boot', '/proc', '/sys', '/dev', '/run']
|
||||||
|
|
||||||
function writableAppDir(game) {
|
function writableAppDir(game) {
|
||||||
|
|
@ -75,9 +73,8 @@ function copyFromHost(src, dst, { refresh = false } = {}) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// PulseAudio looks for its cookie under $HOME/.config/pulse; with HOME
|
// With HOME redirected there is no pulse cookie, and everything through Pulse
|
||||||
// redirected there is none, and sound dies for everything going through
|
// comes up silent; copied once, then the app owns it.
|
||||||
// Pulse. Copied once - the app owns it afterwards.
|
|
||||||
function seedPulseCookie(root) {
|
function seedPulseCookie(root) {
|
||||||
const dst = path.join(root, 'config', 'pulse', 'cookie')
|
const dst = path.join(root, 'config', 'pulse', 'cookie')
|
||||||
const seeded = copyFromHost(path.join(os.homedir(), '.config', 'pulse', 'cookie'), dst)
|
const seeded = copyFromHost(path.join(os.homedir(), '.config', 'pulse', 'cookie'), dst)
|
||||||
|
|
@ -88,9 +85,8 @@ function seedPulseCookie(root) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// The host's desktop preferences, not its data: a redirected home has no
|
// The host's desktop preferences, not its data: without them Qt/GTK fall back
|
||||||
// theme at all, and Qt/GTK then fall back to their built-in light look (KDE's
|
// to their built-in light look (KDE's dark palette lives in kdeglobals).
|
||||||
// dark palette lives in kdeglobals).
|
|
||||||
const DESKTOP_SEEDS = [
|
const DESKTOP_SEEDS = [
|
||||||
'kdeglobals',
|
'kdeglobals',
|
||||||
'kdedefaults/kdeglobals',
|
'kdedefaults/kdeglobals',
|
||||||
|
|
@ -102,21 +98,17 @@ const DESKTOP_SEEDS = [
|
||||||
'user-dirs.dirs'
|
'user-dirs.dirs'
|
||||||
]
|
]
|
||||||
|
|
||||||
// The host's desktop *data* - fonts, icons, themes, colour schemes - is not
|
// The host's desktop *data* (fonts, icons, themes) is not copied but handed
|
||||||
// copied but handed over read-only (see buildBwrapArgs), so it follows the
|
// over read-only: see buildBwrapArgs.
|
||||||
// host without an import.
|
|
||||||
const DATA_SHARES = ['fonts', 'icons', 'themes', 'color-schemes']
|
const DATA_SHARES = ['fonts', 'icons', 'themes', 'color-schemes']
|
||||||
|
|
||||||
// A clicked link is the host desktop's business: in here there is no default
|
// A clicked link is the host desktop's business: the association naming a
|
||||||
// browser at all, because the association (mimeapps.list) and the .desktop
|
// browser lives in the host's own home, so the URI goes to the host's portal.
|
||||||
// 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_BUS = 'org.freedesktop.portal.Desktop'
|
||||||
const PORTAL_METHOD = 'org.freedesktop.portal.OpenURI.OpenURI'
|
const PORTAL_METHOD = 'org.freedesktop.portal.OpenURI.OpenURI'
|
||||||
const OPEN_BINS = ['/usr/bin/xdg-open', '/bin/xdg-open', '/usr/local/bin/xdg-open']
|
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,
|
// Launcher-owned files in the redirected home (never mounted into a sandbox),
|
||||||
// and each is rewritten whenever it differs. The prefix root is not mounted in
|
// rewritten whenever they differ.
|
||||||
// the sandbox, so nothing a link needs may live there.
|
|
||||||
const SHIM_NAME = 'xdg-open'
|
const SHIM_NAME = 'xdg-open'
|
||||||
const OPENER_NAME = 'open-url'
|
const OPENER_NAME = 'open-url'
|
||||||
const OPENER_DESKTOP = 'plwl-url-opener.desktop'
|
const OPENER_DESKTOP = 'plwl-url-opener.desktop'
|
||||||
|
|
@ -155,19 +147,10 @@ function writeIfChanged(file, text, mode) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// The one script both hand-offs run: it takes xdg-open's place inside a
|
// The one script both hand-offs run: xdg-open's stand-in inside a sandbox, and
|
||||||
// sandbox and it is what the seeded desktop entry starts. A URI with a scheme
|
// what the seeded desktop entry starts. A scheme goes to the host's portal
|
||||||
// belongs to the host's portal, which also puts up its own "choose an
|
// (chooser included), a path to the request file the launcher watches - the
|
||||||
// application" when nothing claims that scheme; everything else - every local
|
// portal's answer is unreadable (development.md), the call is all we can trust.
|
||||||
// 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 }) {
|
function handOffScript({ hostHome, fsRoot, request }) {
|
||||||
const cases = [
|
const cases = [
|
||||||
`file://"$HOME") arg="file://${hostHome}" ;;`,
|
`file://"$HOME") arg="file://${hostHome}" ;;`,
|
||||||
|
|
@ -187,8 +170,7 @@ function handOffScript({ hostHome, fsRoot, request }) {
|
||||||
arg="$1"
|
arg="$1"
|
||||||
[ -n "$arg" ] || exit 1
|
[ -n "$arg" ] || exit 1
|
||||||
|
|
||||||
# the portal owns a link from here on, chooser and all; only a call that does
|
# a link is the host portal's from here on; anything else falls back
|
||||||
# not go out at all (no portal on this machine) falls back to the launcher
|
|
||||||
open_portal() {
|
open_portal() {
|
||||||
command -v gdbus >/dev/null 2>&1 || return 1
|
command -v gdbus >/dev/null 2>&1 || return 1
|
||||||
gdbus call --session --dest ${PORTAL_BUS} \\
|
gdbus call --session --dest ${PORTAL_BUS} \\
|
||||||
|
|
@ -210,13 +192,13 @@ printf '%s\\n' "$arg" > "${request}.part" && mv "${request}.part" "${request}" |
|
||||||
`
|
`
|
||||||
}
|
}
|
||||||
|
|
||||||
// The stand-in for xdg-open inside the sandbox.
|
// The stand-in for xdg-open inside the sandbox, and (same script) what the
|
||||||
|
// seeded desktop entry runs: a browser started in here would be the wrong one,
|
||||||
|
// with an empty profile growing inside the prefix.
|
||||||
function writeUrlShim(root, opts) {
|
function writeUrlShim(root, opts) {
|
||||||
return writeIfChanged(path.join(root, SHIM_REL), handOffScript(opts), 0o755)
|
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) {
|
function writeUrlOpener(root, opts) {
|
||||||
return writeIfChanged(path.join(root, OPENER_REL), handOffScript(opts), 0o755)
|
return writeIfChanged(path.join(root, OPENER_REL), handOffScript(opts), 0o755)
|
||||||
}
|
}
|
||||||
|
|
@ -228,14 +210,10 @@ function linkOpenerName() {
|
||||||
return text === 'sandbox.linkOpener' ? 'PLWL' : text
|
return text === 'sandbox.linkOpener' ? 'PLWL' : text
|
||||||
}
|
}
|
||||||
|
|
||||||
// GIO resolves an URI in-process (gtk_show_uri, `gio open`) against the MIME
|
// GIO resolves an URI in-process (gtk_show_uri, `gio open`) and would pick a
|
||||||
// lists, and the host's default browser is not among the choices in here: it
|
// system browser in here: naming our opener the default for exactly these types
|
||||||
// would open a system browser, or none at all. Naming our opener as the
|
// changes only its answer. GIO's own portal path is not reachable (it wants a
|
||||||
// default for exactly these types leaves that resolution alone and changes
|
// sandbox marker this launcher does not fake).
|
||||||
// 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) {
|
function writeMimeSeed(root, home) {
|
||||||
const desktop = [
|
const desktop = [
|
||||||
'[Desktop Entry]',
|
'[Desktop Entry]',
|
||||||
|
|
@ -252,10 +230,8 @@ function writeMimeSeed(root, home) {
|
||||||
return writeIfChanged(path.join(root, MIME_REL), mime, 0o644)
|
return writeIfChanged(path.join(root, MIME_REL), mime, 0o644)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Everything an opened thing needs: the hand-off script, the association
|
// Everything an opened thing needs: the hand-off script, the association naming
|
||||||
// naming it as the default, and - in a sandbox - xdg-open replaced by the same
|
// it the default, and - in a sandbox - xdg-open replaced by the same script.
|
||||||
// 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 }) {
|
function urlRouting({ root, home, sandboxed, fsRoot = false }) {
|
||||||
const target = OPEN_BINS.find(bin => fs.existsSync(bin)) || ''
|
const target = OPEN_BINS.find(bin => fs.existsSync(bin)) || ''
|
||||||
const opts = { hostHome: path.join(root, 'home'), fsRoot, request: requestPath(home) }
|
const opts = { hostHome: path.join(root, 'home'), fsRoot, request: requestPath(home) }
|
||||||
|
|
@ -273,22 +249,12 @@ function hashOf(file) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function mtimeOf(file) {
|
// Hashes of what we seeded, kept in the prefix root: the app only ever sees
|
||||||
try {
|
// home/config/data/cache/state.
|
||||||
return fs.statSync(file).mtimeMs
|
|
||||||
} catch {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Hashes of what we put in the prefix, so a host change can be followed while
|
|
||||||
// the app has not touched our copy. Kept in the prefix root: the app only
|
|
||||||
// ever sees home/config/data/cache/state.
|
|
||||||
const SEED_MARKER = '.seeded.json'
|
const SEED_MARKER = '.seeded.json'
|
||||||
|
|
||||||
// Copies a preference in, then keeps following it for as long as we can prove
|
// Keeps a seeded preference following the host while the copy is provably ours;
|
||||||
// the copy is still ours: an unusable theme is worse than a stale one, and an
|
// an app's own edit is never overwritten.
|
||||||
// app's own edit is never overwritten.
|
|
||||||
function syncDesktopConfig(root) {
|
function syncDesktopConfig(root) {
|
||||||
const hostConfig = path.join(os.homedir(), '.config')
|
const hostConfig = path.join(os.homedir(), '.config')
|
||||||
const markerFile = path.join(root, SEED_MARKER)
|
const markerFile = path.join(root, SEED_MARKER)
|
||||||
|
|
@ -300,19 +266,12 @@ function syncDesktopConfig(root) {
|
||||||
const hostHash = hashOf(src)
|
const hostHash = hashOf(src)
|
||||||
if (!hostHash) continue
|
if (!hostHash) continue
|
||||||
const known = marker[rel]
|
const known = marker[rel]
|
||||||
if (fs.existsSync(dst)) {
|
if (fs.existsSync(dst) && known) {
|
||||||
const current = hashOf(dst)
|
const current = hashOf(dst)
|
||||||
if (known) {
|
// the app owns it now, or there is nothing to do
|
||||||
// the app owns it now, or there is nothing to do
|
if (current !== known || current === hostHash) continue
|
||||||
if (current !== known || current === hostHash) continue
|
|
||||||
} else if (current !== hostHash && mtimeOf(dst) >= mtimeOf(src)) {
|
|
||||||
// a prefix from an older launcher: an identical copy is ours, and so is
|
|
||||||
// one the host's file has since outgrown. A copy that is not older than
|
|
||||||
// the host's is the app's own - ties stay with the app
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
// refresh: this is provably still our own copy
|
// no marker entry: the copy is ours (seeded before the marker existed)
|
||||||
if (copyFromHost(src, dst, { refresh: true })) {
|
if (copyFromHost(src, dst, { refresh: true })) {
|
||||||
marker[rel] = hostHash
|
marker[rel] = hostHash
|
||||||
changed = true
|
changed = true
|
||||||
|
|
@ -325,9 +284,8 @@ function syncDesktopConfig(root) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// X11 access: XAUTHORITY usually lives in /run/user (bound), but a launcher
|
// X11 access: a cookie is session state, not data, so XAUTHORITY is refreshed
|
||||||
// started without it would leave X11 dead inside. Refreshed: a cookie is
|
// rather than seeded once.
|
||||||
// session state, not data.
|
|
||||||
function seedXauthority(root) {
|
function seedXauthority(root) {
|
||||||
copyFromHost(path.join(os.homedir(), '.Xauthority'), path.join(root, 'home', '.Xauthority'), { refresh: true })
|
copyFromHost(path.join(os.homedir(), '.Xauthority'), path.join(root, 'home', '.Xauthority'), { refresh: true })
|
||||||
}
|
}
|
||||||
|
|
@ -343,10 +301,8 @@ function seededRelPaths() {
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
// syncDesktopConfig only follows the host while the copy is provably ours, so
|
// Drops what we seeded (and the hashes tracking it) so the next start seeds
|
||||||
// an app that edited its own theme keeps it forever. The reset drops our
|
// clean ones; the app's own files in the same folders are never touched.
|
||||||
// copies (and the hashes that track them) and lets the next start seed clean
|
|
||||||
// ones - the app's own files in the same folders are never touched.
|
|
||||||
function resetSeededConfig(name) {
|
function resetSeededConfig(name) {
|
||||||
const root = prefixLinuxRoot(name)
|
const root = prefixLinuxRoot(name)
|
||||||
const removed = []
|
const removed = []
|
||||||
|
|
@ -378,9 +334,8 @@ function ensureLinuxPfx(name) {
|
||||||
return root
|
return root
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wayland cannot carry a window icon and the compositor only guesses one from
|
// XWayland is the only place a window can carry its own icon, and GLFW/Qt/GTK
|
||||||
// the app id; XWayland can, and GLFW/Qt/GTK all read these, so one flag moves
|
// all read these, so one switch moves the whole process tree back to X11.
|
||||||
// the whole process tree (Prism and the Minecraft it launches) back to X11.
|
|
||||||
const X11_ENV = { XDG_SESSION_TYPE: 'x11', QT_QPA_PLATFORM: 'xcb', GDK_BACKEND: 'x11' }
|
const X11_ENV = { XDG_SESSION_TYPE: 'x11', QT_QPA_PLATFORM: 'xcb', GDK_BACKEND: 'x11' }
|
||||||
|
|
||||||
// The theme data sits in front of the inherited list: on the host the user's
|
// The theme data sits in front of the inherited list: on the host the user's
|
||||||
|
|
@ -404,10 +359,8 @@ function buildLinuxEnv({ game, backend = 'env', fsRoot = false }) {
|
||||||
for (const [key, [hostDir, sandboxSub]] of Object.entries(XDG_LAYOUT)) {
|
for (const [key, [hostDir, sandboxSub]] of Object.entries(XDG_LAYOUT)) {
|
||||||
env[key] = sandboxed ? path.join(home, sandboxSub) : path.join(root, hostDir)
|
env[key] = sandboxed ? path.join(home, sandboxSub) : path.join(root, hostDir)
|
||||||
}
|
}
|
||||||
// XDG_RUNTIME_DIR stays the host's: Wayland, PipeWire and the session bus
|
// The X session manager sits in the private /tmp, so its pointer would lead
|
||||||
// live there. It weakens the isolation, and the UI says so.
|
// to a dead socket. XDG_RUNTIME_DIR stays the host's (it weakens isolation).
|
||||||
// The X session manager sits in the private /tmp, so its pointer would only
|
|
||||||
// lead to a dead socket; clients skip it when it is absent.
|
|
||||||
if (sandboxed) delete env.SESSION_MANAGER
|
if (sandboxed) delete env.SESSION_MANAGER
|
||||||
// the host's fonts/icons/themes: mounted read-only by buildBwrapArgs, or - in
|
// the host's fonts/icons/themes: mounted read-only by buildBwrapArgs, or - in
|
||||||
// a plain run, where nothing is hidden - just put on the search path
|
// a plain run, where nothing is hidden - just put on the search path
|
||||||
|
|
@ -415,23 +368,19 @@ function buildLinuxEnv({ game, backend = 'env', fsRoot = false }) {
|
||||||
if (game.forceX11) Object.assign(env, X11_ENV)
|
if (game.forceX11) Object.assign(env, X11_ENV)
|
||||||
// the app's own environment comes last: it overrides everything above
|
// the app's own environment comes last: it overrides everything above
|
||||||
for (const [k, v] of Object.entries(game.env || {})) env[k] = v
|
for (const [k, v] of Object.entries(game.env || {})) env[k] = v
|
||||||
// an opened link or document must leave for the host desktop; `home` is where
|
// an opened link or document leaves for the host desktop (see openrelay.js)
|
||||||
// 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 }) }
|
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
|
// Where a handed document goes inside the app's own home - a live bind, so a
|
||||||
// a live bind of a host folder, so a copy written here is visible to an app
|
// copy written here reaches an app that is already running too.
|
||||||
// that is already running - the one route a running namespace can be reached
|
|
||||||
// through at all.
|
|
||||||
const HANDOFF_DIR = 'plwl-handoff'
|
const HANDOFF_DIR = 'plwl-handoff'
|
||||||
// Copies above this are worth a popup: below it the copy is over before a
|
// 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.
|
// window could paint, and the launcher would flash for nothing.
|
||||||
const STAGE_NOTICE_BYTES = 50 * 1000 * 1000
|
const STAGE_NOTICE_BYTES = 50 * 1000 * 1000
|
||||||
|
|
||||||
// The probe measures once and answers from its cache afterwards. It is awaited
|
// Awaited because a shortcut boots HEADLESS: without a measurement "auto" would
|
||||||
// here because a shortcut boots HEADLESS: no renderer ran, so nothing else
|
// quietly pick the weaker level.
|
||||||
// would have measured, and "auto" would quietly pick the weaker level.
|
|
||||||
async function levelStatus(sandbox) {
|
async function levelStatus(sandbox) {
|
||||||
if (!sandbox || typeof sandbox.status !== 'function') return null
|
if (!sandbox || typeof sandbox.status !== 'function') return null
|
||||||
const cached = sandbox.status()
|
const cached = sandbox.status()
|
||||||
|
|
@ -444,11 +393,8 @@ async function levelStatus(sandbox) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// bwrap when it works, plain run otherwise - unless the app demands a sandbox.
|
// `auto` is the only level that may weaken itself: a machine without bwrap runs
|
||||||
// `auto` is the only level allowed to take a weaker one: a machine that cannot
|
// the app plain, while an explicit relaxed/isolated refuses instead.
|
||||||
// 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 }) {
|
async function resolveLevel({ game, sandbox }) {
|
||||||
const policy = (game && game.isolation) || 'auto'
|
const policy = (game && game.isolation) || 'auto'
|
||||||
const status = await levelStatus(sandbox)
|
const status = await levelStatus(sandbox)
|
||||||
|
|
@ -483,15 +429,9 @@ function linuxPathFor(file) {
|
||||||
return abs
|
return abs
|
||||||
}
|
}
|
||||||
|
|
||||||
// The document a shortcut was handed, in the one shape the chosen level can
|
// The document a shortcut was handed, in the shape the chosen level can reach:
|
||||||
// reach - and the app is told nothing else, so a second hand-off names the
|
// none = the host path, relaxed = through FS_ROOT, isolated = a copy in the
|
||||||
// same file the same way.
|
// app's own home (refreshed on every open). '' when there is nothing to hand over.
|
||||||
// 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 }) {
|
async function handedPathFor({ openFile, level, root, home, onProgress }) {
|
||||||
if (!openFile) return ''
|
if (!openFile) return ''
|
||||||
const abs = linuxPathFor(openFile)
|
const abs = linuxPathFor(openFile)
|
||||||
|
|
|
||||||
|
|
@ -1,30 +1,27 @@
|
||||||
'use strict'
|
'use strict'
|
||||||
|
|
||||||
// What a sandboxed app asks to open: a folder, a document, a link the portal
|
// What a sandboxed app asked to open - a folder, a document, a path: nothing in
|
||||||
// refused. The app cannot start anything out here itself - measured, the host's
|
// there can start a host program (measured), so the hand-off script leaves the
|
||||||
// own xdg-open *inside* the sandbox ends in kde-open, which exits 0 and opens no
|
// host-rewritten target in a request file inside the app's home (a live bind)
|
||||||
// window - so the hand-off script leaves the (already host-rewritten) path in a
|
// and the launcher opens it here with the host's own xdg-open.
|
||||||
// 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 fs = require('fs')
|
||||||
const path = require('path')
|
const path = require('path')
|
||||||
const { spawn } = require('child_process')
|
const { spawn } = require('child_process')
|
||||||
|
|
||||||
// The hand-off protocol, in one place: the folder PLWL owns inside an app's
|
// The hand-off protocol, in one place: the folder PLWL owns inside an app's home
|
||||||
// home (the shim and the opener are written there too) and the file the
|
// (the shim and opener are written there too) and the file the launcher watches.
|
||||||
// launcher watches.
|
|
||||||
const PLWL_DIR = '.plwl'
|
const PLWL_DIR = '.plwl'
|
||||||
const REQUEST_NAME = 'open-request'
|
const REQUEST_NAME = 'open-request'
|
||||||
const DRAIN_DEBOUNCE_MS = 60
|
const DRAIN_DEBOUNCE_MS = 60
|
||||||
|
|
||||||
// `homeDir` is that home as the host sees it - the prefix's own `home`, which
|
// `homeDir` is that home as the host sees it: a live bind, so a write is here
|
||||||
// is a live bind, so what the app writes is here the moment it lands.
|
// the moment it lands.
|
||||||
const requestPath = homeDir => path.join(homeDir, PLWL_DIR, REQUEST_NAME)
|
const requestPath = homeDir => path.join(homeDir, PLWL_DIR, REQUEST_NAME)
|
||||||
const requestFile = root => requestPath(path.join(root, 'home'))
|
const requestFile = root => requestPath(path.join(root, 'home'))
|
||||||
|
|
||||||
// detached: the opened program is the desktop's child, not the launcher's - it
|
// detached: the opened program is the desktop's child, and outlives a launcher
|
||||||
// must outlive a launcher that quits to the tray and never hold it open
|
// that quit to the tray
|
||||||
function openOnHost(target) {
|
function openOnHost(target) {
|
||||||
spawn('xdg-open', [target], { detached: true, stdio: 'ignore' }).unref()
|
spawn('xdg-open', [target], { detached: true, stdio: 'ignore' }).unref()
|
||||||
}
|
}
|
||||||
|
|
@ -32,9 +29,8 @@ function openOnHost(target) {
|
||||||
function createOpenRelay({ open = openOnHost, onError = null } = {}) {
|
function createOpenRelay({ open = openOnHost, onError = null } = {}) {
|
||||||
const watchers = new Map()
|
const watchers = new Map()
|
||||||
|
|
||||||
// the request is consumed before it is opened: a second click while the first
|
// consumed before it is opened: a second click overwrites the file, so nothing
|
||||||
// program starts writes a fresh file, so nothing is opened twice and nothing
|
// is opened twice and nothing is lost
|
||||||
// is dropped
|
|
||||||
function drain(root) {
|
function drain(root) {
|
||||||
let target = ''
|
let target = ''
|
||||||
try {
|
try {
|
||||||
|
|
@ -51,8 +47,8 @@ function createOpenRelay({ open = openOnHost, onError = null } = {}) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// watch from the moment the app starts: a request written before the watcher
|
// a request written before the watcher was up (the app's first window) must
|
||||||
// was up (the app's first window) must not be missed, so drain once first
|
// not be missed, so drain once first
|
||||||
function watch(root) {
|
function watch(root) {
|
||||||
if (!root || watchers.has(root)) return
|
if (!root || watchers.has(root)) return
|
||||||
const file = requestFile(root)
|
const file = requestFile(root)
|
||||||
|
|
|
||||||
|
|
@ -4,9 +4,8 @@ const { execFile } = require('child_process')
|
||||||
const fs = require('fs')
|
const fs = require('fs')
|
||||||
const path = require('path')
|
const path = require('path')
|
||||||
|
|
||||||
// bubblewrap is only usable when unprivileged user namespaces actually work:
|
// bwrap only works when unprivileged namespaces really work (Ubuntu's AppArmor
|
||||||
// Ubuntu 24.04 ships bwrap but AppArmor kills it ("Failed to unshare user
|
// kills it), so a functional probe decides - never `which bwrap`.
|
||||||
// namespace"), so a functional probe decides - not `which bwrap`.
|
|
||||||
const PROBE_TIMEOUT_MS = 3000
|
const PROBE_TIMEOUT_MS = 3000
|
||||||
// /bin/true is a symlink on most distros but missing on a few; env is the
|
// /bin/true is a symlink on most distros but missing on a few; env is the
|
||||||
// last resort that still exits 0.
|
// last resort that still exits 0.
|
||||||
|
|
@ -93,23 +92,18 @@ function createSandboxProbe({ timeoutMs = PROBE_TIMEOUT_MS } = {}) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Host theme data (fonts, icons, colour schemes) is mounted read-only under
|
// Host theme data is mounted read-only here and listed in XDG_DATA_DIRS (inside
|
||||||
// this path - inside the private /tmp the app cannot mistake it for its own
|
// the private /tmp, where the app cannot mistake it for its own data). A bind,
|
||||||
// data, and the caller lists it in XDG_DATA_DIRS. A bind, not a symlink: the
|
// not a symlink: the prefix is mounted over ~, so a link would point at itself.
|
||||||
// prefix is mounted over ~, so a link back into ~ would point at itself.
|
|
||||||
const SHARE_ROOT = '/tmp/.plwl-host'
|
const SHARE_ROOT = '/tmp/.plwl-host'
|
||||||
|
|
||||||
// Relaxed isolation hands the app the whole host filesystem as ONE writable
|
// Relaxed isolation mounts the whole host filesystem writable here. It takes
|
||||||
// mount. It has to sit under the private /tmp: bubblewrap creates a mount
|
// the private /tmp (bwrap creates the mount point itself) and one fixed string,
|
||||||
// point itself, and the root of a read-only root cannot be written (measured:
|
// so a hand-off into an app that is ALREADY running resolves too.
|
||||||
// "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'
|
const FS_ROOT = '/tmp/plwl-fs-root'
|
||||||
|
|
||||||
// The bwrap profile for a native Linux app. Pure: the caller decides which
|
// 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
|
// devices and host folders exist, so the argv is testable as-is.
|
||||||
// as-is.
|
|
||||||
function buildBwrapArgs({
|
function buildBwrapArgs({
|
||||||
dataRoot,
|
dataRoot,
|
||||||
user,
|
user,
|
||||||
|
|
@ -142,16 +136,13 @@ function buildBwrapArgs({
|
||||||
'--tmpfs', '/tmp',
|
'--tmpfs', '/tmp',
|
||||||
// X11 sockets live in /tmp, which the tmpfs above just emptied
|
// X11 sockets live in /tmp, which the tmpfs above just emptied
|
||||||
...(x11 ? ['--ro-bind', x11, x11] : []),
|
...(x11 ? ['--ro-bind', x11, x11] : []),
|
||||||
// relaxed: the host filesystem, whole and writable. Nothing is hidden in
|
// relaxed: the whole host filesystem, writable. Nothing is hidden, and the
|
||||||
// here, so a document handed over later can be opened - and saved - where
|
// app may write anywhere on the machine - that is this level's price.
|
||||||
// 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] : []),
|
...(fsRoot ? ['--bind', '/', FS_ROOT] : []),
|
||||||
// the host's theme data, read-only: fonts, icons, colour schemes
|
// the host's theme data, read-only: fonts, icons, colour schemes
|
||||||
...shares.flatMap(dir => ['--ro-bind', dir, path.join(SHARE_ROOT, path.basename(dir))]),
|
...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
|
// an opened link or document belongs to the host desktop, so xdg-open is
|
||||||
// replaced by the launcher's hand-off script (the portal for a link, the
|
// replaced by the launcher's hand-off script (linuxrunner.js)
|
||||||
// launcher itself for a document - see linuxrunner.js)
|
|
||||||
...(urlOpener && urlOpener.shim ? ['--ro-bind', urlOpener.shim, urlOpener.target] : []),
|
...(urlOpener && urlOpener.shim ? ['--ro-bind', urlOpener.shim, urlOpener.target] : []),
|
||||||
// hard profile: nothing of anybody's /home is even readable
|
// hard profile: nothing of anybody's /home is even readable
|
||||||
...(hideHome ? ['--tmpfs', '/home', '--tmpfs', '/root', '--tmpfs', '/mnt', '--tmpfs', '/media'] : []),
|
...(hideHome ? ['--tmpfs', '/home', '--tmpfs', '/root', '--tmpfs', '/mnt', '--tmpfs', '/media'] : []),
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue