add app and apps icons
This commit is contained in:
parent
e0173d410b
commit
7508811374
16 changed files with 554 additions and 7 deletions
3
build.sh
3
build.sh
|
|
@ -6,9 +6,10 @@ VERSION=$(node -p "require('./package.json').version")
|
||||||
OUT="build/PLGL-$VERSION.tar.gz"
|
OUT="build/PLGL-$VERSION.tar.gz"
|
||||||
|
|
||||||
rm -rf build/dist
|
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 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
|
if [ ! -d node_modules/electron/dist ]; then
|
||||||
npm install
|
npm install
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,10 @@
|
||||||
"library.remove": "Remove",
|
"library.remove": "Remove",
|
||||||
"library.openFolder": "Open folder",
|
"library.openFolder": "Open folder",
|
||||||
"library.lastPlayed": "Last played {0}",
|
"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.never": "Never",
|
||||||
"library.portable": "Portable",
|
"library.portable": "Portable",
|
||||||
"library.external": "External",
|
"library.external": "External",
|
||||||
|
|
@ -42,6 +46,15 @@
|
||||||
"import.failed": "Import failed: {0}",
|
"import.failed": "Import failed: {0}",
|
||||||
|
|
||||||
"config.title": "App configuration",
|
"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.name": "Name",
|
||||||
"config.workdir": "Working directory",
|
"config.workdir": "Working directory",
|
||||||
"config.exePath": "Executable",
|
"config.exePath": "Executable",
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,10 @@
|
||||||
"library.remove": "Usuń",
|
"library.remove": "Usuń",
|
||||||
"library.openFolder": "Otwórz folder",
|
"library.openFolder": "Otwórz folder",
|
||||||
"library.lastPlayed": "Ostatnio grane {0}",
|
"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.never": "Nigdy",
|
||||||
"library.portable": "Przenośna",
|
"library.portable": "Przenośna",
|
||||||
"library.external": "Zewnętrzna",
|
"library.external": "Zewnętrzna",
|
||||||
|
|
@ -42,6 +46,15 @@
|
||||||
"import.failed": "Import nie powiódł się: {0}",
|
"import.failed": "Import nie powiódł się: {0}",
|
||||||
|
|
||||||
"config.title": "Konfiguracja aplikacji",
|
"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.name": "Nazwa",
|
||||||
"config.workdir": "Katalog roboczy",
|
"config.workdir": "Katalog roboczy",
|
||||||
"config.exePath": "Plik wykonywalny",
|
"config.exePath": "Plik wykonywalny",
|
||||||
|
|
|
||||||
|
|
@ -262,6 +262,31 @@ function wineLabel(id) {
|
||||||
return w ? w.label : (id === 'system' ? t('wines.system') : 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() {
|
function renderLibrary() {
|
||||||
const list = qs('#game-list')
|
const list = qs('#game-list')
|
||||||
list.innerHTML = ''
|
list.innerHTML = ''
|
||||||
|
|
@ -280,6 +305,7 @@ function renderLibrary() {
|
||||||
<span class="badge">${wineLabel(game.wine)}</span>
|
<span class="badge">${wineLabel(game.wine)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="meta">${t('library.lastPlayed', last)}</div>
|
<div class="meta">${t('library.lastPlayed', last)}</div>
|
||||||
|
<div class="meta">${t('library.playtime', fmtPlaytime(game.playSeconds || 0))}</div>
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<button class="button primary small" data-act="play">${t('library.play')}</button>
|
<button class="button primary small" data-act="play">${t('library.play')}</button>
|
||||||
<button class="button small danger" data-act="kill" style="display:none">${t('library.stop')}</button>
|
<button class="button small danger" data-act="kill" style="display:none">${t('library.stop')}</button>
|
||||||
|
|
@ -321,6 +347,7 @@ function renderLibrary() {
|
||||||
renderLibrary()
|
renderLibrary()
|
||||||
}
|
}
|
||||||
list.appendChild(card)
|
list.appendChild(card)
|
||||||
|
attachCardIcon(card, game)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -571,6 +598,31 @@ function renderPrefixOptions(selectId, selected) {
|
||||||
else selects[selectId].update(opts, 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) {
|
async function openGameConfig(id) {
|
||||||
const game = state.games.find(g => g.id === id)
|
const game = state.games.find(g => g.id === id)
|
||||||
if (!game) return
|
if (!game) return
|
||||||
|
|
@ -578,6 +630,7 @@ async function openGameConfig(id) {
|
||||||
showView('gameconfig')
|
showView('gameconfig')
|
||||||
qs('#gc-title').textContent = game.name
|
qs('#gc-title').textContent = game.name
|
||||||
qs('#gc-name').value = game.name
|
qs('#gc-name').value = game.name
|
||||||
|
refreshConfigIcon()
|
||||||
qs('#gc-exe').value = game.exePath
|
qs('#gc-exe').value = game.exePath
|
||||||
qs('#gc-workdir').value = game.workdir
|
qs('#gc-workdir').value = game.workdir
|
||||||
qs('#gc-args').value = game.args || ''
|
qs('#gc-args').value = game.args || ''
|
||||||
|
|
@ -744,6 +797,46 @@ function wireEvents() {
|
||||||
if (p) qs('#gc-workdir').value = p
|
if (p) qs('#gc-workdir').value = p
|
||||||
})
|
})
|
||||||
qs('#gc-tool-run').addEventListener('click', runCustomTool)
|
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 => {
|
qs('#modal-overlay').addEventListener('click', e => {
|
||||||
if (e.target === qs('#modal-overlay')) closeModal()
|
if (e.target === qs('#modal-overlay')) closeModal()
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:;">
|
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: plgl-icon:;">
|
||||||
<title>PLGL</title>
|
<title>PLGL</title>
|
||||||
<link rel="stylesheet" href="style.css">
|
<link rel="stylesheet" href="style.css">
|
||||||
</head>
|
</head>
|
||||||
|
|
@ -117,8 +117,21 @@
|
||||||
<button class="button" id="btn-gc-back" data-t="config.back"></button>
|
<button class="button" id="btn-gc-back" data-t="config.back"></button>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-card">
|
<div class="form-card">
|
||||||
|
<div class="gc-head">
|
||||||
|
<div class="gc-icon-box">
|
||||||
|
<img id="gc-icon" alt="" style="display:none">
|
||||||
|
<div id="gc-icon-placeholder" class="gc-icon-placeholder"></div>
|
||||||
|
</div>
|
||||||
|
<div class="gc-head-main">
|
||||||
<label class="field-label" data-t="config.name"></label>
|
<label class="field-label" data-t="config.name"></label>
|
||||||
<input type="text" id="gc-name">
|
<input type="text" id="gc-name">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="icon-actions">
|
||||||
|
<button class="button" id="gc-icon-auto" data-t="config.icon.auto"></button>
|
||||||
|
<button class="button" id="gc-icon-browse" data-t="config.icon.browse"></button>
|
||||||
|
<button class="button danger" id="gc-icon-reset" data-t="config.icon.remove"></button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<label class="field-label" data-t="config.exePath"></label>
|
<label class="field-label" data-t="config.exePath"></label>
|
||||||
<div class="path-row">
|
<div class="path-row">
|
||||||
|
|
@ -149,6 +162,7 @@
|
||||||
<button class="button" id="gc-override-add" data-t="config.overrides.add"></button>
|
<button class="button" id="gc-override-add" data-t="config.overrides.add"></button>
|
||||||
|
|
||||||
<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 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>
|
||||||
|
|
|
||||||
BIN
renderer/plgl.png
Normal file
BIN
renderer/plgl.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 35 KiB |
14
renderer/plgl.svg
Normal file
14
renderer/plgl.svg
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<stop offset="0" stop-color="#2a2a2e"/>
|
||||||
|
<stop offset="1" stop-color="#161618"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect x="16" y="16" width="224" height="224" rx="48" fill="url(#bg)" stroke="#3c3c42" stroke-width="4"/>
|
||||||
|
<g fill="none" stroke="#eaeaf0" stroke-width="14" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M78 88l40 40-40 40"/>
|
||||||
|
<path d="M132 168h46"/>
|
||||||
|
</g>
|
||||||
|
<path d="M186 64c0 22-14 34-30 38v18h-16v-18c-16-4-30-16-30-38 0-4 3-8 8-8s8 4 8 8c0 12 8 20 22 20s22-8 22-20c0-4 3-8 8-8s8 4 8 8z" fill="#eaeaf0"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 688 B |
|
|
@ -162,6 +162,54 @@ content {
|
||||||
font-size: 1.8rem;
|
font-size: 1.8rem;
|
||||||
}
|
}
|
||||||
.header-actions { display: flex; gap: 0.6rem; align-items: center; }
|
.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,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2'><rect x='3' y='3' width='18' height='18' rx='2'/><circle cx='8.5' cy='8.5' r='1.5'/><path d='M21 15l-5-5L5 21'/></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='2'><rect x='3' y='3' width='18' height='18' rx='2'/><circle cx='8.5' cy='8.5' r='1.5'/><path d='M21 15l-5-5L5 21'/></svg>") 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; }
|
.subtitle { color: var(--darker-text-color); font-size: 0.9rem; margin-top: 0.2rem; }
|
||||||
|
|
||||||
.button {
|
.button {
|
||||||
|
|
@ -392,6 +440,14 @@ select {
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
animation: view-in 0.3s var(--spring);
|
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:hover { border-color: rgba(255, 255, 255, 0.2); }
|
||||||
.game-card h3 {
|
.game-card h3 {
|
||||||
font-size: 1.05rem;
|
font-size: 1.05rem;
|
||||||
|
|
|
||||||
1
run.sh
1
run.sh
|
|
@ -8,5 +8,6 @@ if [ ! -d node_modules ]; then
|
||||||
fi
|
fi
|
||||||
|
|
||||||
mkdir -p build/files
|
mkdir -p build/files
|
||||||
|
cp -r files/translations build/files/ 2>/dev/null || true
|
||||||
|
|
||||||
exec npx electron . "$@"
|
exec npx electron . "$@"
|
||||||
|
|
|
||||||
54
src/appicon.js
Normal file
54
src/appicon.js
Normal file
|
|
@ -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 }
|
||||||
174
src/icons.js
Normal file
174
src/icons.js
Normal file
|
|
@ -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 }
|
||||||
|
|
@ -68,11 +68,13 @@ function newGame({ name, exePath, workdir, mode }) {
|
||||||
path: workdir,
|
path: workdir,
|
||||||
exePath,
|
exePath,
|
||||||
workdir,
|
workdir,
|
||||||
wine: 'system',
|
wine: 'default',
|
||||||
prefix: '',
|
prefix: '',
|
||||||
args: '',
|
args: '',
|
||||||
dllOverrides: [],
|
dllOverrides: [],
|
||||||
env: {},
|
env: {},
|
||||||
|
iconSource: '',
|
||||||
|
playSeconds: 0,
|
||||||
created: Date.now(),
|
created: Date.now(),
|
||||||
lastPlayed: 0
|
lastPlayed: 0
|
||||||
}
|
}
|
||||||
|
|
|
||||||
85
src/main.js
85
src/main.js
|
|
@ -1,7 +1,8 @@
|
||||||
'use strict'
|
'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 path = require('path')
|
||||||
|
const fs = require('fs')
|
||||||
const { ensureDirs, DIRS, readJson, writeJson } = require('./paths')
|
const { ensureDirs, DIRS, readJson, writeJson } = require('./paths')
|
||||||
const wines = require('./wines')
|
const wines = require('./wines')
|
||||||
const prefixes = require('./prefixes')
|
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:remove', (e, id) => library.removeGame(id))
|
||||||
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: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 => {
|
ipcMain.handle('games:pickFolder', async e => {
|
||||||
const res = await dialog.showOpenDialog(win, {
|
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()])
|
ipcMain.handle('games:listRunning', () => [...running.keys()])
|
||||||
|
|
||||||
app.whenReady().then(async () => {
|
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()
|
ensureDirs()
|
||||||
await i18n.init()
|
await i18n.init()
|
||||||
await wines.refreshWines()
|
await wines.refreshWines()
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,12 @@ contextBridge.exposeInMainWorld('plgl', {
|
||||||
gamesList: () => ipcRenderer.invoke('games:list'),
|
gamesList: () => ipcRenderer.invoke('games:list'),
|
||||||
gamesGet: id => ipcRenderer.invoke('games:get', id),
|
gamesGet: id => ipcRenderer.invoke('games:get', id),
|
||||||
gamesUpdate: (id, patch) => ipcRenderer.invoke('games:update', id, patch),
|
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),
|
gamesRemove: id => ipcRenderer.invoke('games:remove', 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),
|
||||||
|
|
|
||||||
|
|
@ -75,6 +75,8 @@ function parseArgs(args) {
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const GRACE_MS = 60000
|
||||||
|
|
||||||
function runGame({ game, wine, systemWine, onExit }) {
|
function runGame({ game, wine, systemWine, onExit }) {
|
||||||
const isProton = wine && wine.type === 'proton'
|
const isProton = wine && wine.type === 'proton'
|
||||||
const { env } = buildEnv({ game, wine })
|
const { env } = buildEnv({ game, wine })
|
||||||
|
|
@ -112,9 +114,15 @@ function runGame({ game, wine, systemWine, onExit }) {
|
||||||
child.stderr.pipe(ws)
|
child.stderr.pipe(ws)
|
||||||
child.on('error', reject)
|
child.on('error', reject)
|
||||||
child.unref()
|
child.unref()
|
||||||
|
const startedAt = Date.now()
|
||||||
child.on('exit', (code, signal) => {
|
child.on('exit', (code, signal) => {
|
||||||
ws.end()
|
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)
|
if (onExit) onExit(code, signal)
|
||||||
})
|
})
|
||||||
resolve({ pid: child.pid, logFile })
|
resolve({ pid: child.pid, logFile })
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,20 @@ const fs = require('fs')
|
||||||
|
|
||||||
const DIST = path.resolve(__dirname, '..', 'renderer')
|
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() {
|
function createMainWindow() {
|
||||||
const { BrowserWindow } = require('electron')
|
const { BrowserWindow } = require('electron')
|
||||||
const win = new BrowserWindow({
|
const win = new BrowserWindow({
|
||||||
|
|
@ -14,6 +28,7 @@ function createMainWindow() {
|
||||||
minHeight: 600,
|
minHeight: 600,
|
||||||
backgroundColor: '#141414',
|
backgroundColor: '#141414',
|
||||||
autoHideMenuBar: true,
|
autoHideMenuBar: true,
|
||||||
|
icon: resolveIcon(),
|
||||||
webPreferences: {
|
webPreferences: {
|
||||||
preload: path.join(__dirname, 'preload.js'),
|
preload: path.join(__dirname, 'preload.js'),
|
||||||
contextIsolation: true,
|
contextIsolation: true,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue