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 0000000..24cf4e8 Binary files /dev/null and b/renderer/plgl.png differ diff --git a/renderer/plgl.svg b/renderer/plgl.svg new file mode 100644 index 0000000..e14f3ba --- /dev/null +++ b/renderer/plgl.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + 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,