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

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