From 1bbc71166e0e4c741f9e6271abff2de22aca1059 Mon Sep 17 00:00:00 2001 From: olcxja Date: Wed, 7 Oct 2026 10:29:07 +0200 Subject: [PATCH] (i hope) support for ALL xdg- dialogs --- README.md | 2 +- development.md | 7 +- files/translations/en-us.json | 8 +- files/translations/pl.json | 8 +- src/ipc/games.js | 17 +++- src/ipc/index.js | 4 +- src/linuxrunner.js | 175 ++++++++++++++++------------------ src/main.js | 6 +- src/openrelay.js | 85 +++++++++++++++++ src/sandbox.js | 10 +- 10 files changed, 210 insertions(+), 112 deletions(-) create mode 100644 src/openrelay.js diff --git a/README.md b/README.md index 95b19b7..039f34c 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,7 @@ When **bubblewrap** is installed *and* usable (PLWL probes it with a real run, n 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 `~`. -A **link you click inside the app opens on your desktop**, in your own default browser: PLWL hands the URI to the host's xdg-desktop-portal instead of resolving a browser in here, where the default association and its `.desktop` file are behind the redirected home — that used to mean a system browser you never chose, 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`). 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). +**Anything the app wants to open is opened by your own desktop.** A **link** goes to the host's xdg-desktop-portal, so it lands in the browser you actually chose instead of one resolved in here, where the default association and its `.desktop` file are behind the redirected home — that used to mean a system browser you never picked, or nothing at all. Both routes reach the portal: `xdg-open` itself (what Qt, Prism and Electron call) and GIO's in-process resolution (`gtk_show_uri`). A **folder or a document** (Prism's *Open Pack Folder*, a text file, a drop) takes the other route: the app leaves the target in its own home, PLWL picks it up, translates the path back out of the sandbox and opens it with your `xdg-open` — so it arrives in your file manager, and a scheme nothing handles gets your desktop's own *Choose an application* dialog rather than a guess. On a machine without a portal nothing is changed — a link then behaves exactly as it did before. A KDE-native app that resolves the link in-process through KIO (Dolphin, Kate) is the one path still left out: it asks KDE's own handler database, which has no answer in here (measured: `kde-open` opens nothing and reports success). Every prefix in the **Wine prefixes** tab has a **Reset Linux config** button: it deletes the desktop preferences PLWL copied in (and the hashes that track them), so the next app start seeds a clean copy from your system — the way back when an app has hijacked a seeded file with its own theme. App data in `linux_pfx` is never touched, and an app running from that prefix blocks the reset. diff --git a/development.md b/development.md index 1d6bc09..b29d792 100644 --- a/development.md +++ b/development.md @@ -17,6 +17,8 @@ src/ main process (Node/Electron backend) 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 + openrelay.js the launcher side of the hand-off: the request folder an app + leaves what it wants opened in, watched while it runs linuxapp.js native app detection (ELF/AppImage/.desktop) + icon lookup sandbox.js bubblewrap probe (functional, cached) + buildBwrapArgs (+ FS_ROOT, the one mount the relaxed level adds) @@ -88,7 +90,9 @@ Renderer never touches Node/Electron directly — everything goes through the `w - 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). - 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` — both routes, because two different things open a link. The `xdg-open` BINARY is replaced inside the sandbox (a written shim `--ro-bind` over `/usr/bin/xdg-open`, the host's own script parked at `/tmp/.plwl-host/xdg-open` as what the shim falls back to for anything that is not a URI): 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) and so does Electron. 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` and `text/html`. 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), while on a machine WITHOUT a portal it fails hard (“Failed to create OpenURI proxy”). The whole wiring is therefore written only when a session bus and a portal are present (`portalReady`), and a KDE-native app resolving through KIO in-process (`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 portal is not told about a FOLDER or a document, which is what "Open Pack Folder" or a drop asks for: it cannot see a path from in here and answers `Response` 2 for `file://` (measured, while https answered 0). The shim leaves those in `home/.plwl/open-request` (written as `.part` and renamed, so nothing ever reads half a request) — a live bind of the app's home, so it reaches an app that is ALREADY running — and the launcher opens it out here with the host's own `xdg-open` (openrelay.js: `createOpenRelay` watches that folder for exactly as long as the app runs, consumes each request once, and quits watching on exit). The target is rewritten on the way out — the private `$HOME` back to `{prefix}/linux_pfx/home`, `FS_ROOT` back to `/` at relaxed — so the host opens the REAL folder and never a path that only exists inside. Nothing in the sandbox can start a host program itself: measured, the host's own `xdg-open` run in there ends in `kde-open`, which exits 0 and opens no window at all. A scheme nothing on the host claims ends in the desktop's own "choose an application" dialog, which is the point of handing it out instead of guessing. +- The portal's `Response` is NOT read back: it is a signal addressed to the caller's own bus connection, so a separate `gdbus monitor` never receives it (measured: 15 calls, 15 timeouts, while the portal did open what it was given) and the readback only cost the app 3 s per click before giving up. Only whether the call went out is trusted, so a machine without a portal needs no special case any more: the call fails, and the request file is what still lands. (0.5.x had the host's own script parked inside the sandbox as the shim's fallback; measured, that route opens nothing.) - A file handed over (a shortcut's `--file %f`, a drop on it) is the one path the sandbox cannot see by itself: a file out of the user's home is buried under the prefix home, one out of `/tmp` sits behind the private tmpfs, and the rest of the machine is read-only. Per-file `--bind` (what 0.5.0 did) is the wrong tool: a mount cannot be added to a namespace that is ALREADY running, so a hand-off into a running app could never be served that way. `handedPathFor` instead names the document in the one shape the chosen level can reach, and the app is told nothing else: `none` → the real path; `relaxed` → `FS_ROOT` + the real path; `isolated` → a copy in `{prefix}/linux_pfx/home/plwl-handoff/`, which is a live bind of the app's home, so it reaches a running app too (measured end to end in test/linux-e2e.js: both levels hand a document to a sandbox that is already up). Copies are one per name and refreshed on every open (the folder cannot grow), written through a `.part` name and renamed so nothing ever reads a half-written document, and the app's edits stay in the copy — that IS the level's promise. A handed path that is gone fails the hand-off instead of launching an app with no document. - `FS_ROOT` (`/tmp/plwl-fs-root`) is where relaxed mounts the host filesystem, and it has to live under the private `/tmp`: bubblewrap creates the mount point itself, and the root of a read-only root cannot be written to (measured on 0.12.0: `Can't create file /PLWL_FS_ROOT: Read-only file system`, for a `--dir` before the `--ro-bind / /` too). Measured also: through it the real `~` reads, a write lands on the real file, and a file created AFTER a sandboxed run started is visible inside it — that last one is what makes a hand-off into a running app possible at all. - Not built: a private or overlay `/etc` (`buildBwrapArgs` accepts `etcRoot`, nothing sets it; `--ro-bind / /` leaves `/etc` read-only, which is all we promise today), `--tmpfs /home` against OTHER users' homes (`hideHome` is plumbed, no UI) and `dataMode` — app data always lands in `{prefix}/linux_pfx`. @@ -140,6 +144,7 @@ node test/linux-sandbox.test.js # bwrap argv profile + the four levels, node test/linux-env.test.js # redirected HOME/XDG_* for both backends node test/linux-import.test.js # ELF/AppImage/.desktop detection + icons node test/linux-links.test.js # link opening: shim binds + mime seed +node test/linux-open.test.js # what an app opens: the script + the launcher relay node test/linux-sync.test.js # linux_pfx stays out of a sync node test/linux-e2e.js # REAL runs: plain, relaxed (+ its mount), isolated, hand-off into a running app npm start # click through the flow you touched diff --git a/files/translations/en-us.json b/files/translations/en-us.json index 9f038f7..f3a5598 100644 --- a/files/translations/en-us.json +++ b/files/translations/en-us.json @@ -125,10 +125,10 @@ "sandbox.warnTitle": "No sandbox for Linux apps", "sandbox.warnBody": "bubblewrap is {0}. Apps left on Automatic run as plain programs (None), while apps set to Relaxed or Isolated refuse to start instead of pretending the sandbox is there.", "sandbox.dismiss": "Got it", - "sandbox.badgeNone": "no isolation", - "sandbox.badgeRelaxed": "relaxed", - "sandbox.badgeIsolated": "isolated", - "sandbox.badgeRequired": "bwrap required", + "sandbox.badgeNone": "No isolation", + "sandbox.badgeRelaxed": "Relaxed", + "sandbox.badgeIsolated": "Isolated", + "sandbox.badgeRequired": "Bwrap required", "handoff.copying": "Copying into the app: {0}", "handoff.copyNote": "{0} / {1} - the app works on a copy inside its own home folder (plwl-handoff); edits in the copy never come back.", "handoff.failed": "Could not copy {0} into the app: {1}", diff --git a/files/translations/pl.json b/files/translations/pl.json index e12374e..6447804 100644 --- a/files/translations/pl.json +++ b/files/translations/pl.json @@ -125,10 +125,10 @@ "sandbox.warnTitle": "Brak sandboxa dla aplikacji Linux", "sandbox.warnBody": "bubblewrap {0}. Aplikacje ustawione na Automatyczną uruchomią się jak zwykłe programy (None), a te ustawione jawnie na Relaxed albo Isolated odmówią startu, zamiast udawać, że sandbox działa.", "sandbox.dismiss": "Rozumiem", - "sandbox.badgeNone": "bez izolacji", - "sandbox.badgeRelaxed": "relaxed", - "sandbox.badgeIsolated": "isolated", - "sandbox.badgeRequired": "wymaga bwrap", + "sandbox.badgeNone": "Bez izolacji", + "sandbox.badgeRelaxed": "Relaxed", + "sandbox.badgeIsolated": "Isolated", + "sandbox.badgeRequired": "Wymaga bwrap", "handoff.copying": "Kopiowanie do aplikacji: {0}", "handoff.copyNote": "{0} / {1} - aplikacja pracuje na kopii w swoim folderze domowym (plwl-handoff); zmiany w kopii nie wracają do oryginału.", "handoff.failed": "Nie udało się skopiować {0} do aplikacji: {1}", diff --git a/src/ipc/games.js b/src/ipc/games.js index cea44f1..0ac0576 100644 --- a/src/ipc/games.js +++ b/src/ipc/games.js @@ -3,6 +3,7 @@ const { runGame, runTool } = require('../runner') const { runLinuxGame } = require('../linuxrunner') const { importGame, importGameFromPrefix } = require('../importer') +const { prefixLinuxRoot } = require('../prefixes') const library = require('../library') function killProcessGroup(pid) { @@ -23,7 +24,7 @@ function sendToWindow(win, channel, data) { // onGamesChanged fires in the main process when the running set changes. const STARTUP_GRACE_MS = 60000 -function createGamesService({ getWindow, onGamesChanged, sandbox, onHandoffProgress }) { +function createGamesService({ getWindow, onGamesChanged, sandbox, onHandoffProgress, openRelay }) { const running = new Map() const startedAt = new Map() const killRequests = new Set() @@ -50,6 +51,7 @@ function createGamesService({ getWindow, onGamesChanged, sandbox, onHandoffProgr if (!entry.pid && age < STARTUP_GRACE_MS) continue // may still be starting running.delete(id) startedAt.delete(id) + if (entry.openRoot && openRelay) openRelay.unwatch(entry.openRoot) } } @@ -85,8 +87,10 @@ function createGamesService({ getWindow, onGamesChanged, sandbox, onHandoffProgr } function handleExit(id) { + const entry = running.get(id) running.delete(id) startedAt.delete(id) + if (entry && entry.openRoot && openRelay) openRelay.unwatch(entry.openRoot) sendToWindow(getWindow(), 'games:changed') notifyChanged() if (onGameExit) onGameExit(id) @@ -98,6 +102,11 @@ function createGamesService({ getWindow, onGamesChanged, sandbox, onHandoffProgr return payload => runLinuxGame({ game, sandbox, ...payload }) } + // where a native app leaves what it wants opened on the host (openrelay.js) + function openRootFor(game) { + return library.isLinuxGame(game) ? prefixLinuxRoot(library.prefixNameForGame(game)) : '' + } + // An isolated hand-off copies the document before the app starts, which is // long enough to be worth reporting. The window may not exist yet (a // shortcut boots headless), so the main process gets the event first and @@ -131,7 +140,11 @@ function createGamesService({ getWindow, onGamesChanged, sandbox, onHandoffProgr return { ok: false, error: String((err && err.message) || err) } } } - running.set(id, { pid: null, prefix: library.prefixNameForGame(game), wine: wine ? wine.id : '' }) + const openRoot = openRootFor(game) + running.set(id, { pid: null, prefix: library.prefixNameForGame(game), wine: wine ? wine.id : '', openRoot }) + // the app may ask for a folder the moment it paints its window, so the + // request file is watched before the run starts + if (openRoot && openRelay) openRelay.watch(openRoot) startedAt.set(id, Date.now()) try { const res = await run({ onExit: () => handleExit(id), openFile, onProgress: handoffProgress }) diff --git a/src/ipc/index.js b/src/ipc/index.js index e97ef53..3a7fe02 100644 --- a/src/ipc/index.js +++ b/src/ipc/index.js @@ -51,10 +51,10 @@ function extractGameIcon(id, exePath, kind) { // --- registration ----------------------------------------------------------- -function registerIpc({ getWindow, onGamesChanged, onHandoffProgress }) { +function registerIpc({ getWindow, onGamesChanged, onHandoffProgress, openRelay }) { // one probe per launcher, cached: it decides bwrap vs plain run const sandbox = createSandboxProbe() - const games = createGamesService({ getWindow, onGamesChanged, sandbox, onHandoffProgress }) + const games = createGamesService({ getWindow, onGamesChanged, sandbox, onHandoffProgress, openRelay }) // sandbox -------------------------------------------------------------------- ipcMain.handle('sandbox:status', async () => sandbox.status() || (await sandbox.probe())) diff --git a/src/linuxrunner.js b/src/linuxrunner.js index d970568..d0c40c4 100644 --- a/src/linuxrunner.js +++ b/src/linuxrunner.js @@ -14,6 +14,7 @@ const { prefixNameForGame, loadSettings } = require('./library') const i18n = require('./i18n') const { spawnTracked, parseArgs } = require('./runner') const { buildBwrapArgs, SHARE_ROOT, FS_ROOT } = require('./sandbox') +const { PLWL_DIR, requestPath } = require('./openrelay') const { copyFileProgress } = require('./fsutil') const DATA_DIRS = ['home', 'config', 'data', 'cache', 'state'] @@ -112,13 +113,10 @@ const DATA_SHARES = ['fonts', 'icons', 'themes', 'color-schemes'] // portal is the only route that ends in the browser the user actually chose. const PORTAL_BUS = 'org.freedesktop.portal.Desktop' const PORTAL_METHOD = 'org.freedesktop.portal.OpenURI.OpenURI' -const PORTAL_BINS = ['/usr/libexec/xdg-desktop-portal', '/usr/lib/xdg-desktop-portal', '/usr/lib64/xdg-desktop-portal'] -const PORTAL_DIRS = ['/usr/share/xdg-desktop-portal/portals', '/usr/local/share/xdg-desktop-portal/portals'] const OPEN_BINS = ['/usr/bin/xdg-open', '/bin/xdg-open', '/usr/local/bin/xdg-open'] // Launcher-owned files, kept in the redirected home: the app never edits them, // and each is rewritten whenever it differs. The prefix root is not mounted in // the sandbox, so nothing a link needs may live there. -const PLWL_DIR = '.plwl' const SHIM_NAME = 'xdg-open' const OPENER_NAME = 'open-url' const OPENER_DESKTOP = 'plwl-url-opener.desktop' @@ -126,9 +124,9 @@ const SHIM_REL = path.join('home', PLWL_DIR, SHIM_NAME) const OPENER_REL = path.join('home', PLWL_DIR, OPENER_NAME) const MIME_REL = path.join('config', 'mimeapps.list') const DESKTOP_REL = path.join('data', 'applications', OPENER_DESKTOP) -// What a link in an app resolves to. Nothing else about the app's file types -// is touched: the rest of the MIME lists stay the system's. -const LINK_TYPES = ['x-scheme-handler/http', 'x-scheme-handler/https', 'text/html'] +// What an opened thing in an app resolves to. Nothing else about the app's +// file types is touched: the rest of the MIME lists stay the system's. +const OPEN_TYPES = ['x-scheme-handler/http', 'x-scheme-handler/https', 'text/html', 'x-scheme-handler/file', 'inode/directory'] function hostShareDir() { return path.join(os.homedir(), '.local', 'share') @@ -138,37 +136,6 @@ function dataShares() { return DATA_SHARES.map(name => path.join(hostShareDir(), name)).filter(dir => fs.existsSync(dir)) } -// The session bus address names the socket, and a stale address is how the -// portal "disappears" after a session restart. -function busSocket(address) { - for (const part of String(address || '').split(';')) { - const match = part.match(/^unix:path=([^,]+)/) - if (match) return match[1] - } - return '' -} - -function portalDescribed() { - return PORTAL_DIRS.some(dir => { - try { - return fs.readdirSync(dir).some(file => file.endsWith('.portal')) - } catch { - return false - } - }) -} - -// Sending a URI to a portal that is not there fails hard, it does not fall -// back (measured: GIO answers "Failed to create OpenURI proxy"), so the check -// stays on this side of the sandbox. -function portalReady() { - const bus = process.env.DBUS_SESSION_BUS_ADDRESS - if (!bus) return false - const socket = busSocket(bus) - if (socket && !fs.existsSync(socket)) return false - return PORTAL_BINS.some(bin => fs.existsSync(bin)) || portalDescribed() -} - function readText(file) { try { return fs.readFileSync(file, 'utf8') @@ -188,45 +155,70 @@ function writeIfChanged(file, text, mode) { } } -// The one shell snippet both hand-offs need: give the URI to the host portal. -function portalOpen(uri) { - return [ - `gdbus call --session --dest ${PORTAL_BUS} \\`, - ' --object-path /org/freedesktop/portal/desktop \\', - ` --method ${PORTAL_METHOD} "" ${uri} "{}"` - ].join('\n') +// The one script both hand-offs run: it takes xdg-open's place inside a +// sandbox and it is what the seeded desktop entry starts. A URI with a scheme +// belongs to the host's portal, which also puts up its own "choose an +// application" when nothing claims that scheme; everything else - every local +// document, folder or plain path - is left in the request file for the +// launcher (see openrelay.js), with the path rewritten first, because the +// private home and the whole-disk mount are the two names the host answers to +// differently. Nothing in here starts a host program itself: measured, the +// host's own xdg-open *inside* the sandbox ends in kde-open, which exits 0 and +// opens no window at all. The portal's answer is not read back either: it is a +// signal addressed to the caller's own bus connection, so a separate monitor +// never receives it (measured: 15 calls, 15 timeouts, while the portal did +// open what it was given) - only whether the call itself went out can be used. +function handOffScript({ hostHome, fsRoot, request }) { + const cases = [ + `file://"$HOME") arg="file://${hostHome}" ;;`, + `file://"$HOME"/*) arg="file://${hostHome}\${arg#file://"$HOME"}" ;;`, + ...(fsRoot + ? [ + `file://${FS_ROOT}) arg="file:///" ;;`, + `file://${FS_ROOT}/*) arg="file:///\${arg#file://${FS_ROOT}/}" ;;` + ] + : []), + `"$HOME") arg="${hostHome}" ;;`, + `"$HOME"/*) arg="${hostHome}\${arg#$HOME}" ;;`, + ...(fsRoot ? [`${FS_ROOT}) arg="/" ;;`, `${FS_ROOT}/*) arg="/\${arg#${FS_ROOT}/}" ;;`] : []) + ].join('\n ') + return `#!/bin/sh +# Written by PLWL: what an app opens belongs to the host desktop. +arg="$1" +[ -n "$arg" ] || exit 1 + +# the portal owns a link from here on, chooser and all; only a call that does +# not go out at all (no portal on this machine) falls back to the launcher +open_portal() { + command -v gdbus >/dev/null 2>&1 || return 1 + gdbus call --session --dest ${PORTAL_BUS} \\ + --object-path /org/freedesktop/portal/desktop \\ + --method ${PORTAL_METHOD} "" "$1" "{}" >/dev/null 2>&1 } -// The stand-in for xdg-open inside the sandbox. The host's own script is -// mounted at the fallback path, so what this cannot hand over - a plain path, -// or a portal that stopped answering - behaves exactly as it did before. -function writeUrlShim(root) { - const script = `#!/bin/sh -# Written by PLWL: a link belongs to the host desktop, so it is opened there. -case "$1" in - *://*|mailto:*) - if command -v gdbus >/dev/null 2>&1; then - ${portalOpen('"$1"')} >/dev/null 2>&1 && exit 0 - fi - ;; +# a local document is not the portal's business - it cannot see this path +case "$arg" in + file:*) ;; + *://*|mailto:*) open_portal "$arg" && exit 0 ;; esac -exec ${path.join(SHARE_ROOT, SHIM_NAME)} "$@" + +case "$arg" in + ${cases} +esac + +printf '%s\\n' "$arg" > "${request}.part" && mv "${request}.part" "${request}" || exit 1 ` - return writeIfChanged(path.join(root, SHIM_REL), script, 0o755) } -// What the seeded desktop entry runs: the portal hand-off, and nothing else - -// a browser started in here would be the wrong one, with an empty profile -// growing inside the prefix. -function writeUrlOpener(root) { - const script = `#!/bin/sh -# Written by PLWL: hand the link to the host desktop. -if command -v gdbus >/dev/null 2>&1; then - ${portalOpen('"$1"')} >/dev/null 2>&1 && exit 0 -fi -exit 1 -` - return writeIfChanged(path.join(root, OPENER_REL), script, 0o755) +// The stand-in for xdg-open inside the sandbox. +function writeUrlShim(root, opts) { + return writeIfChanged(path.join(root, SHIM_REL), handOffScript(opts), 0o755) +} + +// What the seeded desktop entry runs. Same hand-off: a browser started in here +// would be the wrong one, with an empty profile growing inside the prefix. +function writeUrlOpener(root, opts) { + return writeIfChanged(path.join(root, OPENER_REL), handOffScript(opts), 0o755) } // The entry's display name. A translation that is not loaded answers with its @@ -239,35 +231,38 @@ function linkOpenerName() { // GIO resolves an URI in-process (gtk_show_uri, `gio open`) against the MIME // lists, and the host's default browser is not among the choices in here: it // would open a system browser, or none at all. Naming our opener as the -// default for the link types leaves that resolution alone and changes only its -// answer. 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). +// default for exactly these types leaves that resolution alone and changes +// only its answer - including the two a local document comes in as (measured: +// Qt hands xdg-open a file:// URI, GIO resolves a folder by inode/directory). +// The portal itself is not reachable this way - GIO looks for a sandbox marker +// this launcher does not fake (measured: GIO_USE_PORTALS opens nothing). function writeMimeSeed(root, home) { const desktop = [ '[Desktop Entry]', 'Type=Application', `Name=${linkOpenerName()}`, `Exec="${path.join(home, PLWL_DIR, OPENER_NAME)}" %u`, - `MimeType=${LINK_TYPES.join(';')};`, + `MimeType=${OPEN_TYPES.join(';')};`, 'NoDisplay=true', 'Terminal=false', '' ].join('\n') writeIfChanged(path.join(root, DESKTOP_REL), desktop, 0o644) - const mime = ['[Default Applications]', ...LINK_TYPES.map(type => `${type}=${OPENER_DESKTOP};`), ''].join('\n') + const mime = ['[Default Applications]', ...OPEN_TYPES.map(type => `${type}=${OPENER_DESKTOP};`), ''].join('\n') return writeIfChanged(path.join(root, MIME_REL), mime, 0o644) } -// Everything a link needs: the portal hand-off, the association naming it as -// the default, and - in a sandbox - xdg-open replaced by the same hand-off. -// Nothing is done on a machine without a portal: a URI sent to a portal that -// is not there fails hard, it does not fall back. -function urlRouting({ root, home, sandboxed }) { - if (!portalReady() || !writeUrlOpener(root)) return null +// Everything an opened thing needs: the hand-off script, the association +// naming it as the default, and - in a sandbox - xdg-open replaced by the same +// script. A machine without a portal needs no special case: the script's call +// is what fails there, and the request file is what still lands. +function urlRouting({ root, home, sandboxed, fsRoot = false }) { + const target = OPEN_BINS.find(bin => fs.existsSync(bin)) || '' + const opts = { hostHome: path.join(root, 'home'), fsRoot, request: requestPath(home) } + if (!writeUrlOpener(root, opts)) return null writeMimeSeed(root, home) - const target = OPEN_BINS.find(bin => fs.existsSync(bin)) - const shim = sandboxed && target ? writeUrlShim(root) : '' - return { target: target || '', shim, real: target || '' } + const shim = sandboxed && target ? writeUrlShim(root, opts) : '' + return { target, shim } } function hashOf(file) { @@ -398,7 +393,7 @@ function withHostShare(env, entries) { // HOME and the XDG folders point at the prefix (plain run) or at the bind // targets inside the sandbox - the two must agree or the app writes outside. -function buildLinuxEnv({ game, backend = 'env' }) { +function buildLinuxEnv({ game, backend = 'env', fsRoot = false }) { const prefixName = prefixNameForGame(game) const root = ensureLinuxPfx(prefixName) syncDesktopConfig(root) @@ -420,9 +415,9 @@ function buildLinuxEnv({ game, backend = 'env' }) { if (game.forceX11) Object.assign(env, X11_ENV) // the app's own environment comes last: it overrides everything above for (const [k, v] of Object.entries(game.env || {})) env[k] = v - // a clicked link must leave through the host's portal; `home` is where the - // seeded desktop entry has to find the opener, so it is resolved first - return { env, root, home, prefixName, prefixDir: prefixPath(prefixName), routing: urlRouting({ root, home, sandboxed }) } + // an opened link or document must leave for the host desktop; `home` is where + // the seeded desktop entry has to find the opener, so it is resolved first + return { env, root, home, prefixName, prefixDir: prefixPath(prefixName), routing: urlRouting({ root, home, sandboxed, fsRoot }) } } // Where a handed document goes inside the app's own home. The prefix home is @@ -542,7 +537,7 @@ 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 }) + const { env, root, home, 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) @@ -570,8 +565,6 @@ module.exports = { OPENER_REL, MIME_REL, DESKTOP_REL, - busSocket, - portalReady, writeUrlShim, writeUrlOpener, writeMimeSeed, diff --git a/src/main.js b/src/main.js index 44d0940..ebdd130 100644 --- a/src/main.js +++ b/src/main.js @@ -9,6 +9,7 @@ const { createMainWindow } = require('./window') const { setAppIcon } = require('./appicon') const { parseArgv } = require('./argv') const openwith = require('./openwith') +const { createOpenRelay } = require('./openrelay') const lansync = require('./lansync') const path = require('path') const fs = require('fs') @@ -235,7 +236,10 @@ app.whenReady().then(async () => { ensureDirs() await i18n.init() await wines.refreshWines() - const { games } = registerIpc({ getWindow, onGamesChanged: rebuildTrayMenu, onHandoffProgress }) + // a folder or document a sandboxed app asked for is opened here, by the + // host's own xdg-open (see openrelay.js) + const openRelay = createOpenRelay({ onError: (err, target) => trace(`openRelay ${target}: ${err.message}`) }) + const { games } = registerIpc({ getWindow, onGamesChanged: rebuildTrayMenu, onHandoffProgress, openRelay }) gamesService = games // the tray comes first: nothing may keep it from coming up diff --git a/src/openrelay.js b/src/openrelay.js new file mode 100644 index 0000000..0a17e59 --- /dev/null +++ b/src/openrelay.js @@ -0,0 +1,85 @@ +'use strict' + +// What a sandboxed app asks to open: a folder, a document, a link the portal +// refused. The app cannot start anything out here itself - measured, the host's +// own xdg-open *inside* the sandbox ends in kde-open, which exits 0 and opens no +// window - so the hand-off script leaves the (already host-rewritten) path in a +// request file inside the app's home, which is a live bind, and the launcher +// opens it here with the host's own xdg-open. + +const fs = require('fs') +const path = require('path') +const { spawn } = require('child_process') + +// The hand-off protocol, in one place: the folder PLWL owns inside an app's +// home (the shim and the opener are written there too) and the file the +// launcher watches. +const PLWL_DIR = '.plwl' +const REQUEST_NAME = 'open-request' +const DRAIN_DEBOUNCE_MS = 60 + +// `homeDir` is that home as the host sees it - the prefix's own `home`, which +// is a live bind, so what the app writes is here the moment it lands. +const requestPath = homeDir => path.join(homeDir, PLWL_DIR, REQUEST_NAME) +const requestFile = root => requestPath(path.join(root, 'home')) + +// detached: the opened program is the desktop's child, not the launcher's - it +// must outlive a launcher that quits to the tray and never hold it open +function openOnHost(target) { + spawn('xdg-open', [target], { detached: true, stdio: 'ignore' }).unref() +} + +function createOpenRelay({ open = openOnHost, onError = null } = {}) { + const watchers = new Map() + + // the request is consumed before it is opened: a second click while the first + // program starts writes a fresh file, so nothing is opened twice and nothing + // is dropped + function drain(root) { + let target = '' + try { + target = fs.readFileSync(requestFile(root), 'utf8').trim().split('\n')[0] + fs.rmSync(requestFile(root), { force: true }) + } catch { + return + } + if (!target) return + try { + open(target) + } catch (err) { + if (onError) onError(err, target) + } + } + + // watch from the moment the app starts: a request written before the watcher + // was up (the app's first window) must not be missed, so drain once first + function watch(root) { + if (!root || watchers.has(root)) return + const file = requestFile(root) + // best effort: a missing folder only means nothing was ever asked for + try { + fs.mkdirSync(path.dirname(file), { recursive: true }) + } catch {} + let timer = null + const watcher = fs.watch(path.dirname(file), () => { + clearTimeout(timer) + timer = setTimeout(() => drain(root), DRAIN_DEBOUNCE_MS) + }) + watcher.on('error', () => unwatch(root)) + watchers.set(root, watcher) + drain(root) + } + + function unwatch(root) { + const watcher = watchers.get(root) + if (!watcher) return + try { + watcher.close() + } catch {} + watchers.delete(root) + } + + return { watch, unwatch, drain, requestFile } +} + +module.exports = { createOpenRelay, PLWL_DIR, REQUEST_NAME, requestFile, requestPath } diff --git a/src/sandbox.js b/src/sandbox.js index 58bea0b..6216847 100644 --- a/src/sandbox.js +++ b/src/sandbox.js @@ -149,12 +149,10 @@ function buildBwrapArgs({ ...(fsRoot ? ['--bind', '/', FS_ROOT] : []), // the host's theme data, read-only: fonts, icons, colour schemes ...shares.flatMap(dir => ['--ro-bind', dir, path.join(SHARE_ROOT, path.basename(dir))]), - // a link clicked in here belongs to the host desktop: xdg-open is replaced - // by a shim that hands the URI to the host's portal, and the host's own - // script stays mounted aside as what that shim falls back to - ...(urlOpener && urlOpener.shim - ? ['--ro-bind', urlOpener.real, path.join(SHARE_ROOT, 'xdg-open'), '--ro-bind', urlOpener.shim, urlOpener.target] - : []), + // an opened link or document belongs to the host desktop: xdg-open is + // replaced by the launcher's hand-off script (the portal for a link, the + // launcher itself for a document - see linuxrunner.js) + ...(urlOpener && urlOpener.shim ? ['--ro-bind', urlOpener.shim, urlOpener.target] : []), // hard profile: nothing of anybody's /home is even readable ...(hideHome ? ['--tmpfs', '/home', '--tmpfs', '/root', '--tmpfs', '/mnt', '--tmpfs', '/media'] : []), // the app's own folder stays writable (portable apps keep logs/data next