diff --git a/files/translations/en-us.json b/files/translations/en-us.json
index 70b6c97..0a2f79f 100644
--- a/files/translations/en-us.json
+++ b/files/translations/en-us.json
@@ -71,6 +71,9 @@
"config.overrides.setting": "Setting",
"config.overrides.remove": "Remove",
"config.save": "Save changes",
+ "config.shortcut": "Create shortcut",
+ "config.shortcutCreated": "Shortcut for {0} created (menu + desktop)",
+ "config.shortcutFailed": "Failed to create the shortcut",
"config.saved": "Configuration saved",
"config.back": "Back to library",
"config.browse": "Browse",
@@ -138,6 +141,7 @@
"settings.save": "Save",
"settings.saved": "Settings saved",
"settings.openFiles": "Open files folder",
+ "settings.closeToTray": "Close window to tray (keep tracking playtime in background)",
"dialog.ok": "OK",
"dialog.cancel": "Cancel",
@@ -166,6 +170,11 @@
"common.type": "Type",
"common.actions": "Actions",
"common.default": "Default",
- "common.loading": "Loading…"
+ "common.loading": "Loading…",
+
+ "tray.open": "Open PLWL",
+ "tray.running": "Running apps:",
+ "tray.quit": "Quit PLWL",
+ "tray.tooltipRunning": "{0} running"
}
}
diff --git a/files/translations/pl.json b/files/translations/pl.json
index 5e5b022..b31f6ea 100644
--- a/files/translations/pl.json
+++ b/files/translations/pl.json
@@ -71,6 +71,9 @@
"config.overrides.setting": "Ustawienie",
"config.overrides.remove": "Usuń",
"config.save": "Zapisz zmiany",
+ "config.shortcut": "Utwórz skrót",
+ "config.shortcutCreated": "Skrót dla {0} utworzony (menu + pulpit)",
+ "config.shortcutFailed": "Nie udało się utworzyć skrótu",
"config.saved": "Konfiguracja zapisana",
"config.back": "Wróć do biblioteki",
"config.browse": "Przeglądaj",
@@ -138,6 +141,7 @@
"settings.save": "Zapisz",
"settings.saved": "Ustawienia zapisane",
"settings.openFiles": "Otwórz folder files",
+ "settings.closeToTray": "Zamykaj okno do zasobnika systemowego (licz czas gry w tle)",
"dialog.ok": "OK",
"dialog.cancel": "Anuluj",
@@ -166,6 +170,11 @@
"common.type": "Typ",
"common.actions": "Akcje",
"common.default": "Domyślny",
- "common.loading": "Ładowanie…"
+ "common.loading": "Ładowanie…",
+
+ "tray.open": "Otwórz PLWL",
+ "tray.running": "Uruchomione aplikacje:",
+ "tray.quit": "Zakończ PLWL",
+ "tray.tooltipRunning": "uruchomione: {0}"
}
}
diff --git a/package.json b/package.json
index d68c601..a60da0b 100644
--- a/package.json
+++ b/package.json
@@ -6,8 +6,8 @@
"main": "src/main.js",
"scripts": {
"start": "electron .",
- "test": "node test/import.test.js && node test/icons.test.js",
- "smoke": "node test/import.test.js && node test/icons.test.js"
+ "test": "node test/import.test.js && node test/icons.test.js && node test/shortcuts.test.js",
+ "smoke": "node test/import.test.js && node test/icons.test.js && node test/shortcuts.test.js"
},
"devDependencies": {
"@electron/asar": "^3.4.1",
diff --git a/renderer/app.js b/renderer/app.js
index 4c90730..e423a6f 100644
--- a/renderer/app.js
+++ b/renderer/app.js
@@ -26,14 +26,14 @@ function t(key, ...args) {
}
const ICONS = {
- // app grid — the library view (games AND other Windows apps)
+ // app grid - the library view (games AND other Windows apps)
library: () => '',
- // stacked layers — wine prefixes stacked on top of each other
+ // stacked layers - wine prefixes stacked on top of each other
prefixes: () => '',
- // wine glass — wine/proton runners
+ // wine glass - wine/proton runners
wines: () => '',
settings: () => '',
- // arrow into a tray — import
+ // arrow into a tray - import
import: () => '',
config: () => '',
folder: () => '',
@@ -619,12 +619,14 @@ function renderSettings() {
selects['set-language'].update(state.langs.map(l => ({ value: l.code, label: l.nameSelf })), state.lang)
selects['set-wine'].update(state.wines.map(w => ({ value: w.id, label: w.label })), state.settings.defaultWine || 'system')
selects['set-prefix'].update(state.prefixes.prefixes.map(p => ({ value: p.name, label: p.name })), state.prefixes.default)
+ qs('#set-closetotray').checked = state.settings.closeToTray !== false
}
async function saveSettings() {
state.settings = await window.plwl.settingsSet({
language: selects['set-language'].value,
- defaultWine: selects['set-wine'].value
+ defaultWine: selects['set-wine'].value,
+ closeToTray: qs('#set-closetotray').checked
})
await window.plwl.prefixesSetDefault(selects['set-prefix'].value || state.prefixes.default)
state.prefixes = await window.plwl.prefixesList()
@@ -702,6 +704,17 @@ async function openGameConfig(id) {
renderTools()
}
+async function createAppShortcut() {
+ const game = state.currentGame
+ if (!game) return
+ const res = await window.plwl.gamesCreateShortcut(game.id)
+ if (res && res.ok) {
+ toast(t('config.shortcutCreated', game.name), 'success')
+ } else {
+ toast(t('config.shortcutFailed') + ': ' + ((res && res.error) || ''), 'error')
+ }
+}
+
function renderOverrides(overrides) {
const box = qs('#gc-overrides')
box.innerHTML = ''
@@ -848,6 +861,7 @@ function wireEvents() {
qs('#btn-open-files').addEventListener('click', () => window.plwl.shellOpenPath(window.plwl.FILES_DIR))
qs('#btn-gc-back').addEventListener('click', () => showView('library'))
qs('#btn-gc-save').addEventListener('click', saveGameConfig)
+ qs('#btn-gc-shortcut').addEventListener('click', createAppShortcut)
qs('#gc-override-add').addEventListener('click', addOverrideRow)
qs('#gc-exe-browse').addEventListener('click', async () => {
const cur = absPathInput(qs('#gc-workdir').value.trim())
diff --git a/renderer/index.html b/renderer/index.html
index a3108d9..1b37be0 100644
--- a/renderer/index.html
+++ b/renderer/index.html
@@ -101,6 +101,10 @@
+
diff --git a/renderer/style.css b/renderer/style.css
index 2cb5fb1..a961507 100644
--- a/renderer/style.css
+++ b/renderer/style.css
@@ -284,6 +284,55 @@ input:active, textarea:active { background-color: rgba(255, 255, 255, 0.1); }
input:focus, textarea:focus { border-color: rgba(255, 255, 255, 0.25); background-color: rgba(255, 255, 255, 0.1); }
textarea { resize: vertical; font-family: inherit; }
+/* --- checkbox: styled to match the app (radius, borders, press feedback) --- */
+.check-label {
+ display: flex;
+ align-items: center;
+ gap: 0.7rem;
+ margin-top: 1.1rem; /* extra breathing room below the custom-select */
+ margin-bottom: var(--button-margin);
+ cursor: pointer;
+ color: var(--darker-text-color);
+ user-select: none;
+}
+.check-label:hover { color: var(--text-color); }
+input[type='checkbox'] {
+ -webkit-appearance: none;
+ appearance: none;
+ width: 1.7rem;
+ height: 1.7rem;
+ min-height: 0;
+ flex: 0 0 auto;
+ margin: 0;
+ padding: 0;
+ border-radius: 0.65rem;
+ background-color: rgba(255, 255, 255, 0.05);
+ border: var(--border-width) solid var(--light-border-color);
+ cursor: pointer;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ backface-visibility: hidden;
+ transform: translateZ(0);
+ transition: all 0.15s ease;
+}
+input[type='checkbox']:hover { background-color: rgba(255, 255, 255, 0.09); }
+input[type='checkbox']:active { background-color: rgba(255, 255, 255, 0.1); transform: var(--press-scale); }
+input[type='checkbox']:focus-visible { border-color: rgba(255, 255, 255, 0.25); }
+input[type='checkbox']:checked {
+ background-color: var(--text-color);
+ border-color: var(--text-color);
+}
+input[type='checkbox']:checked::before {
+ content: '';
+ width: 0.95rem;
+ height: 0.95rem;
+ background: var(--main-bg-color);
+ -webkit-mask: url("data:image/svg+xml;utf8,") center / contain no-repeat;
+ mask: url("data:image/svg+xml;utf8,") center / contain no-repeat;
+}
+.check-label input[type='checkbox']:checked + span { color: var(--text-color); }
+
select {
display: none;
}
diff --git a/src/ipc/games.js b/src/ipc/games.js
index 4bf71e5..1d51f18 100644
--- a/src/ipc/games.js
+++ b/src/ipc/games.js
@@ -18,9 +18,15 @@ function sendToWindow(win, channel, data) {
}
// Game-process state: id -> pid (or null while the process is starting).
-function createGamesService({ getWindow }) {
+// onGamesChanged fires in the MAIN process whenever the running-games set
+// changes (the tray menu rebuilds itself from it).
+function createGamesService({ getWindow, onGamesChanged }) {
const running = new Map()
const killRequests = new Set()
+ let onGameExit = null
+ const notifyChanged = () => {
+ if (typeof onGamesChanged === 'function') onGamesChanged()
+ }
const isRunning = id => running.has(id)
const listRunning = () => [...running.keys()]
@@ -35,6 +41,8 @@ function createGamesService({ getWindow }) {
function handleExit(id) {
running.delete(id)
sendToWindow(getWindow(), 'games:changed')
+ notifyChanged()
+ if (onGameExit) onGameExit(id)
}
async function start(win, id) {
@@ -58,7 +66,8 @@ function createGamesService({ getWindow }) {
if (res.pid) killProcessGroup(res.pid)
}
sendToWindow(win, 'games:changed')
- return { ok: true }
+ notifyChanged()
+ return { ok: true, pid: res.pid || null, logFile: res.logFile || null }
} catch (err) {
running.delete(id)
return { ok: false, error: String((err && err.message) || err) }
@@ -71,6 +80,7 @@ function createGamesService({ getWindow }) {
killProcessGroup(pid)
running.delete(id)
sendToWindow(getWindow(), 'games:changed')
+ notifyChanged()
} else if (running.has(id)) {
killRequests.add(id)
}
@@ -94,11 +104,17 @@ function createGamesService({ getWindow }) {
}
}
+ // subscribe to game exits (used by the tray/headless mode to e.g. quit when
+ // the game that auto-started the launcher has finished)
+ function setOnGameExit(fn) {
+ onGameExit = typeof fn === 'function' ? fn : null
+ }
+
async function importTo(win, srcDir, mode) {
return importGame(srcDir, mode, data => sendToWindow(win, 'import:progress', data))
}
- return { isRunning, listRunning, start, kill, runToolFor, import: importTo }
+ return { isRunning, listRunning, start, kill, runToolFor, import: importTo, setOnGameExit }
}
module.exports = { createGamesService, killProcessGroup, sendToWindow }
diff --git a/src/ipc/index.js b/src/ipc/index.js
index 4af46a0..b27ae57 100644
--- a/src/ipc/index.js
+++ b/src/ipc/index.js
@@ -11,6 +11,7 @@ const prefixes = require('../prefixes')
const runner = require('../runner')
const { extractIconFromExe } = require('../icons')
const { createGamesService, sendToWindow } = require('./games')
+const shortcuts = require('../shortcuts')
const ICON_EXTS = ['png', 'jpe?g', 'svg', 'ico', 'webp', 'gif', 'bmp']
@@ -39,8 +40,8 @@ function extractGameIcon(id, exePath) {
// --- registration -----------------------------------------------------------
-function registerIpc({ getWindow }) {
- const games = createGamesService({ getWindow })
+function registerIpc({ getWindow, onGamesChanged }) {
+ const games = createGamesService({ getWindow, onGamesChanged })
// i18n ---------------------------------------------------------------------
ipcMain.handle('i18n:list', async () => {
@@ -93,7 +94,20 @@ function registerIpc({ getWindow }) {
ipcMain.handle('games:list', () => library.loadGames())
ipcMain.handle('games:get', (e, id) => library.getGame(id))
ipcMain.handle('games:update', (e, id, patch) => library.updateGame(id, patch))
- ipcMain.handle('games:remove', (e, id) => library.removeGame(id))
+ ipcMain.handle('games:remove', (e, id) => {
+ shortcuts.removeShortcut(id)
+ return library.removeGame(id)
+ })
+ ipcMain.handle('games:createShortcut', (e, id) => {
+ const game = library.getGame(id)
+ if (!game) return { ok: false, error: 'game-not-found' }
+ try {
+ return shortcuts.createShortcut(game)
+ } catch (err) {
+ return errorResult(err)
+ }
+ })
+ ipcMain.handle('games:shortcutPaths', (e, id) => shortcuts.shortcutPaths(id))
ipcMain.handle('games:clearPlaytime', (e, id) => library.clearPlaytime(id))
// games: icons ---------------------------------------------------------------------
diff --git a/src/main.js b/src/main.js
index bbc495b..3c5af33 100644
--- a/src/main.js
+++ b/src/main.js
@@ -7,28 +7,216 @@ const wines = require('./wines')
const { registerIpc } = require('./ipc')
const { createMainWindow } = require('./window')
const { setAppIcon } = require('./appicon')
+const path = require('path')
+const fs = require('fs')
app.setName('PLWL')
+// The launcher keeps running with only a tray icon, so closing the window
+// must not quit the app (playtime keeps counting while a game runs).
+app.on('window-all-closed', e => {
+ // no-op on purpose: tray Quit / menu Quit is the way out
+})
+
let win = null
+let tray = null
+let quitting = false
+let headless = false // started via --launch: no window until the user asks
+let autoLaunchId = null // game id from --launch
+let autoQuitWhenDone = false // headless boot: quit after that game exits
const getWindow = () => win
+// --- single instance ---------------------------------------------------------
+// A second launcher invocation (e.g. a desktop shortcut with --launch )
+// hands its arguments to the running instance and exits immediately.
+const gotLock = app.requestSingleInstanceLock()
+if (!gotLock) {
+ app.quit()
+} else {
+ app.on('second-instance', (e, argv) => {
+ const id = parseLaunchId(argv)
+ lastHandoffAt = Date.now()
+ if (id) launchGame(id)
+ else showMainWindow()
+ })
+}
+
+// set on every second-instance handoff: the auto-quit check skips itself for
+// a moment afterwards, so "one game exits" racing "a shortcut just started
+// another game" can never kill the launcher mid-handoff
+let lastHandoffAt = 0
+
+function parseLaunchId(argv) {
+ const args = Array.isArray(argv) ? argv : process.argv
+ const i = args.indexOf('--launch')
+ if (i !== -1 && args[i + 1]) return args[i + 1]
+ const eq = args.find(a => a.startsWith('--launch='))
+ return eq ? eq.slice('--launch='.length) : null
+}
+
+autoLaunchId = parseLaunchId(process.argv)
+headless = !!autoLaunchId
+autoQuitWhenDone = headless // booting just to run one game -> exit when it ends
+
+// --- tray ---------------------------------------------------------------------
+// Lives as long as the app does: shows "active in background" even when the
+// window is closed, and offers running games + Quit.
+function trayIconPath() {
+ const png = path.resolve(__dirname, '..', 'renderer', 'plwl.png')
+ return fs.existsSync(png) ? png : undefined
+}
+
+function createTray() {
+ const { Tray, Menu } = require('electron')
+ try {
+ tray = new Tray(trayIconPath())
+ } catch {
+ return // no icon available; window still works normally
+ }
+ tray.setToolTip('PLWL')
+ rebuildTrayMenu()
+}
+
+function rebuildTrayMenu() {
+ if (!tray) return
+ const { Menu } = require('electron')
+ const lang = loadLangSafe()
+ const gamesSvc = gamesService
+ const running = gamesSvc ? gamesSvc.listRunning() : []
+ const items = []
+
+ items.push({
+ label: i18n.translate(lang, 'tray.open'),
+ click: () => showMainWindow()
+ })
+
+ if (running.length) {
+ items.push({ type: 'separator' })
+ items.push({ label: i18n.translate(lang, 'tray.running'), enabled: false })
+ for (const id of running) {
+ const game = require('./library').getGame(id)
+ if (!game) continue
+ items.push({
+ label: `• ${game.name}`,
+ click: () => {
+ // click toggles: stop the running game
+ gamesSvc.kill(id)
+ }
+ })
+ }
+ }
+
+ items.push({ type: 'separator' })
+ items.push({
+ label: i18n.translate(lang, 'tray.quit'),
+ click: () => {
+ quitting = true
+ app.quit()
+ }
+ })
+ tray.setContextMenu(Menu.buildFromTemplate(items))
+ const tip = running.length
+ ? `PLWL - ${i18n.translate(lang, 'tray.tooltipRunning', running.length)}`
+ : 'PLWL'
+ tray.setToolTip(tip)
+}
+
+function loadLangSafe() {
+ try {
+ return require('./library').loadSettings().language || 'en-us'
+ } catch {
+ return 'en-us'
+ }
+}
+
+function showMainWindow() {
+ // the user is present now - headless auto-quit must never fire again
+ headless = false
+ autoQuitWhenDone = false
+ if (gamesService) gamesService.setOnGameExit(null)
+ if (win && !win.isDestroyed()) {
+ win.show()
+ win.focus()
+ return
+ }
+ win = createMainWindow()
+ wireWindow(win)
+}
+
+function wireWindow(w) {
+ // closing the window hides it to the tray (playtime keeps counting in the
+ // background) unless the user disabled that in settings - then it quits
+ w.on('close', e => {
+ if (quitting) return
+ let closeToTray = true
+ try {
+ closeToTray = require('./library').loadSettings().closeToTray !== false
+ } catch {}
+ if (closeToTray) {
+ e.preventDefault()
+ w.hide()
+ } else {
+ quitting = true
+ app.quit()
+ }
+ })
+ w.on('closed', () => {
+ win = null
+ })
+}
+
+// --- game auto-launch (shortcuts) -----------------------------------------------
+let gamesService = null
+
+async function launchGame(id) {
+ if (!gamesService) return
+ const res = await gamesService.start(getWindow(), id)
+ if (res.ok) {
+ rebuildTrayMenu()
+ // headless boot: when the auto-started game finishes and nothing else is
+ // running, quit the launcher
+ if (autoQuitWhenDone) {
+ gamesService.setOnGameExit(() => {
+ // fires for EVERY game exit; quit only when the last one just ended,
+ // the user never opened the window and no shortcut handed a new game
+ // over in the last few seconds (start/exit race guard)
+ if (Date.now() - lastHandoffAt < 5000) return
+ if (!gamesService.listRunning().length) {
+ quitting = true
+ app.quit()
+ }
+ })
+ }
+ } else if (headless && autoQuitWhenDone) {
+ // nothing to run (bad shortcut target) -> do not linger in the tray
+ quitting = true
+ app.quit()
+ }
+}
+
app.whenReady().then(async () => {
if (process.platform === 'linux') {
try {
setAppIcon(app)
} catch {}
}
- registerIpc({ getWindow })
ensureDirs()
await i18n.init()
await wines.refreshWines()
- win = createMainWindow()
- win.on('closed', () => {
- win = null
- })
+ const { games } = registerIpc({ getWindow, onGamesChanged: rebuildTrayMenu })
+ gamesService = games
+
+ createTray()
+
+ if (headless) {
+ // shortcut boot: run the game without opening the launcher window
+ launchGame(autoLaunchId)
+ } else {
+ win = createMainWindow()
+ wireWindow(win)
+ }
})
-app.on('window-all-closed', () => {
- app.quit()
+app.on('before-quit', () => {
+ quitting = true
})
diff --git a/src/preload.js b/src/preload.js
index 103649a..6fc26f2 100644
--- a/src/preload.js
+++ b/src/preload.js
@@ -29,6 +29,8 @@ contextBridge.exposeInMainWorld('plwl', {
gamesIconPath: id => ipcRenderer.invoke('games:iconPath', id),
gamesResetIcon: id => ipcRenderer.invoke('games:resetIcon', id),
gamesRemove: id => ipcRenderer.invoke('games:remove', id),
+ gamesCreateShortcut: id => ipcRenderer.invoke('games:createShortcut', id),
+ gamesShortcutPaths: id => ipcRenderer.invoke('games:shortcutPaths', id),
gamesPickFolder: () => ipcRenderer.invoke('games:pickFolder'),
gamesImport: (src, mode) => ipcRenderer.invoke('games:import', src, mode),
gamesRun: id => ipcRenderer.invoke('games:run', id),
diff --git a/src/shortcuts.js b/src/shortcuts.js
new file mode 100644
index 0000000..0718b3b
--- /dev/null
+++ b/src/shortcuts.js
@@ -0,0 +1,124 @@
+'use strict'
+
+// .desktop shortcut generation. A shortcut launches the launcher itself with
+// `--launch `: the main process either starts the game in the running
+// instance or boots headless (no window, tray only), starts the game and lets
+// playtime tracking continue normally.
+
+const fs = require('fs')
+const path = require('path')
+const { DIRS, resolvePortablePath } = require('./paths')
+
+const DATA_DIR = process.env.PLWL_BUILD_DIR ? path.join(process.env.PLWL_BUILD_DIR, 'files', 'data') : DIRS.DATA
+
+// Where user-visible shortcuts live (XDG). Override with PLWL_SHORTCUTS_DIR
+// so tests (and portable exotic setups) can redirect it.
+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')
+ }
+}
+
+// Launcher executable as the shortcut will see it, as an argv list: the
+// packaged olcxja.plwl/olcxja.plwl binary, or `electron ` in 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 field escaping (per the spec): % must be doubled,
+// quotes and backslashes escaped, and tokens containing spaces wrapped in
+// double quotes.
+function execToken(token) {
+ const escaped = token.replace(/%/g, '%%').replace(/\\/g, '\\\\').replace(/"/g, '\\"')
+ return /[ $"'\\]/.test(token) ? `"${escaped}"` : escaped
+}
+
+function execLine(extraArgs) {
+ return [...launcherArgv(), ...extraArgs.split(' ').filter(Boolean).map(execToken)].map(execToken).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) {
+ // fall back to the launcher icon so the shortcut is never naked
+ const png = path.resolve(__dirname, '..', 'renderer', 'plwl.png')
+ if (fs.existsSync(png)) icon = png
+ }
+ const lines = [
+ '[Desktop Entry]',
+ 'Type=Application',
+ `Name=${name}`,
+ 'Comment=Launch via PLWL',
+ `Exec=${execLine(`--launch ${game.id}`)}`,
+ '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 }