try to improve link handling

This commit is contained in:
olcxja 2026-10-07 01:46:26 +02:00
commit 3f57682a9b
5 changed files with 30 additions and 14 deletions

View file

@ -60,7 +60,7 @@ Per app you choose the **Isolation** policy: *Automatic* (bwrap when it works, p
## "Open with PLWL" ## "Open with PLWL"
The desktop entry registers `.exe/.msi/.bat/.cmd/.lnk` in file managers. Opening one shows a popup to pick the **Wine/Proton build** and **prefix** for that run (defaults preselected, nothing imported). A second instance hands the path to the running one. App shortcuts work as **Open With** targets too: the app receives the file as the Windows path it would get on Windows — `C:\...` inside its prefix, `Z:\...` otherwise. A file handed to an already running app goes to that instance instead of a second tracked run. The desktop entry registers `.exe/.msi/.bat/.cmd/.lnk` in file managers. Opening one shows a popup to pick the **Wine/Proton build** and **prefix** for that run (defaults preselected, nothing imported). A second instance hands the path to the running one. App shortcuts work as **Open With** targets too: the app receives the file as the Windows path it would get on Windows — `C:\...` inside its prefix, `Z:\...` otherwise. A file handed to an already running app goes to that instance instead of a second tracked run. A **Linux app shortcut** is an Open With target the same way: the app gets the plain absolute path, and that one file is mounted read-write inside its sandbox — the deliberate exception to "only its home and its app folder are writable", because it is the document you chose to hand over.
## LAN syncing ## LAN syncing

View file

@ -85,6 +85,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).
- 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. - 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). - 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 file handed over (a shortcut's `--file %f`, a drop on it) is the ONE path the sandbox cannot see by itself, and all three reasons are real: 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. `handedFileBinds` returns it for `--bind` at the same path, placed AFTER the app data binds so it wins over the home mounted there, and writable — the way Wine hands a file over as `Z:\…` and may save it (parity with the Wine side is the point: same shortcut, same outcome). Prefix data is skipped, being the app's own writable home already; a folder and a path that is gone are skipped too, since a missing bind source makes bwrap refuse the launch. The argv is untouched: `linuxPathFor` still passes the host path to the app. Only a *new* run gets the mount — a handoff into an app already running in its own namespace cannot, and the app reports that itself.
- 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`. - 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. - 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.

View file

@ -252,13 +252,8 @@ function findIconFile(spec, baseDir) {
} }
module.exports = { module.exports = {
ELF_MAGIC,
isElfBuffer,
isElfSync,
findLinuxExe, findLinuxExe,
parseDesktopEntry,
readDesktopEntry, readDesktopEntry,
desktopExecPath,
desktopFileFor, desktopFileFor,
findIconFile, findIconFile,
pngOrSvg pngOrSvg

View file

@ -450,6 +450,26 @@ function linuxPathFor(file) {
return abs return abs
} }
// The document a shortcut was handed. The sandbox cannot hand it over 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. So it is mounted where the app was told it is, writable - the way
// a Wine app gets it as Z:\... and may save it. Prefix data needs nothing: it
// already is the app's own writable home.
function handedFileBinds({ openFile, root }) {
if (!openFile) return []
let abs = ''
try {
abs = fs.realpathSync(String(openFile))
if (!fs.statSync(abs).isFile()) return []
} catch {
// a path that is gone must not become a bind bwrap would refuse
return []
}
if (abs === root || abs.startsWith(root + path.sep)) return []
return [abs]
}
function bwrapSource() { function bwrapSource() {
const devices = ['/dev/shm', '/dev/dri', '/dev/snd', '/dev/input'].filter(d => fs.existsSync(d)) const devices = ['/dev/shm', '/dev/dri', '/dev/snd', '/dev/input'].filter(d => fs.existsSync(d))
const x11 = fs.existsSync('/tmp/.X11-unix') ? '/tmp/.X11-unix' : '' const x11 = fs.existsSync('/tmp/.X11-unix') ? '/tmp/.X11-unix' : ''
@ -481,7 +501,7 @@ async function runLinuxGame({ game, sandbox, onExit, openFile, handoff = false }
if (chosen.backend === 'bwrap') { if (chosen.backend === 'bwrap') {
const source = bwrapSource() const source = bwrapSource()
cmd = 'bwrap' cmd = 'bwrap'
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] args = [...buildBwrapArgs({ dataRoot: root, user: source.user, devices: source.devices, x11: source.x11, runUser: source.runUser, shares: source.shares, appDir: writableAppDir(game), openFiles: handedFileBinds({ openFile, root }), urlOpener: routing }), '--', exe, ...argList]
header.push(`[plwl] HOME=${env.HOME}`) header.push(`[plwl] HOME=${env.HOME}`)
} }
@ -489,12 +509,7 @@ async function runLinuxGame({ game, sandbox, onExit, openFile, handoff = false }
} }
module.exports = { module.exports = {
DATA_DIRS,
XDG_LAYOUT,
SYSTEM_DIRS,
DESKTOP_SEEDS, DESKTOP_SEEDS,
DATA_SHARES,
X11_ENV,
SHIM_REL, SHIM_REL,
OPENER_REL, OPENER_REL,
MIME_REL, MIME_REL,
@ -509,10 +524,10 @@ module.exports = {
ensureLinuxPfx, ensureLinuxPfx,
resetSeededConfig, resetSeededConfig,
dataShares, dataShares,
syncDesktopConfig,
buildLinuxEnv, buildLinuxEnv,
pickBackend, pickBackend,
isolationLine, isolationLine,
linuxPathFor, linuxPathFor,
handedFileBinds,
runLinuxGame runLinuxGame
} }

View file

@ -115,6 +115,7 @@ function buildBwrapArgs({
etcRoot = '', etcRoot = '',
unshareNet = false, unshareNet = false,
appDir = '', appDir = '',
openFiles = [],
urlOpener = null urlOpener = null
}) { }) {
const xdgDir = sub => path.join(home, sub) const xdgDir = sub => path.join(home, sub)
@ -152,6 +153,10 @@ function buildBwrapArgs({
'--bind', path.join(dataRoot, 'data'), xdgDir(path.join('.local', 'share')), '--bind', path.join(dataRoot, 'data'), xdgDir(path.join('.local', 'share')),
'--bind', path.join(dataRoot, 'cache'), xdgDir('.cache'), '--bind', path.join(dataRoot, 'cache'), xdgDir('.cache'),
'--bind', path.join(dataRoot, 'state'), xdgDir(path.join('.local', 'state')), '--bind', path.join(dataRoot, 'state'), xdgDir(path.join('.local', 'state')),
// a file handed over from the desktop, mounted where the app was told it
// is: the prefix home sits ON TOP of the user's real home and /tmp is
// emptied, so such a document would otherwise simply not be there
...openFiles.flatMap(file => ['--bind', file, file]),
// Wayland, PipeWire, PulseAudio and the session bus all live here // Wayland, PipeWire, PulseAudio and the session bus all live here
...(runUser ? ['--bind', runUser, runUser] : []), ...(runUser ? ['--bind', runUser, runUser] : []),
...(etcRoot ? ['--bind', etcRoot, '/etc'] : []), ...(etcRoot ? ['--bind', etcRoot, '/etc'] : []),
@ -164,4 +169,4 @@ function buildBwrapArgs({
] ]
} }
module.exports = { createSandboxProbe, buildBwrapArgs, SHARE_ROOT, PROBE_TIMEOUT_MS } module.exports = { createSandboxProbe, buildBwrapArgs, SHARE_ROOT }