From 7508811374fcd4d1a96219049bb377cdd092f406 Mon Sep 17 00:00:00 2001 From: olcxja Date: Fri, 25 Sep 2026 18:42:03 +0200 Subject: [PATCH] add app and apps icons --- build.sh | 3 +- files/translations/en-us.json | 13 +++ files/translations/pl.json | 13 +++ renderer/app.js | 93 ++++++++++++++++++ renderer/index.html | 20 +++- renderer/plgl.png | Bin 0 -> 35781 bytes renderer/plgl.svg | 14 +++ renderer/style.css | 56 +++++++++++ run.sh | 1 + src/appicon.js | 54 +++++++++++ src/icons.js | 174 ++++++++++++++++++++++++++++++++++ src/library.js | 4 +- src/main.js | 85 ++++++++++++++++- src/preload.js | 6 ++ src/runner.js | 10 +- src/window.js | 15 +++ 16 files changed, 554 insertions(+), 7 deletions(-) create mode 100644 renderer/plgl.png create mode 100644 renderer/plgl.svg create mode 100644 src/appicon.js create mode 100644 src/icons.js diff --git a/build.sh b/build.sh index 8785dff..beff2fa 100755 --- a/build.sh +++ b/build.sh @@ -6,9 +6,10 @@ VERSION=$(node -p "require('./package.json').version") OUT="build/PLGL-$VERSION.tar.gz" rm -rf build/dist -mkdir -p build/dist/PLGL +mkdir -p build/dist/PLGL build/files cp -r src renderer files package.json build/dist/PLGL/ +cp -r files/translations build/files/ 2>/dev/null || true if [ ! -d node_modules/electron/dist ]; then npm install diff --git a/files/translations/en-us.json b/files/translations/en-us.json index 730b573..832fd24 100644 --- a/files/translations/en-us.json +++ b/files/translations/en-us.json @@ -23,6 +23,10 @@ "library.remove": "Remove", "library.openFolder": "Open folder", "library.lastPlayed": "Last played {0}", + "library.playtime": "Playtime: {0}", + "library.playtimeSeconds": "{0}s", + "library.playtimeMin": "{0} min", + "library.playtimeHM": "{0}h {1} min", "library.never": "Never", "library.portable": "Portable", "library.external": "External", @@ -42,6 +46,15 @@ "import.failed": "Import failed: {0}", "config.title": "App configuration", + "config.icon.auto": "Extract from exe", + "config.icon.browse": "Choose image", + "config.icon.remove": "Remove icon", + "config.icon.pick": "Choose an icon image", + "config.icon.set": "Icon updated", + "config.icon.noIcon": "No icon found in this file", + "config.clearPlaytime": "Clear playtime", + "config.clearPlaytimeConfirm": "Reset the playtime counter for this app?", + "config.playtimeCleared": "Playtime cleared", "config.name": "Name", "config.workdir": "Working directory", "config.exePath": "Executable", diff --git a/files/translations/pl.json b/files/translations/pl.json index c363b91..0cb8d15 100644 --- a/files/translations/pl.json +++ b/files/translations/pl.json @@ -23,6 +23,10 @@ "library.remove": "Usuń", "library.openFolder": "Otwórz folder", "library.lastPlayed": "Ostatnio grane {0}", + "library.playtime": "Czas gry: {0}", + "library.playtimeSeconds": "{0} s", + "library.playtimeMin": "{0} min", + "library.playtimeHM": "{0} godz {1} min", "library.never": "Nigdy", "library.portable": "Przenośna", "library.external": "Zewnętrzna", @@ -42,6 +46,15 @@ "import.failed": "Import nie powiódł się: {0}", "config.title": "Konfiguracja aplikacji", + "config.icon.auto": "Wyciągnij z exe", + "config.icon.browse": "Wybierz obrazek", + "config.icon.remove": "Usuń ikonę", + "config.icon.pick": "Wybierz obrazek ikony", + "config.icon.set": "Ikona zaktualizowana", + "config.icon.noIcon": "Nie znaleziono ikony w tym pliku", + "config.clearPlaytime": "Wyczyść czas gry", + "config.clearPlaytimeConfirm": "Wyzerować licznik czasu gry dla tej aplikacji?", + "config.playtimeCleared": "Czas gry wyczyszczony", "config.name": "Nazwa", "config.workdir": "Katalog roboczy", "config.exePath": "Plik wykonywalny", diff --git a/renderer/app.js b/renderer/app.js index e7f8857..58aec48 100644 --- a/renderer/app.js +++ b/renderer/app.js @@ -262,6 +262,31 @@ function wineLabel(id) { return w ? w.label : (id === 'system' ? t('wines.system') : id) } +function iconUrl(id) { + return `plgl-icon://${encodeURIComponent(id)}/icon` +} + +function fmtPlaytime(seconds) { + const s = Math.round(seconds || 0) + if (s < 60) return t('library.playtimeSeconds', s) + const h = Math.floor(s / 3600) + const m = Math.floor((s % 3600) / 60) + if (h > 0) return t('library.playtimeHM', h, m) + return t('library.playtimeMin', m) +} + +async function attachCardIcon(card, game) { + const p = await window.plgl.gamesIconPath(game.id) + if (!p) return + const img = document.createElement('img') + img.className = 'game-icon' + img.alt = '' + img.onerror = () => img.remove() + img.onload = () => card.prepend(img) + img.src = iconUrl(game.id) + if (img.complete && img.naturalWidth > 0) card.prepend(img) +} + function renderLibrary() { const list = qs('#game-list') list.innerHTML = '' @@ -280,6 +305,7 @@ function renderLibrary() { ${wineLabel(game.wine)}
${t('library.lastPlayed', last)}
+
${t('library.playtime', fmtPlaytime(game.playSeconds || 0))}
@@ -321,6 +347,7 @@ function renderLibrary() { renderLibrary() } list.appendChild(card) + attachCardIcon(card, game) }) } @@ -571,6 +598,31 @@ function renderPrefixOptions(selectId, selected) { else selects[selectId].update(opts, selected || '') } +async function refreshConfigIcon() { + const game = state.currentGame + if (!game) return + const p = await window.plgl.gamesIconPath(game.id) + const img = qs('#gc-icon') + const ph = qs('#gc-icon-placeholder') + img.onerror = () => { + img.style.display = 'none' + img.removeAttribute('src') + ph.style.display = 'flex' + } + if (p) { + img.onload = () => { + img.style.display = '' + ph.style.display = 'none' + } + img.src = iconUrl(game.id) + '?t=' + Date.now() + if (img.complete && img.naturalWidth > 0) img.onload() + } else { + img.style.display = 'none' + img.removeAttribute('src') + ph.style.display = 'flex' + } +} + async function openGameConfig(id) { const game = state.games.find(g => g.id === id) if (!game) return @@ -578,6 +630,7 @@ async function openGameConfig(id) { showView('gameconfig') qs('#gc-title').textContent = game.name qs('#gc-name').value = game.name + refreshConfigIcon() qs('#gc-exe').value = game.exePath qs('#gc-workdir').value = game.workdir qs('#gc-args').value = game.args || '' @@ -744,6 +797,46 @@ function wireEvents() { if (p) qs('#gc-workdir').value = p }) qs('#gc-tool-run').addEventListener('click', runCustomTool) + qs('#gc-icon-auto').addEventListener('click', async () => { + const res = await window.plgl.gamesAutoIcon(state.currentGame.id) + if (res.ok) { + toast(t('config.icon.set'), 'success') + refreshConfigIcon() + } else { + toast(t('config.icon.noIcon'), 'error') + } + }) + qs('#gc-icon-browse').addEventListener('click', async () => { + const p = await window.plgl.dialogPickFile({ + properties: ['openFile'], + title: t('config.icon.pick'), + filters: [{ name: t('config.icon.pick'), extensions: ['png', 'jpg', 'jpeg', 'svg', 'ico', 'webp', 'gif', 'bmp', 'exe'] }] + }) + if (!p) return + let res + if (p.toLowerCase().endsWith('.exe')) { + res = await window.plgl.gamesSetIconFile(state.currentGame.id, p) + if (res.ok) toast(t('config.icon.set'), 'success') + else toast(t('config.icon.noIcon'), 'error') + } else { + res = await window.plgl.gamesSetIconFile(state.currentGame.id, p) + if (res.ok) toast(t('config.icon.set'), 'success') + else toast(t('config.icon.noIcon'), 'error') + } + refreshConfigIcon() + }) + qs('#gc-icon-reset').addEventListener('click', async () => { + await window.plgl.gamesResetIcon(state.currentGame.id) + refreshConfigIcon() + }) + qs('#btn-gc-clear-playtime').addEventListener('click', async () => { + const ok = await confirmModal(t('config.clearPlaytimeConfirm'), t('config.clearPlaytime')) + if (!ok) return + await window.plgl.gamesClearPlaytime(state.currentGame.id) + state.games = await window.plgl.gamesList() + state.currentGame = state.games.find(g => g.id === state.currentGame.id) + toast(t('config.playtimeCleared'), 'success') + }) qs('#modal-overlay').addEventListener('click', e => { if (e.target === qs('#modal-overlay')) closeModal() }) diff --git a/renderer/index.html b/renderer/index.html index 26917f1..903a065 100644 --- a/renderer/index.html +++ b/renderer/index.html @@ -2,7 +2,7 @@ - + PLGL @@ -117,8 +117,21 @@
- - +
+
+ +
+
+
+ + +
+
+
+ + + +
@@ -149,6 +162,7 @@
+
diff --git a/renderer/plgl.png b/renderer/plgl.png new file mode 100644 index 0000000000000000000000000000000000000000..24cf4e8a1094e6b281623823e782cb41728aacd6 GIT binary patch literal 35781 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%0DG1OPxZ5G>r}j&FRI&gK*$I^Y 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 + + + + + + + + + + + + + diff --git a/renderer/style.css b/renderer/style.css index 24df0ac..e32d5ec 100644 --- a/renderer/style.css +++ b/renderer/style.css @@ -162,6 +162,54 @@ content { font-size: 1.8rem; } .header-actions { display: flex; gap: 0.6rem; align-items: center; } + +.gc-head { + display: flex; + gap: 1rem; + align-items: flex-start; +} +.gc-head-main { flex: 1; } +.gc-head-main .field-label { margin-top: 0; } +.gc-icon-box { + width: 6rem; + height: 6rem; + border-radius: 0.9rem; + border: var(--border-width) solid var(--light-border-color); + background: rgba(255, 255, 255, 0.03); + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; + flex-shrink: 0; +} +.gc-icon-box img { + width: 100%; + height: 100%; + object-fit: contain; +} +.gc-icon-placeholder { + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + color: var(--darker-text-color); + opacity: 0.5; +} +.gc-icon-placeholder::before { + content: ''; + width: 2.4rem; + height: 2.4rem; + background: currentColor; + -webkit-mask: url("data:image/svg+xml;utf8,") center / contain no-repeat; + mask: url("data:image/svg+xml;utf8,") center / contain no-repeat; +} +.icon-actions { + display: flex; + gap: 0.5rem; + margin-top: 0.4rem; + flex-wrap: wrap; +} .subtitle { color: var(--darker-text-color); font-size: 0.9rem; margin-top: 0.2rem; } .button { @@ -392,6 +440,14 @@ select { gap: 0.5rem; animation: view-in 0.3s var(--spring); } +.game-icon { + width: 4rem; + height: 4rem; + object-fit: contain; + border-radius: 0.6rem; + background: rgba(255, 255, 255, 0.03); + align-self: flex-start; +} .game-card:hover { border-color: rgba(255, 255, 255, 0.2); } .game-card h3 { font-size: 1.05rem; diff --git a/run.sh b/run.sh index 4fb5c3d..aad6053 100755 --- a/run.sh +++ b/run.sh @@ -8,5 +8,6 @@ if [ ! -d node_modules ]; then fi mkdir -p build/files +cp -r files/translations build/files/ 2>/dev/null || true exec npx electron . "$@" diff --git a/src/appicon.js b/src/appicon.js new file mode 100644 index 0000000..0f8b049 --- /dev/null +++ b/src/appicon.js @@ -0,0 +1,54 @@ +'use strict' + +const path = require('path') +const fs = require('fs') + +const APP_NAME = 'PLGL' + +function findPng() { + const p = path.resolve(__dirname, '..', 'renderer', 'plgl.png') + try { + fs.accessSync(p) + return p + } catch { + return null + } +} + +function writeIconThemeDirs(png) { + try { + const sizes = { '16x16': 16, '32x32': 32, '48x48': 48, '256x256': 256 } + const home = process.env.HOME + if (!home) return + const base = path.join(home, '.local', 'share', 'icons') + for (const [dir, size] of Object.entries(sizes)) { + const themeDir = path.join(base, 'hicolor', dir, 'apps') + fs.mkdirSync(themeDir, { recursive: true }) + fs.copyFileSync(png, path.join(themeDir, 'plgl.png')) + } + const desktopDir = path.join(home, '.local', 'share', 'applications') + fs.mkdirSync(desktopDir, { recursive: true }) + const desktop = [ + '[Desktop Entry]', + 'Type=Application', + `Name=${APP_NAME}`, + 'Comment=Portable Linux Wine Launcher', + 'Exec=plgl', + `Icon=plgl`, + 'Terminal=false', + 'Categories=Game;' + ].join('\n') + fs.writeFileSync(path.join(desktopDir, 'plgl.desktop'), desktop) + } catch {} +} + +function setAppIcon(app) { + const png = findPng() + if (!png) return + try { + app.setIcon(png) + } catch {} + writeIconThemeDirs(png) +} + +module.exports = { setAppIcon } diff --git a/src/icons.js b/src/icons.js new file mode 100644 index 0000000..0907101 --- /dev/null +++ b/src/icons.js @@ -0,0 +1,174 @@ +'use strict' + +const fs = require('fs') +const path = require('path') + +function rvaToOffset(sections, rva) { + for (const s of sections) { + if (rva >= s.va && rva < s.va + (s.vs || s.rawSize)) return rva - s.va + s.raw + } + return null +} + +function dirEntries(buf, dirOff) { + const named = buf.readUInt16LE(dirOff + 12) + const ids = buf.readUInt16LE(dirOff + 14) + const out = [] + for (let i = 0; i < named + ids; i++) { + const e = dirOff + 16 + i * 8 + const nameOrId = buf.readUInt32LE(e) + const value = buf.readUInt32LE(e + 4) + out.push({ id: nameOrId & 0x7fffffff, isName: (nameOrId & 0x80000000) !== 0, value, isDir: (value & 0x80000000) !== 0 }) + } + return out +} + +function parseIcons(buf) { + try { + if (buf.length < 64 || buf.readUInt16LE(0) !== 0x5a4d) return null + const peOff = buf.readUInt32LE(0x3c) + if (peOff + 24 > buf.length || buf.readUInt32LE(peOff) !== 0x00004550) return null + const numSections = buf.readUInt16LE(peOff + 6) + const optSize = buf.readUInt16LE(peOff + 20) + const optOff = peOff + 24 + const magic = buf.readUInt16LE(optOff) + const resRva = magic === 0x20b ? buf.readUInt32LE(optOff + 128) : buf.readUInt32LE(optOff + 112) + if (!resRva) return [] + + const secOff = optOff + optSize + const sections = [] + for (let i = 0; i < numSections; i++) { + const s = secOff + i * 40 + sections.push({ + va: buf.readUInt32LE(s + 12), + vs: buf.readUInt32LE(s + 8), + rawSize: buf.readUInt32LE(s + 16), + raw: buf.readUInt32LE(s + 20) + }) + } + + const rootOff = rvaToOffset(sections, resRva) + if (rootOff == null) return [] + + const typeEntry = dirEntries(buf, rootOff).find(e => e.id === 3 && e.isDir) + if (!typeEntry) return [] + const iconDirOff = rvaToOffset(sections, resRva + (typeEntry.value & 0x7fffffff)) + if (iconDirOff == null) return [] + + const iconDatas = [] + for (const nameEntry of dirEntries(buf, iconDirOff)) { + if (!nameEntry.isDir) continue + const langDirOff = rvaToOffset(sections, resRva + (nameEntry.value & 0x7fffffff)) + if (langDirOff == null) continue + const langEntry = dirEntries(buf, langDirOff)[0] + if (!langEntry || langEntry.isDir) continue + const dataEntryOff = rvaToOffset(sections, resRva + langEntry.value) + if (dataEntryOff == null) continue + const dataRva = buf.readUInt32LE(dataEntryOff) + const dataSize = buf.readUInt32LE(dataEntryOff + 4) + const dataOff = rvaToOffset(sections, dataRva) + if (dataOff == null || !dataSize) continue + iconDatas.push({ id: nameEntry.id, buf: Buffer.from(buf.subarray(dataOff, dataOff + dataSize)) }) + } + if (!iconDatas.length) return [] + + const groupEntry = dirEntries(buf, rootOff).find(e => e.id === 14 && e.isDir) + if (groupEntry) { + const grpDirOff = rvaToOffset(sections, resRva + (groupEntry.value & 0x7fffffff)) + if (grpDirOff != null) { + for (const nameEntry of dirEntries(buf, grpDirOff)) { + if (!nameEntry.isDir) continue + const langDirOff = rvaToOffset(sections, resRva + (nameEntry.value & 0x7fffffff)) + if (langDirOff == null) continue + const langEntry = dirEntries(buf, langDirOff)[0] + if (!langEntry || langEntry.isDir) continue + const dataEntryOff = rvaToOffset(sections, resRva + langEntry.value) + if (dataEntryOff == null) continue + const dataRva = buf.readUInt32LE(dataEntryOff) + const dataSize = buf.readUInt32LE(dataEntryOff + 4) + const dataOff = rvaToOffset(sections, dataRva) + if (dataOff == null || !dataSize) continue + const grp = buf.subarray(dataOff, dataOff + dataSize) + if (grp.length < 6) continue + const count = grp.readUInt16LE(4) + const best = { area: -1, buf: null } + for (let i = 0; i < count; i++) { + const e = 6 + i * 14 + const w = grp.readUInt8(e) || 256 + const h = grp.readUInt8(e + 1) || 256 + const id = grp.readUInt16LE(e + 12) + const icon = iconDatas.find(ic => ic.id === id) + if (icon && w * h > best.area) { + best.area = w * h + best.buf = icon.buf + } + } + if (best.buf) return [best.buf] + } + } + } + return [iconDatas.reduce((a, b) => (b.buf.length > a.buf.length ? b : a)).buf] + } catch { + return null + } +} + +function pngSize(img) { + if (img.length > 24 && img.readUInt32BE(0) === 0x89504e47) { + return { w: img.readUInt32BE(16), h: img.readUInt32BE(20) } + } + return null +} + +function buildIco(images) { + const header = Buffer.alloc(6) + header.writeUInt16LE(0, 0) + header.writeUInt16LE(1, 2) + header.writeUInt16LE(images.length, 4) + const metas = [] + const datas = [] + let offset = 6 + images.length * 16 + for (const img of images) { + let w, h, colors = 0, planes = 1, bpp = 32 + if (img.readUInt32BE(0) === 0x89504e47) { + const dim = pngSize(img) || { w: 256, h: 256 } + w = dim.w + h = dim.h + } else { + w = img.readUInt8(0) || 256 + h = img.readUInt8(1) || 256 + colors = img.readUInt8(2) + planes = img.length > 4 ? img.readUInt16LE(4) || 1 : 1 + bpp = img.length > 6 ? img.readUInt16LE(6) || 32 : 32 + } + const meta = Buffer.alloc(16) + meta.writeUInt8(w >= 256 ? 0 : w, 0) + meta.writeUInt8(h >= 256 ? 0 : h, 1) + meta.writeUInt8(colors, 2) + meta.writeUInt8(0, 3) + meta.writeUInt16LE(planes, 4) + meta.writeUInt16LE(bpp, 6) + meta.writeUInt32LE(img.length, 8) + meta.writeUInt32LE(offset, 12) + metas.push(meta) + datas.push(img) + offset += img.length + } + return Buffer.concat([header, ...metas, ...datas]) +} + +function extractIconFromExe(exePath, outPath) { + try { + const buf = fs.readFileSync(exePath) + const icon = parseIcons(buf) + if (!icon || !icon.length) return { ok: false, error: 'no-icon' } + const ico = buildIco(icon) + fs.mkdirSync(path.dirname(outPath), { recursive: true }) + fs.writeFileSync(outPath, ico) + return { ok: true, path: outPath } + } catch (err) { + return { ok: false, error: String(err && err.message || err) } + } +} + +module.exports = { extractIconFromExe, parseIcons, buildIco } diff --git a/src/library.js b/src/library.js index 91c2891..514748c 100644 --- a/src/library.js +++ b/src/library.js @@ -68,11 +68,13 @@ function newGame({ name, exePath, workdir, mode }) { path: workdir, exePath, workdir, - wine: 'system', + wine: 'default', prefix: '', args: '', dllOverrides: [], env: {}, + iconSource: '', + playSeconds: 0, created: Date.now(), lastPlayed: 0 } diff --git a/src/main.js b/src/main.js index 40c060e..69de2c0 100644 --- a/src/main.js +++ b/src/main.js @@ -1,7 +1,8 @@ 'use strict' -const { app, BrowserWindow, ipcMain, dialog, shell } = require('electron') +const { app, BrowserWindow, ipcMain, dialog, shell, protocol, net } = require('electron') const path = require('path') +const fs = require('fs') const { ensureDirs, DIRS, readJson, writeJson } = require('./paths') const wines = require('./wines') const prefixes = require('./prefixes') @@ -109,6 +110,70 @@ ipcMain.handle('games:list', () => library.loadGames()) ipcMain.handle('games:remove', (e, id) => library.removeGame(id)) ipcMain.handle('games:get', (e, id) => library.getGame(id)) ipcMain.handle('games:update', (e, id, patch) => library.updateGame(id, patch)) +ipcMain.handle('games:clearPlaytime', (e, id) => library.updateGame(id, { playSeconds: 0, lastPlayed: 0 })) +ipcMain.handle('games:resetIcon', (e, id) => { + const game = library.getGame(id) + if (!game) return { ok: false } + for (const f of ['icon.png', 'icon.ico']) { + try { + fs.unlinkSync(path.join(DIRS.DATA, 'icons', id, f)) + } catch {} + } + library.updateGame(id, { iconSource: 'none' }) + return { ok: true } +}) +ipcMain.handle('games:setIconFile', async (e, id, filePath) => { + const game = library.getGame(id) + if (!game || !filePath) return { ok: false, error: 'bad-request' } + try { + const dir = path.join(DIRS.DATA, 'icons', id) + fs.mkdirSync(dir, { recursive: true }) + for (const f of fs.readdirSync(dir)) { + if (f.startsWith('icon.')) fs.unlinkSync(path.join(dir, f)) + } + const icon = require('./icons') + let dst + if (filePath.toLowerCase().endsWith('.exe')) { + dst = path.join(dir, 'icon.ico') + const res = icon.extractIconFromExe(filePath, dst) + if (!res.ok) return res + } else { + dst = path.join(dir, `icon${path.extname(filePath).toLowerCase() || '.png'}`) + fs.copyFileSync(filePath, dst) + } + library.updateGame(id, { iconSource: dst }) + return { ok: true, path: dst } + } catch (err) { + return { ok: false, error: String(err && err.message || err) } + } +}) +ipcMain.handle('games:extractIcon', (e, id) => { + const game = library.getGame(id) + if (!game || !game.exePath) return { ok: false, error: 'bad-request' } + const out = path.join(DIRS.DATA, 'icons', id, 'icon.ico') + const res = require('./icons').extractIconFromExe(game.exePath, out) + if (res.ok) library.updateGame(id, { iconSource: out }) + return res +}) +ipcMain.handle('games:autoIcon', (e, id) => { + const game = library.getGame(id) + if (!game || !game.exePath) return { ok: false, error: 'bad-request' } + const dir = path.join(DIRS.DATA, 'icons', id) + try { + for (const f of fs.readdirSync(dir)) { + if (f.startsWith('icon.')) fs.unlinkSync(path.join(dir, f)) + } + } catch {} + const out = path.join(dir, 'icon.ico') + const res = require('./icons').extractIconFromExe(game.exePath, out) + if (res.ok) library.updateGame(id, { iconSource: out }) + return res +}) +ipcMain.handle('games:iconPath', (e, id) => { + const game = library.getGame(id) + if (!game || !game.iconSource) return null + return fs.existsSync(game.iconSource) ? game.iconSource : null +}) ipcMain.handle('games:pickFolder', async e => { const res = await dialog.showOpenDialog(win, { @@ -238,6 +303,24 @@ ipcMain.handle('games:isRunning', (e, id) => running.has(id)) ipcMain.handle('games:listRunning', () => [...running.keys()]) app.whenReady().then(async () => { + if (process.platform === 'linux') { + const { setAppIcon } = require('./appicon') + try { + setAppIcon(app) + } catch {} + } + protocol.handle('plgl-icon', request => { + const id = decodeURIComponent(new URL(request.url).hostname) + const dir = path.join(DIRS.DATA, 'icons', id) + let file = null + try { + const files = fs.readdirSync(dir).filter(f => f.startsWith('icon.')).sort() + const pick = files.find(f => /^icon\.(png|jpe?g|svg|ico|webp|gif|bmp)$/i.test(f)) + if (pick) file = path.join(dir, pick) + } catch {} + if (!file) return new Response('', { status: 404 }) + return net.fetch('file://' + file) + }) ensureDirs() await i18n.init() await wines.refreshWines() diff --git a/src/preload.js b/src/preload.js index 153da5a..b2713a5 100644 --- a/src/preload.js +++ b/src/preload.js @@ -22,6 +22,12 @@ contextBridge.exposeInMainWorld('plgl', { gamesList: () => ipcRenderer.invoke('games:list'), gamesGet: id => ipcRenderer.invoke('games:get', id), gamesUpdate: (id, patch) => ipcRenderer.invoke('games:update', id, patch), + gamesClearPlaytime: id => ipcRenderer.invoke('games:clearPlaytime', id), + gamesSetIconFile: (id, p) => ipcRenderer.invoke('games:setIconFile', id, p), + gamesExtractIcon: id => ipcRenderer.invoke('games:extractIcon', id), + gamesAutoIcon: id => ipcRenderer.invoke('games:autoIcon', id), + gamesIconPath: id => ipcRenderer.invoke('games:iconPath', id), + gamesResetIcon: id => ipcRenderer.invoke('games:resetIcon', id), gamesRemove: id => ipcRenderer.invoke('games:remove', id), gamesPickFolder: () => ipcRenderer.invoke('games:pickFolder'), gamesImport: (src, mode) => ipcRenderer.invoke('games:import', src, mode), diff --git a/src/runner.js b/src/runner.js index 1b2fa21..60ef235 100644 --- a/src/runner.js +++ b/src/runner.js @@ -75,6 +75,8 @@ function parseArgs(args) { return out } +const GRACE_MS = 60000 + function runGame({ game, wine, systemWine, onExit }) { const isProton = wine && wine.type === 'proton' const { env } = buildEnv({ game, wine }) @@ -112,9 +114,15 @@ function runGame({ game, wine, systemWine, onExit }) { child.stderr.pipe(ws) child.on('error', reject) child.unref() + const startedAt = Date.now() child.on('exit', (code, signal) => { ws.end() - require('./library').updateGame(game.id, { lastPlayed: Date.now() }) + const playedMs = Date.now() - startedAt + const counted = playedMs > GRACE_MS ? Math.round((playedMs - GRACE_MS) / 1000) : 0 + require('./library').updateGame(game.id, { + lastPlayed: Date.now(), + playSeconds: (game.playSeconds || 0) + counted + }) if (onExit) onExit(code, signal) }) resolve({ pid: child.pid, logFile }) diff --git a/src/window.js b/src/window.js index 8209fdf..c7ec68b 100644 --- a/src/window.js +++ b/src/window.js @@ -5,6 +5,20 @@ const fs = require('fs') const DIST = path.resolve(__dirname, '..', 'renderer') +function resolveIcon() { + const candidates = [ + path.join(DIST, 'plgl.png'), + path.join(DIST, 'plgl.svg') + ] + for (const c of candidates) { + try { + fs.accessSync(c) + return c + } catch {} + } + return undefined +} + function createMainWindow() { const { BrowserWindow } = require('electron') const win = new BrowserWindow({ @@ -14,6 +28,7 @@ function createMainWindow() { minHeight: 600, backgroundColor: '#141414', autoHideMenuBar: true, + icon: resolveIcon(), webPreferences: { preload: path.join(__dirname, 'preload.js'), contextIsolation: true,