PLWL/src/ipc/index.js

332 lines
14 KiB
JavaScript

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