support opening links from linux apps
This commit is contained in:
parent
a2f1f83ee3
commit
2ff8920225
6 changed files with 187 additions and 5 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue