This commit is contained in:
2026-07-17 15:03:20 -05:00
parent 2e0ec5f70c
commit 44c2ce91a1
22 changed files with 887 additions and 841 deletions
+25 -5
View File
@@ -20,6 +20,8 @@ Browse, back up, and delete game saves across all your emulators.
- RetroArch saves with recursive core-directory scanning - RetroArch saves with recursive core-directory scanning
- Custom save directories per emulator - Custom save directories per emulator
- Backup saves before deleting - Backup saves before deleting
- **Restore** — restore any previous backup with one click (new)
- **Open Backup Folder** — jump straight to your backups on disk (new)
### New Emulators ### New Emulators
Five new emulators added to the library: Five new emulators added to the library:
@@ -65,9 +67,12 @@ Visual theme picker with live color swatches:
Organized emulator list with filter tabs: Organized emulator list with filter tabs:
- **All** — every emulator, frontend, and decomp - **All** — every emulator, frontend, and decomp
- **Emulators** — core emulators only (Dolphin, PCSX2, RetroArch, etc.) - **Emulators** — core emulators only (Dolphin, PCSX2, RetroArch, etc.)
- **Frontends** — ES-DE, NeoStation, Pegasus, EmuBuddy - **Frontends** — ES-DE, NeoStation, Pegasus, EmuBuddy (shown only when Frontend Support is enabled)
- **Decomps** — native PC ports (visible when Decomp Projects is on) - **Decomps** — native PC ports (visible when Decomp Projects is on)
### Beta Badges
Beta and experimental emulators are now clearly labeled with a "Beta" badge in their card header. The following emulators are marked beta: ES-DE, NeoStation, EmuBuddy, Pegasus, xemu, Vita3K, Azahar, Project64, Mednafen.
### SteamGridDB Cover Art ### SteamGridDB Cover Art
Search and set custom cover art from SteamGridDB's free API. Search and set custom cover art from SteamGridDB's free API.
- Search covers from the game detail modal - Search covers from the game detail modal
@@ -106,6 +111,20 @@ Redesigned top-bar navigation with full gamepad/controller support:
- **BIOS folder scan** — `scanRoms()` now skips BIOS directories - **BIOS folder scan** — `scanRoms()` now skips BIOS directories
- **CSS accent colors** — replaced hardcoded colors with `color-mix()` using theme variables - **CSS accent colors** — replaced hardcoded colors with `color-mix()` using theme variables
- **Duplicate CSS** — renamed `.platform-tag` conflict to `.platform-tag-accent` - **Duplicate CSS** — renamed `.platform-tag` conflict to `.platform-tag-accent`
- **Frontend Support toggle** — now actually gates ES-DE, NeoStation, Pegasus, and EmuBuddy on/off in the Emulators page
- **Beta Emulators toggle** — now actually hides/shows beta emulators in the Emulators page
- **Decomp launch** — decomps now copy the ROM into the install directory and set execute permissions before launch
- **Stuck loading screens** — all 5 main pages (Dashboard, Settings, Emulators, Save Manager, Library) now resolve loading state on errors instead of hanging forever
- **setTimeout memory leaks** — fixed 12+ tracked-but-never-cleaned timers across ControllerPage, SaveManagerPage, UtilitiesPage, BiosCheckPanel, and useGamepadNav
- **App startup crash** — `ensureRomsStructure()` now caught; fatal window creation shows error dialog instead of zombie process
- **Syncthing quit cleanup** — Syncthing process now stopped on app quit
- **execSync hangs** — path detection and version detection now have 3-5 second timeouts instead of blocking indefinitely
- **BrowserWindow null crash** — removed unsafe `!` assertions on `fromWebContents()` calls in IPC handlers
- **Download redirect loops** — both installer and Syncthing download functions now cap redirects at 10
- **Download timeouts** — Syncthing binary download now has a 30s request timeout
- **Cloud Sync type error** — added missing `uninstall` method to cloud IPC type definition
- **Decomp tab rendering** — Decomps tab now correctly hides emulator cards and shows only decomp entries
- **Build fix** — removed Linux ARM64 cross-compile from `build:all` (requires Linux host)
--- ---
@@ -116,12 +135,13 @@ Redesigned top-bar navigation with full gamepad/controller support:
--- ---
## Supported Emulators (20 total) ## Supported Emulators (20+ total)
RetroArch, Dolphin, RPCS3, Cemu, xemu, Vita3K, Azahar, Eden, PPSSPP, DuckStation, PCSX2, Flycast, Yuzu (legacy), mGBA, MelonDS, Mednafen, ES-DE, NeoStation, Pegasus, EmuBuddy, and more. RetroArch, Dolphin, RPCS3, Cemu, xemu, Vita3K, Azahar, Eden, PPSSPP, DuckStation, PCSX2, Flycast, mGBA, MelonDS, Mednafen, Project64, Snes9x, Mesen2, MAME, ES-DE, NeoStation, Pegasus, EmuBuddy, and more.
--- ---
## 🙏 Thank You!
## Thank You!
Thanks for using OmniEmu! Every star on GitHub, every bug report, and every feature request helps us make this app better for everyone. If you run into any issues, please open an issue on [GitHub](https://github.com/mileswolfallen2/OmniEmu2.0) — we're always happy to help. Thanks for using OmniEmu! Every star on GitHub, every bug report, and every feature request helps us make this app better for everyone. If you run into any issues, please open an issue on [GitHub](https://github.com/mileswolfallen2/OmniEmu2.0) — we're always happy to help.
Happy gaming! 🎮✨ Happy gaming!
+1 -1
View File
@@ -20,7 +20,7 @@
"package:win": "npm run build && npx electron-builder --win", "package:win": "npm run build && npx electron-builder --win",
"package:linux": "npm run build && npx electron-builder --linux", "package:linux": "npm run build && npx electron-builder --linux",
"package:all": "npm run build && npx electron-builder --mac --universal --win --linux", "package:all": "npm run build && npx electron-builder --mac --universal --win --linux",
"build:all": "npm run build && npx electron-builder --mac --universal && npx electron-builder --win --arm64 --linux --arm64 && npx electron-builder --win --x64 --linux --x64", "build:all": "npm run build && npx electron-builder --mac --universal && npx electron-builder --win --arm64 && npx electron-builder --win --x64 --linux --x64",
"typecheck": "tsc --noEmit -p tsconfig.main.json && tsc --noEmit -p tsconfig.renderer.json" "typecheck": "tsc --noEmit -p tsconfig.main.json && tsc --noEmit -p tsconfig.renderer.json"
}, },
"author": "fedl team <[email protected]>", "author": "fedl team <[email protected]>",
+27 -8
View File
@@ -1,7 +1,7 @@
import { existsSync, mkdirSync, readFileSync, rmSync, readdirSync, writeFileSync } from 'fs'; import { existsSync, mkdirSync, readFileSync, rmSync, readdirSync, writeFileSync, copyFileSync } from 'fs';
import { join, extname } from 'path'; import { join, extname, basename } from 'path';
import { app, shell } from 'electron'; import { app, shell } from 'electron';
import { exec } from 'child_process'; import { spawn, execSync } from 'child_process';
import { DecompProject, DecompState, Platform } from '../shared/types'; import { DecompProject, DecompState, Platform } from '../shared/types';
import { getPlatform } from './platform'; import { getPlatform } from './platform';
import { settings } from './settings'; import { settings } from './settings';
@@ -442,8 +442,30 @@ export function launchDecomp(id: string): boolean {
const state = checkDecomp(id); const state = checkDecomp(id);
if (!state.installed || !state.path) return false; if (!state.installed || !state.path) return false;
const child = exec(`"${state.path}"`, { cwd: getDecompDir(id) }); const installDir = getDecompDir(id);
if (child) child.unref();
// Ensure the executable has execute permission on macOS/Linux
if (platform !== 'win32') {
try { execSync(`chmod +x "${state.path}"`, { timeout: 3000 }); } catch { /* ignore */ }
}
// Copy ROM into the install dir so the port can find it
if (state.romPath && existsSync(state.romPath)) {
const romBase = basename(state.romPath);
const dest = join(installDir, romBase);
if (state.romPath !== dest) {
try { copyFileSync(state.romPath, dest); } catch { /* ignore */ }
}
}
try {
const child = spawn(state.path, [], { cwd: installDir, detached: true, stdio: 'ignore' });
child.on('error', (err) => { console.error(`Decomp launch error (${id}):`, err.message); });
child.unref();
} catch (err: any) {
console.error(`Decomp spawn failed (${id}):`, err.message);
return false;
}
return true; return true;
} }
@@ -632,9 +654,6 @@ async function extractArchive(
onProgress: (p: DecompInstallProgress) => void, onProgress: (p: DecompInstallProgress) => void,
decompId: string, decompId: string,
): Promise<void> { ): Promise<void> {
const { execSync } = require('child_process');
const platform = process.platform;
try { try {
if (archiveName.endsWith('.zip')) { if (archiveName.endsWith('.zip')) {
onProgress({ decompId, stage: 'extracting', percent: 75, message: 'Extracting zip archive...' }); onProgress({ decompId, stage: 'extracting', percent: 75, message: 'Extracting zip archive...' });
+21 -3
View File
@@ -15,6 +15,7 @@ import { applyCachedCovers } from './scraper';
const platform = getPlatform(); const platform = getPlatform();
const arch = getArch(); const arch = getArch();
const FRONTEND_IDS = new Set(['esde', 'neostation', 'pegasus']);
export const knownEmulators: EmulatorConfig[] = [ export const knownEmulators: EmulatorConfig[] = [
{ {
@@ -482,6 +483,7 @@ export const knownEmulators: EmulatorConfig[] = [
], ],
}, },
supported: true, supported: true,
beta: true,
websiteUrl: { websiteUrl: {
win32: 'https://es-de.org/download/', win32: 'https://es-de.org/download/',
darwin: 'https://es-de.org/download/', darwin: 'https://es-de.org/download/',
@@ -524,6 +526,7 @@ export const knownEmulators: EmulatorConfig[] = [
], ],
}, },
supported: true, supported: true,
beta: true,
websiteUrl: { websiteUrl: {
win32: 'https://neostation.dev/downloads/', win32: 'https://neostation.dev/downloads/',
darwin: 'https://neostation.dev/downloads/', darwin: 'https://neostation.dev/downloads/',
@@ -565,6 +568,7 @@ export const knownEmulators: EmulatorConfig[] = [
], ],
}, },
supported: true, supported: true,
beta: true,
websiteUrl: { websiteUrl: {
win32: 'https://github.com/computerex/EmuBuddy', win32: 'https://github.com/computerex/EmuBuddy',
darwin: 'https://github.com/computerex/EmuBuddy', darwin: 'https://github.com/computerex/EmuBuddy',
@@ -607,6 +611,7 @@ export const knownEmulators: EmulatorConfig[] = [
], ],
}, },
supported: true, supported: true,
beta: true,
websiteUrl: { websiteUrl: {
win32: 'https://pegasus-frontend.org/download/', win32: 'https://pegasus-frontend.org/download/',
darwin: 'https://pegasus-frontend.org/download/', darwin: 'https://pegasus-frontend.org/download/',
@@ -683,6 +688,7 @@ export const knownEmulators: EmulatorConfig[] = [
], ],
}, },
supported: true, supported: true,
beta: true,
websiteUrl: { websiteUrl: {
win32: 'https://xemu.app/', win32: 'https://xemu.app/',
darwin: 'https://xemu.app/', darwin: 'https://xemu.app/',
@@ -727,6 +733,7 @@ export const knownEmulators: EmulatorConfig[] = [
], ],
}, },
supported: true, supported: true,
beta: true,
websiteUrl: { websiteUrl: {
win32: 'https://vita3k.org/', win32: 'https://vita3k.org/',
darwin: 'https://vita3k.org/', darwin: 'https://vita3k.org/',
@@ -765,6 +772,7 @@ export const knownEmulators: EmulatorConfig[] = [
], ],
}, },
supported: true, supported: true,
beta: true,
websiteUrl: { websiteUrl: {
win32: 'https://azahar-emu.org/', win32: 'https://azahar-emu.org/',
darwin: 'https://azahar-emu.org/', darwin: 'https://azahar-emu.org/',
@@ -791,6 +799,7 @@ export const knownEmulators: EmulatorConfig[] = [
], ],
}, },
supported: true, supported: true,
beta: true,
websiteUrl: { websiteUrl: {
win32: 'https://www.pj64-emu.com/', win32: 'https://www.pj64-emu.com/',
darwin: 'https://www.pj64-emu.com/', darwin: 'https://www.pj64-emu.com/',
@@ -940,6 +949,7 @@ export const knownEmulators: EmulatorConfig[] = [
}, },
packageNames: { linux: 'mednafen', darwin: 'mednafen' }, packageNames: { linux: 'mednafen', darwin: 'mednafen' },
supported: true, supported: true,
beta: true,
websiteUrl: { websiteUrl: {
win32: 'https://mednafen.github.io/', win32: 'https://mednafen.github.io/',
darwin: 'https://mednafen.github.io/', darwin: 'https://mednafen.github.io/',
@@ -967,7 +977,7 @@ function detectEmulatorPath(config: EmulatorConfig): string | undefined {
if (existsSync(omniEmuDir)) { if (existsSync(omniEmuDir)) {
try { try {
const cmd = isWindows() ? 'where' : 'which'; const cmd = isWindows() ? 'where' : 'which';
const result = execSync(`${cmd} ${config.id} 2>${isWindows() ? 'nul' : '/dev/null'}`) const result = execSync(`${cmd} ${config.id} 2>${isWindows() ? 'nul' : '/dev/null'}`, { timeout: 5000 })
.toString().trim(); .toString().trim();
if (result) return result.split('\n')[0]; if (result) return result.split('\n')[0];
} catch { } catch {
@@ -1130,7 +1140,8 @@ function detectVersion(binaryPath: string): string | undefined {
if (isWindows()) { if (isWindows()) {
try { try {
const out = execSync( const out = execSync(
`powershell -NoProfile -Command "(Get-Item '${binaryPath}').VersionInfo.ProductVersion" 2>nul` `powershell -NoProfile -Command "(Get-Item '${binaryPath}').VersionInfo.ProductVersion" 2>nul`,
{ timeout: 3000 }
).toString().trim(); ).toString().trim();
if (out) return out; if (out) return out;
} catch { /* ignore */ } } catch { /* ignore */ }
@@ -1174,7 +1185,14 @@ export function checkEmulator(id: string): EmulatorState {
} }
export function getAllEmulatorStates(): EmulatorState[] { export function getAllEmulatorStates(): EmulatorState[] {
return knownEmulators.map((e) => checkEmulator(e.id)); const s = settings.get();
const includeFrontends = s.frontendSupport;
return knownEmulators
.filter((e) => {
if (!includeFrontends && (FRONTEND_IDS.has(e.id) || e.id === 'emubuddy')) return false;
return true;
})
.map((e) => checkEmulator(e.id));
} }
export function launchEmulator(emulatorId: string): boolean { export function launchEmulator(emulatorId: string): boolean {
+10 -4
View File
@@ -1,4 +1,4 @@
import { app, BrowserWindow, Tray, Menu, nativeImage } from 'electron'; import { app, BrowserWindow, Tray, Menu, nativeImage, dialog } from 'electron';
import { join } from 'path'; import { join } from 'path';
import { existsSync } from 'fs'; import { existsSync } from 'fs';
import { registerIpcHandlers } from './ipc'; import { registerIpcHandlers } from './ipc';
@@ -7,7 +7,7 @@ import { isMacOS, isLinux } from './platform';
import { ensureRomsStructure } from './emulators'; import { ensureRomsStructure } from './emulators';
import { setupAutoUpdater } from './updater'; import { setupAutoUpdater } from './updater';
import { generatePegasusCollectionsForRomDir } from './configurator'; import { generatePegasusCollectionsForRomDir } from './configurator';
import { startSyncthing } from './syncthing'; import { startSyncthing, stopSyncthing } from './syncthing';
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Steam Deck / SteamOS (Gamescope) compatibility // Steam Deck / SteamOS (Gamescope) compatibility
@@ -190,7 +190,7 @@ if (!gotLock) {
} }
app.whenReady().then(() => { app.whenReady().then(() => {
ensureRomsStructure(); try { ensureRomsStructure(); } catch (err) { console.error('ensureRomsStructure failed:', err); }
setupAutoUpdater(); setupAutoUpdater();
registerIpcHandlers(); registerIpcHandlers();
@@ -202,7 +202,12 @@ if (!gotLock) {
console.error('Window creation failed, retrying without GPU:', err); console.error('Window creation failed, retrying without GPU:', err);
app.commandLine.appendSwitch('disable-gpu'); app.commandLine.appendSwitch('disable-gpu');
app.commandLine.appendSwitch('disable-software-rasterizer'); app.commandLine.appendSwitch('disable-software-rasterizer');
try { createWindow(); } catch { /* fatal */ } try { createWindow(); } catch (fatalErr) {
console.error('Window creation failed fatally:', fatalErr);
dialog.showErrorBox('OmniEmu', 'Failed to create application window. The app will exit.');
app.quit();
return;
}
} }
createTray(); createTray();
@@ -232,4 +237,5 @@ app.on('window-all-closed', () => {
app.on('before-quit', () => { app.on('before-quit', () => {
tray = null; tray = null;
try { stopSyncthing(); } catch { /* ignore */ }
}); });
+3 -2
View File
@@ -24,7 +24,8 @@ function tempName(suffix: string): string {
function downloadFile(url: string, dest: string, onProgress: (pct: number) => void): Promise<void> { function downloadFile(url: string, dest: string, onProgress: (pct: number) => void): Promise<void> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const doRequest = (currentUrl: string) => { const doRequest = (currentUrl: string, redirects = 0) => {
if (redirects > 10) { reject(new Error('Too many redirects')); return; }
const protocol = currentUrl.startsWith('https') ? httpsGet : httpGet; const protocol = currentUrl.startsWith('https') ? httpsGet : httpGet;
const opts: RequestOptions = { const opts: RequestOptions = {
headers: { 'User-Agent': 'OmniEmu/0.3.2' }, headers: { 'User-Agent': 'OmniEmu/0.3.2' },
@@ -33,7 +34,7 @@ function downloadFile(url: string, dest: string, onProgress: (pct: number) => vo
protocol(currentUrl, opts, (response) => { protocol(currentUrl, opts, (response) => {
const code = response.statusCode ?? 500; const code = response.statusCode ?? 500;
if (code >= 300 && code < 400 && response.headers.location) { if (code >= 300 && code < 400 && response.headers.location) {
doRequest(response.headers.location); doRequest(response.headers.location, redirects + 1);
return; return;
} }
if (code !== 200) { if (code !== 200) {
+19 -3
View File
@@ -35,7 +35,7 @@ import { addRecentGame, parseGameTitle, buildScrapeTitle, findValidThumbnail, ca
import { getGameAchievements, raSupportedPlatforms } from './ra'; import { getGameAchievements, raSupportedPlatforms } from './ra';
import { FILTER_PRESETS, applyFilterPreset } from './filters'; import { FILTER_PRESETS, applyFilterPreset } from './filters';
import { scanBiosDirectory, getKnownBiosList, getDefaultBiosDir, updateRetroarchBiosPath } from './bios'; import { scanBiosDirectory, getKnownBiosList, getDefaultBiosDir, updateRetroarchBiosPath } from './bios';
import { listAllSaves, deleteSave, backupSave } from './saveManager'; import { listAllSaves, deleteSave, backupSave, listBackups, restoreBackup, openBackupFolder } from './saveManager';
import { import {
installSyncthing, installSyncthing,
startSyncthing, startSyncthing,
@@ -61,7 +61,11 @@ export function registerIpcHandlers(): void {
// Emulators // Emulators
ipcMain.handle('emulators:list', () => { ipcMain.handle('emulators:list', () => {
const plat = getPlatform(); const plat = getPlatform();
const s = settings.get();
const includeFrontends = s.frontendSupport;
const FRONTEND_IDS = new Set(['esde', 'neostation', 'pegasus']);
return knownEmulators.filter(e => { return knownEmulators.filter(e => {
if (!includeFrontends && (FRONTEND_IDS.has(e.id) || e.id === 'emubuddy')) return false;
const hasDownload = e.downloads?.[plat] && e.downloads[plat]!.length > 0; const hasDownload = e.downloads?.[plat] && e.downloads[plat]!.length > 0;
const hasPackage = !!e.packageNames?.[plat]; const hasPackage = !!e.packageNames?.[plat];
return hasDownload || hasPackage; return hasDownload || hasPackage;
@@ -97,7 +101,9 @@ export function registerIpcHandlers(): void {
// ES-DE on macOS: manual install (DMG requires license acceptance in Finder) // ES-DE on macOS: manual install (DMG requires license acceptance in Finder)
if (emulatorId === 'esde' && getPlatform() === 'darwin') { if (emulatorId === 'esde' && getPlatform() === 'darwin') {
const result = await dialog.showMessageBox(BrowserWindow.fromWebContents(event.sender)!, { const win = BrowserWindow.fromWebContents(event.sender);
if (win) {
const result = await dialog.showMessageBox(win, {
type: 'info', type: 'info',
title: 'Install ES-DE', title: 'Install ES-DE',
message: 'ES-DE must be installed manually on macOS', message: 'ES-DE must be installed manually on macOS',
@@ -108,12 +114,15 @@ export function registerIpcHandlers(): void {
if (result.response === 0) { if (result.response === 0) {
shell.openExternal('https://es-de.org/#Download'); shell.openExternal('https://es-de.org/#Download');
} }
}
return checkEmulator(emulatorId); return checkEmulator(emulatorId);
} }
// NeoStation on macOS: manual install (DMG requires license acceptance in Finder) // NeoStation on macOS: manual install (DMG requires license acceptance in Finder)
if (emulatorId === 'neostation' && getPlatform() === 'darwin') { if (emulatorId === 'neostation' && getPlatform() === 'darwin') {
const result = await dialog.showMessageBox(BrowserWindow.fromWebContents(event.sender)!, { const win = BrowserWindow.fromWebContents(event.sender);
if (win) {
const result = await dialog.showMessageBox(win, {
type: 'info', type: 'info',
title: 'Install NeoStation', title: 'Install NeoStation',
message: 'NeoStation must be installed manually on macOS', message: 'NeoStation must be installed manually on macOS',
@@ -124,6 +133,7 @@ export function registerIpcHandlers(): void {
if (result.response === 0) { if (result.response === 0) {
shell.openExternal('https://neostation.dev/downloads/'); shell.openExternal('https://neostation.dev/downloads/');
} }
}
return checkEmulator(emulatorId); return checkEmulator(emulatorId);
} }
@@ -458,6 +468,12 @@ export function registerIpcHandlers(): void {
ipcMain.handle('saves:backup', (_event, filePath: string) => backupSave(filePath)); ipcMain.handle('saves:backup', (_event, filePath: string) => backupSave(filePath));
ipcMain.handle('saves:list-backups', () => listBackups());
ipcMain.handle('saves:restore', (_event, backupPath: string) => restoreBackup(backupPath));
ipcMain.handle('saves:open-backup-folder', () => openBackupFolder());
ipcMain.handle('saves:open-folder', async (_event, folderPath: string) => { ipcMain.handle('saves:open-folder', async (_event, folderPath: string) => {
shell.showItemInFolder(folderPath); shell.showItemInFolder(folderPath);
return true; return true;
+5
View File
@@ -167,6 +167,11 @@ const api = {
ipcRenderer.invoke('saves:delete', filePath), ipcRenderer.invoke('saves:delete', filePath),
backup: (filePath: string): Promise<string | null> => backup: (filePath: string): Promise<string | null> =>
ipcRenderer.invoke('saves:backup', filePath), ipcRenderer.invoke('saves:backup', filePath),
listBackups: (): Promise<any[]> => ipcRenderer.invoke('saves:list-backups'),
restore: (backupPath: string): Promise<boolean> =>
ipcRenderer.invoke('saves:restore', backupPath),
openBackupFolder: (): Promise<boolean> =>
ipcRenderer.invoke('saves:open-backup-folder'),
openFolder: (folderPath: string): Promise<boolean> => openFolder: (folderPath: string): Promise<boolean> =>
ipcRenderer.invoke('saves:open-folder', folderPath), ipcRenderer.invoke('saves:open-folder', folderPath),
selectDirectory: (): Promise<string | null> => selectDirectory: (): Promise<string | null> =>
+73 -2
View File
@@ -1,11 +1,11 @@
import { existsSync, readdirSync, statSync, unlinkSync, copyFileSync, mkdirSync, readFileSync } from 'fs'; import { existsSync, readdirSync, statSync, unlinkSync, copyFileSync, mkdirSync, readFileSync } from 'fs';
import { join, extname, basename } from 'path'; import { join, extname, basename } from 'path';
import { app } from 'electron'; import { app, shell } from 'electron';
import { homedir } from 'os'; import { homedir } from 'os';
import { getPlatform } from './platform'; import { getPlatform } from './platform';
import { settings } from './settings'; import { settings } from './settings';
import { knownEmulators } from './emulators'; import { knownEmulators } from './emulators';
import { SaveEntry, EmulatorSaves } from '../shared/types'; import { SaveEntry, EmulatorSaves, BackupEntry } from '../shared/types';
const platform = getPlatform(); const platform = getPlatform();
const home = homedir(); const home = homedir();
@@ -344,3 +344,74 @@ export function getEmulatorSaveDirs() {
saves: getSaveDir(e.id), saves: getSaveDir(e.id),
})).filter(e => e.saves); })).filter(e => e.saves);
} }
export function getBackupDir(): string {
return join(app.getPath('userData'), 'save-backups');
}
export function listBackups(): BackupEntry[] {
try {
const backupDir = getBackupDir();
if (!existsSync(backupDir)) return [];
const files = readdirSync(backupDir).filter(f => !f.startsWith('.'));
return files.map((f) => {
const fullPath = join(backupDir, f);
const st = statSync(fullPath);
// Filename pattern: {timestamp}_{originalName}
const underscoreIdx = f.indexOf('_');
const stamp = underscoreIdx > 0 ? f.slice(0, underscoreIdx).replace(/-/g, (m, offset) => (offset >= 10 && offset <= 15) ? ':' : m) : '';
const originalName = underscoreIdx > 0 ? f.slice(underscoreIdx + 1) : f;
return {
backupPath: fullPath,
originalName,
backupTime: stamp || st.mtime.toISOString(),
fileSize: st.size,
};
}).sort((a, b) => b.backupTime.localeCompare(a.backupTime));
} catch { /* ignore */ }
return [];
}
export function restoreBackup(backupPath: string): boolean {
try {
if (!existsSync(backupPath)) return false;
const backupDir = getBackupDir();
if (!backupPath.startsWith(backupDir)) return false;
const fileName = basename(backupPath);
const underscoreIdx = fileName.indexOf('_');
const originalName = underscoreIdx > 0 ? fileName.slice(underscoreIdx + 1) : fileName;
// Try to find the original save directory by matching the original filename
const allSaves = listAllSaves();
for (const emu of allSaves) {
for (const save of emu.saves) {
if (save.fileName === originalName) {
copyFileSync(backupPath, save.filePath);
return true;
}
}
}
// Fallback: restore to the first emulator save directory that exists
for (const emu of EMU_SAVE_META) {
const dir = getSaveDir(emu.id);
if (dir && existsSync(dir)) {
copyFileSync(backupPath, join(dir, originalName));
return true;
}
}
} catch { /* ignore */ }
return false;
}
export function openBackupFolder(): boolean {
try {
const backupDir = getBackupDir();
if (!existsSync(backupDir)) {
mkdirSync(backupDir, { recursive: true });
}
shell.openPath(backupDir);
return true;
} catch { /* ignore */ }
return false;
}
+5 -3
View File
@@ -238,14 +238,16 @@ export async function installSyncthing(
function downloadFile( function downloadFile(
url: string, url: string,
onProgress?: (received: number, total: number) => void onProgress?: (received: number, total: number) => void,
redirects = 0
): Promise<Buffer> { ): Promise<Buffer> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
if (redirects > 10) { reject(new Error('Too many redirects')); return; }
const https = require('https'); const https = require('https');
const get = url.startsWith('https') ? https.get : http.get; const get = url.startsWith('https') ? https.get : http.get;
get(url, (res: any) => { get(url, { timeout: 30000 }, (res: any) => {
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) { if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
downloadFile(res.headers.location, onProgress).then(resolve, reject); downloadFile(res.headers.location, onProgress, redirects + 1).then(resolve, reject);
return; return;
} }
if (res.statusCode !== 200) { if (res.statusCode !== 200) {
+6 -2
View File
@@ -1,4 +1,4 @@
import React, { useEffect, useState, useCallback } from 'react'; import React, { useEffect, useState, useCallback, useRef } from 'react';
interface BiosEntry { interface BiosEntry {
emulators: string[]; emulators: string[];
@@ -23,6 +23,9 @@ export function BiosCheckPanel({ biosDir, onBiosDirChange }: Props) {
const [results, setResults] = useState<BiosCheckResult[]>([]); const [results, setResults] = useState<BiosCheckResult[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [retroarchConfigMsg, setRetroarchConfigMsg] = useState(''); const [retroarchConfigMsg, setRetroarchConfigMsg] = useState('');
const statusTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => { return () => { if (statusTimer.current) clearTimeout(statusTimer.current); }; }, []);
const scan = useCallback(async (dir?: string) => { const scan = useCallback(async (dir?: string) => {
setLoading(true); setLoading(true);
@@ -52,7 +55,8 @@ export function BiosCheckPanel({ biosDir, onBiosDirChange }: Props) {
const configDir = homeDir + '/Library/Application Support/RetroArch'; const configDir = homeDir + '/Library/Application Support/RetroArch';
const ok = await window.omni.bios.configureRetroArch(configDir, biosDir); const ok = await window.omni.bios.configureRetroArch(configDir, biosDir);
setRetroarchConfigMsg(ok ? 'RetroArch BIOS path updated' : 'Failed to update RetroArch config'); setRetroarchConfigMsg(ok ? 'RetroArch BIOS path updated' : 'Failed to update RetroArch config');
setTimeout(() => setRetroarchConfigMsg(''), 4000); if (statusTimer.current) clearTimeout(statusTimer.current);
statusTimer.current = setTimeout(() => setRetroarchConfigMsg(''), 4000);
}; };
return ( return (
+1
View File
@@ -169,6 +169,7 @@ export function useGamepadNav(onNavigate: (page: Page) => void, currentPage: Pag
raf = requestAnimationFrame(poll); raf = requestAnimationFrame(poll);
return () => { return () => {
cancelAnimationFrame(raf); cancelAnimationFrame(raf);
if (legendTimer.current) clearTimeout(legendTimer.current);
}; };
}, [onNavigate, showLegendTemporarily]); }, [onNavigate, showLegendTemporarily]);
+4 -1
View File
@@ -51,6 +51,7 @@ export function ControllerPage() {
const [emulators, setEmulators] = useState<EmulatorState[]>([]); const [emulators, setEmulators] = useState<EmulatorState[]>([]);
const [configStatus, setConfigStatus] = useState<string>(''); const [configStatus, setConfigStatus] = useState<string>('');
const rafRef = useRef<number>(0); const rafRef = useRef<number>(0);
const statusTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const poll = useCallback(() => { const poll = useCallback(() => {
const gamepads = navigator.getGamepads(); const gamepads = navigator.getGamepads();
@@ -109,6 +110,7 @@ export function ControllerPage() {
window.removeEventListener('gamepadconnected', onConnected); window.removeEventListener('gamepadconnected', onConnected);
window.removeEventListener('gamepaddisconnected', onDisconnected); window.removeEventListener('gamepaddisconnected', onDisconnected);
cancelAnimationFrame(rafRef.current); cancelAnimationFrame(rafRef.current);
if (statusTimerRef.current) clearTimeout(statusTimerRef.current);
}; };
}, [poll]); }, [poll]);
@@ -120,7 +122,8 @@ export function ControllerPage() {
} catch (e: any) { } catch (e: any) {
setConfigStatus(`Error: ${e.message}`); setConfigStatus(`Error: ${e.message}`);
} }
setTimeout(() => setConfigStatus(''), 4000); if (statusTimerRef.current) clearTimeout(statusTimerRef.current);
statusTimerRef.current = setTimeout(() => setConfigStatus(''), 4000);
}; };
return ( return (
+8 -1
View File
@@ -8,18 +8,23 @@ export function Dashboard({ onNavigate }: { onNavigate?: (tab: 'dashboard' | 'em
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
useEffect(() => { useEffect(() => {
let cancelled = false;
async function load() { async function load() {
try {
const [states, info, recentGames] = await Promise.all([ const [states, info, recentGames] = await Promise.all([
window.omni.emulators.states(), window.omni.emulators.states(),
window.omni.system.info(), window.omni.system.info(),
window.omni.game.recent(), window.omni.game.recent(),
]); ]);
if (cancelled) return;
setEmulators(states); setEmulators(states);
setSystem(info); setSystem(info);
setRecent(recentGames || []); setRecent(recentGames || []);
setLoading(false); } catch { /* ignore */ }
if (!cancelled) setLoading(false);
} }
load(); load();
return () => { cancelled = true; };
}, []); }, []);
const installed = emulators.filter((e) => e.installed).length; const installed = emulators.filter((e) => e.installed).length;
@@ -103,9 +108,11 @@ export function Dashboard({ onNavigate }: { onNavigate?: (tab: 'dashboard' | 'em
tabIndex={0} tabIndex={0}
role="button" role="button"
onClick={async () => { onClick={async () => {
try {
await window.omni.game.launch(game.emulatorId, game.romPath); await window.omni.game.launch(game.emulatorId, game.romPath);
const updated = await window.omni.game.recent(); const updated = await window.omni.game.recent();
setRecent(updated || []); setRecent(updated || []);
} catch { /* ignore */ }
}} }}
> >
<div className="game-card-cover"> <div className="game-card-cover">
+37 -16
View File
@@ -15,6 +15,7 @@ export function EmulatorsPage() {
const [states, setStates] = useState<EmulatorState[]>([]); const [states, setStates] = useState<EmulatorState[]>([]);
const [decompStates, setDecompStates] = useState<DecompState[]>([]); const [decompStates, setDecompStates] = useState<DecompState[]>([]);
const [betaFeatures, setBetaFeatures] = useState(false); const [betaFeatures, setBetaFeatures] = useState(false);
const [frontendSupport, setFrontendSupport] = useState(false);
const [decompProjects, setDecompProjects] = useState(false); const [decompProjects, setDecompProjects] = useState(false);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [progress, setProgress] = useState<Record<string, InstallProgress>>({}); const [progress, setProgress] = useState<Record<string, InstallProgress>>({});
@@ -25,6 +26,7 @@ export function EmulatorsPage() {
const load = useCallback(async () => { const load = useCallback(async () => {
setLoading(true); setLoading(true);
try {
const [emuResult, decompResult, settings] = await Promise.all([ const [emuResult, decompResult, settings] = await Promise.all([
window.omni.emulators.states(), window.omni.emulators.states(),
window.omni.decomps.states(), window.omni.decomps.states(),
@@ -33,7 +35,9 @@ export function EmulatorsPage() {
setStates(emuResult); setStates(emuResult);
setDecompStates(decompResult); setDecompStates(decompResult);
setBetaFeatures(!!settings.betaFeatures); setBetaFeatures(!!settings.betaFeatures);
setFrontendSupport(!!settings.frontendSupport);
setDecompProjects(!!settings.decompProjects); setDecompProjects(!!settings.decompProjects);
} catch { /* ignore */ }
setLoading(false); setLoading(false);
}, []); }, []);
@@ -73,7 +77,8 @@ export function EmulatorsPage() {
...prev, ...prev,
[id]: { emulatorId: id, stage: 'downloading', percent: 0, message: 'Starting...' }, [id]: { emulatorId: id, stage: 'downloading', percent: 0, message: 'Starting...' },
})); }));
await window.omni.emulators.install(id); try { await window.omni.emulators.install(id); } catch { /* ignore */ }
setActioning(null);
await load(); await load();
}; };
@@ -83,10 +88,13 @@ export function EmulatorsPage() {
...prev, ...prev,
[id]: { emulatorId: id, stage: 'downloading', percent: 0, message: 'Starting...' }, [id]: { emulatorId: id, stage: 'downloading', percent: 0, message: 'Starting...' },
})); }));
try {
const result = await window.omni.emulators.install(id); const result = await window.omni.emulators.install(id);
if (result.installed && result.path) { if (result.installed && result.path) {
await window.omni.emulators.configure(id, result.path); await window.omni.emulators.configure(id, result.path);
} }
} catch { /* ignore */ }
setActioning(null);
await load(); await load();
}; };
@@ -100,7 +108,7 @@ export function EmulatorsPage() {
stage: 'configuring', percent: 0, message: 'Applying recommended settings...', stage: 'configuring', percent: 0, message: 'Applying recommended settings...',
}, },
})); }));
await window.omni.emulators.configure(state.config.id, state.path); try { await window.omni.emulators.configure(state.config.id, state.path); } catch { /* ignore */ }
setProgress((prev) => ({ setProgress((prev) => ({
...prev, ...prev,
[state.config.id]: { [state.config.id]: {
@@ -115,20 +123,20 @@ export function EmulatorsPage() {
const handleOpen = async (id: string) => { const handleOpen = async (id: string) => {
setActioning(id); setActioning(id);
await window.omni.emulators.launch(id); try { await window.omni.emulators.launch(id); } catch { /* ignore */ }
setActioning(null); setActioning(null);
}; };
const handleUninstall = async (id: string) => { const handleUninstall = async (id: string) => {
if (!confirm(`Uninstall ${id} and remove all its files?`)) return; if (!confirm(`Uninstall ${id} and remove all its files?`)) return;
setActioning(id); setActioning(id);
await window.omni.emulators.uninstall(id); try { await window.omni.emulators.uninstall(id); } catch { /* ignore */ }
setActioning(null); setActioning(null);
await load(); await load();
}; };
const handleOpenWebsite = async (id: string) => { const handleOpenWebsite = async (id: string) => {
await window.omni.emulators.openWebsite(id); try { await window.omni.emulators.openWebsite(id); } catch { /* ignore */ }
}; };
// ── Decomp handlers ─────────────────────────────────────── // ── Decomp handlers ───────────────────────────────────────
@@ -139,31 +147,33 @@ export function EmulatorsPage() {
...prev, ...prev,
[id]: { stage: 'downloading', percent: 0, message: 'Starting...' }, [id]: { stage: 'downloading', percent: 0, message: 'Starting...' },
})); }));
await window.omni.decomps.install(id); try { await window.omni.decomps.install(id); } catch { /* ignore */ }
await load(); await load();
}; };
const handleDecompUninstall = async (id: string) => { const handleDecompUninstall = async (id: string) => {
if (!confirm(`Uninstall this port and remove all its files?`)) return; if (!confirm(`Uninstall this port and remove all its files?`)) return;
setActioning(id); setActioning(id);
await window.omni.decomps.uninstall(id); try { await window.omni.decomps.uninstall(id); } catch { /* ignore */ }
setActioning(null); setActioning(null);
await load(); await load();
}; };
const handleDecompLaunch = async (id: string) => { const handleDecompLaunch = async (id: string) => {
setActioning(id); setActioning(id);
await window.omni.decomps.launch(id); try { await window.omni.decomps.launch(id); } catch { /* ignore */ }
setActioning(null); setActioning(null);
}; };
const handleDecompSelectRom = async (id: string) => { const handleDecompSelectRom = async (id: string) => {
try {
const result = await window.omni.decomps.selectRom(id); const result = await window.omni.decomps.selectRom(id);
if (result.state) { if (result.state) {
setDecompStates((prev) => setDecompStates((prev) =>
prev.map((d) => (d.config.id === id ? result.state : d)) prev.map((d) => (d.config.id === id ? result.state : d))
); );
} }
} catch { /* ignore */ }
}; };
const handleRefresh = () => load(); const handleRefresh = () => load();
@@ -174,16 +184,23 @@ export function EmulatorsPage() {
const currentProgress = (id: string) => progress[id]; const currentProgress = (id: string) => progress[id];
const emulators = states.filter((s) => s.config.id !== 'emubuddy' && !FRONTEND_IDS.has(s.config.id)); const allEmulators = states.filter((s) => s.config.id !== 'emubuddy' && !FRONTEND_IDS.has(s.config.id));
const frontends = states.filter((s) => FRONTEND_IDS.has(s.config.id) || s.config.id === 'emubuddy'); const allFrontends = states.filter((s) => FRONTEND_IDS.has(s.config.id) || s.config.id === 'emubuddy');
const installedCount = states.filter((s) => s.installed).length;
const configuredCount = states.filter((s) => s.configured).length; const emulators = betaFeatures ? allEmulators : allEmulators.filter((s) => !s.config.beta);
const frontends = frontendSupport ? allFrontends : [];
const visibleStates = [...emulators, ...frontends];
const installedCount = visibleStates.filter((s) => s.installed).length;
const configuredCount = visibleStates.filter((s) => s.configured).length;
const filteredStates = activeTab === 'emulators' const filteredStates = activeTab === 'emulators'
? emulators ? emulators
: activeTab === 'frontends' : activeTab === 'frontends'
? frontends ? frontends
: states; : activeTab === 'decomps'
? []
: visibleStates;
const showDecomps = decompProjects && (activeTab === 'all' || activeTab === 'decomps'); const showDecomps = decompProjects && (activeTab === 'all' || activeTab === 'decomps');
@@ -208,7 +225,7 @@ export function EmulatorsPage() {
onClick={() => setActiveTab('all')} onClick={() => setActiveTab('all')}
> >
All All
<span className="page-tab-count">{states.length + (showDecomps ? decompStates.length : 0)}</span> <span className="page-tab-count">{visibleStates.length + (showDecomps ? decompStates.length : 0)}</span>
</button> </button>
<button <button
className={`page-tab ${activeTab === 'emulators' ? 'active' : ''}`} className={`page-tab ${activeTab === 'emulators' ? 'active' : ''}`}
@@ -217,6 +234,7 @@ export function EmulatorsPage() {
Emulators Emulators
<span className="page-tab-count">{emulators.length}</span> <span className="page-tab-count">{emulators.length}</span>
</button> </button>
{frontendSupport && (
<button <button
className={`page-tab ${activeTab === 'frontends' ? 'active' : ''}`} className={`page-tab ${activeTab === 'frontends' ? 'active' : ''}`}
onClick={() => setActiveTab('frontends')} onClick={() => setActiveTab('frontends')}
@@ -224,6 +242,7 @@ export function EmulatorsPage() {
Frontends Frontends
<span className="page-tab-count">{frontends.length}</span> <span className="page-tab-count">{frontends.length}</span>
</button> </button>
)}
{decompProjects && ( {decompProjects && (
<button <button
className={`page-tab ${activeTab === 'decomps' ? 'active' : ''}`} className={`page-tab ${activeTab === 'decomps' ? 'active' : ''}`}
@@ -244,7 +263,7 @@ export function EmulatorsPage() {
return ( return (
<div className="card" key={state.config.id}> <div className="card" key={state.config.id}>
<div className="card-header"> <div className="card-header">
<h3>{state.config.name}</h3> <h3>{state.config.name} {state.config.beta && <span className="badge-beta" style={{ marginLeft: 6, fontSize: 10, verticalAlign: 'middle' }}>Beta</span>}</h3>
<span <span
className={`badge ${ className={`badge ${
!state.config.supported !state.config.supported
@@ -407,13 +426,15 @@ export function EmulatorsPage() {
{/* ── Decompilations Section ───────────────────────── */} {/* ── Decompilations Section ───────────────────────── */}
{showDecomps && (<> {showDecomps && (<>
<div style={{ marginTop: 40 }}> <div style={{ marginTop: activeTab === 'decomps' ? 0 : 40 }}>
{activeTab !== 'decomps' && (
<div className="info-bar" style={{ marginBottom: 16 }}> <div className="info-bar" style={{ marginBottom: 16 }}>
<h2 style={{ fontSize: 20, fontWeight: 600 }}>Decompilations <span className="badge-beta">Beta</span></h2> <h2 style={{ fontSize: 20, fontWeight: 600 }}>Decompilations <span className="badge-beta">Beta</span></h2>
<span className="text-sm text-muted"> <span className="text-sm text-muted">
Native PC ports built from reverse-engineered source code bring your own ROM Native PC ports built from reverse-engineered source code bring your own ROM
</span> </span>
</div> </div>
)}
<div className="card-grid"> <div className="card-grid">
{decompStates.map((decomp) => { {decompStates.map((decomp) => {
+13 -3
View File
@@ -19,20 +19,28 @@ export function LibraryPage() {
const [selectedGame, setSelectedGame] = useState<GameEntry | null>(null); const [selectedGame, setSelectedGame] = useState<GameEntry | null>(null);
useEffect(() => { useEffect(() => {
let cancelled = false;
(async () => { (async () => {
try {
const settings = await window.omni.settings.get(); const settings = await window.omni.settings.get();
if (cancelled) return;
setDecompProjects(!!settings.decompProjects); setDecompProjects(!!settings.decompProjects);
if (settings.romsDirectory) { if (settings.romsDirectory) {
setRomsDir(settings.romsDirectory); setRomsDir(settings.romsDirectory);
setLoading(true); setLoading(true);
const results = await window.omni.roms.scan(settings.romsDirectory); const results = await window.omni.roms.scan(settings.romsDirectory);
if (cancelled) return;
setGames(await scrapeMissingArt(results)); setGames(await scrapeMissingArt(results));
setLoading(false); setLoading(false);
} }
// Load installed decomps // Load installed decomps
const decompStates = await window.omni.decomps.states(); const decompStates = await window.omni.decomps.states();
if (cancelled) return;
setDecompGames(decompStates.filter(d => d.installed)); setDecompGames(decompStates.filter(d => d.installed));
} catch { /* ignore */ }
if (!cancelled) setLoading(false);
})(); })();
return () => { cancelled = true; };
}, []); }, []);
const scrapeMissingArt = async (list: GameEntry[]): Promise<GameEntry[]> => { const scrapeMissingArt = async (list: GameEntry[]): Promise<GameEntry[]> => {
@@ -52,9 +60,11 @@ export function LibraryPage() {
const scanDirectory = useCallback(async (dir: string) => { const scanDirectory = useCallback(async (dir: string) => {
setRomsDir(dir); setRomsDir(dir);
setLoading(true); setLoading(true);
try {
await window.omni.settings.save({ romsDirectory: dir }); await window.omni.settings.save({ romsDirectory: dir });
const results = await window.omni.roms.scan(dir); const results = await window.omni.roms.scan(dir);
setGames(await scrapeMissingArt(results)); setGames(await scrapeMissingArt(results));
} catch { /* ignore */ }
setLoading(false); setLoading(false);
}, []); }, []);
@@ -65,12 +75,12 @@ export function LibraryPage() {
}, [scanDirectory]); }, [scanDirectory]);
const handleLaunch = async (game: GameEntry) => { const handleLaunch = async (game: GameEntry) => {
await window.omni.game.launch(game.emulatorId, game.romPath); try { await window.omni.game.launch(game.emulatorId, game.romPath); } catch { /* ignore */ }
}; };
const handleDecompLaunch = async (decomp: DecompState) => { const handleDecompLaunch = async (decomp: DecompState) => {
if (decomp.romPath) { if (decomp.romPath) {
await window.omni.decomps.launch(decomp.config.id); try { await window.omni.decomps.launch(decomp.config.id); } catch { /* ignore */ }
} }
}; };
@@ -123,7 +133,7 @@ export function LibraryPage() {
<div style={{ display: 'flex', gap: 6 }}> <div style={{ display: 'flex', gap: 6 }}>
<button className="btn btn-secondary btn-sm" onClick={async () => { <button className="btn btn-secondary btn-sm" onClick={async () => {
setLoading(true); setLoading(true);
setGames(await scrapeMissingArt(games)); try { setGames(await scrapeMissingArt(games)); } catch { /* ignore */ }
setLoading(false); setLoading(false);
}}> }}>
Scrape All Art Scrape All Art
+101 -4
View File
@@ -1,5 +1,5 @@
import React, { useEffect, useState, useCallback } from 'react'; import React, { useEffect, useState, useCallback, useRef } from 'react';
import type { GameEntry, EmulatorSaves, SaveEntry, SyncthingFolder } from '../../shared/types'; import type { GameEntry, EmulatorSaves, SaveEntry, SyncthingFolder, BackupEntry } from '../../shared/types';
function formatSize(bytes: number): string { function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`; if (bytes < 1024) return `${bytes} B`;
@@ -47,13 +47,19 @@ export function SaveManagerPage() {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [expandedGame, setExpandedGame] = useState<string | null>(null); const [expandedGame, setExpandedGame] = useState<string | null>(null);
const [status, setStatus] = useState(''); const [status, setStatus] = useState('');
const [view, setView] = useState<'games' | 'emulators'>('games'); const [view, setView] = useState<'games' | 'emulators' | 'backups'>('games');
const [syncFolders, setSyncFolders] = useState<SyncthingFolder[]>([]); const [syncFolders, setSyncFolders] = useState<SyncthingFolder[]>([]);
const [cloudEnabled, setCloudEnabled] = useState(false); const [cloudEnabled, setCloudEnabled] = useState(false);
const [togglingSync, setTogglingSync] = useState<string | null>(null); const [togglingSync, setTogglingSync] = useState<string | null>(null);
const [backups, setBackups] = useState<BackupEntry[]>([]);
const [restoring, setRestoring] = useState<string | null>(null);
const statusTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => { return () => { if (statusTimer.current) clearTimeout(statusTimer.current); }; }, []);
const loadData = useCallback(async () => { const loadData = useCallback(async () => {
setLoading(true); setLoading(true);
try {
const [settings, saves] = await Promise.all([ const [settings, saves] = await Promise.all([
window.omni.settings.get(), window.omni.settings.get(),
window.omni.saves.list(), window.omni.saves.list(),
@@ -71,6 +77,11 @@ export function SaveManagerPage() {
if (cloudStatus.running) cloudOk = true; if (cloudStatus.running) cloudOk = true;
} catch { /* ignore */ } } catch { /* ignore */ }
setCloudEnabled(cloudOk); setCloudEnabled(cloudOk);
try {
const backupList = await window.omni.saves.listBackups();
setBackups(backupList);
} catch { /* ignore */ }
} catch { /* ignore */ }
setLoading(false); setLoading(false);
}, []); }, []);
@@ -78,7 +89,8 @@ export function SaveManagerPage() {
const showStatus = (msg: string) => { const showStatus = (msg: string) => {
setStatus(msg); setStatus(msg);
setTimeout(() => setStatus(''), 3000); if (statusTimer.current) clearTimeout(statusTimer.current);
statusTimer.current = setTimeout(() => setStatus(''), 3000);
}; };
const allSaveFiles = allSaves.flatMap(e => e.saves); const allSaveFiles = allSaves.flatMap(e => e.saves);
@@ -149,6 +161,27 @@ export function SaveManagerPage() {
setTogglingSync(null); setTogglingSync(null);
}; };
const handleRestore = async (backup: BackupEntry) => {
if (!confirm(`Restore ${backup.originalName}? This will overwrite the current save file.`)) return;
setRestoring(backup.backupPath);
try {
const ok = await window.omni.saves.restore(backup.backupPath);
if (ok) {
showStatus(`Restored ${backup.originalName}`);
loadData();
} else {
showStatus('Restore failed — could not find original save location');
}
} catch {
showStatus('Restore failed');
}
setRestoring(null);
};
const handleOpenBackupFolder = async () => {
try { await window.omni.saves.openBackupFolder(); } catch { /* ignore */ }
};
return ( return (
<div> <div>
<div className="info-bar mb-4" style={{ justifyContent: 'space-between' }}> <div className="info-bar mb-4" style={{ justifyContent: 'space-between' }}>
@@ -166,6 +199,12 @@ export function SaveManagerPage() {
> >
By Emulator By Emulator
</button> </button>
<button
className={`btn btn-sm ${view === 'backups' ? 'btn-primary' : 'btn-secondary'}`}
onClick={() => setView('backups')}
>
Backups {backups.length > 0 && <span style={{ opacity: 0.7 }}>({backups.length})</span>}
</button>
<button className="btn btn-secondary btn-sm" onClick={loadData}> <button className="btn btn-secondary btn-sm" onClick={loadData}>
Refresh Refresh
</button> </button>
@@ -311,6 +350,64 @@ export function SaveManagerPage() {
</div> </div>
)) ))
)} )}
{!loading && view === 'backups' && (
<div>
<div className="info-bar mb-4" style={{ justifyContent: 'space-between' }}>
<span>{backups.length} backup{backups.length !== 1 ? 's' : ''}</span>
<button className="btn btn-secondary btn-sm" onClick={handleOpenBackupFolder}>
Open Backup Folder
</button>
</div>
{backups.length === 0 ? (
<div className="empty-state">
<div className="empty-state-icon">📁</div>
<h3>No backups yet</h3>
<p>Click "Backup" next to any save file to create a backup.</p>
</div>
) : (
<div className="card">
<div style={{ padding: 12 }}>
{backups.map((backup) => (
<div
key={backup.backupPath}
style={{
display: 'flex',
alignItems: 'center',
gap: 10,
padding: '8px 10px',
borderRadius: 8,
border: '1px solid var(--border)',
marginBottom: 4,
background: 'var(--bg-tertiary)',
}}
>
<span style={{ fontSize: 16, flexShrink: 0 }}>📦</span>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontWeight: 600, fontSize: 13, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{backup.originalName}
</div>
<div className="text-sm text-muted" style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{formatDate(backup.backupTime)} · {formatSize(backup.fileSize)}
</div>
</div>
<div style={{ display: 'flex', gap: 4, flexShrink: 0 }}>
<button
className="btn btn-primary btn-sm"
disabled={restoring === backup.backupPath}
onClick={() => handleRestore(backup)}
>
{restoring === backup.backupPath ? 'Restoring...' : 'Restore'}
</button>
</div>
</div>
))}
</div>
</div>
)}
</div>
)}
</div> </div>
); );
} }
+10 -5
View File
@@ -67,41 +67,46 @@ export function SettingsPage() {
}, []); }, []);
useEffect(() => { useEffect(() => {
let cancelled = false;
Promise.all([ Promise.all([
window.omni.settings.get(), window.omni.settings.get(),
window.omni.emulators.systemAssignments(), window.omni.emulators.systemAssignments(),
window.omni.emulators.list(), window.omni.emulators.list(),
]).then(([s, assignments, emulators]) => { ]).then(([s, assignments, emulators]) => {
if (cancelled) return;
setSettings(s); setSettings(s);
setSystemAssignments(assignments); setSystemAssignments(assignments);
const nameMap: Record<string, string> = {}; const nameMap: Record<string, string> = {};
for (const e of emulators) nameMap[e.id] = e.name; for (const e of emulators) nameMap[e.id] = e.name;
setEmuNameMap(nameMap); setEmuNameMap(nameMap);
}); }).catch(() => { if (!cancelled) setSettings({} as AppSettings); });
return () => { cancelled = true; };
}, []); }, []);
const update = async (partial: Partial<AppSettings>) => { const update = async (partial: Partial<AppSettings>) => {
if (!settings) return; if (!settings) return;
setSaving(true); setSaving(true);
try {
const updated = await window.omni.settings.save(partial); const updated = await window.omni.settings.save(partial);
setSettings(updated); setSettings(updated);
} catch { /* ignore */ }
setSaving(false); setSaving(false);
}; };
const handleCheckUpdates = async () => { const handleCheckUpdates = async () => {
setChecking(true); setChecking(true);
setUpdateInfo(null); setUpdateInfo(null);
await window.omni.updates.check(); try { await window.omni.updates.check(); } catch { setChecking(false); }
}; };
const handleDownloadUpdate = async () => { const handleDownloadUpdate = async () => {
setDownloading(true); setDownloading(true);
setDownloadProgress(0); setDownloadProgress(0);
await window.omni.updates.download(); try { await window.omni.updates.download(); } catch { setDownloading(false); }
}; };
const handleQuitAndInstall = async () => { const handleQuitAndInstall = async () => {
await window.omni.updates.quitAndInstall(); try { await window.omni.updates.quitAndInstall(); } catch { /* app is restarting */ }
}; };
if (!settings || !systemAssignments) { if (!settings || !systemAssignments) {
@@ -407,7 +412,7 @@ export function SettingsPage() {
onClick={async () => { onClick={async () => {
if (!confirm('This will delete all emulators, settings, and app data. Your ROMs are safe. Continue?')) return; if (!confirm('This will delete all emulators, settings, and app data. Your ROMs are safe. Continue?')) return;
if (!confirm('Are you really sure? This cannot be undone.')) return; if (!confirm('Are you really sure? This cannot be undone.')) return;
await window.omni.app.nukeData(); try { await window.omni.app.nukeData(); } catch { /* ignore */ }
window.location.reload(); window.location.reload();
}} }}
> >
+27 -8
View File
@@ -1,4 +1,4 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect, useRef } from 'react';
interface FilterPreset { interface FilterPreset {
id: string; id: string;
@@ -24,6 +24,25 @@ export function UtilitiesPage() {
const [filterResult, setFilterResult] = useState<{ message: string; ok: boolean } | null>(null); const [filterResult, setFilterResult] = useState<{ message: string; ok: boolean } | null>(null);
const [applyingAll, setApplyingAll] = useState(false); const [applyingAll, setApplyingAll] = useState(false);
const [applyAllResult, setApplyAllResult] = useState<string | null>(null); const [applyAllResult, setApplyAllResult] = useState<string | null>(null);
const timerRefs = useRef<ReturnType<typeof setTimeout>[]>([]);
const clearTimer = (ref: ReturnType<typeof setTimeout>) => {
clearTimeout(ref);
const idx = timerRefs.current.indexOf(ref);
if (idx >= 0) timerRefs.current.splice(idx, 1);
};
const addTimer = (ms: number, cb: () => void): ReturnType<typeof setTimeout> => {
const t = setTimeout(() => {
const idx = timerRefs.current.indexOf(t);
if (idx >= 0) timerRefs.current.splice(idx, 1);
cb();
}, ms);
timerRefs.current.push(t);
return t;
};
useEffect(() => { return () => { timerRefs.current.forEach(clearTimeout); }; }, []);
useEffect(() => { useEffect(() => {
window.omni.settings.get().then((s) => { window.omni.settings.get().then((s) => {
@@ -31,14 +50,14 @@ export function UtilitiesPage() {
setRaPassword(s.retroAchievementsPassword || ''); setRaPassword(s.retroAchievementsPassword || '');
setRaApiKey(s.retroAchievementsApiKey || ''); setRaApiKey(s.retroAchievementsApiKey || '');
setSgdbKey(s.steamGridDbApiKey || ''); setSgdbKey(s.steamGridDbApiKey || '');
}); }).catch(() => { /* ignore */ });
window.omni.filters.list().then(setFilterPresets); window.omni.filters.list().then(setFilterPresets).catch(() => { /* ignore */ });
}, []); }, []);
const handleRecreate = async () => { const handleRecreate = async () => {
setRegenerating(true); setRegenerating(true);
await window.omni.utilities.regenerateRomsStructure(); await window.omni.utilities.regenerateRomsStructure();
setTimeout(() => setRegenerating(false), 1500); addTimer(1500, () => setRegenerating(false));
}; };
const handleAutoApply = async () => { const handleAutoApply = async () => {
@@ -68,7 +87,7 @@ export function UtilitiesPage() {
} catch { } catch {
setFilterResult({ message: 'Failed to apply filter', ok: false }); setFilterResult({ message: 'Failed to apply filter', ok: false });
} }
setTimeout(() => { setApplyingFilter(null); setFilterResult(null); }, 2500); addTimer(2500, () => { setApplyingFilter(null); setFilterResult(null); });
}; };
const handleApplyAll = async () => { const handleApplyAll = async () => {
@@ -82,7 +101,7 @@ export function UtilitiesPage() {
} catch { } catch {
setApplyAllResult('Failed to apply settings'); setApplyAllResult('Failed to apply settings');
} }
setTimeout(() => { setApplyingAll(false); setApplyAllResult(null); }, 3000); addTimer(3000, () => { setApplyingAll(false); setApplyAllResult(null); });
}; };
const handleRaSave = async () => { const handleRaSave = async () => {
@@ -99,7 +118,7 @@ export function UtilitiesPage() {
} catch { } catch {
setRaResults({}); setRaResults({});
} }
setTimeout(() => setSaving(false), 500); addTimer(500, () => setSaving(false));
}; };
const emuLabels: Record<string, string> = { const emuLabels: Record<string, string> = {
@@ -376,7 +395,7 @@ export function UtilitiesPage() {
if (!window.confirm('Are you sure you want to clear all game covers? This cannot be undone.')) return; if (!window.confirm('Are you sure you want to clear all game covers? This cannot be undone.')) return;
setClearingCovers(true); setClearingCovers(true);
await window.omni.game.clearCoverCache(); await window.omni.game.clearCoverCache();
setTimeout(() => setClearingCovers(false), 1500); addTimer(1500, () => setClearingCovers(false));
}} }}
> >
{clearingCovers ? 'Cleared!' : 'Clear Covers'} {clearingCovers ? 'Cleared!' : 'Clear Covers'}
+384 -677
View File
File diff suppressed because it is too large Load Diff
+5
View File
@@ -11,6 +11,7 @@ import type {
InstallProgress, InstallProgress,
ConfigPreset, ConfigPreset,
EmulatorSaves, EmulatorSaves,
BackupEntry,
SyncthingStatus, SyncthingStatus,
SyncthingPendingDevice, SyncthingPendingDevice,
SyncthingPendingFolder, SyncthingPendingFolder,
@@ -89,6 +90,9 @@ declare global {
list: () => Promise<EmulatorSaves[]>; list: () => Promise<EmulatorSaves[]>;
delete: (filePath: string) => Promise<boolean>; delete: (filePath: string) => Promise<boolean>;
backup: (filePath: string) => Promise<string | null>; backup: (filePath: string) => Promise<string | null>;
listBackups: () => Promise<BackupEntry[]>;
restore: (backupPath: string) => Promise<boolean>;
openBackupFolder: () => Promise<boolean>;
openFolder: (folderPath: string) => Promise<boolean>; openFolder: (folderPath: string) => Promise<boolean>;
selectDirectory: () => Promise<string | null>; selectDirectory: () => Promise<string | null>;
setDirectory: (emulatorId: string, dir: string) => Promise<boolean>; setDirectory: (emulatorId: string, dir: string) => Promise<boolean>;
@@ -124,6 +128,7 @@ declare global {
guessPath: (label: string) => Promise<string | null>; guessPath: (label: string) => Promise<string | null>;
emulatorDirs: () => Promise<{ id: string; name: string; saves: string | null }[]>; emulatorDirs: () => Promise<{ id: string; name: string; saves: string | null }[]>;
toggleFolderSync: (emuId: string, sync: boolean) => Promise<boolean>; toggleFolderSync: (emuId: string, sync: boolean) => Promise<boolean>;
uninstall: () => Promise<boolean>;
onInstallProgress: (cb: (progress: { stage: string; percent: number; message: string }) => void) => () => void; onInstallProgress: (cb: (progress: { stage: string; percent: number; message: string }) => void) => () => void;
}; };
app: { app: {
+9
View File
@@ -29,6 +29,8 @@ export interface EmulatorConfig {
/** Package manager names for auto-install via system pkg manager */ /** Package manager names for auto-install via system pkg manager */
packageNames?: Partial<Record<Platform, string>>; packageNames?: Partial<Record<Platform, string>>;
supported: boolean; supported: boolean;
/** Whether this emulator is in beta / experimental */
beta?: boolean;
/** URL to fetch recommended config presets from */ /** URL to fetch recommended config presets from */
presetUrl?: string; presetUrl?: string;
/** Website URL for manual/fallback */ /** Website URL for manual/fallback */
@@ -230,6 +232,13 @@ export interface EmulatorSaves {
saves: SaveEntry[]; saves: SaveEntry[];
} }
export interface BackupEntry {
backupPath: string;
originalName: string;
backupTime: string;
fileSize: number;
}
export interface SyncthingStatus { export interface SyncthingStatus {
installed: boolean; installed: boolean;
running: boolean; running: boolean;