add tray & creating shortcuts
This commit is contained in:
parent
81f5eadc76
commit
1097a1f441
11 changed files with 452 additions and 22 deletions
|
|
@ -71,6 +71,9 @@
|
||||||
"config.overrides.setting": "Setting",
|
"config.overrides.setting": "Setting",
|
||||||
"config.overrides.remove": "Remove",
|
"config.overrides.remove": "Remove",
|
||||||
"config.save": "Save changes",
|
"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.saved": "Configuration saved",
|
||||||
"config.back": "Back to library",
|
"config.back": "Back to library",
|
||||||
"config.browse": "Browse",
|
"config.browse": "Browse",
|
||||||
|
|
@ -138,6 +141,7 @@
|
||||||
"settings.save": "Save",
|
"settings.save": "Save",
|
||||||
"settings.saved": "Settings saved",
|
"settings.saved": "Settings saved",
|
||||||
"settings.openFiles": "Open files folder",
|
"settings.openFiles": "Open files folder",
|
||||||
|
"settings.closeToTray": "Close window to tray (keep tracking playtime in background)",
|
||||||
|
|
||||||
"dialog.ok": "OK",
|
"dialog.ok": "OK",
|
||||||
"dialog.cancel": "Cancel",
|
"dialog.cancel": "Cancel",
|
||||||
|
|
@ -166,6 +170,11 @@
|
||||||
"common.type": "Type",
|
"common.type": "Type",
|
||||||
"common.actions": "Actions",
|
"common.actions": "Actions",
|
||||||
"common.default": "Default",
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -71,6 +71,9 @@
|
||||||
"config.overrides.setting": "Ustawienie",
|
"config.overrides.setting": "Ustawienie",
|
||||||
"config.overrides.remove": "Usuń",
|
"config.overrides.remove": "Usuń",
|
||||||
"config.save": "Zapisz zmiany",
|
"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.saved": "Konfiguracja zapisana",
|
||||||
"config.back": "Wróć do biblioteki",
|
"config.back": "Wróć do biblioteki",
|
||||||
"config.browse": "Przeglądaj",
|
"config.browse": "Przeglądaj",
|
||||||
|
|
@ -138,6 +141,7 @@
|
||||||
"settings.save": "Zapisz",
|
"settings.save": "Zapisz",
|
||||||
"settings.saved": "Ustawienia zapisane",
|
"settings.saved": "Ustawienia zapisane",
|
||||||
"settings.openFiles": "Otwórz folder files",
|
"settings.openFiles": "Otwórz folder files",
|
||||||
|
"settings.closeToTray": "Zamykaj okno do zasobnika systemowego (licz czas gry w tle)",
|
||||||
|
|
||||||
"dialog.ok": "OK",
|
"dialog.ok": "OK",
|
||||||
"dialog.cancel": "Anuluj",
|
"dialog.cancel": "Anuluj",
|
||||||
|
|
@ -166,6 +170,11 @@
|
||||||
"common.type": "Typ",
|
"common.type": "Typ",
|
||||||
"common.actions": "Akcje",
|
"common.actions": "Akcje",
|
||||||
"common.default": "Domyślny",
|
"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}"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,8 +6,8 @@
|
||||||
"main": "src/main.js",
|
"main": "src/main.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "electron .",
|
"start": "electron .",
|
||||||
"test": "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"
|
"smoke": "node test/import.test.js && node test/icons.test.js && node test/shortcuts.test.js"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@electron/asar": "^3.4.1",
|
"@electron/asar": "^3.4.1",
|
||||||
|
|
|
||||||
|
|
@ -26,14 +26,14 @@ function t(key, ...args) {
|
||||||
}
|
}
|
||||||
|
|
||||||
const ICONS = {
|
const ICONS = {
|
||||||
// app grid — the library view (games AND other Windows apps)
|
// app grid - the library view (games AND other Windows apps)
|
||||||
library: () => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3.5" y="3.5" width="7.5" height="7.5" rx="2"/><rect x="13" y="3.5" width="7.5" height="7.5" rx="2"/><rect x="3.5" y="13" width="7.5" height="7.5" rx="2"/><rect x="13" y="13" width="7.5" height="7.5" rx="2"/></svg>',
|
library: () => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3.5" y="3.5" width="7.5" height="7.5" rx="2"/><rect x="13" y="3.5" width="7.5" height="7.5" rx="2"/><rect x="3.5" y="13" width="7.5" height="7.5" rx="2"/><rect x="13" y="13" width="7.5" height="7.5" rx="2"/></svg>',
|
||||||
// stacked layers — wine prefixes stacked on top of each other
|
// stacked layers - wine prefixes stacked on top of each other
|
||||||
prefixes: () => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3 3.5 7.25 12 11.5l8.5-4.25L12 3Z"/><path d="m3.5 12.25 8.5 4.25 8.5-4.25"/><path d="m3.5 16.75 8.5 4.25 8.5-4.25"/></svg>',
|
prefixes: () => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3 3.5 7.25 12 11.5l8.5-4.25L12 3Z"/><path d="m3.5 12.25 8.5 4.25 8.5-4.25"/><path d="m3.5 16.75 8.5 4.25 8.5-4.25"/></svg>',
|
||||||
// wine glass — wine/proton runners
|
// wine glass - wine/proton runners
|
||||||
wines: () => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M8 22h8"/><path d="M12 15v7"/><path d="M7 10h10"/><path d="M12 15a5 5 0 0 0 5-5c0-2-.5-4-2-8H9c-1.5 4-2 6-2 8a5 5 0 0 0 5 5Z"/></svg>',
|
wines: () => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M8 22h8"/><path d="M12 15v7"/><path d="M7 10h10"/><path d="M12 15a5 5 0 0 0 5-5c0-2-.5-4-2-8H9c-1.5 4-2 6-2 8a5 5 0 0 0 5 5Z"/></svg>',
|
||||||
settings: () => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19 12a7 7 0 0 0-.1-1.2l2-1.5-2-3.5-2.4 1a7 7 0 0 0-2-1.2L14 3h-4l-.5 2.6a7 7 0 0 0-2 1.2l-2.4-1-2 3.5 2 1.5a7 7 0 0 0 0 2.4l-2 1.5 2 3.5 2.4-1a7 7 0 0 0 2 1.2L10 21h4l.5-2.6a7 7 0 0 0 2-1.2l2.4 1 2-3.5-2-1.5c.06-.4.1-.8.1-1.2z"/></svg>',
|
settings: () => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19 12a7 7 0 0 0-.1-1.2l2-1.5-2-3.5-2.4 1a7 7 0 0 0-2-1.2L14 3h-4l-.5 2.6a7 7 0 0 0-2 1.2l-2.4-1-2 3.5 2 1.5a7 7 0 0 0 0 2.4l-2 1.5 2 3.5 2.4-1a7 7 0 0 0 2 1.2L10 21h4l.5-2.6a7 7 0 0 0 2-1.2l2.4 1 2-3.5-2-1.5c.06-.4.1-.8.1-1.2z"/></svg>',
|
||||||
// arrow into a tray — import
|
// arrow into a tray - import
|
||||||
import: () => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3v10"/><path d="m8 9 4 4 4-4"/><path d="M4 16v2.5A2.5 2.5 0 0 0 6.5 21h11a2.5 2.5 0 0 0 2.5-2.5V16"/></svg>',
|
import: () => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3v10"/><path d="m8 9 4 4 4-4"/><path d="M4 16v2.5A2.5 2.5 0 0 0 6.5 21h11a2.5 2.5 0 0 0 2.5-2.5V16"/></svg>',
|
||||||
config: () => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M4 21v-7M4 10V3M12 21v-9M12 8V3M20 21v-5M20 12V3M1 14h6M9 8h6M17 16h6"/></svg>',
|
config: () => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M4 21v-7M4 10V3M12 21v-9M12 8V3M20 21v-5M20 12V3M1 14h6M9 8h6M17 16h6"/></svg>',
|
||||||
folder: () => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></svg>',
|
folder: () => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></svg>',
|
||||||
|
|
@ -619,12 +619,14 @@ function renderSettings() {
|
||||||
selects['set-language'].update(state.langs.map(l => ({ value: l.code, label: l.nameSelf })), state.lang)
|
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-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)
|
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() {
|
async function saveSettings() {
|
||||||
state.settings = await window.plwl.settingsSet({
|
state.settings = await window.plwl.settingsSet({
|
||||||
language: selects['set-language'].value,
|
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)
|
await window.plwl.prefixesSetDefault(selects['set-prefix'].value || state.prefixes.default)
|
||||||
state.prefixes = await window.plwl.prefixesList()
|
state.prefixes = await window.plwl.prefixesList()
|
||||||
|
|
@ -702,6 +704,17 @@ async function openGameConfig(id) {
|
||||||
renderTools()
|
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) {
|
function renderOverrides(overrides) {
|
||||||
const box = qs('#gc-overrides')
|
const box = qs('#gc-overrides')
|
||||||
box.innerHTML = ''
|
box.innerHTML = ''
|
||||||
|
|
@ -848,6 +861,7 @@ function wireEvents() {
|
||||||
qs('#btn-open-files').addEventListener('click', () => window.plwl.shellOpenPath(window.plwl.FILES_DIR))
|
qs('#btn-open-files').addEventListener('click', () => window.plwl.shellOpenPath(window.plwl.FILES_DIR))
|
||||||
qs('#btn-gc-back').addEventListener('click', () => showView('library'))
|
qs('#btn-gc-back').addEventListener('click', () => showView('library'))
|
||||||
qs('#btn-gc-save').addEventListener('click', saveGameConfig)
|
qs('#btn-gc-save').addEventListener('click', saveGameConfig)
|
||||||
|
qs('#btn-gc-shortcut').addEventListener('click', createAppShortcut)
|
||||||
qs('#gc-override-add').addEventListener('click', addOverrideRow)
|
qs('#gc-override-add').addEventListener('click', addOverrideRow)
|
||||||
qs('#gc-exe-browse').addEventListener('click', async () => {
|
qs('#gc-exe-browse').addEventListener('click', async () => {
|
||||||
const cur = absPathInput(qs('#gc-workdir').value.trim())
|
const cur = absPathInput(qs('#gc-workdir').value.trim())
|
||||||
|
|
|
||||||
|
|
@ -101,6 +101,10 @@
|
||||||
<span id="set-wine"></span>
|
<span id="set-wine"></span>
|
||||||
<label class="field-label" data-t="settings.defaultPrefix"></label>
|
<label class="field-label" data-t="settings.defaultPrefix"></label>
|
||||||
<span id="set-prefix"></span>
|
<span id="set-prefix"></span>
|
||||||
|
<label class="check-label">
|
||||||
|
<input type="checkbox" id="set-closetotray">
|
||||||
|
<span data-t="settings.closeToTray"></span>
|
||||||
|
</label>
|
||||||
<div class="form-actions">
|
<div class="form-actions">
|
||||||
<button class="button primary" id="btn-settings-save" data-t="settings.save"></button>
|
<button class="button primary" id="btn-settings-save" data-t="settings.save"></button>
|
||||||
<button class="button" id="btn-open-files" data-t="settings.openFiles"></button>
|
<button class="button" id="btn-open-files" data-t="settings.openFiles"></button>
|
||||||
|
|
@ -163,6 +167,7 @@
|
||||||
|
|
||||||
<div class="form-actions">
|
<div class="form-actions">
|
||||||
<button class="button danger" id="btn-gc-clear-playtime" data-t="config.clearPlaytime"></button>
|
<button class="button danger" id="btn-gc-clear-playtime" data-t="config.clearPlaytime"></button>
|
||||||
|
<button class="button" id="btn-gc-shortcut" data-t="config.shortcut"></button>
|
||||||
<button class="button primary" id="btn-gc-save" data-t="config.save"></button>
|
<button class="button primary" id="btn-gc-save" data-t="config.save"></button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -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); }
|
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; }
|
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,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='3.5' stroke-linecap='round' stroke-linejoin='round'><path d='M4 12.5l5.5 5.5L20 6.5'/></svg>") center / contain no-repeat;
|
||||||
|
mask: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='3.5' stroke-linecap='round' stroke-linejoin='round'><path d='M4 12.5l5.5 5.5L20 6.5'/></svg>") center / contain no-repeat;
|
||||||
|
}
|
||||||
|
.check-label input[type='checkbox']:checked + span { color: var(--text-color); }
|
||||||
|
|
||||||
select {
|
select {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,9 +18,15 @@ function sendToWindow(win, channel, data) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Game-process state: id -> pid (or null while the process is starting).
|
// 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 running = new Map()
|
||||||
const killRequests = new Set()
|
const killRequests = new Set()
|
||||||
|
let onGameExit = null
|
||||||
|
const notifyChanged = () => {
|
||||||
|
if (typeof onGamesChanged === 'function') onGamesChanged()
|
||||||
|
}
|
||||||
|
|
||||||
const isRunning = id => running.has(id)
|
const isRunning = id => running.has(id)
|
||||||
const listRunning = () => [...running.keys()]
|
const listRunning = () => [...running.keys()]
|
||||||
|
|
@ -35,6 +41,8 @@ function createGamesService({ getWindow }) {
|
||||||
function handleExit(id) {
|
function handleExit(id) {
|
||||||
running.delete(id)
|
running.delete(id)
|
||||||
sendToWindow(getWindow(), 'games:changed')
|
sendToWindow(getWindow(), 'games:changed')
|
||||||
|
notifyChanged()
|
||||||
|
if (onGameExit) onGameExit(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function start(win, id) {
|
async function start(win, id) {
|
||||||
|
|
@ -58,7 +66,8 @@ function createGamesService({ getWindow }) {
|
||||||
if (res.pid) killProcessGroup(res.pid)
|
if (res.pid) killProcessGroup(res.pid)
|
||||||
}
|
}
|
||||||
sendToWindow(win, 'games:changed')
|
sendToWindow(win, 'games:changed')
|
||||||
return { ok: true }
|
notifyChanged()
|
||||||
|
return { ok: true, pid: res.pid || null, logFile: res.logFile || null }
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
running.delete(id)
|
running.delete(id)
|
||||||
return { ok: false, error: String((err && err.message) || err) }
|
return { ok: false, error: String((err && err.message) || err) }
|
||||||
|
|
@ -71,6 +80,7 @@ function createGamesService({ getWindow }) {
|
||||||
killProcessGroup(pid)
|
killProcessGroup(pid)
|
||||||
running.delete(id)
|
running.delete(id)
|
||||||
sendToWindow(getWindow(), 'games:changed')
|
sendToWindow(getWindow(), 'games:changed')
|
||||||
|
notifyChanged()
|
||||||
} else if (running.has(id)) {
|
} else if (running.has(id)) {
|
||||||
killRequests.add(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) {
|
async function importTo(win, srcDir, mode) {
|
||||||
return importGame(srcDir, mode, data => sendToWindow(win, 'import:progress', data))
|
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 }
|
module.exports = { createGamesService, killProcessGroup, sendToWindow }
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ const prefixes = require('../prefixes')
|
||||||
const runner = require('../runner')
|
const runner = require('../runner')
|
||||||
const { extractIconFromExe } = require('../icons')
|
const { extractIconFromExe } = require('../icons')
|
||||||
const { createGamesService, sendToWindow } = require('./games')
|
const { createGamesService, sendToWindow } = require('./games')
|
||||||
|
const shortcuts = require('../shortcuts')
|
||||||
|
|
||||||
const ICON_EXTS = ['png', 'jpe?g', 'svg', 'ico', 'webp', 'gif', 'bmp']
|
const ICON_EXTS = ['png', 'jpe?g', 'svg', 'ico', 'webp', 'gif', 'bmp']
|
||||||
|
|
||||||
|
|
@ -39,8 +40,8 @@ function extractGameIcon(id, exePath) {
|
||||||
|
|
||||||
// --- registration -----------------------------------------------------------
|
// --- registration -----------------------------------------------------------
|
||||||
|
|
||||||
function registerIpc({ getWindow }) {
|
function registerIpc({ getWindow, onGamesChanged }) {
|
||||||
const games = createGamesService({ getWindow })
|
const games = createGamesService({ getWindow, onGamesChanged })
|
||||||
|
|
||||||
// i18n ---------------------------------------------------------------------
|
// i18n ---------------------------------------------------------------------
|
||||||
ipcMain.handle('i18n:list', async () => {
|
ipcMain.handle('i18n:list', async () => {
|
||||||
|
|
@ -93,7 +94,20 @@ function registerIpc({ getWindow }) {
|
||||||
ipcMain.handle('games:list', () => library.loadGames())
|
ipcMain.handle('games:list', () => library.loadGames())
|
||||||
ipcMain.handle('games:get', (e, id) => library.getGame(id))
|
ipcMain.handle('games:get', (e, id) => library.getGame(id))
|
||||||
ipcMain.handle('games:update', (e, id, patch) => library.updateGame(id, patch))
|
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))
|
ipcMain.handle('games:clearPlaytime', (e, id) => library.clearPlaytime(id))
|
||||||
|
|
||||||
// games: icons ---------------------------------------------------------------------
|
// games: icons ---------------------------------------------------------------------
|
||||||
|
|
|
||||||
202
src/main.js
202
src/main.js
|
|
@ -7,28 +7,216 @@ const wines = require('./wines')
|
||||||
const { registerIpc } = require('./ipc')
|
const { registerIpc } = require('./ipc')
|
||||||
const { createMainWindow } = require('./window')
|
const { createMainWindow } = require('./window')
|
||||||
const { setAppIcon } = require('./appicon')
|
const { setAppIcon } = require('./appicon')
|
||||||
|
const path = require('path')
|
||||||
|
const fs = require('fs')
|
||||||
|
|
||||||
app.setName('PLWL')
|
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 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
|
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 () => {
|
app.whenReady().then(async () => {
|
||||||
if (process.platform === 'linux') {
|
if (process.platform === 'linux') {
|
||||||
try {
|
try {
|
||||||
setAppIcon(app)
|
setAppIcon(app)
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
registerIpc({ getWindow })
|
|
||||||
ensureDirs()
|
ensureDirs()
|
||||||
await i18n.init()
|
await i18n.init()
|
||||||
await wines.refreshWines()
|
await wines.refreshWines()
|
||||||
win = createMainWindow()
|
const { games } = registerIpc({ getWindow, onGamesChanged: rebuildTrayMenu })
|
||||||
win.on('closed', () => {
|
gamesService = games
|
||||||
win = null
|
|
||||||
})
|
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.on('before-quit', () => {
|
||||||
app.quit()
|
quitting = true
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,8 @@ contextBridge.exposeInMainWorld('plwl', {
|
||||||
gamesIconPath: id => ipcRenderer.invoke('games:iconPath', id),
|
gamesIconPath: id => ipcRenderer.invoke('games:iconPath', id),
|
||||||
gamesResetIcon: id => ipcRenderer.invoke('games:resetIcon', id),
|
gamesResetIcon: id => ipcRenderer.invoke('games:resetIcon', id),
|
||||||
gamesRemove: id => ipcRenderer.invoke('games:remove', 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'),
|
gamesPickFolder: () => ipcRenderer.invoke('games:pickFolder'),
|
||||||
gamesImport: (src, mode) => ipcRenderer.invoke('games:import', src, mode),
|
gamesImport: (src, mode) => ipcRenderer.invoke('games:import', src, mode),
|
||||||
gamesRun: id => ipcRenderer.invoke('games:run', id),
|
gamesRun: id => ipcRenderer.invoke('games:run', id),
|
||||||
|
|
|
||||||
124
src/shortcuts.js
Normal file
124
src/shortcuts.js
Normal 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 }
|
||||||
Loading…
Add table
Add a link
Reference in a new issue