code cleanup

This commit is contained in:
olcxja 2026-09-26 10:13:50 +02:00
commit e8cd664965
8 changed files with 362 additions and 406 deletions

View file

@ -826,14 +826,12 @@ function wireEvents() {
if (p) qs('#gc-workdir').value = p
})
qs('#gc-tool-run').addEventListener('click', runCustomTool)
async function applyIconResult(res) {
toast(res.ok ? t('config.icon.set') : t('config.icon.noIcon'), res.ok ? 'success' : 'error')
if (res.ok) refreshConfigIcon()
}
qs('#gc-icon-auto').addEventListener('click', async () => {
const res = await window.plwl.gamesAutoIcon(state.currentGame.id)
if (res.ok) {
toast(t('config.icon.set'), 'success')
refreshConfigIcon()
} else {
toast(t('config.icon.noIcon'), 'error')
}
applyIconResult(await window.plwl.gamesAutoIcon(state.currentGame.id))
})
qs('#gc-icon-browse').addEventListener('click', async () => {
const p = await window.plwl.dialogPickFile({
@ -842,17 +840,7 @@ function wireEvents() {
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.plwl.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.plwl.gamesSetIconFile(state.currentGame.id, p)
if (res.ok) toast(t('config.icon.set'), 'success')
else toast(t('config.icon.noIcon'), 'error')
}
refreshConfigIcon()
applyIconResult(await window.plwl.gamesSetIconFile(state.currentGame.id, p))
})
qs('#gc-icon-reset').addEventListener('click', async () => {
await window.plwl.gamesResetIcon(state.currentGame.id)

15
src/fsutil.js Normal file
View file

@ -0,0 +1,15 @@
'use strict'
const fs = require('fs')
const path = require('path')
// Returns a non-existing path in `base` named `name` (or `name (2)`, `name (3)`...).
function dedupeDir(base, name) {
const candidate = path.join(base, name)
if (!fs.existsSync(candidate)) return candidate
let n = 2
while (fs.existsSync(path.join(base, `${name} (${n})`))) n++
return path.join(base, `${name} (${n})`)
}
module.exports = { dedupeDir }

View file

@ -2,24 +2,12 @@
const fsp = require('fs').promises
const path = require('path')
const fs = require('fs')
const { DIRS } = require('./paths')
const { newGame } = require('./library')
const { dedupeDir } = require('./fsutil')
function walk(dir, base) {
return fsp.readdir(dir, { withFileTypes: true }).then(async entries => {
let total = 0
for (const e of entries) {
const full = path.join(dir, e.name)
if (e.isDirectory()) total += await walk(full, base)
else if (e.isFile()) {
const st = await fsp.stat(full)
total += st.size
}
}
return total
}).catch(() => 0)
}
// Directories that typically hold installers/redistributables, not the game.
const SKIPPED_DIRS = new Set(['redist', 'directx', '_commonredist', 'support', 'dotnet', 'vc'])
function copyRecursive(src, dst, onProgress) {
return fsp.readdir(src, { withFileTypes: true }).then(async entries => {
@ -38,41 +26,28 @@ function copyRecursive(src, dst, onProgress) {
}
function moveFolder(src, dst, onProgress) {
if (path.parse(src).root !== path.parse(dst).root) {
return copyRecursive(src, dst, onProgress).then(() => fsp.rm(src, { recursive: true, force: true }))
}
return fsp.rename(src, dst).catch(() => copyRecursive(src, dst, onProgress).then(() => fsp.rm(src, { recursive: true, force: true })))
}
function dedupeName(base, name) {
let candidate = path.join(base, name)
let n = 2
while (fs.existsSync(candidate)) {
candidate = path.join(base, `${name} (${n})`)
n++
}
return candidate
const fallback = () => copyRecursive(src, dst, onProgress).then(() => fsp.rm(src, { recursive: true, force: true }))
if (path.parse(src).root !== path.parse(dst).root) return fallback()
return fsp.rename(src, dst).catch(fallback)
}
async function importGame(srcDir, mode, onProgress) {
const name = path.basename(srcDir)
let workdir = srcDir
if (mode === 'copy') {
const dst = dedupeName(DIRS.APPS, name)
await copyRecursive(srcDir, dst, onProgress)
workdir = dst
} else if (mode === 'move') {
const dst = dedupeName(DIRS.APPS, name)
await moveFolder(srcDir, dst, onProgress)
if (mode === 'copy' || mode === 'move') {
const dst = dedupeDir(DIRS.APPS, name)
if (mode === 'copy') await copyRecursive(srcDir, dst, onProgress)
else await moveFolder(srcDir, dst, onProgress)
workdir = dst
}
const exePath = await findDefaultExe(workdir)
const game = newGame({ name, exePath, workdir, mode })
return game
return newGame({ name, exePath, workdir, mode })
}
// Largest .exe in the tree, skipping redistributable folders.
async function findDefaultExe(dir) {
let best = ''
let bestSize = -1
const walk = async d => {
let entries = []
try {
@ -83,12 +58,13 @@ async function findDefaultExe(dir) {
for (const e of entries) {
const full = path.join(d, e.name)
if (e.isDirectory()) {
const lower = e.name.toLowerCase()
if (lower === 'redist' || lower === 'directx' || lower === '_commonredist' || lower === 'support' || lower === 'dotnet' || lower === 'vc') continue
await walk(full)
if (!SKIPPED_DIRS.has(e.name.toLowerCase())) await walk(full)
} else if (e.name.toLowerCase().endsWith('.exe')) {
const st = await fsp.stat(full)
if (!best || st.size > (await fsp.stat(best)).size) best = full
const size = await fsp.stat(full).then(st => st.size).catch(() => -1)
if (size > bestSize) {
best = full
bestSize = size
}
}
}
}
@ -96,4 +72,4 @@ async function findDefaultExe(dir) {
return best
}
module.exports = { importGame, findDefaultExe, copyRecursive, moveFolder, walk }
module.exports = { importGame, findDefaultExe, copyRecursive, moveFolder }

104
src/ipc/games.js Normal file
View file

@ -0,0 +1,104 @@
'use strict'
const { runGame, runTool } = require('../runner')
const { importGame } = require('../importer')
const library = require('../library')
function killProcessGroup(pid) {
try {
process.kill(-pid, 'SIGKILL')
} catch {}
try {
process.kill(pid, 'SIGKILL')
} catch {}
}
function sendToWindow(win, channel, data) {
if (win && !win.isDestroyed()) win.webContents.send(channel, data)
}
// Game-process state: id -> pid (or null while the process is starting).
function createGamesService({ getWindow }) {
const running = new Map()
const killRequests = new Set()
const isRunning = id => running.has(id)
const listRunning = () => [...running.keys()]
function resolveWine(game) {
const system = { id: 'system', type: 'system', label: 'System Wine' }
const wanted = game.wine === 'default' ? (library.loadSettings().defaultWine || 'system') : (game.wine || 'system')
if (!wanted || wanted === 'system') return system
return require('../wines').getWine(wanted) || system
}
function handleExit(id) {
running.delete(id)
sendToWindow(getWindow(), 'games:changed')
}
async function start(win, id) {
const game = library.getGame(id)
if (!game) return { ok: false, error: 'game-not-found' }
if (running.has(id)) return { ok: false, error: 'already-running' }
running.set(id, null)
let wine
try {
wine = resolveWine(game)
} catch {
running.delete(id)
return { ok: false, error: 'wine-not-found' }
}
try {
const res = await runGame({ game, wine, systemWine: wine.type === 'system', onExit: () => handleExit(id) })
if (res.pid) running.set(id, res.pid)
// stop was requested while the process was still starting
if (killRequests.has(id)) {
killRequests.delete(id)
if (res.pid) killProcessGroup(res.pid)
}
sendToWindow(win, 'games:changed')
return { ok: true }
} catch (err) {
running.delete(id)
return { ok: false, error: String((err && err.message) || err) }
}
}
async function kill(id) {
const pid = running.get(id)
if (pid) {
killProcessGroup(pid)
running.delete(id)
sendToWindow(getWindow(), 'games:changed')
} else if (running.has(id)) {
killRequests.add(id)
}
return { ok: true }
}
async function runToolFor(id, tool, args) {
const game = library.getGame(id)
if (!game) return { ok: false, error: 'game-not-found' }
let wine
try {
wine = resolveWine(game)
} catch {
return { ok: false, error: 'wine-not-found' }
}
try {
await runTool({ game, wine, tool, args, systemWine: wine.type === 'system' })
return { ok: true }
} catch (err) {
return { ok: false, error: String((err && err.message) || err) }
}
}
async function importTo(win, srcDir, mode) {
return importGame(srcDir, mode, data => sendToWindow(win, 'import:progress', data))
}
return { isRunning, listRunning, start, kill, runToolFor, import: importTo }
}
module.exports = { createGamesService, killProcessGroup, sendToWindow }

200
src/ipc/index.js Normal file
View file

@ -0,0 +1,200 @@
'use strict'
const { ipcMain, dialog, shell, protocol, net } = require('electron')
const path = require('path')
const fs = require('fs')
const { DIRS } = require('../paths')
const i18n = require('../i18n')
const library = require('../library')
const wines = require('../wines')
const prefixes = require('../prefixes')
const runner = require('../runner')
const { extractIconFromExe } = require('../icons')
const { createGamesService, sendToWindow } = require('./games')
const ICON_EXTS = ['png', 'jpe?g', 'svg', 'ico', 'webp', 'gif', 'bmp']
function errorResult(err) {
return { ok: false, error: String((err && err.message) || err) }
}
// --- icon helpers -----------------------------------------------------------
const iconDir = id => path.join(DIRS.DATA, 'icons', id)
function clearIconDir(id) {
try {
for (const f of fs.readdirSync(iconDir(id))) {
if (f.startsWith('icon.')) fs.rmSync(path.join(iconDir(id), f), { force: true })
}
} catch {}
}
function extractGameIcon(id, exePath) {
const out = path.join(iconDir(id), 'icon.ico')
const res = extractIconFromExe(exePath, out)
if (res.ok) library.updateGame(id, { iconSource: out })
return res
}
// --- registration -----------------------------------------------------------
function registerIpc({ getWindow }) {
const games = createGamesService({ getWindow })
// i18n ---------------------------------------------------------------------
ipcMain.handle('i18n:list', async () => {
await i18n.init()
return i18n.listLangs()
})
ipcMain.handle('i18n:get', async (e, code) => {
const lang = await i18n.loadLang(code)
if (lang) return lang
return (await i18n.loadLang(i18n.FALLBACK)) || { data: {}, texts: {} }
})
// settings -------------------------------------------------------------------
ipcMain.handle('settings:get', () => library.loadSettings())
ipcMain.handle('settings:set', (e, patch) => library.saveSettings(patch))
// wines ----------------------------------------------------------------------
function systemWine() {
let version = ''
try {
const { execFileSync } = require('child_process')
version = String(execFileSync('wine', ['--version'], { timeout: 15000 })).trim().replace(/^wine-?/i, '')
} catch {}
return { id: 'system', type: 'system', label: 'System Wine', version }
}
ipcMain.handle('wines:list', async () => {
await wines.refreshWines()
return [systemWine(), ...wines.getWines()]
})
ipcMain.handle('wines:refresh', async () => (await wines.refreshWines()).filter(w => !w.disabled))
ipcMain.handle('wines:setDisabled', (e, id, disabled) => wines.setWineDisabled(id, disabled))
ipcMain.handle('wines:setDefault', (e, id) => wines.setDefaultWine(id))
ipcMain.handle('wines:delete', (e, id) => wines.deleteWine(id))
ipcMain.handle('wines:import', async (e, srcDir) => {
try {
return await wines.importWineFolder(srcDir)
} catch (err) {
return errorResult(err)
}
})
// prefixes ---------------------------------------------------------------------
ipcMain.handle('prefixes:list', () => prefixes.listPrefixes())
ipcMain.handle('prefixes:create', (e, name) => prefixes.createPrefix(name))
ipcMain.handle('prefixes:delete', (e, name) => prefixes.deletePrefix(name))
ipcMain.handle('prefixes:setDefault', (e, name) => prefixes.setDefaultPrefix(name))
// games: library -----------------------------------------------------------------
ipcMain.handle('games:list', () => library.loadGames())
ipcMain.handle('games:get', (e, id) => library.getGame(id))
ipcMain.handle('games:update', (e, id, patch) => library.updateGame(id, patch))
ipcMain.handle('games:remove', (e, id) => library.removeGame(id))
ipcMain.handle('games:clearPlaytime', (e, id) => library.clearPlaytime(id))
// games: icons ---------------------------------------------------------------------
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:resetIcon', (e, id) => {
if (!library.getGame(id)) return { ok: false }
clearIconDir(id)
library.updateGame(id, { iconSource: 'none' })
return { ok: true }
})
ipcMain.handle('games:setIconFile', async (e, id, filePath) => {
if (!library.getGame(id) || !filePath) return { ok: false, error: 'bad-request' }
try {
clearIconDir(id)
fs.mkdirSync(iconDir(id), { recursive: true })
let dst
if (filePath.toLowerCase().endsWith('.exe')) {
dst = path.join(iconDir(id), 'icon.ico')
const res = extractIconFromExe(filePath, dst)
if (!res.ok) return res
} else {
dst = path.join(iconDir(id), `icon${path.extname(filePath).toLowerCase() || '.png'}`)
fs.copyFileSync(filePath, dst)
}
library.updateGame(id, { iconSource: dst })
return { ok: true, path: dst }
} catch (err) {
return errorResult(err)
}
})
ipcMain.handle('games:extractIcon', (e, id) => {
const game = library.getGame(id)
if (!game || !game.exePath) return { ok: false, error: 'bad-request' }
return extractGameIcon(id, game.exePath)
})
ipcMain.handle('games:autoIcon', (e, id) => {
const game = library.getGame(id)
if (!game || !game.exePath) return { ok: false, error: 'bad-request' }
clearIconDir(id)
return extractGameIcon(id, game.exePath)
})
// games: run --------------------------------------------------------------------------
ipcMain.handle('games:run', (e, id) => games.start(getWindow(), id))
ipcMain.handle('games:kill', (e, id) => games.kill(id))
ipcMain.handle('games:runTool', (e, id, tool, args) => games.runToolFor(id, tool, args))
ipcMain.handle('games:isRunning', (e, id) => games.isRunning(id))
ipcMain.handle('games:listRunning', () => games.listRunning())
ipcMain.handle('games:getLogs', (e, id) => {
const file = runner.latestLogPath(id)
return fs.existsSync(file) ? [file] : []
})
// games: import -------------------------------------------------------------------------
ipcMain.handle('games:pickFolder', async () => {
const res = await dialog.showOpenDialog(getWindow(), {
title: i18n.translate(library.loadSettings().language, 'import.pickFolder'),
properties: ['openDirectory']
})
return res.canceled || !res.filePaths[0] ? null : res.filePaths[0]
})
ipcMain.handle('games:import', async (e, srcDir, mode) => {
try {
return await games.import(getWindow(), srcDir, mode)
} catch (err) {
return errorResult(err)
}
})
// dialogs & shell ---------------------------------------------------------------------------
ipcMain.handle('dialog:pickFile', async (e, opts) => {
const res = await dialog.showOpenDialog(getWindow(), { properties: ['openFile', 'openDirectory'], ...(opts || {}) })
return res.canceled || !res.filePaths[0] ? null : res.filePaths[0]
})
ipcMain.handle('shell:openPath', (e, p) => shell.openPath(p))
ipcMain.handle('shell:showItemInFolder', (e, p) => {
shell.showItemInFolder(p)
return true
})
// game icons as a protocol so the renderer can display them safely ---------------------------
protocol.handle('plwl-icon', request => {
const id = decodeURIComponent(new URL(request.url).hostname)
let file = null
try {
const pick = fs
.readdirSync(iconDir(id))
.filter(f => new RegExp(`^icon\\.(${ICON_EXTS.join('|')})$`, 'i').test(f))
.sort()
.pop()
if (pick) file = path.join(iconDir(id), pick)
} catch {}
if (!file) return new Response('', { status: 404 })
return net.fetch('file://' + file)
})
return { games }
}
module.exports = { registerIpc, sendToWindow }

View file

@ -1,326 +1,25 @@
'use strict'
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')
const library = require('./library')
const runner = require('./runner')
const importer = require('./importer')
const { app } = require('electron')
const { ensureDirs } = require('./paths')
const i18n = require('./i18n')
const wines = require('./wines')
const { registerIpc } = require('./ipc')
const { createMainWindow } = require('./window')
const { setAppIcon } = require('./appicon')
app.setName('PLWL')
let win = null
function t(code, key, ...args) {
return i18n.translate(code, key, ...args)
}
function wineFor(id) {
if (!id || id === 'system') {
return { id: 'system', type: 'system', label: 'System Wine' }
}
if (id === 'default') {
id = library.loadSettings().defaultWine || 'system'
}
const w = wines.getWine(id)
if (!w) {
const fallback = library.loadSettings().defaultWine || 'system'
return wineFor(fallback)
}
return w
}
function scanGamesFolder(dir, depth, out) {
if (depth <= 0 || !out) return
let entries = []
try {
entries = require('fs').readdirSync(dir, { withFileTypes: true })
} catch {
return
}
for (const e of entries) {
if (!e.isDirectory()) continue
const full = path.join(dir, e.name)
if (require('fs').existsSync(path.join(full, '.plwl-imported'))) {
out.push(full)
} else {
scanGamesFolder(full, depth - 1, out)
}
}
}
ipcMain.handle('i18n:list', async () => {
await i18n.init()
return i18n.listLangs()
})
ipcMain.handle('i18n:get', async (e, code) => {
const lang = await i18n.loadLang(code)
if (lang) return lang
return (await i18n.loadLang(i18n.FALLBACK)) || { data: {}, texts: {} }
})
ipcMain.handle('settings:get', () => library.loadSettings())
ipcMain.handle('settings:set', (e, patch) => library.saveSettings(patch))
ipcMain.handle('wines:list', async () => {
await wines.refreshWines()
return [
{ id: 'system', type: 'system', label: 'System Wine', version: detectSystemWineVersion() },
...wines.getWines()
]
})
function detectSystemWineVersion() {
try {
const { execFileSync } = require('child_process')
return String(execFileSync('wine', ['--version'], { timeout: 15000 })).trim().replace(/^wine-?/i, '')
} catch {
return ''
}
}
ipcMain.handle('wines:refresh', async () => {
const list = await wines.refreshWines()
return list.filter(w => !w.disabled)
})
ipcMain.handle('wines:setDisabled', (e, id, disabled) => wines.setWineDisabled(id, disabled))
ipcMain.handle('wines:setDefault', (e, id) => wines.setDefaultWine(id))
ipcMain.handle('wines:delete', (e, id) => wines.deleteWine(id))
ipcMain.handle('wines:import', async (e, srcDir) => {
try {
return await wines.importWineFolder(srcDir)
} catch (err) {
return { ok: false, error: String(err && err.message || err) }
}
})
ipcMain.handle('prefixes:list', () => prefixes.listPrefixes())
ipcMain.handle('prefixes:create', (e, name) => prefixes.createPrefix(name))
ipcMain.handle('prefixes:delete', (e, name) => prefixes.deletePrefix(name))
ipcMain.handle('prefixes:setDefault', (e, name) => prefixes.setDefaultPrefix(name))
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.clearPlaytime(id))
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, {
title: t(library.loadSettings().language, 'import.pickFolder'),
properties: ['openDirectory']
})
if (res.canceled || !res.filePaths[0]) return null
return res.filePaths[0]
})
ipcMain.handle('games:import', async (e, srcDir, mode) => {
const onProgress = data => {
if (win && !win.isDestroyed()) win.webContents.send('import:progress', data)
}
const game = await importer.importGame(srcDir, mode, onProgress)
library.addGame(game)
require('fs').writeFileSync(path.join(game.workdir, '.plwl-imported'), '')
return game
})
const running = new Map()
const killRequests = new Set()
ipcMain.handle('games:run', async (e, id) => {
const game = library.getGame(id)
if (!game) return { ok: false, error: 'game-not-found' }
if (running.has(id)) return { ok: false, error: 'already-running' }
running.set(id, null)
let wine
try {
wine = wineFor(game.wine)
} catch {
running.delete(id)
return { ok: false, error: 'wine-not-found' }
}
const systemWine = wine.type === 'system'
try {
const res = await runner.runGame({
game,
wine,
systemWine,
onExit: () => {
running.delete(id)
if (win && !win.isDestroyed()) win.webContents.send('games:changed')
}
})
if (res.pid) running.set(id, res.pid)
if (killRequests.has(id)) {
killRequests.delete(id)
if (res.pid) {
try {
process.kill(-res.pid, 'SIGKILL')
} catch {}
try {
process.kill(res.pid, 'SIGKILL')
} catch {}
}
}
if (win && !win.isDestroyed()) win.webContents.send('games:changed')
return { ok: true }
} catch (err) {
running.delete(id)
return { ok: false, error: String(err && err.message || err) }
}
})
ipcMain.handle('games:runTool', async (e, id, tool, args) => {
const game = library.getGame(id)
if (!game) return { ok: false, error: 'game-not-found' }
let wine
try {
wine = wineFor(game.wine)
} catch {
return { ok: false, error: 'wine-not-found' }
}
const systemWine = wine.type === 'system'
try {
await runner.runTool({ game, wine, tool, args, systemWine })
return { ok: true }
} catch (err) {
return { ok: false, error: String(err && err.message || err) }
}
})
ipcMain.handle('dialog:pickFile', async (e, opts) => {
const res = await dialog.showOpenDialog(win, {
properties: ['openFile', 'openDirectory'],
...(opts || {})
})
if (res.canceled || !res.filePaths[0]) return null
return res.filePaths[0]
})
ipcMain.handle('shell:openPath', (e, p) => shell.openPath(p))
ipcMain.handle('shell:showItemInFolder', (e, p) => {
shell.showItemInFolder(p)
return true
})
ipcMain.handle('games:getLogs', (e, id) => {
const file = require('./runner').latestLogPath(id)
try {
fs.accessSync(file)
return [file]
} catch {}
return []
})
ipcMain.handle('games:kill', (e, id) => {
const pid = running.get(id)
if (pid) {
try {
process.kill(-pid, 'SIGKILL')
} catch {}
try {
process.kill(pid, 'SIGKILL')
} catch {}
running.delete(id)
if (win && !win.isDestroyed()) win.webContents.send('games:changed')
} else if (running.has(id)) {
killRequests.add(id)
}
return { ok: true }
})
ipcMain.handle('games:isRunning', (e, id) => running.has(id))
ipcMain.handle('games:listRunning', () => [...running.keys()])
const getWindow = () => win
app.whenReady().then(async () => {
if (process.platform === 'linux') {
const { setAppIcon } = require('./appicon')
try {
setAppIcon(app)
} catch {}
}
protocol.handle('plwl-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)
})
registerIpc({ getWindow })
ensureDirs()
await i18n.init()
await wines.refreshWines()

View file

@ -27,7 +27,7 @@ function linkPrefix(compatData, prefixDir) {
} catch {}
}
function buildEnv({ game, wine, workdir, systemWine }) {
function buildEnv({ game, wine }) {
const env = { ...process.env }
const isProton = wine && wine.type === 'proton'
let prefixDir = game.prefix ? prefixPath(game.prefix) : null
@ -46,14 +46,13 @@ function buildEnv({ game, wine, workdir, systemWine }) {
env.SteamGameId = '0'
env.WINEPREFIX = path.join(env.STEAM_COMPAT_DATA_PATH, 'pfx')
env.PROTON_LOG_DIR = path.join(DIRS.DATA, 'logs')
fs.mkdirSync(path.join(env.PROTON_LOG_DIR), { recursive: true })
fs.mkdirSync(env.PROTON_LOG_DIR, { recursive: true })
linkPrefix(env.STEAM_COMPAT_DATA_PATH, prefixDir)
if (!fs.existsSync(env.STEAM_COMPAT_DATA_PATH)) {
fs.mkdirSync(env.STEAM_COMPAT_DATA_PATH, { recursive: true })
}
} else if (!systemWine) {
env.WINEPREFIX = prefixDir
} else {
// plain wine (system or imported build)
env.WINEPREFIX = prefixDir
}
@ -98,15 +97,7 @@ function latestLogPath(gameId) {
function openLatestLog(gameId) {
const logFile = latestLogPath(gameId)
fs.mkdirSync(path.dirname(logFile), { recursive: true })
// Keep only the latest run's log: truncate the file from the previous session
// and clean up logs left by older versions (<id>-<timestamp>.log).
try {
for (const f of fs.readdirSync(path.dirname(logFile))) {
if (f.startsWith(`${gameId}-`) && f.endsWith('.log') && f !== `${gameId}-latest.log`) {
fs.rmSync(path.join(path.dirname(logFile), f), { force: true })
}
}
} catch {}
// 'w' truncates, so every session starts with a fresh log
return fs.createWriteStream(logFile, { flags: 'w' })
}

View file

@ -4,6 +4,7 @@ const fs = require('fs')
const fsp = fs.promises
const path = require('path')
const { DIRS, readJson, writeJson } = require('./paths')
const { dedupeDir } = require('./fsutil')
const WINES_DIR = DIRS.WINES
const REGISTRY = path.join(DIRS.DATA, 'wines.json')
@ -119,13 +120,8 @@ async function refreshWines() {
}
const disabled = new Set(prev.filter(w => w.disabled).map(w => w.id))
for (const w of found) if (disabled.has(w.id)) w.disabled = true
const merged = [...found]
for (const p of prev) {
if (p.system) merged.push(p)
else if (!found.some(f => f.id === p.id) && exists(p.path)) merged.push(p)
}
writeJson(REGISTRY, merged)
return merged
writeJson(REGISTRY, found)
return found
}
function getWines() {
@ -169,16 +165,6 @@ function deleteWine(id) {
return { ok: true }
}
function dedupeDir(base, name) {
let candidate = path.join(base, name)
let n = 2
while (fs.existsSync(candidate)) {
candidate = path.join(base, `${name} (${n})`)
n++
}
return candidate
}
// --- symlink healing -------------------------------------------------------
// Wine/Proton builds contain symlinks (e.g. inside the bundled default prefix)
// that point to absolute paths from wherever the build lived before (zip
@ -261,16 +247,13 @@ async function importWineFolder(srcDir) {
const type = detectType(srcDir)
let copySrc = srcDir
if (!type) {
// the user picked a folder one level above the actual build
let subs = []
try {
subs = (await fsp.readdir(srcDir, { withFileTypes: true })).filter(e => e.isDirectory())
} catch {}
for (const s of subs) {
if (detectType(path.join(srcDir, s.name))) {
copySrc = path.join(srcDir, s.name)
break
}
}
const build = subs.find(s => detectType(path.join(srcDir, s.name)))
if (build) copySrc = path.join(srcDir, build.name)
}
const dst = dedupeDir(WINES_DIR, path.basename(copySrc))
await fsp.cp(copySrc, dst, { recursive: true, verbatimSymlinks: true })