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 @@ +
@@ -163,6 +167,7 @@
+
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 }