add tray & creating shortcuts

This commit is contained in:
olcxja 2026-09-26 13:57:12 +02:00
commit 1097a1f441
11 changed files with 452 additions and 22 deletions

View file

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

View file

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

View file

@ -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 <id>
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 <id>)
// 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
})

View file

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

124
src/shortcuts.js Normal file
View file

@ -0,0 +1,124 @@
'use strict'
// .desktop shortcut generation. A shortcut launches the launcher itself with
// `--launch <id>`: 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 <project>` 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 }