PLWL/src/shortcuts.js

119 lines
4.2 KiB
JavaScript

'use strict'
// .desktop shortcut generation: a shortcut runs the launcher with
// `--launch <id>` (headless boot when nothing is running yet).
const fs = require('fs')
const path = require('path')
const { DIRS, resolvePortablePath, launcherIconDiskPath } = require('./paths')
const DATA_DIR = process.env.PLWL_BUILD_DIR ? path.join(process.env.PLWL_BUILD_DIR, 'files', 'data') : DIRS.DATA
// XDG shortcuts dir; PLWL_SHORTCUTS_DIR overrides it (tests).
function desktopDirs() {
const home = process.env.HOME || '/'
const dataHome = process.env.XDG_DATA_HOME || path.join(home, '.local', 'share')
return {
menu: process.env.PLWL_SHORTCUTS_DIR || path.join(dataHome, 'applications'),
desktop: process.env.PLWL_SHORTCUTS_DIR || path.join(home, 'Desktop')
}
}
// The launcher executable as an argv list (packaged binary or electron dev).
function launcherArgv() {
if (process.env.PLWL_LAUNCHER_CMD) return process.env.PLWL_LAUNCHER_CMD.split(' ').filter(Boolean)
const m = __dirname.match(/^(.*)[/\\]app\.asar([/\\]|$)/)
if (m) {
const root = path.resolve(m[1], '..', '..')
const bin = path.join(root, 'olcxja.plwl', 'olcxja.plwl')
if (fs.existsSync(bin)) return [bin]
}
// dev fallback: run through electron from the project dir
return ['electron', path.resolve(__dirname, '..')]
}
// Desktop Entry Exec escaping per the spec (% doubled, quotes, spaces).
function execToken(token) {
const escaped = token.replace(/%/g, '%%').replace(/\\/g, '\\\\').replace(/"/g, '\\"')
return /[ $"'\\]/.test(token) ? `"${escaped}"` : escaped
}
// %f appended after escaping makes the shortcut a file handler.
function execLine(extraArgs) {
const tokens = [...launcherArgv(), ...String(extraArgs).split(' ').filter(Boolean)]
return [...tokens.map(execToken), '%f'].join(' ')
}
function sanitizeName(name) {
return String(name || 'App').replace(/[\r\n]/g, ' ').trim() || 'App'
}
// Builds the .desktop file content for launching app `id`.
function buildDesktopEntry(game) {
const name = sanitizeName(game.name)
const iconDir = path.join(DATA_DIR, 'icons', game.id)
let icon = ''
try {
const found = fs.readdirSync(iconDir).filter(f => f.startsWith('icon.')).sort().pop()
if (found) icon = path.join(iconDir, found)
} catch {}
if (!icon) {
// on-disk launcher icon fallback (Icon= cannot read the asar)
icon = launcherIconDiskPath() || ''
}
const lines = [
'[Desktop Entry]',
'Type=Application',
`Name=${name}`,
'Comment=Launch via PLWL',
`Exec=${execLine(`--launch ${game.id} --file`)}`,
'Terminal=false',
'Categories=Game;'
]
if (icon) lines.push(`Icon=${icon}`)
return lines.join('\n') + '\n'
}
// Where a given shortcut would be written (used by tests and by the UI hint).
function shortcutPaths(gameId) {
const dirs = desktopDirs()
return { menu: path.join(dirs.menu, `plwl-${gameId}.desktop`), desktop: path.join(dirs.desktop, `plwl-${gameId}.desktop`) }
}
// Writes the shortcut. Returns the paths it created.
function createShortcut(game) {
const content = buildDesktopEntry(game)
const dirs = desktopDirs()
const written = []
for (const [key, dir] of [['menu', dirs.menu], ['desktop', dirs.desktop]]) {
try {
fs.mkdirSync(dir, { recursive: true })
const file = path.join(dir, `plwl-${game.id}.desktop`)
fs.writeFileSync(file, content)
if (key === 'menu') {
// update desktop databases best-effort so the entry shows up at once
try {
require('child_process').execFileSync('update-desktop-database', [dir], { timeout: 10000 })
} catch {}
}
written.push(file)
} catch (err) {
// a read-only Desktop must not fail the whole operation
if (key === 'desktop') continue
throw err
}
}
return { ok: true, paths: written }
}
// Removes shortcuts for a game (also called when the game is removed).
function removeShortcut(gameId) {
const dirs = desktopDirs()
for (const file of [path.join(dirs.menu, `plwl-${gameId}.desktop`), path.join(dirs.desktop, `plwl-${gameId}.desktop`)]) {
try {
fs.rmSync(file, { force: true })
} catch {}
}
}
module.exports = { buildDesktopEntry, createShortcut, removeShortcut, shortcutPaths, launcherArgv }