This commit is contained in:
2026-07-08 16:08:30 -05:00
parent 27a5394510
commit e1ee2173d2
17 changed files with 8149 additions and 131 deletions
+96 -2
View File
@@ -1,2 +1,96 @@
# OmniEmu2.0
idk weeee will see
# OmniEmu
Cross-platform emulator manager and ROM library — for Windows, macOS, and Linux.
## Features
- **Emulator Management** — Detect installed emulators, download new ones, manage versions
- **ROM Library** — Scan directories for ROMs, organise by platform, launch games
- **Cross-Platform** — Same experience on Windows, macOS, and Linux (x86-64 & ARM64)
- **Built-in Launcher** — Launch games directly from the library with proper emulator arguments
## Supported Emulators
| Emulator | Systems | Windows | macOS | Linux |
|------------|-------------------|---------|-------|-------|
| Dolphin | GameCube, Wii | ✅ | ✅ | ✅ |
| RPCS3 | PlayStation 3 | ✅ | ✅ | ✅ |
| Ryujinx | Nintendo Switch | ✅ | ✅ | ✅ |
| PCSX2 | PlayStation 2 | ✅ | ✅ | ✅ |
| MAME | Arcade | ✅ | ✅ | ✅ |
| RetroArch | Multi-system | ✅ | ✅ | ✅ |
## Development
### Prerequisites
- Node.js 20+ (recommended: 22)
- npm 10+
### Setup
```bash
npm install
npm run dev
```
This starts the Vite dev server for the renderer and the TypeScript compiler for the main process in watch mode.
### Build for production
```bash
npm run build
```
### Package for distribution
```bash
# Current platform
npm run package:mac
npm run package:win
npm run package:linux
# All platforms
npm run package:all
```
Output goes to `./release/`.
### Platform-specific scripts
```bash
# macOS (specify arch: x64 or arm64)
./scripts/build-mac.sh arm64
# Windows (PowerShell)
.\scripts\build-win.ps1 -Arch x64
# Linux
./scripts/build-linux.sh x64
```
## Project Structure
```
src/
├── main/ # Electron main process
│ ├── index.ts # App entry, window creation, tray
│ ├── preload.ts # Context bridge API
│ ├── ipc.ts # IPC handler registration
│ ├── platform.ts # OS/arch detection utilities
│ ├── emulators.ts # Emulator detection, ROM scanning, launching
│ └── settings.ts # Persistent settings
├── renderer/ # React UI (Vite)
│ ├── App.tsx
│ ├── components/
│ ├── pages/
│ └── styles.css
└── shared/ # Types shared between main & renderer
└── types.ts
scripts/ # Build and install scripts
.github/workflows/ # CI configuration
```
## License
MIT
+7160
View File
File diff suppressed because it is too large Load Diff
Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
View File
+282
View File
@@ -0,0 +1,282 @@
import { existsSync, mkdirSync, writeFileSync, readFileSync, readdirSync } from 'fs';
import { join, dirname } from 'path';
import { app } from 'electron';
import { ConfigPreset, InstallProgress, Platform } from '../shared/types';
import { getPlatform, isWindows, isMacOS } from './platform';
const platform = getPlatform();
/**
* Built-in recommended presets for each emulator.
* These are known-good configs curated by the EmuDeck community and
* adapted for OmniEmu. They set optimal performance/quality balances.
*/
const builtInPresets: Record<string, ConfigPreset[]> = {
dolphin: [
{
name: 'OmniEmu Recommended',
description: 'Best balance of performance and quality for most systems',
files: {
'Config/Dolphin.ini': `[General]
LastFilename =
ShowLag = False
ShowRenderTimes = False
[Display]
FullscreenDisplayRes = Auto
Fullscreen = True
RenderToMain = True
[Interface]
ConfirmStop = False
PauseOnFocusLost = False
[Core]
CPUCore = 3
Fastmem = True
MMU = False
[Enhancements]
InternalResolution = 3
MaxAnisotropy = 4
`,
'Config/GFX.ini': `[Settings]
Backend = Vulkan
ShaderCompilationMode = 2
WaitForShadersBeforeStarting = True
HiresTextures = False
CacheHiresTextures = True
[Enhancements]
ForceFiltering = False
WidescreenHack = True
`,
},
},
],
rpcs3: [
{
name: 'OmniEmu Recommended',
description: 'Optimized settings for RPCS3 with Vulkan backend',
files: {
'config.yml': `Video:
Format: Vulkan
FrameLimit: Auto
Resolution: 1920x1080
AntiAliasing: Disabled
RenderScale: 100
Audio:
Format: XAudio2
Device: Default
Input:
PadHandler: DualShock4
MouseHandler: Basic
CPU:
PPUDecoder: LLVM
SPUDecoder: ASMJIT
ThreadScheduler: OS
`,
},
},
],
ryujinx: [
{
name: 'OmniEmu Recommended',
description: 'Best settings for Ryujinx Switch emulation',
files: {
'Config.json': `{
"graphics_backend": "Vulkan",
"resolution_scale": 2,
"docked_mode": true,
"anisotropy_filtering": 4,
"aspect_ratio": "16:9",
"enable_vsync": true,
"shader_cache": true,
"audio_backend": "OpenAL"
}`,
},
},
],
pcsx2: [
{
name: 'OmniEmu Recommended',
description: 'Optimal PCSX2 configuration for modern hardware',
files: {
'inis/PCSX2.ini': `[Filenames]
[Settings]
UserMode = 0
EnableVSync = 1
[EmuCore]
GSRenderer = 13
EnableCheats = 0
EnableWideScreenPatches = 1
[GS]
Renderer = Vulkan
UpscaleMultiplier = 3
BilinearFilter = 1
TrilinearFilter = 1
`,
},
},
],
retroarch: [
{
name: 'OmniEmu Recommended',
description: 'Universal RetroArch config with optimal defaults',
files: {
'retroarch.cfg': `video_driver = "vulkan"
audio_driver = "pulseaudio"
input_driver = "udev"
video_fullscreen = true
video_vsync = true
video_scale_integer = false
video_smooth = true
audio_sync = true
savestate_thumbnail_enable = true
notification_show_autoconfig = false
`,
},
},
],
mame: [
{
name: 'OmniEmu Recommended',
description: 'MAME optimized settings',
files: {
'mame.ini': `#
# OmniEmu Recommended MAME Config
#
video auto
screen auto
aspect 4:3
effect none
waitvsync 1
syncrefresh 0
sleep 0
autosave 0
`,
},
},
],
};
const presetSourceUrl = 'https://raw.githubusercontent.com/mileswolfallen2/omniemu-presets/main/presets.json';
/** Fetch remote presets, falling back to built-in */
async function fetchRemotePresets(): Promise<Record<string, ConfigPreset[]> | null> {
try {
const { get } = await import('https');
const data = await new Promise<string>((resolve, reject) => {
get(presetSourceUrl, (res) => {
if (res.statusCode !== 200) { reject(new Error(`HTTP ${res.statusCode}`)); return; }
const chunks: Buffer[] = [];
res.on('data', (c: Buffer) => chunks.push(c));
res.on('end', () => resolve(Buffer.concat(chunks).toString()));
}).on('error', reject);
});
return JSON.parse(data);
} catch {
return null;
}
}
export function getBuiltInPresets(): Record<string, ConfigPreset[]> {
return builtInPresets;
}
export async function getPresets(emulatorId: string): Promise<ConfigPreset[]> {
// Try remote first, fall back to built-in
const remote = await fetchRemotePresets();
if (remote && remote[emulatorId]) return remote[emulatorId];
return builtInPresets[emulatorId] || [];
}
/** Get the config directory for an emulator given its install path */
function getConfigDir(emulatorId: string, installPath: string): string {
const platformDirs: Record<string, Record<string, string>> = {
dolphin: {
win32: join(process.env.APPDATA || join(require('os').homedir(), 'AppData', 'Roaming'), 'Dolphin'),
darwin: join(require('os').homedir(), 'Library', 'Application Support', 'Dolphin'),
linux: join(require('os').homedir(), '.config', 'dolphin-emu'),
},
rpcs3: {
win32: join(process.env.APPDATA || '', 'RPCS3'),
darwin: join(require('os').homedir(), 'Library', 'Application Support', 'rpcs3'),
linux: join(require('os').homedir(), '.config', 'rpcs3'),
},
ryujinx: {
win32: join(process.env.APPDATA || '', 'Ryujinx'),
darwin: join(require('os').homedir(), 'Library', 'Application Support', 'Ryujinx'),
linux: join(require('os').homedir(), '.config', 'Ryujinx'),
},
pcsx2: {
win32: join(process.env.APPDATA || '', 'PCSX2'),
darwin: join(require('os').homedir(), 'Library', 'Application Support', 'PCSX2'),
linux: join(require('os').homedir(), '.config', 'PCSX2'),
},
retroarch: {
win32: join(process.env.APPDATA || '', 'RetroArch'),
darwin: join(require('os').homedir(), 'Library', 'Application Support', 'RetroArch'),
linux: join(require('os').homedir(), '.config', 'retroarch'),
},
mame: {
win32: dirname(installPath),
darwin: dirname(installPath),
linux: join(require('os').homedir(), '.mame'),
},
};
return platformDirs[emulatorId]?.[platform] || dirname(installPath);
}
/** Check if an emulator has been configured with OmniEmu presets */
export function checkConfigured(emulatorId: string, installPath?: string): boolean {
if (!installPath) return false;
const marker = join(app.getPath('userData'), 'configs', `${emulatorId}.configured`);
return existsSync(marker);
}
/** Apply a config preset to the emulator's config directory */
export async function applyPreset(
emulatorId: string,
preset: ConfigPreset,
installPath: string,
onProgress?: (p: InstallProgress) => void
): Promise<void> {
const configDir = getConfigDir(emulatorId, installPath);
const report = onProgress
? (percent: number, message: string) =>
onProgress({ emulatorId, stage: 'configuring', percent, message })
: () => {};
report(0, `Configuring ${emulatorId} with "${preset.name}"...`);
const totalFiles = Object.keys(preset.files).length;
let done = 0;
for (const [relativePath, content] of Object.entries(preset.files)) {
const fullPath = join(configDir, relativePath);
const parentDir = dirname(fullPath);
if (!existsSync(parentDir)) {
mkdirSync(parentDir, { recursive: true });
}
writeFileSync(fullPath, content, 'utf-8');
done++;
report(Math.round((done / totalFiles) * 100), `Wrote ${relativePath}`);
}
// Write marker so we know this emulator was configured
const markerDir = join(app.getPath('userData'), 'configs');
if (!existsSync(markerDir)) mkdirSync(markerDir, { recursive: true });
writeFileSync(join(markerDir, `${emulatorId}.configured`), new Date().toISOString(), 'utf-8');
report(100, `${emulatorId} configured with "${preset.name}"`);
}
/** Apply the recommended preset (first preset available) */
export async function applyRecommendedConfig(
emulatorId: string,
installPath: string,
onProgress?: (p: InstallProgress) => void
): Promise<boolean> {
const presets = await getPresets(emulatorId);
if (presets.length === 0) return false;
await applyPreset(emulatorId, presets[0], installPath, onProgress);
return true;
}
+215 -110
View File
@@ -1,18 +1,18 @@
import { execSync, exec, ChildProcess } from 'child_process';
import { existsSync, mkdirSync } from 'fs';
import { join } from 'path';
import { join, dirname, basename, extname } from 'path';
import { app } from 'electron';
import {
EmulatorConfig,
EmulatorState,
Platform,
GameEntry,
RomFile,
InstallProgress,
} from '../shared/types';
import { getPlatform, getArch, isWindows, isMacOS, isLinux } from './platform';
import { settings } from './settings';
const platform = getPlatform();
const arch = getArch();
export const knownEmulators: EmulatorConfig[] = [
{
@@ -25,13 +25,41 @@ export const knownEmulators: EmulatorConfig[] = [
darwin: '/Applications/Dolphin.app/Contents/MacOS/Dolphin',
linux: '/usr/bin/dolphin-emu',
},
installUrl: {
downloads: {
win32: [
{
url: 'https://dl.dolphin-emu.org/releases/2412/dolphin-2412-x64.7z',
format: '7z',
executablePath: 'Dolphin.exe',
arch: 'x64',
},
],
darwin: [
{
url: 'https://dl.dolphin-emu.org/releases/2412/dolphin-2412-universal.dmg',
format: 'dmg',
arch: 'arm64',
},
{
url: 'https://dl.dolphin-emu.org/releases/2412/dolphin-2412-x64.dmg',
format: 'dmg',
arch: 'x64',
},
],
linux: [
{
url: 'https://dl.dolphin-emu.org/releases/2412/dolphin-2412-x86_64.AppImage',
format: 'appimage',
},
],
},
packageNames: { linux: 'dolphin-emu' },
supported: true,
websiteUrl: {
win32: 'https://dolphin-emu.org/download/',
darwin: 'https://dolphin-emu.org/download/',
linux: null,
linux: 'https://dolphin-emu.org/download/',
},
installVia: 'download',
supported: true,
},
{
id: 'rpcs3',
@@ -43,31 +71,27 @@ export const knownEmulators: EmulatorConfig[] = [
darwin: '/Applications/RPCS3.app/Contents/MacOS/rpcs3',
linux: '/usr/bin/rpcs3',
},
installUrl: {
win32: 'https://rpcs3.net/download',
darwin: null,
linux: null,
downloads: {
win32: [
{
url: 'https://github.com/RPCS3/rpcs3-binaries-win/releases/download/build-d2c3b344332efc6e545a09ad44b9f083c2a1a519/rpcs3-v0.0.34-17491-d2c3b344_win64.7z',
format: '7z',
executablePath: 'rpcs3.exe',
},
],
linux: [
{
url: 'https://github.com/RPCS3/rpcs3-binaries-linux/releases/download/build-d2c3b344332efc6e545a09ad44b9f083c2a1a519/rpcs3-v0.0.34-17491-d2c3b344_linux64.AppImage',
format: 'appimage',
},
],
},
installVia: 'download',
supported: true,
},
{
id: 'yuzu',
name: 'Yuzu',
description: 'Nintendo Switch emulator',
platforms: ['switch'],
defaultPath: {
win32: 'C:\\Program Files\\Yuzu\\yuzu.exe',
darwin: '/Applications/Yuzu.app/Contents/MacOS/yuzu',
linux: '/usr/bin/yuzu',
websiteUrl: {
win32: 'https://rpcs3.net/download',
darwin: 'https://rpcs3.net/download',
linux: 'https://rpcs3.net/download',
},
installUrl: {
win32: 'https://yuzu-emu.org/downloads/',
darwin: null,
linux: null,
},
installVia: 'download',
supported: false,
},
{
id: 'ryujinx',
@@ -79,13 +103,35 @@ export const knownEmulators: EmulatorConfig[] = [
darwin: '/Applications/Ryujinx.app/Contents/MacOS/Ryujinx',
linux: '/usr/bin/Ryujinx',
},
installUrl: {
downloads: {
win32: [
{
url: 'https://github.com/Ryujinx/release-channel-master/releases/latest/download/ryujinx-1.2.0-win_x64.zip',
format: 'zip',
executablePath: 'Ryujinx.exe',
},
],
darwin: [
{
url: 'https://github.com/Ryujinx/release-channel-master/releases/latest/download/ryujinx-1.2.0-mac_universal.zip',
format: 'zip',
executablePath: 'Ryujinx.app/Contents/MacOS/Ryujinx',
},
],
linux: [
{
url: 'https://github.com/Ryujinx/release-channel-master/releases/latest/download/ryujinx-1.2.0-linux_x64.zip',
format: 'zip',
executablePath: 'Ryujinx',
},
],
},
supported: true,
websiteUrl: {
win32: 'https://ryujinx.org/download',
darwin: 'https://ryujinx.org/download',
linux: 'https://ryujinx.org/download',
},
installVia: 'download',
supported: true,
},
{
id: 'pcsx2',
@@ -97,13 +143,39 @@ export const knownEmulators: EmulatorConfig[] = [
darwin: '/Applications/PCSX2.app/Contents/MacOS/PCSX2',
linux: '/usr/bin/pcsx2',
},
installUrl: {
downloads: {
win32: [
{
url: 'https://github.com/PCSX2/pcsx2/releases/download/v2.3.200/pcsx2-v2.3.200-windows-x64-installer.exe',
format: 'exe',
},
],
darwin: [
{
url: 'https://github.com/PCSX2/pcsx2/releases/download/v2.3.200/pcsx2-v2.3.200-macos-arm64.dmg',
format: 'dmg',
arch: 'arm64',
},
{
url: 'https://github.com/PCSX2/pcsx2/releases/download/v2.3.200/pcsx2-v2.3.200-macos-x64.dmg',
format: 'dmg',
arch: 'x64',
},
],
linux: [
{
url: 'https://github.com/PCSX2/pcsx2/releases/download/v2.3.200/pcsx2-v2.3.200-linux-x86_64.AppImage',
format: 'appimage',
},
],
},
packageNames: { linux: 'pcsx2' },
supported: true,
websiteUrl: {
win32: 'https://pcsx2.net/downloads/',
darwin: 'https://pcsx2.net/downloads/',
linux: null,
linux: 'https://pcsx2.net/downloads/',
},
installVia: 'downloacd',
supported: true,
},
{
id: 'mame',
@@ -115,18 +187,33 @@ export const knownEmulators: EmulatorConfig[] = [
darwin: '/Applications/MAME.app/Contents/MacOS/mame',
linux: '/usr/bin/mame',
},
installUrl: {
win32: 'https://www.mamedev.org/release.html',
darwin: null,
linux: null,
downloads: {
win32: [
{
url: 'https://github.com/mamedev/mame/releases/download/mame0276/mame0276_64bit.7z',
format: '7z',
executablePath: 'mame64.exe',
},
],
linux: [
{
url: 'https://github.com/mamedev/mame/releases/download/mame0276/mame0276-x86_64.AppImage',
format: 'appimage',
},
],
},
installVia: 'download',
packageNames: { linux: 'mame' },
supported: true,
websiteUrl: {
win32: 'https://www.mamedev.org/release.html',
darwin: 'https://www.mamedev.org/release.html',
linux: 'https://www.mamedev.org/release.html',
},
},
{
id: 'retroarch',
name: 'RetroArch',
description: 'Multi-system emulator frontend',
description: 'Multi-system emulator frontend (NES, SNES, N64, GB, GBA, PS1, etc.)',
platforms: [
'nes', 'snes', 'n64', 'gb', 'gba', 'gbc',
'ps1', 'pce', 'sega-md', 'sega-saturn', 'sega-dc',
@@ -136,17 +223,42 @@ export const knownEmulators: EmulatorConfig[] = [
darwin: '/Applications/RetroArch.app/Contents/MacOS/RetroArch',
linux: '/usr/bin/retroarch',
},
installUrl: {
downloads: {
win32: [
{
url: 'https://buildbot.libretro.com/stable/1.19.1/windows/x86_64/RetroArch.7z',
format: '7z',
executablePath: 'RetroArch.exe',
},
],
darwin: [
{
url: 'https://buildbot.libretro.com/stable/1.19.1/apple/osx/universal/RetroArch.dmg',
format: 'dmg',
},
],
linux: [
{
url: 'https://buildbot.libretro.com/stable/1.19.1/linux/x86_64/RetroArch.7z',
format: '7z',
executablePath: 'retroarch',
},
],
},
packageNames: {
linux: 'retroarch',
darwin: 'retroarch',
},
supported: true,
websiteUrl: {
win32: 'https://retroarch.com/?page=platforms',
darwin: 'https://retroarch.com/?page=platforms',
linux: 'https://retroarch.com/?page=platforms',
},
installVia: 'download',
supported: true,
},
];
function findEmulator(id: string): EmulatorConfig | undefined {
export function findEmulator(id: string): EmulatorConfig | undefined {
return knownEmulators.find((e) => e.id === id);
}
@@ -154,20 +266,18 @@ function detectEmulatorPath(config: EmulatorConfig): string | undefined {
const path = config.defaultPath[platform];
if (path && existsSync(path)) return path;
// Search common alternative paths
const alternatives = alternativePaths(config.id);
for (const alt of alternatives) {
if (existsSync(alt)) return alt;
}
// Try which/where command
try {
const cmd = isWindows() ? 'where' : 'which';
const result = execSync(`${cmd} ${config.id} 2>${isWindows() ? 'nul' : '/dev/null'}`)
.toString().trim();
if (result) return result.split('\n')[0];
} catch {
// not found via path
// not found
}
return undefined;
@@ -175,19 +285,47 @@ function detectEmulatorPath(config: EmulatorConfig): string | undefined {
function alternativePaths(emulatorId: string): string[] {
const home = require('os').homedir();
const userData = app.getPath('userData');
const omniEmuDir = join(userData, 'emulators', emulatorId);
const common: Record<string, string[]> = {
dolphin: [
join(omniEmuDir, 'Dolphin.exe'),
join(omniEmuDir, 'Dolphin'),
join(omniEmuDir, 'dolphin-emu'),
join(home, 'Applications', 'Dolphin.app', 'Contents', 'MacOS', 'Dolphin'),
'/usr/local/bin/dolphin-emu',
'/snap/bin/dolphin-emu',
],
retroarch: [
join(omniEmuDir, 'RetroArch.exe'),
join(omniEmuDir, 'RetroArch'),
join(omniEmuDir, 'retroarch'),
join(home, 'Applications', 'RetroArch.app', 'Contents', 'MacOS', 'RetroArch'),
'/usr/local/bin/retroarch',
'/snap/bin/retroarch',
],
rpcs3: [
join(omniEmuDir, 'rpcs3.exe'),
join(omniEmuDir, 'rpcs3'),
join(omniEmuDir, 'RPCS3.AppImage'),
],
ryujinx: [
join(omniEmuDir, 'Ryujinx.exe'),
join(omniEmuDir, 'Ryujinx'),
],
pcsx2: [
join(omniEmuDir, 'pcsx2.exe'),
join(omniEmuDir, 'PCSX2'),
join(omniEmuDir, 'pcsx2.AppImage'),
],
mame: [
join(omniEmuDir, 'mame64.exe'),
join(omniEmuDir, 'mame'),
join(omniEmuDir, 'MAME.AppImage'),
],
};
return common[emulatorId] || [];
return common[emulatorId] || [join(omniEmuDir)];
}
export function checkEmulator(id: string): EmulatorState {
@@ -195,14 +333,14 @@ export function checkEmulator(id: string): EmulatorState {
if (!config) {
return {
installed: false,
configured: false,
config: {
id,
name: id,
description: '',
platforms: [],
defaultPath: { win32: '', darwin: '', linux: '' },
installUrl: { win32: null, darwin: null, linux: null },
installVia: 'manual',
downloads: {},
supported: false,
},
};
@@ -226,6 +364,7 @@ export function checkEmulator(id: string): EmulatorState {
version,
path,
config,
configured: !!path && existsSync(join(app.getPath('userData'), 'configs', `${id}.configured`)),
};
}
@@ -244,7 +383,7 @@ export function launchGame(emulatorId: string, romPath: string): ChildProcess |
const cmd = `"${state.path}" ${args}`;
const proc = exec(cmd, {
cwd: require('path').dirname(state.path),
cwd: dirname(state.path),
});
return proc;
@@ -285,18 +424,12 @@ export function getEmulatorsDirectory(): string {
const s = settings.get();
if (s.emulatorsDirectory && existsSync(s.emulatorsDirectory)) return s.emulatorsDirectory;
const home = require('os').homedir();
const defaultDir = join(home, 'OmniEmu', 'emulators');
if (!existsSync(defaultDir)) {
mkdirSync(defaultDir, { recursive: true });
}
return defaultDir;
const dir = join(app.getPath('userData'), 'emulators');
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
return dir;
}
export function scanRoms(directory: string): GameEntry[] {
const { readdirSync, statSync } = require('fs');
const { extname, basename, join: pathJoin } = require('path');
const romExtensions = [
'.nes', '.sfc', '.smc', '.n64', '.z64', '.v64',
'.gba', '.gb', '.gbc', '.nds', '.iso', '.bin', '.cue',
@@ -309,10 +442,10 @@ export function scanRoms(directory: string): GameEntry[] {
function scanDir(dir: string) {
try {
const files = readdirSync(dir);
const files = require('fs').readdirSync(dir);
for (const file of files) {
const fullPath = pathJoin(dir, file);
const stat = statSync(fullPath);
const fullPath = join(dir, file);
const stat = require('fs').statSync(fullPath);
if (stat.isDirectory()) {
scanDir(fullPath);
} else {
@@ -330,9 +463,7 @@ export function scanRoms(directory: string): GameEntry[] {
}
}
}
} catch {
// skip unreadable dirs
}
} catch { /* skip unreadable */ }
}
scanDir(directory);
@@ -341,55 +472,29 @@ export function scanRoms(directory: string): GameEntry[] {
function guessPlatform(ext: string): string {
const map: Record<string, string> = {
'.nes': 'nes',
'.sfc': 'snes',
'.smc': 'snes',
'.n64': 'n64',
'.z64': 'n64',
'.v64': 'n64',
'.gba': 'gba',
'.gb': 'gb',
'.gbc': 'gbc',
'.nds': 'nds',
'.iso': 'ps1',
'.bin': 'ps1',
'.cue': 'ps1',
'.wbfs': 'wii',
'.wad': 'wii',
'.nsp': 'switch',
'.xci': 'switch',
'.pkg': 'ps3',
'.chd': 'ps1',
'.gcm': 'gc',
'.gcz': 'gc',
'.rvz': 'gc',
'.ps2': 'ps2',
'.cso': 'ps2',
'.nes': 'nes', '.sfc': 'snes', '.smc': 'snes',
'.n64': 'n64', '.z64': 'n64', '.v64': 'n64',
'.gba': 'gba', '.gb': 'gb', '.gbc': 'gbc', '.nds': 'nds',
'.iso': 'ps1', '.bin': 'ps1', '.cue': 'ps1',
'.wbfs': 'wii', '.wad': 'wii',
'.nsp': 'switch', '.xci': 'switch',
'.pkg': 'ps3', '.chd': 'ps1',
'.gcm': 'gc', '.gcz': 'gc', '.rvz': 'gc',
'.ps2': 'ps2', '.cso': 'ps2',
};
return map[ext] || 'other';
}
function guessEmulator(ext: string): string {
const map: Record<string, string> = {
'.nes': 'retroarch',
'.sfc': 'retroarch',
'.smc': 'retroarch',
'.n64': 'retroarch',
'.z64': 'retroarch',
'.v64': 'retroarch',
'.gba': 'retroarch',
'.gb': 'retroarch',
'.gbc': 'retroarch',
'.wbfs': 'dolphin',
'.wad': 'dolphin',
'.gcm': 'dolphin',
'.gcz': 'dolphin',
'.rvz': 'dolphin',
'.nsp': 'ryujinx',
'.xci': 'ryujinx',
'.nes': 'retroarch', '.sfc': 'retroarch', '.smc': 'retroarch',
'.n64': 'retroarch', '.z64': 'retroarch', '.v64': 'retroarch',
'.gba': 'retroarch', '.gb': 'retroarch', '.gbc': 'retroarch',
'.wbfs': 'dolphin', '.wad': 'dolphin', '.gcm': 'dolphin',
'.gcz': 'dolphin', '.rvz': 'dolphin',
'.nsp': 'ryujinx', '.xci': 'ryujinx',
'.pkg': 'rpcs3',
'.ps2': 'pcsx2',
'.cso': 'pcsx2',
'.ps2': 'pcsx2', '.cso': 'pcsx2',
};
return map[ext] || 'retroarch';
}
+191
View File
@@ -0,0 +1,191 @@
import { createWriteStream, existsSync, mkdirSync, createReadStream } from 'fs';
import { join, dirname, basename, extname } from 'path';
import { get } from 'https';
import { get as getHttp } from 'http';
import { execSync, spawn } from 'child_process';
import { pipeline } from 'stream/promises';
import { createGunzip, createBrotliDecompress, createInflate } from 'zlib';
import { randomBytes } from 'crypto';
import { app } from 'electron';
import { EmulatorDownload, InstallProgress, Platform, Arch } from '../shared/types';
import { getPlatform, getArch, isWindows, isMacOS, isLinux } from './platform';
type ProgressCallback = (progress: InstallProgress) => void;
function downloadDir(): string {
const d = join(app.getPath('userData'), 'downloads');
if (!existsSync(d)) mkdirSync(d, { recursive: true });
return d;
}
function tempName(suffix: string): string {
return join(downloadDir(), `${randomBytes(8).toString('hex')}${suffix}`);
}
function downloadFile(url: string, dest: string, onProgress: (pct: number) => void): Promise<void> {
return new Promise((resolve, reject) => {
const protocol = url.startsWith('https') ? get : getHttp;
protocol(url, (response) => {
const code = response.statusCode ?? 500;
if (code >= 300 && code < 400 && response.headers.location) {
downloadFile(response.headers.location, dest, onProgress).then(resolve).catch(reject);
return;
}
if (code !== 200) {
reject(new Error(`HTTP ${code} downloading ${url}`));
return;
}
const total = parseInt(response.headers['content-length'] ?? '0', 10);
let downloaded = 0;
const file = createWriteStream(dest);
response.on('data', (chunk: Buffer) => {
downloaded += chunk.length;
if (total > 0) onProgress(Math.round((downloaded / total) * 100));
});
pipeline(response, file)
.then(() => {
onProgress(100);
resolve();
})
.catch(reject);
}).on('error', reject);
});
}
function extractArchive(archivePath: string, destDir: string, format: string, emulatorId: string, onProgress: (msg: string) => void): void {
if (!existsSync(destDir)) mkdirSync(destDir, { recursive: true });
onProgress(`Extracting ${basename(archivePath)}...`);
switch (format) {
case 'zip': {
execSync(`unzip -o "${archivePath}" -d "${destDir}"`, { stdio: 'pipe' });
break;
}
case 'tar.gz':
case 'tar.bz2': {
const decompress = format === 'tar.bz2' ? '-j' : '-z';
execSync(`tar -x${decompress}f "${archivePath}" -C "${destDir}"`, { stdio: 'pipe' });
break;
}
case '7z': {
execSync(`7z x "${archivePath}" -o"${destDir}" -y`, { stdio: 'pipe' });
break;
}
case 'dmg': {
// Attach DMG and copy .app out
const mountPoint = `/tmp/omniemu_${emulatorId}`;
execSync(`hdiutil attach "${archivePath}" -mountpoint "${mountPoint}" -nobrowse -quiet`, { stdio: 'pipe' });
execSync(`cp -R "${mountPoint}"/*.app "${destDir}/" 2>/dev/null; cp -R "${mountPoint}"/*/*.app "${destDir}/" 2>/dev/null; true`, { stdio: 'pipe' });
execSync(`hdiutil detach "${mountPoint}" -quiet 2>/dev/null; true`, { stdio: 'pipe' });
break;
}
case 'exe':
case 'msi':
case 'appimage':
case 'pkg': {
// These are direct installers, not archives
break;
}
default:
throw new Error(`Unknown archive format: ${format}`);
}
onProgress('Extraction complete');
}
function runInstaller(installerPath: string, format: string, emulatorId: string, installDir: string, onProgress: (msg: string) => void): void {
onProgress(`Running installer for ${emulatorId}...`);
switch (format) {
case 'exe': {
if (isWindows()) {
execSync(`"${installerPath}" /S /D="${installDir}"`, { stdio: 'pipe', timeout: 300000 });
} else {
execSync(`chmod +x "${installerPath}" && "${installerPath}" -- "${installDir}"`, { stdio: 'pipe', timeout: 300000 });
}
break;
}
case 'msi': {
execSync(`msiexec /i "${installerPath}" /quiet /norestart INSTALLDIR="${installDir}"`, { stdio: 'pipe', timeout: 300000 });
break;
}
case 'appimage': {
execSync(`chmod +x "${installerPath}"`, { stdio: 'pipe' });
// Copy AppImage to install dir
execSync(`cp "${installerPath}" "${installDir}/${emulatorId}.AppImage"`, { stdio: 'pipe' });
break;
}
case 'pkg': {
execSync(`installer -pkg "${installerPath}" -target /`, { stdio: 'pipe', timeout: 300000 });
break;
}
case 'dmg': {
// Already handled in extractArchive
break;
}
default:
throw new Error(`Cannot install format: ${format}`);
}
onProgress('Installer finished');
}
export type { ProgressCallback };
export async function installEmulator(
emulatorId: string,
downloads: EmulatorDownload[],
platform: Platform,
arch: Arch,
onProgress: ProgressCallback
): Promise<string> {
// Pick the right download for this platform + arch
const candidates = downloads.filter((d) => !d.arch || d.arch === arch);
const download = candidates[0];
if (!download) throw new Error(`No download available for ${platform}/${arch}`);
const report = (stage: InstallProgress['stage'], percent: number, message: string) => {
onProgress({ emulatorId, stage, percent, message });
};
report('downloading', 0, `Downloading ${download.url}...`);
const downloadPath = tempName(`.${download.format}`);
await downloadFile(download.url, downloadPath, (pct) => {
report('downloading', pct, `Downloading ${emulatorId}... ${pct}%`);
});
const installDir = join(app.getPath('userData'), 'emulators', emulatorId);
if (!existsSync(installDir)) mkdirSync(installDir, { recursive: true });
// Determine if this is an archive or an installer
const archiveFormats = ['zip', 'tar.gz', 'tar.bz2', '7z', 'dmg'];
const installerFormats = ['exe', 'msi', 'pkg', 'appimage'];
if (archiveFormats.includes(download.format)) {
report('extracting', 0, `Extracting ${emulatorId}...`);
extractArchive(downloadPath, installDir, download.format, emulatorId, (msg) => {
report('extracting', 50, msg);
});
report('extracting', 100, 'Extraction complete');
}
if (installerFormats.includes(download.format)) {
report('installing', 0, `Installing ${emulatorId}...`);
runInstaller(downloadPath, download.format, emulatorId, installDir, (msg) => {
report('installing', 50, msg);
});
report('installing', 100, 'Installation complete');
}
// Cleanup
try { execSync(`rm -f "${downloadPath}"`); } catch { /* ignore */ }
report('done', 100, `${emulatorId} installed successfully`);
return installDir;
}
+65 -11
View File
@@ -1,16 +1,19 @@
import { ipcMain, shell, dialog } from 'electron';
import { ipcMain, shell, dialog, BrowserWindow } from 'electron';
import {
getAllEmulatorStates,
checkEmulator,
launchGame,
scanRoms,
knownEmulators,
findEmulator,
getRomsDirectory,
getEmulatorsDirectory,
} from './emulators';
import { installEmulator } from './installer';
import { applyRecommendedConfig, getPresets, checkConfigured } from './configurator';
import { settings } from './settings';
import { getSystemInfo, platformName } from './platform';
import { GameEntry, AppSettings } from '../shared/types';
import { getSystemInfo, platformName, getPlatform, getArch } from './platform';
import { InstallProgress, AppSettings } from '../shared/types';
export function registerIpcHandlers(): void {
// System
@@ -21,15 +24,66 @@ export function registerIpcHandlers(): void {
ipcMain.handle('emulators:list', () => knownEmulators);
ipcMain.handle('emulators:states', () => getAllEmulatorStates());
ipcMain.handle('emulators:check', (_event, id: string) => checkEmulator(id));
ipcMain.handle('emulators:install-url', (_event, id: string) => {
const emu = knownEmulators.find((e) => e.id === id);
if (emu?.installUrl) {
const platform = getSystemInfo().platform;
const url = emu.installUrl[platform];
if (url) shell.openExternal(url);
return url;
// Install emulator - direct download + install
ipcMain.handle(
'emulators:install',
async (event, emulatorId: string) => {
const config = findEmulator(emulatorId);
if (!config) throw new Error(`Unknown emulator: ${emulatorId}`);
if (!config.downloads) throw new Error(`No downloads configured for ${emulatorId}`);
const platform = getPlatform();
const arch = getArch();
const downloads = config.downloads[platform];
if (!downloads || downloads.length === 0)
throw new Error(`No downloads available for ${emulatorId} on ${platform}`);
const win = BrowserWindow.fromWebContents(event.sender);
const sendProgress = (p: InstallProgress) => {
win?.webContents.send('emulators:install-progress', p);
};
await installEmulator(emulatorId, downloads, platform, arch, sendProgress);
// Re-check state after install
return checkEmulator(emulatorId);
}
return null;
);
// Configure emulator with recommended settings
ipcMain.handle(
'emulators:configure',
async (_event, emulatorId: string, installPath: string) => {
const success = await applyRecommendedConfig(emulatorId, installPath);
return { success, state: checkEmulator(emulatorId) };
}
);
// Get available presets for an emulator
ipcMain.handle(
'emulators:presets',
async (_event, emulatorId: string) => {
return getPresets(emulatorId);
}
);
// Check if configured
ipcMain.handle(
'emulators:configured',
(_event, emulatorId: string, installPath?: string) => {
return checkConfigured(emulatorId, installPath);
}
);
// Open website (fallback for manual download)
ipcMain.handle('emulators:open-website', (_event, id: string) => {
const emu = findEmulator(id);
if (emu?.websiteUrl?.[getPlatform()]) {
shell.openExternal(emu.websiteUrl[getPlatform()]!);
return true;
}
return false;
});
// ROMs / Games
+30 -3
View File
@@ -1,10 +1,12 @@
import { contextBridge, ipcRenderer } from 'electron';
import {
import type {
EmulatorConfig,
EmulatorState,
GameEntry,
SystemInfo,
AppSettings,
InstallProgress,
ConfigPreset,
} from '../shared/types';
const api = {
@@ -18,8 +20,33 @@ const api = {
states: (): Promise<EmulatorState[]> => ipcRenderer.invoke('emulators:states'),
check: (id: string): Promise<EmulatorState> =>
ipcRenderer.invoke('emulators:check', id),
openInstallUrl: (id: string): Promise<string | null> =>
ipcRenderer.invoke('emulators:install-url', id),
/** Download + install emulator directly. Returns final EmulatorState */
install: (id: string): Promise<EmulatorState> =>
ipcRenderer.invoke('emulators:install', id),
/** Apply recommended config preset to an installed emulator */
configure: (id: string, installPath: string): Promise<{ success: boolean; state: EmulatorState }> =>
ipcRenderer.invoke('emulators:configure', id, installPath),
/** Get available config presets for an emulator */
presets: (id: string): Promise<ConfigPreset[]> =>
ipcRenderer.invoke('emulators:presets', id),
/** Check if an emulator has been configured */
configured: (id: string, installPath?: string): Promise<boolean> =>
ipcRenderer.invoke('emulators:configured', id, installPath),
/** Open the emulator's website in browser (fallback) */
openWebsite: (id: string): Promise<boolean> =>
ipcRenderer.invoke('emulators:open-website', id),
/** Listen for install progress updates */
onInstallProgress: (cb: (progress: InstallProgress) => void) => {
const handler = (_event: Electron.IpcRendererEvent, p: InstallProgress) => cb(p);
ipcRenderer.on('emulators:install-progress', handler);
return () => ipcRenderer.removeListener('emulators:install-progress', handler);
},
},
roms: {
+1 -1
View File
@@ -2,7 +2,6 @@ import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs';
import { join, dirname } from 'path';
import { app } from 'electron';
import { AppSettings } from '../shared/types';
import { getPlatform } from './platform';
const SETTINGS_FILE = 'settings.json';
@@ -17,6 +16,7 @@ const defaultSettings: AppSettings = {
minimiseToTray: true,
launchInFullscreen: false,
closeToTray: true,
presetSourceUrl: 'https://raw.githubusercontent.com/mileswolfallen2/omniemu-presets/main/presets.json',
};
let cached: AppSettings | null = null;
+57
View File
@@ -0,0 +1,57 @@
export type Platform = 'win32' | 'darwin' | 'linux';
export type Arch = 'x64' | 'arm64';
export interface EmulatorConfig {
id: string;
name: string;
description: string;
platforms: string[];
defaultPath: Record<Platform, string>;
installUrl: Record<Platform, string | null>;
installVia: 'download' | 'brew' | 'apt' | 'winget' | 'manual';
supported: boolean;
}
export interface RomFile {
path: string;
name: string;
size: number;
format: string;
platform: string;
lastPlayed?: string;
playCount: number;
}
export interface GameEntry {
id: string;
romPath: string;
title: string;
platform: string;
emulatorId: string;
coverUrl?: string;
lastPlayed?: string;
playCount: number;
addedAt: string;
}
export interface ScanResult {
emulatorId: string;
games: GameEntry[];
}
export interface AppSettings {
romsDirectory: string;
emulatorsDirectory: string;
theme: 'light' | 'dark' | 'system';
minimiseToTray: boolean;
launchInFullscreen: boolean;
closeToTray: boolean;
}
export interface SystemInfo {
platform: Platform;
arch: Arch;
homeDir: string;
appDataDir: string;
}
export interface EmulatorState {
installed: boolean;
version?: string;
path?: string;
config: EmulatorConfig;
}
//# sourceMappingURL=types.d.ts.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["types.ts"],"names":[],"mappings":"AAAA,MAAM,MAAM,QAAQ,GAAG,OAAO,GAAG,QAAQ,GAAG,OAAO,CAAC;AACpD,MAAM,MAAM,IAAI,GAAG,KAAK,GAAG,OAAO,CAAC;AAEnC,MAAM,WAAW,cAAc;IAC7B,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,EAAE,MAAM,CAAC;IACb,WAAW,EAAE,MAAM,CAAC;IACpB,SAAS,EAAE,MAAM,EAAE,CAAC;IACpB,WAAW,EAAE,MAAM,CAAC,QAAQ,EAAE,MAAM,CAAC,CAAC;IACtC,UAAU,EAAE,MAAM,CAAC,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAC,CAAC;IAC5C,UAAU,EAAE,UAAU,GAAG,MAAM,GAAG,KAAK,GAAG,QAAQ,GAAG,QAAQ,CAAC;IAC9D,SAAS,EAAE,OAAO,CAAC;CACpB;AAED,MAAM,WAAW,OAAO;IACtB,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,MAAM,CAAC;IACjB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,SAAS,EAAE,MAAM,CAAC;CACnB;AAED,MAAM,WAAW,SAAS;IACxB,EAAE,EAAE,MAAM,CAAC;IACX,OAAO,EAAE,MAAM,CAAC;IAChB,KAAK,EAAE,MAAM,CAAC;IACd,QAAQ,EAAE,MAAM,CAAC;IACjB,UAAU,EAAE,MAAM,CAAC;IACnB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,SAAS,EAAE,MAAM,CAAC;IAClB,OAAO,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,WAAW,UAAU;IACzB,UAAU,EAAE,MAAM,CAAC;IACnB,KAAK,EAAE,SAAS,EAAE,CAAC;CACpB;AAED,MAAM,WAAW,WAAW;IAC1B,aAAa,EAAE,MAAM,CAAC;IACtB,kBAAkB,EAAE,MAAM,CAAC;IAC3B,KAAK,EAAE,OAAO,GAAG,MAAM,GAAG,QAAQ,CAAC;IACnC,cAAc,EAAE,OAAO,CAAC;IACxB,kBAAkB,EAAE,OAAO,CAAC;IAC5B,WAAW,EAAE,OAAO,CAAC;CACtB;AAED,MAAM,WAAW,UAAU;IACzB,QAAQ,EAAE,QAAQ,CAAC;IACnB,IAAI,EAAE,IAAI,CAAC;IACX,OAAO,EAAE,MAAM,CAAC;IAChB,UAAU,EAAE,MAAM,CAAC;CACpB;AAED,MAAM,WAAW,aAAa;IAC5B,SAAS,EAAE,OAAO,CAAC;IACnB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,cAAc,CAAC;CACxB"}
+3
View File
@@ -0,0 +1,3 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
//# sourceMappingURL=types.js.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"file":"types.js","sourceRoot":"","sources":["types.ts"],"names":[],"mappings":""}
+45 -2
View File
@@ -1,15 +1,55 @@
export type Platform = 'win32' | 'darwin' | 'linux';
export type Arch = 'x64' | 'arm64';
export interface EmulatorDownload {
url: string;
/** Archive type: 'exe' = direct installer, 'msi' = windows msi,
* 'dmg' = macOS disk image, 'appimage' = linux appimage,
* 'tar.gz' | 'tar.bz2' | 'zip' = archive to extract,
* 'pkg' = macOS installer, '7z' = 7zip archive */
format: string;
/** Path inside archive to the executable (if archive format) */
executablePath?: string;
/** Expected file size in bytes (for validation) */
size?: number;
/** Only for this arch (omitted = all arches) */
arch?: Arch;
}
export interface EmulatorConfig {
id: string;
name: string;
description: string;
platforms: string[];
defaultPath: Record<Platform, string>;
installUrl: Record<Platform, string | null>;
installVia: 'download' | 'brew' | 'apt' | 'winget' | 'manual';
/** Direct download sources per platform */
downloads: Partial<Record<Platform, EmulatorDownload[]>>;
/** Package manager names for auto-install via system pkg manager */
packageNames?: Partial<Record<Platform, string>>;
supported: boolean;
/** URL to fetch recommended config presets from */
presetUrl?: string;
/** Website URL for manual/fallback */
websiteUrl?: Record<Platform, string>;
}
export interface InstallProgress {
emulatorId: string;
stage: 'downloading' | 'extracting' | 'installing' | 'configuring' | 'done' | 'error';
percent: number;
message: string;
error?: string;
}
export interface ConfigPreset {
/** Name of the preset (e.g. "Performance", "Quality", "Recommended") */
name: string;
/** Description of what this preset does */
description: string;
/** Platform-specific config files to write: relative path -> file content */
files: Record<string, string>;
/** Registry/settings changes for Windows */
registry?: Record<string, string>;
}
export interface RomFile {
@@ -46,6 +86,8 @@ export interface AppSettings {
minimiseToTray: boolean;
launchInFullscreen: boolean;
closeToTray: boolean;
/** URL to fetch recommended config presets from */
presetSourceUrl: string;
}
export interface SystemInfo {
@@ -60,4 +102,5 @@ export interface EmulatorState {
version?: string;
path?: string;
config: EmulatorConfig;
configured: boolean;
}
+2 -2
View File
@@ -2,8 +2,8 @@
"extends": "./tsconfig.json",
"compilerOptions": {
"module": "CommonJS",
"outDir": "dist/main",
"rootDir": "src/main",
"moduleResolution": "node10",
"outDir": "dist",
"types": ["node"]
},
"include": ["src/main/**/*", "src/shared/**/*"]