mirror of
https://github.com/mileswolfallen2/OmniEmu2.0.git
synced 2026-09-08 11:23:17 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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());
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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: '⚙️' },
|
||||
];
|
||||
|
||||
|
||||
@@ -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);
|
||||
}, []);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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={() => {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user