support opening links from linux apps

This commit is contained in:
olcxja 2026-10-07 00:57:55 +02:00
commit 2ff8920225
6 changed files with 187 additions and 5 deletions

View file

@ -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.

View file

@ -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/<app>/`, 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

View file

@ -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)",

View file

@ -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)",

View file

@ -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,

View file

@ -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