From 2ff8920225e70fd14e9167355e9251eb11b94604 Mon Sep 17 00:00:00 2001 From: olcxja Date: Wed, 7 Oct 2026 00:57:55 +0200 Subject: [PATCH] support opening links from linux apps --- README.md | 2 + development.md | 2 + files/translations/en-us.json | 1 + files/translations/pl.json | 1 + src/linuxrunner.js | 177 +++++++++++++++++++++++++++++++++- src/sandbox.js | 9 +- 6 files changed, 187 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 21084ec..f18276c 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,8 @@ 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). + Every prefix in the **Wine prefixes** tab has a **Reset Linux config** button: it deletes the desktop preferences PLWL copied in (and the hashes that track them), so the next app start seeds a clean copy from your system — the way back when an app has hijacked a seeded file with its own theme. App data in `linux_pfx` is never touched, and an app running from that prefix blocks the reset. Imported trees get **normalised permissions**: every file gets the owner's `rwx` and an `x` mirrored from its `r`, because sources come from archives, FAT sticks or read-only backups where those bits are simply not there. diff --git a/development.md b/development.md index f464a91..45bb67d 100644 --- a/development.md +++ b/development.md @@ -84,6 +84,7 @@ 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). - The user's real `~` is deliberately invisible in the sandbox (HOME is bound straight to `{prefix}/linux_pfx/home`), so absolute paths an app wrote into its config before the import (Prism's instance `JavaPath`) do not resolve — that is what `isolation: 'off'` is for. +- 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). - Not built: a private or overlay `/etc` (`buildBwrapArgs` accepts `etcRoot`, nothing sets it; `--ro-bind / /` leaves `/etc` read-only, which is all we promise today), `--tmpfs /home` against OTHER users' homes (`hideHome` is plumbed, no UI) and `dataMode` — app data always lands in `{prefix}/linux_pfx`. - Measured, not assumed (Debian 13, kernel 6.12, `bwrap` 0.12.0): the functional probe passes here, no firejail/proot/fuse-overlayfs on the box, `/etc` is 16 MB and its `machine-id` is read-only even on the host. @@ -132,6 +133,7 @@ node test/bench-sync.js # timing: cold vs cached sync node test/linux-sandbox.test.js # bwrap argv profile + auto/off/require choice node test/linux-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-sync.test.js # linux_pfx stays out of a sync node test/linux-e2e.js # REAL run: plain + bwrap (/etc read-only, home in prefix) npm start # click through the flow you touched diff --git a/files/translations/en-us.json b/files/translations/en-us.json index bac6a51..09be67c 100644 --- a/files/translations/en-us.json +++ b/files/translations/en-us.json @@ -125,6 +125,7 @@ "sandbox.badgeOff": "isolation off", "sandbox.badgeRequired": "bwrap required", "sandbox.required": "This app needs a working bubblewrap sandbox: {0}", + "sandbox.linkOpener": "PLWL link opener", "config.save": "Save changes", "config.shortcut": "Create shortcut", "config.shortcutCreated": "Shortcut for {0} created (menu + desktop)", diff --git a/files/translations/pl.json b/files/translations/pl.json index 470e7c4..a3a5e7d 100644 --- a/files/translations/pl.json +++ b/files/translations/pl.json @@ -125,6 +125,7 @@ "sandbox.badgeOff": "izolacja wyłączona", "sandbox.badgeRequired": "wymaga bwrap", "sandbox.required": "Ta aplikacja wymaga działającego sandboxa bubblewrap: {0}", + "sandbox.linkOpener": "PLWL - otwieranie linków", "config.save": "Zapisz zmiany", "config.shortcut": "Utwórz skrót", "config.shortcutCreated": "Skrót dla {0} utworzony (menu + pulpit)", diff --git a/src/linuxrunner.js b/src/linuxrunner.js index cc401fe..daf1571 100644 --- a/src/linuxrunner.js +++ b/src/linuxrunner.js @@ -10,7 +10,8 @@ const os = require('os') const path = require('path') const { DIRS, readJson, writeJson } = require('./paths') const { prefixPath, prefixLinuxRoot } = require('./prefixes') -const { prefixNameForGame } = require('./library') +const { prefixNameForGame, loadSettings } = require('./library') +const i18n = require('./i18n') const { spawnTracked, parseArgs } = require('./runner') const { buildBwrapArgs, SHARE_ROOT } = require('./sandbox') @@ -104,6 +105,30 @@ const DESKTOP_SEEDS = [ // host without an import. const DATA_SHARES = ['fonts', 'icons', 'themes', 'color-schemes'] +// A clicked link is the host desktop's business: in here there is no default +// browser at all, because the association (mimeapps.list) and the .desktop +// file it names live in the host's own home. Handing the URI to the host's +// portal is the only route that ends in the browser the user actually chose. +const PORTAL_BUS = 'org.freedesktop.portal.Desktop' +const PORTAL_METHOD = 'org.freedesktop.portal.OpenURI.OpenURI' +const 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' +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'] + function hostShareDir() { return path.join(os.homedir(), '.local', 'share') } @@ -112,6 +137,138 @@ 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') + } catch { + return '' + } +} + +function writeIfChanged(file, text, mode) { + try { + fs.mkdirSync(path.dirname(file), { recursive: true }) + if (readText(file) !== text) fs.writeFileSync(file, text) + fs.chmodSync(file, mode) + return file + } catch { + return '' + } +} + +// The one 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 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 + ;; +esac +exec ${path.join(SHARE_ROOT, SHIM_NAME)} "$@" +` + 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 entry's display name. A translation that is not loaded answers with its +// own key, and that would end up in a chooser dialog, so it is never used. +function linkOpenerName() { + const text = i18n.translate(loadSettings().language, 'sandbox.linkOpener') + return text === 'sandbox.linkOpener' ? 'PLWL' : text +} + +// GIO resolves an URI in-process (gtk_show_uri, `gio open`) against the MIME +// lists, and the host's default browser is not among the choices in here: it +// would open a system browser, or none at all. Naming our opener as the +// default for 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). +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(';')};`, + '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') + 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 + writeMimeSeed(root, home) + const target = OPEN_BINS.find(bin => fs.existsSync(bin)) + const shim = sandboxed && target ? writeUrlShim(root) : '' + return { target: target || '', shim, real: target || '' } +} + function hashOf(file) { try { return crypto.createHash('sha1').update(fs.readFileSync(file)).digest('hex') @@ -262,7 +419,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 - return { env, root, home, prefixName, prefixDir: prefixPath(prefixName) } + // 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 }) } } // bwrap when it works, plain run otherwise - unless the app demands a sandbox. @@ -313,7 +472,7 @@ async function runLinuxGame({ game, sandbox, onExit, openFile, handoff = false } const argList = parseArgs(game.args) if (openFile) argList.push(linuxPathFor(openFile)) - const { env, root } = buildLinuxEnv({ game, backend: chosen.backend }) + const { env, root, routing } = buildLinuxEnv({ game, backend: chosen.backend }) const header = [isolationLine(chosen)] if (game.forceX11) header.push('[plwl] x11 forced (XWayland)') @@ -322,7 +481,7 @@ async function runLinuxGame({ game, sandbox, onExit, openFile, handoff = false } if (chosen.backend === 'bwrap') { const source = bwrapSource() cmd = 'bwrap' - args = [...buildBwrapArgs({ dataRoot: root, user: source.user, devices: source.devices, x11: source.x11, runUser: source.runUser, shares: source.shares, appDir: writableAppDir(game) }), '--', exe, ...argList] + args = [...buildBwrapArgs({ dataRoot: root, user: source.user, devices: source.devices, x11: source.x11, runUser: source.runUser, shares: source.shares, appDir: writableAppDir(game), urlOpener: routing }), '--', exe, ...argList] header.push(`[plwl] HOME=${env.HOME}`) } @@ -336,6 +495,16 @@ module.exports = { DESKTOP_SEEDS, DATA_SHARES, X11_ENV, + SHIM_REL, + OPENER_REL, + MIME_REL, + DESKTOP_REL, + busSocket, + portalReady, + writeUrlShim, + writeUrlOpener, + writeMimeSeed, + urlRouting, writableAppDir, ensureLinuxPfx, resetSeededConfig, diff --git a/src/sandbox.js b/src/sandbox.js index 44644ca..bfc990e 100644 --- a/src/sandbox.js +++ b/src/sandbox.js @@ -114,7 +114,8 @@ function buildBwrapArgs({ hideHome = false, etcRoot = '', unshareNet = false, - appDir = '' + appDir = '', + urlOpener = null }) { const xdgDir = sub => path.join(home, sub) return [ @@ -134,6 +135,12 @@ function buildBwrapArgs({ ...(x11 ? ['--ro-bind', x11, x11] : []), // 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] + : []), // 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