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 @@
-
-
+
+
+
![]()
+
+
+
+
+
+
+
+
+
+
+
+
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,