code cleanup
This commit is contained in:
parent
1bbc71166e
commit
bf5abeef73
6 changed files with 124 additions and 197 deletions
|
|
@ -27,10 +27,8 @@ const XDG_LAYOUT = {
|
|||
XDG_STATE_HOME: ['state', path.join('.local', 'state')]
|
||||
}
|
||||
|
||||
// Write access is handed out to the folder the app was imported from and to
|
||||
// nothing else outside its home: a portable app writes its logs and data next
|
||||
// to itself (Prism Launcher refuses to start otherwise), while system trees
|
||||
// must never come back writable.
|
||||
// Write access goes to the folder the app was imported from and nowhere else
|
||||
// outside its home: a portable app writes its logs next to itself.
|
||||
const SYSTEM_DIRS = ['/bin', '/sbin', '/lib', '/lib32', '/lib64', '/libx32', '/usr', '/etc', '/boot', '/proc', '/sys', '/dev', '/run']
|
||||
|
||||
function writableAppDir(game) {
|
||||
|
|
@ -75,9 +73,8 @@ function copyFromHost(src, dst, { refresh = false } = {}) {
|
|||
}
|
||||
}
|
||||
|
||||
// PulseAudio looks for its cookie under $HOME/.config/pulse; with HOME
|
||||
// redirected there is none, and sound dies for everything going through
|
||||
// Pulse. Copied once - the app owns it afterwards.
|
||||
// With HOME redirected there is no pulse cookie, and everything through Pulse
|
||||
// comes up silent; copied once, then the app owns it.
|
||||
function seedPulseCookie(root) {
|
||||
const dst = path.join(root, 'config', 'pulse', 'cookie')
|
||||
const seeded = copyFromHost(path.join(os.homedir(), '.config', 'pulse', 'cookie'), dst)
|
||||
|
|
@ -88,9 +85,8 @@ function seedPulseCookie(root) {
|
|||
}
|
||||
}
|
||||
|
||||
// The host's desktop preferences, not its data: a redirected home has no
|
||||
// theme at all, and Qt/GTK then fall back to their built-in light look (KDE's
|
||||
// dark palette lives in kdeglobals).
|
||||
// The host's desktop preferences, not its data: without them Qt/GTK fall back
|
||||
// to their built-in light look (KDE's dark palette lives in kdeglobals).
|
||||
const DESKTOP_SEEDS = [
|
||||
'kdeglobals',
|
||||
'kdedefaults/kdeglobals',
|
||||
|
|
@ -102,21 +98,17 @@ const DESKTOP_SEEDS = [
|
|||
'user-dirs.dirs'
|
||||
]
|
||||
|
||||
// The host's desktop *data* - fonts, icons, themes, colour schemes - is not
|
||||
// copied but handed over read-only (see buildBwrapArgs), so it follows the
|
||||
// host without an import.
|
||||
// The host's desktop *data* (fonts, icons, themes) is not copied but handed
|
||||
// over read-only: see buildBwrapArgs.
|
||||
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.
|
||||
// A clicked link is the host desktop's business: the association naming a
|
||||
// browser lives in the host's own home, so the URI goes to the host's portal.
|
||||
const PORTAL_BUS = 'org.freedesktop.portal.Desktop'
|
||||
const PORTAL_METHOD = 'org.freedesktop.portal.OpenURI.OpenURI'
|
||||
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.
|
||||
// Launcher-owned files in the redirected home (never mounted into a sandbox),
|
||||
// rewritten whenever they differ.
|
||||
const SHIM_NAME = 'xdg-open'
|
||||
const OPENER_NAME = 'open-url'
|
||||
const OPENER_DESKTOP = 'plwl-url-opener.desktop'
|
||||
|
|
@ -155,19 +147,10 @@ function writeIfChanged(file, text, mode) {
|
|||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
// The one script both hand-offs run: xdg-open's stand-in inside a sandbox, and
|
||||
// what the seeded desktop entry starts. A scheme goes to the host's portal
|
||||
// (chooser included), a path to the request file the launcher watches - the
|
||||
// portal's answer is unreadable (development.md), the call is all we can trust.
|
||||
function handOffScript({ hostHome, fsRoot, request }) {
|
||||
const cases = [
|
||||
`file://"$HOME") arg="file://${hostHome}" ;;`,
|
||||
|
|
@ -187,8 +170,7 @@ function handOffScript({ hostHome, fsRoot, request }) {
|
|||
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
|
||||
# a link is the host portal's from here on; anything else falls back
|
||||
open_portal() {
|
||||
command -v gdbus >/dev/null 2>&1 || return 1
|
||||
gdbus call --session --dest ${PORTAL_BUS} \\
|
||||
|
|
@ -210,13 +192,13 @@ printf '%s\\n' "$arg" > "${request}.part" && mv "${request}.part" "${request}" |
|
|||
`
|
||||
}
|
||||
|
||||
// The stand-in for xdg-open inside the sandbox.
|
||||
// The stand-in for xdg-open inside the sandbox, and (same script) what the
|
||||
// seeded desktop entry runs: a browser started in here would be the wrong one,
|
||||
// with an empty profile growing inside the prefix.
|
||||
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)
|
||||
}
|
||||
|
|
@ -228,14 +210,10 @@ function linkOpenerName() {
|
|||
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 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).
|
||||
// GIO resolves an URI in-process (gtk_show_uri, `gio open`) and would pick a
|
||||
// system browser in here: naming our opener the default for exactly these types
|
||||
// changes only its answer. GIO's own portal path is not reachable (it wants a
|
||||
// sandbox marker this launcher does not fake).
|
||||
function writeMimeSeed(root, home) {
|
||||
const desktop = [
|
||||
'[Desktop Entry]',
|
||||
|
|
@ -252,10 +230,8 @@ function writeMimeSeed(root, home) {
|
|||
return writeIfChanged(path.join(root, MIME_REL), mime, 0o644)
|
||||
}
|
||||
|
||||
// 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.
|
||||
// Everything an opened thing needs: the hand-off script, the association naming
|
||||
// it the default, and - in a sandbox - xdg-open replaced by the same script.
|
||||
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) }
|
||||
|
|
@ -273,22 +249,12 @@ function hashOf(file) {
|
|||
}
|
||||
}
|
||||
|
||||
function mtimeOf(file) {
|
||||
try {
|
||||
return fs.statSync(file).mtimeMs
|
||||
} catch {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
// Hashes of what we put in the prefix, so a host change can be followed while
|
||||
// the app has not touched our copy. Kept in the prefix root: the app only
|
||||
// ever sees home/config/data/cache/state.
|
||||
// Hashes of what we seeded, kept in the prefix root: the app only ever sees
|
||||
// home/config/data/cache/state.
|
||||
const SEED_MARKER = '.seeded.json'
|
||||
|
||||
// Copies a preference in, then keeps following it for as long as we can prove
|
||||
// the copy is still ours: an unusable theme is worse than a stale one, and an
|
||||
// app's own edit is never overwritten.
|
||||
// Keeps a seeded preference following the host while the copy is provably ours;
|
||||
// an app's own edit is never overwritten.
|
||||
function syncDesktopConfig(root) {
|
||||
const hostConfig = path.join(os.homedir(), '.config')
|
||||
const markerFile = path.join(root, SEED_MARKER)
|
||||
|
|
@ -300,19 +266,12 @@ function syncDesktopConfig(root) {
|
|||
const hostHash = hashOf(src)
|
||||
if (!hostHash) continue
|
||||
const known = marker[rel]
|
||||
if (fs.existsSync(dst)) {
|
||||
if (fs.existsSync(dst) && known) {
|
||||
const current = hashOf(dst)
|
||||
if (known) {
|
||||
// the app owns it now, or there is nothing to do
|
||||
if (current !== known || current === hostHash) continue
|
||||
} else if (current !== hostHash && mtimeOf(dst) >= mtimeOf(src)) {
|
||||
// a prefix from an older launcher: an identical copy is ours, and so is
|
||||
// one the host's file has since outgrown. A copy that is not older than
|
||||
// the host's is the app's own - ties stay with the app
|
||||
continue
|
||||
}
|
||||
// the app owns it now, or there is nothing to do
|
||||
if (current !== known || current === hostHash) continue
|
||||
}
|
||||
// refresh: this is provably still our own copy
|
||||
// no marker entry: the copy is ours (seeded before the marker existed)
|
||||
if (copyFromHost(src, dst, { refresh: true })) {
|
||||
marker[rel] = hostHash
|
||||
changed = true
|
||||
|
|
@ -325,9 +284,8 @@ function syncDesktopConfig(root) {
|
|||
}
|
||||
}
|
||||
|
||||
// X11 access: XAUTHORITY usually lives in /run/user (bound), but a launcher
|
||||
// started without it would leave X11 dead inside. Refreshed: a cookie is
|
||||
// session state, not data.
|
||||
// X11 access: a cookie is session state, not data, so XAUTHORITY is refreshed
|
||||
// rather than seeded once.
|
||||
function seedXauthority(root) {
|
||||
copyFromHost(path.join(os.homedir(), '.Xauthority'), path.join(root, 'home', '.Xauthority'), { refresh: true })
|
||||
}
|
||||
|
|
@ -343,10 +301,8 @@ function seededRelPaths() {
|
|||
]
|
||||
}
|
||||
|
||||
// syncDesktopConfig only follows the host while the copy is provably ours, so
|
||||
// an app that edited its own theme keeps it forever. The reset drops our
|
||||
// copies (and the hashes that track them) and lets the next start seed clean
|
||||
// ones - the app's own files in the same folders are never touched.
|
||||
// Drops what we seeded (and the hashes tracking it) so the next start seeds
|
||||
// clean ones; the app's own files in the same folders are never touched.
|
||||
function resetSeededConfig(name) {
|
||||
const root = prefixLinuxRoot(name)
|
||||
const removed = []
|
||||
|
|
@ -378,9 +334,8 @@ function ensureLinuxPfx(name) {
|
|||
return root
|
||||
}
|
||||
|
||||
// Wayland cannot carry a window icon and the compositor only guesses one from
|
||||
// the app id; XWayland can, and GLFW/Qt/GTK all read these, so one flag moves
|
||||
// the whole process tree (Prism and the Minecraft it launches) back to X11.
|
||||
// XWayland is the only place a window can carry its own icon, and GLFW/Qt/GTK
|
||||
// all read these, so one switch moves the whole process tree back to X11.
|
||||
const X11_ENV = { XDG_SESSION_TYPE: 'x11', QT_QPA_PLATFORM: 'xcb', GDK_BACKEND: 'x11' }
|
||||
|
||||
// The theme data sits in front of the inherited list: on the host the user's
|
||||
|
|
@ -404,10 +359,8 @@ function buildLinuxEnv({ game, backend = 'env', fsRoot = false }) {
|
|||
for (const [key, [hostDir, sandboxSub]] of Object.entries(XDG_LAYOUT)) {
|
||||
env[key] = sandboxed ? path.join(home, sandboxSub) : path.join(root, hostDir)
|
||||
}
|
||||
// XDG_RUNTIME_DIR stays the host's: Wayland, PipeWire and the session bus
|
||||
// live there. It weakens the isolation, and the UI says so.
|
||||
// The X session manager sits in the private /tmp, so its pointer would only
|
||||
// lead to a dead socket; clients skip it when it is absent.
|
||||
// The X session manager sits in the private /tmp, so its pointer would lead
|
||||
// to a dead socket. XDG_RUNTIME_DIR stays the host's (it weakens isolation).
|
||||
if (sandboxed) delete env.SESSION_MANAGER
|
||||
// the host's fonts/icons/themes: mounted read-only by buildBwrapArgs, or - in
|
||||
// a plain run, where nothing is hidden - just put on the search path
|
||||
|
|
@ -415,23 +368,19 @@ function buildLinuxEnv({ game, backend = 'env', fsRoot = false }) {
|
|||
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
|
||||
// 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
|
||||
// an opened link or document leaves for the host desktop (see openrelay.js)
|
||||
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
|
||||
// a live bind of a host folder, so a copy written here is visible to an app
|
||||
// that is already running - the one route a running namespace can be reached
|
||||
// through at all.
|
||||
// Where a handed document goes inside the app's own home - a live bind, so a
|
||||
// copy written here reaches an app that is already running too.
|
||||
const HANDOFF_DIR = 'plwl-handoff'
|
||||
// Copies above this are worth a popup: below it the copy is over before a
|
||||
// window could paint, and the launcher would flash for nothing.
|
||||
const STAGE_NOTICE_BYTES = 50 * 1000 * 1000
|
||||
|
||||
// The probe measures once and answers from its cache afterwards. It is awaited
|
||||
// here because a shortcut boots HEADLESS: no renderer ran, so nothing else
|
||||
// would have measured, and "auto" would quietly pick the weaker level.
|
||||
// Awaited because a shortcut boots HEADLESS: without a measurement "auto" would
|
||||
// quietly pick the weaker level.
|
||||
async function levelStatus(sandbox) {
|
||||
if (!sandbox || typeof sandbox.status !== 'function') return null
|
||||
const cached = sandbox.status()
|
||||
|
|
@ -444,11 +393,8 @@ async function levelStatus(sandbox) {
|
|||
}
|
||||
}
|
||||
|
||||
// bwrap when it works, plain run otherwise - unless the app demands a sandbox.
|
||||
// `auto` is the only level allowed to take a weaker one: a machine that cannot
|
||||
// sandbox runs the app plain, while an explicit relaxed/isolated refuses
|
||||
// instead of quietly dropping the promise. (Values written by launcher 0.5.x
|
||||
// are mapped to these four by library.js.)
|
||||
// `auto` is the only level that may weaken itself: a machine without bwrap runs
|
||||
// the app plain, while an explicit relaxed/isolated refuses instead.
|
||||
async function resolveLevel({ game, sandbox }) {
|
||||
const policy = (game && game.isolation) || 'auto'
|
||||
const status = await levelStatus(sandbox)
|
||||
|
|
@ -483,15 +429,9 @@ function linuxPathFor(file) {
|
|||
return abs
|
||||
}
|
||||
|
||||
// The document a shortcut was handed, in the one shape the chosen level can
|
||||
// reach - and the app is told nothing else, so a second hand-off names the
|
||||
// same file the same way.
|
||||
// none the host path, exactly what a shell would name
|
||||
// relaxed the same file through the whole-host mount every run carries
|
||||
// isolated a copy in the app's own home, refreshed on every open (one copy
|
||||
// per name, so the folder cannot grow; the app's edits stay in the
|
||||
// copy - that is this level's promise)
|
||||
// Returns '' when there is nothing to hand over, so argv stays honest.
|
||||
// The document a shortcut was handed, in the shape the chosen level can reach:
|
||||
// none = the host path, relaxed = through FS_ROOT, isolated = a copy in the
|
||||
// app's own home (refreshed on every open). '' when there is nothing to hand over.
|
||||
async function handedPathFor({ openFile, level, root, home, onProgress }) {
|
||||
if (!openFile) return ''
|
||||
const abs = linuxPathFor(openFile)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue