Files

3417 lines
166 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use strict';
const { app, BrowserWindow, ipcMain, shell, dialog, net: eNet, protocol, safeStorage } = require('electron');
const { getConversionCapabilities } = require('./src/platform-capabilities');
// The SteamOS/Wayland validation host repeatedly rejected Electron's GPU child
// process (error 1002), which makes Chromium terminate after its retry limit.
// Software compositing is the portable fallback; Windows keeps its normal GPU path.
if (getConversionCapabilities().disableHardwareAcceleration) {
app.disableHardwareAcceleration();
}
// Register pork-cache as a privileged scheme before app is ready so that
// <video src="pork-cache://..."> is treated as a secure, standard origin and
// is allowed through CSP. Must happen before app.whenReady().
protocol.registerSchemesAsPrivileged([
{ scheme: 'pork-cache', privileges: { standard: true, secure: true, supportFetchAPI: true, bypassCSP: false } },
]);
const path = require('path');
const fs = require('fs');
const os = require('os');
const net = require('net');
const https = require('https');
const NodeFormData = require('form-data');
const nodeFetch = require('node-fetch');
const http = require('http');
const log = require('electron-log');
const Store = require('electron-store');
const { reconcileConsoleScan } = require('./src/services/console-scan-reconciler');
const { createPs4PackageBackupService } = require('./src/services/ps4-package-backup-service');
const { createPs4PkgBuilderProvider } = require('./src/services/ps4-pkg-builder-provider');
const { registerPs4PackageManifest } = require('./src/services/ps4-package-registration-service');
const { createV3Repositories } = require('./src/services/v3-repositories');
const { createPs5NativePackageService } = require('./src/services/ps5-native-package-service');
const { createPs5ConsoleFtpProvider, createPs5ConsoleStagingService } = require('./src/services/ps5-console-staging-service');
const { createPs5NativePkgBuilderProvider } = require('./src/services/ps5-native-pkg-builder-provider');
const { registerPs5NativePackageManifest } = require('./src/services/ps5-native-package-registration-service');
const { comparePs5PackageSets } = require('./src/services/ps5-package-comparison-service');
const { createPs5UploadProfileProvider } = require('./src/services/ps5upload-profile-provider');
const { createPs5ProfileManagementService } = require('./src/services/ps5-profile-management-service');
// Last-resort safety net: a rejected promise or thrown error in any fire-and-forget
// path (auto-scans, ps5Notify, streamed IPC) would otherwise crash or silently kill
// the main process. Log it instead of taking the whole app down.
process.on('unhandledRejection', (reason) => {
log.error('[unhandledRejection]', reason);
});
process.on('uncaughtException', (err) => {
log.error('[uncaughtException]', err);
});
const { PS5Notifier } = require('./PSNotifyModule');
const xavatar = require('./src/xavatarElectronModule');
const ffmpegPath = require('ffmpeg-static');
const { spawn } = require('child_process');
const db = require('./src/database');
const ftp = require('./src/ftp');
const prospero = require('./src/prospero');
const cheats = require('./src/cheats');
const { hashFile } = require('./src/hashing');
const github = require('./src/github');
// ── Deferred IPC modules (registered after win is created) ──────────────────────
const _registerMedia = require('./src/ipc/media');
const _registerConversion = require('./src/ipc/conversion');
const _registerGarlic = require('./src/ipc/garlic-saves');
const _registerBpgen = require('./src/ipc/backpork-gen');
const _registerElfArsenal = require('./src/ipc/elf-arsenal');
const _registerPfsRipper = require('./src/ipc/pfs-ripper');
const _registerY2jbGen = require('./src/ipc/y2jb-generator');
const _registerSystemState = require('./src/ipc/system-state');
const _registerBdjbGen = require('./src/ipc/bdjb-generator');
const _registerTrophyHistory = require('./src/ipc/trophy-history');
const _registerTrophyProgression = require('./src/ipc/trophy-progression');
// ── Web UI server ─────────────────────────────────────────────────────────────
const WebUIServer = require('./src/web-server');
// ── Settings store (credentials persisted via electron-store) ─────────────────
const store = new Store({ name: 'porkfolio-settings' });
const desktopSessionSecrets = new Map();
const DESKTOP_SECRET_KEYS = Object.freeze({
ftpPassword: { encrypted: 'secure.ftp.password', legacy: 'ftp.pass' },
discordWebhook: { encrypted: 'secure.discord.webhookUrl', legacy: 'discord.webhookUrl' },
});
function desktopEncryptionAvailable() {
if (!safeStorage.isEncryptionAvailable()) return false;
if (process.platform === 'linux' && typeof safeStorage.getSelectedStorageBackend === 'function') {
return safeStorage.getSelectedStorageBackend() !== 'basic_text';
}
return true;
}
function encryptDesktopSecret(value) {
if (!desktopEncryptionAvailable()) return null;
return safeStorage.encryptString(String(value)).toString('base64');
}
function hasDesktopSecret(name) {
const definition = DESKTOP_SECRET_KEYS[name];
if (!definition) throw new Error(`Onbekend desktopsecret: ${name}`);
return desktopSessionSecrets.has(name) || Boolean(store.get(definition.encrypted, ''));
}
function getDesktopSecret(name) {
const definition = DESKTOP_SECRET_KEYS[name];
if (!definition) throw new Error(`Onbekend desktopsecret: ${name}`);
if (desktopSessionSecrets.has(name)) return desktopSessionSecrets.get(name);
const encrypted = String(store.get(definition.encrypted, '') || '');
if (!encrypted) return '';
if (!desktopEncryptionAvailable()) throw new Error('Beveiligde OS-opslag is niet beschikbaar; voer het secret opnieuw in voor deze sessie.');
try { return safeStorage.decryptString(Buffer.from(encrypted, 'base64')); }
catch (_) { throw new Error('Het opgeslagen desktopsecret kan niet veilig worden ontsleuteld. Voer het opnieuw in.'); }
}
function setDesktopSecret(name, value) {
const definition = DESKTOP_SECRET_KEYS[name];
if (!definition) throw new Error(`Onbekend desktopsecret: ${name}`);
const normalized = String(value || '');
store.delete(definition.legacy);
desktopSessionSecrets.delete(name);
if (!normalized) { store.delete(definition.encrypted); return; }
const encrypted = encryptDesktopSecret(normalized);
if (encrypted) store.set(definition.encrypted, encrypted);
else {
store.delete(definition.encrypted);
desktopSessionSecrets.set(name, normalized);
log.warn(`[Secrets] ${name} blijft alleen voor deze sessie beschikbaar omdat beveiligde OS-opslag ontbreekt.`);
}
}
function migrateDesktopSecrets() {
for (const [name, definition] of Object.entries(DESKTOP_SECRET_KEYS)) {
const legacy = String(store.get(definition.legacy, '') || '');
if (!legacy) continue;
setDesktopSecret(name, legacy);
log.info(`[Secrets] Verouderd plaintext ${name}-secret uit electron-store verwijderd.`);
}
}
function requireExactConfirmation(input, phrase) {
if (!input || input.confirmation !== phrase) throw new Error(`Typ ${phrase} om deze actie te bevestigen.`);
}
// ── Web UI handler registry — intercept ipcMain.handle before any registrations
// Every handler registered with ipcMain.handle is also stored in webHandlers so
// the HTTP web server can call them directly without going through Electron IPC.
const webHandlers = new Map();
const WEB_BLOCKED_CHANNELS = new Set([
'dialog:select-folder',
'shell:open',
'db:export:sql',
'db:export:csv',
'system-view:pick-save',
'system-view:write-file',
'system-view:append-file',
'system-view:delete-file',
'trophy:history-import:browser-authenticate',
]);
const _origIpcHandle = ipcMain.handle.bind(ipcMain);
ipcMain.handle = (channel, handler) => {
if (!WEB_BLOCKED_CHANNELS.has(channel)) webHandlers.set(channel, handler);
return _origIpcHandle(channel, handler);
};
let webServer = null; // WebUIServer instance
// ── Community hash database (bundled with app, updated via app updates) ────────
const communityHashPath = path.join(__dirname, 'data', 'game_hashes.json');
const LANG_DIR = path.join(__dirname, 'build', 'lang');
const communityHashMap = new Map(); // file_hash → community entry
const communityGameIndex = new Map(); // game_id → Set<file_hash>
let _communityHashMeta = null; // { version, updated, source } — set when hashes are loaded
// Populate (or replace) both community lookup maps from an array of hash entries.
// Each entry is expected to have at least { file_hash, game_id } fields.
function loadCommunityHashes(hashes) {
communityHashMap.clear();
communityGameIndex.clear();
for (const h of hashes || []) {
communityHashMap.set(h.file_hash, h);
if (h.game_id) {
if (!communityGameIndex.has(h.game_id)) communityGameIndex.set(h.game_id, new Set());
communityGameIndex.get(h.game_id).add(h.file_hash);
}
}
}
try {
if (fs.existsSync(communityHashPath)) {
const raw = JSON.parse(fs.readFileSync(communityHashPath, 'utf8'));
loadCommunityHashes(raw.hashes || []);
_communityHashMeta = { version: raw.version || 1, updated: raw.updated || null, source: 'bundled' };
log.info(`[Hashes] Loaded ${communityHashMap.size} community hash(es)`);
}
} catch (e) {
log.warn('[Hashes] Failed to load community hashes:', e.message);
}
// ── PS5 Notifier ──────────────────────────────────────────────────────────────
const ps5 = new PS5Notifier('127.0.0.1'); // host updated from store at send time
/** Strip C0/C1 control characters and null bytes from a string, but preserve
* all printable Unicode — including emoji. The PS5's customPSNotify daemon
* reads the payload as UTF-8, so emoji arrive correctly as long as we send
* the packet as explicit UTF-8 bytes (handled in PSNotifyModule). */
function sanitizePs5Str(s) {
// Remove null bytes and control characters (0x00-0x1F, 0x7F, 0x80-0x9F)
// but keep all printable ASCII and all Unicode above U+009F (emoji etc.).
return String(s ?? '').replace(/[\x00-\x1F\x7F-\x9F]/g, '');
}
/** Fire-and-forget PS5 toast. Silently does nothing if disabled or unconfigured. */
async function ps5Notify(message, subMessage = '') {
const host = store.get('ftp.host', '');
if (!host || !store.get('psnotify.enabled', true)) return;
ps5.setHost(host);
ps5.port = store.get('psnotify.port', 6969);
ps5.timeout = store.get('psnotify.timeout', 5000);
try { await ps5.send(sanitizePs5Str(message), { subMessage: sanitizePs5Str(subMessage) }); } catch (_) {}
}
// ── Prospero background fetch queue ──────────────────────────────────────────
const PROSPERO_TTL = 7 * 24 * 60 * 60 * 1000; // 7 days
const prosperoQueue = new Set();
let prosperoRunning = false;
async function processProsperoQueue() {
if (prosperoRunning || prosperoQueue.size === 0) return;
prosperoRunning = true;
let fetched = 0, failed = 0;
log.info(`[Prospero] Queue started — ${prosperoQueue.size} game(s)`);
while (prosperoQueue.size > 0) {
const [game_id] = prosperoQueue;
prosperoQueue.delete(game_id);
try {
const data = await prospero.fetchGameMetadata(game_id);
db.updateProsperoData(game_id, data);
win?.webContents.send('prospero:updated', { game_id, data });
log.info(`[Prospero] Cached metadata for ${game_id}`);
fetched++;
} catch (e) {
log.warn(`[Prospero] Fetch failed for ${game_id}: ${e.message}`);
failed++;
}
if (prosperoQueue.size > 0) await new Promise(r => setTimeout(r, 600));
}
prosperoRunning = false;
log.info('[Prospero] Queue finished');
win?.webContents.send('prospero:queue:done', { fetched, failed });
if (fetched > 0) ps5Notify('ProsperoPatches Updated', `${fetched} game${fetched !== 1 ? 's' : ''} updated`);
}
function queueProsperoFetch(gameIds) {
for (const game_id of gameIds) {
if (db.getProsperoAge(game_id) < PROSPERO_TTL) continue; // still fresh
prosperoQueue.add(game_id);
}
if (prosperoQueue.size > 0) {
processProsperoQueue().catch(e => log.error('[Prospero] Queue error:', e));
}
}
// ── Window ────────────────────────────────────────────────────────────────────
let win;
let systemViewPopup = null;
let splashWin = null;
let donationWin = null;
let _quitting = false; // set to true once we are committed to quitting — lets win.on('close') pass through
let _splashLoaded = false;
let _pendingSplashStatus = null;
function createSplash() {
return new Promise(resolve => {
_splashLoaded = false;
_pendingSplashStatus = null;
splashWin = new BrowserWindow({
width: 360,
height: 260,
center: true,
frame: false,
resizable: false,
alwaysOnTop: true,
show: false,
backgroundColor: '#121212',
icon: path.join(__dirname, 'build', 'icon.png'),
webPreferences: { contextIsolation: true, nodeIntegration: false },
});
splashWin.webContents.once('did-finish-load', () => {
_splashLoaded = true;
// Flush any status message that arrived before the page was ready
if (_pendingSplashStatus) {
_execSplashStatus(_pendingSplashStatus);
_pendingSplashStatus = null;
}
// Small delay lets the GPU composite the first paint before revealing the window
setTimeout(() => {
if (splashWin && !splashWin.isDestroyed()) splashWin.show();
resolve();
}, 80);
});
splashWin.loadFile(path.join(__dirname, 'renderer', 'splash.html'));
});
}
function closeSplash() {
_splashLoaded = false;
_pendingSplashStatus = null;
if (splashWin && !splashWin.isDestroyed()) {
splashWin.close();
splashWin = null;
}
}
function _execSplashStatus(msg) {
splashWin?.webContents.executeJavaScript(
`window.setSplashStatus && window.setSplashStatus(${JSON.stringify(msg)})`
).catch(() => {});
}
function setSplashStatus(msg) {
if (!splashWin || splashWin.isDestroyed()) return;
if (_splashLoaded) {
_execSplashStatus(msg);
} else {
_pendingSplashStatus = msg; // deliver once page is ready
}
}
function createWindow() {
win = new BrowserWindow({
width: 1200,
height: 800,
minWidth: 900,
minHeight: 600,
show: false, // hidden until ready-to-show
backgroundColor: '#121212',
icon: path.join(__dirname, 'build', 'icon.png'),
frame: false,
titleBarStyle: 'hiddenInset',
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
contextIsolation: true,
nodeIntegration: false,
webSecurity: true,
},
});
win.once('ready-to-show', () => {
closeSplash();
win.show();
});
// Forward all main-process events to web UI browser clients via WebSocket
const _origWcSend = win.webContents.send.bind(win.webContents);
win.webContents.send = (channel, ...args) => {
_origWcSend(channel, ...args);
if (webServer && webServer.isRunning()) webServer.broadcast(channel, ...args);
};
// Grant camera/microphone access for System View (capture card via getUserMedia)
win.webContents.session.setPermissionRequestHandler((_wc, permission, callback) => {
callback(['media', 'microphone', 'camera', 'videoCapture', 'audioCapture'].includes(permission));
});
win.loadFile(path.join(__dirname, 'renderer', 'index.html'));
// Intercept close to show the donation popup (WinRAR-style).
// _quitting is set before app.quit() so this handler lets it pass through.
win.on('close', (e) => {
if (_quitting) {
// Real quit — clean up sub-windows then let electron proceed
if (systemViewPopup && !systemViewPopup.isDestroyed()) {
systemViewPopup.destroy();
systemViewPopup = null;
}
return;
}
e.preventDefault();
// If the popup is already open just focus it — don't open a second one
if (donationWin && !donationWin.isDestroyed()) {
donationWin.focus();
return;
}
// Hide the main window so the user can't trigger another popup while this one is open
win.hide();
openDonationWindow(true);
});
// Auto-scan local backups and all known backpork folders once the renderer is ready.
// Deferred via setImmediate so the renderer's init() IPC calls (settings, stats, FTP status)
// get responses immediately — the scan runs after those complete.
win.webContents.once('did-finish-load', () => {
setImmediate(async () => {
// ── Local backup scan (all configured source folders) ────────────────────
const _startupBps = store.get('backupPaths', null) || (store.get('backupPath', '') ? [store.get('backupPath', '')] : []);
for (const bp of _startupBps) {
if (!bp) continue;
try {
const { found, newIds } = await scanLocalBackups(bp);
queueProsperoFetch(found);
win.webContents.send('local:scan:complete', { total: found.length, added: newIds.length });
log.info(`[AutoScan] Local (${bp}): ${found.length} game(s), ${newIds.length} new`);
} catch (e) {
log.warn('[AutoScan Local]', e.message);
}
}
// ── Re-queue games with missing icon URLs (e.g. after prospero regex fix) ──
try {
const missing = db.getGamesMissingIconUrl();
if (missing.length > 0) {
queueProsperoFetch(missing);
log.info(`[AutoScan] Queued ${missing.length} game(s) for icon re-fetch`);
}
} catch (e) {
log.warn('[AutoScan Icons]', e.message);
}
// ── Backpork folder auto-scan ──────────────────────────────────────────────
try {
const bpFolders = db.listBackporkFolders();
let bpTotal = 0;
const allBpFound = [];
for (const folder of bpFolders) {
if (!fs.existsSync(folder.path)) continue;
const found = await scanBackporkFolder(folder.path, folder.name);
bpTotal += found.length;
allBpFound.push(...found);
queueProsperoFetch(found);
}
if (bpFolders.length > 0) {
const bpUniqueGames = new Set(allBpFound).size;
win.webContents.send('backporks:scan:complete', { folders: bpFolders.length, count: bpTotal, uniqueGames: bpUniqueGames });
log.info(`[AutoScan] Backporks: ${bpFolders.length} firmware folder(s), ${bpTotal} build(s) across ${bpUniqueGames} unique game(s)`);
}
} catch (e) {
log.warn('[AutoScan Backporks]', e.message);
}
}); // end setImmediate
});
}
// ── Donation window ───────────────────────────────────────────────────────────
// exitMode = true → "Close Porkfolio" button actually quits the app
// false → "Close" button just dismisses the popup (opened from sidebar)
function openDonationWindow(exitMode = false) {
if (donationWin && !donationWin.isDestroyed()) {
donationWin.focus();
return;
}
const query = exitMode ? '?mode=exit' : '';
donationWin = new BrowserWindow({
width: 400,
height: 560,
center: true,
frame: false,
resizable: false,
alwaysOnTop: true,
show: false,
backgroundColor: '#121212',
icon: path.join(__dirname, 'build', 'icon.png'),
webPreferences: {
preload: path.join(__dirname, 'donation-preload.js'),
contextIsolation: true,
nodeIntegration: false,
},
});
donationWin.once('ready-to-show', () => {
if (donationWin && !donationWin.isDestroyed()) donationWin.show();
});
// When the popup is closed by ANY means (button click or its own X)
// and we were in exit-mode, commit to quitting.
donationWin.on('closed', () => {
donationWin = null;
if (exitMode && !_quitting) {
_quitting = true;
app.quit();
}
});
donationWin.loadFile(path.join(__dirname, 'renderer', 'donation.html'), { search: query });
}
// ── Donation IPC ──────────────────────────────────────────────────────────────
// Renderer-side donate widget opens the popup in manual mode
ipcMain.on('donation:show', () => openDonationWindow(false));
// "Close Porkfolio" button inside exit-mode popup
ipcMain.on('donation:exit-app', () => {
_quitting = true;
app.quit(); // donationWin 'closed' handler will fire but _quitting is already true
});
// Dismiss — manual mode only (sidebar button): just close the popup, main window stays
ipcMain.on('donation:dismiss', () => {
if (donationWin && !donationWin.isDestroyed()) donationWin.close();
});
// Open external links from the donation popup
ipcMain.on('donation:open-x', () => shell.openExternal('https://x.com/StonedModder'));
ipcMain.on('donation:open-revolut', () => shell.openExternal('https://revolut.me/zenithprints'));
// Generate QR as SVG in main process — works without native canvas
ipcMain.handle('donation:gen-qr', async () => {
try {
const QRCode = require('qrcode');
const btcAddr = '3Kt8L3FRS12XW6HJt9QqFGd8irxBc8xTD4';
const svg = await QRCode.toString(`bitcoin:${btcAddr}`, {
type: 'svg',
margin: 1,
color: { dark: '#BB86FC', light: '#1e1e1e' },
});
return { ok: true, svg };
} catch (e) {
log.warn('[Donation] QR gen failed:', e.message);
return { ok: false };
}
});
app.whenReady().then(async () => {
migrateDesktopSecrets();
await createSplash();
// Serve cached media files (thumbnails, clips) through pork-cache://<safeName>
// so <video> and <img> elements can reference large files without base64 encoding.
protocol.handle('pork-cache', req => {
// The renderer sets src="pork-cache://<safeName>" where safeName contains
// no slashes, so the entire filename is in the URL hostname component.
const safeName = decodeURIComponent(new URL(req.url).hostname)
.replace(/\.\./g, '').replace(/[/\\]/g, '');
const filePath = path.join(path.join(os.tmpdir(), 'porkfolio-media-thumb'), safeName);
return eNet.fetch(`file:///${filePath.replace(/\\/g, '/')}`);
});
try {
setSplashStatus('Loading database…');
await db.initialize();
// Overlay user-imported community hashes (persisted across launches)
try {
const userHashPath = path.join(app.getPath('userData'), 'community-hashes.json');
if (fs.existsSync(userHashPath)) {
const raw = JSON.parse(fs.readFileSync(userHashPath, 'utf8'));
if (Array.isArray(raw.hashes)) {
loadCommunityHashes(raw.hashes);
_communityHashMeta = { version: raw.version || 1, updated: raw.updated || null, source: 'imported' };
log.info(`[Hashes] Loaded ${communityHashMap.size} imported community hash(es)`);
}
}
} catch (_ie) { log.warn('[Hashes] Failed to load imported hashes:', _ie.message); }
const dbStats = db.getStats();
const cheatStats = db.getCheatStats();
setSplashStatus(`${dbStats.totalGames} game${dbStats.totalGames !== 1 ? 's' : ''} found · ${cheatStats.files} cheats cached`);
createWindow();
// Auto-launch System View popout immediately using stored settings —
// done here in the main process so it opens without waiting for the
// renderer's init() sequence (which can take many seconds).
if (store.get('sv.autoLoad', false)) {
_openSystemViewPopout(
store.get('sv.videoDevice', ''),
store.get('sv.audioDevice', ''),
store.get('sv.resolution', '')
);
}
// Register large IPC modules now that win is set
_registerMedia(ipcMain, { win, store, getDesktopSecret, log, ftp, shell, dialog, path, fs, os, http, https, ffmpegPath, spawn, transferMgr, ps5Notify, eNet, app, BrowserWindow });
_registerConversion(ipcMain, { win, store, log, path, fs, os, spawn, ps5Notify, transferMgr, dialog, ftp });
_registerGarlic(ipcMain, { win, store, ftp, path, fs, app, net, github, ps5Notify });
_registerBpgen(ipcMain, { win, db, log, path, fs, dialog, app, store });
_registerElfArsenal(ipcMain, { win, store, log, path, fs, dialog, nodeFetch, ps5Notify });
_registerPfsRipper(ipcMain, { win, store, log, path, fs, dialog, shell, ftp, transferMgr });
_registerY2jbGen(ipcMain, { app, dialog, log });
_registerSystemState(ipcMain,{ store, transferMgr, log });
_registerBdjbGen(ipcMain, { app, dialog, log, win });
_registerTrophyHistory(ipcMain, { app, BrowserWindow, db, parent: win, log });
_registerTrophyProgression(ipcMain, { app, db });
// ── Web UI server — start if enabled in settings ──────────────────────────
webServer = new WebUIServer({
appRoot: __dirname, store, log, webHandlers,
onRelayRequest: (active) => {
if (win && !win.isDestroyed()) win.webContents.send('sv:relay:request', { active });
},
});
if (store.get('webui.enabled', false)) {
const port = store.get('webui.port', 6967);
webServer.start(port).catch(e => log.error('[WebUI] Failed to start:', e.message));
}
} catch (e) {
closeSplash();
log.error('[App] Startup error:', e);
dialog.showErrorBox('Startup Error', e.message);
app.quit();
}
});
const _xavatarCleanup = xavatar.registerIpcHandlers(ipcMain);
app.on('window-all-closed', () => { if (process.platform !== 'darwin') app.quit(); });
app.on('will-quit', () => { ftp.disconnect().catch(() => {}); db.close(); _xavatarCleanup(); });
// ── Window controls ───────────────────────────────────────────────────────────
ipcMain.on('window:minimize', () => win?.minimize());
ipcMain.on('window:maximize', () => win?.isMaximized() ? win.restore() : win.maximize());
ipcMain.on('window:close', () => win?.close());
// ── Settings ──────────────────────────────────────────────────────────────────
ipcMain.handle('settings:get', () => ({
ftpHost: store.get('ftp.host', ''),
ftpPort: store.get('ftp.port', '21'),
ftpPorts: store.get('ftp.ports', ['1337', '2121', '21']),
ftpUser: store.get('ftp.user', ''),
ftpPass: '',
ftpPassConfigured:hasDesktopSecret('ftpPassword'),
backupPath: store.get('backupPath', ''),
backupPaths: store.get('backupPaths', null) || (store.get('backupPath', '') ? [store.get('backupPath', '')] : []),
accentColor: store.get('accentColor', '#BB86FC'),
payloadLocalPath: store.get('payload.localPath', ''),
payloadRemotePath:store.get('payload.remotePath', '/data/payloads/'),
svResolution: store.get('sv.resolution', ''),
svVideoDevice: store.get('sv.videoDevice', ''),
svAudioDevice: store.get('sv.audioDevice', ''),
svGifFps: store.get('sv.gifFps', '10'),
svVidQuality: store.get('sv.vidQuality', '8000000'),
svHotkeys: store.get('sv.hotkeys', { mute: 'm', gif: 'g', video: 'v', fullscreen: 'f', popout: 'p' }),
svAutoLoad: store.get('sv.autoLoad', false),
savemgrPort: store.get('savemgr.port', store.get('savemgrPort', 8082)),
remoteGamePaths: store.get('remoteGamePaths', []),
mediaLocalPath: store.get('mediaLocalPath', ''),
discordWebhookUrl: '',
discordWebhookConfigured:hasDesktopSecret('discordWebhook'),
mediaPsNotifyEnabled: store.get('media.psNotify.enabled', true),
mediaPsNotifyDiscordDownload: store.get('media.psNotify.discordDownload', true),
mediaPsNotifyDiscordConvert: store.get('media.psNotify.discordConvert', true),
mediaPsNotifyDiscordUpload: store.get('media.psNotify.discordUpload', true),
mediaPsNotifyDiscordDone: store.get('media.psNotify.discordDone', true),
mediaPsNotifyDiscordError: store.get('media.psNotify.discordError', true),
y2jbZipUrl: store.get('y2jb.zipUrl', ''),
y2jbFtpPath: store.get('y2jb.ftpPath', '/data/'),
psnotifyEnabled: store.get('psnotify.enabled', true),
psnotifyPort: store.get('psnotify.port', 6969),
pinnedCards: store.get('pinnedCards', []),
autoConnect: store.get('autoConnect', false),
// ── Game Conversion (FFPKG / ExFAT) ────────────────────────────────────────
gameConversionMode: store.get('conv.mode', 'pfs'), // 'pfs' | 'ffpkg' | 'exfat'
ufs2ToolPath: store.get('conv.ufs2ToolPath', ''),
exfatToolPath: store.get('conv.exfatToolPath', ''),
osfmountPath: store.get('conv.osfmountPath', ''),
ufs2Method: store.get('conv.ufs2Method', 'makefs'), // 'makefs' | 'newfs'
convOutputDir: store.get('conv.outputDir', ''),
convTempDir: store.get('conv.tempDir', ''),
convFtpUpload: store.get('conv.ftpUpload', false),
convDeleteAfter: store.get('conv.deleteAfter', false),
convPsNotifyEnabled: store.get('conv.psNotify.enabled', true),
convPsNotifyOnGameQueued: store.get('conv.psNotify.onGameQueued', true),
convPsNotifyOnBatchQueued: store.get('conv.psNotify.onBatchQueued', true),
convPsNotifyOnCopyStart: store.get('conv.psNotify.onCopyStart', true),
convPsNotifyOnConvertStart:store.get('conv.psNotify.onConvertStart',true),
convPsNotifyOnJobDone: store.get('conv.psNotify.onJobDone', true),
bpgenOutputDir: store.get('bpgen.outputDir', store.get('bpgenOutputDir', '')),
bpgenFakelibBaseDir: store.get('bpgen.fakelibBaseDir', store.get('bpgenFakelibBaseDir', '')),
bpgenFakelibDir: store.get('bpgen.fakelibDir', store.get('bpgenFakelibDir', '')),
bpgenSdkPair: store.get('bpgen.sdkPair', store.get('bpgenSdkPair', '')),
bpgenFirmwareLabel: store.get('bpgen.firmwareLabel', store.get('bpgenFirmwareLabel', '')),
elfArsenalPort: store.get('elfArsenal.port', 6969),
language: store.get('language', 'en'),
webUiEnabled: store.get('webui.enabled', false),
webUiPort: store.get('webui.port', 6967),
hashConcurrency: store.get('hash.concurrency', 8),
ps4PackageBuilderPath: store.get('ps4.packageBuilderPath', ''),
ps4PackageOutputPath: store.get('ps4.packageOutputPath', path.join(app.getPath('documents'), 'Chimera', 'ps4-packages')),
ps5NativePackageBuilderPath: store.get('ps5.nativePackageBuilderPath', ''),
ps5NativePackageOutputPath: store.get('ps5.nativePackageOutputPath', path.join(app.getPath('documents'), 'Chimera', 'ps5-native-packages')),
}));
ipcMain.handle('settings:set', (_e, s) => {
if (s.ftpHost != null) store.set('ftp.host', s.ftpHost);
if (s.ftpPort != null) store.set('ftp.port', s.ftpPort);
if (s.ftpPorts != null) store.set('ftp.ports', s.ftpPorts);
if (s.ftpUser != null) store.set('ftp.user', s.ftpUser);
if (s.ftpPass != null) setDesktopSecret('ftpPassword', s.ftpPass);
if (s.backupPath != null) store.set('backupPath', s.backupPath);
if (s.backupPaths != null) { store.set('backupPaths', s.backupPaths); if (s.backupPaths.length) store.set('backupPath', s.backupPaths[0]); }
if (s.ps4PackageBuilderPath != null) {
const builderRoot = path.resolve(String(s.ps4PackageBuilderPath || ''));
if (!fs.existsSync(builderRoot) || !fs.statSync(builderRoot).isDirectory()) throw new Error('De PS4-builderlocatie moet een bestaande map zijn.');
store.set('ps4.packageBuilderPath', builderRoot);
}
if (s.ps4PackageOutputPath != null) store.set('ps4.packageOutputPath', path.resolve(String(s.ps4PackageOutputPath || '')));
if (s.ps5NativePackageBuilderPath != null) {
const builderRoot = path.resolve(String(s.ps5NativePackageBuilderPath || ''));
if (!fs.existsSync(builderRoot) || !fs.statSync(builderRoot).isDirectory()) throw new Error('De PS5-packagebridgelocatie moet een bestaande map zijn.');
store.set('ps5.nativePackageBuilderPath', builderRoot);
}
if (s.ps5NativePackageOutputPath != null) store.set('ps5.nativePackageOutputPath', path.resolve(String(s.ps5NativePackageOutputPath || '')));
if (s.accentColor != null) store.set('accentColor', s.accentColor);
if (s.payloadLocalPath != null) store.set('payload.localPath', s.payloadLocalPath);
if (s.payloadRemotePath != null) store.set('payload.remotePath', s.payloadRemotePath);
if (s.svResolution != null) store.set('sv.resolution', s.svResolution);
if (s.svVideoDevice != null) store.set('sv.videoDevice', s.svVideoDevice);
if (s.svAudioDevice != null) store.set('sv.audioDevice', s.svAudioDevice);
if (s.svGifFps != null) store.set('sv.gifFps', s.svGifFps);
if (s.svVidQuality != null) store.set('sv.vidQuality', s.svVidQuality);
if (s.svHotkeys != null) store.set('sv.hotkeys', s.svHotkeys);
if (s.svAutoLoad != null) store.set('sv.autoLoad', s.svAutoLoad);
if (s.savemgrPort != null) { store.set('savemgr.port', Number(s.savemgrPort)); store.set('savemgrPort', Number(s.savemgrPort)); }
if (s.remoteGamePaths != null) store.set('remoteGamePaths', s.remoteGamePaths);
if (s.mediaLocalPath != null) store.set('mediaLocalPath', s.mediaLocalPath);
if (s.discordWebhookUrl != null) setDesktopSecret('discordWebhook', s.discordWebhookUrl);
if (s.mediaPsNotifyEnabled != null) store.set('media.psNotify.enabled', s.mediaPsNotifyEnabled);
if (s.mediaPsNotifyDiscordDownload != null) store.set('media.psNotify.discordDownload', s.mediaPsNotifyDiscordDownload);
if (s.mediaPsNotifyDiscordConvert != null) store.set('media.psNotify.discordConvert', s.mediaPsNotifyDiscordConvert);
if (s.mediaPsNotifyDiscordUpload != null) store.set('media.psNotify.discordUpload', s.mediaPsNotifyDiscordUpload);
if (s.mediaPsNotifyDiscordDone != null) store.set('media.psNotify.discordDone', s.mediaPsNotifyDiscordDone);
if (s.mediaPsNotifyDiscordError != null) store.set('media.psNotify.discordError', s.mediaPsNotifyDiscordError);
if (s.y2jbZipUrl != null) store.set('y2jb.zipUrl', s.y2jbZipUrl);
if (s.y2jbFtpPath != null) store.set('y2jb.ftpPath', s.y2jbFtpPath);
if (s.psnotifyEnabled != null) store.set('psnotify.enabled', s.psnotifyEnabled);
if (s.psnotifyPort != null) store.set('psnotify.port', s.psnotifyPort);
if (s.pinnedCards != null) store.set('pinnedCards', s.pinnedCards);
if (s.autoConnect != null) store.set('autoConnect', s.autoConnect);
// ── Game Conversion ────────────────────────────────────────────────────────
if (s.gameConversionMode != null) store.set('conv.mode', s.gameConversionMode);
if (s.ufs2ToolPath != null) store.set('conv.ufs2ToolPath', s.ufs2ToolPath);
if (s.exfatToolPath != null) store.set('conv.exfatToolPath', s.exfatToolPath);
if (s.osfmountPath != null) store.set('conv.osfmountPath', s.osfmountPath);
if (s.ufs2Method != null) store.set('conv.ufs2Method', s.ufs2Method);
if (s.convOutputDir != null) store.set('conv.outputDir', s.convOutputDir);
if (s.convTempDir != null) store.set('conv.tempDir', s.convTempDir);
if (s.convFtpUpload != null) store.set('conv.ftpUpload', s.convFtpUpload);
if (s.convDeleteAfter != null) store.set('conv.deleteAfter', s.convDeleteAfter);
if (s.convPsNotifyEnabled != null) store.set('conv.psNotify.enabled', s.convPsNotifyEnabled);
if (s.convPsNotifyOnGameQueued != null) store.set('conv.psNotify.onGameQueued', s.convPsNotifyOnGameQueued);
if (s.convPsNotifyOnBatchQueued != null) store.set('conv.psNotify.onBatchQueued', s.convPsNotifyOnBatchQueued);
if (s.convPsNotifyOnCopyStart != null) store.set('conv.psNotify.onCopyStart', s.convPsNotifyOnCopyStart);
if (s.convPsNotifyOnConvertStart != null) store.set('conv.psNotify.onConvertStart',s.convPsNotifyOnConvertStart);
if (s.convPsNotifyOnJobDone != null) store.set('conv.psNotify.onJobDone', s.convPsNotifyOnJobDone);
if (s.bpgenOutputDir != null) { store.set('bpgen.outputDir', s.bpgenOutputDir); store.set('bpgenOutputDir', s.bpgenOutputDir); }
if (s.bpgenFakelibBaseDir != null) { store.set('bpgen.fakelibBaseDir', s.bpgenFakelibBaseDir); store.set('bpgenFakelibBaseDir', s.bpgenFakelibBaseDir); }
if (s.bpgenFakelibDir != null) { store.set('bpgen.fakelibDir', s.bpgenFakelibDir); store.set('bpgenFakelibDir', s.bpgenFakelibDir); }
if (s.bpgenSdkPair != null) { store.set('bpgen.sdkPair', s.bpgenSdkPair); store.set('bpgenSdkPair', s.bpgenSdkPair); }
if (s.bpgenFirmwareLabel != null) { store.set('bpgen.firmwareLabel', s.bpgenFirmwareLabel); store.set('bpgenFirmwareLabel', s.bpgenFirmwareLabel); }
if (s.elfArsenalPort != null) store.set('elfArsenal.port', s.elfArsenalPort);
if (s.language != null) store.set('language', s.language);
// Web UI settings — apply changes immediately if webServer is ready
if (s.webUiEnabled != null) store.set('webui.enabled', s.webUiEnabled);
if (s.webUiPort != null) store.set('webui.port', Number(s.webUiPort));
if (s.hashConcurrency != null) store.set('hash.concurrency', Math.max(1, Math.min(32, Number(s.hashConcurrency) || 8)));
if (webServer && (s.webUiEnabled != null || s.webUiPort != null)) {
const enabled = store.get('webui.enabled', false);
const port = store.get('webui.port', 6967);
if (enabled) {
webServer.stop().then(() => webServer.start(port)).catch(e => log.error('[WebUI] Restart failed:', e.message));
} else {
webServer.stop().catch(e => log.error('[WebUI] Stop failed:', e.message));
}
}
return true;
});
// ── Web UI control IPC handlers ───────────────────────────────────────────────
ipcMain.handle('webui:status', () => {
const running = webServer ? webServer.isRunning() : false;
const port = running ? webServer.port : store.get('webui.port', 6967);
return { running, port, hosts: ['localhost', '127.0.0.1'], localOnly: true };
});
ipcMain.handle('webui:start', async () => {
if (!webServer) return { ok: false, error: 'Server not initialized' };
if (webServer.isRunning()) return { ok: true, port: webServer.port };
const port = store.get('webui.port', 6967);
try {
await webServer.start(port);
store.set('webui.enabled', true);
return { ok: true, port };
} catch (e) {
return { ok: false, error: e.message };
}
});
ipcMain.handle('webui:stop', async () => {
if (!webServer || !webServer.isRunning()) return { ok: true };
try {
await webServer.stop();
store.set('webui.enabled', false);
return { ok: true };
} catch (e) {
return { ok: false, error: e.message };
}
});
// ── Language / i18n ─────────────────────────────────────────────────────────
//
// Two-tier language resolution:
// 1. Bundled: build/lang/*.json (shipped with the app, read-only)
// 2. Imported: userData/lang/*.json (user-added, persists across updates)
// Imported files override bundled ones when the same language code exists.
// Lazy getter — app.getPath() requires app to be ready
function getExtLangDir() {
return path.join(app.getPath('userData'), 'lang');
}
// Returns a Map of filename → { filePath, source: 'bundled'|'imported' }
// Imported entries shadow bundled entries with the same filename.
async function listLangFiles() {
const files = new Map();
for (const f of await fs.promises.readdir(LANG_DIR).catch(() => [])) {
if (f.endsWith('.json')) files.set(f, { filePath: path.join(LANG_DIR, f), source: 'bundled' });
}
const extDir = getExtLangDir();
for (const f of await fs.promises.readdir(extDir).catch(() => [])) {
if (f.endsWith('.json')) files.set(f, { filePath: path.join(extDir, f), source: 'imported' });
}
return files;
}
ipcMain.handle('lang:list', async () => {
try {
const files = await listLangFiles();
const langs = [];
for (const [, { filePath, source }] of files) {
try {
const raw = await fs.promises.readFile(filePath, 'utf8');
const data = JSON.parse(raw);
if (data.meta && data.meta.code && data.meta.name)
langs.push({ code: data.meta.code, name: data.meta.name, source });
} catch (_) {}
}
langs.sort((a, b) => a.code === 'en' ? -1 : b.code === 'en' ? 1 : a.name.localeCompare(b.name));
if (!langs.length) langs.push({ code: 'en', name: 'English', source: 'bundled' });
return langs;
} catch (_) {
return [{ code: 'en', name: 'English', source: 'bundled' }];
}
});
ipcMain.handle('lang:load', async (_e, code) => {
if (typeof code !== 'string' || !/^[a-z]{2,8}(-[A-Za-z]{2,4})?$/.test(code))
throw new Error('Invalid language code.');
const files = await listLangFiles();
const entry = files.get(`${code}.json`);
if (!entry) throw new Error(`Language not found: ${code}`);
const raw = await fs.promises.readFile(entry.filePath, 'utf8');
const data = JSON.parse(raw);
if (!data.meta || typeof data.strings !== 'object') throw new Error('Invalid language file format.');
return data;
});
ipcMain.handle('lang:export-base', async () => {
const { filePath: dest, canceled } = await dialog.showSaveDialog(win, {
title: 'Export English Base Language File',
defaultPath: 'en.json',
filters: [{ name: 'JSON Language File', extensions: ['json'] }],
});
if (canceled || !dest) return { canceled: true };
const src = path.join(LANG_DIR, 'en.json');
await fs.promises.copyFile(src, dest);
return { ok: true, dest };
});
ipcMain.handle('lang:import', async () => {
try {
const result = await dialog.showOpenDialog({
title: 'Import Language File',
filters: [{ name: 'JSON Language File', extensions: ['json'] }],
properties: ['openFile'],
});
const { canceled, filePaths } = result;
if (canceled || !filePaths || !filePaths.length) return { canceled: true };
const raw = await fs.promises.readFile(filePaths[0], 'utf8');
const data = JSON.parse(raw);
if (!data.meta || !data.meta.code || !data.meta.name || typeof data.strings !== 'object')
throw new Error('Invalid language file. Must have meta.code, meta.name, and a strings object.');
if (!/^[a-z]{2,8}(-[A-Za-z]{2,4})?$/.test(data.meta.code))
throw new Error(`Invalid language code "${data.meta.code}" in file.`);
const extDir = getExtLangDir();
await fs.promises.mkdir(extDir, { recursive: true });
const dest = path.join(extDir, `${data.meta.code}.json`);
await fs.promises.writeFile(dest, raw, 'utf8');
return { ok: true, code: data.meta.code, name: data.meta.name };
} catch (err) {
log.error('[lang:import] Failed:', err.message);
throw err;
}
});
// ── Y2JB Updater ──────────────────────────────────────────────────────────────
ipcMain.handle('y2jb:check', async () => {
const url = store.get('y2jb.zipUrl', '');
if (!url) throw new Error('No ZIP URL configured. Open Y2JB Settings to set one.');
const lastModified = await new Promise((resolve, reject) => {
const parsed = new URL(url);
const lib = parsed.protocol === 'https:' ? https : http;
const req = lib.request(
{ hostname: parsed.hostname, path: parsed.pathname + parsed.search, method: 'HEAD' },
res => resolve(res.headers['last-modified'] || res.headers['date'] || '')
);
req.on('error', reject);
req.setTimeout(10000, () => req.destroy(new Error('Timeout')));
req.end();
});
return { lastModified, lastApplied: store.get('y2jb.lastApplied', '') };
});
ipcMain.handle('y2jb:apply', async () => {
const zipUrl = store.get('y2jb.zipUrl', '');
const ftpPath = store.get('y2jb.ftpPath', '/data/');
if (!zipUrl) throw new Error('No ZIP URL configured. Open Y2JB Settings.');
if (!ftp.isConnected() && !ftp.hasCredentials()) throw new Error('Not connected to FTP. Connect to your PS5 first.');
const filename = new URL(zipUrl).pathname.split('/').pop() || 'y2jb-update.zip';
const tmpFile = path.join(os.tmpdir(), filename);
const finalFtpPath = ftpPath.endsWith('/') ? ftpPath + filename : ftpPath + '/' + filename;
// Download ZIP to temp file (follow one redirect)
await new Promise((resolve, reject) => {
let redirects = 0;
const doGet = (targetUrl) => {
const parsed = new URL(targetUrl);
const lib = parsed.protocol === 'https:' ? https : http;
lib.get(targetUrl, res => {
// Redirect: drain the response to free the socket, then follow (capped).
if (res.statusCode >= 300 && res.statusCode < 400) {
res.resume();
if (!res.headers.location) { reject(new Error(`Redirect ${res.statusCode} without Location header`)); return; }
if (++redirects > 5) { reject(new Error('Too many redirects downloading ZIP')); return; }
doGet(res.headers.location);
return;
}
if (res.statusCode >= 400) {
res.resume();
reject(new Error(`HTTP ${res.statusCode} downloading ZIP`));
return;
}
// Final response only — open the write stream here so redirects never race it.
const file = fs.createWriteStream(tmpFile);
res.pipe(file);
file.on('finish', () => file.close(resolve));
file.on('error', reject);
}).on('error', reject).setTimeout(60000, function() { this.destroy(new Error('Download timeout')); });
};
doGet(zipUrl);
});
store.set('y2jb.lastApplied', new Date().toISOString());
return transferMgr.enqueue('upload', {
label: `Y2JB Update \u2192 PS5`,
localPath: tmpFile,
remotePath: finalFtpPath,
});
});
// ── PSNotify IPC ──────────────────────────────────────────────────────────────
ipcMain.handle('psnotify:send', async (_e, { message, subMessage = '' }) => {
const host = store.get('ftp.host', '');
if (!host) throw new Error('No PS5 IP configured. Set one in FTP Settings.');
ps5.setHost(host);
ps5.port = store.get('psnotify.port', 6969);
ps5.timeout = store.get('psnotify.timeout', 5000);
await ps5.send(message, { subMessage });
return { ok: true };
});
ipcMain.handle('psnotify:history', () => ps5.history(100));
ipcMain.handle('psnotify:pushPayload', async () => {
if (!ftp.isConnected() && !ftp.hasCredentials()) throw new Error('Not connected to FTP. Connect to your PS5 first.');
const localPath = path.join(__dirname, 'build', 'garlic', 'customPSNotify.js');
const remotePath = store.get('payload.remotePath', '/data/payloads/');
const remoteFile = remotePath.endsWith('/') ? remotePath + 'customPSNotify.js' : remotePath + '/customPSNotify.js';
transferMgr.enqueue('upload', {
label: 'customPSNotify.js (UTF-8 patched) → PS5',
localPath,
remotePath: remoteFile,
}).catch(e => log.warn('[PSNotify] Push payload failed: ' + e.message));
return { ok: true, remotePath: remoteFile };
});
// Upload garlic-savemgr.elf to /data/ps5_autoloader and append it to autoload.txt.
ipcMain.handle('savemgr:push-payload', async (_event, input = {}) => {
if (input.confirmation !== 'INSTALLEER GARLIC AUTOLOAD') {
throw new Error('Typ INSTALLEER GARLIC AUTOLOAD om deze actie te bevestigen.');
}
if (!ftp.isConnected() && !ftp.hasCredentials()) throw new Error('Not connected to FTP. Connect to your PS5 first.');
const localPath = path.join(__dirname, 'build', 'garlic', 'garlic-savemgr.elf');
if (!fs.existsSync(localPath)) throw new Error('garlic-savemgr.elf not found in the build folder.');
// Upload the ELF
const remoteElf = `${AUTOLOADER_DIR}/garlic-savemgr.elf`;
await ftp.uploadFile(localPath, remoteElf);
// Read autoload.txt, append filename if not already present
let content = '';
const tmpPath = path.join(os.tmpdir(), `pork-autoload-savemgr-${Date.now()}.txt`);
try {
await ftp.downloadFile(AUTOLOADER_FILE, tmpPath);
content = await fs.promises.readFile(tmpPath, 'utf8');
} catch (_) {}
const lines = content.split('\n').map(l => l.trim()).filter(Boolean);
let appended = false;
if (!lines.includes('garlic-savemgr.elf')) {
lines.push('garlic-savemgr.elf');
const updated = lines.join('\n') + '\n';
await fs.promises.writeFile(tmpPath, updated, 'utf8');
await ftp.uploadFile(tmpPath, AUTOLOADER_FILE);
appended = true;
}
await fs.promises.unlink(tmpPath).catch(() => {});
// Send a PS5 notification if psnotify is configured
try {
const host = store.get('ftp.host', '');
if (host && store.get('psnotify.enabled', true)) {
ps5.setHost(host);
ps5.port = store.get('psnotify.port', 6969);
ps5.timeout = store.get('psnotify.timeout', 5000);
await ps5.send('Local Save Manager installed ✓', { subMessage: 'Reboot your console to apply.' });
}
} catch (_) {}
return { ok: true, remoteElf, appended };
});
// ── GarlicMgr native API proxy ───────────────────────────────────────────────
function _smgrBase() {
const ip = store.get('ftp.host', '');
const port = store.get('savemgr.port', store.get('savemgrPort', 8082));
if (!ip) throw new Error('PS5 IP not set. Configure FTP Settings first.');
return `http://${ip}:${port}`;
}
// Generic GET → JSON
ipcMain.handle('savemgr:request', async (_, { path: apiPath }) => {
const r = await nodeFetch(_smgrBase() + apiPath);
if (!r.ok) throw new Error(`HTTP ${r.status}`);
return r.json();
});
// POST binary data → JSON
ipcMain.handle('savemgr:upload', async (_, { path: apiPath, data }) => {
const r = await nodeFetch(_smgrBase() + apiPath, {
method: 'POST',
headers: { 'Content-Type': 'application/octet-stream' },
body: Buffer.from(data),
});
if (!r.ok) throw new Error(`HTTP ${r.status}`);
return r.json();
});
// GET binary → show system save dialog → write file
ipcMain.handle('savemgr:download', async (_, { path: apiPath, filename }) => {
const r = await nodeFetch(_smgrBase() + apiPath);
if (!r.ok) throw new Error(`HTTP ${r.status}`);
const buf = await r.buffer();
const { canceled, filePath } = await dialog.showSaveDialog(win, { defaultPath: filename });
if (canceled || !filePath) return { canceled: true };
await fs.promises.writeFile(filePath, buf);
shell.showItemInFolder(filePath);
return { ok: true, size: buf.length };
});
// POST binary → receive binary ZIP → save dialog
ipcMain.handle('savemgr:decrypt', async (_, { data, filename }) => {
const r = await nodeFetch(_smgrBase() + '/api/decrypt_upload', {
method: 'POST',
headers: { 'Content-Type': 'application/octet-stream' },
body: Buffer.from(data),
});
if (!r.ok) {
const d = await r.json().catch(() => ({}));
throw new Error(d.error || `HTTP ${r.status}`);
}
const buf = await r.buffer();
const { canceled, filePath } = await dialog.showSaveDialog(win, { defaultPath: filename });
if (canceled || !filePath) return { canceled: true };
await fs.promises.writeFile(filePath, buf);
shell.showItemInFolder(filePath);
return { ok: true, size: buf.length };
});
// POST binary → resign → download resigned binary → save dialog
ipcMain.handle('savemgr:resign', async (_, { data, aid, filename }) => {
const r = await nodeFetch(_smgrBase() + `/api/resign?aid=${encodeURIComponent(aid)}`, {
method: 'POST',
headers: { 'Content-Type': 'application/octet-stream' },
body: Buffer.from(data),
});
const d = await r.json();
if (d.error) throw new Error(d.error);
const r2 = await nodeFetch(_smgrBase() + `/api/resign_download?name=${encodeURIComponent(filename)}`);
if (!r2.ok) throw new Error(`HTTP ${r2.status}`);
const buf = await r2.buffer();
const { canceled, filePath } = await dialog.showSaveDialog(win, { defaultPath: filename });
if (canceled || !filePath) return { canceled: true };
await fs.promises.writeFile(filePath, buf);
shell.showItemInFolder(filePath);
return { ok: true, size: buf.length };
});
// GET game icon → base64 data URL
ipcMain.handle('savemgr:icon', async () => {
try {
const r = await nodeFetch(_smgrBase() + `/api/icon?t=${Date.now()}`);
if (!r.ok) return null;
const buf = await r.buffer();
const ct = r.headers.get('content-type') || 'image/png';
return `data:${ct};base64,${buf.toString('base64')}`;
} catch (_) { return null; }
});
// GET /api/create_pfs → JSON
ipcMain.handle('savemgr:create-pfs', async (_, { size }) => {
const r = await nodeFetch(_smgrBase() + `/api/create_pfs?size=${size}`);
if (!r.ok) throw new Error(`HTTP ${r.status}`);
return r.json();
});
// GET /api/download_new → binary → save dialog
ipcMain.handle('savemgr:download-new', async (_, { name, aid }) => {
let url = _smgrBase() + `/api/download_new?name=${encodeURIComponent(name)}`;
if (aid) url += `&aid=${encodeURIComponent(aid)}`;
const r = await nodeFetch(url);
if (!r.ok) throw new Error(`HTTP ${r.status}`);
const buf = await r.buffer();
const { canceled, filePath } = await dialog.showSaveDialog(win, { defaultPath: name });
if (canceled || !filePath) return { canceled: true };
await fs.promises.writeFile(filePath, buf);
shell.showItemInFolder(filePath);
return { ok: true, size: buf.length };
});
// GET /api/dump_usb streaming → emit progress events to renderer
ipcMain.handle('savemgr:dump-usb', async (_, { idx }) => {
const r = await nodeFetch(_smgrBase() + `/api/dump_usb?idx=${idx}`);
if (!r.ok || !r.body) throw new Error(`HTTP ${r.status}`);
let buf = '';
let last = null;
const handleLine = (line) => {
if (!line.trim()) return;
let j;
try { j = JSON.parse(line); }
catch { return; } // skip a malformed NDJSON line instead of aborting the whole stream
if (j.error) throw new Error(j.error);
if (j.progress !== undefined) win?.webContents.send('savemgr:usb-progress', j);
if (j.ok) last = j;
};
for await (const chunk of r.body) {
buf += chunk.toString();
const lines = buf.split('\n');
buf = lines.pop();
for (const line of lines) handleLine(line);
}
if (buf) handleLine(buf); // flush trailing partial line
return last || { ok: true };
});
ipcMain.handle('psnotify:test', async () => {
const host = store.get('ftp.host', '');
if (!host) throw new Error('No PS5 IP configured. Set one in FTP Settings.');
ps5.setHost(host);
ps5.port = store.get('psnotify.port', 6969);
ps5.timeout = store.get('psnotify.timeout', 5000);
await ps5.send('Porkfolio Test', { subMessage: 'PSNotify is working!' });
return { ok: true };
});
// ── FTP ───────────────────────────────────────────────────────────────────────
ipcMain.handle('ftp:connect', async (_e, creds) => {
const resolvedCreds = { ...creds, password: creds?.password || getDesktopSecret('ftpPassword') };
// Support multiple ports: try each in order until one succeeds.
const ports = (Array.isArray(resolvedCreds.ports) && resolvedCreds.ports.length)
? resolvedCreds.ports
: [resolvedCreds.port || '21'];
const failures = [];
for (const port of ports) {
try {
await ftp.connect({ ...resolvedCreds, port: String(port) });
store.set('ftp.lastPort', String(port)); // remember for extra-client connections
store.set('ftp.host', resolvedCreds.host); // ensure host is stored so ps5Notify can reach it
ps5Notify('🐷 Porkfolio Connected ✓', 'Created by StonedModder');
return { success: true, port };
} catch (e) {
const reason = e.code === 'ECONNREFUSED' ? 'refused' : e.message.split('\n')[0];
log.warn(`[FTP] Connect attempt on port ${port} failed: ${e.message}`);
failures.push(`${port} (${reason})`);
}
}
const summary = failures.length === 1
? `Connection failed: ${failures[0]}`
: `All ports failed — ${failures.join(', ')}`;
throw new Error(summary);
});
ipcMain.handle('ftp:disconnect', async () => {
await ftp.disconnect();
return { success: true };
});
ipcMain.handle('ftp:status', () => ({ connected: ftp.isConnected() }));
async function runDesktopFtpScan() {
const customPaths = (store.get('remoteGamePaths', []) || []).map(p => p.path).filter(Boolean);
const scanResult = await ftp.scanGames(progress => {
win?.webContents.send('ftp:progress', progress);
}, customPaths);
const reconciliation = reconcileConsoleScan(db, scanResult, {
consoleProfileId: consoleProfiles?.activeId?.() || 'legacy-console',
});
if (!reconciliation.applied) {
log.warn(`[FTP] Scan reconcile skipped: ${reconciliation.reason}`);
}
// Cross-reference with local backups and queue their Prospero fetches too
const _ftpScanBps = store.get('backupPaths', null) || (store.get('backupPath', '') ? [store.get('backupPath', '')] : []);
for (const bp of _ftpScanBps) {
if (bp) {
const { found } = await scanLocalBackups(bp);
queueProsperoFetch(found);
}
}
// Queue prospero metadata fetch for all FTP-discovered games
queueProsperoFetch(scanResult.games.map(g => g.game_id));
ps5Notify('PS5 Scan Complete', `${scanResult.count} game${scanResult.count !== 1 ? 's' : ''} found`);
return scanResult;
}
ipcMain.handle('ftp:scan', runDesktopFtpScan);
ipcMain.handle('ftp:list-dir', async (_e, { path }) => {
return ftp.listDir(path || '/');
});
ipcMain.handle('ftp:find-game', async (_e, { game_id }) => {
if (!ftp.isConnected() && !ftp.hasCredentials()) return null;
log.info(`[FTP] find-game: searching for ${game_id}`);
const customPaths = (store.get('remoteGamePaths', []) || []).map(p => p.path).filter(Boolean);
const scanPaths = customPaths.length
? customPaths
: ['/mnt/sandbox/pfsmnt/', '/user/app/', '/mnt/data/app/'];
for (const basePath of scanPaths) {
const base = basePath.endsWith('/') ? basePath : basePath + '/';
try {
const list = await ftp.listDir(base);
const match = list.find(f => f.isDir && f.name.startsWith(game_id));
if (match) {
log.info(`[FTP] find-game: found ${game_id} at ${base + match.name}`);
return base + match.name;
}
} catch (e) {
log.info(`[FTP] find-game: skipping ${base} — ${e.message}`);
}
}
log.info(`[FTP] find-game: ${game_id} not found in any configured path`);
return null;
});
ipcMain.handle('ftp:delete-game', async (_e, input = {}) => {
requireExactConfirmation(input, 'VERWIJDER VAN PS5');
const { game_id } = input;
const game = db.getGame(game_id);
if (!game?.ftp_path) throw new Error('No PS5 path stored for this game — cannot delete.');
await ftp.removeDir(game.ftp_path);
db.markConsoleInstallAbsent(game_id, game.ftp_path);
db.updateGame(game_id, { ftp_path: '' });
log.info(`[FTP] Deleted game ${game_id} from PS5 at ${game.ftp_path}`);
return { success: true };
});
// ── Transfer Queue Manager ────────────────────────────────────────────────────
const transferMgr = {
jobs: [],
maxConcurrent: 1,
paused: false,
nextId: 1,
// Enqueue a transfer job. Returns a Promise that resolves/rejects when the
// job actually completes (so IPC callers can still await results).
enqueue(type, opts) {
const job = {
id: this.nextId++,
type,
label: opts.label || `${type} ${opts.gameId || ''}`.trim(),
gameId: opts.gameId || null,
localPath: opts.localPath || null,
remotePath: opts.remotePath || null,
// pork-specific
folderName: opts.folderName || null,
folderPath: opts.folderPath || null,
status: 'queued',
progress: { percent: 0, speedBps: 0, transferred: 0, total: 0, file: '', filesDone: 0, filesTotal: 0 },
error: null,
addedAt: Date.now(),
startedAt: null,
completedAt: null,
_resolve: null,
_reject: null,
_cancelled: false,
};
const promise = new Promise((resolve, reject) => {
job._resolve = resolve;
job._reject = reject;
});
this.jobs.push(job);
this._notify();
this._tick();
return promise;
},
pause() { this.paused = true; this._notify(); },
resume() { this.paused = false; this._tick(); this._notify(); },
cancel(id) {
const job = this.jobs.find(j => j.id === id);
if (!job) return;
job._cancelled = true;
if (job.status === 'queued') {
job.status = 'cancelled';
job._reject?.(new Error('Cancelled'));
this._notify();
}
// Active jobs check _cancelled in their progress loop; they'll abort shortly.
},
clearDone() {
this.jobs = this.jobs.filter(j => j.status !== 'done' && j.status !== 'error' && j.status !== 'cancelled');
this._notify();
},
setConcurrent(n) {
this.maxConcurrent = Math.max(1, Math.min(15, n));
this._tick();
this._notify();
},
getState() {
return {
jobs: this.jobs.map(({ _resolve, _reject, _cancelled, ...rest }) => rest),
maxConcurrent: this.maxConcurrent,
paused: this.paused,
activeCount: this.jobs.filter(j => j.status === 'active').length,
queuedCount: this.jobs.filter(j => j.status === 'queued').length,
};
},
_notify() {
win?.webContents.send('transfer:update', this.getState());
},
_tick() {
if (this.paused) return;
const activeCount = this.jobs.filter(j => j.status === 'active').length;
const slots = this.maxConcurrent - activeCount;
if (slots <= 0) return;
const queued = this.jobs.filter(j => j.status === 'queued');
for (let i = 0; i < Math.min(slots, queued.length); i++) {
this._runJob(queued[i]);
}
},
async _runJob(job) {
job.status = 'active';
job.startedAt = Date.now();
this._notify();
// Determine whether to use the primary singleton or a fresh extra client.
// Slot 0 (i.e. when primary is free): use ftp singleton directly.
// Extra slots: create a dedicated client using stored creds.
const activeJobs = this.jobs.filter(j => j.status === 'active');
const usePrimary = activeJobs.length <= 1; // this job just became active, so ≤ 1
let extraClient = null;
let ftpClient; // object with uploadFile / uploadDirectory / downloadFile
try {
if (!usePrimary) {
const creds = {
host: store.get('ftp.host', ''),
port: store.get('ftp.lastPort', store.get('ftp.port', '21')),
user: store.get('ftp.user', ''),
password: getDesktopSecret('ftpPassword'),
};
extraClient = await ftp.createClient(creds);
ftpClient = extraClient;
} else {
// Wrap singleton methods so callers use the same interface
ftpClient = {
uploadFile: ftp.uploadFile.bind(ftp),
uploadDirectory: ftp.uploadDirectory.bind(ftp),
downloadFile: ftp.downloadFile.bind(ftp),
};
}
// Speed tracking
let lastBytes = 0;
let lastTime = Date.now();
const _notifiedMilestones = new Set();
const trackProgress = (info) => {
if (job._cancelled) return;
const now = Date.now();
const dt = (now - lastTime) / 1000;
const bytesDone = info.bytesOverall || info.bytes || 0;
if (dt >= 0.2) {
job.progress.speedBps = Math.max(0, Math.round((bytesDone - lastBytes) / dt));
lastBytes = bytesDone;
lastTime = now;
}
// Directory upload shape: { file, filesDone, filesTotal, bytes, bytesOverall, currentFileSize }
if (info.filesTotal != null) {
const filesDone = info.filesDone || 0;
const filesTotal = info.filesTotal || 1;
// Smooth %: blend completed files with the byte-fraction of the current in-flight file.
// filesDone is 1-indexed (current file), so completed = filesDone - 1.
const currentFileSize = info.currentFileSize || 0;
const currentBytes = info.bytes || 0;
const fileFraction = currentFileSize > 0 ? Math.min(currentBytes / currentFileSize, 1) : 0;
const smoothDone = Math.max(0, filesDone - 1) + fileFraction;
job.progress.percent = Math.min(99, Math.round((smoothDone / filesTotal) * 100));
job.progress.file = info.file || '';
job.progress.filesDone = filesDone;
job.progress.filesTotal = filesTotal;
job.progress.transferred = bytesDone;
job.progress.currentBytes = currentBytes;
job.progress.currentFileSize = currentFileSize;
// 25 / 50 / 75 % milestone notifications
for (const m of [25, 50, 75]) {
if (job.progress.percent >= m && !_notifiedMilestones.has(m)) {
_notifiedMilestones.add(m);
ps5Notify(`${job.label} \u2014 ${m}%`, `${filesDone} / ${filesTotal} files`);
}
}
} else {
// Single file shape: { bytes, bytesOverall, name, type, size }
// bytesOverall is the cumulative total across the session; bytes is the
// window delta. size is injected by _uploadFileWith via fs.statSync.
const total = info.size || 0;
const done = info.bytesOverall || info.bytes || 0;
job.progress.percent = total > 0 ? Math.min(99, Math.round((done / total) * 100)) : 0;
job.progress.transferred = done;
job.progress.total = total;
}
this._notify();
};
let result;
// Factory for extra file-worker FTP connections used inside _uploadDirectoryWith.
const _ftpWorkerCreds = {
host: store.get('ftp.host', ''),
port: store.get('ftp.lastPort', store.get('ftp.port', '21')),
user: store.get('ftp.user', ''),
password: getDesktopSecret('ftpPassword'),
};
const _createWorkerClient = () => ftp.createRawClient(_ftpWorkerCreds);
if (job.type === 'upload') {
const stat = fs.statSync(job.localPath);
if (stat.isDirectory()) {
const uploadHooks = {
onStart: ({ count, totalBytes }) => {
job.progress.totalBytes = totalBytes;
ps5Notify(job.label, `${count} file${count !== 1 ? 's' : ''} \u00b7 ${fmtBytes(totalBytes)}`);
},
onRetry: ({ rel, attempt, maxAttempts }) =>
ps5Notify(`Retrying \u2014 ${job.label}`, `"${rel.split('/').pop()}" attempt ${attempt + 1}/${maxAttempts}`),
onFileFailed: ({ rel, attempts }) =>
ps5Notify(`Install Failed \u2014 ${job.label}`, `"${rel.split('/').pop()}" failed after ${attempts} attempts`),
fileConcurrency: transferMgr.maxConcurrent,
_createWorkerClient,
};
await ftpClient.uploadDirectory(job.localPath, job.remotePath, info => {
win?.webContents.send('ftp:upload:progress', info);
trackProgress(info);
if (job._cancelled) throw new Error('Cancelled');
}, uploadHooks);
} else {
await ftpClient.uploadFile(job.localPath, job.remotePath, info => {
win?.webContents.send('ftp:upload:progress', info);
trackProgress(info);
if (job._cancelled) throw new Error('Cancelled');
});
}
result = { success: true };
} else if (job.type === 'download') {
await ftpClient.downloadFile(job.remotePath, job.localPath, info => {
win?.webContents.send('ftp:download:progress', info);
trackProgress(info);
if (job._cancelled) throw new Error('Cancelled');
});
// Only update the DB for game-save backups (media downloads have no gameId)
if (job.gameId) {
const stat = fs.statSync(job.localPath);
db.upsertBackup({
game_id: job.gameId,
backup_path: job.localPath,
size: stat.size,
backup_type: path.extname(job.localPath).slice(1) || 'dump',
});
}
result = { localPath: job.localPath };
} else if (job.type === 'download-folder') {
// Mirror a full game folder from PS5 → local backup directory.
await ftpClient.downloadDirectory(job.remotePath, job.localPath, info => {
win?.webContents.send('ftp:download:progress', info);
trackProgress(info);
if (job._cancelled) throw new Error('Cancelled');
});
const size = await dirSize(job.localPath);
db.upsertBackup({
game_id: job.gameId,
backup_path: job.localPath,
size,
backup_type: 'folder',
});
result = { localPath: job.localPath };
} else if (job.type === 'pork') {
const subfolder = findGameSubfolder(job.folderPath, job.gameId);
if (!subfolder) throw new Error(`No folder for ${job.gameId} found in ${job.folderPath}`);
const localGameDir = path.join(job.folderPath, subfolder);
const gameRow = db.getGame(job.gameId);
const remotePath = gameRow?.ftp_path || `/mnt/sandbox/pfsmnt/${job.gameId}-app0`;
const porkHooks = {
onStart: ({ count, totalBytes }) => {
job.progress.totalBytes = totalBytes;
ps5Notify(job.label, `${count} file${count !== 1 ? 's' : ''} \u00b7 ${fmtBytes(totalBytes)}`);
},
onRetry: ({ rel, attempt, maxAttempts }) =>
ps5Notify(`Retrying \u2014 ${job.label}`, `"${rel.split('/').pop()}" attempt ${attempt + 1}/${maxAttempts}`),
onFileFailed: ({ rel, attempts }) =>
ps5Notify(`Install Failed \u2014 ${job.label}`, `"${rel.split('/').pop()}" failed after ${attempts} attempts`),
fileConcurrency: transferMgr.maxConcurrent,
_createWorkerClient,
};
await ftpClient.uploadDirectory(localGameDir, remotePath, info => {
trackProgress(info);
if (job._cancelled) throw new Error('Cancelled');
}, porkHooks);
db.setGamePorked(job.gameId, job.folderName);
result = { success: true, remotePath };
}
job.progress.percent = 100;
job.status = 'done';
job.completedAt = Date.now();
job._resolve?.(result);
log.info(`[Transfer] Job #${job.id} (${job.type}) done`);
// Media downloads respect the per-media psNotify toggle; all others use the main setting.
const isMediaDl = job.type === 'download' && job.label?.startsWith('Media:');
if (!isMediaDl || store.get('media.psNotify.enabled', true)) {
ps5Notify(job.label, 'Transfer complete');
}
} catch (e) {
if (job._cancelled) {
job.status = 'cancelled';
} else {
job.status = 'error';
job.error = e.message;
log.error(`[Transfer] Job #${job.id} (${job.type}) error:`, e.message);
}
job._reject?.(e);
} finally {
extraClient?.close();
job._resolve = null;
job._reject = null;
this._notify();
this._tick(); // start next queued job
}
},
};
// ── FTP Transfer IPC (via queue) ──────────────────────────────────────────────
ipcMain.handle('ftp:download', async (_e, { remotePath, game_id }) => {
const _dlBps = store.get('backupPaths', null) || (store.get('backupPath', '') ? [store.get('backupPath', '')] : []);
const bp = _dlBps[0] || '';
if (!bp) throw new Error('No game source folder configured. Add one in Settings → Game Source Folders.');
if (!ftp.isConnected() && !ftp.hasCredentials()) throw new Error('Not connected to FTP. Connect to your PS5 first.');
const filename = path.basename(remotePath);
const localPath = path.join(bp, game_id, filename);
fs.mkdirSync(path.dirname(localPath), { recursive: true });
transferMgr.enqueue('download', {
label: `Download ${game_id}/${filename}`,
gameId: game_id,
localPath,
remotePath,
}).catch(e => log.warn(`[Transfer] Download ${game_id} failed: ${e.message}`));
return { success: true };
});
ipcMain.handle('ftp:upload', async (_e, { localPath, remotePath }) => {
if (!ftp.isConnected() && !ftp.hasCredentials()) throw new Error('Not connected to FTP. Connect to your PS5 first.');
const label = path.basename(localPath);
transferMgr.enqueue('upload', {
label: `Upload ${label}`,
localPath,
remotePath,
}).catch(e => log.warn(`[Transfer] Upload ${label} failed: ${e.message}`));
return { success: true };
});
ipcMain.handle('ftp:download-folder', async (_e, { game_id }) => {
const _dlFolderBps = store.get('backupPaths', null) || (store.get('backupPath', '') ? [store.get('backupPath', '')] : []);
const bp = _dlFolderBps[0] || '';
if (!bp) throw new Error('No game source folder configured. Add one in Settings → Game Source Folders.');
if (!ftp.isConnected() && !ftp.hasCredentials()) throw new Error('Not connected to FTP. Connect to your PS5 first.');
const game = db.getGame(game_id);
if (!game?.ftp_path) throw new Error('No PS5 path stored for this game. Run a scan first.');
// Preserve the remote folder name (e.g. PPSA12345-app0) so the backup scanner picks it up.
const folderName = game.ftp_path.split('/').filter(Boolean).pop();
const localPath = path.join(bp, folderName);
transferMgr.enqueue('download-folder', {
label: `Backup ${game_id} \u2190 PS5`,
gameId: game_id,
localPath,
remotePath: game.ftp_path,
}).catch(e => log.warn(`[Transfer] Download-folder ${game_id} failed: ${e.message}`));
return { success: true };
});
// ── App privilege helpers ────────────────────────────────────────────────────
// Check whether the current process has an elevated (Administrator) token.
// Uses base64-encoded PS command to avoid cmd.exe quoting issues and runs
// asynchronously so it never blocks the IPC thread.
ipcMain.handle('app:is-admin', () => {
if (process.platform !== 'win32') return true;
return new Promise((resolve) => {
// Encode the PS expression in UTF-16LE so cmd.exe never sees the brackets
const encodedCmd = Buffer.from(
'([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)',
'utf16le'
).toString('base64');
require('child_process').exec(
`powershell -NoProfile -NonInteractive -EncodedCommand ${encodedCmd}`,
{ timeout: 6000 },
(err, stdout) => resolve(!err && stdout.trim() === 'True')
);
});
});
// ── Database ──────────────────────────────────────────────────────────────────
ipcMain.handle('db:games:list', (_e, filter) => db.listGames(filter));
ipcMain.handle('library:titles:list', (_e, filter) => db.listLibraryTitles(filter));
const desktopEtahenLinkInventory = require('./src/services/etahen-link-inventory-service').createEtahenLinkInventoryService({ db });
ipcMain.handle('etahen-link:inventory:status', () => desktopEtahenLinkInventory.status());
ipcMain.handle('storage:inventory:list', (_e, filter) => require('./src/services/storage-inventory-service').createStorageInventoryService({ db }).list(filter || {}));
ipcMain.handle('storage:inventory:label', (_e, input) => require('./src/services/storage-inventory-service').createStorageInventoryService({ db }).setFriendlyLabel(input || {}));
ipcMain.handle('storage:locator:list', (_e, input) => require('./src/services/storage-inventory-service').createStorageInventoryService({ db }).locateTitles(input || {}));
const guidedMoveService = require('./src/services/guided-game-move-service').createGuidedGameMoveService({ db });
ipcMain.handle('guided-move:list', (_e, input) => guidedMoveService.list(input || {}));
ipcMain.handle('guided-move:create', (_e, input) => guidedMoveService.create(input || {}));
ipcMain.handle('guided-move:cancel', (_e, input) => guidedMoveService.cancel(input?.id));
ipcMain.handle('guided-move:reconcile', (_e, input) => guidedMoveService.reconcile(input?.id));
const gameMoveAdapter = require('./src/services/game-move-adapter-service').createGameMoveAdapterService();
ipcMain.handle('game-move:capabilities', () => gameMoveAdapter.probe());
ipcMain.handle('install:destinations:list', (_e, input) => require('./src/services/install-destination-service').createInstallDestinationService({ inventory: require('./src/services/storage-inventory-service').createStorageInventoryService({ db }) }).listDestinations(input || {}));
let desktopPs4Packages = null;
let desktopPs4PackagesKey = '';
function desktopPs4PackageContext() {
const outputRoot = path.resolve(store.get('ps4.packageOutputPath', path.join(app.getPath('documents'), 'Chimera', 'ps4-packages')));
const builderRoot = String(store.get('ps4.packageBuilderPath', '') || '');
const sourceRoots = (store.get('backupPaths', null) || (store.get('backupPath', '') ? [store.get('backupPath', '')] : [])).filter(root => root && fs.existsSync(root)).map(root => path.resolve(root));
const key = JSON.stringify({ outputRoot, builderRoot, sourceRoots });
if (!desktopPs4Packages || desktopPs4PackagesKey !== key) {
fs.mkdirSync(outputRoot, { recursive: true });
const provider = createPs4PkgBuilderProvider({ toolRoot: builderRoot, allowedWorkRoots: [outputRoot], allowedSourceRoots: sourceRoots });
desktopPs4Packages = createPs4PackageBackupService({ allowedRoots: () => [...sourceRoots, outputRoot], outputRoot, stateFile: path.join(app.getPath('userData'), 'ps4-package-jobs.json'), provider, registrar: manifest => registerPs4PackageManifest({ db, outputRoot, manifest }) });
desktopPs4PackagesKey = key;
}
return { outputRoot, service: desktopPs4Packages };
}
ipcMain.handle('ps4-packages:builder-status', () => desktopPs4PackageContext().service.builderStatus());
ipcMain.handle('ps4-packages:inspect', (_e, input) => desktopPs4PackageContext().service.inspect(input));
ipcMain.handle('ps4-packages:create', (_e, input) => desktopPs4PackageContext().service.create(input));
ipcMain.handle('ps4-packages:list', () => desktopPs4PackageContext().service.list());
ipcMain.handle('ps4-packages:get', (_e, input) => desktopPs4PackageContext().service.get(input.id));
ipcMain.handle('ps4-packages:run', (_e, input) => desktopPs4PackageContext().service.run(input.id));
ipcMain.handle('ps4-packages:resume', (_e, input) => desktopPs4PackageContext().service.resume(input.id));
ipcMain.handle('package-recipes:list', (_e, input) => ({ packageSets: createV3Repositories(db).packageSets.listByTitle(input.titleId), recipes: require('./src/services/install-recipe-repository').createInstallRecipeRepository({ db }).listByTitle(input.titleId) }));
ipcMain.handle('package-catalog:list', () => ({ generatedAt: Date.now(), sets: createV3Repositories(db).packageSets.listCatalog(), semantics: { inventoryOnly: true, installedInferred: false } }));
ipcMain.handle('ps4-packages:restore-plan', async (_e, input) => {
const packageSet = createV3Repositories(db).packageSets.listByTitle(input.titleId).find(item => item.id === Number(input.packageSetId));
if (!packageSet) throw new Error('PS4 packageset niet gevonden.');
require('./src/services/ps4-pkg-installer-adapter').validateOrder(packageSet.components);
return { packageSet, orderedComponents: packageSet.components, installer: { reachable: false, reason: 'server-origin-required' }, confirmation: 'INSTALL PS4 PACKAGES', installed: false, postScanRequired: true };
});
ipcMain.handle('ps4-packages:restore', (_e, input) => { requireExactConfirmation(input, 'INSTALL PS4 PACKAGES'); throw Object.assign(new Error('Start PKG-herstel via de Chimera-server met een geconfigureerde HTTPS-origin; de desktopapp publiceert geen anonieme packagebestanden.'), { code: 'PS4_RESTORE_SERVER_REQUIRED' }); });
ipcMain.handle('ps4-packages:restore-verify', async (_e, input) => {
await runDesktopFtpScan();
const row = db.runQuery(`SELECT ci.install_state,ci.installed_version,ci.observed_at FROM console_installs ci JOIN titles t ON t.id=ci.title_id WHERE t.platform='ps4' AND t.title_id='${String(input.titleId || '').toUpperCase().replace(/'/g, "''")}' ORDER BY ci.observed_at DESC LIMIT 1`)[0];
return row?.install_state === 'observed' ? { verified: true, installed: true, launchVerified: false, productionRestorable: false, nextAction: 'launch-qualification-server-required', source: 'post-install-console-scan', evidence: row } : { verified: false, installed: false, launchVerified: false, productionRestorable: false, source: 'post-install-console-scan', reason: 'console-install-not-observed' };
});
let desktopPs5NativePackages = null;
let desktopPs5NativePackagesKey = '';
function desktopPs5NativePackageContext() {
const outputRoot = path.resolve(store.get('ps5.nativePackageOutputPath', path.join(app.getPath('documents'), 'Chimera', 'ps5-native-packages')));
const consoleStagingRoot = path.resolve(store.get('ps5.consoleStagingPath', path.join(app.getPath('documents'), 'Chimera', 'ps5-console-staging')));
const builderRoot = String(store.get('ps5.nativePackageBuilderPath', '') || '');
const sourceRoots = (store.get('backupPaths', null) || (store.get('backupPath', '') ? [store.get('backupPath', '')] : [])).filter(root => root && fs.existsSync(root)).map(root => path.resolve(root));
const allowedSourceRoots = [...new Set([...sourceRoots, consoleStagingRoot])];
const key = JSON.stringify({ outputRoot, builderRoot, allowedSourceRoots });
if (!desktopPs5NativePackages || desktopPs5NativePackagesKey !== key) {
fs.mkdirSync(outputRoot, { recursive: true });
fs.mkdirSync(consoleStagingRoot, { recursive: true });
const provider = createPs5NativePkgBuilderProvider({ toolRoot: builderRoot, allowedWorkRoots: [outputRoot], allowedSourceRoots });
desktopPs5NativePackages = createPs5NativePackageService({ allowedRoots: () => [...allowedSourceRoots, outputRoot], outputRoot, stateFile: path.join(app.getPath('userData'), 'ps5-native-package-jobs.json'), provider, registrar: manifest => registerPs5NativePackageManifest({ db, outputRoot, manifest }) });
desktopPs5NativePackagesKey = key;
}
return { outputRoot, service: desktopPs5NativePackages };
}
ipcMain.handle('ps5-native-packages:builder-status', () => desktopPs5NativePackageContext().service.builderStatus());
ipcMain.handle('ps5-native-packages:inspect-backup', (_e, input) => desktopPs5NativePackageContext().service.inspectBackup(input));
ipcMain.handle('ps5-native-packages:inspect-package', (_e, input) => desktopPs5NativePackageContext().service.inspectPackage(input.filePath));
ipcMain.handle('ps5-native-packages:control-status', () => ({ status: 'server-required', installAuthorized: false }));
ipcMain.handle('ps5-native-packages:control-import', () => { throw Object.assign(new Error('Importeer de upstream control-PKG via de Chimera-server.'), { code: 'PS5_CONTROL_SERVER_REQUIRED' }); });
ipcMain.handle('ps5-native-packages:control-restore', (_e, input) => { requireExactConfirmation(input, 'INSTALL PS5 UPSTREAM CONTROL PACKAGE'); throw Object.assign(new Error('Installeer de upstream control-PKG via de Chimera-server.'), { code: 'PS5_CONTROL_SERVER_REQUIRED' }); });
ipcMain.handle('ps5-native-packages:control-restore-verify', () => { throw Object.assign(new Error('Voer de control-post-scan uit via de Chimera-server.'), { code: 'PS5_CONTROL_SERVER_REQUIRED' }); });
ipcMain.handle('ps5-native-packages:a8-distinct-control-status', () => ({ status: 'server-required', installed: false, launchVerified: false, productionRestorable: false }));
ipcMain.handle('ps5-native-packages:a8-distinct-control-prepare', () => { throw Object.assign(new Error('Bereid het bevroren A8-controlartifact voor via de Chimera-server.'), { code: 'PS5_A8_CONTROL_SERVER_REQUIRED' }); });
ipcMain.handle('ps5-native-packages:a8-distinct-control-install-plan', () => { throw Object.assign(new Error('Voer de verse A8-controlpreflight uit via de Chimera-server.'), { code: 'PS5_A8_CONTROL_SERVER_REQUIRED' }); });
ipcMain.handle('ps5-native-packages:a8-distinct-control-install', (_e, input) => { requireExactConfirmation(input, 'INSTALL PS5 A9 CONTROL CHIM00002'); throw Object.assign(new Error('Installeer de canonieke control via de Chimera-server.'), { code: 'PS5_A8_CONTROL_SERVER_REQUIRED' }); });
ipcMain.handle('ps5-native-packages:a10-transition-control-status', () => ({ status: 'server-required', installed: false, launchVerified: false, productionRestorable: false }));
ipcMain.handle('ps5-native-packages:a10-transition-control-prepare', () => { throw Object.assign(new Error('Bereid het bevroren A10-controlartifact voor via de Chimera-server.'), { code: 'PS5_A10_CONTROL_SERVER_REQUIRED' }); });
ipcMain.handle('ps5-native-packages:a10-transition-control-install-plan', () => { throw Object.assign(new Error('Voer de verse A10-controlpreflight uit via de Chimera-server.'), { code: 'PS5_A10_CONTROL_SERVER_REQUIRED' }); });
ipcMain.handle('ps5-native-packages:a10-transition-control-install', (_e, input) => { requireExactConfirmation(input, 'INSTALL PS5 A10 CONTROL CHIM00003'); throw Object.assign(new Error('Installeer de A10 transition control via de Chimera-server.'), { code: 'PS5_A10_CONTROL_SERVER_REQUIRED' }); });
ipcMain.handle('ps5-native-packages:launch-reference-status', () => ({ status: 'server-required', installAuthorized: false, launchVerified: false }));
ipcMain.handle('ps5-native-packages:launch-reference-import', () => { throw Object.assign(new Error('Importeer de websrv-launchreferentie via de Chimera-server.'), { code: 'PS5_LAUNCH_REFERENCE_SERVER_REQUIRED' }); });
ipcMain.handle('ps5-native-packages:launch-reference-restore', (_e, input) => { requireExactConfirmation(input, 'INSTALL PS5 LAUNCH REFERENCE'); throw Object.assign(new Error('Installeer de websrv-launchreferentie via de Chimera-server.'), { code: 'PS5_LAUNCH_REFERENCE_SERVER_REQUIRED' }); });
ipcMain.handle('ps5-native-packages:launch-reference-restore-verify', () => { throw Object.assign(new Error('Voer de launchreferentie-post-scan uit via de Chimera-server.'), { code: 'PS5_LAUNCH_REFERENCE_SERVER_REQUIRED' }); });
ipcMain.handle('ps5-native-packages:compare', (_e, input) => {
const sets = createV3Repositories(db).packageSets.listByTitle(input.titleId);
return comparePs5PackageSets({ candidate: sets.find(item => item.id === Number(input.candidatePackageSetId)), reference: sets.find(item => item.id === Number(input.referencePackageSetId)), operations: [], inspectPackage: filePath => desktopPs5NativePackageContext().service.inspectPackage(filePath) });
});
ipcMain.handle('ps5-native-packages:create', (_e, input) => desktopPs5NativePackageContext().service.create(input));
ipcMain.handle('ps5-native-packages:list', () => desktopPs5NativePackageContext().service.list());
ipcMain.handle('ps5-native-packages:get', (_e, input) => desktopPs5NativePackageContext().service.get(input.id));
ipcMain.handle('ps5-native-packages:run', (_e, input) => desktopPs5NativePackageContext().service.start(input.id));
ipcMain.handle('ps5-native-packages:resume', (_e, input) => desktopPs5NativePackageContext().service.resume(input.id));
let desktopPs5ConsoleStaging = null;
let desktopPs5ConsoleStagingKey = '';
function desktopPs5ConsoleStagingContext() {
const stagingRoot = path.resolve(store.get('ps5.consoleStagingPath', path.join(app.getPath('documents'), 'Chimera', 'ps5-console-staging')));
if (!desktopPs5ConsoleStaging || desktopPs5ConsoleStagingKey !== stagingRoot) {
fs.mkdirSync(stagingRoot, { recursive: true });
desktopPs5ConsoleStaging = createPs5ConsoleStagingService({
stagingRoot,
stateFile: path.join(app.getPath('userData'), 'ps5-console-staging-jobs.json'),
provider: createPs5ConsoleFtpProvider(ftp),
});
desktopPs5ConsoleStagingKey = stagingRoot;
}
return { stagingRoot, service: desktopPs5ConsoleStaging };
}
ipcMain.handle('ps5-console-staging:probe', () => desktopPs5ConsoleStagingContext().service.probe());
ipcMain.handle('ps5-console-staging:discover', (_e, input) => desktopPs5ConsoleStagingContext().service.discover(input));
ipcMain.handle('ps5-console-staging:create', (_e, input) => desktopPs5ConsoleStagingContext().service.create(input));
ipcMain.handle('ps5-console-staging:list', () => desktopPs5ConsoleStagingContext().service.list());
ipcMain.handle('ps5-console-staging:get', (_e, input) => desktopPs5ConsoleStagingContext().service.get(input.id));
ipcMain.handle('ps5-console-staging:run', (_e, input) => desktopPs5ConsoleStagingContext().service.run(input.id));
ipcMain.handle('ps5-console-staging:resume', (_e, input) => desktopPs5ConsoleStagingContext().service.resume(input.id));
let desktopPs5Profiles = null;
let desktopPs5ProfilesKey = '';
function desktopPs5ProfileContext() {
const engineOrigin = String(store.get('ps5.profileEngineOrigin', 'http://127.0.0.1:19113'));
const consoleHost = String(store.get('ftp.host', '') || '');
const sharedRoot = path.join(app.getPath('userData'), 'ps5-profile-shared');
const key = JSON.stringify({ engineOrigin, consoleHost, sharedRoot });
if (!desktopPs5Profiles || desktopPs5ProfilesKey !== key) {
const provider = Object.freeze({
probe: async () => {
try { return await createPs5UploadProfileProvider({ engineOrigin, consoleHost, sharedRoot }).probe(); }
catch (error) { return { available: false, reason: error.code || 'configuration-invalid', capabilities: {} }; }
},
listUsers: input => createPs5UploadProfileProvider({ engineOrigin, consoleHost, sharedRoot }).listUsers(input),
currentAvatar: input => createPs5UploadProfileProvider({ engineOrigin, consoleHost, sharedRoot }).currentAvatar(input),
renameUser: input => createPs5UploadProfileProvider({ engineOrigin, consoleHost, sharedRoot }).renameUser(input),
applyAvatar: input => createPs5UploadProfileProvider({ engineOrigin, consoleHost, sharedRoot }).applyAvatar(input),
});
desktopPs5Profiles = createPs5ProfileManagementService({ provider, stateFile: path.join(app.getPath('userData'), 'ps5-profile-operations.json'), recoveryRoot: path.join(app.getPath('userData'), 'ps5-profile-recovery') });
desktopPs5ProfilesKey = key;
}
return desktopPs5Profiles;
}
ipcMain.handle('ps5-profile:inspect', () => desktopPs5ProfileContext().inspect());
ipcMain.handle('ps5-profile:runtime-status', () => ({ product: 'ps5upload', version: '5.2.1', engine: { available: false, verified: false, reason: 'server-required' }, payload: { verified: false, reason: 'server-required' }, console: { configured: Boolean(store.get('ftp.host', '')) }, ports: { engine: 19113, transfer: 9113, management: 9114, loader: 9021 } }));
ipcMain.handle('ps5-profile:runtime-preflight', () => ({ ready: false, engine: { available: false, verified: false, reason: 'server-required' }, payload: { verified: false, reason: 'server-required' }, console: { configured: Boolean(store.get('ftp.host', '')) }, loader: { port: 9021, reachable: false, reason: 'server-required' } }));
ipcMain.handle('ps5-profile:start-payload', (_e, input) => { requireExactConfirmation(input, 'START PS5 PROFILE PAYLOAD'); throw Object.assign(new Error('Start de vastgepinde PS5Upload-payload via de Chimera-server.'), { code: 'PS5UPLOAD_SERVER_REQUIRED' }); });
ipcMain.handle('ps5-p15-a10:runtime-status', () => ({ status: 'server-required', liveActionAuthorized: false, payloadStartAuthorized: false, productionRestorable: false }));
ipcMain.handle('ps5-p15-a10:runtime-plan', () => { throw Object.assign(new Error('Maak het A10-runtimeplan via de Chimera-server.'), { code: 'P15_A10_SERVER_REQUIRED' }); });
ipcMain.handle('ps5-p15-a10:runtime-stage', (_e, input) => { requireExactConfirmation(input, 'STAGE CHIMERA P15 A10 TRANSITION RUNTIME'); throw Object.assign(new Error('Stage de A10-runtime via de Chimera-server.'), { code: 'P15_A10_SERVER_REQUIRED' }); });
ipcMain.handle('ps5-p15-a10:payload-start', (_e, input) => { requireExactConfirmation(input, 'START PS5 A10 PROFILE PAYLOAD'); throw Object.assign(new Error('Start de A10-payload via de Chimera-server.'), { code: 'P15_A10_SERVER_REQUIRED' }); });
ipcMain.handle('ps5-p15-a10:runtime-restore', (_e, input) => { requireExactConfirmation(input, 'RESTORE PS5 PRODUCTION PROFILE RUNTIME'); throw Object.assign(new Error('Herstel de productieruntime via de Chimera-server.'), { code: 'P15_A10_SERVER_REQUIRED' }); });
ipcMain.handle('ps5-p15-r2:status', () => ({ schema: 'chimera.ps5-p15-r2-qualification-state.v1', profile: null, readiness: null, budget: { state: 'blocked', used: 0, total: 1 }, lastQualification: null, lastResult: null, serverRequired: true }));
ipcMain.handle('ps5-p15-r2:preflight', () => { throw Object.assign(new Error('Run de P15-R2-preflight via de Chimera-server.'), { code: 'P15_R2_SERVER_REQUIRED' }); });
ipcMain.handle('ps5-p15-r2:qualify', () => { throw Object.assign(new Error('Run de P15-R2-kwalificatie via de Chimera-server.'), { code: 'P15_R2_SERVER_REQUIRED' }); });
ipcMain.handle('ps5-profile:current-avatar', (_e, input) => desktopPs5ProfileContext().currentAvatar(input));
ipcMain.handle('ps5-profile:list-operations', () => desktopPs5ProfileContext().listOperations());
ipcMain.handle('ps5-profile:prepare-rename', (_e, input) => desktopPs5ProfileContext().prepareRename(input));
ipcMain.handle('ps5-profile:prepare-avatar', (_e, input) => desktopPs5ProfileContext().prepareAvatar(input));
ipcMain.handle('ps5-profile:apply', (_e, input) => desktopPs5ProfileContext().apply(input));
ipcMain.handle('ps5-profile:rollback', (_e, input) => desktopPs5ProfileContext().rollback(input));
ipcMain.handle('ps5-native-packages:restore-plan', async (_e, input) => {
const packageSet = createV3Repositories(db).packageSets.listByTitle(input.titleId).find(item => item.id === Number(input.packageSetId));
if (!packageSet || packageSet.platform !== 'ps5' || packageSet.manifest?.packageFormat?.container !== 'FIH') throw new Error('Geverifieerde PS5 FIH-packageset niet gevonden.');
return { packageSet, orderedComponents: packageSet.components, installer: { reachable: false, reason: 'server-origin-required' }, confirmation: 'INSTALL PS5 DEBUG PACKAGE', installed: false, postScanRequired: true, experimental: true };
});
ipcMain.handle('ps5-native-packages:restore', (_e, input) => { requireExactConfirmation(input, 'INSTALL PS5 DEBUG PACKAGE'); throw Object.assign(new Error('Start PS5 PKG-herstel via de Chimera-server met een geconfigureerde HTTPS-origin.'), { code: 'PS5_RESTORE_SERVER_REQUIRED' }); });
ipcMain.handle('ps5-native-packages:restore-verify', async (_e, input) => {
await runDesktopFtpScan();
const row = db.runQuery(`SELECT ci.install_state,ci.installed_version,ci.observed_at FROM console_installs ci JOIN titles t ON t.id=ci.title_id WHERE t.platform='ps5' AND t.title_id='${String(input.titleId || '').toUpperCase().replace(/'/g, "''")}' ORDER BY ci.observed_at DESC LIMIT 1`)[0];
return row?.install_state === 'observed' ? { verified: true, installed: true, source: 'post-install-console-scan', evidence: row } : { verified: false, installed: false, source: 'post-install-console-scan', reason: 'console-install-not-observed' };
});
ipcMain.handle('ps5-native-packages:restore-launch-plan', () => { throw Object.assign(new Error('Launch qualification vereist de Chimera-server en de vastgepinde PS5Upload-runtime.'), { code: 'PS5_RESTORE_SERVER_REQUIRED' }); });
ipcMain.handle('ps5-native-packages:restore-launch', () => { throw Object.assign(new Error('Start launch qualification via de Chimera-server.'), { code: 'PS5_RESTORE_SERVER_REQUIRED' }); });
ipcMain.handle('ps5-native-packages:restore-launch-status', () => { throw Object.assign(new Error('Observeer launch qualification via de Chimera-server.'), { code: 'PS5_RESTORE_SERVER_REQUIRED' }); });
ipcMain.handle('ps5-native-packages:retained-launch-evidence', () => { throw Object.assign(new Error('Exporteer bewaard launchbewijs via de Chimera-server.'), { code: 'PS5_RESTORE_SERVER_REQUIRED' }); });
ipcMain.handle('library:evidence:override', (_e, input) => db.recordSemanticEvidence(input));
ipcMain.handle('library:evidence:list', (_e, input) => db.listSemanticEvidence(input));
ipcMain.handle('library:legacy-diagnostics', () => db.getLegacySemanticDiagnostics());
ipcMain.handle('db:games:update', (_e, { game_id, fields }) => db.updateGame(game_id, fields));
ipcMain.handle('db:backups:list', (_e, game_id) => db.listBackups(game_id));
ipcMain.handle('db:backups:delete', (_e, input = {}) => { requireExactConfirmation(input, 'VERWIJDER'); db.deleteBackup(input.id); return { success: true }; });
ipcMain.handle('db:backups:upsert', async (_e, { game_id, backup_path }) => {
if (!game_id || !backup_path) throw new Error('game_id and backup_path are required');
let stat;
try { stat = await fs.promises.stat(backup_path); } catch (_) { throw new Error(`Path not found: ${backup_path}`); }
const size = stat.isDirectory() ? await dirSize(backup_path) : stat.size;
const backup_type = stat.isDirectory() ? 'folder' : (path.extname(backup_path).slice(1) || 'pkg');
db.upsertGameMinimal(game_id);
db.upsertBackup({ game_id, backup_path, size, backup_type, source: 'local' });
return { success: true };
});
ipcMain.handle('db:stats', () => db.getStats());
ipcMain.handle('db:query', (_e, { sql }) => db.runQuery(sql));
ipcMain.handle('db:clear', (_e, input = {}) => { requireExactConfirmation(input, 'WIS DATABASE'); db.clearAll(); return { success: true }; });
ipcMain.handle('db:clear-selective', (_e, input = {}) => {
requireExactConfirmation(input, 'WIS SELECTIE');
const { confirmation: _confirmation, ...opts } = input;
db.clearSelective(opts);
return { success: true };
});
ipcMain.handle('db:delete-rows', (_e, input = {}) => { requireExactConfirmation(input, 'VERWIJDER'); return { deleted: db.deleteRows(input.table, input.ids) }; });
ipcMain.handle('db:clear-hashes', (_e, input = {}) => { requireExactConfirmation(input, 'WIS HASHES'); db.clearHashes(); return { success: true }; });
ipcMain.handle('db:export:sql', async () => {
const { filePath } = await dialog.showSaveDialog(win, {
title: 'Export DB as SQL',
defaultPath: `porkfolio_${Date.now()}.sql`,
filters: [{ name: 'SQL', extensions: ['sql'] }],
});
if (!filePath) return null;
fs.writeFileSync(filePath, db.exportSql(), 'utf8');
return filePath;
});
ipcMain.handle('db:export:csv', async (_e, { table }) => {
const { filePath } = await dialog.showSaveDialog(win, {
title: `Export ${table} as CSV`,
defaultPath: `porkfolio_${table}_${Date.now()}.csv`,
filters: [{ name: 'CSV', extensions: ['csv'] }],
});
if (!filePath) return null;
fs.writeFileSync(filePath, db.exportCsv(table), 'utf8');
return filePath;
});
// ── Byte formatter (used by transfer notifications) ─────────────────────────
function fmtBytes(n) {
if (n >= 1073741824) return `${(n / 1073741824).toFixed(2)} GB`;
if (n >= 1048576) return `${(n / 1048576).toFixed(1)} MB`;
if (n >= 1024) return `${(n / 1024).toFixed(0)} KB`;
return `${n} B`;
}
// ── Shared directory-size helper ──────────────────────────────────────────────
// Sequential async walk — non-blocking and avoids EMFILE from concurrent handles.
// Aborts after TIMEOUT_MS and returns whatever total was accumulated so far,
// so games with hundreds of thousands of files don't stall the scanner forever.
const DIR_SIZE_TIMEOUT_MS = 5000; // 5 s max per folder
async function dirSize(dir, _deadline) {
const deadline = _deadline ?? (Date.now() + DIR_SIZE_TIMEOUT_MS);
let total = 0;
try {
const entries = await fs.promises.readdir(dir, { withFileTypes: true });
for (const e of entries) {
if (Date.now() >= deadline) break; // time budget exhausted — return partial size
const p = path.join(dir, e.name);
if (e.isDirectory()) {
total += await dirSize(p, deadline);
} else {
try { total += (await fs.promises.stat(p)).size; } catch (_) {}
}
}
} catch (_) {}
return total;
}
// ── Local backup scanner ──────────────────────────────────────────────────────
// Returns { found: string[], newIds: string[] } so callers can report and queue Prospero.
// Async so file-system I/O doesn't block the main thread / UI.
const ARCHIVE_EXTS = new Set(['.rar', '.zip', '.7z', '.tar', '.gz', '.bz2', '.xz', '.zst', '.001', '.002', '.003']);
async function scanLocalBackups(backupPath) {
if (!fs.existsSync(backupPath)) return { found: [], newIds: [] };
db.beginBulkUpdate();
try {
db.purgeArchiveBackups();
const beforeIds = new Set(db.listGames().map(g => g.game_id));
const foundIds = new Set();
const backupsAtStart = db.listBackups();
const knownSizes = new Map(backupsAtStart.map(b => [b.backup_path, b.size]));
const yield_ = () => new Promise(r => setImmediate(r));
try {
const entries = await fs.promises.readdir(backupPath, { withFileTypes: true });
for (const entry of entries) {
const full = path.join(backupPath, entry.name);
try {
if (entry.isDirectory()) {
const m = entry.name.match(/([A-Z]{4}\d{5})/);
if (m) {
const game_id = m[1];
const size = knownSizes.has(full) ? knownSizes.get(full) : await dirSize(full);
db.upsertGameMinimal(game_id);
db.upsertBackup({ game_id, backup_path: full, size, backup_type: 'folder' });
foundIds.add(game_id);
}
} else if (entry.isFile()) {
const ext = path.extname(entry.name).toLowerCase();
if (ARCHIVE_EXTS.has(ext)) continue;
const m = entry.name.match(/([A-Z]{4}\d{5})/);
if (m) {
const game_id = m[1];
let size;
if (knownSizes.has(full)) {
size = knownSizes.get(full);
} else {
try { size = (await fs.promises.stat(full)).size; } catch (_) { continue; }
}
db.upsertGameMinimal(game_id);
const extType = ext.slice(1) || 'pkg';
db.upsertBackup({ game_id, backup_path: full, size, backup_type: extType });
foundIds.add(game_id);
}
}
} catch (entryErr) {
log.warn(`[LocalScan] Skipped entry "${entry.name}": ${entryErr.message}`);
}
await yield_(); // let event loop breathe between each game entry
}
} catch (e) {
log.error('[LocalScan]', e.message);
}
// Prune stale local backup entries whose path falls under this source folder
// but no longer exist on disk. Use existsSync (same as original) — fs.promises.access
// can throw for network errors / permission issues and would falsely delete valid backups.
const rootPrefix = backupPath.replace(/[\\/]+$/, '').toLowerCase() + path.sep;
let removedCount = 0;
for (const b of backupsAtStart) {
if (b.source === 'backpork') continue;
if (!b.backup_path.toLowerCase().startsWith(rootPrefix)) continue;
if (!fs.existsSync(b.backup_path)) {
db.deleteBackup(b.id);
log.info(`[LocalScan] Removed stale backup: ${b.backup_path}`);
removedCount++;
}
await yield_();
}
db.syncBackedUpFlags();
db.pruneOrphanScanGames();
const found = [...foundIds];
const newIds = found.filter(id => !beforeIds.has(id));
log.info(`[LocalScan] ${found.length} game(s) found, ${newIds.length} new${removedCount ? `, ${removedCount} stale removed` : ''}`);
return { found, newIds };
} finally {
db.endBulkUpdate();
}
}
ipcMain.handle('local:scan-backups', async () => {
const scanBps = store.get('backupPaths', null) || (store.get('backupPath', '') ? [store.get('backupPath', '')] : []);
if (!scanBps.length) throw new Error('No game source folders configured. Add one in Settings \u2192 Game Source Folders.');
let allFound = [], allNewIds = [];
for (const bp of scanBps) {
if (!bp) continue;
const { found, newIds } = await scanLocalBackups(bp);
allFound = [...allFound, ...found];
allNewIds = [...allNewIds, ...newIds];
}
const uniqueFound = [...new Set(allFound)];
const uniqueNewIds = [...new Set(allNewIds)];
queueProsperoFetch(uniqueFound);
return { success: true, total: uniqueFound.length, added: uniqueNewIds.length };
});
// ── Prospero force-fetch (renderer can request a specific game) ───────────────
ipcMain.handle('prospero:fetch', async (_e, { game_id }) => {
try {
const data = await prospero.fetchGameMetadata(game_id);
db.updateProsperoData(game_id, data);
win?.webContents.send('prospero:updated', { game_id, data });
return { success: true, data };
} catch (e) {
log.error(`[Prospero] Force-fetch failed for ${game_id}:`, e.message);
throw e;
}
});
// ── Lightweight icon-only fetch for cheats page (no DB write required) ───────
ipcMain.handle('prospero:fetch-icon', async (_e, { game_id }) => {
try {
// Return cached icon if already in DB
const existing = db.getGame(game_id);
if (existing?.prospero_icon_url) return { iconUrl: existing.prospero_icon_url };
// Fetch just the game page (skips patches/DLC/regions)
const page = await prospero.fetchPageMetadata(game_id);
// Persist to DB if game exists there
if (existing && page.iconUrl) db.updateProsperoData(game_id, {
name: page.name || '', description: '', region: page.region || '',
contentId: page.contentId || '', publisher: page.publisher || '',
publisherId: page.publisherId || '', iconUrl: page.iconUrl,
bannerUrl: page.bannerUrl || '', cdnHash: page.cdnHash || '',
lastUpdated: '', patchCount: 0, patches: [], additionalContent: [],
otherRegions: [], fetchedAt: Date.now(),
});
return { iconUrl: page.iconUrl || '' };
} catch (_) {
return { iconUrl: '' };
}
});
// ── Prospero refresh-all (force re-fetch every game) ─────────────────────────
ipcMain.handle('prospero:refresh-all', async () => {
const gameIds = db.resetAllProsperoFetchedAt();
log.info(`[Prospero] Refresh-all queued — ${gameIds.length} game(s)`);
queueProsperoFetch(gameIds);
return { queued: gameIds.length };
});
// ── Backpork folder scanner ───────────────────────────────────────────────────
async function scanBackporkFolder(folderPath, folderName) {
const found = [];
const yield_ = () => new Promise(r => setImmediate(r));
const rootPrefix = folderPath.replace(/[\\/]+$/, '').toLowerCase() + path.sep;
db.beginBulkUpdate();
try {
const backupsAtStart = db.listBackups().filter(
b => b.source === 'backpork' && b.backup_path.toLowerCase().startsWith(rootPrefix)
);
try {
const entries = await fs.promises.readdir(folderPath, { withFileTypes: true });
for (const entry of entries) {
if (!entry.isDirectory()) continue;
const m = entry.name.match(/([A-Z]{4}\d{5})/i);
if (!m) continue;
const game_id = m[1].toUpperCase();
const gameDir = path.join(folderPath, entry.name);
try {
db.upsertGameMinimal(game_id);
db.setGameFirmwareLabel(game_id, folderName);
db.upsertBackup({ game_id, backup_path: gameDir, size: await dirSize(gameDir), backup_type: 'folder', source: 'backpork' });
found.push(game_id);
} catch (entryErr) {
log.warn(`[Backporks] Skipped entry "${entry.name}": ${entryErr.message}`);
}
await yield_(); // yield between each game so UI stays responsive
}
} catch (e) {
log.error('[Backporks] Scan error:', e.message);
}
let removedCount = 0;
for (const b of backupsAtStart) {
if (!fs.existsSync(b.backup_path)) {
db.deleteBackup(b.id);
log.info(`[Backporks] Removed stale backup: ${b.backup_path}`);
removedCount++;
}
await yield_();
}
if (removedCount > 0) {
db.syncBackedUpFlags();
log.info(`[Backporks] Pruned ${removedCount} stale backup(s) from "${folderName}"`);
}
const foundSet = new Set(found);
db.pruneGameFirmwareForFolder(folderName, foundSet);
db.pruneOrphanScanGames();
log.info(`[Backporks] "${folderName}" — found ${found.length} game(s)`);
return found;
} finally {
db.endBulkUpdate();
}
}
function isFirmwareFolderName(name) {
return /^\d+(?:\.(?:\d+|xx)){1,2}$/i.test(String(name || '').trim());
}
ipcMain.handle('backporks:list', () => db.listBackporkFolders());
ipcMain.handle('backporks:add', async (_e, { name, path: folderPath }) => {
db.addBackporkFolder(name, folderPath);
const found = await scanBackporkFolder(folderPath, name);
queueProsperoFetch(found);
return { success: true, count: found.length };
});
ipcMain.handle('backporks:create-folder', (_e, { name, parentPath }) => {
if (!/^\d+\.\d+\.\d+$/.test(name)) throw new Error('Firmware version must follow x.x.x format (e.g. 11.50.0)');
const folderPath = path.join(parentPath, name);
fs.mkdirSync(folderPath, { recursive: true });
db.addBackporkFolder(name, folderPath);
return { success: true, path: folderPath };
});
ipcMain.handle('backporks:remove', (_e, input = {}) => {
requireExactConfirmation(input, 'VERWIJDER');
const { id } = input;
// Look up the folder path before removing so we can clean up its backup rows
const folders = db.listBackporkFolders();
const folder = folders.find(f => f.id === id);
db.removeBackporkFolder(id);
if (folder) {
// Delete all backpork backup entries whose path falls under this folder
const rootPrefix = folder.path.replace(/[\\/]+$/, '').toLowerCase() + path.sep;
const allBackups = db.listBackups();
for (const b of allBackups) {
if (b.source === 'backpork' && b.backup_path.toLowerCase().startsWith(rootPrefix)) {
db.deleteBackup(b.id);
}
}
db.syncBackedUpFlags();
db.pruneOrphanScanGames();
}
return { success: true };
});
ipcMain.handle('backporks:scan', async (_e, { id }) => {
const folders = db.listBackporkFolders();
const folder = folders.find(f => f.id === id);
if (!folder) throw new Error('Backpork folder not found');
const found = await scanBackporkFolder(folder.path, folder.name);
queueProsperoFetch(found);
return { success: true, count: found.length };
});
ipcMain.handle('backporks:add-root', async (_e, { rootPath }) => {
if (!fs.existsSync(rootPath)) throw new Error('Selected folder does not exist.');
const entries = await fs.promises.readdir(rootPath, { withFileTypes: true });
const subdirs = entries.filter(e => e.isDirectory() && isFirmwareFolderName(e.name));
if (!subdirs.length) throw new Error('No firmware-style subfolders found. Expected folders like 4.xx, 7.xx, or 11.50.0.');
let totalGames = 0;
const allFound = [];
for (const subdir of subdirs) {
const name = subdir.name;
const folderPath = path.join(rootPath, name);
db.addBackporkFolder(name, folderPath);
const found = await scanBackporkFolder(folderPath, name);
totalGames += found.length;
allFound.push(...found);
}
queueProsperoFetch(allFound);
log.info(`[Backporks] Root scan: ${subdirs.length} firmware folder(s), ${totalGames} game(s) total`);
return { success: true, folders: subdirs.length, count: totalGames };
});
ipcMain.handle('backporks:scan-all', async () => {
const folders = db.listBackporkFolders();
let total = 0;
const allFound = [];
for (const folder of folders) {
if (!fs.existsSync(folder.path)) continue;
const found = await scanBackporkFolder(folder.path, folder.name);
total += found.length;
allFound.push(...found);
}
queueProsperoFetch(allFound);
const uniqueGames = new Set(allFound).size;
log.info(`[Backporks] Scan-all: ${folders.length} firmware folder(s), ${total} build(s) across ${uniqueGames} unique game(s)`);
return { success: true, folders: folders.length, count: total, uniqueGames };
});
ipcMain.handle('db:firmware:labels', () => db.listFirmwareLabels());
ipcMain.handle('backporks:games', (_e, { folder }) => db.listGamesForFolder(folder));
ipcMain.handle('backporks:entries', (_e, { game_id })=> db.getGameBackporkEntries(game_id));
// Find the game's subfolder within a firmware folder path
function findGameSubfolder(folderPath, game_id) {
try {
const entries = fs.readdirSync(folderPath, { withFileTypes: true });
for (const entry of entries) {
if (!entry.isDirectory()) continue;
// Match the game ID anywhere in the folder name so long descriptive names
// like "PPSA04341 – EUR Avatar Backport 5XX By BADERLINK" are found.
const m = entry.name.match(/([A-Z]{4}\d{5})/i);
if (m && m[1].toUpperCase() === game_id.toUpperCase()) return entry.name;
}
} catch (_) {}
return null;
}
ipcMain.handle('backporks:pork', async (_e, { game_id, folder_name, folder_path }) => {
if (!ftp.isConnected() && !ftp.hasCredentials()) throw new Error('Not connected to FTP. Connect to your PS5 first.');
const p = transferMgr.enqueue('pork', {
label: `Pork ${game_id} (${folder_name})`,
gameId: game_id,
folderName: folder_name,
folderPath: folder_path,
});
// Job was just pushed — its id is available synchronously
const jobId = transferMgr.jobs[transferMgr.jobs.length - 1].id;
p.catch(e => log.warn(`[Transfer] Pork ${game_id} failed: ${e.message}`));
return { success: true, jobId };
});
// PFS install: upload the game folder then — only after the upload completes —
// enqueue the pork (patch) step. Both steps go through the transfer queue so
// they appear in the Transfers tab with progress.
ipcMain.handle('ftp:install-pfs', async (_e, { game_id, localPath, remotePath, destinationVolumeId, expertRawPath, pork }) => {
if (!ftp.isConnected() && !ftp.hasCredentials()) throw new Error('Not connected to FTP. Connect to your PS5 first.');
const localStat = await fs.promises.stat(localPath);
const requiredBytes = localStat.isDirectory() ? await dirSize(localPath) : localStat.size;
if (destinationVolumeId != null && destinationVolumeId !== '') {
remotePath = require('./src/services/install-destination-service')
.createInstallDestinationService({ inventory: require('./src/services/storage-inventory-service').createStorageInventoryService({ db }) })
.resolveForExecution({ volumeId: destinationVolumeId, requiredBytes, titleId: game_id }).suggestedRemotePath;
} else if (expertRawPath !== true) {
throw new Error('Select a semantic destination volume. Raw paths require Expert mode.');
}
const label = path.basename(localPath);
const uploadPromise = transferMgr.enqueue('upload', {
label: `Install ${label}`,
localPath,
remotePath,
});
if (pork) {
// Chain the pork job: it only enters the queue once the upload job resolves.
uploadPromise
.then(() => {
transferMgr.enqueue('pork', {
label: `Pork ${game_id} (${pork.folder_name})`,
gameId: game_id,
folderName: pork.folder_name,
folderPath: pork.folder_path,
}).catch(e => log.warn(`[Transfer] Pork ${game_id} failed: ${e.message}`));
})
.catch(e => log.warn(`[Transfer] PFS install ${game_id} upload failed — skipping pork: ${e.message}`));
} else {
uploadPromise.catch(e => log.warn(`[Transfer] PFS install ${label} failed: ${e.message}`));
}
return { success: true };
});
// ── Transfer Manager IPC ──────────────────────────────────────────────────────
ipcMain.handle('transfer:state', () => transferMgr.getState());
ipcMain.handle('transfer:pause', () => { transferMgr.pause(); return { success: true }; });
ipcMain.handle('transfer:resume', () => { transferMgr.resume(); return { success: true }; });
ipcMain.handle('transfer:cancel', (_e, { id }) => { transferMgr.cancel(id); return { success: true }; });
ipcMain.handle('transfer:clear-done', () => { transferMgr.clearDone(); return { success: true }; });
ipcMain.handle('transfer:set-concurrent', (_e, { n }) => { transferMgr.setConcurrent(n); return { success: true }; });
// ── Dialog / shell ────────────────────────────────────────────────────────────
ipcMain.handle('dialog:select-folder', async () => {
const { filePaths } = await dialog.showOpenDialog(win, {
title: 'Select Folder',
properties: ['openDirectory'],
});
return filePaths?.[0] || null;
});
// ── Helper: recursively collect all files in a directory ────────────────────
function collectFiles(dir) {
const out = [];
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
const p = path.join(dir, e.name);
if (e.isDirectory()) out.push(...collectFiles(p));
else out.push(p);
}
return out;
}
// ── Hash verification ─────────────────────────────────────────────────────────
ipcMain.handle('hash:status', (_e, { game_id }) => {
const localHashes = db.getGameHashes(game_id);
const communityMatches = localHashes
.filter(h => communityHashMap.has(h.file_hash))
.map(h => ({ ...h, communityEntry: communityHashMap.get(h.file_hash) }));
// Mismatch: community has verified hashes for this game but none of ours match
const communityForGame = communityGameIndex.get(game_id) || new Set();
const communityMismatch = communityForGame.size > 0 && communityMatches.length === 0 && localHashes.length > 0;
return { localHashes, communityMatches, communityMismatch };
});
ipcMain.handle('hash:compute', async (_e, { backup_path, game_id }) => {
if (!fs.existsSync(backup_path)) throw new Error('Path not found: ' + backup_path);
const stat = fs.statSync(backup_path);
if (stat.isDirectory()) {
const files = collectFiles(backup_path);
const fileSizes = files.map(fp => { try { return fs.statSync(fp).size; } catch { return 0; } });
const totalBytes = fileSizes.reduce((a, b) => a + b, 0);
const hashes = new Array(files.length);
// Track bytes across all concurrent workers for overall progress.
// Node.js is single-threaded so the delta-update is race-free.
const fileProgress = new Array(files.length).fill(0);
let globalBytes = 0;
let lastPctSent = -1;
function sendProgress(pct) {
if (pct !== lastPctSent) { lastPctSent = pct; win?.webContents.send('hash:progress', { percent: pct }); }
}
let nextIdx = 0;
async function hashWorker() {
while (nextIdx < files.length) {
const i = nextIdx++;
const fp = files[i];
const file_hash = await hashFile(fp, ({ bytesRead }) => {
globalBytes += bytesRead - fileProgress[i];
fileProgress[i] = bytesRead;
sendProgress(Math.min(Math.round(globalBytes / (totalBytes || 1) * 100), 99));
});
globalBytes += fileSizes[i] - fileProgress[i];
fileProgress[i] = fileSizes[i];
hashes[i] = { backup_path: fp, file_hash, file_size: fileSizes[i] };
}
}
const CONCURRENCY = Math.min(store.get('hash.concurrency', 8), files.length || 1);
await Promise.all(Array.from({ length: CONCURRENCY }, hashWorker));
sendProgress(100);
log.info(`[Hashes] Hashed ${hashes.length} file(s) for ${game_id}`);
return { hashes };
}
// Single file
const file_hash = await hashFile(backup_path, progress => {
win?.webContents.send('hash:progress', progress);
});
const file_size = stat.size;
log.info(`[Hashes] Computed hash for ${game_id}: ${file_hash.slice(0, 12)}…`);
return { hashes: [{ backup_path, file_hash, file_size }] };
});
ipcMain.handle('hash:add', (_e, { game_id, file_hash, file_size, backup_path, hash_type = 'game', verified }) => {
const firmware_label = hash_type === 'backpork' ? (db.getBackporkFirmwareLabel(backup_path) ?? null) : null;
db.addGameHash({ game_id, file_hash, file_size, backup_path, hash_type, firmware_label, verified });
const communityMatch = communityHashMap.get(file_hash) || null;
log.info(`[Hashes] Saved hash for ${game_id} (${hash_type})${firmware_label ? ' [' + firmware_label + ']' : ''}${communityMatch ? ' — community match!' : ''}`);
return { success: true, communityMatch };
});
ipcMain.handle('hash:export', async () => {
const { filePath } = await dialog.showSaveDialog(win, {
title: 'Export Hash Database',
defaultPath: `porkfolio_hashes_${Date.now()}.json`,
filters: [{ name: 'JSON', extensions: ['json'] }],
});
if (!filePath) return null;
const hashes = db.exportAllHashes();
const out = { version: 1, updated: new Date().toISOString().split('T')[0], hashes };
fs.writeFileSync(filePath, JSON.stringify(out, null, 2), 'utf8');
log.info(`[Hashes] Exported ${hashes.length} hash(es) to ${filePath}`);
return filePath;
});
// Batch-hash all backups of a given source type ('local' | 'backpork' | 'all').
ipcMain.handle('hash:bulk', async (_e, { source = 'all' } = {}) => {
const yield_ = () => new Promise(r => setImmediate(r)); // lets event loop breathe so IPC events deliver
const allBackups = db.listBackups();
const targets = source === 'all' ? allBackups : allBackups.filter(b => b.source === source);
const total = targets.length;
let done = 0;
let totalFilesHashed = 0;
let totalBytesHashed = 0;
const results = [];
const send = (payload) => win?.webContents.send('hash:bulk:progress', payload);
for (const backup of targets) {
const bp = backup.backup_path;
try {
if (!fs.existsSync(bp)) throw new Error('Path not found');
const stat = fs.statSync(bp);
const files = stat.isDirectory() ? collectFiles(bp) : [bp];
send({
phase: 'backup-start',
done, total, percent: Math.round(done / (total || 1) * 100),
gameId: backup.game_id,
fileCount: files.length, totalFilesHashed, totalBytesHashed,
});
await yield_();
// Hash up to CONCURRENCY files in parallel within this backup.
const HASH_CONCURRENCY = Math.min(store.get('hash.concurrency', 8), files.length || 1);
const fileSizes = files.map(fp => { try { return fs.statSync(fp).size; } catch { return 0; } });
const fileHashes = new Array(files.length);
let bulkNextIdx = 0;
async function bulkWorker() {
while (bulkNextIdx < files.length) {
const i = bulkNextIdx++;
const fp = files[i];
fileHashes[i] = await hashFile(fp);
}
}
await Promise.all(Array.from({ length: HASH_CONCURRENCY }, bulkWorker));
// Commit results to DB and accumulate counters
const hash_type = backup.source === 'backpork' ? 'backpork' : 'game';
for (let i = 0; i < files.length; i++) {
const fp = files[i];
const file_hash = fileHashes[i];
const file_size = fileSizes[i];
const firmware_label = hash_type === 'backpork' ? (db.getBackporkFirmwareLabel(fp) ?? null) : null;
db.addGameHash({ game_id: backup.game_id, file_hash, file_size, backup_path: fp, hash_type, firmware_label, verified: 1 });
totalFilesHashed++;
totalBytesHashed += file_size;
}
send({
phase: 'file-progress',
done, total, percent: Math.round(done / (total || 1) * 100),
gameId: backup.game_id, currentFile: null, filePct: 100,
totalFilesHashed, totalBytesHashed,
});
await yield_();
results.push({ game_id: backup.game_id, ok: true, count: files.length });
} catch (e) {
log.warn(`[Hash:bulk] ${backup.game_id} — ${e.message}`);
results.push({ game_id: backup.game_id, ok: false, error: e.message });
}
done++;
send({
phase: 'backup-done',
done, total, percent: Math.round(done / (total || 1) * 100),
gameId: backup.game_id, currentFile: null, filePct: 100,
totalFilesHashed, totalBytesHashed,
});
await yield_();
}
return { results, total, totalFilesHashed, totalBytesHashed };
});
// Import an external community hash list JSON; persists to userData and reloads into memory.
ipcMain.handle('hash:import-community', async () => {
const { filePaths } = await dialog.showOpenDialog(win, {
title: 'Import Community Hash List',
filters: [{ name: 'JSON', extensions: ['json'] }],
properties: ['openFile'],
});
if (!filePaths?.length) return null;
const raw = JSON.parse(fs.readFileSync(filePaths[0], 'utf8'));
if (!Array.isArray(raw.hashes)) throw new Error('Invalid format \u2014 expected { hashes: [...] }');
loadCommunityHashes(raw.hashes);
_communityHashMeta = { version: raw.version || 1, updated: raw.updated || null, source: 'imported' };
const userHashPath = path.join(app.getPath('userData'), 'community-hashes.json');
fs.writeFileSync(userHashPath, JSON.stringify(raw, null, 2), 'utf8');
log.info(`[Hashes] Imported ${communityHashMap.size} community hash(es)`);
return { count: communityHashMap.size, version: raw.version, updated: raw.updated };
});
// Return stats about the loaded community hash list and the user's local hashes.
ipcMain.handle('hash:community-info', () => ({
..._communityHashMeta,
count: communityHashMap.size,
localCount: db.getHashCount(),
}));
// Return per-game hash summary (hash count + community verification status) for all hashed games.
// Results are grouped by (game_id, hash_type, firmware_label) so game and backpork hashes appear separately.
ipcMain.handle('hash:game-summary', () => {
const allHashes = db.getAllGameHashes();
const byKey = new Map(); // composite key → { game_id, hash_type, firmware_label, hashes[] }
for (const h of allHashes) {
const ht = h.hash_type || 'game';
const fw = h.firmware_label || null;
const key = `${h.game_id}|${ht}|${fw || ''}`;
if (!byKey.has(key)) byKey.set(key, { game_id: h.game_id, hash_type: ht, firmware_label: fw, hashes: [] });
byKey.get(key).hashes.push(h.file_hash);
}
const out = [];
for (const [, entry] of byKey) {
const { game_id, hash_type, firmware_label, hashes } = entry;
const communityHits = hashes.filter(h => communityHashMap.has(h));
const communityMismatch = communityGameIndex.has(game_id) && communityHits.length === 0;
out.push({
game_id, hash_type, firmware_label,
hash_count: hashes.length,
community_matches: communityHits.length,
community_mismatch: communityMismatch,
});
}
return out;
});
ipcMain.handle('shell:open', (_e, { target }) => {
if (/^https?:\/\//i.test(target)) shell.openExternal(target);
else if (fs.existsSync(target)) shell.openPath(target);
});
// ── Payload Manager IPC ───────────────────────────────────────────────────────
ipcMain.handle('payload:sources:list', () => db.listPayloadSources());
ipcMain.handle('payload:sources:add', (_e, { name, url }) => {
// Normalize: prepend https:// if bare domain entered
const normalised = /^https?:\/\//i.test(url) ? url : `https://${url}`;
// For GitHub repo URLs, validate the owner/repo portion
if (/github\.com\//.test(normalised)) github.parseGitHubUrl(normalised);
else {
try { new URL(normalised); } catch { throw new Error(`"${url}" is not a valid URL`); }
}
db.addPayloadSource({ name, github_url: normalised });
return db.listPayloadSources();
});
ipcMain.handle('payload:sources:remove', (_e, input = {}) => {
requireExactConfirmation(input, 'VERWIJDER');
const { id } = input;
db.removePayloadSource(id);
return { success: true };
});
ipcMain.handle('payload:sources:toggle', (_e, { id }) => {
db.togglePayloadSource(id);
return { success: true };
});
ipcMain.handle('payload:check-updates', async (_e, { id } = {}) => {
const sources = id
? db.listPayloadSources().filter(s => s.id === id && s.enabled)
: db.listPayloadSources().filter(s => s.enabled);
const results = [];
const localDir = store.get('payload.localPath', '');
for (const source of sources) {
try {
win?.webContents.send('payload:check:progress', { source_id: source.id, status: 'checking' });
const release = await github.fetchLatestRelease(source.github_url);
const hashMap = await github.fetchChecksums(release.assets);
// Filter to valid payload extensions only; also exclude checksum files.
const assets = release.assets.filter(a =>
/\.(bin|elf|js)$/i.test(a.name) &&
!/sha256|checksums|sha2|sums/i.test(a.name)
);
// 1. Upsert remote asset records (with remote_hash from checksum file if available)
for (const asset of assets) {
const remoteHash = hashMap.get(asset.name) || null;
db.upsertPayloadFile({
source_id: source.id,
asset_name: asset.name,
asset_url: asset.url,
remote_hash: remoteHash,
version: release.tag,
});
if (remoteHash) {
log.info(`[Payload] Remote SHA256 for ${asset.name}: ${remoteHash}`);
} else {
log.info(`[Payload] Remote SHA256 for ${asset.name}: <no checksum file in release>`);
}
}
// 2. Hash any local copies that are present on disk but have no local_hash in DB yet.
// This covers: files downloaded via app where hashing was skipped, AND manually placed files.
if (localDir) {
for (const asset of assets) {
const localPath = path.join(localDir, asset.name);
if (!fs.existsSync(localPath)) continue;
const row = db.getPayloadFiles(source.id).find(f => f.asset_name === asset.name);
if (row?.local_hash) continue; // already hashed
try {
log.info(`[Payload] Hashing local copy of ${asset.name}…`);
const local_hash = await hashFile(localPath, () => {});
const local_size = fs.statSync(localPath).size;
db.updatePayloadFileLocal({
source_id: source.id, asset_name: asset.name,
local_path: localPath, local_hash, local_size,
version: row?.version || release.tag,
});
log.info(`[Payload] Local SHA256 for ${asset.name}: ${local_hash}`);
} catch (hashErr) {
log.warn(`[Payload] Could not hash local ${asset.name}: ${hashErr.message}`);
}
}
}
// 3. Log hash comparison summary for every asset
const updatedFiles = db.getPayloadFiles(source.id);
for (const asset of assets) {
const row = updatedFiles.find(f => f.asset_name === asset.name);
const r = row?.remote_hash || null;
const l = row?.local_hash || null;
if (r && l) {
const status = r === l ? 'UP TO DATE' : 'UPDATE AVAILABLE';
log.info(`[Payload] ${asset.name}: remote=${r.slice(0, 12)}… local=${l.slice(0, 12)}… → ${status}`);
} else if (r && !l) {
log.info(`[Payload] ${asset.name}: remote=${r.slice(0, 12)}… local=<not downloaded>`);
} else if (!r && l) {
log.info(`[Payload] ${asset.name}: remote=<no checksum> local=${l.slice(0, 12)}…`);
} else {
log.info(`[Payload] ${asset.name}: no hashes available yet`);
}
}
db.updatePayloadSourceChecked(source.id, release.tag);
const files = db.getPayloadFiles(source.id);
results.push({ source: { ...source, latest_tag: release.tag }, files });
win?.webContents.send('payload:check:progress', { source_id: source.id, status: 'done', tag: release.tag });
log.info(`[Payload] Checked ${source.name} — tag: ${release.tag}, assets: ${assets.length}`);
} catch (e) {
log.warn(`[Payload] Check failed for ${source.name}: ${e.message}`);
win?.webContents.send('payload:check:progress', { source_id: source.id, status: 'error', error: e.message });
results.push({ source, files: db.getPayloadFiles(source.id), error: e.message });
}
}
return results;
});
ipcMain.handle('payload:download', async (_e, { source_id, asset_name, asset_url, version }) => {
const localDir = store.get('payload.localPath', '');
if (!localDir) throw new Error('No payload local folder configured. Set one in Settings first.');
const localPath = path.join(localDir, asset_name);
log.info(`[Payload] Downloading ${asset_name} to ${localPath}`);
await github.downloadAsset(asset_url, localPath, info => {
win?.webContents.send('payload:download:progress', { asset_name, ...info });
});
const stat = fs.statSync(localPath);
const local_hash = await hashFile(localPath, prog => {
win?.webContents.send('payload:hash:progress', { asset_name, ...prog });
});
db.updatePayloadFileLocal({ source_id, asset_name, local_path: localPath, local_hash, local_size: stat.size, version });
log.info(`[Payload] Downloaded ${asset_name} — hash: ${local_hash.slice(0, 12)}…`);
return { localPath, local_hash };
});
ipcMain.handle('payload:list-local', async () => {
const localDir = store.get('payload.localPath', '');
if (!localDir || !fs.existsSync(localDir)) return [];
// Pull all DB payload file rows so we can join on name
const allSources = db.listPayloadSources();
const sourceMap = new Map(allSources.map(s => [s.id, s]));
const allFiles = allSources.flatMap(s => db.getPayloadFiles(s.id));
const dbByName = new Map(allFiles.map(f => [f.asset_name, f]));
const out = [];
try {
for (const entry of fs.readdirSync(localDir, { withFileTypes: true })) {
if (!entry.isFile()) continue;
// Only list valid payload extensions
if (!/\.(bin|elf|js)$/i.test(entry.name)) continue;
let stat;
try { stat = fs.statSync(path.join(localDir, entry.name)); } catch { continue; }
const dbRow = dbByName.get(entry.name) || null;
out.push({
name: entry.name,
local_path: path.join(localDir, entry.name),
size: stat.size,
local_hash: dbRow?.local_hash || null,
remote_hash: dbRow?.remote_hash || null,
version: dbRow?.version || null,
source_id: dbRow?.source_id || null,
source_name: dbRow?.source_id != null ? (sourceMap.get(dbRow.source_id)?.name || null) : null,
db_id: dbRow?.id || null,
downloaded_at: dbRow?.downloaded_at || null,
});
}
} catch (e) {
log.warn('[Payload] list-local error:', e.message);
}
// Auto-hash any local file that does not yet have a local_hash in the DB.
// This ensures manually-placed files are always hashable for update comparison.
for (const f of out) {
if (f.local_hash) continue;
try {
log.info(`[Payload] Auto-hashing local file: ${f.name}`);
const local_hash = await hashFile(f.local_path, () => {});
f.local_hash = local_hash;
// Persist to DB — if row exists update it, otherwise we can only log (no source association)
const dbRow = dbByName.get(f.name);
if (dbRow) {
db.updatePayloadFileLocal({
source_id: dbRow.source_id,
asset_name: f.name,
local_path: f.local_path,
local_hash,
local_size: f.size,
version: dbRow.version || null,
});
}
log.info(`[Payload] Auto-hashed ${f.name}: ${local_hash}`);
} catch (hashErr) {
log.warn(`[Payload] Auto-hash failed for ${f.name}: ${hashErr.message}`);
}
}
return out.sort((a, b) => a.name.localeCompare(b.name));
});
ipcMain.handle('payload:list-remote', async () => {
if (!ftp.isConnected() && !ftp.hasCredentials()) throw new Error('Not connected to FTP. Connect to your PS5 first.');
const remotePath = store.get('payload.remotePath', '/data/payloads/');
const list = await ftp.listRemote(remotePath);
return list;
});
ipcMain.handle('payload:push', (_e, { local_path, filename }) => {
if (!ftp.isConnected() && !ftp.hasCredentials()) throw new Error('Not connected to FTP. Connect to your PS5 first.');
const remotePath = store.get('payload.remotePath', '/data/payloads/');
return transferMgr.enqueue('upload', {
label: `Payload → ${filename}`,
localPath: local_path,
remotePath: remotePath.replace(/\/$/, '') + '/' + filename,
});
});
ipcMain.handle('payload:delete-remote', async (_e, input = {}) => {
requireExactConfirmation(input, 'VERWIJDER VAN PS5');
const { filename } = input;
if (!ftp.isConnected() && !ftp.hasCredentials()) throw new Error('Not connected to FTP. Connect to your PS5 first.');
const remotePath = store.get('payload.remotePath', '/data/payloads/');
await ftp.deleteRemote(remotePath.replace(/\/$/, '') + '/' + filename);
return { success: true };
});
ipcMain.handle('payload:delete-local', (_e, input = {}) => {
requireExactConfirmation(input, 'VERWIJDER');
const { id, local_path } = input;
if (local_path && fs.existsSync(local_path)) fs.unlinkSync(local_path);
if (id != null) {
// Get the asset_name and source_id from the DB to call updatePayloadFileLocal
// We do a raw delete since we don't need the row data
db.deletePayloadFile(id);
}
return { success: true };
});
// ── System View ───────────────────────────────────────────────────────────────
function _openSystemViewPopout(videoId, audioId, resolution) {
if (systemViewPopup && !systemViewPopup.isDestroyed()) {
systemViewPopup.focus();
return { success: true, focused: true };
}
systemViewPopup = new BrowserWindow({
width: 1280,
height: 720,
minWidth: 640,
minHeight: 360,
backgroundColor: '#000',
title: 'System View — Porkfolio',
webPreferences: {
contextIsolation: true,
nodeIntegration: false,
},
});
systemViewPopup.loadFile(
path.join(__dirname, 'renderer', 'system-view.html'),
{ query: { videoId: videoId || '', audioId: audioId || '', resolution: resolution || '' } }
);
systemViewPopup.on('closed', () => {
systemViewPopup = null;
win?.webContents.send('system-view:popout-closed');
});
return { success: true };
}
ipcMain.handle('system-view:open-popout', (_e, { videoId, audioId, resolution } = {}) =>
_openSystemViewPopout(videoId, audioId, resolution)
);
ipcMain.handle('system-view:pick-save', async (_e, { defaultPath, filters = [] } = {}) => {
const { canceled, filePath } = await dialog.showSaveDialog(win, { defaultPath, filters });
return { canceled: !!canceled, filePath: filePath || '' };
});
ipcMain.handle('system-view:write-file', async (_e, { filePath, buffer }) => {
if (!filePath) throw new Error('filePath is required');
await fs.promises.writeFile(filePath, Buffer.from(new Uint8Array(buffer)));
return { ok: true };
});
ipcMain.handle('system-view:append-file', async (_e, { filePath, buffer }) => {
if (!filePath) throw new Error('filePath is required');
await fs.promises.appendFile(filePath, Buffer.from(new Uint8Array(buffer)));
return { ok: true };
});
ipcMain.handle('system-view:delete-file', async (_e, { filePath }) => {
if (!filePath) return { ok: true };
await fs.promises.unlink(filePath).catch(() => {});
return { ok: true };
});
// ── XAvatar PS5 management ────────────────────────────────────────────────────
const AVATARS_PATH = '/data/AVATARS';
/** Write buffer to temp, enqueue FTP upload to /data/AVATARS */
ipcMain.handle('xavatar:upload-to-ps5', async (_e, { buffer, filename }) => {
if (!ftp.isConnected() && !ftp.hasCredentials()) throw new Error('Not connected to FTP. Connect to your PS5 first.');
const tmpPath = path.join(os.tmpdir(), `pork-xav-${Date.now()}-${filename}`);
await fs.promises.writeFile(tmpPath, Buffer.from(new Uint8Array(buffer)));
// Enqueue and return *immediately* (fire-and-forget from the renderer's POV).
// Do NOT await the enqueue promise — that would hold the IPC open for the full
// FTP transfer duration, leaving the renderer blocked and unable to reset the UI.
const job = transferMgr.enqueue('upload', {
label: `XAvatar → PS5: ${filename}`,
localPath: tmpPath,
remotePath: `${AVATARS_PATH}/${filename}`,
});
// Let the job run in the background; discard the completion promise.
job.catch(err => log.warn('[xavatar:upload-to-ps5] Transfer error:', err.message));
return { ok: true, queued: true };
});
/** Download a remote file and return its contents as a Buffer */
ipcMain.handle('ftp:download-file', async (_e, { remotePath }) => {
if (!ftp.isConnected() && !ftp.hasCredentials()) throw new Error('Not connected to FTP. Connect to your PS5 first.');
const filename = path.basename(remotePath);
const tmpPath = path.join(os.tmpdir(), `pork-ftpdl-${Date.now()}-${filename}`);
await ftp.downloadFile(remotePath, tmpPath);
const buf = await fs.promises.readFile(tmpPath);
await fs.promises.unlink(tmpPath).catch(() => {});
return buf;
});
/** Delete a single remote file */
ipcMain.handle('ftp:delete-file', async (_e, input = {}) => {
requireExactConfirmation(input, 'VERWIJDER VAN PS5');
const { remotePath } = input;
if (!ftp.isConnected() && !ftp.hasCredentials()) throw new Error('Not connected to FTP. Connect to your PS5 first.');
await ftp.deleteRemote(remotePath);
return { ok: true };
});
/** Extract avatar.png from a .xavatar ZIP buffer */
ipcMain.handle('xavatar:extract-png', async (_e, { buffer }) => {
const JSZip = require('jszip');
const zip = await JSZip.loadAsync(Buffer.from(new Uint8Array(buffer)));
const entry = zip.file('avatar.png');
if (!entry) throw new Error('avatar.png not found in .xavatar archive');
return entry.async('nodebuffer');
});
/** Show a save-file dialog and write buffer to the chosen path */
ipcMain.handle('xavatar:save-file', async (_e, { buffer, filename }) => {
const ext = path.extname(filename).replace('.', '') || 'bin';
const { canceled, filePath } = await dialog.showSaveDialog(win, {
defaultPath: filename,
filters: [{ name: ext.toUpperCase() + ' Files', extensions: [ext] }],
});
if (canceled || !filePath) return { ok: true, saved: false };
await fs.promises.writeFile(filePath, Buffer.from(new Uint8Array(buffer)));
return { ok: true, saved: true };
});
// ── PS5 Autoloader ─────────────────────────────────────────────────────────────
const AUTOLOADER_DIR = '/data/ps5_autoloader';
const AUTOLOADER_FILE = `${AUTOLOADER_DIR}/autoload.txt`;
ipcMain.handle('autoloader:list', async () => {
if (!ftp.isConnected() && !ftp.hasCredentials()) throw new Error('Not connected to FTP. Connect to your PS5 first.');
return ftp.listDir(AUTOLOADER_DIR);
});
ipcMain.handle('autoloader:read', async () => {
if (!ftp.isConnected() && !ftp.hasCredentials()) throw new Error('Not connected to FTP. Connect to your PS5 first.');
const tmpPath = path.join(os.tmpdir(), `pork-autoload-${Date.now()}.txt`);
try {
await ftp.downloadFile(AUTOLOADER_FILE, tmpPath);
const content = await fs.promises.readFile(tmpPath, 'utf8');
await fs.promises.unlink(tmpPath).catch(() => {});
return content;
} catch (_) {
return null; // autoload.txt may not exist yet
}
});
ipcMain.handle('autoloader:save', async (_e, { content }) => {
if (!ftp.isConnected() && !ftp.hasCredentials()) throw new Error('Not connected to FTP. Connect to your PS5 first.');
const tmpPath = path.join(os.tmpdir(), `pork-autoload-save-${Date.now()}.txt`);
await fs.promises.writeFile(tmpPath, content, 'utf8');
try {
await ftp.uploadFile(tmpPath, AUTOLOADER_FILE);
} finally {
await fs.promises.unlink(tmpPath).catch(() => {});
}
return { ok: true };
});
// FTP-download a file from /data/ps5_autoloader and save it to the local payloads folder.
ipcMain.handle('autoloader:save-local', async (_e, { filename }) => {
if (!ftp.isConnected() && !ftp.hasCredentials()) throw new Error('Not connected to FTP. Connect to your PS5 first.');
const localDir = store.get('payload.localPath', '');
if (!localDir) throw new Error('No local payload folder configured. Set one in Settings → Payload Manager.');
const remotePath = `${AUTOLOADER_DIR}/${filename}`;
const localPath = path.join(localDir, filename);
await ftp.downloadFile(remotePath, localPath);
return { ok: true };
});
// ── Autoloader Snapshots ──────────────────────────────────────────────────────
const SNAPSHOT_BASE = path.join(app.getPath('userData'), 'autoloader-snapshots');
// Take a snapshot: download all payload files + autoload.txt to a local folder.
ipcMain.handle('snapshot:autoloader:take', async (_e, { label }) => {
if (!ftp.isConnected() && !ftp.hasCredentials()) throw new Error('Not connected to FTP. Connect to your PS5 first.');
const ts = Date.now();
const safeLbl = ((label || '').replace(/[^\w\s\-_.]/g, '').trim().slice(0, 40)) || 'Snapshot';
const snapDir = path.join(SNAPSHOT_BASE, String(ts));
await fs.promises.mkdir(snapDir, { recursive: true });
let hasAutoload = false;
// 1. Download autoload.txt
const tmpTxt = path.join(os.tmpdir(), `pork-snap-al-${ts}.txt`);
try {
await ftp.downloadFile(AUTOLOADER_FILE, tmpTxt);
// Validate: must be non-empty and must not be all null bytes (corrupt download guard)
const txtStat = await fs.promises.stat(tmpTxt).catch(() => null);
if (!txtStat || txtStat.size === 0) throw new Error('autoload.txt download returned empty file');
const txt = await fs.promises.readFile(tmpTxt, 'utf8');
if (!txt || txt.replace(/\0/g, '').trim() === '') throw new Error('autoload.txt download returned only null bytes');
await fs.promises.writeFile(path.join(snapDir, 'autoload.txt'), txt, 'utf8');
hasAutoload = true;
} catch (e) {
log.warn(`[Snapshot:take] autoload.txt skipped: ${e.message}`);
}
await fs.promises.unlink(tmpTxt).catch(() => {});
// 2. Download each payload file sequentially (basic-ftp: no concurrent ops)
const files = await ftp.listDir(AUTOLOADER_DIR);
const payloads = files.filter(f => !f.isDir && /\.(elf|bin|js)$/i.test(f.name));
const downloaded = [];
for (const f of payloads) {
const localFilePath = path.join(snapDir, f.name);
try {
await ftp.downloadFile(`${AUTOLOADER_DIR}/${f.name}`, localFilePath);
// Validate: non-empty and first byte must not be null (guards against silent zero-fill)
const stat = await fs.promises.stat(localFilePath).catch(() => null);
if (!stat || stat.size === 0) throw new Error('download returned empty file');
// Read first 4 bytes — valid ELF/BIN/JS files never start with a null byte
const header = Buffer.alloc(4);
const fd = await fs.promises.open(localFilePath, 'r');
try { await fd.read(header, 0, 4, 0); } finally { await fd.close(); }
if (header[0] === 0x00 && header[1] === 0x00 && header[2] === 0x00 && header[3] === 0x00) {
throw new Error('download produced a zero-filled file (corrupt transfer)');
}
downloaded.push(f.name);
} catch (e) {
log.warn(`[Snapshot:take] ${f.name} skipped: ${e.message}`);
await fs.promises.unlink(localFilePath).catch(() => {}); // remove the corrupt partial file
}
}
// 3. Write metadata
const meta = { ts, label: safeLbl, files: downloaded, hasAutoload, createdAt: new Date().toISOString() };
await fs.promises.writeFile(path.join(snapDir, '_meta.json'), JSON.stringify(meta, null, 2), 'utf8');
return { ok: true, meta };
});
// List all saved snapshots (newest first).
ipcMain.handle('snapshot:autoloader:list', async () => {
await fs.promises.mkdir(SNAPSHOT_BASE, { recursive: true });
const entries = await fs.promises.readdir(SNAPSHOT_BASE).catch(() => []);
const metas = [];
for (const entry of entries) {
try {
const raw = await fs.promises.readFile(path.join(SNAPSHOT_BASE, entry, '_meta.json'), 'utf8');
metas.push({ id: entry, ...JSON.parse(raw) });
} catch (_) {}
}
return metas.sort((a, b) => b.ts - a.ts);
});
// Restore a snapshot: upload all its files back to /data/ps5_autoloader.
ipcMain.handle('snapshot:autoloader:restore', async (_e, input = {}) => {
requireExactConfirmation(input, 'RESTORE AUTOLOADER');
const { id } = input;
if (!ftp.isConnected()) throw new Error('Not connected to PS5. Connect via FTP first.');
if (!/^\d+$/.test(id)) throw new Error('Invalid snapshot id.');
const snapDir = path.join(SNAPSHOT_BASE, id);
const meta = JSON.parse(await fs.promises.readFile(path.join(snapDir, '_meta.json'), 'utf8'));
let uploaded = 0;
const errors = [];
// Helper: validate a local snapshot file is not empty or zero-filled before we
// push it to the PS5 — prevents corrupt snapshots from overwriting good PS5 files.
async function _validateLocalFile(filePath, isBinary) {
const stat = await fs.promises.stat(filePath).catch(() => null);
if (!stat || stat.size === 0) throw new Error('snapshot file is missing or empty');
if (isBinary) {
const header = Buffer.alloc(4);
const fd = await fs.promises.open(filePath, 'r');
try { await fd.read(header, 0, 4, 0); } finally { await fd.close(); }
if (header[0] === 0x00 && header[1] === 0x00 && header[2] === 0x00 && header[3] === 0x00) {
throw new Error('snapshot file starts with null bytes (corrupt — re-take snapshot)');
}
} else {
const txt = await fs.promises.readFile(filePath, 'utf8');
if (!txt || txt.replace(/\0/g, '').trim() === '') throw new Error('snapshot file contains only null bytes (corrupt — re-take snapshot)');
}
}
// Upload payload files sequentially
for (const filename of (meta.files || [])) {
const localFilePath = path.join(snapDir, filename);
try {
const isBinary = /\.(elf|bin)$/i.test(filename);
await _validateLocalFile(localFilePath, isBinary);
await ftp.uploadFile(localFilePath, `${AUTOLOADER_DIR}/${filename}`);
uploaded++;
} catch (e) {
errors.push(`${filename}: ${e.message}`);
log.warn(`[Snapshot:restore] Failed to upload ${filename}: ${e.message}`);
}
}
// Upload autoload.txt
if (meta.hasAutoload) {
const localTxt = path.join(snapDir, 'autoload.txt');
try {
await _validateLocalFile(localTxt, false);
await ftp.uploadFile(localTxt, AUTOLOADER_FILE);
uploaded++;
} catch (e) {
errors.push(`autoload.txt: ${e.message}`);
log.warn(`[Snapshot:restore] Failed to upload autoload.txt: ${e.message}`);
}
}
const totalExpected = (meta.files || []).length + (meta.hasAutoload ? 1 : 0);
if (uploaded === 0 && totalExpected > 0) throw new Error(`Upload failed — could not upload any files. Check FTP connection.`);
return { ok: true, fileCount: uploaded, errors };
});
// Delete a snapshot folder permanently.
ipcMain.handle('snapshot:autoloader:delete', async (_e, input = {}) => {
requireExactConfirmation(input, 'VERWIJDER');
const { id } = input;
if (!/^\d+$/.test(id)) throw new Error('Invalid snapshot id.');
const snapDir = path.join(SNAPSHOT_BASE, id);
if (!snapDir.startsWith(SNAPSHOT_BASE + path.sep)) throw new Error('Invalid snapshot path.');
await fs.promises.rm(snapDir, { recursive: true, force: true });
return { ok: true };
});
// Export a snapshot: bundle meta + all files into a single .porksnap file.
ipcMain.handle('snapshot:autoloader:export', async (_e, { id }) => {
if (!/^\d+$/.test(id)) throw new Error('Invalid snapshot id.');
const snapDir = path.join(SNAPSHOT_BASE, id);
const meta = JSON.parse(await fs.promises.readFile(path.join(snapDir, '_meta.json'), 'utf8'));
const safeLabel = (meta.label || 'snapshot').replace(/[^\w\s\-_.]/g, '').trim().slice(0, 40);
const dateStr = new Date(meta.createdAt || Date.now()).toISOString().slice(0, 10);
const { filePath, canceled } = await dialog.showSaveDialog(win, {
title: 'Export Snapshot',
defaultPath: `${safeLabel}-${dateStr}.porkchop`,
filters: [{ name: 'Porkfolio Snapshot', extensions: ['porkchop'] }, { name: 'All Files', extensions: ['*'] }],
});
if (canceled || !filePath) return { ok: false, canceled: true };
const bundle = { version: 1, meta, files: {} };
if (meta.hasAutoload) {
try {
bundle.files['autoload.txt'] = {
encoding: 'utf8',
data: await fs.promises.readFile(path.join(snapDir, 'autoload.txt'), 'utf8'),
};
} catch (_) {}
}
for (const filename of (meta.files || [])) {
try {
const buf = await fs.promises.readFile(path.join(snapDir, filename));
bundle.files[filename] = { encoding: 'base64', data: buf.toString('base64') };
} catch (_) {}
}
await fs.promises.writeFile(filePath, JSON.stringify(bundle), 'utf8');
return { ok: true, filePath };
});
// Import a snapshot from a .porkchop file or a ZIP containing a ps5_autoloader folder.
ipcMain.handle('snapshot:autoloader:import', async (_e, { label: providedLabel, filePath: providedFilePath } = {}) => {
let chosenPath;
if (providedFilePath) {
// Second call for ZIP label — use the already-chosen file, skip dialog
chosenPath = providedFilePath;
} else {
const { filePaths, canceled } = await dialog.showOpenDialog(win, {
title: 'Import Snapshot',
filters: [
{ name: 'Snapshots & ZIPs', extensions: ['porkchop', 'zip'] },
{ name: 'Porkfolio Snapshot', extensions: ['porkchop'] },
{ name: 'ZIP Archive', extensions: ['zip'] },
{ name: 'All Files', extensions: ['*'] },
],
properties: ['openFile'],
});
if (canceled || !filePaths.length) return { ok: false, canceled: true };
chosenPath = filePaths[0];
}
const ext = path.extname(chosenPath).toLowerCase();
// ── ZIP import ──────────────────────────────────────────────────────────────
if (ext === '.zip') {
// Caller must supply a label. If not yet provided, signal the renderer to ask.
if (!providedLabel) return { ok: false, needsLabel: true, filePath: chosenPath };
const JSZip = require('jszip');
let zip;
try {
zip = await JSZip.loadAsync(await fs.promises.readFile(chosenPath));
} catch (_) {
throw new Error('Could not read ZIP file — it may be corrupt.');
}
const ts = Date.now();
const snapDir = path.join(SNAPSHOT_BASE, String(ts));
await fs.promises.mkdir(snapDir, { recursive: true });
const AUTOLOADER_PREFIX = 'ps5_autoloader/';
const downloaded = [];
let hasAutoload = false;
for (const [zipPath, zipEntry] of Object.entries(zip.files)) {
if (zipEntry.dir) continue;
// Only process files inside the ps5_autoloader folder
const lower = zipPath.replace(/\\/g, '/');
if (!lower.startsWith(AUTOLOADER_PREFIX)) continue;
const filename = lower.slice(AUTOLOADER_PREFIX.length);
// Reject path traversal and nested folders
if (!filename || filename.includes('/') || filename.includes('..')) continue;
if (!/^[\w\-. ]+$/.test(filename)) continue;
const dest = path.join(snapDir, filename);
if (!dest.startsWith(snapDir + path.sep)) continue; // traversal guard
const buf = await zipEntry.async('nodebuffer');
await fs.promises.writeFile(dest, buf);
if (filename.toLowerCase() === 'autoload.txt') {
hasAutoload = true;
} else if (/\.(elf|bin|js)$/i.test(filename)) {
downloaded.push(filename);
}
}
const safeLabel = (providedLabel || 'Imported ZIP').replace(/[^\w\s\-_.]/g, '').trim().slice(0, 40);
const importedMeta = {
ts,
label: safeLabel,
files: downloaded,
hasAutoload,
createdAt: new Date().toISOString(),
importedAt: new Date().toISOString(),
importedFrom: path.basename(chosenPath),
};
await fs.promises.writeFile(path.join(snapDir, '_meta.json'), JSON.stringify(importedMeta, null, 2), 'utf8');
return { ok: true, meta: importedMeta };
}
// ── .porkchop import ────────────────────────────────────────────────────────
let bundle;
try {
bundle = JSON.parse(await fs.promises.readFile(chosenPath, 'utf8'));
} catch (_) {
throw new Error('Could not read snapshot file — it may be corrupt or not a valid .porkchop file.');
}
if (!bundle || bundle.version !== 1 || !bundle.meta)
throw new Error('Invalid snapshot file format (missing version or meta).');
const ts = Date.now();
const snapDir = path.join(SNAPSHOT_BASE, String(ts));
await fs.promises.mkdir(snapDir, { recursive: true });
for (const [filename, fileData] of Object.entries(bundle.files || {})) {
if (!/^[\w\-. ]+$/.test(filename) || filename.includes('..')) continue;
const dest = path.join(snapDir, filename);
if (!dest.startsWith(snapDir + path.sep)) continue; // traversal guard
if (fileData.encoding === 'base64') {
await fs.promises.writeFile(dest, Buffer.from(fileData.data, 'base64'));
} else {
await fs.promises.writeFile(dest, fileData.data, 'utf8');
}
}
const importedMeta = {
...bundle.meta,
ts,
label: (bundle.meta.label || 'Imported Snapshot').slice(0, 40),
importedAt: new Date().toISOString(),
};
await fs.promises.writeFile(path.join(snapDir, '_meta.json'), JSON.stringify(importedMeta, null, 2), 'utf8');
return { ok: true, meta: importedMeta };
});
// Send a local payload file to the PS5 over a raw TCP connection (e.g. bin-loader on port 9021).
//
// Protocol: identical to `socat -t 99999999 - TCP:{ip}:{port}` (stdin = file bytes).
// 1. Open TCP connection (10s timeout to establish).
// 2. Write all bytes then send FIN (sock.end) — PS5 has everything it needs at this point.
// 3. Resolve the IPC call immediately — UI clears "Sending…" right away.
// 4. Fire a PS5 notify so the user gets on-screen confirmation the payload was received.
// 5. Keep socket alive in the background (mirroring socat's enormous -t value) so the
// PS5 can close its end whenever it wants without us interrupting the connection.
ipcMain.handle('payload:tcp-send', async (_e, { localPath, port, filename }) => {
const host = store.get('ftp.host', '');
if (!host) throw new Error('No PS5 IP configured. Set it in FTP Settings.');
const tcpPort = port || 9021;
const label = filename || path.basename(localPath);
// Payloads are typically small ELFs (KB–low MB) — buffering is fine.
const buf = await fs.promises.readFile(localPath);
return new Promise((resolve, reject) => {
let resolved = false;
const sock = new net.Socket();
// Phase 1: 10s connection-establishment timeout (cleared on connect).
// We intentionally do NOT use sock.setTimeout() — that fires on *idle* and would
// incorrectly kill the socket while the PS5 is silently loading the ELF.
const connectTimer = setTimeout(() => {
sock.destroy();
reject(new Error('TCP connection timed out — PS5 not reachable on port ' + tcpPort));
}, 10000);
sock.on('error', err => {
clearTimeout(connectTimer);
if (!resolved) reject(err);
// If already resolved, the background socket errored after we already told the UI
// everything was fine — log but don't surface to the renderer.
else log.warn(`[Payload TCP] background socket error for ${label}:`, err.message);
});
sock.connect(tcpPort, host, () => {
clearTimeout(connectTimer);
// Phase 2: write all bytes then send FIN.
sock.write(buf, writeErr => {
if (writeErr) { reject(writeErr); return; }
sock.end(); // half-close: EOF → PS5 exploit host starts loading the ELF
// Phase 3: IPC resolves NOW — bytes are on the wire, FIN is sent.
// The renderer can immediately show "Sent ✓" without waiting for PS5 to respond.
resolved = true;
resolve({ ok: true, bytes: buf.length });
// Fire PS5 notify in background — gives on-screen confirmation on the PS5.
ps5Notify(`Payload Sent — ${label}`, `${(buf.length / 1024).toFixed(1)} KB on port ${tcpPort}`).catch(() => {});
// Phase 4: keep socket open in background (socat -t 99999999 behaviour).
// PS5 closes when it's done; we just let it happen without blocking anything.
sock.once('close', () => log.info(`[Payload TCP] ${label} — PS5 closed connection`));
});
});
});
});
// ── Cheats ────────────────────────────────────────────────────────────────────
let cheatAbort = false;
ipcMain.handle('cheats:stats', () => db.getCheatStats());
ipcMain.handle('cheats:for-game', (_e, { cusa_id }) => db.getCheatFiles(cusa_id));
ipcMain.handle('cheats:search', (_e, { query }) => db.searchCheats(query));
ipcMain.handle('cheats:all', () => db.listAllCheatsGrouped());
ipcMain.handle('cheats:cancel', () => { cheatAbort = true; return { ok: true }; });
ipcMain.handle('cheats:fetch-index', () => cheats.fetchIndex());
ipcMain.handle('cheats:unmatched', () => db.getUnmatchedCheats());
ipcMain.handle('cheats:assign', (_e, { filename, cusaId }) => db.assignCheatCusaId(filename, cusaId));
ipcMain.handle('cheats:download-all', async () => {
cheatAbort = false;
const entries = await cheats.fetchIndex();
const cachedSet = new Set(db.listCachedFilenames());
const todo = entries.filter(e => !cachedSet.has(e.filename));
const total = entries.length;
let done = cachedSet.size;
let saved = 0;
let failed = 0;
const startTime = Date.now();
win?.webContents.send('cheats:dl:start', { total, alreadyCached: cachedSet.size });
for (const entry of todo) {
if (cheatAbort) break;
try {
const data = await cheats.fetchCheatFile(entry.filename);
db.upsertCheatFile(entry.filename, entry.cusaId, entry.version, entry.title, JSON.stringify(data));
saved++;
} catch (e) {
failed++;
log.warn('[Cheats] Failed ' + entry.filename + ': ' + e.message);
}
done++;
const elapsedSec = (Date.now() - startTime) / 1000;
const rate = saved / Math.max(elapsedSec, 0.1);
const etaSec = rate > 0 ? Math.round((todo.length - saved - failed) / rate) : null;
win?.webContents.send('cheats:dl:progress', {
done, total, saved, failed, etaSec,
current: entry.title,
pct: Math.round((done / total) * 100),
});
await new Promise(r => setTimeout(r, 100));
}
const cancelled = cheatAbort;
cheatAbort = false;
win?.webContents.send('cheats:dl:done', { saved, failed, total, cancelled });
return { saved, failed, total, cancelled };
});
// ── Cheats FTP Install ─────────────────────────────────────────────────────────
ipcMain.handle('cheats:ftp-install', async (_e, { remotePath } = {}) => {
if (!ftp.isConnected() && !ftp.hasCredentials()) throw new Error('Not connected to FTP. Connect to your PS5 first.');
const files = db.getAllCheatFilesWithData();
const total = files.length;
if (total === 0) throw new Error('No cheats cached. Download the cheat database first.');
const tmpDir = path.join(os.tmpdir(), 'porkfolio-cheats-install');
const remoteDst = (remotePath || store.get('cheats.remotePath', '/data/etaHEN/cheats')).replace(/\/+$/, '');
// Write cheat JSONs to temp directory
win?.webContents.send('cheats:ftp:start', { total, phase: 'write' });
fs.mkdirSync(tmpDir, { recursive: true });
for (let i = 0; i < files.length; i++) {
fs.writeFileSync(path.join(tmpDir, files[i].filename), files[i].data, 'utf8');
if (i % 100 === 0 || i === files.length - 1) {
win?.webContents.send('cheats:ftp:progress', {
phase: 'write', done: i + 1, total,
pct: Math.round(((i + 1) / total) * 50), // first half of progress
current: files[i].filename,
});
}
}
// Upload temp directory to PS5
win?.webContents.send('cheats:ftp:progress', { phase: 'upload', done: 0, total, pct: 50, current: '' });
try {
await ftp.uploadDirectory(tmpDir, remoteDst, info => {
const uploadPct = info.filesTotal > 0 ? Math.round((info.filesDone / info.filesTotal) * 50) : 0;
win?.webContents.send('cheats:ftp:progress', {
phase: 'upload',
done: info.filesDone,
total: info.filesTotal,
pct: 50 + uploadPct,
current: info.name || '',
});
});
win?.webContents.send('cheats:ftp:done', { success: true, count: total });
return { success: true, count: total };
} finally {
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch (_) {}
}
});