(i hope) support for ALL xdg- dialogs
This commit is contained in:
parent
9748b98163
commit
1bbc71166e
10 changed files with 213 additions and 115 deletions
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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/<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.
|
||||
- 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/<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.
|
||||
- `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
|
||||
|
|
|
|||
|
|
@ -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}",
|
||||
|
|
|
|||
|
|
@ -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}",
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
|
|
|
|||
|
|
@ -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()))
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
85
src/openrelay.js
Normal file
85
src/openrelay.js
Normal file
|
|
@ -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 }
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue