From e8cd664965aa1dcdd03aaf836ecd03a62af50a52 Mon Sep 17 00:00:00 2001 From: olcxja Date: Sat, 26 Sep 2026 10:13:50 +0200 Subject: [PATCH] code cleanup --- renderer/app.js | 24 +--- src/fsutil.js | 15 +++ src/importer.js | 64 +++------- src/ipc/games.js | 104 ++++++++++++++++ src/ipc/index.js | 200 ++++++++++++++++++++++++++++++ src/main.js | 315 ++--------------------------------------------- src/runner.js | 17 +-- src/wines.js | 29 +---- 8 files changed, 362 insertions(+), 406 deletions(-) create mode 100644 src/fsutil.js create mode 100644 src/ipc/games.js create mode 100644 src/ipc/index.js diff --git a/renderer/app.js b/renderer/app.js index d01e971..dc82d52 100644 --- a/renderer/app.js +++ b/renderer/app.js @@ -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) diff --git a/src/fsutil.js b/src/fsutil.js new file mode 100644 index 0000000..1d57bc1 --- /dev/null +++ b/src/fsutil.js @@ -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 } diff --git a/src/importer.js b/src/importer.js index 892e0c3..4f5d8ce 100644 --- a/src/importer.js +++ b/src/importer.js @@ -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 } diff --git a/src/ipc/games.js b/src/ipc/games.js new file mode 100644 index 0000000..4bf71e5 --- /dev/null +++ b/src/ipc/games.js @@ -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 } diff --git a/src/ipc/index.js b/src/ipc/index.js new file mode 100644 index 0000000..4af46a0 --- /dev/null +++ b/src/ipc/index.js @@ -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 } diff --git a/src/main.js b/src/main.js index b86f69e..bbc495b 100644 --- a/src/main.js +++ b/src/main.js @@ -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() diff --git a/src/runner.js b/src/runner.js index 651fdcb..d9fd927 100644 --- a/src/runner.js +++ b/src/runner.js @@ -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 (-.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' }) } diff --git a/src/wines.js b/src/wines.js index 82d9cc2..7669168 100644 --- a/src/wines.js +++ b/src/wines.js @@ -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 })