From 3ae0b812c92da841ce2e3b5a2e91f6419e06027f Mon Sep 17 00:00:00 2001 From: mileswa1q22 Date: Thu, 9 Jul 2026 03:31:59 -0500 Subject: [PATCH] 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. --- .github/workflows/build.yml | 19 +- src/main/bios.ts | 162 +++++++++++++ src/main/configurator.ts | 131 ++++++++++- src/main/emulators.ts | 255 +++++++++++++++++---- src/main/installer.ts | 140 +++++++---- src/main/ipc.ts | 90 +++++++- src/main/preload.ts | 26 +++ src/main/scraper.ts | 108 +++++++++ src/main/settings.ts | 2 + src/renderer/App.tsx | 10 +- src/renderer/components/BiosCheckPanel.tsx | 122 ++++++++++ src/renderer/components/Sidebar.tsx | 3 +- src/renderer/hooks/useGamepadNav.ts | 99 ++++++++ src/renderer/pages/ControllerPage.tsx | 191 +++++++++++++++ src/renderer/pages/Dashboard.tsx | 54 ++++- src/renderer/pages/EmulatorsPage.tsx | 33 +++ src/renderer/pages/LibraryPage.tsx | 42 +++- src/renderer/pages/SettingsPage.tsx | 9 + src/renderer/styles.css | 166 ++++++++++++++ src/shared/types.ts | 4 + 20 files changed, 1532 insertions(+), 134 deletions(-) create mode 100644 src/main/bios.ts create mode 100644 src/main/scraper.ts create mode 100644 src/renderer/components/BiosCheckPanel.tsx create mode 100644 src/renderer/hooks/useGamepadNav.ts create mode 100644 src/renderer/pages/ControllerPage.tsx diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 799dfc7..ba28578 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -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 + diff --git a/src/main/bios.ts b/src/main/bios.ts new file mode 100644 index 0000000..9f89e56 --- /dev/null +++ b/src/main/bios.ts @@ -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; +} diff --git a/src/main/configurator.ts b/src/main/configurator.ts index fb0cc7a..7865523 100644 --- a/src/main/configurator.ts +++ b/src/main/configurator.ts @@ -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; +} diff --git a/src/main/emulators.ts b/src/main/emulators.ts index 9006757..c9ae0f1 100644 --- a/src/main/emulators.ts +++ b/src/main/emulators.ts @@ -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 = { + '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 = { '.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'; diff --git a/src/main/installer.ts b/src/main/installer.ts index 5bd9aeb..2a53bee 100644 --- a/src/main/installer.ts +++ b/src/main/installer.ts @@ -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 { 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); +} diff --git a/src/main/ipc.ts b/src/main/ipc.ts index 6aedc86..d71c4b3 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -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) => @@ -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()); diff --git a/src/main/preload.ts b/src/main/preload.ts index 1788385..d668a34 100644 --- a/src/main/preload.ts +++ b/src/main/preload.ts @@ -25,6 +25,14 @@ const api = { install: (id: string): Promise => ipcRenderer.invoke('emulators:install', id), + /** Launch emulator standalone (no ROM) */ + launch: (id: string): Promise => + 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 => ipcRenderer.invoke('emulators:open-website', id), + /** Apply controller config to an installed emulator */ + updateControllerConfig: (id: string, installPath: string, controllerName?: string): Promise => + 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 => ipcRenderer.invoke('game:launch', emulatorId, romPath), + recent: (): Promise => ipcRenderer.invoke('games:recent'), + clearRecent: (): Promise => ipcRenderer.invoke('games:clear-recent'), + scrapeArt: (title: string, platform: string): Promise => + ipcRenderer.invoke('games:scrape-art', title, platform), + }, + + bios: { + listKnown: (): Promise => ipcRenderer.invoke('bios:list-known'), + scan: (directory?: string): Promise => + ipcRenderer.invoke('bios:scan', directory), + selectDirectory: (): Promise => + ipcRenderer.invoke('bios:select-directory'), + configureRetroArch: (configDir: string, biosDir: string): Promise => + ipcRenderer.invoke('bios:configure-retroarch', configDir, biosDir), }, settings: { diff --git a/src/main/scraper.ts b/src/main/scraper.ts new file mode 100644 index 0000000..71e530f --- /dev/null +++ b/src/main/scraper.ts @@ -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 = { + '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 { + 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(); + + 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 { + 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); +} diff --git a/src/main/settings.ts b/src/main/settings.ts index fc09929..2013b66 100644 --- a/src/main/settings.ts +++ b/src/main/settings.ts @@ -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; diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index d2c19c3..02442e0 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -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('dashboard'); const renderPage = () => { switch (currentPage) { case 'dashboard': - return ; + return ; case 'emulators': return ; case 'library': return ; case 'settings': return ; + case 'controller': + return ; } }; @@ -28,6 +33,7 @@ export function App() { emulators: 'Emulators', library: 'Game Library', settings: 'Settings', + controller: 'Controller', }; return ( diff --git a/src/renderer/components/BiosCheckPanel.tsx b/src/renderer/components/BiosCheckPanel.tsx new file mode 100644 index 0000000..e1a0967 --- /dev/null +++ b/src/renderer/components/BiosCheckPanel.tsx @@ -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([]); + 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 ( +
+
+
+
BIOS Directory
+
+ {biosDir || 'Not set — default locations will be scanned'} +
+
+ +
+ + {retroarchConfigMsg && ( +
+ {retroarchConfigMsg} +
+ )} + + {loading &&
Scanning BIOS files...
} + + {!loading && results.length > 0 && ( +
+
+ {presentCount} of {totalCount} BIOS files found + + +
+ +
+ {results.map((r, i) => ( +
+
+ + {r.present ? '✓' : '✗'} + + {r.entry.name} + {r.entry.platform} +
+
+ {r.entry.files.map(f => ( + + {f} + + ))} +
+
+ ))} +
+
+ )} + + {!loading && results.length === 0 && !biosDir && ( +

+ Select a BIOS directory to scan for required firmware files. +

+ )} +
+ ); +} diff --git a/src/renderer/components/Sidebar.tsx b/src/renderer/components/Sidebar.tsx index c389424..6799eb0 100644 --- a/src/renderer/components/Sidebar.tsx +++ b/src/renderer/components/Sidebar.tsx @@ -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: 'âš™ī¸' }, ]; diff --git a/src/renderer/hooks/useGamepadNav.ts b/src/renderer/hooks/useGamepadNav.ts new file mode 100644 index 0000000..e5153a0 --- /dev/null +++ b/src/renderer/hooks/useGamepadNav.ts @@ -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>({}); + + 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 = { + 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('button, [tabindex]:not([tabindex="-1"]), a, input'); + firstBtn?.focus(); + } + } + + raf = requestAnimationFrame(poll); + }; + + raf = requestAnimationFrame(poll); + return () => cancelAnimationFrame(raf); + }, []); +} diff --git a/src/renderer/pages/ControllerPage.tsx b/src/renderer/pages/ControllerPage.tsx new file mode 100644 index 0000000..9c12bdb --- /dev/null +++ b/src/renderer/pages/ControllerPage.tsx @@ -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([]); + const [emulators, setEmulators] = useState([]); + const [configStatus, setConfigStatus] = useState(''); + const rafRef = useRef(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 ( +
+ {controllers.length === 0 && ( +
+
🎮
+

No Controller Detected

+

Connect a gamepad and press a button to start

+
+ )} + + {controllers.map((ctrl) => ( +
+
+

Controller {ctrl.index + 1}

+ Connected +
+

{ctrl.id}

+ +
+ {ctrl.buttons.length > 0 && buttonLabels.map((label, i) => { + const btn = ctrl.buttons[i]; + if (!btn) return null; + return ( +
+ {label} + {btn.value.toFixed(2)} +
+ ); + })} +
+ + {ctrl.axes.length > 0 && ( +
+

Axes

+
+ {ctrl.axes.map((val, i) => ( +
+ Axis {i} +
+
+
+ {val.toFixed(2)} +
+ ))} +
+
+ )} +
+ ))} + + {emulators.length > 0 && ( +
+

+ Emulator Controller Config +

+

+ Apply controller bindings to installed emulators so they recognize your gamepad. +

+ + {configStatus && ( +
+ {configStatus} +
+ )} + +
+ {emulators.map((emu) => ( +
+
+

{emu.config.name}

+ + {emu.configured ? 'Configured' : 'Not Configured'} + +
+

{emu.config.description}

+
+ {emu.config.platforms.map((p) => ( + {p} + ))} +
+ +
+ ))} +
+
+ )} + + {!controllers.length && !emulators.length && ( +
Loading emulators...
+ )} +
+ ); +} diff --git a/src/renderer/pages/Dashboard.tsx b/src/renderer/pages/Dashboard.tsx index 8747658..3a0c4c4 100644 --- a/src/renderer/pages/Dashboard.tsx +++ b/src/renderer/pages/Dashboard.tsx @@ -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([]); const [system, setSystem] = useState(null); + const [recent, setRecent] = useState([]); 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
Loading dashboard...
; @@ -66,9 +68,9 @@ export function Dashboard() {

Game Library

- {gamesCount} + {recent.filter(g => g.playCount > 0).length || 0}

-

games in your library

+

games played

@@ -88,14 +90,46 @@ export function Dashboard() {
+ {recent.length > 0 && ( + <> +

+ Resume Games +

+
+ {recent.slice(0, 6).map((game) => ( +
{ + await window.omni.game.launch(game.emulatorId, game.romPath); + const updated = await window.omni.game.recent(); + setRecent(updated || []); + }} + > +
+ {game.coverUrl ? ( + {game.title} + ) : ( +
+ {game.platform.slice(0, 2).toUpperCase()} +
+ )} +
+
+ {game.title} + {game.lastPlayed ? new Date(game.lastPlayed).toLocaleDateString() : ''} +
+
+ ))} +
+ + )} +

Quick Actions

- + + + )} + {state.config.websiteUrl && ( +
+ + +
@@ -104,7 +121,16 @@ export function LibraryPage() { title={`Launch ${game.title} via ${game.emulatorId}`} >
- {platformIcons[game.platform] || '🎮'} + {game.coverUrl ? ( + {game.title} { (e.target as HTMLImageElement).style.display = 'none'; }} + /> + ) : ( + {platformIcons[game.platform] || '🎮'} + )}
{game.title}
diff --git a/src/renderer/pages/SettingsPage.tsx b/src/renderer/pages/SettingsPage.tsx index 66a3c94..d832736 100644 --- a/src/renderer/pages/SettingsPage.tsx +++ b/src/renderer/pages/SettingsPage.tsx @@ -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(null); @@ -63,6 +64,14 @@ export function SettingsPage() {
+
+

BIOS

+ update({ biosDirectory: dir })} + /> +
+

Appearance

diff --git a/src/renderer/styles.css b/src/renderer/styles.css index 09a7dc3..9528416 100644 --- a/src/renderer/styles.css +++ b/src/renderer/styles.css @@ -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); +} diff --git a/src/shared/types.ts b/src/shared/types.ts index 5413534..9e9b2c3 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -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 {