'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 linuxrunner = require('../linuxrunner') const { createSandboxProbe } = require('../sandbox') const { extractIconFromExe, iconFromLinuxApp } = require('../icons') const { createGamesService, sendToWindow } = require('./games') const shortcuts = require('../shortcuts') const openwith = require('../openwith') const { registerLanSyncIpc } = require('./lansync') 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) // `keep` spares the icon just written: the protocol picks the LAST icon.*. function clearIconDir(id, keep) { try { for (const f of fs.readdirSync(iconDir(id))) { if (f.startsWith('icon.') && f !== keep) fs.rmSync(path.join(iconDir(id), f), { force: true }) } } catch {} } function extractGameIcon(id, exePath, kind) { const isLinux = kind === 'linux' // the Linux helper picks the extension itself (png/svg), the PE one is ico const out = path.join(iconDir(id), isLinux ? 'icon' : 'icon.ico') const res = isLinux ? iconFromLinuxApp(exePath, out) : extractIconFromExe(exePath, out) // on failure nothing is cleared, so the previous icon survives a wrong exe if (res.ok) { clearIconDir(id, path.basename(res.path)) library.updateGame(id, { iconSource: res.path }) } return res } // --- registration ----------------------------------------------------------- function registerIpc({ getWindow, onGamesChanged }) { // one probe per launcher, cached: it decides bwrap vs plain run const sandbox = createSandboxProbe() const games = createGamesService({ getWindow, onGamesChanged, sandbox }) // sandbox -------------------------------------------------------------------- ipcMain.handle('sandbox:status', async () => sandbox.status() || (await sandbox.probe())) ipcMain.handle('sandbox:probe', async () => { sandbox.invalidate() return sandbox.probe() }) // 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 ---------------------------------------------------------------------- ipcMain.handle('wines:list', async () => { await wines.refreshWines() return [wines.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:setDisplayName', (e, id, name) => wines.setWineDisplayName(id, name)) ipcMain.handle('wines:setDefault', (e, id) => wines.setDefaultWine(id)) ipcMain.handle('wines:delete', async (e, id) => { // the build's files back a running prefix; deleting it breaks that game if (library.isWineInUse(id, games.runningInfo())) { const apps = games.runningInfo().filter(r => r.wine === id).map(r => r.name) return { ok: false, error: 'inUse', apps } } return wines.deleteWine(id, data => sendToWindow(getWindow(), 'import:progress', data)) }) ipcMain.handle('wines:import', async (e, srcDir) => { try { return await wines.importWineFolder(srcDir, data => sendToWindow(getWindow(), 'import:progress', data)) } catch (err) { return errorResult(err) } }) // prefixes --------------------------------------------------------------------- // A prefix must not move/disappear while a game runs in it. const prefixInUse = name => { const apps = library.appsUsingPrefix(name, games.runningInfo()) return apps.length ? { ok: false, error: 'inUse', apps: apps.map(r => r.name) } : null } ipcMain.handle('prefixes:list', () => prefixes.listPrefixes()) ipcMain.handle('prefixes:create', (e, name) => prefixes.createPrefix(name)) ipcMain.handle('prefixes:delete', async (e, name) => { const busy = prefixInUse(name) if (busy) return busy // deleting a prefix with registered apps would orphan them const users = library.gamesUsingPrefix(name).map(g => g.name) if (users.length) return { ok: false, error: 'hasApps', apps: users } return prefixes.deletePrefix(name, data => sendToWindow(getWindow(), 'import:progress', data)) }) ipcMain.handle('prefixes:rename', (e, oldName, newName) => prefixInUse(oldName) || prefixes.renamePrefix(oldName, newName)) // The seeded desktop config is ours to drop (app data is not a seed), but a // running app would lose the files under its feet. ipcMain.handle('prefixes:resetLinuxConfig', (e, name) => { if (!prefixes.listPrefixes().prefixes.some(p => p.name === name)) return { ok: false, error: 'notFound' } return prefixInUse(name) || linuxrunner.resetSeededConfig(name) }) ipcMain.handle('prefixes:setDefault', (e, name) => { // switching the default would silently move apps that follow it const defs = prefixes.listPrefixes() if (name !== defs.default) { const affected = library.gamesFollowingDefault(games.runningInfo()) if (affected.length) return { ok: false, error: 'inUse', apps: affected.map(r => r.name) } } return prefixes.setDefaultPrefix(name) }) // games: library ----------------------------------------------------------------- ipcMain.handle('games:list', () => library.loadGames()) ipcMain.handle('games:get', (e, id) => library.getGame(id)) // the prefix pin is enforced inside library.updateGame ipcMain.handle('games:update', (e, id, patch) => library.updateGame(id, patch)) ipcMain.handle('games:reorder', (e, ids) => library.reorderGames(ids)) ipcMain.handle('games:remove', (e, id) => { shortcuts.removeShortcut(id) return library.removeGame(id, data => sendToWindow(getWindow(), 'import:progress', data)) }) ipcMain.handle('games:createShortcut', (e, id) => { const game = library.getGame(id) if (!game) return { ok: false, error: 'game-not-found' } try { return shortcuts.createShortcut(game) } catch (err) { return errorResult(err) } }) ipcMain.handle('games:shortcutPaths', (e, id) => shortcuts.shortcutPaths(id)) ipcMain.handle('openwith:texts', (e, lang) => openwith.textsFor(lang)) 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, game.kind) }) // use the exe the field holds (maybe picked but not saved yet) ipcMain.handle('games:autoIcon', (e, id, exePath) => { const game = library.getGame(id) if (!game) return { ok: false, error: 'bad-request' } const exe = exePath || game.exePath if (!exe) return { ok: false, error: 'bad-request' } return extractGameIcon(id, exe, game.kind) }) // Linux apps keep their data next to the prefix's Windows side ipcMain.handle('games:openDataFolder', async (e, id) => { const game = library.getGame(id) if (!library.isLinuxGame(game)) return { ok: false, error: 'not-linux' } try { const root = linuxrunner.ensureLinuxPfx(library.prefixNameForGame(game)) const err = await shell.openPath(root) return err ? { ok: false, error: err } : { ok: true, path: root } } catch (err) { return errorResult(err) } }) // "Open with PLWL" ------------------------------------------------------------------ // `--open` (Exec %f) hands the file over; nothing is imported. ipcMain.handle('openwith:take', async () => { const p = openwith.getPending() if (!p) return null openwith.clearPending() return p }) ipcMain.handle('openwith:run', async (e, payload) => { try { return await openwith.run({ openPath: payload && payload.openPath, wine: payload && payload.wine, prefix: payload && payload.prefix, lang: library.loadSettings().language }) } catch (err) { return errorResult(err) } }) // 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, kind) => { try { return await games.import(getWindow(), srcDir, mode, kind) } catch (err) { return errorResult(err) } }) // import from an existing prefix: paths are stored prefix-relative ipcMain.handle('games:pickPrefixFolder', async (e, prefix) => { // open the picker in the prefix's C: drive (mixed layouts: prefix folder) const driveRoots = prefixes.prefixDriveRoots(prefix) const start = driveRoots.length === 1 ? driveRoots[0] : prefixes.prefixPath(prefix) const res = await dialog.showOpenDialog(getWindow(), { title: i18n.translate(library.loadSettings().language, 'import.pickPrefixFolder'), defaultPath: start, properties: ['openDirectory'] }) return res.canceled || !res.filePaths[0] ? null : res.filePaths[0] }) ipcMain.handle('games:importFromPrefix', async (e, prefix, srcDir) => { try { return await games.importFromPrefix(getWindow(), prefix, srcDir) } catch (err) { return errorResult(err) } }) // lan syncing --------------------------------------------------------------------------------- registerLanSyncIpc({ getWindow }) // dialogs & shell --------------------------------------------------------------------------- ipcMain.handle('dialog:pickFile', async (e, opts) => { const o = { ...(opts || {}) } // anchor FILES-relative paths, or Electron resolves them against cwd if (o.defaultPath && typeof o.defaultPath === 'string' && !path.isAbsolute(o.defaultPath)) { o.defaultPath = path.join(DIRS.FILES, o.defaultPath) } const res = await dialog.showOpenDialog(getWindow(), { properties: ['openFile', 'openDirectory'], ...o }) 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', async 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 }) // the icon url never changes: send no-cache headers const res = await net.fetch('file://' + file) return new Response(await res.arrayBuffer(), { headers: { 'content-type': res.headers.get('content-type') || 'image/x-icon', 'cache-control': 'no-store' } }) }) return { games } } module.exports = { registerIpc, sendToWindow }