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
|
path: release/*.dmg
|
||||||
if-no-files-found: ignore
|
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',
|
name: 'OmniEmu Recommended',
|
||||||
description: 'Best settings for Ryujinx Switch emulation',
|
description: 'Best settings for Eden Switch emulation',
|
||||||
files: {
|
files: {
|
||||||
'Config.json': `{
|
'Config.json': `{
|
||||||
"graphics_backend": "Vulkan",
|
"graphics_backend": "Vulkan",
|
||||||
@@ -149,6 +149,29 @@ waitvsync 1
|
|||||||
syncrefresh 0
|
syncrefresh 0
|
||||||
sleep 0
|
sleep 0
|
||||||
autosave 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'),
|
darwin: join(require('os').homedir(), 'Library', 'Application Support', 'rpcs3'),
|
||||||
linux: join(require('os').homedir(), '.config', '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: {
|
pcsx2: {
|
||||||
win32: join(process.env.APPDATA || '', 'PCSX2'),
|
win32: join(process.env.APPDATA || '', 'PCSX2'),
|
||||||
darwin: join(require('os').homedir(), 'Library', 'Application Support', 'PCSX2'),
|
darwin: join(require('os').homedir(), 'Library', 'Application Support', 'PCSX2'),
|
||||||
@@ -219,6 +237,16 @@ function getConfigDir(emulatorId: string, installPath: string): string {
|
|||||||
darwin: dirname(installPath),
|
darwin: dirname(installPath),
|
||||||
linux: join(require('os').homedir(), '.mame'),
|
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);
|
return platformDirs[emulatorId]?.[platform] || dirname(installPath);
|
||||||
}
|
}
|
||||||
@@ -280,3 +308,92 @@ export async function applyRecommendedConfig(
|
|||||||
await applyPreset(emulatorId, presets[0], installPath, onProgress);
|
await applyPreset(emulatorId, presets[0], installPath, onProgress);
|
||||||
return true;
|
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 { 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 { join, dirname, basename, extname } from 'path';
|
||||||
import { app } from 'electron';
|
import { app } from 'electron';
|
||||||
import {
|
import {
|
||||||
@@ -94,43 +94,41 @@ export const knownEmulators: EmulatorConfig[] = [
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'ryujinx',
|
id: 'eden',
|
||||||
name: 'Ryujinx',
|
name: 'Eden',
|
||||||
description: 'Nintendo Switch emulator',
|
description: 'Nintendo Switch emulator (community fork)',
|
||||||
platforms: ['switch'],
|
platforms: ['switch'],
|
||||||
defaultPath: {
|
defaultPath: {
|
||||||
win32: 'C:\\Program Files\\Ryujinx\\Ryujinx.exe',
|
win32: 'C:\\Program Files\\Eden\\Eden.exe',
|
||||||
darwin: '/Applications/Ryujinx.app/Contents/MacOS/Ryujinx',
|
darwin: '/Applications/Eden.app/Contents/MacOS/Eden',
|
||||||
linux: '/usr/bin/Ryujinx',
|
linux: '/usr/bin/eden',
|
||||||
},
|
},
|
||||||
downloads: {
|
downloads: {
|
||||||
win32: [
|
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',
|
format: 'zip',
|
||||||
executablePath: 'Ryujinx.exe',
|
executablePath: 'Eden.exe',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
darwin: [
|
darwin: [
|
||||||
{
|
{
|
||||||
url: 'https://github.com/Ryujinx/release-channel-master/releases/latest/download/ryujinx-1.2.0-mac_universal.zip',
|
url: 'https://master.eden-emu.dev/v1783561671.41762940d6/Eden-macOS-41762940d6.dmg',
|
||||||
format: 'zip',
|
format: 'dmg',
|
||||||
executablePath: 'Ryujinx.app/Contents/MacOS/Ryujinx',
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
linux: [
|
linux: [
|
||||||
{
|
{
|
||||||
url: 'https://github.com/Ryujinx/release-channel-master/releases/latest/download/ryujinx-1.2.0-linux_x64.zip',
|
url: 'https://master.eden-emu.dev/v1783561671.41762940d6/Eden-Linux-41762940d6-amd64-gcc-standard.AppImage',
|
||||||
format: 'zip',
|
format: 'appimage',
|
||||||
executablePath: 'Ryujinx',
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
supported: true,
|
supported: true,
|
||||||
websiteUrl: {
|
websiteUrl: {
|
||||||
win32: 'https://ryujinx.org/download',
|
win32: 'https://eden-emu.dev/',
|
||||||
darwin: 'https://ryujinx.org/download',
|
darwin: 'https://eden-emu.dev/',
|
||||||
linux: 'https://ryujinx.org/download',
|
linux: 'https://eden-emu.dev/',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -210,6 +208,58 @@ export const knownEmulators: EmulatorConfig[] = [
|
|||||||
linux: 'https://www.mamedev.org/release.html',
|
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',
|
id: 'retroarch',
|
||||||
name: 'RetroArch',
|
name: 'RetroArch',
|
||||||
@@ -226,23 +276,41 @@ export const knownEmulators: EmulatorConfig[] = [
|
|||||||
downloads: {
|
downloads: {
|
||||||
win32: [
|
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',
|
format: '7z',
|
||||||
executablePath: 'RetroArch.exe',
|
executablePath: 'RetroArch.exe',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
url: 'https://buildbot.libretro.com/stable/1.22.2/windows/x86_64/RetroArch_cores.7z',
|
||||||
|
format: '7z',
|
||||||
|
},
|
||||||
],
|
],
|
||||||
darwin: [
|
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',
|
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: [
|
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',
|
format: '7z',
|
||||||
executablePath: 'retroarch',
|
executablePath: 'retroarch',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
url: 'https://buildbot.libretro.com/stable/1.22.2/linux/x86_64/RetroArch_cores.7z',
|
||||||
|
format: '7z',
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
packageNames: {
|
packageNames: {
|
||||||
@@ -317,9 +385,18 @@ function alternativePaths(emulatorId: string): string[] {
|
|||||||
join(omniEmuDir, 'rpcs3'),
|
join(omniEmuDir, 'rpcs3'),
|
||||||
join(omniEmuDir, 'RPCS3.AppImage'),
|
join(omniEmuDir, 'RPCS3.AppImage'),
|
||||||
],
|
],
|
||||||
ryujinx: [
|
eden: [
|
||||||
join(omniEmuDir, 'Ryujinx.exe'),
|
join(omniEmuDir, 'Eden.exe'),
|
||||||
join(omniEmuDir, 'Ryujinx'),
|
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: [
|
pcsx2: [
|
||||||
join(omniEmuDir, 'pcsx2.exe'),
|
join(omniEmuDir, 'pcsx2.exe'),
|
||||||
@@ -335,6 +412,32 @@ function alternativePaths(emulatorId: string): string[] {
|
|||||||
return common[emulatorId] || [join(omniEmuDir)];
|
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 {
|
export function checkEmulator(id: string): EmulatorState {
|
||||||
const config = findEmulator(id);
|
const config = findEmulator(id);
|
||||||
if (!config) {
|
if (!config) {
|
||||||
@@ -354,17 +457,7 @@ export function checkEmulator(id: string): EmulatorState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const path = detectEmulatorPath(config);
|
const path = detectEmulatorPath(config);
|
||||||
let version: string | undefined;
|
const version = path ? detectVersion(path) : 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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
installed: !!path,
|
installed: !!path,
|
||||||
@@ -379,6 +472,16 @@ export function getAllEmulatorStates(): EmulatorState[] {
|
|||||||
return knownEmulators.map((e) => checkEmulator(e.id));
|
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 {
|
export function launchGame(emulatorId: string, romPath: string): ChildProcess | null {
|
||||||
const state = checkEmulator(emulatorId);
|
const state = checkEmulator(emulatorId);
|
||||||
if (!state.installed || !state.path) return null;
|
if (!state.installed || !state.path) return null;
|
||||||
@@ -396,20 +499,74 @@ export function launchGame(emulatorId: string, romPath: string): ChildProcess |
|
|||||||
return proc;
|
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 {
|
function launchArgs(emulatorId: string, romPath: string): string {
|
||||||
switch (emulatorId) {
|
switch (emulatorId) {
|
||||||
case 'dolphin':
|
case 'dolphin':
|
||||||
return `--exec="${romPath}"`;
|
return `--exec="${romPath}"`;
|
||||||
case 'rpcs3':
|
case 'rpcs3':
|
||||||
return `"${romPath}"`;
|
return `"${romPath}"`;
|
||||||
case 'ryujinx':
|
case 'eden':
|
||||||
return `"${romPath}"`;
|
return `"${romPath}"`;
|
||||||
case 'pcsx2':
|
case 'pcsx2':
|
||||||
return `"${romPath}"`;
|
return `"${romPath}"`;
|
||||||
case 'mame':
|
case 'mame':
|
||||||
return `"${romPath}"`;
|
return `"${romPath}"`;
|
||||||
case 'retroarch':
|
case 'retroarch': {
|
||||||
return `-L "${romPath}"`;
|
const core = findRetroArchCore(romPath);
|
||||||
|
if (core) return `-L "${core}" "${romPath}"`;
|
||||||
|
return `"${romPath}"`;
|
||||||
|
}
|
||||||
|
case 'duckstation':
|
||||||
|
return `"${romPath}"`;
|
||||||
default:
|
default:
|
||||||
return `"${romPath}"`;
|
return `"${romPath}"`;
|
||||||
}
|
}
|
||||||
@@ -492,6 +649,23 @@ function guessPlatform(ext: string): string {
|
|||||||
return map[ext] || 'other';
|
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 {
|
function guessEmulator(ext: string): string {
|
||||||
const map: Record<string, string> = {
|
const map: Record<string, string> = {
|
||||||
'.nes': 'retroarch', '.sfc': 'retroarch', '.smc': 'retroarch',
|
'.nes': 'retroarch', '.sfc': 'retroarch', '.smc': 'retroarch',
|
||||||
@@ -499,8 +673,11 @@ function guessEmulator(ext: string): string {
|
|||||||
'.gba': 'retroarch', '.gb': 'retroarch', '.gbc': 'retroarch',
|
'.gba': 'retroarch', '.gb': 'retroarch', '.gbc': 'retroarch',
|
||||||
'.wbfs': 'dolphin', '.wad': 'dolphin', '.gcm': 'dolphin',
|
'.wbfs': 'dolphin', '.wad': 'dolphin', '.gcm': 'dolphin',
|
||||||
'.gcz': 'dolphin', '.rvz': 'dolphin',
|
'.gcz': 'dolphin', '.rvz': 'dolphin',
|
||||||
'.nsp': 'ryujinx', '.xci': 'ryujinx',
|
'.nsp': 'eden', '.xci': 'eden', '.nca': 'eden',
|
||||||
'.pkg': 'rpcs3',
|
'.pkg': 'rpcs3',
|
||||||
|
'.bin': 'duckstation', '.cue': 'duckstation', '.iso': 'duckstation',
|
||||||
|
'.img': 'duckstation', '.m3u': 'duckstation', '.pbp': 'duckstation',
|
||||||
|
'.chd': 'duckstation', '.ecm': 'duckstation', '.mds': 'duckstation',
|
||||||
'.ps2': 'pcsx2', '.cso': 'pcsx2',
|
'.ps2': 'pcsx2', '.cso': 'pcsx2',
|
||||||
};
|
};
|
||||||
return map[ext] || 'retroarch';
|
return map[ext] || 'retroarch';
|
||||||
|
|||||||
+95
-45
@@ -63,27 +63,41 @@ function execOrThrow(cmd: string): string {
|
|||||||
|
|
||||||
function findAppInDir(dir: string): string | undefined {
|
function findAppInDir(dir: string): string | undefined {
|
||||||
const entries = readdirSync(dir, { withFileTypes: true });
|
const entries = readdirSync(dir, { withFileTypes: true });
|
||||||
for (const e of entries) {
|
const dirs = entries.filter(e => e.isDirectory());
|
||||||
const full = join(dir, e.name);
|
const files = entries.filter(e => e.isFile());
|
||||||
if (e.isDirectory()) {
|
|
||||||
if (e.name.endsWith('.app')) {
|
// Check .app bundles first (before cores or other dirs)
|
||||||
const macosBin = join(full, 'Contents', 'MacOS', basename(e.name, '.app'));
|
for (const e of dirs) {
|
||||||
if (existsSync(macosBin)) return macosBin;
|
if (e.name.endsWith('.app')) {
|
||||||
}
|
const macosBin = join(dir, e.name, 'Contents', 'MacOS', basename(e.name, '.app'));
|
||||||
const found = findAppInDir(full);
|
if (existsSync(macosBin)) return macosBin;
|
||||||
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 */ }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -188,44 +202,47 @@ export async function installEmulator(
|
|||||||
onProgress: ProgressCallback
|
onProgress: ProgressCallback
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const candidates = downloads.filter((d) => !d.arch || d.arch === arch);
|
const candidates = downloads.filter((d) => !d.arch || d.arch === arch);
|
||||||
const download = candidates[0];
|
if (candidates.length === 0) throw new Error(`No download available for ${emulatorId} on ${platform} (${arch})`);
|
||||||
if (!download) throw new Error(`No download available for ${emulatorId} on ${platform} (${arch})`);
|
|
||||||
|
|
||||||
const report = (stage: InstallProgress['stage'], percent: number, message: string) => {
|
const report = (stage: InstallProgress['stage'], percent: number, message: string) => {
|
||||||
onProgress({ emulatorId, stage, percent, message });
|
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);
|
const installDir = join(app.getPath('userData'), 'emulators', emulatorId);
|
||||||
if (!existsSync(installDir)) mkdirSync(installDir, { recursive: true });
|
if (!existsSync(installDir)) mkdirSync(installDir, { recursive: true });
|
||||||
|
|
||||||
const archiveFormats = ['zip', 'tar.gz', 'tar.bz2', '7z', 'dmg'];
|
const archiveFormats = ['zip', 'tar.gz', 'tar.bz2', '7z', 'dmg'];
|
||||||
const installerFormats = ['exe', 'msi', 'pkg', 'appimage'];
|
const installerFormats = ['exe', 'msi', 'pkg', 'appimage'];
|
||||||
|
|
||||||
if (archiveFormats.includes(download.format)) {
|
let totalSteps = candidates.length;
|
||||||
report('extracting', 0, `Extracting ${emulatorId}...`);
|
let completedSteps = 0;
|
||||||
extractArchive(downloadPath, installDir, download.format, emulatorId, (msg) => {
|
|
||||||
report('extracting', 50, msg);
|
|
||||||
});
|
|
||||||
report('extracting', 100, 'Extraction complete');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (installerFormats.includes(download.format)) {
|
for (const download of candidates) {
|
||||||
report('installing', 0, `Installing ${emulatorId}...`);
|
const stepLabel = totalSteps > 1 ? ` (${completedSteps + 1}/${totalSteps})` : '';
|
||||||
runInstaller(downloadPath, download.format, emulatorId, installDir, (msg) => {
|
report('downloading', Math.round((completedSteps / totalSteps) * 100), `Downloading ${download.url.split('/').pop()}${stepLabel}...`);
|
||||||
report('installing', 50, msg);
|
|
||||||
});
|
|
||||||
report('installing', 100, 'Installation complete');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cleanup
|
const downloadPath = tempName(`.${download.format}`);
|
||||||
try { execSync(`rm -f "${downloadPath}"`); } catch { /* ignore */ }
|
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`);
|
report('done', 100, `${emulatorId} installed`);
|
||||||
|
|
||||||
@@ -233,7 +250,40 @@ export async function installEmulator(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** After install, find the executable in the install dir */
|
/** 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;
|
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);
|
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 {
|
import {
|
||||||
getAllEmulatorStates,
|
getAllEmulatorStates,
|
||||||
checkEmulator,
|
checkEmulator,
|
||||||
|
uninstallEmulator,
|
||||||
|
launchEmulator,
|
||||||
launchGame,
|
launchGame,
|
||||||
scanRoms,
|
scanRoms,
|
||||||
knownEmulators,
|
knownEmulators,
|
||||||
@@ -12,10 +14,12 @@ import {
|
|||||||
getEmulatorsDirectory,
|
getEmulatorsDirectory,
|
||||||
} from './emulators';
|
} from './emulators';
|
||||||
import { installEmulator, findInstalledBinary } from './installer';
|
import { installEmulator, findInstalledBinary } from './installer';
|
||||||
import { applyRecommendedConfig, getPresets, checkConfigured } from './configurator';
|
import { applyRecommendedConfig, getPresets, checkConfigured, applyControllerConfig } from './configurator';
|
||||||
import { settings } from './settings';
|
import { settings } from './settings';
|
||||||
import { getSystemInfo, platformName, getPlatform, getArch } from './platform';
|
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 {
|
export function registerIpcHandlers(): void {
|
||||||
// System
|
// System
|
||||||
@@ -49,7 +53,8 @@ export function registerIpcHandlers(): void {
|
|||||||
const installDir = await installEmulator(emulatorId, downloads, platform, arch, sendProgress);
|
const installDir = await installEmulator(emulatorId, downloads, platform, arch, sendProgress);
|
||||||
|
|
||||||
// Try to find the binary and create a symlink or record it
|
// 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) {
|
if (binary) {
|
||||||
const marker = join(installDir, '.installed');
|
const marker = join(installDir, '.installed');
|
||||||
writeFileSync(marker, binary);
|
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)
|
// Open website (fallback for manual download)
|
||||||
ipcMain.handle('emulators:open-website', (_event, id: string) => {
|
ipcMain.handle('emulators:open-website', (_event, id: string) => {
|
||||||
const emu = findEmulator(id);
|
const emu = findEmulator(id);
|
||||||
@@ -115,10 +129,45 @@ export function registerIpcHandlers(): void {
|
|||||||
ipcMain.handle(
|
ipcMain.handle(
|
||||||
'game:launch',
|
'game:launch',
|
||||||
(_event, emulatorId: string, romPath: string) => {
|
(_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
|
// Settings
|
||||||
ipcMain.handle('settings:get', () => settings.get());
|
ipcMain.handle('settings:get', () => settings.get());
|
||||||
ipcMain.handle('settings:save', (_event, s: Partial<AppSettings>) =>
|
ipcMain.handle('settings:save', (_event, s: Partial<AppSettings>) =>
|
||||||
@@ -126,6 +175,39 @@ export function registerIpcHandlers(): void {
|
|||||||
);
|
);
|
||||||
ipcMain.handle('settings:reset', () => settings.reset());
|
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
|
// Paths
|
||||||
ipcMain.handle('paths:roms-directory', () => getRomsDirectory());
|
ipcMain.handle('paths:roms-directory', () => getRomsDirectory());
|
||||||
ipcMain.handle('paths:emulators-directory', () => getEmulatorsDirectory());
|
ipcMain.handle('paths:emulators-directory', () => getEmulatorsDirectory());
|
||||||
|
|||||||
@@ -25,6 +25,14 @@ const api = {
|
|||||||
install: (id: string): Promise<EmulatorState> =>
|
install: (id: string): Promise<EmulatorState> =>
|
||||||
ipcRenderer.invoke('emulators:install', id),
|
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 */
|
/** Apply recommended config preset to an installed emulator */
|
||||||
configure: (id: string, installPath: string): Promise<{ success: boolean; state: EmulatorState }> =>
|
configure: (id: string, installPath: string): Promise<{ success: boolean; state: EmulatorState }> =>
|
||||||
ipcRenderer.invoke('emulators:configure', id, installPath),
|
ipcRenderer.invoke('emulators:configure', id, installPath),
|
||||||
@@ -41,6 +49,10 @@ const api = {
|
|||||||
openWebsite: (id: string): Promise<boolean> =>
|
openWebsite: (id: string): Promise<boolean> =>
|
||||||
ipcRenderer.invoke('emulators:open-website', id),
|
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 */
|
/** Listen for install progress updates */
|
||||||
onInstallProgress: (cb: (progress: InstallProgress) => void) => {
|
onInstallProgress: (cb: (progress: InstallProgress) => void) => {
|
||||||
const handler = (_event: Electron.IpcRendererEvent, p: InstallProgress) => cb(p);
|
const handler = (_event: Electron.IpcRendererEvent, p: InstallProgress) => cb(p);
|
||||||
@@ -59,6 +71,20 @@ const api = {
|
|||||||
game: {
|
game: {
|
||||||
launch: (emulatorId: string, romPath: string): Promise<boolean> =>
|
launch: (emulatorId: string, romPath: string): Promise<boolean> =>
|
||||||
ipcRenderer.invoke('game:launch', emulatorId, romPath),
|
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: {
|
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,
|
launchInFullscreen: false,
|
||||||
closeToTray: true,
|
closeToTray: true,
|
||||||
presetSourceUrl: 'https://raw.githubusercontent.com/mileswolfallen2/omniemu-presets/main/presets.json',
|
presetSourceUrl: 'https://raw.githubusercontent.com/mileswolfallen2/omniemu-presets/main/presets.json',
|
||||||
|
recentGames: [],
|
||||||
|
biosDirectory: '',
|
||||||
};
|
};
|
||||||
|
|
||||||
let cached: AppSettings | null = null;
|
let cached: AppSettings | null = null;
|
||||||
|
|||||||
@@ -4,22 +4,27 @@ import { Dashboard } from './pages/Dashboard';
|
|||||||
import { EmulatorsPage } from './pages/EmulatorsPage';
|
import { EmulatorsPage } from './pages/EmulatorsPage';
|
||||||
import { LibraryPage } from './pages/LibraryPage';
|
import { LibraryPage } from './pages/LibraryPage';
|
||||||
import { SettingsPage } from './pages/SettingsPage';
|
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() {
|
export function App() {
|
||||||
|
useGamepadNav();
|
||||||
const [currentPage, setCurrentPage] = useState<Page>('dashboard');
|
const [currentPage, setCurrentPage] = useState<Page>('dashboard');
|
||||||
|
|
||||||
const renderPage = () => {
|
const renderPage = () => {
|
||||||
switch (currentPage) {
|
switch (currentPage) {
|
||||||
case 'dashboard':
|
case 'dashboard':
|
||||||
return <Dashboard />;
|
return <Dashboard onNavigate={setCurrentPage} />;
|
||||||
case 'emulators':
|
case 'emulators':
|
||||||
return <EmulatorsPage />;
|
return <EmulatorsPage />;
|
||||||
case 'library':
|
case 'library':
|
||||||
return <LibraryPage />;
|
return <LibraryPage />;
|
||||||
case 'settings':
|
case 'settings':
|
||||||
return <SettingsPage />;
|
return <SettingsPage />;
|
||||||
|
case 'controller':
|
||||||
|
return <ControllerPage />;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -28,6 +33,7 @@ export function App() {
|
|||||||
emulators: 'Emulators',
|
emulators: 'Emulators',
|
||||||
library: 'Game Library',
|
library: 'Game Library',
|
||||||
settings: 'Settings',
|
settings: 'Settings',
|
||||||
|
controller: 'Controller',
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
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';
|
import React from 'react';
|
||||||
|
|
||||||
type Page = 'dashboard' | 'emulators' | 'library' | 'settings';
|
type Page = 'dashboard' | 'emulators' | 'library' | 'settings' | 'controller';
|
||||||
|
|
||||||
interface SidebarProps {
|
interface SidebarProps {
|
||||||
currentPage: Page;
|
currentPage: Page;
|
||||||
@@ -11,6 +11,7 @@ const navItems: { page: Page; label: string; icon: string }[] = [
|
|||||||
{ page: 'dashboard', label: 'Dashboard', icon: '📊' },
|
{ page: 'dashboard', label: 'Dashboard', icon: '📊' },
|
||||||
{ page: 'emulators', label: 'Emulators', icon: '🕹️' },
|
{ page: 'emulators', label: 'Emulators', icon: '🕹️' },
|
||||||
{ page: 'library', label: 'Game Library', icon: '📚' },
|
{ page: 'library', label: 'Game Library', icon: '📚' },
|
||||||
|
{ page: 'controller', label: 'Controller', icon: '🎮' },
|
||||||
{ page: 'settings', label: 'Settings', 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 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 [emulators, setEmulators] = useState<EmulatorState[]>([]);
|
||||||
const [system, setSystem] = useState<SystemInfo | null>(null);
|
const [system, setSystem] = useState<SystemInfo | null>(null);
|
||||||
|
const [recent, setRecent] = useState<GameEntry[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function load() {
|
async function load() {
|
||||||
const [states, info] = await Promise.all([
|
const [states, info, recentGames] = await Promise.all([
|
||||||
window.omni.emulators.states(),
|
window.omni.emulators.states(),
|
||||||
window.omni.system.info(),
|
window.omni.system.info(),
|
||||||
|
window.omni.game.recent(),
|
||||||
]);
|
]);
|
||||||
setEmulators(states);
|
setEmulators(states);
|
||||||
setSystem(info);
|
setSystem(info);
|
||||||
|
setRecent(recentGames || []);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
load();
|
load();
|
||||||
@@ -21,7 +24,6 @@ export function Dashboard() {
|
|||||||
|
|
||||||
const installed = emulators.filter((e) => e.installed).length;
|
const installed = emulators.filter((e) => e.installed).length;
|
||||||
const total = emulators.filter((e) => e.config.supported).length;
|
const total = emulators.filter((e) => e.config.supported).length;
|
||||||
const gamesCount = 0; // would come from library scan
|
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return <div className="loading">Loading dashboard...</div>;
|
return <div className="loading">Loading dashboard...</div>;
|
||||||
@@ -66,9 +68,9 @@ export function Dashboard() {
|
|||||||
<h3>Game Library</h3>
|
<h3>Game Library</h3>
|
||||||
</div>
|
</div>
|
||||||
<p style={{ fontSize: 32, fontWeight: 700, color: 'var(--accent)' }}>
|
<p style={{ fontSize: 32, fontWeight: 700, color: 'var(--accent)' }}>
|
||||||
{gamesCount}
|
{recent.filter(g => g.playCount > 0).length || 0}
|
||||||
</p>
|
</p>
|
||||||
<p>games in your library</p>
|
<p>games played</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="card">
|
<div className="card">
|
||||||
@@ -88,14 +90,46 @@ export function Dashboard() {
|
|||||||
</div>
|
</div>
|
||||||
</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 }}>
|
<h3 className="mt-4 mb-2" style={{ fontSize: 16, fontWeight: 600 }}>
|
||||||
Quick Actions
|
Quick Actions
|
||||||
</h3>
|
</h3>
|
||||||
<div style={{ display: 'flex', gap: 8 }}>
|
<div style={{ display: 'flex', gap: 8 }}>
|
||||||
<button className="btn btn-primary" onClick={() => {
|
<button className="btn btn-primary" onClick={() => onNavigate?.('emulators')}>
|
||||||
const nav = document.querySelector('[data-nav-emulators]') as HTMLButtonElement;
|
|
||||||
nav?.click();
|
|
||||||
}}>
|
|
||||||
Manage Emulators
|
Manage Emulators
|
||||||
</button>
|
</button>
|
||||||
<button className="btn btn-secondary" onClick={() => {
|
<button className="btn btn-secondary" onClick={() => {
|
||||||
|
|||||||
@@ -81,6 +81,20 @@ export function EmulatorsPage() {
|
|||||||
await load();
|
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) => {
|
const handleOpenWebsite = async (id: string) => {
|
||||||
await window.omni.emulators.openWebsite(id);
|
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 && (
|
{state.config.websiteUrl && (
|
||||||
<button
|
<button
|
||||||
className="btn-icon"
|
className="btn-icon"
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ export function LibraryPage() {
|
|||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [romsDir, setRomsDir] = useState<string>('');
|
const [romsDir, setRomsDir] = useState<string>('');
|
||||||
|
|
||||||
// Load saved ROM directory on mount
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
(async () => {
|
(async () => {
|
||||||
const settings = await window.omni.settings.get();
|
const settings = await window.omni.settings.get();
|
||||||
@@ -22,19 +21,28 @@ export function LibraryPage() {
|
|||||||
setRomsDir(settings.romsDirectory);
|
setRomsDir(settings.romsDirectory);
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const results = await window.omni.roms.scan(settings.romsDirectory);
|
const results = await window.omni.roms.scan(settings.romsDirectory);
|
||||||
setGames(results);
|
setGames(await scrapeMissingArt(results));
|
||||||
setLoading(false);
|
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) => {
|
const scanDirectory = useCallback(async (dir: string) => {
|
||||||
setRomsDir(dir);
|
setRomsDir(dir);
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
// Save to persistent settings
|
|
||||||
await window.omni.settings.save({ romsDirectory: dir });
|
await window.omni.settings.save({ romsDirectory: dir });
|
||||||
const results = await window.omni.roms.scan(dir);
|
const results = await window.omni.roms.scan(dir);
|
||||||
setGames(results);
|
setGames(await scrapeMissingArt(results));
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -90,9 +98,18 @@ export function LibraryPage() {
|
|||||||
<div>
|
<div>
|
||||||
<div className="info-bar">
|
<div className="info-bar">
|
||||||
<span>{games.length} games found</span>
|
<span>{games.length} games found</span>
|
||||||
<button className="btn btn-secondary btn-sm" onClick={handleScan}>
|
<div style={{ display: 'flex', gap: 6 }}>
|
||||||
Rescan
|
<button className="btn btn-secondary btn-sm" onClick={async () => {
|
||||||
</button>
|
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>
|
||||||
|
|
||||||
<div className="library-grid">
|
<div className="library-grid">
|
||||||
@@ -104,7 +121,16 @@ export function LibraryPage() {
|
|||||||
title={`Launch ${game.title} via ${game.emulatorId}`}
|
title={`Launch ${game.title} via ${game.emulatorId}`}
|
||||||
>
|
>
|
||||||
<div className="game-card-cover">
|
<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>
|
||||||
<div className="game-card-info">
|
<div className="game-card-info">
|
||||||
<div className="game-card-title">{game.title}</div>
|
<div className="game-card-title">{game.title}</div>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import type { AppSettings } from '../../shared/types';
|
import type { AppSettings } from '../../shared/types';
|
||||||
|
import { BiosCheckPanel } from '../components/BiosCheckPanel';
|
||||||
|
|
||||||
export function SettingsPage() {
|
export function SettingsPage() {
|
||||||
const [settings, setSettings] = useState<AppSettings | null>(null);
|
const [settings, setSettings] = useState<AppSettings | null>(null);
|
||||||
@@ -63,6 +64,14 @@ export function SettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="settings-section">
|
||||||
|
<h3>BIOS</h3>
|
||||||
|
<BiosCheckPanel
|
||||||
|
biosDir={settings.biosDirectory}
|
||||||
|
onBiosDirChange={(dir) => update({ biosDirectory: dir })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="settings-section">
|
<div className="settings-section">
|
||||||
<h3>Appearance</h3>
|
<h3>Appearance</h3>
|
||||||
|
|
||||||
|
|||||||
@@ -271,6 +271,17 @@ html, body, #root {
|
|||||||
color: var(--text-primary);
|
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 */
|
/* Game library */
|
||||||
.library-grid {
|
.library-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -538,3 +549,158 @@ select:focus, input[type="text"]:focus {
|
|||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
margin-top: 8px;
|
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;
|
closeToTray: boolean;
|
||||||
/** URL to fetch recommended config presets from */
|
/** URL to fetch recommended config presets from */
|
||||||
presetSourceUrl: string;
|
presetSourceUrl: string;
|
||||||
|
/** Recently played games (max 10) */
|
||||||
|
recentGames: GameEntry[];
|
||||||
|
/** Directory for BIOS files */
|
||||||
|
biosDirectory: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SystemInfo {
|
export interface SystemInfo {
|
||||||
|
|||||||
Reference in New Issue
Block a user