feat: add BIOS management and controller configuration features

- Implemented BIOS scanning and configuration functionality.
- Added support for managing recent games and scraping game art.
- Introduced a new Controller page for configuring gamepad settings.
- Enhanced the Dashboard to display recent games and quick actions.
- Updated the Emulators page to include launch and uninstall options.
- Improved the Settings page to manage BIOS directory and configurations.
- Added a gamepad navigation hook for better controller support.
- Refactored styles to accommodate new UI components and features.
This commit is contained in:
2026-07-09 03:31:59 -05:00
parent 59cd586e3b
commit 3ae0b812c9
20 changed files with 1532 additions and 134 deletions
-17
View File
@@ -57,21 +57,4 @@ jobs:
path: release/*.dmg
if-no-files-found: ignore
release:
if: startsWith(github.ref, 'refs/tags/v')
needs: [build]
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v4
- name: Download all artifacts
uses: actions/download-artifact@v4
- name: Create Release
uses: softprops/action-gh-release@v2
with:
files: omniemu-*/*
generate_release_notes: true
+162
View File
@@ -0,0 +1,162 @@
import { existsSync, readdirSync, statSync } from 'fs';
import { join, extname } from 'path';
import { app } from 'electron';
export interface BiosEntry {
/** Emulator(s) that need this BIOS */
emulators: string[];
/** Platform it belongs to */
platform: string;
/** Known filenames (any match counts) */
files: string[];
/** Friendly name */
name: string;
/** Optional MD5 hash (not checked currently) */
md5?: string;
/** Size in bytes (for validation) */
size?: number;
}
const knownBiosFiles: BiosEntry[] = [
{
emulators: ['duckstation', 'retroarch'],
platform: 'ps1',
files: ['scph5500.bin', 'scph5501.bin', 'scph5502.bin'],
name: 'PlayStation BIOS',
size: 524288,
},
{
emulators: ['duckstation', 'retroarch'],
platform: 'ps1',
files: ['scph1001.bin', 'scph3000.bin', 'scph7001.bin', 'scph7502.bin'],
name: 'PlayStation BIOS (alt)',
},
{
emulators: ['pcsx2'],
platform: 'ps2',
files: ['scph39001.bin', 'scph70012.bin', 'scph77001.bin', 'scph90001.bin', 'PS2_ROM.BIN', 'PS2DRV.BIN'],
name: 'PlayStation 2 BIOS',
},
{
emulators: ['rpcs3'],
platform: 'ps3',
files: ['PS3UPDAT.PUP'],
name: 'PlayStation 3 Firmware',
},
{
emulators: ['retroarch'],
platform: 'sega-md',
files: ['bios_MD.bin', 'bios_SegaCD.bin', 'bios_U.bin', 'bios_E.bin', 'bios_J.bin'],
name: 'Sega Mega Drive / CD BIOS',
},
{
emulators: ['retroarch'],
platform: 'sega-saturn',
files: ['sega_101.bin', 'mpr-17933.bin', 'mpr-17934.bin', 'mpr-17935.bin'],
name: 'Sega Saturn BIOS',
},
{
emulators: ['retroarch'],
platform: 'sega-dc',
files: ['dc_boot.bin', 'dc_flash.bin'],
name: 'Sega Dreamcast BIOS',
},
{
emulators: ['retroarch'],
platform: 'pce',
files: ['syscard3.pce', 'syscard2.pce', 'syscard1.pce', 'gexpress.pce'],
name: 'PC Engine BIOS',
},
{
emulators: ['retroarch'],
platform: 'nds',
files: ['bios7.bin', 'bios9.bin', 'firmware.bin'],
name: 'Nintendo DS BIOS',
},
{
emulators: ['retroarch'],
platform: 'gba',
files: ['gba_bios.bin'],
name: 'Game Boy Advance BIOS',
size: 16384,
},
];
export function getKnownBiosList(): BiosEntry[] {
return knownBiosFiles;
}
export interface BiosCheckResult {
entry: BiosEntry;
present: boolean;
foundFiles: string[];
directory: string;
}
/** Scan a directory for known BIOS files */
export function scanBiosDirectory(biosDir: string): BiosCheckResult[] {
if (!existsSync(biosDir)) {
return knownBiosFiles.map(entry => ({
entry,
present: false,
foundFiles: [],
directory: biosDir,
}));
}
const files: string[] = [];
try {
const scan = (dir: string) => {
const entries = readdirSync(dir, { withFileTypes: true });
for (const e of entries) {
const full = join(dir, e.name);
if (e.isDirectory()) scan(full);
else files.push(e.name.toLowerCase());
}
};
scan(biosDir);
} catch { /* ignore */ }
return knownBiosFiles.map(entry => {
const foundFiles = entry.files.filter(f => files.includes(f.toLowerCase()));
return {
entry,
present: foundFiles.length > 0,
foundFiles,
directory: biosDir,
};
});
}
/** Get the default BIOS directory */
export function getDefaultBiosDir(): string {
const home = require('os').homedir();
const candidates = [
join(home, 'OmniEmu', 'bios'),
join(home, 'Library', 'Application Support', 'RetroArch', 'system'),
join(app.getPath('userData'), 'bios'),
];
for (const dir of candidates) {
if (existsSync(dir)) return dir;
}
return candidates[0];
}
/** Update RetroArch config to point system_directory at the BIOS folder */
export function updateRetroarchBiosPath(configDir: string, biosDir: string): boolean {
const cfgPath = join(configDir, 'retroarch.cfg');
if (!existsSync(configDir)) return false;
let content = '';
if (existsSync(cfgPath)) {
content = require('fs').readFileSync(cfgPath, 'utf-8');
}
const lines = content.split('\n').filter(l =>
!l.startsWith('system_directory') && !l.trim().startsWith('system_directory')
);
lines.push(`system_directory = "${biosDir}"`);
require('fs').writeFileSync(cfgPath, lines.join('\n'), 'utf-8');
return true;
}
+124 -7
View File
@@ -74,10 +74,10 @@ CPU:
},
},
],
ryujinx: [
eden: [
{
name: 'OmniEmu Recommended',
description: 'Best settings for Ryujinx Switch emulation',
description: 'Best settings for Eden Switch emulation',
files: {
'Config.json': `{
"graphics_backend": "Vulkan",
@@ -149,6 +149,29 @@ waitvsync 1
syncrefresh 0
sleep 0
autosave 0
`,
},
},
],
duckstation: [
{
name: 'OmniEmu Recommended',
description: 'Optimal DuckStation settings for PS1 emulation',
files: {
'settings.ini': `[General]
UserMode = 0
StartFullscreen = True
[Display]
RenderToMain = True
Fullscreen = True
VSync = True
[GPU]
Renderer = Vulkan
ResolutionScale = 3
Multisamples = 1
PGXPEnable = True
PGXPCulling = True
WidescreenHack = True
`,
},
},
@@ -199,11 +222,6 @@ function getConfigDir(emulatorId: string, installPath: string): string {
darwin: join(require('os').homedir(), 'Library', 'Application Support', 'rpcs3'),
linux: join(require('os').homedir(), '.config', 'rpcs3'),
},
ryujinx: {
win32: join(process.env.APPDATA || '', 'Ryujinx'),
darwin: join(require('os').homedir(), 'Library', 'Application Support', 'Ryujinx'),
linux: join(require('os').homedir(), '.config', 'Ryujinx'),
},
pcsx2: {
win32: join(process.env.APPDATA || '', 'PCSX2'),
darwin: join(require('os').homedir(), 'Library', 'Application Support', 'PCSX2'),
@@ -219,6 +237,16 @@ function getConfigDir(emulatorId: string, installPath: string): string {
darwin: dirname(installPath),
linux: join(require('os').homedir(), '.mame'),
},
duckstation: {
win32: join(process.env.APPDATA || '', 'duckstation'),
darwin: join(require('os').homedir(), 'Library', 'Application Support', 'DuckStation'),
linux: join(require('os').homedir(), '.config', 'duckstation'),
},
eden: {
win32: join(process.env.APPDATA || '', 'Eden'),
darwin: join(require('os').homedir(), 'Library', 'Application Support', 'Eden'),
linux: join(require('os').homedir(), '.config', 'Eden'),
},
};
return platformDirs[emulatorId]?.[platform] || dirname(installPath);
}
@@ -280,3 +308,92 @@ export async function applyRecommendedConfig(
await applyPreset(emulatorId, presets[0], installPath, onProgress);
return true;
}
/** Write controller config for a given emulator */
export function applyControllerConfig(emulatorId: string, installPath: string, controllerName?: string): boolean {
const configDir = getConfigDir(emulatorId, installPath);
if (!existsSync(configDir)) {
mkdirSync(configDir, { recursive: true });
}
switch (emulatorId) {
case 'retroarch': {
const cfgPath = join(configDir, 'retroarch.cfg');
const existing = existsSync(cfgPath) ? readFileSync(cfgPath, 'utf-8') : '';
// Append (or overwrite) controller-specific lines
const lines = existing.split('\n').filter(l =>
!l.startsWith('input_player1_joypad_index') &&
!l.startsWith('input_driver') &&
!l.startsWith('input_autodetect_enable')
);
lines.push('input_player1_joypad_index = "0"');
lines.push('input_autodetect_enable = "true"');
// Set input driver per platform
if (isMacOS()) lines.push('input_driver = "hid"');
else if (isWindows()) lines.push('input_driver = "dinput"');
else lines.push('input_driver = "udev"');
writeFileSync(cfgPath, lines.join('\n'), 'utf-8');
return true;
}
case 'duckstation': {
const iniPath = join(configDir, 'settings.ini');
const existing = existsSync(iniPath) ? readFileSync(iniPath, 'utf-8') : '';
const lines = existing.split('\n').filter(l =>
!l.startsWith('ControllerBackend') &&
!l.startsWith('MultitapPort1')
);
lines.push('[Input]');
lines.push('ControllerBackend = "SDL"');
lines.push('[ControllerPort0]');
lines.push('MultitapPort1 = false');
writeFileSync(iniPath, lines.join('\n'), 'utf-8');
return true;
}
case 'pcsx2': {
const iniPath = join(configDir, 'inis', 'PCSX2.ini');
const dir = dirname(iniPath);
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
const existing = existsSync(iniPath) ? readFileSync(iniPath, 'utf-8') : '';
const lines = existing.split('\n').filter(l =>
!l.startsWith('Multitap') && !l.startsWith('Pad1')
);
lines.push('[Pad]');
lines.push('MultitapPort0_Enabled = false');
lines.push('MultitapPort1_Enabled = false');
lines.push('Pad1 = "SDL"');
writeFileSync(iniPath, lines.join('\n'), 'utf-8');
return true;
}
case 'dolphin': {
const iniPath = join(configDir, 'Config', 'Dolphin.ini');
const dir = dirname(iniPath);
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
const existing = existsSync(iniPath) ? readFileSync(iniPath, 'utf-8') : '';
const lines = existing.split('\n').filter(l =>
!l.startsWith('SIDevice') && !l.startsWith('AdapterRumble')
);
lines.push('[Android]');
lines.push('SIDevice0 = 6');
lines.push('AdapterRumble0 = True');
writeFileSync(iniPath, lines.join('\n'), 'utf-8');
return true;
}
case 'eden':
case 'rpcs3': {
// RPCS3/Eden don't have simple config overrides for controller
break;
}
case 'mame': {
const iniPath = join(configDir, 'mame.ini');
const existing = existsSync(iniPath) ? readFileSync(iniPath, 'utf-8') : '';
const lines = existing.split('\n').filter(l =>
!l.startsWith('joystick') && !l.startsWith('keyboard')
);
lines.push('joystick 1');
lines.push('keyboard 0');
writeFileSync(iniPath, lines.join('\n'), 'utf-8');
return true;
}
}
return false;
}
+216 -39
View File
@@ -1,5 +1,5 @@
import { execSync, exec, ChildProcess } from 'child_process';
import { existsSync, mkdirSync } from 'fs';
import { existsSync, mkdirSync, readdirSync } from 'fs';
import { join, dirname, basename, extname } from 'path';
import { app } from 'electron';
import {
@@ -94,43 +94,41 @@ export const knownEmulators: EmulatorConfig[] = [
},
},
{
id: 'ryujinx',
name: 'Ryujinx',
description: 'Nintendo Switch emulator',
id: 'eden',
name: 'Eden',
description: 'Nintendo Switch emulator (community fork)',
platforms: ['switch'],
defaultPath: {
win32: 'C:\\Program Files\\Ryujinx\\Ryujinx.exe',
darwin: '/Applications/Ryujinx.app/Contents/MacOS/Ryujinx',
linux: '/usr/bin/Ryujinx',
win32: 'C:\\Program Files\\Eden\\Eden.exe',
darwin: '/Applications/Eden.app/Contents/MacOS/Eden',
linux: '/usr/bin/eden',
},
downloads: {
win32: [
{
url: 'https://github.com/Ryujinx/release-channel-master/releases/latest/download/ryujinx-1.2.0-win_x64.zip',
url: 'https://master.eden-emu.dev/v1783561671.41762940d6/Eden-Windows-41762940d6-amd64-gcc-standard.zip',
format: 'zip',
executablePath: 'Ryujinx.exe',
executablePath: 'Eden.exe',
},
],
darwin: [
{
url: 'https://github.com/Ryujinx/release-channel-master/releases/latest/download/ryujinx-1.2.0-mac_universal.zip',
format: 'zip',
executablePath: 'Ryujinx.app/Contents/MacOS/Ryujinx',
url: 'https://master.eden-emu.dev/v1783561671.41762940d6/Eden-macOS-41762940d6.dmg',
format: 'dmg',
},
],
linux: [
{
url: 'https://github.com/Ryujinx/release-channel-master/releases/latest/download/ryujinx-1.2.0-linux_x64.zip',
format: 'zip',
executablePath: 'Ryujinx',
url: 'https://master.eden-emu.dev/v1783561671.41762940d6/Eden-Linux-41762940d6-amd64-gcc-standard.AppImage',
format: 'appimage',
},
],
},
supported: true,
websiteUrl: {
win32: 'https://ryujinx.org/download',
darwin: 'https://ryujinx.org/download',
linux: 'https://ryujinx.org/download',
win32: 'https://eden-emu.dev/',
darwin: 'https://eden-emu.dev/',
linux: 'https://eden-emu.dev/',
},
},
{
@@ -210,6 +208,58 @@ export const knownEmulators: EmulatorConfig[] = [
linux: 'https://www.mamedev.org/release.html',
},
},
{
id: 'duckstation',
name: 'DuckStation',
description: 'PlayStation 1 emulator',
platforms: ['ps1'],
defaultPath: {
win32: 'C:\\Program Files\\DuckStation\\duckstation-qt-x64-ReleaseLGL.normal.exe',
darwin: '/Applications/DuckStation.app/Contents/MacOS/DuckStation',
linux: '/usr/bin/duckstation-qt',
},
downloads: {
win32: [
{
url: 'https://github.com/stenzek/duckstation/releases/download/latest/duckstation-windows-x64-release.zip',
format: 'zip',
executablePath: 'duckstation-qt-x64-ReleaseLGL.normal.exe',
arch: 'x64',
},
{
url: 'https://github.com/stenzek/duckstation/releases/download/latest/duckstation-windows-arm64-release.zip',
format: 'zip',
executablePath: 'duckstation-qt-arm64-ReleaseLGL.normal.exe',
arch: 'arm64',
},
],
darwin: [
{
url: 'https://github.com/stenzek/duckstation/releases/download/latest/duckstation-mac-release.zip',
format: 'zip',
executablePath: 'DuckStation.app/Contents/MacOS/DuckStation',
},
],
linux: [
{
url: 'https://github.com/stenzek/duckstation/releases/download/latest/DuckStation-x64.AppImage',
format: 'appimage',
arch: 'x64',
},
{
url: 'https://github.com/stenzek/duckstation/releases/download/latest/DuckStation-arm64.AppImage',
format: 'appimage',
arch: 'arm64',
},
],
},
supported: true,
websiteUrl: {
win32: 'https://github.com/stenzek/duckstation/releases/latest',
darwin: 'https://github.com/stenzek/duckstation/releases/latest',
linux: 'https://github.com/stenzek/duckstation/releases/latest',
},
},
{
id: 'retroarch',
name: 'RetroArch',
@@ -226,23 +276,41 @@ export const knownEmulators: EmulatorConfig[] = [
downloads: {
win32: [
{
url: 'https://buildbot.libretro.com/stable/1.19.1/windows/x86_64/RetroArch.7z',
url: 'https://buildbot.libretro.com/stable/1.22.2/windows/x86_64/RetroArch.7z',
format: '7z',
executablePath: 'RetroArch.exe',
},
{
url: 'https://buildbot.libretro.com/stable/1.22.2/windows/x86_64/RetroArch_cores.7z',
format: '7z',
},
],
darwin: [
{
url: 'https://buildbot.libretro.com/stable/1.19.1/apple/osx/universal/RetroArch.dmg',
url: 'https://buildbot.libretro.com/stable/1.22.2/apple/osx/universal/RetroArch_Metal.dmg',
format: 'dmg',
arch: 'arm64',
},
{
url: 'https://buildbot.libretro.com/stable/1.22.2/apple/osx/x86_64/RetroArch.dmg',
format: 'dmg',
arch: 'x64',
},
{
url: 'https://buildbot.libretro.com/stable/1.22.2/apple/osx/universal/RetroArch_cores.7z',
format: '7z',
},
],
linux: [
{
url: 'https://buildbot.libretro.com/stable/1.19.1/linux/x86_64/RetroArch.7z',
url: 'https://buildbot.libretro.com/stable/1.22.2/linux/x86_64/RetroArch.7z',
format: '7z',
executablePath: 'retroarch',
},
{
url: 'https://buildbot.libretro.com/stable/1.22.2/linux/x86_64/RetroArch_cores.7z',
format: '7z',
},
],
},
packageNames: {
@@ -317,9 +385,18 @@ function alternativePaths(emulatorId: string): string[] {
join(omniEmuDir, 'rpcs3'),
join(omniEmuDir, 'RPCS3.AppImage'),
],
ryujinx: [
join(omniEmuDir, 'Ryujinx.exe'),
join(omniEmuDir, 'Ryujinx'),
eden: [
join(omniEmuDir, 'Eden.exe'),
join(omniEmuDir, 'Eden'),
join(home, 'Applications', 'Eden.app', 'Contents', 'MacOS', 'Eden'),
'/usr/local/bin/eden',
],
duckstation: [
join(omniEmuDir, 'duckstation-qt-x64-ReleaseLGL.normal.exe'),
join(omniEmuDir, 'DuckStation'),
join(omniEmuDir, 'DuckStation.app', 'Contents', 'MacOS', 'DuckStation'),
join(home, 'Applications', 'DuckStation.app', 'Contents', 'MacOS', 'DuckStation'),
'/usr/local/bin/duckstation-qt',
],
pcsx2: [
join(omniEmuDir, 'pcsx2.exe'),
@@ -335,6 +412,32 @@ function alternativePaths(emulatorId: string): string[] {
return common[emulatorId] || [join(omniEmuDir)];
}
function detectVersion(binaryPath: string): string | undefined {
if (isMacOS() && binaryPath.includes('.app/Contents/MacOS/')) {
const plist = join(binaryPath, '..', '..', '..', 'Info.plist');
if (existsSync(plist)) {
try {
const out = execSync(
`/usr/libexec/PlistBuddy -c "Print CFBundleShortVersionString" "${plist}" 2>/dev/null || true`,
{ timeout: 3000 }
).toString().trim();
if (out) return out;
} catch { /* ignore */ }
}
}
if (isWindows()) {
try {
const out = execSync(
`powershell -NoProfile -Command "(Get-Item '${binaryPath}').VersionInfo.ProductVersion" 2>nul`
).toString().trim();
if (out) return out;
} catch { /* ignore */ }
}
return undefined;
}
export function checkEmulator(id: string): EmulatorState {
const config = findEmulator(id);
if (!config) {
@@ -354,17 +457,7 @@ export function checkEmulator(id: string): EmulatorState {
}
const path = detectEmulatorPath(config);
let version: string | undefined;
if (path) {
try {
const result = execSync(`"${path}" --version 2>&1 || "${path}" -v 2>&1`)
.toString().trim().split('\n')[0];
version = result || undefined;
} catch {
version = undefined;
}
}
const version = path ? detectVersion(path) : undefined;
return {
installed: !!path,
@@ -379,6 +472,16 @@ export function getAllEmulatorStates(): EmulatorState[] {
return knownEmulators.map((e) => checkEmulator(e.id));
}
export function launchEmulator(emulatorId: string): boolean {
const state = checkEmulator(emulatorId);
if (!state.installed || !state.path) return false;
const child = exec(`"${state.path}"`, { cwd: dirname(state.path) });
if (child) child.unref();
return true;
}
export function launchGame(emulatorId: string, romPath: string): ChildProcess | null {
const state = checkEmulator(emulatorId);
if (!state.installed || !state.path) return null;
@@ -396,20 +499,74 @@ export function launchGame(emulatorId: string, romPath: string): ChildProcess |
return proc;
}
function findRetroArchCore(romPath: string): string | undefined {
const ext = extname(romPath).toLowerCase().replace(/^\./, '');
const corePreference: Record<string, string[]> = {
'nes': ['nestopia', 'mesen', 'fceumm'],
'smc': ['snes9x', 'bsnes_hd', 'bsnes', 'mednafen_snes'],
'sfc': ['snes9x', 'bsnes_hd', 'bsnes', 'mednafen_snes'],
'swc': ['snes9x', 'bsnes'],
'n64': ['mupen64plus_next', 'parallel_n64'],
'z64': ['mupen64plus_next', 'parallel_n64'],
'v64': ['mupen64plus_next', 'parallel_n64'],
'gba': ['mgba', 'vba_next', 'gpsp'],
'gb': ['mgba', 'gambatte', 'sameboy', 'gearboy'],
'gbc': ['mgba', 'gambatte', 'sameboy', 'gearboy'],
'nds': ['melonds', 'desmume'],
'bin': ['mednafen_psx_hw', 'pcsx_rearmed', 'swanstation'],
'cue': ['mednafen_psx_hw', 'pcsx_rearmed', 'swanstation'],
'iso': ['mednafen_psx_hw', 'pcsx_rearmed', 'swanstation'],
'pce': ['mednafen_pce_fast', 'mednafen_pce'],
'md': ['genesis_plus_gx', 'picodrive'],
'smd': ['genesis_plus_gx', 'picodrive'],
};
const candidates = corePreference[ext];
if (!candidates) return undefined;
const home = require('os').homedir();
const userData = app.getPath('userData');
const coreDirs = [
join(userData, 'emulators', 'retroarch', 'RetroArch.app', 'Contents', 'Resources', 'cores'),
join(userData, 'emulators', 'retroarch', 'cores'),
join(home, 'Library', 'Application Support', 'RetroArch', 'cores'),
'/usr/local/lib/retroarch/cores',
'/usr/lib/x86_64-linux-gnu/libretro',
];
if (isWindows()) {
coreDirs.unshift(join(process.env.APPDATA || '', 'RetroArch', 'cores'));
}
for (const dir of coreDirs) {
if (!existsSync(dir)) continue;
let coreFiles: string[];
try { coreFiles = readdirSync(dir); } catch { continue; }
for (const preferred of candidates) {
const match = coreFiles.find(f => f.toLowerCase().includes(preferred));
if (match) return join(dir, match);
}
}
return undefined;
}
function launchArgs(emulatorId: string, romPath: string): string {
switch (emulatorId) {
case 'dolphin':
return `--exec="${romPath}"`;
case 'rpcs3':
return `"${romPath}"`;
case 'ryujinx':
case 'eden':
return `"${romPath}"`;
case 'pcsx2':
return `"${romPath}"`;
case 'mame':
return `"${romPath}"`;
case 'retroarch':
return `-L "${romPath}"`;
case 'retroarch': {
const core = findRetroArchCore(romPath);
if (core) return `-L "${core}" "${romPath}"`;
return `"${romPath}"`;
}
case 'duckstation':
return `"${romPath}"`;
default:
return `"${romPath}"`;
}
@@ -492,6 +649,23 @@ function guessPlatform(ext: string): string {
return map[ext] || 'other';
}
export function uninstallEmulator(id: string): boolean {
const userData = app.getPath('userData');
const installDir = join(userData, 'emulators', id);
const configMarker = join(userData, 'configs', `${id}.configured`);
let removed = false;
if (existsSync(installDir)) {
require('fs').rmSync(installDir, { recursive: true, force: true });
removed = true;
}
if (existsSync(configMarker)) {
require('fs').rmSync(configMarker, { force: true });
}
return removed;
}
function guessEmulator(ext: string): string {
const map: Record<string, string> = {
'.nes': 'retroarch', '.sfc': 'retroarch', '.smc': 'retroarch',
@@ -499,8 +673,11 @@ function guessEmulator(ext: string): string {
'.gba': 'retroarch', '.gb': 'retroarch', '.gbc': 'retroarch',
'.wbfs': 'dolphin', '.wad': 'dolphin', '.gcm': 'dolphin',
'.gcz': 'dolphin', '.rvz': 'dolphin',
'.nsp': 'ryujinx', '.xci': 'ryujinx',
'.nsp': 'eden', '.xci': 'eden', '.nca': 'eden',
'.pkg': 'rpcs3',
'.bin': 'duckstation', '.cue': 'duckstation', '.iso': 'duckstation',
'.img': 'duckstation', '.m3u': 'duckstation', '.pbp': 'duckstation',
'.chd': 'duckstation', '.ecm': 'duckstation', '.mds': 'duckstation',
'.ps2': 'pcsx2', '.cso': 'pcsx2',
};
return map[ext] || 'retroarch';
+95 -45
View File
@@ -63,27 +63,41 @@ function execOrThrow(cmd: string): string {
function findAppInDir(dir: string): string | undefined {
const entries = readdirSync(dir, { withFileTypes: true });
for (const e of entries) {
const full = join(dir, e.name);
if (e.isDirectory()) {
if (e.name.endsWith('.app')) {
const macosBin = join(full, 'Contents', 'MacOS', basename(e.name, '.app'));
if (existsSync(macosBin)) return macosBin;
}
const found = findAppInDir(full);
if (found) return found;
} else if (e.isFile()) {
const isExec = e.name.endsWith('.exe') || e.name.endsWith('.AppImage')
|| e.name === 'retroarch' || e.name === 'dolphin-emu'
|| e.name === 'rpcs3' || e.name === 'Ryujinx'
|| e.name === 'mame' || e.name === 'mame64' || e.name === 'PCSX2';
if (isExec) return full;
// Check if it's executable
try {
if (statSync(full).mode & 0o111) return full;
} catch { /* skip */ }
const dirs = entries.filter(e => e.isDirectory());
const files = entries.filter(e => e.isFile());
// Check .app bundles first (before cores or other dirs)
for (const e of dirs) {
if (e.name.endsWith('.app')) {
const macosBin = join(dir, e.name, 'Contents', 'MacOS', basename(e.name, '.app'));
if (existsSync(macosBin)) return macosBin;
}
}
// Then check known executable files in root
for (const e of files) {
const lowerName = e.name.toLowerCase();
const knownNames = ['retroarch', 'dolphin-emu', 'dolphin', 'rpcs3', 'ryujinx', 'eden', 'mame', 'mame64', 'pcsx2', 'duckstation'];
const isExec = e.name.endsWith('.exe') || e.name.endsWith('.AppImage')
|| knownNames.includes(lowerName);
if (isExec) return join(dir, e.name);
}
// Recurse into non-.app directories (skip .app bundles)
for (const e of dirs) {
if (!e.name.endsWith('.app')) {
const found = findAppInDir(join(dir, e.name));
if (found) return found;
}
}
// Last resort: any executable file in root
for (const e of files) {
try {
if (statSync(join(dir, e.name)).mode & 0o111) return join(dir, e.name);
} catch { /* skip */ }
}
return undefined;
}
@@ -188,44 +202,47 @@ export async function installEmulator(
onProgress: ProgressCallback
): Promise<string> {
const candidates = downloads.filter((d) => !d.arch || d.arch === arch);
const download = candidates[0];
if (!download) throw new Error(`No download available for ${emulatorId} on ${platform} (${arch})`);
if (candidates.length === 0) throw new Error(`No download available for ${emulatorId} on ${platform} (${arch})`);
const report = (stage: InstallProgress['stage'], percent: number, message: string) => {
onProgress({ emulatorId, stage, percent, message });
};
report('downloading', 0, `Downloading ${emulatorId}...`);
const downloadPath = tempName(`.${download.format}`);
await downloadFile(download.url, downloadPath, (pct) => {
report('downloading', pct, `Downloading ${emulatorId}... ${pct}%`);
});
report('downloading', 100, 'Download complete');
const installDir = join(app.getPath('userData'), 'emulators', emulatorId);
if (!existsSync(installDir)) mkdirSync(installDir, { recursive: true });
const archiveFormats = ['zip', 'tar.gz', 'tar.bz2', '7z', 'dmg'];
const installerFormats = ['exe', 'msi', 'pkg', 'appimage'];
if (archiveFormats.includes(download.format)) {
report('extracting', 0, `Extracting ${emulatorId}...`);
extractArchive(downloadPath, installDir, download.format, emulatorId, (msg) => {
report('extracting', 50, msg);
});
report('extracting', 100, 'Extraction complete');
}
let totalSteps = candidates.length;
let completedSteps = 0;
if (installerFormats.includes(download.format)) {
report('installing', 0, `Installing ${emulatorId}...`);
runInstaller(downloadPath, download.format, emulatorId, installDir, (msg) => {
report('installing', 50, msg);
});
report('installing', 100, 'Installation complete');
}
for (const download of candidates) {
const stepLabel = totalSteps > 1 ? ` (${completedSteps + 1}/${totalSteps})` : '';
report('downloading', Math.round((completedSteps / totalSteps) * 100), `Downloading ${download.url.split('/').pop()}${stepLabel}...`);
// Cleanup
try { execSync(`rm -f "${downloadPath}"`); } catch { /* ignore */ }
const downloadPath = tempName(`.${download.format}`);
await downloadFile(download.url, downloadPath, (pct) => {
report('downloading', Math.round(((completedSteps + pct / 100) / totalSteps) * 100), `Downloading ${download.url.split('/').pop()}${stepLabel}... ${pct}%`);
});
if (archiveFormats.includes(download.format)) {
report('extracting', Math.round((completedSteps / totalSteps) * 100), `Extracting ${download.url.split('/').pop()}${stepLabel}...`);
extractArchive(downloadPath, installDir, download.format, emulatorId, (msg) => {
report('extracting', Math.round(((completedSteps + 0.5) / totalSteps) * 100), msg);
});
}
if (installerFormats.includes(download.format)) {
report('installing', Math.round((completedSteps / totalSteps) * 100), `Installing ${download.url.split('/').pop()}${stepLabel}...`);
runInstaller(downloadPath, download.format, emulatorId, installDir, (msg) => {
report('installing', Math.round(((completedSteps + 0.5) / totalSteps) * 100), msg);
});
}
try { execSync(`rm -f "${downloadPath}"`); } catch { /* ignore */ }
completedSteps++;
}
report('done', 100, `${emulatorId} installed`);
@@ -233,7 +250,40 @@ export async function installEmulator(
}
/** After install, find the executable in the install dir */
export function findInstalledBinary(emulatorId: string, installDir: string): string | undefined {
export function findInstalledBinary(emulatorId: string, installDir: string, executablePath?: string): string | undefined {
if (!existsSync(installDir)) return undefined;
// Try explicit executablePath first (from download config)
if (executablePath) {
const explicit = join(installDir, executablePath);
if (existsSync(explicit)) return explicit;
}
// macOS: check for .app bundle with several naming variants
if (isMacOS()) {
const appNames = [
`${capitalize(emulatorId)}.app`, // Retroarch.app
`${emulatorId}.app`, // retroarch.app
`${emulatorId.charAt(0).toUpperCase()}${emulatorId.slice(1).toLowerCase()}.app`, // Retroarch.app
];
// Also check common overrides
if (emulatorId === 'retroarch') {
appNames.unshift('RetroArch.app'); // actual macOS app name
}
for (const appName of appNames) {
const appDir = join(installDir, appName);
if (existsSync(appDir)) {
const binName = basename(appName, '.app');
const macosBin = join(appDir, 'Contents', 'MacOS', binName);
if (existsSync(macosBin)) return macosBin;
}
}
}
return findAppInDir(installDir);
}
function capitalize(s: string): string {
return s.charAt(0).toUpperCase() + s.slice(1);
}
+86 -4
View File
@@ -4,6 +4,8 @@ import { writeFileSync } from 'fs';
import {
getAllEmulatorStates,
checkEmulator,
uninstallEmulator,
launchEmulator,
launchGame,
scanRoms,
knownEmulators,
@@ -12,10 +14,12 @@ import {
getEmulatorsDirectory,
} from './emulators';
import { installEmulator, findInstalledBinary } from './installer';
import { applyRecommendedConfig, getPresets, checkConfigured } from './configurator';
import { applyRecommendedConfig, getPresets, checkConfigured, applyControllerConfig } from './configurator';
import { settings } from './settings';
import { getSystemInfo, platformName, getPlatform, getArch } from './platform';
import { InstallProgress, AppSettings } from '../shared/types';
import { InstallProgress, AppSettings, GameEntry } from '../shared/types';
import { addRecentGame, parseGameTitle, buildScrapeTitle, findValidThumbnail } from './scraper';
import { scanBiosDirectory, getKnownBiosList, getDefaultBiosDir, updateRetroarchBiosPath } from './bios';
export function registerIpcHandlers(): void {
// System
@@ -49,7 +53,8 @@ export function registerIpcHandlers(): void {
const installDir = await installEmulator(emulatorId, downloads, platform, arch, sendProgress);
// Try to find the binary and create a symlink or record it
const binary = findInstalledBinary(emulatorId, installDir);
const download = downloads.filter((d) => !d.arch || d.arch === arch)[0];
const binary = findInstalledBinary(emulatorId, installDir, download?.executablePath);
if (binary) {
const marker = join(installDir, '.installed');
writeFileSync(marker, binary);
@@ -85,6 +90,15 @@ export function registerIpcHandlers(): void {
}
);
// Launch emulator standalone (no ROM)
ipcMain.handle('emulators:launch', (_event, id: string) => launchEmulator(id));
// Uninstall emulator
ipcMain.handle('emulators:uninstall', (_event, id: string) => {
const removed = uninstallEmulator(id);
return { removed, state: checkEmulator(id) };
});
// Open website (fallback for manual download)
ipcMain.handle('emulators:open-website', (_event, id: string) => {
const emu = findEmulator(id);
@@ -115,10 +129,45 @@ export function registerIpcHandlers(): void {
ipcMain.handle(
'game:launch',
(_event, emulatorId: string, romPath: string) => {
return !!launchGame(emulatorId, romPath);
const launched = launchGame(emulatorId, romPath);
if (launched) {
const emu = findEmulator(emulatorId);
const filename = romPath.split('/').pop() || romPath.split('\\').pop() || romPath;
const title = parseGameTitle(filename);
const entry: GameEntry = {
id: `${emulatorId}-${Date.now()}`,
romPath,
title,
platform: emu?.platforms?.[0] || '',
emulatorId,
lastPlayed: new Date().toISOString(),
playCount: 1,
addedAt: new Date().toISOString(),
};
const s = settings.get();
settings.save({ recentGames: addRecentGame(s.recentGames || [], entry) });
}
return !!launched;
}
);
// Recent games
ipcMain.handle('games:recent', () => {
const s = settings.get();
return s.recentGames || [];
});
// Clear recent games
ipcMain.handle('games:clear-recent', () => {
settings.save({ recentGames: [] });
return true;
});
// Scrape a game's art URL — accepts display title, uses scrape-friendly title internally
ipcMain.handle('games:scrape-art', async (_event, title: string, platform: string) => {
return findValidThumbnail(buildScrapeTitle(title), platform);
});
// Settings
ipcMain.handle('settings:get', () => settings.get());
ipcMain.handle('settings:save', (_event, s: Partial<AppSettings>) =>
@@ -126,6 +175,39 @@ export function registerIpcHandlers(): void {
);
ipcMain.handle('settings:reset', () => settings.reset());
// Controller config
ipcMain.handle(
'emulators:update-controller-config',
(_event, emulatorId: string, installPath: string, controllerName?: string) => {
return applyControllerConfig(emulatorId, installPath, controllerName);
}
);
// BIOS
ipcMain.handle('bios:list-known', () => getKnownBiosList());
ipcMain.handle('bios:scan', (_event, directory?: string) => {
const dir = directory || settings.get().biosDirectory || getDefaultBiosDir();
return scanBiosDirectory(dir);
});
ipcMain.handle('bios:select-directory', async () => {
const result = await dialog.showOpenDialog({
properties: ['openDirectory'],
title: 'Select BIOS Directory',
});
if (!result.canceled && result.filePaths.length > 0) {
const dir = result.filePaths[0];
settings.save({ biosDirectory: dir });
return dir;
}
return null;
});
ipcMain.handle('bios:configure-retroarch', (_event, configDir: string, biosDir: string) => {
return updateRetroarchBiosPath(configDir, biosDir);
});
// Paths
ipcMain.handle('paths:roms-directory', () => getRomsDirectory());
ipcMain.handle('paths:emulators-directory', () => getEmulatorsDirectory());
+26
View File
@@ -25,6 +25,14 @@ const api = {
install: (id: string): Promise<EmulatorState> =>
ipcRenderer.invoke('emulators:install', id),
/** Launch emulator standalone (no ROM) */
launch: (id: string): Promise<boolean> =>
ipcRenderer.invoke('emulators:launch', id),
/** Uninstall an emulator */
uninstall: (id: string): Promise<{ removed: boolean; state: EmulatorState }> =>
ipcRenderer.invoke('emulators:uninstall', id),
/** Apply recommended config preset to an installed emulator */
configure: (id: string, installPath: string): Promise<{ success: boolean; state: EmulatorState }> =>
ipcRenderer.invoke('emulators:configure', id, installPath),
@@ -41,6 +49,10 @@ const api = {
openWebsite: (id: string): Promise<boolean> =>
ipcRenderer.invoke('emulators:open-website', id),
/** Apply controller config to an installed emulator */
updateControllerConfig: (id: string, installPath: string, controllerName?: string): Promise<boolean> =>
ipcRenderer.invoke('emulators:update-controller-config', id, installPath, controllerName),
/** Listen for install progress updates */
onInstallProgress: (cb: (progress: InstallProgress) => void) => {
const handler = (_event: Electron.IpcRendererEvent, p: InstallProgress) => cb(p);
@@ -59,6 +71,20 @@ const api = {
game: {
launch: (emulatorId: string, romPath: string): Promise<boolean> =>
ipcRenderer.invoke('game:launch', emulatorId, romPath),
recent: (): Promise<GameEntry[]> => ipcRenderer.invoke('games:recent'),
clearRecent: (): Promise<boolean> => ipcRenderer.invoke('games:clear-recent'),
scrapeArt: (title: string, platform: string): Promise<string | undefined> =>
ipcRenderer.invoke('games:scrape-art', title, platform),
},
bios: {
listKnown: (): Promise<any[]> => ipcRenderer.invoke('bios:list-known'),
scan: (directory?: string): Promise<any[]> =>
ipcRenderer.invoke('bios:scan', directory),
selectDirectory: (): Promise<string | null> =>
ipcRenderer.invoke('bios:select-directory'),
configureRetroArch: (configDir: string, biosDir: string): Promise<boolean> =>
ipcRenderer.invoke('bios:configure-retroarch', configDir, biosDir),
},
settings: {
+108
View File
@@ -0,0 +1,108 @@
import { get as httpsGet } from 'https';
import { GameEntry } from '../shared/types';
/** Clean a filename into a display title */
export function parseGameTitle(filename: string): string {
let name = filename.replace(/\.[^.]+$/, '');
name = name.replace(/\([^)]*\)/g, '');
name = name.replace(/\[[^\]]*\]/g, '');
name = name.replace(/[._]/g, ' ');
name = name.replace(/\s+/g, ' ').trim();
return name;
}
/** Build a scrape-friendly title (keeps region info like (World), (USA), etc.) */
export function buildScrapeTitle(filename: string): string {
let name = filename.replace(/\.[^.]+$/, '');
name = name.replace(/\[[^\]]*\]/g, '');
name = name.replace(/[._]/g, ' ');
name = name.replace(/[!]/g, '');
name = name.replace(/\s+/g, ' ').trim();
return name;
}
const thumbBase = 'https://raw.githubusercontent.com/libretro-thumbnails/libretro-thumbnails/master';
const platformThumbDir: Record<string, string> = {
'nes': 'Nintendo_-_Nintendo_Entertainment_System',
'snes': 'Nintendo_-_Super_Nintendo_Entertainment_System',
'n64': 'Nintendo_-_Nintendo_64',
'gba': 'Nintendo_-_Game_Boy_Advance',
'gb': 'Nintendo_-_Game_Boy',
'gbc': 'Nintendo_-_Game_Boy_Color',
'nds': 'Nintendo_-_Nintendo_DS',
'switch': 'Nintendo_-_Nintendo_Switch',
'ps1': 'Sony_-_PlayStation',
'ps2': 'Sony_-_PlayStation_2',
'ps3': 'Sony_-_PlayStation_3',
'psp': 'Sony_-_PSP',
'pce': 'NEC_-_PC_Engine_-_TurboGrafx_16',
'sega-md': 'Sega_-_Mega_Drive_-_Genesis',
'sega-saturn': 'Sega_-_Saturn',
'sega-dc': 'Sega_-_Dreamcast',
'gc': 'Nintendo_-_GameCube',
'wii': 'Nintendo_-_Wii',
'arcade': 'MAME',
};
function safeTitle(title: string): string {
return title.replace(/[:]/g, '').replace(/[/\\?*]/g, '_').trim();
}
export function buildThumbnailUrl(title: string, platform: string): string | undefined {
const dir = platformThumbDir[platform];
if (!dir) return undefined;
const safe = safeTitle(title);
return `${thumbBase}/${dir}/Named_Boxarts/${safe}.png`;
}
/** Try multiple URL patterns and return the first that resolves */
export async function findValidThumbnail(title: string, platform: string): Promise<string | undefined> {
const dir = platformThumbDir[platform];
if (!dir) return undefined;
const urls: string[] = [];
const safe = safeTitle(title);
// Try with and without region info
const regionStripped = title.replace(/\([^)]*\)/g, '').trim();
const safeStripped = safeTitle(regionStripped);
const basePaths = [`${thumbBase}/${dir}`];
const subdirs = ['Named_Boxarts', 'Named_Snaps'];
const titles = new Set<string>();
if (safe) titles.add(safe);
if (safeStripped && safeStripped !== safe) titles.add(safeStripped);
for (const base of basePaths) {
for (const sub of subdirs) {
for (const t of titles) {
urls.push(`${base}/${sub}/${t}.png`);
}
}
}
for (const url of urls) {
try {
const valid = await urlExists(url);
if (valid) return url;
} catch { /* try next */ }
}
return undefined;
}
function urlExists(url: string): Promise<boolean> {
return new Promise((resolve) => {
const req = httpsGet(url, { method: 'HEAD' }, (res) => {
resolve(res.statusCode === 200);
});
req.on('error', () => resolve(false));
req.setTimeout(5000, () => { req.destroy(); resolve(false); });
});
}
/** Track a game launch in the recent games list */
export function addRecentGame(games: GameEntry[], game: GameEntry, max: number = 10): GameEntry[] {
const updated = [game, ...games.filter(g => g.romPath !== game.romPath)];
return updated.slice(0, max);
}
+2
View File
@@ -17,6 +17,8 @@ const defaultSettings: AppSettings = {
launchInFullscreen: false,
closeToTray: true,
presetSourceUrl: 'https://raw.githubusercontent.com/mileswolfallen2/omniemu-presets/main/presets.json',
recentGames: [],
biosDirectory: '',
};
let cached: AppSettings | null = null;
+8 -2
View File
@@ -4,22 +4,27 @@ import { Dashboard } from './pages/Dashboard';
import { EmulatorsPage } from './pages/EmulatorsPage';
import { LibraryPage } from './pages/LibraryPage';
import { SettingsPage } from './pages/SettingsPage';
import { ControllerPage } from './pages/ControllerPage';
import { useGamepadNav } from './hooks/useGamepadNav';
type Page = 'dashboard' | 'emulators' | 'library' | 'settings';
type Page = 'dashboard' | 'emulators' | 'library' | 'settings' | 'controller';
export function App() {
useGamepadNav();
const [currentPage, setCurrentPage] = useState<Page>('dashboard');
const renderPage = () => {
switch (currentPage) {
case 'dashboard':
return <Dashboard />;
return <Dashboard onNavigate={setCurrentPage} />;
case 'emulators':
return <EmulatorsPage />;
case 'library':
return <LibraryPage />;
case 'settings':
return <SettingsPage />;
case 'controller':
return <ControllerPage />;
}
};
@@ -28,6 +33,7 @@ export function App() {
emulators: 'Emulators',
library: 'Game Library',
settings: 'Settings',
controller: 'Controller',
};
return (
+122
View File
@@ -0,0 +1,122 @@
import React, { useEffect, useState, useCallback } from 'react';
interface BiosEntry {
emulators: string[];
platform: string;
files: string[];
name: string;
}
interface BiosCheckResult {
entry: BiosEntry;
present: boolean;
foundFiles: string[];
directory: string;
}
interface Props {
biosDir: string;
onBiosDirChange: (dir: string) => void;
}
export function BiosCheckPanel({ biosDir, onBiosDirChange }: Props) {
const [results, setResults] = useState<BiosCheckResult[]>([]);
const [loading, setLoading] = useState(false);
const [retroarchConfigMsg, setRetroarchConfigMsg] = useState('');
const scan = useCallback(async (dir?: string) => {
setLoading(true);
const res = await window.omni.bios.scan(dir);
setResults(res);
setLoading(false);
}, []);
useEffect(() => {
if (biosDir) scan(biosDir);
}, [biosDir, scan]);
const presentCount = results.filter(r => r.present).length;
const totalCount = results.length;
const handleSelectDir = async () => {
const dir = await window.omni.bios.selectDirectory();
if (dir) {
onBiosDirChange(dir);
scan(dir);
}
};
const handleConfigureRetroarch = async () => {
const info = await window.omni.system.info();
const homeDir = info.homeDir;
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);
};
return (
<div>
<div className="setting-row">
<div>
<div className="setting-label">BIOS Directory</div>
<div className="setting-desc">
{biosDir || 'Not set — default locations will be scanned'}
</div>
</div>
<button className="btn btn-secondary btn-sm" onClick={handleSelectDir}>
Browse
</button>
</div>
{retroarchConfigMsg && (
<div className="info-bar" style={{ color: 'var(--success)', marginBottom: 8 }}>
{retroarchConfigMsg}
</div>
)}
{loading && <div className="loading">Scanning BIOS files...</div>}
{!loading && results.length > 0 && (
<div style={{ marginTop: 8 }}>
<div className="info-bar">
<span>{presentCount} of {totalCount} BIOS files found</span>
<button className="btn btn-secondary btn-sm" onClick={() => scan()}>
Rescan
</button>
<button className="btn btn-primary btn-sm" onClick={handleConfigureRetroarch}>
Update RetroArch BIOS Path
</button>
</div>
<div className="bios-grid" style={{ marginTop: 8 }}>
{results.map((r, i) => (
<div key={i} className={`bios-entry ${r.present ? 'present' : 'missing'}`}>
<div className="bios-entry-header">
<span className={`bios-indicator ${r.present ? 'present' : 'missing'}`}>
{r.present ? '✓' : '✗'}
</span>
<span className="bios-entry-name">{r.entry.name}</span>
<span className="platform-tag">{r.entry.platform}</span>
</div>
<div className="bios-entry-files">
{r.entry.files.map(f => (
<span key={f} className={`bios-file ${r.foundFiles.includes(f) ? 'found' : ''}`}>
{f}
</span>
))}
</div>
</div>
))}
</div>
</div>
)}
{!loading && results.length === 0 && !biosDir && (
<p className="text-sm text-muted mt-2">
Select a BIOS directory to scan for required firmware files.
</p>
)}
</div>
);
}
+2 -1
View File
@@ -1,6 +1,6 @@
import React from 'react';
type Page = 'dashboard' | 'emulators' | 'library' | 'settings';
type Page = 'dashboard' | 'emulators' | 'library' | 'settings' | 'controller';
interface SidebarProps {
currentPage: Page;
@@ -11,6 +11,7 @@ const navItems: { page: Page; label: string; icon: string }[] = [
{ page: 'dashboard', label: 'Dashboard', icon: '📊' },
{ page: 'emulators', label: 'Emulators', icon: '🕹️' },
{ page: 'library', label: 'Game Library', icon: '📚' },
{ page: 'controller', label: 'Controller', icon: '🎮' },
{ page: 'settings', label: 'Settings', icon: '⚙️' },
];
+99
View File
@@ -0,0 +1,99 @@
import { useEffect, useRef } from 'react';
const DEBOUNCE_MS = 200;
type NavDir = 'up' | 'down' | 'left' | 'right';
export function useGamepadNav() {
const lastInput = useRef<Record<string, number>>({});
useEffect(() => {
let raf = 0;
const poll = () => {
const gamepads = navigator.getGamepads();
const now = Date.now();
for (const gp of gamepads) {
if (!gp || !gp.connected) continue;
// D-Pad buttons (indices 12-15)
const dpadUp = gp.buttons[12]?.pressed;
const dpadDown = gp.buttons[13]?.pressed;
const dpadLeft = gp.buttons[14]?.pressed;
const dpadRight = gp.buttons[15]?.pressed;
// Left stick axes (indices 0, 1)
const axisX = gp.axes[0] || 0;
const axisY = gp.axes[1] || 0;
const threshold = 0.5;
const dirs: NavDir[] = [];
if (dpadUp) dirs.push('up');
if (dpadDown) dirs.push('down');
if (dpadLeft) dirs.push('left');
if (dpadRight) dirs.push('right');
if (axisY < -threshold) dirs.push('up');
if (axisY > threshold) dirs.push('down');
if (axisX < -threshold) dirs.push('left');
if (axisX > threshold) dirs.push('right');
for (const dir of dirs) {
const key = `dir:${dir}`;
if (now - (lastInput.current[key] || 0) < DEBOUNCE_MS) continue;
lastInput.current[key] = now;
const keyMap: Record<NavDir, string> = {
up: 'ArrowUp',
down: 'ArrowDown',
left: 'ArrowLeft',
right: 'ArrowRight',
};
document.dispatchEvent(new KeyboardEvent('keydown', {
key: keyMap[dir],
bubbles: true,
cancelable: true,
}));
}
// A button (0) → Enter
if (gp.buttons[0]?.pressed) {
if (now - (lastInput.current['a'] || 0) < DEBOUNCE_MS) continue;
lastInput.current['a'] = now;
const focused = document.activeElement;
if (focused) {
focused.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
} else {
document.dispatchEvent(new KeyboardEvent('keydown', {
key: 'Enter', bubbles: true, cancelable: true,
}));
}
}
// B button (1) → Escape
if (gp.buttons[1]?.pressed) {
if (now - (lastInput.current['b'] || 0) < DEBOUNCE_MS) continue;
lastInput.current['b'] = now;
document.dispatchEvent(new KeyboardEvent('keydown', {
key: 'Escape', bubbles: true, cancelable: true,
}));
}
// Start (9) → Enter on first focusable
if (gp.buttons[9]?.pressed) {
if (now - (lastInput.current['start'] || 0) < DEBOUNCE_MS) continue;
lastInput.current['start'] = now;
const firstBtn = document.querySelector<HTMLElement>('button, [tabindex]:not([tabindex="-1"]), a, input');
firstBtn?.focus();
}
}
raf = requestAnimationFrame(poll);
};
raf = requestAnimationFrame(poll);
return () => cancelAnimationFrame(raf);
}, []);
}
+191
View File
@@ -0,0 +1,191 @@
import React, { useEffect, useState, useRef, useCallback } from 'react';
import type { EmulatorState } from '../../shared/types';
interface ControllerState {
index: number;
id: string;
buttons: { pressed: boolean; value: number }[];
axes: number[];
connected: boolean;
}
const buttonLabels = [
'A', 'B', 'X', 'Y', 'LB', 'RB', 'LT', 'RT',
'Back', 'Start', 'L3', 'R3', 'DPad-Up', 'DPad-Down', 'DPad-Left', 'DPad-Right',
'Home',
];
export function ControllerPage() {
const [controllers, setControllers] = useState<ControllerState[]>([]);
const [emulators, setEmulators] = useState<EmulatorState[]>([]);
const [configStatus, setConfigStatus] = useState<string>('');
const rafRef = useRef<number>(0);
const poll = useCallback(() => {
const gamepads = navigator.getGamepads();
const connected: ControllerState[] = [];
for (const gp of gamepads) {
if (gp && gp.connected) {
connected.push({
index: gp.index,
id: gp.id,
buttons: gp.buttons.map(b => ({ pressed: b.pressed, value: b.value })),
axes: Array.from(gp.axes),
connected: true,
});
}
}
setControllers(prev => {
const same = prev.length === connected.length &&
prev.every((c, i) => c.id === connected[i]?.id && c.index === connected[i]?.index);
return same ? prev : connected;
});
rafRef.current = requestAnimationFrame(poll);
}, []);
useEffect(() => {
const onConnected = (e: GamepadEvent) => {
setControllers(prev => [...prev.filter(c => c.index !== e.gamepad.index), {
index: e.gamepad.index,
id: e.gamepad.id,
buttons: [],
axes: [],
connected: true,
}]);
};
const onDisconnected = (e: GamepadEvent) => {
setControllers(prev => prev.filter(c => c.index !== e.gamepad.index));
};
window.addEventListener('gamepadconnected', onConnected);
window.addEventListener('gamepaddisconnected', onDisconnected);
poll();
(async () => {
const states = await window.omni.emulators.states();
setEmulators(states.filter(s => s.installed && s.path));
})();
return () => {
window.removeEventListener('gamepadconnected', onConnected);
window.removeEventListener('gamepaddisconnected', onDisconnected);
cancelAnimationFrame(rafRef.current);
};
}, [poll]);
const handleUpdateConfig = async (emulatorId: string, installPath: string, controllerId?: string) => {
setConfigStatus(`Configuring ${emulatorId}...`);
try {
const result = await window.omni.emulators.updateControllerConfig(emulatorId, installPath, controllerId);
setConfigStatus(result ? `${emulatorId} controller config applied` : `${emulatorId}: no config available`);
} catch (e: any) {
setConfigStatus(`Error: ${e.message}`);
}
setTimeout(() => setConfigStatus(''), 4000);
};
return (
<div>
{controllers.length === 0 && (
<div className="empty-state">
<div className="empty-state-icon">🎮</div>
<h3>No Controller Detected</h3>
<p>Connect a gamepad and press a button to start</p>
</div>
)}
{controllers.map((ctrl) => (
<div key={ctrl.index} className="card mb-4">
<div className="card-header">
<h3>Controller {ctrl.index + 1}</h3>
<span className="badge badge-installed">Connected</span>
</div>
<p className="text-sm text-muted mb-2">{ctrl.id}</p>
<div className="controller-buttons">
{ctrl.buttons.length > 0 && buttonLabels.map((label, i) => {
const btn = ctrl.buttons[i];
if (!btn) return null;
return (
<div
key={i}
className={`controller-btn ${btn.pressed ? 'pressed' : ''}`}
>
<span className="controller-btn-label">{label}</span>
<span className="controller-btn-value">{btn.value.toFixed(2)}</span>
</div>
);
})}
</div>
{ctrl.axes.length > 0 && (
<div className="mt-2">
<p className="text-sm text-muted mb-2">Axes</p>
<div className="controller-axes">
{ctrl.axes.map((val, i) => (
<div key={i} className="axis-bar">
<span className="axis-label">Axis {i}</span>
<div className="axis-track">
<div
className="axis-fill"
style={{ left: `${((val + 1) / 2) * 100}%` }}
/>
</div>
<span className="axis-value">{val.toFixed(2)}</span>
</div>
))}
</div>
</div>
)}
</div>
))}
{emulators.length > 0 && (
<div>
<h3 className="mb-2" style={{ fontSize: 16, fontWeight: 600 }}>
Emulator Controller Config
</h3>
<p className="text-sm text-muted mb-4">
Apply controller bindings to installed emulators so they recognize your gamepad.
</p>
{configStatus && (
<div className="info-bar" style={{ color: 'var(--success)', marginBottom: 8 }}>
{configStatus}
</div>
)}
<div className="card-grid">
{emulators.map((emu) => (
<div key={emu.config.id} className="card">
<div className="card-header">
<h3>{emu.config.name}</h3>
<span className={`badge ${emu.configured ? 'badge-installed' : 'badge-missing'}`}>
{emu.configured ? 'Configured' : 'Not Configured'}
</span>
</div>
<p className="text-sm text-muted">{emu.config.description}</p>
<div style={{ marginTop: 8 }}>
{emu.config.platforms.map((p) => (
<span className="platform-tag" key={p}>{p}</span>
))}
</div>
<button
className="btn btn-primary btn-sm mt-4"
onClick={() => handleUpdateConfig(emu.config.id, emu.path!, controllers[0]?.id)}
>
Update Controller Config
</button>
</div>
))}
</div>
</div>
)}
{!controllers.length && !emulators.length && (
<div className="loading">Loading emulators...</div>
)}
</div>
);
}
+44 -10
View File
@@ -1,19 +1,22 @@
import React, { useEffect, useState } from 'react';
import type { EmulatorState, SystemInfo } from '../../shared/types';
import type { EmulatorState, SystemInfo, GameEntry } from '../../shared/types';
export function Dashboard() {
export function Dashboard({ onNavigate }: { onNavigate?: (tab: string) => void }) {
const [emulators, setEmulators] = useState<EmulatorState[]>([]);
const [system, setSystem] = useState<SystemInfo | null>(null);
const [recent, setRecent] = useState<GameEntry[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
async function load() {
const [states, info] = await Promise.all([
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);
}
load();
@@ -21,7 +24,6 @@ export function Dashboard() {
const installed = emulators.filter((e) => e.installed).length;
const total = emulators.filter((e) => e.config.supported).length;
const gamesCount = 0; // would come from library scan
if (loading) {
return <div className="loading">Loading dashboard...</div>;
@@ -66,9 +68,9 @@ export function Dashboard() {
<h3>Game Library</h3>
</div>
<p style={{ fontSize: 32, fontWeight: 700, color: 'var(--accent)' }}>
{gamesCount}
{recent.filter(g => g.playCount > 0).length || 0}
</p>
<p>games in your library</p>
<p>games played</p>
</div>
<div className="card">
@@ -88,14 +90,46 @@ export function Dashboard() {
</div>
</div>
{recent.length > 0 && (
<>
<h3 className="mt-4 mb-2" style={{ fontSize: 16, fontWeight: 600 }}>
Resume Games
</h3>
<div className="library-grid">
{recent.slice(0, 6).map((game) => (
<div
key={game.id}
className="game-card"
onClick={async () => {
await window.omni.game.launch(game.emulatorId, game.romPath);
const updated = await window.omni.game.recent();
setRecent(updated || []);
}}
>
<div className="game-card-cover">
{game.coverUrl ? (
<img src={game.coverUrl} alt={game.title} />
) : (
<div className="game-card-placeholder">
{game.platform.slice(0, 2).toUpperCase()}
</div>
)}
</div>
<div className="game-card-info">
<strong>{game.title}</strong>
<span className="text-muted text-sm">{game.lastPlayed ? new Date(game.lastPlayed).toLocaleDateString() : ''}</span>
</div>
</div>
))}
</div>
</>
)}
<h3 className="mt-4 mb-2" style={{ fontSize: 16, fontWeight: 600 }}>
Quick Actions
</h3>
<div style={{ display: 'flex', gap: 8 }}>
<button className="btn btn-primary" onClick={() => {
const nav = document.querySelector('[data-nav-emulators]') as HTMLButtonElement;
nav?.click();
}}>
<button className="btn btn-primary" onClick={() => onNavigate?.('emulators')}>
Manage Emulators
</button>
<button className="btn btn-secondary" onClick={() => {
+33
View File
@@ -81,6 +81,20 @@ export function EmulatorsPage() {
await load();
};
const handleOpen = async (id: string) => {
setActioning(id);
await window.omni.emulators.launch(id);
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);
setActioning(null);
await load();
};
const handleOpenWebsite = async (id: string) => {
await window.omni.emulators.openWebsite(id);
};
@@ -241,6 +255,25 @@ export function EmulatorsPage() {
</>
)}
{state.installed && (
<>
<button
className="btn btn-primary btn-sm"
disabled={isActioning}
onClick={() => handleOpen(state.config.id)}
>
Open
</button>
<button
className="btn btn-danger btn-sm"
disabled={isActioning}
onClick={() => handleUninstall(state.config.id)}
>
Uninstall
</button>
</>
)}
{state.config.websiteUrl && (
<button
className="btn-icon"
+34 -8
View File
@@ -14,7 +14,6 @@ export function LibraryPage() {
const [loading, setLoading] = useState(false);
const [romsDir, setRomsDir] = useState<string>('');
// Load saved ROM directory on mount
useEffect(() => {
(async () => {
const settings = await window.omni.settings.get();
@@ -22,19 +21,28 @@ export function LibraryPage() {
setRomsDir(settings.romsDirectory);
setLoading(true);
const results = await window.omni.roms.scan(settings.romsDirectory);
setGames(results);
setGames(await scrapeMissingArt(results));
setLoading(false);
}
})();
}, []);
const scrapeMissingArt = async (list: GameEntry[]): Promise<GameEntry[]> => {
return Promise.all(list.map(async (g) => {
if (!g.coverUrl) {
const url = await window.omni.game.scrapeArt(g.title, g.platform);
if (url) g.coverUrl = url;
}
return g;
}));
};
const scanDirectory = useCallback(async (dir: string) => {
setRomsDir(dir);
setLoading(true);
// Save to persistent settings
await window.omni.settings.save({ romsDirectory: dir });
const results = await window.omni.roms.scan(dir);
setGames(results);
setGames(await scrapeMissingArt(results));
setLoading(false);
}, []);
@@ -90,9 +98,18 @@ export function LibraryPage() {
<div>
<div className="info-bar">
<span>{games.length} games found</span>
<button className="btn btn-secondary btn-sm" onClick={handleScan}>
Rescan
</button>
<div style={{ display: 'flex', gap: 6 }}>
<button className="btn btn-secondary btn-sm" onClick={async () => {
setLoading(true);
setGames(await scrapeMissingArt(games));
setLoading(false);
}}>
Scrape All Art
</button>
<button className="btn btn-secondary btn-sm" onClick={handleScan}>
Rescan
</button>
</div>
</div>
<div className="library-grid">
@@ -104,7 +121,16 @@ export function LibraryPage() {
title={`Launch ${game.title} via ${game.emulatorId}`}
>
<div className="game-card-cover">
{platformIcons[game.platform] || '🎮'}
{game.coverUrl ? (
<img
src={game.coverUrl}
alt={game.title}
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }}
/>
) : (
<span style={{ fontSize: 32 }}>{platformIcons[game.platform] || '🎮'}</span>
)}
</div>
<div className="game-card-info">
<div className="game-card-title">{game.title}</div>
+9
View File
@@ -1,5 +1,6 @@
import React, { useEffect, useState } from 'react';
import type { AppSettings } from '../../shared/types';
import { BiosCheckPanel } from '../components/BiosCheckPanel';
export function SettingsPage() {
const [settings, setSettings] = useState<AppSettings | null>(null);
@@ -63,6 +64,14 @@ export function SettingsPage() {
</div>
</div>
<div className="settings-section">
<h3>BIOS</h3>
<BiosCheckPanel
biosDir={settings.biosDirectory}
onBiosDirChange={(dir) => update({ biosDirectory: dir })}
/>
</div>
<div className="settings-section">
<h3>Appearance</h3>
+166
View File
@@ -271,6 +271,17 @@ html, body, #root {
color: var(--text-primary);
}
.btn-danger {
background: color-mix(in srgb, var(--error) 20%, transparent);
border: 1px solid var(--error);
color: var(--error);
}
.btn-danger:hover {
background: var(--error);
color: #fff;
}
/* Game library */
.library-grid {
display: grid;
@@ -538,3 +549,158 @@ select:focus, input[type="text"]:focus {
font-size: 14px;
margin-top: 8px;
}
/* Controller */
.controller-buttons {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 8px;
}
.controller-btn {
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
padding: 8px 12px;
border-radius: var(--radius-sm);
background: var(--bg-tertiary);
border: 1px solid var(--border);
min-width: 56px;
transition: all var(--transition);
}
.controller-btn.pressed {
background: var(--accent-dim);
border-color: var(--accent);
box-shadow: 0 0 12px rgba(108, 99, 255, 0.4);
}
.controller-btn-label {
font-size: 11px;
font-weight: 600;
color: var(--text-secondary);
}
.controller-btn-value {
font-size: 10px;
color: var(--text-muted);
}
.controller-btn.pressed .controller-btn-value {
color: var(--accent);
}
.controller-axes {
display: flex;
flex-direction: column;
gap: 6px;
}
.axis-bar {
display: flex;
align-items: center;
gap: 8px;
}
.axis-label {
font-size: 11px;
color: var(--text-muted);
min-width: 44px;
}
.axis-track {
flex: 1;
height: 6px;
background: var(--bg-tertiary);
border-radius: 3px;
position: relative;
overflow: visible;
}
.axis-fill {
position: absolute;
top: -2px;
width: 10px;
height: 10px;
background: var(--accent);
border-radius: 50%;
transform: translateX(-50%);
transition: left 50ms linear;
}
.axis-value {
font-size: 10px;
color: var(--text-muted);
min-width: 32px;
text-align: right;
}
/* BIOS */
.bios-grid {
display: flex;
flex-direction: column;
gap: 6px;
}
.bios-entry {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 10px 12px;
}
.bios-entry-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 6px;
}
.bios-indicator {
width: 20px;
height: 20px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 11px;
font-weight: 700;
flex-shrink: 0;
}
.bios-indicator.present {
background: rgba(74, 222, 128, 0.2);
color: var(--success);
}
.bios-indicator.missing {
background: rgba(248, 113, 113, 0.2);
color: var(--error);
}
.bios-entry-name {
font-size: 13px;
font-weight: 600;
}
.bios-entry-files {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.bios-file {
font-size: 10px;
font-family: monospace;
padding: 2px 6px;
border-radius: 3px;
background: var(--bg-tertiary);
color: var(--text-muted);
}
.bios-file.found {
background: rgba(74, 222, 128, 0.15);
color: var(--success);
}
+4
View File
@@ -88,6 +88,10 @@ export interface AppSettings {
closeToTray: boolean;
/** URL to fetch recommended config presets from */
presetSourceUrl: string;
/** Recently played games (max 10) */
recentGames: GameEntry[];
/** Directory for BIOS files */
biosDirectory: string;
}
export interface SystemInfo {