This commit is contained in:
2026-07-10 14:08:44 -05:00
parent fa139ea580
commit bf90aa0e92
14 changed files with 358 additions and 14 deletions
+2 -1
View File
@@ -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
View File
@@ -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": {
+9
View File
@@ -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
View File
@@ -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
View File
@@ -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';
+1 -1
View File
@@ -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
View File
@@ -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();
+5
View File
@@ -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
View File
@@ -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
+2
View File
@@ -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;
+1 -1
View File
@@ -36,7 +36,7 @@ export function Sidebar({ currentPage, onNavigate }: SidebarProps) {
))}
</nav>
<div className="sidebar-footer">
v0.1.1
v0.1.2
</div>
</div>
);
+1 -1
View File
@@ -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>
+110 -1
View File
@@ -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>
);
}
+4
View File
@@ -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 {