This commit is contained in:
2026-07-16 04:50:30 -05:00
parent b317bfec4d
commit d4ac2bf632
20 changed files with 615 additions and 183 deletions
-100
View File
@@ -1,100 +0,0 @@
# OmniEmu v0.3.1 Release Notes
> All your games. One place.
---
## What's New
### Cloud Sync (Beta)
Sync your game saves between devices using Syncthing — free, open-source, and fully local. No third-party accounts needed.
- Install Syncthing directly from the app (one-click download)
- Auto-starts on launch once configured
- Host your device or pair with another to share saves
- Manage shared folders and paired devices from the Cloud Sync tab
- Web UI access for advanced configuration
### Save Manager
Browse, back up, and delete game saves across all your emulators.
- Dual view: **By Game** (fuzzy-matched titles) or **By Emulator**
- RetroArch saves with recursive core-directory scanning
- Custom save directories per emulator
- Backup saves before deleting
### New Emulators
Four new emulators added to the library:
| Emulator | System | Platforms |
|---|---|---|
| **Cemu** | Wii U | Windows, macOS, Linux |
| **xemu** | Original Xbox | Windows, macOS, Linux |
| **Vita3K** | PS Vita | Windows, macOS, Linux |
| **Azahar** | Nintendo 3DS | Windows, macOS, Linux |
### Frontend Support (Beta)
Launch alternative frontends directly from OmniEmu:
- **ES-DE** — full integration with auto-configuration
- **NeoStation** — Flutter-based frontend with ScreenScraper integration
- **Pegasus** — automatic collection file generation from your ROM library
- **EmuBuddy** — available to all users (not behind beta toggle)
### Decompilations (Beta)
Native PC ports built from reverse-engineered source code — bring your own ROM:
- **Ship of Harkinian** (OoT), **2 Ship 2 Harkinian** (MM), **SoH Demo** — Zelda
- **Portal 64**, **Banana Mania PC** — additional ports
- Auto-download from GitHub releases, built-in ROM management
- Supported titles include N64, NDS, GBA, DS, Wii, GameCube, and more
Enable frontends and decompilations under **Settings > Experimental > Beta Features**.
### SteamGridDB Cover Art
Search and set custom cover art from SteamGridDB's free API.
- Search covers from the game detail modal
- Thumbnails downloaded locally for instant display
- Rate-limit aware with automatic retry
- "Reset Cover" button to clear custom art
### Themes
Seven themes available in Settings:
- Light, Dark, System (auto)
- **Midnight** — deep navy
- **Ember** — warm dark
- **Lavender** — soft purple
- **Jade** — cool teal
### Horizontal Navigation
Redesigned top-bar navigation with full gamepad/controller support:
- Spatial D-pad navigation (up/down/left/right between elements)
- A to select, B to close modals
- LB/RB to switch tabs
- Auto-focus on page change
---
## Bug Fixes
- **RetroArch save detection** — now finds saves in core subdirectories (`saves/Snes9x/file.srm`) and supports portable installs (`~/Documents/RetroArch/saves/`)
- **Syncthing install** — fixed macOS extraction from nested zip directory, updated to v2.1.2
- **Syncthing blue screen** — fixed IPC handler crashes, pipe buffer deadlock (`stdio: 'pipe'``stdio: 'ignore'`), null device ID crash in API response parsing
- **Cloud Sync poll leak** — start polling now cleans up on unmount, loading state stays visible during startup
- **Cemu macOS** — removed x64-only filter so it installs via Rosetta on ARM Macs
- **Vita3K macOS** — added ARM64 DMG variant alongside x64
- **RetroArch cores 404** — removed broken `RetroArch_cores.7z` download (use RetroArch's Online Updater instead)
- **BIOS folder scan** — `scanRoms()` now skips BIOS directories
- **CSS accent colors** — replaced hardcoded colors with `color-mix()` using theme variables
- **Duplicate CSS** — renamed `.platform-tag` conflict to `.platform-tag-accent`
- **Unused imports** — cleaned up `require()` calls, dead imports, stale `types.d.ts`
---
## System Requirements
- **OS:** Windows 10+, macOS 11+, Ubuntu 20.04+
- **Disk:** ~200 MB for app + emulators vary
- **RAM:** 4 GB minimum
---
## Supported Emulators (18 total)
RetroArch, Dolphin, RPCS3, Cemu, xemu, Vita3K, Azahar, Eden, PPSSPP, DuckStation, PCSX2, Flycast, Dolphin (trio), Yuzu (legacy), mGBA, MelonDS, and more.
---
*Built with Electron, React, and TypeScript.*
+127
View File
@@ -0,0 +1,127 @@
# OmniEmu v0.3.2 Release Notes
> All your games. One place.
---
## What's New
### Cloud Sync
Sync your game saves between devices using Syncthing — free, open-source, and fully local. No third-party accounts needed.
- Install Syncthing directly from the app (one-click download)
- Toggle on/off from **Settings > Experimental > Cloud Sync**
- Host your device or pair with another to share saves
- Manage shared folders and paired devices from the Cloud Sync tab
- Web UI access for advanced configuration
### Save Manager
Browse, back up, and delete game saves across all your emulators.
- Dual view: **By Game** (fuzzy-matched titles) or **By Emulator**
- RetroArch saves with recursive core-directory scanning
- Custom save directories per emulator
- Backup saves before deleting
### New Emulators
Five new emulators added to the library:
| Emulator | System | Platforms |
|---|---|---|
| **Cemu** | Wii U | Windows, macOS, Linux |
| **xemu** | Original Xbox | Windows, macOS, Linux |
| **Vita3K** | PS Vita | Windows, macOS, Linux |
| **Azahar** | Nintendo 3DS | Windows, macOS, Linux |
| **Mednafen** | Multi-system | Windows, macOS, Linux |
### Frontend Support
Launch alternative frontends directly from OmniEmu — toggle on in **Settings > Experimental > Frontend Support**:
- **ES-DE** — full integration with auto-configuration, scraping, themes, and more
- **NeoStation** — Flutter-based frontend with built-in ScreenScraper integration
- **Pegasus** — automatic collection file generation from your ROM library
- **EmuBuddy** — available to all users (not behind the toggle)
### Decompilations
Native PC ports built from reverse-engineered source code — toggle on in **Settings > Experimental > Decomp Projects**:
- **Ship of Harkinian** (OoT), **2 Ship 2 Harkinian** (MM), **SoH Demo** — Zelda
- **Portal 64**, **Banana Mania PC** — additional ports
- Auto-download from GitHub releases, built-in ROM management
- Supported titles include N64, NDS, GBA, DS, Wii, GameCube, and more
### Settings > Experimental
Individual toggles for each beta feature — enable only what you want:
- **Frontend Support** — ES-DE, NeoStation, Pegasus, EmuBuddy
- **Beta Emulators** — additional experimental emulator support
- **Decomp Projects** — native PC ports from reverse-engineered source
- **Remote Presets** — fetch recommended config presets from a remote URL
- **Cloud Sync** — sync saves between devices via Syncthing
### Settings > Appearance
Visual theme picker with live color swatches:
- Light, Dark, System (auto)
- **Midnight** — deep navy
- **Ember** — warm dark
- **Lavender** — soft purple
- **Jade** — cool teal
### EmulatorsPage Tabs
Organized emulator list with filter tabs:
- **All** — every emulator, frontend, and decomp
- **Emulators** — core emulators only (Dolphin, PCSX2, RetroArch, etc.)
- **Frontends** — ES-DE, NeoStation, Pegasus, EmuBuddy
- **Decomps** — native PC ports (visible when Decomp Projects is on)
### SteamGridDB Cover Art
Search and set custom cover art from SteamGridDB's free API.
- Search covers from the game detail modal
- Thumbnails downloaded locally for instant display
- Rate-limit aware with automatic retry
- "Reset Cover" button to clear custom art
### Horizontal Navigation
Redesigned top-bar navigation with full gamepad/controller support:
- Spatial D-pad navigation (up/down/left/right between elements)
- A to select, B to close modals
- LB/RB to switch tabs
- Auto-focus on page change
---
## Bug Fixes
- **Emulator launch command injection** — replaced `exec()` with `spawn()` for all emulator and game launches, preventing shell argument injection
- **RetroAchievements password** — encrypted on disk using Electron safeStorage
- **Single-instance lock** — bypass fix prevents second instance from ignoring the lock
- **Sandbox flags** — `--no-sandbox` and `--disable-gpu-sandbox` now only applied on Steam Deck
- **Controller detection** — fixed Xbox L2/R2 axis mapping (axes +4/+5), fixed button/axis comparison including both arrays
- **RetroArch presets** — removed hardcoded `audio_driver = "pulseaudio"` from both presets
- **Save file extension** — fixed `.SVS``.svs` case sensitivity in save detection
- **URL scraper** — `urlExists()` now follows 301/302 redirects (up to 5 deep)
- **Game detail modal** — fixed prop mutation on `coverUrl` (now uses local state)
- **UtilitiesPage** — fixed `window.omni.emu``window.omni.emulators`
- **Error boundaries** — all 8 pages wrapped with ErrorBoundary for graceful crash recovery
- **RetroArch save detection** — now finds saves in core subdirectories and supports portable installs
- **Syncthing install** — fixed macOS extraction from nested zip directory, updated to v2.1.2
- **Syncthing blue screen** — fixed IPC handler crashes, pipe buffer deadlock, null device ID crash
- **Cloud Sync poll leak** — start polling now cleans up on unmount
- **Cemu macOS** — removed x64-only filter so it installs via Rosetta on ARM Macs
- **Vita3K macOS** — added ARM64 DMG variant alongside x64
- **RetroArch cores 404** — removed broken download (use RetroArch's Online Updater instead)
- **BIOS folder scan** — `scanRoms()` now skips BIOS directories
- **CSS accent colors** — replaced hardcoded colors with `color-mix()` using theme variables
- **Duplicate CSS** — renamed `.platform-tag` conflict to `.platform-tag-accent`
---
## System Requirements
- **OS:** Windows 10+, macOS 11+, Ubuntu 20.04+
- **Disk:** ~200 MB for app + emulators vary
- **RAM:** 4 GB minimum
---
## Supported Emulators (20 total)
RetroArch, Dolphin, RPCS3, Cemu, xemu, Vita3K, Azahar, Eden, PPSSPP, DuckStation, PCSX2, Flycast, Yuzu (legacy), mGBA, MelonDS, Mednafen, ES-DE, NeoStation, Pegasus, EmuBuddy, and more.
---
## 🙏 Thank You!
Thanks for using OmniEmu! Every star on GitHub, every bug report, and every feature request helps us make this app better for everyone. If you run into any issues, please open an issue on [GitHub](https://github.com/mileswolfallen2/OmniEmu2.0) — we're always happy to help.
Happy gaming! 🎮✨
+2 -2
View File
@@ -5,7 +5,7 @@
OmniEmu is a cross-platform desktop application for managing emulators, launching games, and organizing ROM libraries. Built with Electron, React, and TypeScript.
**Repository:** [github.com/mileswolfallen2/OmniEmu2.0](https://github.com/mileswolfallen2/OmniEmu2.0)
**Version:** 0.3.1
**Version:** 0.3.2
**License:** MIT (Copyright 2026 mileswa1q22)
---
@@ -33,7 +33,7 @@ OmniEmu is a cross-platform desktop application for managing emulators, launchin
| Technology | Version | Purpose | License |
|---|---|---|---|
| Electron | 43.1.0 | Desktop app shell | MIT |
| Electron | 43.2.0 | Desktop app shell | MIT |
| React | 19.2.7 | UI framework | MIT |
| React DOM | 19.2.7 | DOM rendering | MIT |
| TypeScript | 5.9.3 | Type-safe language | Apache-2.0 |
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "omniemu2",
"version": "0.3.1",
"version": "0.3.2",
"description": "Cross-platform emulator manager, game launcher and ROM manager",
"main": "dist/main/index.js",
"scripts": {
@@ -31,7 +31,7 @@
"@vitejs/plugin-react": "^4.4.0",
"7zip-bin": "^5.2.0",
"concurrently": "^9.1.0",
"electron": "^43.1.0",
"electron": "^43.2.0",
"electron-builder": "^25.1.0",
"typescript": "^5.7.0",
"vite": "^6.0.0"
@@ -98,7 +98,7 @@
}
},
"allowScripts": {
"electron@43.1.0": true,
"electron@43.2.0": true,
"[email protected]": true,
"[email protected]": true
}
+7 -4
View File
@@ -306,7 +306,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.3.1' },
headers: { 'User-Agent': 'OmniEmu/0.3.2' },
timeout: 10000,
}, (res) => {
if (res.statusCode !== 200) { reject(new Error(`HTTP ${res.statusCode}`)); return; }
@@ -351,9 +351,12 @@ export function getBuiltInPresets(): Record<string, ConfigPreset[]> {
}
export async function getPresets(emulatorId: string): Promise<ConfigPreset[]> {
// Priority: remote > local file > built-in
const remote = await fetchRemotePresets();
if (remote && remote[emulatorId]) return remote[emulatorId];
// Priority: remote > local file > built-in (remote only if enabled)
const s = settings.get();
if (s.remotePresets) {
const remote = await fetchRemotePresets();
if (remote && remote[emulatorId]) return remote[emulatorId];
}
const local = loadLocalPresets();
if (local && local[emulatorId]) return local[emulatorId];
return builtInPresets[emulatorId] || [];
+1 -1
View File
@@ -427,7 +427,7 @@ export function checkDecomp(id: string): DecompState {
export function getAllDecompStates(): DecompState[] {
const s = settings.get();
if (!s.betaFeatures) return [];
if (!s.decompProjects) return [];
return knownDecomps.map(d => checkDecomp(d.id));
}
+1 -5
View File
@@ -1174,11 +1174,7 @@ export function checkEmulator(id: string): EmulatorState {
}
export function getAllEmulatorStates(): EmulatorState[] {
const s = settings.get();
const beta = !!s.betaFeatures;
return knownEmulators
.filter(e => (e.id === 'esde' || e.id === 'neostation' || e.id === 'pegasus') ? beta : true)
.map((e) => checkEmulator(e.id));
return knownEmulators.map((e) => checkEmulator(e.id));
}
export function launchEmulator(emulatorId: string): boolean {
+1 -1
View File
@@ -67,7 +67,7 @@ function writeRetroarchCfg(cfgPath: string, lines: string[]): void {
function httpsGetBuffer(url: string): Promise<Buffer> {
return new Promise((resolve, reject) => {
const req = httpsGet(url, {
headers: { 'User-Agent': 'OmniEmu/0.3.1' },
headers: { 'User-Agent': 'OmniEmu/0.3.2' },
timeout: 15000,
}, (res) => {
if (res.statusCode === 301 || res.statusCode === 302) {
+1 -9
View File
@@ -210,19 +210,11 @@ if (!gotLock) {
// Auto-generate Pegasus collection files if Pegasus is configured
try {
const s = settings.get();
if (s.betaFeatures && s.romsDirectory) {
if (s.frontendSupport && s.romsDirectory) {
generatePegasusCollectionsForRomDir(s.romsDirectory);
}
} catch { /* ignore */ }
// Auto-start Syncthing if cloud sync was previously enabled
try {
const s = settings.get();
if (s.cloudSyncEnabled) {
startSyncthing().catch(() => { /* ignore */ });
}
} catch { /* ignore */ }
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) createWindow();
else mainWindow?.show();
+1 -1
View File
@@ -27,7 +27,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.3.1' },
headers: { 'User-Agent': 'OmniEmu/0.3.2' },
timeout: 30000,
};
protocol(currentUrl, opts, (response) => {
-4
View File
@@ -61,11 +61,7 @@ export function registerIpcHandlers(): void {
// Emulators
ipcMain.handle('emulators:list', () => {
const plat = getPlatform();
const s = settings.get();
const beta = !!s.betaFeatures;
return knownEmulators.filter(e => {
// Gate beta emulators behind beta toggle
if ((e.id === 'esde' || e.id === 'neostation' || e.id === 'pegasus') && !beta) return false;
const hasDownload = e.downloads?.[plat] && e.downloads[plat]!.length > 0;
const hasPackage = !!e.packageNames?.[plat];
return hasDownload || hasPackage;
+1 -1
View File
@@ -19,7 +19,7 @@ const raConsoleIds: Record<string, number> = {
/** Platforms where RetroAchievements is actually supported and has a decent game count */
export const raSupportedPlatforms = new Set(Object.keys(raConsoleIds));
const userAgent = 'OmniEmu/0.3.1';
const userAgent = 'OmniEmu/0.3.2';
function fetchText(url: string): Promise<string> {
return new Promise((resolve, reject) => {
+5 -5
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.3.1' }, timeout: 10000 }, (res) => {
const req = httpsGet(url, { headers: { 'User-Agent': 'OmniEmu/0.3.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, maxRedirects = 5): Promise<boolean> {
return new Promise((resolve) => {
const req = httpsGet(url, {
method: 'GET',
headers: { 'User-Agent': 'OmniEmu/0.3.1' },
headers: { 'User-Agent': 'OmniEmu/0.3.2' },
timeout: 10000,
}, (res) => {
if ((res.statusCode === 301 || res.statusCode === 302) && res.headers.location && maxRedirects > 0) {
@@ -262,7 +262,7 @@ export function cacheMetadata(romPath: string, metadata: GameMetadata): void {
writeMetadataCache(cache);
}
const mobygamesUA = 'Mozilla/5.0 (compatible; OmniEmu/0.3.1; +https://github.com/mileswolfallen2/OmniEmu2.0)';
const mobygamesUA = 'Mozilla/5.0 (compatible; OmniEmu/0.3.2; +https://github.com/mileswolfallen2/OmniEmu2.0)';
function mobyFetch(url: string): Promise<string> {
return new Promise((resolve, reject) => {
@@ -439,7 +439,7 @@ function sgdbFetch(url: string, apiKey: string, retries = 2): Promise<string> {
const req = httpsGet(url, {
headers: {
'Authorization': `Bearer ${apiKey}`,
'User-Agent': 'OmniEmu/0.3.1',
'User-Agent': 'OmniEmu/0.3.2',
},
timeout: 10000,
}, (res) => {
@@ -469,7 +469,7 @@ function sgdbDownloadBuffer(url: string, apiKey: string, retries = 2): Promise<B
return new Promise((resolve, reject) => {
const req = httpsGet(url, {
headers: {
'User-Agent': 'OmniEmu/0.3.1',
'User-Agent': 'OmniEmu/0.3.2',
},
timeout: 15000,
}, (res) => {
+1
View File
@@ -33,6 +33,7 @@ const defaultSettings: AppSettings = {
retroAchievementsApiKey: '',
steamGridDbApiKey: '',
syncthingPort: 8384,
cloudSyncEnabled: true,
};
let cached: AppSettings | null = null;
+1 -1
View File
@@ -34,7 +34,7 @@ export function Sidebar({ currentPage, onNavigate }: TopBarProps) {
</button>
))}
</nav>
<div className="topbar-version">v0.3.1</div>
<div className="topbar-version">v0.3.2</div>
</div>
);
}
+59 -5
View File
@@ -7,14 +7,20 @@ interface DecompProgressMap {
const isMacOS = navigator.userAgent.includes('Mac');
const FRONTEND_IDS = new Set(['esde', 'neostation', 'pegasus']);
const EMUBUDDY_ID = 'emubuddy';
type TabId = 'all' | 'emulators' | 'frontends' | 'decomps';
export function EmulatorsPage() {
const [states, setStates] = useState<EmulatorState[]>([]);
const [decompStates, setDecompStates] = useState<DecompState[]>([]);
const [betaFeatures, setBetaFeatures] = useState(false);
const [decompProjects, setDecompProjects] = useState(false);
const [loading, setLoading] = useState(true);
const [progress, setProgress] = useState<Record<string, InstallProgress>>({});
const [decompProgress, setDecompProgress] = useState<DecompProgressMap>({});
const [actioning, setActioning] = useState<string | null>(null);
const [activeTab, setActiveTab] = useState<TabId>('all');
const cleanups = useRef<(() => void)[]>([]);
const load = useCallback(async () => {
@@ -27,6 +33,7 @@ export function EmulatorsPage() {
setStates(emuResult);
setDecompStates(decompResult);
setBetaFeatures(!!settings.betaFeatures);
setDecompProjects(!!settings.decompProjects);
setLoading(false);
}, []);
@@ -167,13 +174,26 @@ export function EmulatorsPage() {
const currentProgress = (id: string) => progress[id];
const emulators = states.filter((s) => s.config.id !== 'emubuddy' && !FRONTEND_IDS.has(s.config.id));
const frontends = states.filter((s) => FRONTEND_IDS.has(s.config.id) || s.config.id === 'emubuddy');
const installedCount = states.filter((s) => s.installed).length;
const configuredCount = states.filter((s) => s.configured).length;
const filteredStates = activeTab === 'emulators'
? emulators
: activeTab === 'frontends'
? frontends
: states;
const showDecomps = decompProjects && (activeTab === 'all' || activeTab === 'decomps');
return (
<div>
<div className="info-bar">
<span>
{states.filter((s) => s.installed).length} installed
{installedCount} installed
{' · '}
{states.filter((s) => s.configured).length} configured
{configuredCount} configured
{Object.keys(progress).length > 0 && ' · Installing...'}
</span>
<button className="btn btn-secondary btn-sm" onClick={handleRefresh}>
@@ -181,9 +201,43 @@ export function EmulatorsPage() {
</button>
</div>
{/* ── Tabs ─────────────────────────────────────────── */}
<div className="page-tabs">
<button
className={`page-tab ${activeTab === 'all' ? 'active' : ''}`}
onClick={() => setActiveTab('all')}
>
All
<span className="page-tab-count">{states.length + (showDecomps ? decompStates.length : 0)}</span>
</button>
<button
className={`page-tab ${activeTab === 'emulators' ? 'active' : ''}`}
onClick={() => setActiveTab('emulators')}
>
Emulators
<span className="page-tab-count">{emulators.length}</span>
</button>
<button
className={`page-tab ${activeTab === 'frontends' ? 'active' : ''}`}
onClick={() => setActiveTab('frontends')}
>
Frontends
<span className="page-tab-count">{frontends.length}</span>
</button>
{decompProjects && (
<button
className={`page-tab ${activeTab === 'decomps' ? 'active' : ''}`}
onClick={() => setActiveTab('decomps')}
>
Decomps
<span className="page-tab-count">{decompStates.length}</span>
</button>
)}
</div>
{/* ── Emulators Grid ───────────────────────────────── */}
<div className="card-grid">
{states.map((state) => {
{filteredStates.map((state) => {
const prog = currentProgress(state.config.id);
const isActioning = actioning === state.config.id;
@@ -351,8 +405,8 @@ export function EmulatorsPage() {
})}
</div>
{/* ── Decompilations Section (Beta) ───────────────── */}
{betaFeatures && (<>
{/* ── Decompilations Section ───────────────────────── */}
{showDecomps && (<>
<div style={{ marginTop: 40 }}>
<div className="info-bar" style={{ marginBottom: 16 }}>
<h2 style={{ fontSize: 20, fontWeight: 600 }}>Decompilations <span className="badge-beta">Beta</span></h2>
+3 -3
View File
@@ -13,7 +13,7 @@ const platformIcons: Record<string, string> = {
export function LibraryPage() {
const [games, setGames] = useState<GameEntry[]>([]);
const [decompGames, setDecompGames] = useState<DecompState[]>([]);
const [betaFeatures, setBetaFeatures] = useState(false);
const [decompProjects, setDecompProjects] = useState(false);
const [loading, setLoading] = useState(false);
const [romsDir, setRomsDir] = useState<string>('');
const [selectedGame, setSelectedGame] = useState<GameEntry | null>(null);
@@ -21,7 +21,7 @@ export function LibraryPage() {
useEffect(() => {
(async () => {
const settings = await window.omni.settings.get();
setBetaFeatures(!!settings.betaFeatures);
setDecompProjects(!!settings.decompProjects);
if (settings.romsDirectory) {
setRomsDir(settings.romsDirectory);
setLoading(true);
@@ -170,7 +170,7 @@ export function LibraryPage() {
)}
{/* ── Installed Decomps as Games (Beta) ──────────────── */}
{betaFeatures && decompGames.length > 0 && (
{decompProjects && decompGames.length > 0 && (
<div style={{ marginTop: 32 }}>
<div className="info-bar">
<span style={{ fontWeight: 600 }}>Decompilations <span className="badge-beta">Beta</span></span>
+173 -38
View File
@@ -33,6 +33,8 @@ export function SettingsPage() {
const [downloading, setDownloading] = useState(false);
const [downloadProgress, setDownloadProgress] = useState(0);
const [showReleaseNotes, setShowReleaseNotes] = useState(false);
const [showAppearance, setShowAppearance] = useState(false);
const [showExperimental, setShowExperimental] = useState(false);
useEffect(() => {
const unsubStatus = window.omni.updates.onStatus((s) => {
@@ -201,51 +203,25 @@ export function SettingsPage() {
)}
</div>
<div className="settings-section">
<h3>Appearance</h3>
<div className="setting-row">
<div className="settings-section clickable-section" onClick={() => setShowAppearance(true)}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div>
<div className="setting-label">Theme</div>
<div className="setting-desc">Application color scheme</div>
<h3>Appearance</h3>
<div className="setting-desc" style={{ marginTop: 4 }}>Theme: {settings.theme.charAt(0).toUpperCase() + settings.theme.slice(1)}</div>
</div>
<select
value={settings.theme}
onChange={(e) => {
const t = e.target.value as AppSettings['theme'];
applyTheme(t);
update({ theme: t });
}}
>
<option value="dark">Dark</option>
<option value="light">Light</option>
<option value="system">System</option>
<option value="midnight">Midnight</option>
<option value="ember">Ember</option>
<option value="lavender">Lavender</option>
<option value="jade">Jade</option>
</select>
<span style={{ color: 'var(--text-muted)', fontSize: 18 }}>&rsaquo;</span>
</div>
</div>
<div className="settings-section">
<h3>Experimental</h3>
<div className="setting-row">
<div className="settings-section clickable-section" onClick={() => setShowExperimental(true)}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div>
<div className="setting-label">Enable Beta Features</div>
<div className="setting-desc">
Unlocks experimental emulators, frontends, and decompilations (ES-DE, NeoStation, SoH, etc.)
<h3>Experimental</h3>
<div className="setting-desc" style={{ marginTop: 4 }}>
Beta emulators, decomp projects, and frontends
</div>
</div>
<label className="toggle">
<input
type="checkbox"
checked={!!settings.betaFeatures}
onChange={(e) => update({ betaFeatures: e.target.checked })}
/>
<span className="toggle-slider" />
</label>
<span style={{ color: 'var(--text-muted)', fontSize: 18 }}>&rsaquo;</span>
</div>
</div>
@@ -415,7 +391,7 @@ export function SettingsPage() {
<div className="settings-section">
<h3>About</h3>
<p className="text-sm text-muted">
OmniEmu v{updateInfo?.version || '0.3.1'} · Cross-platform emulator manager
OmniEmu v{updateInfo?.version || '0.3.2'} · Cross-platform emulator manager
<br />
Built with Electron + React + TypeScript
</p>
@@ -438,6 +414,165 @@ export function SettingsPage() {
Wipe All Data
</button>
</div>
{/* ── Appearance Panel ─────────────────────────────── */}
{showAppearance && (
<div className="modal-overlay" onClick={() => setShowAppearance(false)}>
<div className="settings-panel" onClick={(e) => e.stopPropagation()}>
<div className="settings-panel-header">
<h2>Appearance</h2>
<button className="btn btn-secondary btn-sm" onClick={() => setShowAppearance(false)}>Close</button>
</div>
<p className="text-sm text-muted" style={{ marginBottom: 16 }}>
Select your preferred theme. Changes apply instantly.
</p>
<div className="theme-grid">
{([
{ id: 'dark', name: 'Dark', accent: '#00d4ff', bg: '#0a0a18', secondary: '#ff0080', text: '#e8f4ff' },
{ id: 'light', name: 'Light', accent: '#0099e5', bg: '#f0f4f8', secondary: '#e5007e', text: '#1a1a2e' },
{ id: 'midnight', name: 'Midnight', accent: '#06b6d4', bg: '#050814', secondary: '#06b6d4', text: '#e0f0ff' },
{ id: 'ember', name: 'Ember', accent: '#ff6b35', bg: '#120a08', secondary: '#ff356b', text: '#fff4e8' },
{ id: 'lavender', name: 'Lavender', accent: '#b868ff', bg: '#0c0818', secondary: '#ff68b8', text: '#f4e8ff' },
{ id: 'jade', name: 'Jade', accent: '#20d0a0', bg: '#08100c', secondary: '#d0a020', text: '#e8f8f0' },
]).map((t) => (
<button
key={t.id}
className={`theme-swatch ${settings.theme === t.id ? 'active' : ''}`}
onClick={() => {
applyTheme(t.id as AppSettings['theme']);
update({ theme: t.id as AppSettings['theme'] });
}}
>
<div
className="theme-swatch-preview"
style={{ background: t.bg, border: settings.theme === t.id ? `2px solid ${t.accent}` : '2px solid rgba(255,255,255,0.08)' }}
>
<div className="theme-swatch-preview-bar" style={{ background: t.secondary }}>
<span style={{ color: t.text, fontSize: 10, fontWeight: 700 }}>OmniEmu</span>
</div>
<div style={{ padding: '6px 8px' }}>
<div style={{ height: 6, width: '70%', background: t.accent, borderRadius: 3, marginBottom: 4 }} />
<div style={{ height: 4, width: '50%', background: `${t.text}40`, borderRadius: 2 }} />
</div>
<div className="theme-swatch-preview-dots">
<span style={{ background: t.accent }} />
<span style={{ background: t.secondary }} />
<span style={{ background: `${t.text}60` }} />
</div>
</div>
<span className="theme-swatch-name" style={{ color: settings.theme === t.id ? t.accent : 'var(--text-secondary)' }}>
{t.name}
</span>
{settings.theme === t.id && <span className="theme-swatch-check" style={{ color: t.accent }}>&#10003;</span>}
</button>
))}
</div>
</div>
</div>
)}
{/* ── Experimental Panel ───────────────────────────── */}
{showExperimental && (
<div className="modal-overlay" onClick={() => setShowExperimental(false)}>
<div className="settings-panel" onClick={(e) => e.stopPropagation()}>
<div className="settings-panel-header">
<h2>Experimental</h2>
<button className="btn btn-secondary btn-sm" onClick={() => setShowExperimental(false)}>Close</button>
</div>
<p className="text-sm text-muted" style={{ marginBottom: 16 }}>
Toggle individual beta features on or off. These features are under active development and may be unstable.
</p>
<div className="experimental-features">
<div className="experimental-feature">
<div className="experimental-feature-info">
<div className="experimental-feature-label">Frontend Support</div>
<div className="experimental-feature-desc">
Launch alternative frontends directly from OmniEmu for a more console-like experience.
</div>
</div>
<label className="toggle">
<input
type="checkbox"
checked={!!settings.frontendSupport}
onChange={(e) => update({ frontendSupport: e.target.checked })}
/>
<span className="toggle-slider" />
</label>
</div>
<div className="experimental-feature">
<div className="experimental-feature-info">
<div className="experimental-feature-label">Beta Emulators</div>
<div className="experimental-feature-desc">Unlocks additional experimental emulators beyond the stable set</div>
</div>
<label className="toggle">
<input
type="checkbox"
checked={!!settings.betaFeatures}
onChange={(e) => update({ betaFeatures: e.target.checked })}
/>
<span className="toggle-slider" />
</label>
</div>
<div className="experimental-feature">
<div className="experimental-feature-info">
<div className="experimental-feature-label">Decomp Projects</div>
<div className="experimental-feature-desc">Native PC ports from reverse-engineered source (Ship of Harkinian, SM64, and more)</div>
</div>
<label className="toggle">
<input
type="checkbox"
checked={!!settings.decompProjects}
onChange={(e) => update({ decompProjects: e.target.checked })}
/>
<span className="toggle-slider" />
</label>
</div>
<div className="experimental-feature">
<div className="experimental-feature-info">
<div className="experimental-feature-label">Remote Presets</div>
<div className="experimental-feature-desc">Fetch recommended config presets from a remote URL</div>
</div>
<label className="toggle">
<input
type="checkbox"
checked={!!settings.remotePresets}
onChange={(e) => update({ remotePresets: e.target.checked })}
/>
<span className="toggle-slider" />
</label>
</div>
<div className="experimental-feature">
<div className="experimental-feature-info">
<div className="experimental-feature-label">Cloud Sync</div>
<div className="experimental-feature-desc">Sync save files between devices via Syncthing</div>
</div>
<label className="toggle">
<input
type="checkbox"
checked={!!settings.cloudSyncEnabled}
onChange={async (e) => {
const on = e.target.checked;
if (on) {
try { await window.omni.cloud.start(); } catch { /* retry on next launch */ }
} else {
try { await window.omni.cloud.stop(); } catch { /* best effort */ }
}
update({ cloudSyncEnabled: on });
}}
/>
<span className="toggle-slider" />
</label>
</div>
</div>
</div>
</div>
)}
</div>
);
}
+222
View File
@@ -1402,3 +1402,225 @@ select:focus, input[type="text"]:focus {
justify-content: flex-end;
background: var(--bg-secondary);
}
/* ── Theme Swatch Grid ───────────────────────────── */
.theme-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(130px, 1fr));
gap: 12px;
margin-top: 12px;
}
.theme-swatch {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 14px 10px;
border-radius: var(--radius-sm, 6px);
cursor: pointer;
transition: all 200ms ease;
background: none;
font-family: inherit;
}
.theme-swatch:hover {
transform: translateY(-2px);
}
.theme-swatch.active {
transform: translateY(-2px);
}
.theme-swatch-name {
font-size: 12px;
font-weight: 600;
letter-spacing: 0.3px;
}
/* ── Page Tabs ───────────────────────────────────── */
.page-tabs {
display: flex;
gap: 2px;
margin-bottom: 20px;
background: var(--bg-secondary);
border-radius: var(--radius-sm, 6px);
padding: 3px;
}
.page-tab {
flex: 1;
padding: 8px 16px;
border: none;
border-radius: 4px;
background: transparent;
color: var(--text-muted);
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: all 150ms ease;
font-family: inherit;
white-space: nowrap;
}
.page-tab:hover {
color: var(--text-secondary);
background: var(--bg-hover);
}
.page-tab.active {
color: var(--text-primary);
background: var(--bg-tertiary);
font-weight: 600;
}
.page-tab-count {
display: inline-block;
min-width: 18px;
height: 18px;
line-height: 18px;
text-align: center;
border-radius: 9px;
background: var(--accent-dim);
color: var(--accent);
font-size: 10px;
font-weight: 700;
margin-left: 6px;
padding: 0 4px;
}
/* ── Clickable Settings Sections ─────────────────── */
.clickable-section {
cursor: pointer;
transition: background 150ms ease, border-color 150ms ease;
}
.clickable-section:hover {
background: var(--bg-hover);
border-color: var(--border-hover);
}
/* ── Settings Panel (modal) ──────────────────────── */
.settings-panel {
width: 90vw;
max-width: 560px;
max-height: 85vh;
overflow-y: auto;
background: var(--bg-primary);
border: 1px solid var(--border);
border-radius: var(--radius, 12px);
padding: 28px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.6);
}
.settings-panel-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.settings-panel-header h2 {
font-size: 20px;
font-weight: 700;
}
/* ── Theme Swatch (panel version) ────────────────── */
.theme-swatch-preview {
width: 100%;
aspect-ratio: 4 / 3;
border-radius: 8px;
overflow: hidden;
display: flex;
flex-direction: column;
}
.theme-swatch-preview-bar {
padding: 6px 8px;
display: flex;
align-items: center;
}
.theme-swatch-preview-dots {
display: flex;
gap: 3px;
padding: 4px 8px 6px;
}
.theme-swatch-preview-dots span {
width: 10px;
height: 10px;
border-radius: 50%;
display: block;
}
.theme-swatch-check {
font-size: 12px;
font-weight: 700;
}
/* ── Experimental Features Panel ─────────────────── */
.experimental-features {
display: flex;
flex-direction: column;
gap: 2px;
}
.experimental-feature {
display: flex;
justify-content: space-between;
align-items: center;
padding: 14px 16px;
border-radius: var(--radius-sm, 6px);
background: var(--bg-secondary);
gap: 16px;
}
.experimental-feature:hover {
background: var(--bg-tertiary);
}
.experimental-feature-info {
flex: 1;
min-width: 0;
}
.experimental-feature-label {
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 2px;
}
.experimental-feature-desc {
font-size: 12px;
color: var(--text-muted);
line-height: 1.4;
}
.experimental-feature-detail {
margin-top: 6px;
padding-left: 10px;
border-left: 2px solid var(--border);
font-size: 11px;
line-height: 1.5;
color: var(--text-muted);
}
.experimental-feature-detail strong {
color: var(--text-primary);
font-weight: 600;
}
.experimental-feature-hint {
margin-top: 8px;
font-size: 11px;
color: var(--accent);
font-style: italic;
}
+6
View File
@@ -119,6 +119,12 @@ export interface AppSettings {
retroAchievementsApiKey?: string;
/** Enable experimental/beta features (ES-DE, NeoStation, etc.) */
betaFeatures?: boolean;
/** Enable Decomp Projects section (Ship of Harkinian, SM64, etc.) */
decompProjects?: boolean;
/** Enable frontend support (ES-DE, NeoStation, Pegasus, EmuBuddy) */
frontendSupport?: boolean;
/** Enable remote preset fetching */
remotePresets?: boolean;
/** SteamGridDB API key (free account at steamgriddb.com) */
steamGridDbApiKey?: string;
/** Custom save directories per emulator: emulatorId -> save directory path */