mirror of
https://github.com/mileswolfallen2/OmniEmu2.0.git
synced 2026-09-08 11:23:17 +00:00
update
This commit is contained in:
@@ -13,7 +13,7 @@ OmniEmu is a cross-platform emulator manager, game launcher, and ROM library for
|
||||
|
||||
## Features
|
||||
|
||||
- **One-Click Emulator Setup** — Download, install, and auto-configure 9 emulators from inside the app
|
||||
- **One-Click Emulator Setup** — Download, install, and auto-configure 10 emulators from inside the app
|
||||
- **ROM Library** — Scan your ROMs, browse by platform, and launch games directly
|
||||
- **Auto-Update** — Built-in updater keeps OmniEmu current without manual downloads
|
||||
- **Game Art Scraping** — Automatically fetches covers, screenshots, and titles
|
||||
@@ -34,6 +34,7 @@ OmniEmu is a cross-platform emulator manager, game launcher, and ROM library for
|
||||
| PPSSPP | PSP | ✅ | ✅ | ✅ |
|
||||
| DuckStation | PlayStation 1 | ✅ | ✅ | ✅ |
|
||||
| melonDS | Nintendo DS | ✅ | ✅ | ✅ |
|
||||
| Flycast | Dreamcast, Naomi, Atomiswave | ✅ | ✅ | ✅ |
|
||||
| RetroArch | Multi-system (NES, SNES, N64, GB/GBA, PS1, and more) | ✅ | ✅ | ✅ |
|
||||
|
||||
## Quick Start
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "omniemu2",
|
||||
"version": "0.1.1",
|
||||
"version": "0.1.2",
|
||||
"description": "Cross-platform emulator manager, game launcher and ROM manager",
|
||||
"main": "dist/main/index.js",
|
||||
"scripts": {
|
||||
|
||||
@@ -87,5 +87,14 @@
|
||||
"melonDS.ini": "[General]\nfullscreen = 1\n[Video]\nrenderer = OpenGL\nvsync = 1\n[Audio]\nvolume = 100\n[Controls]\n"
|
||||
}
|
||||
}
|
||||
],
|
||||
"flycast": [
|
||||
{
|
||||
"name": "OmniEmu Recommended",
|
||||
"description": "Optimal Flycast settings for Dreamcast emulation",
|
||||
"files": {
|
||||
"emu.cfg": "[config]\nrenderer = vulkan\nfullscreen = yes\nvsync = yes\nauto_region = yes\ncable_type = vga\nbroadcast = ntsc\nframeskip = 0\n[network]\nenable = no\n[input]\nenable_mouse = no\n"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
+154
-1
@@ -214,6 +214,27 @@ WidescreenHack = True
|
||||
ControllerBackend = SDL
|
||||
[ControllerPort0]
|
||||
MultitapPort1 = false
|
||||
`,
|
||||
},
|
||||
},
|
||||
],
|
||||
flycast: [
|
||||
{
|
||||
name: 'OmniEmu Recommended',
|
||||
description: 'Optimal Flycast settings for Dreamcast emulation',
|
||||
files: {
|
||||
'emu.cfg': `[config]
|
||||
renderer = vulkan
|
||||
fullscreen = yes
|
||||
vsync = yes
|
||||
auto_region = yes
|
||||
cable_type = vga
|
||||
broadcast = ntsc
|
||||
frameskip = 0
|
||||
[network]
|
||||
enable = no
|
||||
[input]
|
||||
enable_mouse = no
|
||||
`,
|
||||
},
|
||||
},
|
||||
@@ -229,7 +250,7 @@ async function fetchRemotePresets(): Promise<Record<string, ConfigPreset[]> | nu
|
||||
const { get } = await import('https');
|
||||
const data = await new Promise<string>((resolve, reject) => {
|
||||
get(presetSourceUrl, {
|
||||
headers: { 'User-Agent': 'OmniEmu/0.1.1' },
|
||||
headers: { 'User-Agent': 'OmniEmu/0.1.2' },
|
||||
timeout: 10000,
|
||||
}, (res) => {
|
||||
if (res.statusCode !== 200) { reject(new Error(`HTTP ${res.statusCode}`)); return; }
|
||||
@@ -319,6 +340,11 @@ function getConfigDir(emulatorId: string, installPath: string): string {
|
||||
darwin: join(require('os').homedir(), 'Library', 'Application Support', 'Eden'),
|
||||
linux: join(require('os').homedir(), '.config', 'Eden'),
|
||||
},
|
||||
flycast: {
|
||||
win32: join(process.env.APPDATA || '', 'flycast'),
|
||||
darwin: join(require('os').homedir(), 'Library', 'Application Support', 'flycast'),
|
||||
linux: join(require('os').homedir(), '.config', 'flycast'),
|
||||
},
|
||||
};
|
||||
return platformDirs[emulatorId]?.[platform] || dirname(installPath);
|
||||
}
|
||||
@@ -510,6 +536,133 @@ export function applyControllerConfig(emulatorId: string, installPath: string, c
|
||||
writeFileSync(iniPath, lines.join('\n'), 'utf-8');
|
||||
return true;
|
||||
}
|
||||
|
||||
case 'flycast': {
|
||||
const cfgPath = join(configDir, 'emu.cfg');
|
||||
const existing = existsSync(cfgPath) ? readFileSync(cfgPath, 'utf-8') : '';
|
||||
const lines = existing.split('\n').filter(l =>
|
||||
!l.startsWith('enable_mouse')
|
||||
);
|
||||
lines.push('[input]');
|
||||
lines.push('enable_mouse = no');
|
||||
writeFileSync(cfgPath, lines.join('\n'), 'utf-8');
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
const raEmulatorConfigs: Record<string, {
|
||||
file: string;
|
||||
enabled: string;
|
||||
username: string;
|
||||
password: string;
|
||||
section?: string;
|
||||
}> = {
|
||||
retroarch: {
|
||||
file: 'retroarch.cfg',
|
||||
enabled: 'cheevos_enable = "true"',
|
||||
username: 'cheevos_username = "%s"',
|
||||
password: 'cheevos_password = "%s"',
|
||||
},
|
||||
dolphin: {
|
||||
file: 'Config/Dolphin.ini',
|
||||
section: 'General',
|
||||
enabled: 'RAEnabled = True',
|
||||
username: 'RAUsername = %s',
|
||||
password: 'RAPassword = %s',
|
||||
},
|
||||
pcsx2: {
|
||||
file: 'inis/PCSX2.ini',
|
||||
section: 'EmuCore',
|
||||
enabled: 'AchievementsEnabled = 1',
|
||||
username: 'AchievementsUsername = %s',
|
||||
password: 'AchievementsPassword = %s',
|
||||
},
|
||||
duckstation: {
|
||||
file: 'settings.ini',
|
||||
section: 'Cheevos',
|
||||
enabled: 'Enabled = True',
|
||||
username: 'Username = %s',
|
||||
password: 'Password = %s',
|
||||
},
|
||||
flycast: {
|
||||
file: 'emu.cfg',
|
||||
section: 'achievements',
|
||||
enabled: 'enable = yes',
|
||||
username: 'username = %s',
|
||||
password: 'password = %s',
|
||||
},
|
||||
};
|
||||
|
||||
export function applyRetroAchievements(username: string, password: string): Record<string, boolean> {
|
||||
const results: Record<string, boolean> = {};
|
||||
|
||||
for (const [emuId, cfg] of Object.entries(raEmulatorConfigs)) {
|
||||
try {
|
||||
const configDir = getConfigDir(emuId, '');
|
||||
if (!configDir || configDir === '.') { results[emuId] = false; continue; }
|
||||
|
||||
const filePath = join(configDir, cfg.file);
|
||||
const parentDir = dirname(filePath);
|
||||
if (!existsSync(parentDir)) mkdirSync(parentDir, { recursive: true });
|
||||
|
||||
let content = '';
|
||||
if (existsSync(filePath)) content = readFileSync(filePath, 'utf-8');
|
||||
|
||||
const lines = content.split('\n');
|
||||
|
||||
if (cfg.section) {
|
||||
const sectionStart = lines.findIndex(l => l.trim() === `[${cfg.section}]`);
|
||||
if (sectionStart === -1) {
|
||||
lines.push('', `[${cfg.section}]`);
|
||||
lines.push(cfg.enabled);
|
||||
lines.push(cfg.username.replace('%s', username));
|
||||
lines.push(cfg.password.replace('%s', password));
|
||||
} else {
|
||||
const existingEnabled = lines.slice(sectionStart).findIndex(l => /^enable|^raenabled|^achievementsenabled|^enabled\b/i.test(l.trim()));
|
||||
if (existingEnabled === -1 || existingEnabled > lines.slice(sectionStart).findIndex(l => l.trim().startsWith('[') && !l.trim().startsWith(`[${cfg.section}]`))) {
|
||||
lines.splice(sectionStart + 1, 0, cfg.enabled);
|
||||
}
|
||||
const existingUser = lines.slice(sectionStart).findIndex(l => /username/i.test(l));
|
||||
if (existingUser === -1 || existingUser > lines.slice(sectionStart).findIndex(l => l.trim().startsWith('[') && !l.trim().startsWith(`[${cfg.section}]`))) {
|
||||
lines.splice(sectionStart + 1, 0, cfg.username.replace('%s', username));
|
||||
}
|
||||
const existingPass = lines.slice(sectionStart).findIndex(l => /password/i.test(l));
|
||||
if (existingPass === -1 || existingPass > lines.slice(sectionStart).findIndex(l => l.trim().startsWith('[') && !l.trim().startsWith(`[${cfg.section}]`))) {
|
||||
lines.splice(sectionStart + 1, 0, cfg.password.replace('%s', password));
|
||||
}
|
||||
// Update existing lines
|
||||
for (let i = sectionStart + 1; i < lines.length; i++) {
|
||||
const line = lines[i].trim();
|
||||
if (line.startsWith('[')) break;
|
||||
if (/^enable|^raenabled|^achievementsenabled|^enabled\b/i.test(line)) {
|
||||
lines[i] = cfg.enabled;
|
||||
} else if (/^\s*username/i.test(line)) {
|
||||
lines[i] = cfg.username.replace('%s', username);
|
||||
} else if (/^\s*password/i.test(line)) {
|
||||
lines[i] = cfg.password.replace('%s', password);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// RetroArch-style flat config
|
||||
const setLine = (prefix: string, value: string) => {
|
||||
const idx = lines.findIndex(l => l.trim().startsWith(prefix));
|
||||
if (idx !== -1) lines[idx] = value;
|
||||
else lines.push(value);
|
||||
};
|
||||
setLine('cheevos_enable', cfg.enabled);
|
||||
setLine('cheevos_username', cfg.username.replace('%s', username));
|
||||
setLine('cheevos_password', cfg.password.replace('%s', password));
|
||||
}
|
||||
|
||||
writeFileSync(filePath, lines.join('\n'), 'utf-8');
|
||||
results[emuId] = true;
|
||||
} catch {
|
||||
results[emuId] = false;
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
+53
-3
@@ -260,6 +260,7 @@ export const knownEmulators: EmulatorConfig[] = [
|
||||
platforms: [
|
||||
'nes', 'snes', 'n64', 'gb', 'gba', 'gbc',
|
||||
'ps1', 'pce', 'sega-md', 'sega-saturn', 'sega-dc',
|
||||
'dreamcast',
|
||||
],
|
||||
defaultPath: {
|
||||
win32: 'C:\\Program Files\\RetroArch\\retroarch.exe',
|
||||
@@ -389,6 +390,45 @@ export const knownEmulators: EmulatorConfig[] = [
|
||||
linux: 'https://melonds.kuribo64.net/',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'flycast',
|
||||
name: 'Flycast',
|
||||
description: 'Sega Dreamcast, Naomi & Atomiswave emulator',
|
||||
platforms: ['dreamcast'],
|
||||
defaultPath: {
|
||||
win32: 'C:\\Program Files\\Flycast\\flycast.exe',
|
||||
darwin: '/Applications/Flycast.app/Contents/MacOS/Flycast',
|
||||
linux: '/usr/bin/flycast',
|
||||
},
|
||||
downloads: {
|
||||
win32: [
|
||||
{
|
||||
url: 'https://github.com/flyinghead/flycast/releases/download/v2.6/flycast-win64-2.6.zip',
|
||||
format: 'zip',
|
||||
executablePath: 'flycast-win64.exe',
|
||||
},
|
||||
],
|
||||
darwin: [
|
||||
{
|
||||
url: 'https://github.com/flyinghead/flycast/releases/download/v2.6/flycast-macOS-2.6.zip',
|
||||
format: 'zip',
|
||||
executablePath: 'Flycast.app/Contents/MacOS/Flycast',
|
||||
},
|
||||
],
|
||||
linux: [
|
||||
{
|
||||
url: 'https://github.com/flyinghead/flycast/releases/download/v2.6/flycast-x86_64.AppImage',
|
||||
format: 'appimage',
|
||||
},
|
||||
],
|
||||
},
|
||||
supported: true,
|
||||
websiteUrl: {
|
||||
win32: 'https://github.com/flyinghead/flycast/releases',
|
||||
darwin: 'https://github.com/flyinghead/flycast/releases',
|
||||
linux: 'https://github.com/flyinghead/flycast/releases',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export function findEmulator(id: string): EmulatorConfig | undefined {
|
||||
@@ -473,6 +513,12 @@ function alternativePaths(emulatorId: string): string[] {
|
||||
join(omniEmuDir, 'mame'),
|
||||
join(omniEmuDir, 'MAME.AppImage'),
|
||||
],
|
||||
flycast: [
|
||||
join(omniEmuDir, 'flycast-win64.exe'),
|
||||
join(omniEmuDir, 'flycast'),
|
||||
join(omniEmuDir, 'Flycast.app', 'Contents', 'MacOS', 'Flycast'),
|
||||
join(home, 'Applications', 'Flycast.app', 'Contents', 'MacOS', 'Flycast'),
|
||||
],
|
||||
};
|
||||
return common[emulatorId] || [join(omniEmuDir)];
|
||||
}
|
||||
@@ -636,6 +682,8 @@ function launchArgs(emulatorId: string, romPath: string): string {
|
||||
}
|
||||
case 'duckstation':
|
||||
return `"${romPath}"`;
|
||||
case 'flycast':
|
||||
return `"${romPath}"`;
|
||||
default:
|
||||
return `"${romPath}"`;
|
||||
}
|
||||
@@ -687,7 +735,7 @@ export function scanRoms(directory: string): GameEntry[] {
|
||||
'.gba', '.gb', '.gbc', '.nds', '.iso', '.bin', '.cue',
|
||||
'.wbfs', '.wad', '.nsp', '.xci', '.pkg', '.chd',
|
||||
'.gcm', '.gcz', '.rvz', '.m3u', '.ps2', '.cso',
|
||||
'.rom', '.zip', '.7z', '.gdi', '.pbp',
|
||||
'.rom', '.zip', '.7z', '.gdi', '.pbp', '.cdi',
|
||||
];
|
||||
|
||||
const entries: GameEntry[] = [];
|
||||
@@ -778,6 +826,7 @@ function guessPlatform(ext: string, dirPath?: string): string {
|
||||
'.gcm': 'gc', '.gcz': 'gc', '.rvz': 'gc',
|
||||
'.ps2': 'ps2', '.cso': 'ps2',
|
||||
'.gdi': 'dreamcast',
|
||||
'.cdi': 'dreamcast',
|
||||
'.pbp': 'psp',
|
||||
};
|
||||
return map[ext] || 'other';
|
||||
@@ -804,7 +853,6 @@ function guessEmulator(ext: string, platform?: string): string {
|
||||
if (platform) {
|
||||
const platformMap: Record<string, string> = {
|
||||
psp: 'ppsspp',
|
||||
dreamcast: 'retroarch',
|
||||
'sega-saturn': 'retroarch',
|
||||
'sega-dc': 'retroarch',
|
||||
'sega-md': 'retroarch',
|
||||
@@ -812,6 +860,7 @@ function guessEmulator(ext: string, platform?: string): string {
|
||||
nes: 'retroarch', snes: 'retroarch', n64: 'retroarch',
|
||||
gb: 'retroarch', gbc: 'retroarch', gba: 'retroarch',
|
||||
nds: 'retroarch',
|
||||
dreamcast: 'flycast',
|
||||
};
|
||||
const mapped = platformMap[platform];
|
||||
if (mapped) return mapped;
|
||||
@@ -829,7 +878,8 @@ function guessEmulator(ext: string, platform?: string): string {
|
||||
'.img': 'duckstation', '.m3u': 'duckstation',
|
||||
'.chd': 'duckstation', '.ecm': 'duckstation', '.mds': 'duckstation',
|
||||
'.ps2': 'pcsx2', '.cso': 'pcsx2',
|
||||
'.gdi': 'retroarch',
|
||||
'.gdi': 'flycast',
|
||||
'.cdi': 'flycast',
|
||||
'.pbp': 'ppsspp',
|
||||
};
|
||||
return map[ext] || 'retroarch';
|
||||
|
||||
@@ -26,7 +26,7 @@ function downloadFile(url: string, dest: string, onProgress: (pct: number) => vo
|
||||
const doRequest = (currentUrl: string) => {
|
||||
const protocol = currentUrl.startsWith('https') ? httpsGet : httpGet;
|
||||
const opts: RequestOptions = {
|
||||
headers: { 'User-Agent': 'OmniEmu/0.1.1' },
|
||||
headers: { 'User-Agent': 'OmniEmu/0.1.2' },
|
||||
timeout: 30000,
|
||||
};
|
||||
protocol(currentUrl, opts, (response) => {
|
||||
|
||||
+12
-1
@@ -16,7 +16,7 @@ import {
|
||||
ensureRomsStructure,
|
||||
} from './emulators';
|
||||
import { installEmulator, findInstalledBinary } from './installer';
|
||||
import { applyRecommendedConfig, getPresets, checkConfigured, applyControllerConfig } from './configurator';
|
||||
import { applyRecommendedConfig, getPresets, checkConfigured, applyControllerConfig, applyRetroAchievements } from './configurator';
|
||||
import { settings } from './settings';
|
||||
import { getSystemInfo, platformName, getPlatform, getArch } from './platform';
|
||||
import { checkForUpdates, downloadUpdate, quitAndInstall } from './updater';
|
||||
@@ -230,6 +230,17 @@ export function registerIpcHandlers(): void {
|
||||
ipcMain.handle('paths:roms-directory', () => getRomsDirectory());
|
||||
ipcMain.handle('paths:emulators-directory', () => getEmulatorsDirectory());
|
||||
|
||||
// RetroAchievements
|
||||
ipcMain.handle(
|
||||
'retroachievements:save',
|
||||
async (_event, username: string, password: string) => {
|
||||
const s = settings.get();
|
||||
settings.save({ retroAchievementsUsername: username, retroAchievementsPassword: password });
|
||||
const results = applyRetroAchievements(username, password);
|
||||
return results;
|
||||
}
|
||||
);
|
||||
|
||||
// Utilities
|
||||
ipcMain.handle('utilities:regenerate-roms-structure', () => {
|
||||
ensureRomsStructure();
|
||||
|
||||
@@ -109,6 +109,11 @@ const api = {
|
||||
ipcRenderer.invoke('utilities:regenerate-roms-structure'),
|
||||
},
|
||||
|
||||
retroachievements: {
|
||||
save: (username: string, password: string): Promise<Record<string, boolean>> =>
|
||||
ipcRenderer.invoke('retroachievements:save', username, password),
|
||||
},
|
||||
|
||||
updates: {
|
||||
check: (): Promise<boolean> => ipcRenderer.invoke('updates:check'),
|
||||
download: (): Promise<boolean> => ipcRenderer.invoke('updates:download'),
|
||||
|
||||
+2
-2
@@ -120,7 +120,7 @@ export async function findValidThumbnail(title: string, platform: string): Promi
|
||||
|
||||
function fetchText(url: string): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = httpsGet(url, { headers: { 'User-Agent': 'OmniEmu/0.1.1' }, timeout: 10000 }, (res) => {
|
||||
const req = httpsGet(url, { headers: { 'User-Agent': 'OmniEmu/0.1.2' }, timeout: 10000 }, (res) => {
|
||||
let data = '';
|
||||
res.on('data', (chunk: string) => data += chunk);
|
||||
res.on('end', () => resolve(data));
|
||||
@@ -134,7 +134,7 @@ function urlExists(url: string): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const req = httpsGet(url, {
|
||||
method: 'GET',
|
||||
headers: { 'User-Agent': 'OmniEmu/0.1.1' },
|
||||
headers: { 'User-Agent': 'OmniEmu/0.1.2' },
|
||||
timeout: 10000,
|
||||
}, (res) => {
|
||||
// GitHub raw returns 200 for existing or redirects to it
|
||||
|
||||
@@ -28,6 +28,8 @@ const defaultSettings: AppSettings = {
|
||||
presetSourceUrl: 'https://raw.githubusercontent.com/mileswolfallen2/OmniEmu2.0/main/presets.json',
|
||||
recentGames: [],
|
||||
biosDirectory: '',
|
||||
retroAchievementsUsername: '',
|
||||
retroAchievementsPassword: '',
|
||||
};
|
||||
|
||||
let cached: AppSettings | null = null;
|
||||
|
||||
@@ -36,7 +36,7 @@ export function Sidebar({ currentPage, onNavigate }: SidebarProps) {
|
||||
))}
|
||||
</nav>
|
||||
<div className="sidebar-footer">
|
||||
v0.1.1
|
||||
v0.1.2
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -363,7 +363,7 @@ export function SettingsPage() {
|
||||
<div className="settings-section">
|
||||
<h3>About</h3>
|
||||
<p className="text-sm text-muted">
|
||||
OmniEmu v{updateInfo?.version || '0.1.1'} · Cross-platform emulator manager
|
||||
OmniEmu v{updateInfo?.version || '0.1.2'} · Cross-platform emulator manager
|
||||
<br />
|
||||
Built with Electron + React + TypeScript
|
||||
</p>
|
||||
|
||||
@@ -1,7 +1,18 @@
|
||||
import React, { useState } from 'react';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
|
||||
export function UtilitiesPage() {
|
||||
const [regenerating, setRegenerating] = useState(false);
|
||||
const [raUsername, setRaUsername] = useState('');
|
||||
const [raPassword, setRaPassword] = useState('');
|
||||
const [raResults, setRaResults] = useState<Record<string, boolean> | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
window.omni.settings.get().then((s) => {
|
||||
setRaUsername(s.retroAchievementsUsername || '');
|
||||
setRaPassword(s.retroAchievementsPassword || '');
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleRecreate = async () => {
|
||||
setRegenerating(true);
|
||||
@@ -9,6 +20,26 @@ export function UtilitiesPage() {
|
||||
setTimeout(() => setRegenerating(false), 1500);
|
||||
};
|
||||
|
||||
const handleRaSave = async () => {
|
||||
setSaving(true);
|
||||
setRaResults(null);
|
||||
try {
|
||||
const results = await window.omni.retroachievements.save(raUsername, raPassword);
|
||||
setRaResults(results);
|
||||
} catch {
|
||||
setRaResults({});
|
||||
}
|
||||
setTimeout(() => setSaving(false), 500);
|
||||
};
|
||||
|
||||
const emuLabels: Record<string, string> = {
|
||||
retroarch: 'RetroArch',
|
||||
dolphin: 'Dolphin',
|
||||
pcsx2: 'PCSX2',
|
||||
duckstation: 'DuckStation',
|
||||
flycast: 'Flycast',
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="settings-section">
|
||||
@@ -31,6 +62,84 @@ export function UtilitiesPage() {
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="settings-section">
|
||||
<h3>RetroAchievements</h3>
|
||||
<p className="text-sm text-muted" style={{ marginBottom: 12 }}>
|
||||
Enable RetroAchievements across all supported emulators. Get your
|
||||
password from{' '}
|
||||
<a
|
||||
href="https://retroachievements.org/settings"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{ color: 'var(--accent)', textDecoration: 'underline' }}
|
||||
>
|
||||
retroachievements.org/settings
|
||||
</a>
|
||||
{' '}(under "Web API Key").
|
||||
</p>
|
||||
|
||||
<div className="setting-row">
|
||||
<div>
|
||||
<div className="setting-label">Username</div>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
className="input"
|
||||
value={raUsername}
|
||||
onChange={(e) => setRaUsername(e.target.value)}
|
||||
placeholder="your RetroAchievements username"
|
||||
style={{ width: 240 }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="setting-row">
|
||||
<div>
|
||||
<div className="setting-label">Password / API Key</div>
|
||||
</div>
|
||||
<input
|
||||
type="password"
|
||||
className="input"
|
||||
value={raPassword}
|
||||
onChange={(e) => setRaPassword(e.target.value)}
|
||||
placeholder="your password or Web API Key"
|
||||
style={{ width: 240 }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="setting-row">
|
||||
<div />
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<button
|
||||
className="btn btn-primary btn-sm"
|
||||
disabled={saving || !raUsername || !raPassword}
|
||||
onClick={handleRaSave}
|
||||
>
|
||||
{saving ? 'Saving...' : 'Save & Apply'}
|
||||
</button>
|
||||
|
||||
{raResults && (
|
||||
<div style={{ fontSize: 13 }}>
|
||||
{Object.keys(raResults).length === 0 ? (
|
||||
<span style={{ color: 'var(--error)' }}>Failed to apply</span>
|
||||
) : (
|
||||
Object.entries(raResults).map(([emu, ok]) => (
|
||||
<span
|
||||
key={emu}
|
||||
style={{
|
||||
color: ok ? 'var(--success)' : 'var(--error)',
|
||||
marginRight: 10,
|
||||
}}
|
||||
>
|
||||
{emuLabels[emu] || emu}: {ok ? '✓' : '✗'}
|
||||
</span>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -96,6 +96,10 @@ export interface AppSettings {
|
||||
biosDirectory: string;
|
||||
/** Per-system preferred emulator override: systemId -> emulatorId */
|
||||
systemEmulators?: Record<string, string>;
|
||||
/** RetroAchievements username */
|
||||
retroAchievementsUsername?: string;
|
||||
/** RetroAchievements password/token */
|
||||
retroAchievementsPassword?: string;
|
||||
}
|
||||
|
||||
export interface SystemInfo {
|
||||
|
||||
Reference in New Issue
Block a user