mirror of
https://github.com/mileswolfallen2/OmniEmu2.0.git
synced 2026-09-08 11:23:17 +00:00
update
This commit is contained in:
+25
-5
@@ -20,6 +20,8 @@ Browse, back up, and delete game saves across all your emulators.
|
||||
- RetroArch saves with recursive core-directory scanning
|
||||
- Custom save directories per emulator
|
||||
- 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
|
||||
Five new emulators added to the library:
|
||||
@@ -65,9 +67,12 @@ Visual theme picker with live color swatches:
|
||||
Organized emulator list with filter tabs:
|
||||
- **All** — every emulator, frontend, and decomp
|
||||
- **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)
|
||||
|
||||
### 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
|
||||
Search and set custom cover art from SteamGridDB's free API.
|
||||
- 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
|
||||
- **CSS accent colors** — replaced hardcoded colors with `color-mix()` using theme variables
|
||||
- **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)
|
||||
RetroArch, Dolphin, RPCS3, Cemu, xemu, Vita3K, Azahar, Eden, PPSSPP, DuckStation, PCSX2, Flycast, Yuzu (legacy), mGBA, MelonDS, Mednafen, ES-DE, NeoStation, Pegasus, EmuBuddy, and more.
|
||||
## Supported Emulators (20+ total)
|
||||
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.
|
||||
|
||||
Happy gaming! 🎮✨
|
||||
Happy gaming!
|
||||
|
||||
+1
-1
@@ -20,7 +20,7 @@
|
||||
"package:win": "npm run build && npx electron-builder --win",
|
||||
"package:linux": "npm run build && npx electron-builder --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"
|
||||
},
|
||||
"author": "fedl team <[email protected]>",
|
||||
|
||||
+27
-8
@@ -1,7 +1,7 @@
|
||||
import { existsSync, mkdirSync, readFileSync, rmSync, readdirSync, writeFileSync } from 'fs';
|
||||
import { join, extname } from 'path';
|
||||
import { existsSync, mkdirSync, readFileSync, rmSync, readdirSync, writeFileSync, copyFileSync } from 'fs';
|
||||
import { join, extname, basename } from 'path';
|
||||
import { app, shell } from 'electron';
|
||||
import { exec } from 'child_process';
|
||||
import { spawn, execSync } from 'child_process';
|
||||
import { DecompProject, DecompState, Platform } from '../shared/types';
|
||||
import { getPlatform } from './platform';
|
||||
import { settings } from './settings';
|
||||
@@ -442,8 +442,30 @@ export function launchDecomp(id: string): boolean {
|
||||
const state = checkDecomp(id);
|
||||
if (!state.installed || !state.path) return false;
|
||||
|
||||
const child = exec(`"${state.path}"`, { cwd: getDecompDir(id) });
|
||||
if (child) child.unref();
|
||||
const installDir = getDecompDir(id);
|
||||
|
||||
// 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;
|
||||
}
|
||||
@@ -632,9 +654,6 @@ async function extractArchive(
|
||||
onProgress: (p: DecompInstallProgress) => void,
|
||||
decompId: string,
|
||||
): Promise<void> {
|
||||
const { execSync } = require('child_process');
|
||||
const platform = process.platform;
|
||||
|
||||
try {
|
||||
if (archiveName.endsWith('.zip')) {
|
||||
onProgress({ decompId, stage: 'extracting', percent: 75, message: 'Extracting zip archive...' });
|
||||
|
||||
+21
-3
@@ -15,6 +15,7 @@ import { applyCachedCovers } from './scraper';
|
||||
|
||||
const platform = getPlatform();
|
||||
const arch = getArch();
|
||||
const FRONTEND_IDS = new Set(['esde', 'neostation', 'pegasus']);
|
||||
|
||||
export const knownEmulators: EmulatorConfig[] = [
|
||||
{
|
||||
@@ -482,6 +483,7 @@ export const knownEmulators: EmulatorConfig[] = [
|
||||
],
|
||||
},
|
||||
supported: true,
|
||||
beta: true,
|
||||
websiteUrl: {
|
||||
win32: 'https://es-de.org/download/',
|
||||
darwin: 'https://es-de.org/download/',
|
||||
@@ -524,6 +526,7 @@ export const knownEmulators: EmulatorConfig[] = [
|
||||
],
|
||||
},
|
||||
supported: true,
|
||||
beta: true,
|
||||
websiteUrl: {
|
||||
win32: 'https://neostation.dev/downloads/',
|
||||
darwin: 'https://neostation.dev/downloads/',
|
||||
@@ -565,6 +568,7 @@ export const knownEmulators: EmulatorConfig[] = [
|
||||
],
|
||||
},
|
||||
supported: true,
|
||||
beta: true,
|
||||
websiteUrl: {
|
||||
win32: 'https://github.com/computerex/EmuBuddy',
|
||||
darwin: 'https://github.com/computerex/EmuBuddy',
|
||||
@@ -607,6 +611,7 @@ export const knownEmulators: EmulatorConfig[] = [
|
||||
],
|
||||
},
|
||||
supported: true,
|
||||
beta: true,
|
||||
websiteUrl: {
|
||||
win32: 'https://pegasus-frontend.org/download/',
|
||||
darwin: 'https://pegasus-frontend.org/download/',
|
||||
@@ -683,6 +688,7 @@ export const knownEmulators: EmulatorConfig[] = [
|
||||
],
|
||||
},
|
||||
supported: true,
|
||||
beta: true,
|
||||
websiteUrl: {
|
||||
win32: 'https://xemu.app/',
|
||||
darwin: 'https://xemu.app/',
|
||||
@@ -727,6 +733,7 @@ export const knownEmulators: EmulatorConfig[] = [
|
||||
],
|
||||
},
|
||||
supported: true,
|
||||
beta: true,
|
||||
websiteUrl: {
|
||||
win32: 'https://vita3k.org/',
|
||||
darwin: 'https://vita3k.org/',
|
||||
@@ -765,6 +772,7 @@ export const knownEmulators: EmulatorConfig[] = [
|
||||
],
|
||||
},
|
||||
supported: true,
|
||||
beta: true,
|
||||
websiteUrl: {
|
||||
win32: 'https://azahar-emu.org/',
|
||||
darwin: 'https://azahar-emu.org/',
|
||||
@@ -791,6 +799,7 @@ export const knownEmulators: EmulatorConfig[] = [
|
||||
],
|
||||
},
|
||||
supported: true,
|
||||
beta: true,
|
||||
websiteUrl: {
|
||||
win32: 'https://www.pj64-emu.com/',
|
||||
darwin: 'https://www.pj64-emu.com/',
|
||||
@@ -940,6 +949,7 @@ export const knownEmulators: EmulatorConfig[] = [
|
||||
},
|
||||
packageNames: { linux: 'mednafen', darwin: 'mednafen' },
|
||||
supported: true,
|
||||
beta: true,
|
||||
websiteUrl: {
|
||||
win32: 'https://mednafen.github.io/',
|
||||
darwin: 'https://mednafen.github.io/',
|
||||
@@ -967,7 +977,7 @@ function detectEmulatorPath(config: EmulatorConfig): string | undefined {
|
||||
if (existsSync(omniEmuDir)) {
|
||||
try {
|
||||
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();
|
||||
if (result) return result.split('\n')[0];
|
||||
} catch {
|
||||
@@ -1130,7 +1140,8 @@ function detectVersion(binaryPath: string): string | undefined {
|
||||
if (isWindows()) {
|
||||
try {
|
||||
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();
|
||||
if (out) return out;
|
||||
} catch { /* ignore */ }
|
||||
@@ -1174,7 +1185,14 @@ export function checkEmulator(id: string): 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 {
|
||||
|
||||
+10
-4
@@ -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 { existsSync } from 'fs';
|
||||
import { registerIpcHandlers } from './ipc';
|
||||
@@ -7,7 +7,7 @@ import { isMacOS, isLinux } from './platform';
|
||||
import { ensureRomsStructure } from './emulators';
|
||||
import { setupAutoUpdater } from './updater';
|
||||
import { generatePegasusCollectionsForRomDir } from './configurator';
|
||||
import { startSyncthing } from './syncthing';
|
||||
import { startSyncthing, stopSyncthing } from './syncthing';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Steam Deck / SteamOS (Gamescope) compatibility
|
||||
@@ -190,7 +190,7 @@ if (!gotLock) {
|
||||
}
|
||||
|
||||
app.whenReady().then(() => {
|
||||
ensureRomsStructure();
|
||||
try { ensureRomsStructure(); } catch (err) { console.error('ensureRomsStructure failed:', err); }
|
||||
setupAutoUpdater();
|
||||
registerIpcHandlers();
|
||||
|
||||
@@ -202,7 +202,12 @@ if (!gotLock) {
|
||||
console.error('Window creation failed, retrying without GPU:', err);
|
||||
app.commandLine.appendSwitch('disable-gpu');
|
||||
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();
|
||||
@@ -232,4 +237,5 @@ app.on('window-all-closed', () => {
|
||||
|
||||
app.on('before-quit', () => {
|
||||
tray = null;
|
||||
try { stopSyncthing(); } catch { /* ignore */ }
|
||||
});
|
||||
|
||||
@@ -24,7 +24,8 @@ function tempName(suffix: string): string {
|
||||
|
||||
function downloadFile(url: string, dest: string, onProgress: (pct: number) => void): Promise<void> {
|
||||
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 opts: RequestOptions = {
|
||||
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) => {
|
||||
const code = response.statusCode ?? 500;
|
||||
if (code >= 300 && code < 400 && response.headers.location) {
|
||||
doRequest(response.headers.location);
|
||||
doRequest(response.headers.location, redirects + 1);
|
||||
return;
|
||||
}
|
||||
if (code !== 200) {
|
||||
|
||||
+37
-21
@@ -35,7 +35,7 @@ import { addRecentGame, parseGameTitle, buildScrapeTitle, findValidThumbnail, ca
|
||||
import { getGameAchievements, raSupportedPlatforms } from './ra';
|
||||
import { FILTER_PRESETS, applyFilterPreset } from './filters';
|
||||
import { scanBiosDirectory, getKnownBiosList, getDefaultBiosDir, updateRetroarchBiosPath } from './bios';
|
||||
import { listAllSaves, deleteSave, backupSave } from './saveManager';
|
||||
import { listAllSaves, deleteSave, backupSave, listBackups, restoreBackup, openBackupFolder } from './saveManager';
|
||||
import {
|
||||
installSyncthing,
|
||||
startSyncthing,
|
||||
@@ -61,7 +61,11 @@ export function registerIpcHandlers(): void {
|
||||
// Emulators
|
||||
ipcMain.handle('emulators:list', () => {
|
||||
const plat = getPlatform();
|
||||
const s = settings.get();
|
||||
const includeFrontends = s.frontendSupport;
|
||||
const FRONTEND_IDS = new Set(['esde', 'neostation', 'pegasus']);
|
||||
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 hasPackage = !!e.packageNames?.[plat];
|
||||
return hasDownload || hasPackage;
|
||||
@@ -97,32 +101,38 @@ export function registerIpcHandlers(): void {
|
||||
|
||||
// ES-DE on macOS: manual install (DMG requires license acceptance in Finder)
|
||||
if (emulatorId === 'esde' && getPlatform() === 'darwin') {
|
||||
const result = await dialog.showMessageBox(BrowserWindow.fromWebContents(event.sender)!, {
|
||||
type: 'info',
|
||||
title: 'Install ES-DE',
|
||||
message: 'ES-DE must be installed manually on macOS',
|
||||
detail: 'OmniEmu will open the ES-DE download page. Please download and install ES-DE, then return to OmniEmu and click Configure.',
|
||||
buttons: ['Open Download Page', 'Cancel'],
|
||||
defaultId: 0,
|
||||
});
|
||||
if (result.response === 0) {
|
||||
shell.openExternal('https://es-de.org/#Download');
|
||||
const win = BrowserWindow.fromWebContents(event.sender);
|
||||
if (win) {
|
||||
const result = await dialog.showMessageBox(win, {
|
||||
type: 'info',
|
||||
title: 'Install ES-DE',
|
||||
message: 'ES-DE must be installed manually on macOS',
|
||||
detail: 'OmniEmu will open the ES-DE download page. Please download and install ES-DE, then return to OmniEmu and click Configure.',
|
||||
buttons: ['Open Download Page', 'Cancel'],
|
||||
defaultId: 0,
|
||||
});
|
||||
if (result.response === 0) {
|
||||
shell.openExternal('https://es-de.org/#Download');
|
||||
}
|
||||
}
|
||||
return checkEmulator(emulatorId);
|
||||
}
|
||||
|
||||
// NeoStation on macOS: manual install (DMG requires license acceptance in Finder)
|
||||
if (emulatorId === 'neostation' && getPlatform() === 'darwin') {
|
||||
const result = await dialog.showMessageBox(BrowserWindow.fromWebContents(event.sender)!, {
|
||||
type: 'info',
|
||||
title: 'Install NeoStation',
|
||||
message: 'NeoStation must be installed manually on macOS',
|
||||
detail: 'OmniEmu will open the NeoStation download page. Please download and install NeoStation, then return to OmniEmu and click Configure.',
|
||||
buttons: ['Open Download Page', 'Cancel'],
|
||||
defaultId: 0,
|
||||
});
|
||||
if (result.response === 0) {
|
||||
shell.openExternal('https://neostation.dev/downloads/');
|
||||
const win = BrowserWindow.fromWebContents(event.sender);
|
||||
if (win) {
|
||||
const result = await dialog.showMessageBox(win, {
|
||||
type: 'info',
|
||||
title: 'Install NeoStation',
|
||||
message: 'NeoStation must be installed manually on macOS',
|
||||
detail: 'OmniEmu will open the NeoStation download page. Please download and install NeoStation, then return to OmniEmu and click Configure.',
|
||||
buttons: ['Open Download Page', 'Cancel'],
|
||||
defaultId: 0,
|
||||
});
|
||||
if (result.response === 0) {
|
||||
shell.openExternal('https://neostation.dev/downloads/');
|
||||
}
|
||||
}
|
||||
return checkEmulator(emulatorId);
|
||||
}
|
||||
@@ -458,6 +468,12 @@ export function registerIpcHandlers(): void {
|
||||
|
||||
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) => {
|
||||
shell.showItemInFolder(folderPath);
|
||||
return true;
|
||||
|
||||
@@ -167,6 +167,11 @@ const api = {
|
||||
ipcRenderer.invoke('saves:delete', filePath),
|
||||
backup: (filePath: string): Promise<string | null> =>
|
||||
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> =>
|
||||
ipcRenderer.invoke('saves:open-folder', folderPath),
|
||||
selectDirectory: (): Promise<string | null> =>
|
||||
|
||||
+73
-2
@@ -1,11 +1,11 @@
|
||||
import { existsSync, readdirSync, statSync, unlinkSync, copyFileSync, mkdirSync, readFileSync } from 'fs';
|
||||
import { join, extname, basename } from 'path';
|
||||
import { app } from 'electron';
|
||||
import { app, shell } from 'electron';
|
||||
import { homedir } from 'os';
|
||||
import { getPlatform } from './platform';
|
||||
import { settings } from './settings';
|
||||
import { knownEmulators } from './emulators';
|
||||
import { SaveEntry, EmulatorSaves } from '../shared/types';
|
||||
import { SaveEntry, EmulatorSaves, BackupEntry } from '../shared/types';
|
||||
|
||||
const platform = getPlatform();
|
||||
const home = homedir();
|
||||
@@ -344,3 +344,74 @@ export function getEmulatorSaveDirs() {
|
||||
saves: getSaveDir(e.id),
|
||||
})).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;
|
||||
}
|
||||
|
||||
@@ -238,14 +238,16 @@ export async function installSyncthing(
|
||||
|
||||
function downloadFile(
|
||||
url: string,
|
||||
onProgress?: (received: number, total: number) => void
|
||||
onProgress?: (received: number, total: number) => void,
|
||||
redirects = 0
|
||||
): Promise<Buffer> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (redirects > 10) { reject(new Error('Too many redirects')); return; }
|
||||
const https = require('https');
|
||||
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) {
|
||||
downloadFile(res.headers.location, onProgress).then(resolve, reject);
|
||||
downloadFile(res.headers.location, onProgress, redirects + 1).then(resolve, reject);
|
||||
return;
|
||||
}
|
||||
if (res.statusCode !== 200) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useState, useCallback } from 'react';
|
||||
import React, { useEffect, useState, useCallback, useRef } from 'react';
|
||||
|
||||
interface BiosEntry {
|
||||
emulators: string[];
|
||||
@@ -23,6 +23,9 @@ export function BiosCheckPanel({ biosDir, onBiosDirChange }: Props) {
|
||||
const [results, setResults] = useState<BiosCheckResult[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
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) => {
|
||||
setLoading(true);
|
||||
@@ -52,7 +55,8 @@ export function BiosCheckPanel({ biosDir, onBiosDirChange }: Props) {
|
||||
const configDir = homeDir + '/Library/Application Support/RetroArch';
|
||||
const ok = await window.omni.bios.configureRetroArch(configDir, biosDir);
|
||||
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 (
|
||||
|
||||
@@ -169,6 +169,7 @@ export function useGamepadNav(onNavigate: (page: Page) => void, currentPage: Pag
|
||||
raf = requestAnimationFrame(poll);
|
||||
return () => {
|
||||
cancelAnimationFrame(raf);
|
||||
if (legendTimer.current) clearTimeout(legendTimer.current);
|
||||
};
|
||||
}, [onNavigate, showLegendTemporarily]);
|
||||
|
||||
|
||||
@@ -51,6 +51,7 @@ export function ControllerPage() {
|
||||
const [emulators, setEmulators] = useState<EmulatorState[]>([]);
|
||||
const [configStatus, setConfigStatus] = useState<string>('');
|
||||
const rafRef = useRef<number>(0);
|
||||
const statusTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const poll = useCallback(() => {
|
||||
const gamepads = navigator.getGamepads();
|
||||
@@ -109,6 +110,7 @@ export function ControllerPage() {
|
||||
window.removeEventListener('gamepadconnected', onConnected);
|
||||
window.removeEventListener('gamepaddisconnected', onDisconnected);
|
||||
cancelAnimationFrame(rafRef.current);
|
||||
if (statusTimerRef.current) clearTimeout(statusTimerRef.current);
|
||||
};
|
||||
}, [poll]);
|
||||
|
||||
@@ -120,7 +122,8 @@ export function ControllerPage() {
|
||||
} catch (e: any) {
|
||||
setConfigStatus(`Error: ${e.message}`);
|
||||
}
|
||||
setTimeout(() => setConfigStatus(''), 4000);
|
||||
if (statusTimerRef.current) clearTimeout(statusTimerRef.current);
|
||||
statusTimerRef.current = setTimeout(() => setConfigStatus(''), 4000);
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -8,18 +8,23 @@ export function Dashboard({ onNavigate }: { onNavigate?: (tab: 'dashboard' | 'em
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
async function load() {
|
||||
const [states, info, recentGames] = await Promise.all([
|
||||
window.omni.emulators.states(),
|
||||
window.omni.system.info(),
|
||||
window.omni.game.recent(),
|
||||
]);
|
||||
setEmulators(states);
|
||||
setSystem(info);
|
||||
setRecent(recentGames || []);
|
||||
setLoading(false);
|
||||
try {
|
||||
const [states, info, recentGames] = await Promise.all([
|
||||
window.omni.emulators.states(),
|
||||
window.omni.system.info(),
|
||||
window.omni.game.recent(),
|
||||
]);
|
||||
if (cancelled) return;
|
||||
setEmulators(states);
|
||||
setSystem(info);
|
||||
setRecent(recentGames || []);
|
||||
} catch { /* ignore */ }
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
load();
|
||||
return () => { cancelled = true; };
|
||||
}, []);
|
||||
|
||||
const installed = emulators.filter((e) => e.installed).length;
|
||||
@@ -103,9 +108,11 @@ export function Dashboard({ onNavigate }: { onNavigate?: (tab: 'dashboard' | 'em
|
||||
tabIndex={0}
|
||||
role="button"
|
||||
onClick={async () => {
|
||||
await window.omni.game.launch(game.emulatorId, game.romPath);
|
||||
const updated = await window.omni.game.recent();
|
||||
setRecent(updated || []);
|
||||
try {
|
||||
await window.omni.game.launch(game.emulatorId, game.romPath);
|
||||
const updated = await window.omni.game.recent();
|
||||
setRecent(updated || []);
|
||||
} catch { /* ignore */ }
|
||||
}}
|
||||
>
|
||||
<div className="game-card-cover">
|
||||
|
||||
@@ -15,6 +15,7 @@ export function EmulatorsPage() {
|
||||
const [states, setStates] = useState<EmulatorState[]>([]);
|
||||
const [decompStates, setDecompStates] = useState<DecompState[]>([]);
|
||||
const [betaFeatures, setBetaFeatures] = useState(false);
|
||||
const [frontendSupport, setFrontendSupport] = useState(false);
|
||||
const [decompProjects, setDecompProjects] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [progress, setProgress] = useState<Record<string, InstallProgress>>({});
|
||||
@@ -25,15 +26,18 @@ export function EmulatorsPage() {
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
const [emuResult, decompResult, settings] = await Promise.all([
|
||||
window.omni.emulators.states(),
|
||||
window.omni.decomps.states(),
|
||||
window.omni.settings.get(),
|
||||
]);
|
||||
setStates(emuResult);
|
||||
setDecompStates(decompResult);
|
||||
setBetaFeatures(!!settings.betaFeatures);
|
||||
setDecompProjects(!!settings.decompProjects);
|
||||
try {
|
||||
const [emuResult, decompResult, settings] = await Promise.all([
|
||||
window.omni.emulators.states(),
|
||||
window.omni.decomps.states(),
|
||||
window.omni.settings.get(),
|
||||
]);
|
||||
setStates(emuResult);
|
||||
setDecompStates(decompResult);
|
||||
setBetaFeatures(!!settings.betaFeatures);
|
||||
setFrontendSupport(!!settings.frontendSupport);
|
||||
setDecompProjects(!!settings.decompProjects);
|
||||
} catch { /* ignore */ }
|
||||
setLoading(false);
|
||||
}, []);
|
||||
|
||||
@@ -73,7 +77,8 @@ export function EmulatorsPage() {
|
||||
...prev,
|
||||
[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();
|
||||
};
|
||||
|
||||
@@ -83,10 +88,13 @@ export function EmulatorsPage() {
|
||||
...prev,
|
||||
[id]: { emulatorId: id, stage: 'downloading', percent: 0, message: 'Starting...' },
|
||||
}));
|
||||
const result = await window.omni.emulators.install(id);
|
||||
if (result.installed && result.path) {
|
||||
await window.omni.emulators.configure(id, result.path);
|
||||
}
|
||||
try {
|
||||
const result = await window.omni.emulators.install(id);
|
||||
if (result.installed && result.path) {
|
||||
await window.omni.emulators.configure(id, result.path);
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
setActioning(null);
|
||||
await load();
|
||||
};
|
||||
|
||||
@@ -100,7 +108,7 @@ export function EmulatorsPage() {
|
||||
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) => ({
|
||||
...prev,
|
||||
[state.config.id]: {
|
||||
@@ -115,20 +123,20 @@ export function EmulatorsPage() {
|
||||
|
||||
const handleOpen = async (id: string) => {
|
||||
setActioning(id);
|
||||
await window.omni.emulators.launch(id);
|
||||
try { await window.omni.emulators.launch(id); } catch { /* ignore */ }
|
||||
setActioning(null);
|
||||
};
|
||||
|
||||
const handleUninstall = async (id: string) => {
|
||||
if (!confirm(`Uninstall ${id} and remove all its files?`)) return;
|
||||
setActioning(id);
|
||||
await window.omni.emulators.uninstall(id);
|
||||
try { await window.omni.emulators.uninstall(id); } catch { /* ignore */ }
|
||||
setActioning(null);
|
||||
await load();
|
||||
};
|
||||
|
||||
const handleOpenWebsite = async (id: string) => {
|
||||
await window.omni.emulators.openWebsite(id);
|
||||
try { await window.omni.emulators.openWebsite(id); } catch { /* ignore */ }
|
||||
};
|
||||
|
||||
// ── Decomp handlers ───────────────────────────────────────
|
||||
@@ -139,31 +147,33 @@ export function EmulatorsPage() {
|
||||
...prev,
|
||||
[id]: { stage: 'downloading', percent: 0, message: 'Starting...' },
|
||||
}));
|
||||
await window.omni.decomps.install(id);
|
||||
try { await window.omni.decomps.install(id); } catch { /* ignore */ }
|
||||
await load();
|
||||
};
|
||||
|
||||
const handleDecompUninstall = async (id: string) => {
|
||||
if (!confirm(`Uninstall this port and remove all its files?`)) return;
|
||||
setActioning(id);
|
||||
await window.omni.decomps.uninstall(id);
|
||||
try { await window.omni.decomps.uninstall(id); } catch { /* ignore */ }
|
||||
setActioning(null);
|
||||
await load();
|
||||
};
|
||||
|
||||
const handleDecompLaunch = async (id: string) => {
|
||||
setActioning(id);
|
||||
await window.omni.decomps.launch(id);
|
||||
try { await window.omni.decomps.launch(id); } catch { /* ignore */ }
|
||||
setActioning(null);
|
||||
};
|
||||
|
||||
const handleDecompSelectRom = async (id: string) => {
|
||||
const result = await window.omni.decomps.selectRom(id);
|
||||
if (result.state) {
|
||||
setDecompStates((prev) =>
|
||||
prev.map((d) => (d.config.id === id ? result.state : d))
|
||||
);
|
||||
}
|
||||
try {
|
||||
const result = await window.omni.decomps.selectRom(id);
|
||||
if (result.state) {
|
||||
setDecompStates((prev) =>
|
||||
prev.map((d) => (d.config.id === id ? result.state : d))
|
||||
);
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
};
|
||||
|
||||
const handleRefresh = () => load();
|
||||
@@ -174,16 +184,23 @@ export function EmulatorsPage() {
|
||||
|
||||
const currentProgress = (id: string) => progress[id];
|
||||
|
||||
const emulators = 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 installedCount = states.filter((s) => s.installed).length;
|
||||
const configuredCount = states.filter((s) => s.configured).length;
|
||||
const allEmulators = states.filter((s) => s.config.id !== 'emubuddy' && !FRONTEND_IDS.has(s.config.id));
|
||||
const allFrontends = states.filter((s) => FRONTEND_IDS.has(s.config.id) || s.config.id === 'emubuddy');
|
||||
|
||||
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'
|
||||
? emulators
|
||||
: activeTab === 'frontends'
|
||||
? frontends
|
||||
: states;
|
||||
: activeTab === 'decomps'
|
||||
? []
|
||||
: visibleStates;
|
||||
|
||||
const showDecomps = decompProjects && (activeTab === 'all' || activeTab === 'decomps');
|
||||
|
||||
@@ -208,7 +225,7 @@ export function EmulatorsPage() {
|
||||
onClick={() => setActiveTab('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
|
||||
className={`page-tab ${activeTab === 'emulators' ? 'active' : ''}`}
|
||||
@@ -217,13 +234,15 @@ export function EmulatorsPage() {
|
||||
Emulators
|
||||
<span className="page-tab-count">{emulators.length}</span>
|
||||
</button>
|
||||
<button
|
||||
className={`page-tab ${activeTab === 'frontends' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('frontends')}
|
||||
>
|
||||
Frontends
|
||||
<span className="page-tab-count">{frontends.length}</span>
|
||||
</button>
|
||||
{frontendSupport && (
|
||||
<button
|
||||
className={`page-tab ${activeTab === 'frontends' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('frontends')}
|
||||
>
|
||||
Frontends
|
||||
<span className="page-tab-count">{frontends.length}</span>
|
||||
</button>
|
||||
)}
|
||||
{decompProjects && (
|
||||
<button
|
||||
className={`page-tab ${activeTab === 'decomps' ? 'active' : ''}`}
|
||||
@@ -244,7 +263,7 @@ export function EmulatorsPage() {
|
||||
return (
|
||||
<div className="card" key={state.config.id}>
|
||||
<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
|
||||
className={`badge ${
|
||||
!state.config.supported
|
||||
@@ -407,13 +426,15 @@ export function EmulatorsPage() {
|
||||
|
||||
{/* ── Decompilations Section ───────────────────────── */}
|
||||
{showDecomps && (<>
|
||||
<div style={{ marginTop: 40 }}>
|
||||
<div className="info-bar" style={{ marginBottom: 16 }}>
|
||||
<h2 style={{ fontSize: 20, fontWeight: 600 }}>Decompilations <span className="badge-beta">Beta</span></h2>
|
||||
<span className="text-sm text-muted">
|
||||
Native PC ports built from reverse-engineered source code — bring your own ROM
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ marginTop: activeTab === 'decomps' ? 0 : 40 }}>
|
||||
{activeTab !== 'decomps' && (
|
||||
<div className="info-bar" style={{ marginBottom: 16 }}>
|
||||
<h2 style={{ fontSize: 20, fontWeight: 600 }}>Decompilations <span className="badge-beta">Beta</span></h2>
|
||||
<span className="text-sm text-muted">
|
||||
Native PC ports built from reverse-engineered source code — bring your own ROM
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="card-grid">
|
||||
{decompStates.map((decomp) => {
|
||||
|
||||
@@ -19,20 +19,28 @@ export function LibraryPage() {
|
||||
const [selectedGame, setSelectedGame] = useState<GameEntry | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
const settings = await window.omni.settings.get();
|
||||
setDecompProjects(!!settings.decompProjects);
|
||||
if (settings.romsDirectory) {
|
||||
setRomsDir(settings.romsDirectory);
|
||||
setLoading(true);
|
||||
const results = await window.omni.roms.scan(settings.romsDirectory);
|
||||
setGames(await scrapeMissingArt(results));
|
||||
setLoading(false);
|
||||
}
|
||||
// Load installed decomps
|
||||
const decompStates = await window.omni.decomps.states();
|
||||
setDecompGames(decompStates.filter(d => d.installed));
|
||||
try {
|
||||
const settings = await window.omni.settings.get();
|
||||
if (cancelled) return;
|
||||
setDecompProjects(!!settings.decompProjects);
|
||||
if (settings.romsDirectory) {
|
||||
setRomsDir(settings.romsDirectory);
|
||||
setLoading(true);
|
||||
const results = await window.omni.roms.scan(settings.romsDirectory);
|
||||
if (cancelled) return;
|
||||
setGames(await scrapeMissingArt(results));
|
||||
setLoading(false);
|
||||
}
|
||||
// Load installed decomps
|
||||
const decompStates = await window.omni.decomps.states();
|
||||
if (cancelled) return;
|
||||
setDecompGames(decompStates.filter(d => d.installed));
|
||||
} catch { /* ignore */ }
|
||||
if (!cancelled) setLoading(false);
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
}, []);
|
||||
|
||||
const scrapeMissingArt = async (list: GameEntry[]): Promise<GameEntry[]> => {
|
||||
@@ -52,9 +60,11 @@ export function LibraryPage() {
|
||||
const scanDirectory = useCallback(async (dir: string) => {
|
||||
setRomsDir(dir);
|
||||
setLoading(true);
|
||||
await window.omni.settings.save({ romsDirectory: dir });
|
||||
const results = await window.omni.roms.scan(dir);
|
||||
setGames(await scrapeMissingArt(results));
|
||||
try {
|
||||
await window.omni.settings.save({ romsDirectory: dir });
|
||||
const results = await window.omni.roms.scan(dir);
|
||||
setGames(await scrapeMissingArt(results));
|
||||
} catch { /* ignore */ }
|
||||
setLoading(false);
|
||||
}, []);
|
||||
|
||||
@@ -65,12 +75,12 @@ export function LibraryPage() {
|
||||
}, [scanDirectory]);
|
||||
|
||||
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) => {
|
||||
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 }}>
|
||||
<button className="btn btn-secondary btn-sm" onClick={async () => {
|
||||
setLoading(true);
|
||||
setGames(await scrapeMissingArt(games));
|
||||
try { setGames(await scrapeMissingArt(games)); } catch { /* ignore */ }
|
||||
setLoading(false);
|
||||
}}>
|
||||
Scrape All Art
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useEffect, useState, useCallback } from 'react';
|
||||
import type { GameEntry, EmulatorSaves, SaveEntry, SyncthingFolder } from '../../shared/types';
|
||||
import React, { useEffect, useState, useCallback, useRef } from 'react';
|
||||
import type { GameEntry, EmulatorSaves, SaveEntry, SyncthingFolder, BackupEntry } from '../../shared/types';
|
||||
|
||||
function formatSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
@@ -47,30 +47,41 @@ export function SaveManagerPage() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [expandedGame, setExpandedGame] = useState<string | null>(null);
|
||||
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 [cloudEnabled, setCloudEnabled] = useState(false);
|
||||
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 () => {
|
||||
setLoading(true);
|
||||
const [settings, saves] = await Promise.all([
|
||||
window.omni.settings.get(),
|
||||
window.omni.saves.list(),
|
||||
]);
|
||||
setAllSaves(saves);
|
||||
if (settings.romsDirectory) {
|
||||
const roms = await window.omni.roms.scan(settings.romsDirectory);
|
||||
setGames(roms);
|
||||
}
|
||||
// Detect cloud sync: either the flag is set, or Syncthing is actually running
|
||||
let cloudOk = !!settings.cloudSyncEnabled;
|
||||
try {
|
||||
const cloudStatus = await window.omni.cloud.status();
|
||||
setSyncFolders(cloudStatus.folders || []);
|
||||
if (cloudStatus.running) cloudOk = true;
|
||||
const [settings, saves] = await Promise.all([
|
||||
window.omni.settings.get(),
|
||||
window.omni.saves.list(),
|
||||
]);
|
||||
setAllSaves(saves);
|
||||
if (settings.romsDirectory) {
|
||||
const roms = await window.omni.roms.scan(settings.romsDirectory);
|
||||
setGames(roms);
|
||||
}
|
||||
// Detect cloud sync: either the flag is set, or Syncthing is actually running
|
||||
let cloudOk = !!settings.cloudSyncEnabled;
|
||||
try {
|
||||
const cloudStatus = await window.omni.cloud.status();
|
||||
setSyncFolders(cloudStatus.folders || []);
|
||||
if (cloudStatus.running) cloudOk = true;
|
||||
} catch { /* ignore */ }
|
||||
setCloudEnabled(cloudOk);
|
||||
try {
|
||||
const backupList = await window.omni.saves.listBackups();
|
||||
setBackups(backupList);
|
||||
} catch { /* ignore */ }
|
||||
} catch { /* ignore */ }
|
||||
setCloudEnabled(cloudOk);
|
||||
setLoading(false);
|
||||
}, []);
|
||||
|
||||
@@ -78,7 +89,8 @@ export function SaveManagerPage() {
|
||||
|
||||
const showStatus = (msg: string) => {
|
||||
setStatus(msg);
|
||||
setTimeout(() => setStatus(''), 3000);
|
||||
if (statusTimer.current) clearTimeout(statusTimer.current);
|
||||
statusTimer.current = setTimeout(() => setStatus(''), 3000);
|
||||
};
|
||||
|
||||
const allSaveFiles = allSaves.flatMap(e => e.saves);
|
||||
@@ -149,6 +161,27 @@ export function SaveManagerPage() {
|
||||
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 (
|
||||
<div>
|
||||
<div className="info-bar mb-4" style={{ justifyContent: 'space-between' }}>
|
||||
@@ -166,6 +199,12 @@ export function SaveManagerPage() {
|
||||
>
|
||||
By Emulator
|
||||
</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}>
|
||||
Refresh
|
||||
</button>
|
||||
@@ -311,6 +350,64 @@ export function SaveManagerPage() {
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -67,41 +67,46 @@ export function SettingsPage() {
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
Promise.all([
|
||||
window.omni.settings.get(),
|
||||
window.omni.emulators.systemAssignments(),
|
||||
window.omni.emulators.list(),
|
||||
]).then(([s, assignments, emulators]) => {
|
||||
if (cancelled) return;
|
||||
setSettings(s);
|
||||
setSystemAssignments(assignments);
|
||||
const nameMap: Record<string, string> = {};
|
||||
for (const e of emulators) nameMap[e.id] = e.name;
|
||||
setEmuNameMap(nameMap);
|
||||
});
|
||||
}).catch(() => { if (!cancelled) setSettings({} as AppSettings); });
|
||||
return () => { cancelled = true; };
|
||||
}, []);
|
||||
|
||||
const update = async (partial: Partial<AppSettings>) => {
|
||||
if (!settings) return;
|
||||
setSaving(true);
|
||||
const updated = await window.omni.settings.save(partial);
|
||||
setSettings(updated);
|
||||
try {
|
||||
const updated = await window.omni.settings.save(partial);
|
||||
setSettings(updated);
|
||||
} catch { /* ignore */ }
|
||||
setSaving(false);
|
||||
};
|
||||
|
||||
const handleCheckUpdates = async () => {
|
||||
setChecking(true);
|
||||
setUpdateInfo(null);
|
||||
await window.omni.updates.check();
|
||||
try { await window.omni.updates.check(); } catch { setChecking(false); }
|
||||
};
|
||||
|
||||
const handleDownloadUpdate = async () => {
|
||||
setDownloading(true);
|
||||
setDownloadProgress(0);
|
||||
await window.omni.updates.download();
|
||||
try { await window.omni.updates.download(); } catch { setDownloading(false); }
|
||||
};
|
||||
|
||||
const handleQuitAndInstall = async () => {
|
||||
await window.omni.updates.quitAndInstall();
|
||||
try { await window.omni.updates.quitAndInstall(); } catch { /* app is restarting */ }
|
||||
};
|
||||
|
||||
if (!settings || !systemAssignments) {
|
||||
@@ -407,7 +412,7 @@ export function SettingsPage() {
|
||||
onClick={async () => {
|
||||
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;
|
||||
await window.omni.app.nukeData();
|
||||
try { await window.omni.app.nukeData(); } catch { /* ignore */ }
|
||||
window.location.reload();
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
|
||||
interface FilterPreset {
|
||||
id: string;
|
||||
@@ -24,6 +24,25 @@ export function UtilitiesPage() {
|
||||
const [filterResult, setFilterResult] = useState<{ message: string; ok: boolean } | null>(null);
|
||||
const [applyingAll, setApplyingAll] = useState(false);
|
||||
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(() => {
|
||||
window.omni.settings.get().then((s) => {
|
||||
@@ -31,14 +50,14 @@ export function UtilitiesPage() {
|
||||
setRaPassword(s.retroAchievementsPassword || '');
|
||||
setRaApiKey(s.retroAchievementsApiKey || '');
|
||||
setSgdbKey(s.steamGridDbApiKey || '');
|
||||
});
|
||||
window.omni.filters.list().then(setFilterPresets);
|
||||
}).catch(() => { /* ignore */ });
|
||||
window.omni.filters.list().then(setFilterPresets).catch(() => { /* ignore */ });
|
||||
}, []);
|
||||
|
||||
const handleRecreate = async () => {
|
||||
setRegenerating(true);
|
||||
await window.omni.utilities.regenerateRomsStructure();
|
||||
setTimeout(() => setRegenerating(false), 1500);
|
||||
addTimer(1500, () => setRegenerating(false));
|
||||
};
|
||||
|
||||
const handleAutoApply = async () => {
|
||||
@@ -68,7 +87,7 @@ export function UtilitiesPage() {
|
||||
} catch {
|
||||
setFilterResult({ message: 'Failed to apply filter', ok: false });
|
||||
}
|
||||
setTimeout(() => { setApplyingFilter(null); setFilterResult(null); }, 2500);
|
||||
addTimer(2500, () => { setApplyingFilter(null); setFilterResult(null); });
|
||||
};
|
||||
|
||||
const handleApplyAll = async () => {
|
||||
@@ -82,7 +101,7 @@ export function UtilitiesPage() {
|
||||
} catch {
|
||||
setApplyAllResult('Failed to apply settings');
|
||||
}
|
||||
setTimeout(() => { setApplyingAll(false); setApplyAllResult(null); }, 3000);
|
||||
addTimer(3000, () => { setApplyingAll(false); setApplyAllResult(null); });
|
||||
};
|
||||
|
||||
const handleRaSave = async () => {
|
||||
@@ -99,7 +118,7 @@ export function UtilitiesPage() {
|
||||
} catch {
|
||||
setRaResults({});
|
||||
}
|
||||
setTimeout(() => setSaving(false), 500);
|
||||
addTimer(500, () => setSaving(false));
|
||||
};
|
||||
|
||||
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;
|
||||
setClearingCovers(true);
|
||||
await window.omni.game.clearCoverCache();
|
||||
setTimeout(() => setClearingCovers(false), 1500);
|
||||
addTimer(1500, () => setClearingCovers(false));
|
||||
}}
|
||||
>
|
||||
{clearingCovers ? 'Cleared!' : 'Clear Covers'}
|
||||
|
||||
+384
-677
File diff suppressed because it is too large
Load Diff
@@ -11,6 +11,7 @@ import type {
|
||||
InstallProgress,
|
||||
ConfigPreset,
|
||||
EmulatorSaves,
|
||||
BackupEntry,
|
||||
SyncthingStatus,
|
||||
SyncthingPendingDevice,
|
||||
SyncthingPendingFolder,
|
||||
@@ -89,6 +90,9 @@ declare global {
|
||||
list: () => Promise<EmulatorSaves[]>;
|
||||
delete: (filePath: string) => Promise<boolean>;
|
||||
backup: (filePath: string) => Promise<string | null>;
|
||||
listBackups: () => Promise<BackupEntry[]>;
|
||||
restore: (backupPath: string) => Promise<boolean>;
|
||||
openBackupFolder: () => Promise<boolean>;
|
||||
openFolder: (folderPath: string) => Promise<boolean>;
|
||||
selectDirectory: () => Promise<string | null>;
|
||||
setDirectory: (emulatorId: string, dir: string) => Promise<boolean>;
|
||||
@@ -124,6 +128,7 @@ declare global {
|
||||
guessPath: (label: string) => Promise<string | null>;
|
||||
emulatorDirs: () => Promise<{ id: string; name: string; saves: string | null }[]>;
|
||||
toggleFolderSync: (emuId: string, sync: boolean) => Promise<boolean>;
|
||||
uninstall: () => Promise<boolean>;
|
||||
onInstallProgress: (cb: (progress: { stage: string; percent: number; message: string }) => void) => () => void;
|
||||
};
|
||||
app: {
|
||||
|
||||
@@ -29,6 +29,8 @@ export interface EmulatorConfig {
|
||||
/** Package manager names for auto-install via system pkg manager */
|
||||
packageNames?: Partial<Record<Platform, string>>;
|
||||
supported: boolean;
|
||||
/** Whether this emulator is in beta / experimental */
|
||||
beta?: boolean;
|
||||
/** URL to fetch recommended config presets from */
|
||||
presetUrl?: string;
|
||||
/** Website URL for manual/fallback */
|
||||
@@ -230,6 +232,13 @@ export interface EmulatorSaves {
|
||||
saves: SaveEntry[];
|
||||
}
|
||||
|
||||
export interface BackupEntry {
|
||||
backupPath: string;
|
||||
originalName: string;
|
||||
backupTime: string;
|
||||
fileSize: number;
|
||||
}
|
||||
|
||||
export interface SyncthingStatus {
|
||||
installed: boolean;
|
||||
running: boolean;
|
||||
|
||||
Reference in New Issue
Block a user