add app and apps icons
This commit is contained in:
parent
e0173d410b
commit
7508811374
16 changed files with 554 additions and 7 deletions
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,
|
||||
exePath,
|
||||
workdir,
|
||||
wine: 'system',
|
||||
wine: 'default',
|
||||
prefix: '',
|
||||
args: '',
|
||||
dllOverrides: [],
|
||||
env: {},
|
||||
iconSource: '',
|
||||
playSeconds: 0,
|
||||
created: Date.now(),
|
||||
lastPlayed: 0
|
||||
}
|
||||
|
|
|
|||
85
src/main.js
85
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()
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue