add app and apps icons

This commit is contained in:
olcxja 2026-09-25 18:42:03 +02:00
commit 7508811374
16 changed files with 554 additions and 7 deletions

54
src/appicon.js Normal file
View 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
View 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 }

View file

@ -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
}

View file

@ -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()

View file

@ -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),

View file

@ -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 })

View file

@ -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,