still working

This commit is contained in:
2026-07-11 00:05:56 -05:00
parent bf90aa0e92
commit c5ad661921
21 changed files with 1507 additions and 146 deletions
+1
View File
@@ -44,6 +44,7 @@
"build": {
"appId": "com.omniemu.app",
"productName": "OmniEmu",
"afterPack": "./scripts/adhoc-sign.cjs",
"publish": ["github"],
"directories": {
"output": "release",
+15
View File
@@ -0,0 +1,15 @@
const { execSync } = require('child_process');
exports.default = async function (context) {
if (context.electronPlatformName !== 'darwin') return;
const appPath = `${context.appOutDir}/${context.packager.appInfo.productFilename}.app`;
console.log(`Ad-hoc signing macOS app at: ${appPath}`);
try {
execSync(`codesign --force --deep -s - "${appPath}"`, { stdio: 'inherit' });
console.log('Ad-hoc signing complete');
} catch {
console.warn('Ad-hoc signing failed (may already be signed or codesign unavailable)');
}
};
+138 -47
View File
@@ -235,6 +235,23 @@ frameskip = 0
enable = no
[input]
enable_mouse = no
`,
},
},
],
melonds: [
{
name: 'OmniEmu Recommended',
description: 'Optimized melonDS settings',
files: {
'melonDS.ini': `[General]
fullscreen = 1
[Video]
renderer = OpenGL
vsync = 1
[Audio]
volume = 100
[Controls]
`,
},
},
@@ -345,6 +362,11 @@ function getConfigDir(emulatorId: string, installPath: string): string {
darwin: join(require('os').homedir(), 'Library', 'Application Support', 'flycast'),
linux: join(require('os').homedir(), '.config', 'flycast'),
},
melonds: {
win32: join(process.env.APPDATA || '', 'melonDS'),
darwin: join(require('os').homedir(), 'Library', 'Application Support', 'melonDS'),
linux: join(require('os').homedir(), '.config', 'melonDS'),
},
};
return platformDirs[emulatorId]?.[platform] || dirname(installPath);
}
@@ -548,56 +570,112 @@ export function applyControllerConfig(emulatorId: string, installPath: string, c
writeFileSync(cfgPath, lines.join('\n'), 'utf-8');
return true;
}
case 'melonds': {
const iniPath = join(configDir, 'melonDS.ini');
const existing = existsSync(iniPath) ? readFileSync(iniPath, 'utf-8') : '';
const lines = existing.split('\n').filter(l =>
!l.startsWith('fullscreen') && !l.startsWith('JoystickID')
);
lines.push('fullscreen = 1');
lines.push('JoystickID = 0');
writeFileSync(iniPath, lines.join('\n'), 'utf-8');
return true;
}
}
return false;
}
async function getRetroAchievementsToken(username: string, password: string): Promise<string | null> {
try {
const https = await import('https');
const { URLSearchParams } = await import('url');
const params = new URLSearchParams();
params.append('u', username);
params.append('p', password);
params.append('r', 'login');
const data = await new Promise<string>((resolve, reject) => {
const body = params.toString();
const req = https.request('https://retroachievements.org/dorequest.php', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'Content-Length': Buffer.byteLength(body),
},
timeout: 15000,
}, (res: import('http').IncomingMessage) => {
const chunks: Buffer[] = [];
res.on('data', (c: Buffer) => chunks.push(c));
res.on('end', () => resolve(Buffer.concat(chunks).toString()));
});
req.on('error', reject);
req.write(body);
req.end();
});
const parsed = JSON.parse(data);
if (parsed.Success && parsed.Token) return parsed.Token;
return null;
} catch {
return null;
}
}
const raEmulatorConfigs: Record<string, {
file: string;
enabled: string;
username: string;
password: string;
token: string;
section?: string;
extra?: 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',
token: 'cheevos_token = "%s"',
},
pcsx2: {
file: 'inis/PCSX2.ini',
section: 'EmuCore',
enabled: 'AchievementsEnabled = 1',
username: 'AchievementsUsername = %s',
password: 'AchievementsPassword = %s',
section: 'Achievements',
enabled: 'Enabled = True',
username: 'Username = %s',
token: 'Token = %s',
extra: ['LoginTimestamp = %d'],
},
duckstation: {
file: 'settings.ini',
section: 'Cheevos',
enabled: 'Enabled = True',
username: 'Username = %s',
password: 'Password = %s',
token: 'Token = %s',
extra: ['LoginTimestamp = %d'],
},
flycast: {
file: 'emu.cfg',
section: 'achievements',
enabled: 'enable = yes',
username: 'username = %s',
password: 'password = %s',
token: 'password = %s',
},
melonds: {
file: 'melonDS.ini',
section: 'Achievements',
enabled: 'Enabled = 1',
username: 'Username = %s',
token: 'Password = %s',
},
};
export function applyRetroAchievements(username: string, password: string): Record<string, boolean> {
export async function applyRetroAchievements(username: string, password: string): Promise<Record<string, boolean>> {
const results: Record<string, boolean> = {};
// Try to get a connect token from the RA API
const token = await getRetroAchievementsToken(username, password);
// Fall back to using the password directly if API fails
const effectiveToken = token || password;
for (const [emuId, cfg] of Object.entries(raEmulatorConfigs)) {
try {
const configDir = getConfigDir(emuId, '');
@@ -613,38 +691,26 @@ export function applyRetroAchievements(username: string, password: string): Reco
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);
}
const sectionStart = findOrCreateSection(lines, cfg.section);
// Remove any existing matching keys in this section
stripKeysInSection(lines, cfg.section, cfg.enabled.split('=')[0].trim());
stripKeysInSection(lines, cfg.section, cfg.username.split('=')[0].trim());
stripKeysInSection(lines, cfg.section, cfg.token.split('=')[0].trim());
if (cfg.extra) {
for (const extra of cfg.extra) {
stripKeysInSection(lines, cfg.section, extra.split('=')[0].trim());
}
}
// Insert new values right after section header
const insertIdx = lines.findIndex(l => l.trim() === `[${cfg.section}]`) + 1;
const insertLines = [cfg.enabled, cfg.username.replace('%s', username), cfg.token.replace('%s', effectiveToken)];
if (cfg.extra) {
const now = Math.floor(Date.now() / 1000);
for (const extra of cfg.extra) {
insertLines.push(extra.replace('%d', String(now)));
}
}
lines.splice(insertIdx, 0, ...insertLines.map(l => l));
} else {
// RetroArch-style flat config
const setLine = (prefix: string, value: string) => {
@@ -652,9 +718,12 @@ export function applyRetroAchievements(username: string, password: string): Reco
if (idx !== -1) lines[idx] = value;
else lines.push(value);
};
// Remove cheevos_password if it exists (token takes precedence)
const passIdx = lines.findIndex(l => l.trim().startsWith('cheevos_password'));
if (passIdx !== -1) lines.splice(passIdx, 1);
setLine('cheevos_enable', cfg.enabled);
setLine('cheevos_username', cfg.username.replace('%s', username));
setLine('cheevos_password', cfg.password.replace('%s', password));
setLine('cheevos_token', cfg.token.replace('%s', effectiveToken));
}
writeFileSync(filePath, lines.join('\n'), 'utf-8');
@@ -666,3 +735,25 @@ export function applyRetroAchievements(username: string, password: string): Reco
return results;
}
function findOrCreateSection(lines: string[], section: string): number {
const idx = lines.findIndex(l => l.trim() === `[${section}]`);
if (idx !== -1) return idx;
lines.push('', `[${section}]`);
return lines.length - 1;
}
function stripKeysInSection(lines: string[], section: string, key: string): void {
let inSection = false;
for (let i = 0; i < lines.length; i++) {
const trimmed = lines[i].trim();
if (trimmed === `[${section}]`) { inSection = true; continue; }
if (inSection) {
if (trimmed.startsWith('[')) break;
if (trimmed.startsWith(key) || trimmed.startsWith(key.toLowerCase()) || trimmed.startsWith(key.charAt(0).toUpperCase() + key.slice(1))) {
lines.splice(i, 1);
i--;
}
}
}
}
+13
View File
@@ -375,6 +375,13 @@ export const knownEmulators: EmulatorConfig[] = [
executablePath: 'melonDS.exe',
},
],
darwin: [
{
url: 'https://melonds.kuribo64.net/downloads/melonDS-1.1-macOS-universal.zip',
format: 'zip',
executablePath: 'melonDS.app/Contents/MacOS/melonDS',
},
],
linux: [
{
url: 'https://github.com/melonDS-emu/melonDS/releases/download/1.1/melonDS-1.1-appimage-x86_64.zip',
@@ -519,6 +526,12 @@ function alternativePaths(emulatorId: string): string[] {
join(omniEmuDir, 'Flycast.app', 'Contents', 'MacOS', 'Flycast'),
join(home, 'Applications', 'Flycast.app', 'Contents', 'MacOS', 'Flycast'),
],
melonds: [
join(omniEmuDir, 'melonDS.exe'),
join(omniEmuDir, 'melonDS'),
join(omniEmuDir, 'melonDS.app', 'Contents', 'MacOS', 'melonDS'),
join(home, 'Applications', 'melonDS.app', 'Contents', 'MacOS', 'melonDS'),
],
};
return common[emulatorId] || [join(omniEmuDir)];
}
+16 -3
View File
@@ -20,8 +20,9 @@ import { applyRecommendedConfig, getPresets, checkConfigured, applyControllerCon
import { settings } from './settings';
import { getSystemInfo, platformName, getPlatform, getArch } from './platform';
import { checkForUpdates, downloadUpdate, quitAndInstall } from './updater';
import { InstallProgress, AppSettings, GameEntry } from '../shared/types';
import { addRecentGame, parseGameTitle, buildScrapeTitle, findValidThumbnail, cacheCovers } from './scraper';
import { InstallProgress, AppSettings, GameEntry, AchievementInfo } from '../shared/types';
import { addRecentGame, parseGameTitle, buildScrapeTitle, findValidThumbnail, cacheCovers, scrapeGameMetadata } from './scraper';
import { getGameAchievements } from './ra';
import { scanBiosDirectory, getKnownBiosList, getDefaultBiosDir, updateRetroarchBiosPath } from './bios';
export function registerIpcHandlers(): void {
@@ -186,6 +187,18 @@ export function registerIpcHandlers(): void {
return true;
});
// Scrape game metadata (description, year, genre, publisher, screenshots)
ipcMain.handle('games:scrape-metadata', async (_event, romPath: string, title: string, platform: string) => {
return scrapeGameMetadata(romPath, buildScrapeTitle(title), platform);
});
// Fetch RetroAchievements for a game
ipcMain.handle('games:achievements', async (_event, romPath: string, title: string, platform: string) => {
const s = settings.get();
if (!s.retroAchievementsApiKey || !s.retroAchievementsUsername) return null;
return getGameAchievements(romPath, title, platform, s.retroAchievementsApiKey, s.retroAchievementsUsername);
});
// Settings
ipcMain.handle('settings:get', () => settings.get());
ipcMain.handle('settings:save', (_event, s: Partial<AppSettings>) =>
@@ -236,7 +249,7 @@ export function registerIpcHandlers(): void {
async (_event, username: string, password: string) => {
const s = settings.get();
settings.save({ retroAchievementsUsername: username, retroAchievementsPassword: password });
const results = applyRetroAchievements(username, password);
const results = await applyRetroAchievements(username, password);
return results;
}
);
+6
View File
@@ -7,6 +7,8 @@ import type {
AppSettings,
InstallProgress,
ConfigPreset,
GameMetadata,
AchievementInfo,
} from '../shared/types';
const api = {
@@ -79,6 +81,10 @@ const api = {
ipcRenderer.invoke('games:scrape-art', title, platform),
cacheCovers: (entries: { romPath: string; coverUrl: string }[]): Promise<boolean> =>
ipcRenderer.invoke('games:cache-covers', entries),
scrapeMetadata: (romPath: string, title: string, platform: string): Promise<GameMetadata> =>
ipcRenderer.invoke('games:scrape-metadata', romPath, title, platform),
achievements: (romPath: string, title: string, platform: string): Promise<AchievementInfo | null> =>
ipcRenderer.invoke('games:achievements', romPath, title, platform),
},
bios: {
+218
View File
@@ -0,0 +1,218 @@
import { get as httpsGet, request as httpsRequest } from 'https';
import { createHash } from 'crypto';
import { createReadStream, readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs';
import { join } from 'path';
import { app } from 'electron';
import type { RetroAchievement, AchievementInfo } from '../shared/types';
// Platform -> RA console ID mapping
const raConsoleIds: Record<string, number> = {
nes: 7, snes: 3, n64: 2, gb: 4, gbc: 6, gba: 5,
nds: 18, gc: 16, wii: 19,
ps1: 12, ps2: 20, ps3: 41, psp: 24,
pce: 8,
'sega-md': 1, 'sega-saturn': 28, 'sega-dc': 27,
dreamcast: 27, arcade: 99,
};
const userAgent = 'OmniEmu/0.1.2';
function fetchText(url: string): Promise<string> {
return new Promise((resolve, reject) => {
const req = httpsGet(url, {
headers: { 'User-Agent': userAgent },
timeout: 15000,
}, (res) => {
let data = '';
res.on('data', (chunk: string) => data += chunk);
res.on('end', () => resolve(data));
});
req.on('error', reject);
req.on('timeout', () => { req.destroy(); reject(new Error('timeout')); });
});
}
function postForm(url: string, body: string): Promise<string> {
return new Promise((resolve, reject) => {
const req = httpsRequest(url, {
method: 'POST',
headers: {
'User-Agent': userAgent,
'Content-Type': 'application/x-www-form-urlencoded',
'Content-Length': Buffer.byteLength(body),
},
timeout: 15000,
}, (res) => {
let data = '';
res.on('data', (chunk: string) => data += chunk);
res.on('end', () => resolve(data));
});
req.on('error', reject);
req.on('timeout', () => { req.destroy(); reject(new Error('timeout')); });
req.write(body);
req.end();
});
}
function computeMD5(filePath: string): Promise<string> {
return new Promise((resolve, reject) => {
const hash = createHash('md5');
const stream = createReadStream(filePath);
stream.on('data', (d: string | Buffer) => hash.update(d));
stream.on('end', () => resolve(hash.digest('hex')));
stream.on('error', reject);
});
}
// ---- Cached game list for each console ----
interface RaGameListEntry {
id: number;
title: string;
}
interface GameListCache {
[consoleId: number]: RaGameListEntry[];
}
function gameListCachePath(): string {
const dir = join(app.getPath('userData'), 'cache');
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
return join(dir, 'ra-game-list-cache.json');
}
function readGameListCache(): GameListCache {
try {
return JSON.parse(readFileSync(gameListCachePath(), 'utf-8'));
} catch { return {}; }
}
function writeGameListCache(cache: GameListCache): void {
try { writeFileSync(gameListCachePath(), JSON.stringify(cache, null, 2), 'utf-8'); } catch { /* */ }
}
async function fetchGameList(consoleId: number, apiKey: string, username: string): Promise<RaGameListEntry[]> {
const url = `https://retroachievements.org/API/API_GetGameList.php?z=${encodeURIComponent(username)}&y=${encodeURIComponent(apiKey)}&c=${consoleId}`;
const json = await fetchText(url);
const data = JSON.parse(json);
if (!Array.isArray(data)) return [];
return data.map((g: any) => ({ id: g.ID, title: g.Title }));
}
async function getGameList(consoleId: number, apiKey: string, username: string): Promise<RaGameListEntry[]> {
const cache = readGameListCache();
if (cache[consoleId]) return cache[consoleId];
const list = await fetchGameList(consoleId, apiKey, username);
cache[consoleId] = list;
writeGameListCache(cache);
return list;
}
function normalizeTitle(title: string): string {
return title.toLowerCase()
.replace(/[^a-z0-9]/g, '')
.replace(/the|a|an|and|of|in|to|for/g, '')
.trim();
}
function findGameIdByTitle(gameList: RaGameListEntry[], title: string): number | undefined {
const search = normalizeTitle(title);
// Exact match first
const exact = gameList.find(g => normalizeTitle(g.title) === search);
if (exact) return exact.id;
// Contains match
const contains = gameList.find(g => normalizeTitle(g.title).includes(search) || search.includes(normalizeTitle(g.title)));
if (contains) return contains.id;
// Prefix match (first 8 chars)
const prefix = search.slice(0, 8);
const prefixMatch = gameList.find(g => normalizeTitle(g.title).slice(0, 8) === prefix);
return prefixMatch?.id;
}
// ---- Main API ----
/** Look up RA game ID by ROM hash (Connect API) */
async function lookupGameIdByHash(romPath: string): Promise<number | null> {
try {
const hash = await computeMD5(romPath);
const body = `r=gameid&m=${hash}`;
const response = await postForm('https://retroachievements.org/dorequest.php', body);
const parsed = JSON.parse(response);
if (parsed.Success && parsed.ID) return parsed.ID;
return null;
} catch {
return null;
}
}
/** Look up RA game ID by title + platform (Web API) */
async function lookupGameIdByTitle(
title: string, platform: string, apiKey: string, username: string
): Promise<number | null> {
const consoleId = raConsoleIds[platform];
if (!consoleId) return null;
try {
const list = await getGameList(consoleId, apiKey, username);
if (list.length === 0) return null;
const id = findGameIdByTitle(list, title);
return id ?? null;
} catch {
return null;
}
}
/** Fetch achievements + user progress from the RA Web API */
async function fetchAchievements(
gameId: number, apiKey: string, username: string
): Promise<AchievementInfo | null> {
try {
const url = `https://retroachievements.org/API/API_GetGameInfoAndUserProgress.php`
+ `?z=${encodeURIComponent(username)}&y=${encodeURIComponent(apiKey)}`
+ `&i=${gameId}&u=${encodeURIComponent(username)}`;
const json = await fetchText(url);
const data = JSON.parse(json);
if (!data || !data.Achievements) return null;
const achievements: RetroAchievement[] = Object.values(data.Achievements).map((a: any) => ({
id: a.ID,
title: a.Title,
description: a.Description,
points: a.Points,
badgeName: a.BadgeName,
dateEarned: a.DateEarned || undefined,
dateEarnedHardcore: a.DateEarnedHardcore || undefined,
}));
return {
gameId: data.ID,
gameTitle: data.Title,
consoleName: data.ConsoleName,
totalAchievements: achievements.length,
totalPoints: achievements.reduce((sum, a) => sum + a.points, 0),
userProgress: achievements.filter(a => a.dateEarned).length,
achievements,
};
} catch {
return null;
}
}
/** High-level function: get achievements for a game */
export async function getGameAchievements(
romPath: string, title: string, platform: string,
apiKey: string, username: string
): Promise<AchievementInfo | null> {
if (!apiKey || !username) return null;
// Try hash-based lookup first
let gameId = await lookupGameIdByHash(romPath);
// Fall back to title-based lookup
if (!gameId) {
gameId = await lookupGameIdByTitle(title, platform, apiKey, username);
}
if (!gameId) return null;
return fetchAchievements(gameId, apiKey, username);
}
+190 -2
View File
@@ -1,8 +1,8 @@
import { get as httpsGet } from 'https';
import { get as httpsGet, request as httpsRequest } from 'https';
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs';
import { join, dirname } from 'path';
import { app } from 'electron';
import { GameEntry } from '../shared/types';
import { GameEntry, GameMetadata } from '../shared/types';
/** Clean a filename into a display title */
export function parseGameTitle(filename: string): string {
@@ -201,3 +201,191 @@ export function addRecentGame(games: GameEntry[], game: GameEntry, max: number =
const updated = [game, ...games.filter(g => g.romPath !== game.romPath)];
return updated.slice(0, max);
}
// ---- Metadata scraping ----
interface MetadataCache {
[romPath: string]: GameMetadata;
}
function metadataCachePath(): string {
const dir = join(app.getPath('userData'), 'cache');
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
return join(dir, 'metadata-cache.json');
}
function readMetadataCache(): MetadataCache {
try {
return JSON.parse(readFileSync(metadataCachePath(), 'utf-8'));
} catch {
return {};
}
}
function writeMetadataCache(cache: MetadataCache): void {
try {
writeFileSync(metadataCachePath(), JSON.stringify(cache, null, 2), 'utf-8');
} catch { /* ignore */ }
}
export function getCachedMetadata(romPath: string): GameMetadata | undefined {
return readMetadataCache()[romPath];
}
export function cacheMetadata(romPath: string, metadata: GameMetadata): void {
const cache = readMetadataCache();
cache[romPath] = metadata;
writeMetadataCache(cache);
}
const mobygamesUA = 'Mozilla/5.0 (compatible; OmniEmu/0.1.2; +https://github.com/mileswolfallen2/OmniEmu2.0)';
function mobyFetch(url: string): Promise<string> {
return new Promise((resolve, reject) => {
const req = httpsRequest(url, {
method: 'GET',
headers: { 'User-Agent': mobygamesUA },
timeout: 15000,
}, (res) => {
let data = '';
res.on('data', (chunk: string) => data += chunk);
res.on('end', () => resolve(data));
});
req.on('error', reject);
req.on('timeout', () => { req.destroy(); reject(new Error('timeout')); });
req.end();
});
}
function buildMobySearchUrl(title: string): string {
const q = encodeURIComponent(title.trim());
return `https://www.mobygames.com/search/quick?q=${q}&search=Go`;
}
async function searchMobyGames(title: string): Promise<string | null> {
try {
const html = await mobyFetch(buildMobySearchUrl(title));
const match = html.match(/<a[^>]*href="(\/game\/[^"]+)"[^>]*>/i)
|| html.match(/href="(\/game\/[^"]+)"/i);
if (match) return `https://www.mobygames.com${match[1]}`;
return null;
} catch {
return null;
}
}
async function scrapeMobyPage(url: string): Promise<GameMetadata | null> {
try {
const html = await mobyFetch(url);
// Description
let description: string | undefined;
const descMatch = html.match(/<div[^>]*class="[^"]*description[^"]*"[^>]*>([\s\S]*?)<\/div>/i)
|| html.match(/<meta[^>]*name="description"[^>]*content="([^"]+)"/i);
if (descMatch) {
description = descMatch[1].replace(/<[^>]+>/g, '').replace(/\s+/g, ' ').trim();
}
// Release year
let year: number | undefined;
const yearMatch = html.match(/Released[:\s]+([A-Za-z]+)\s+(\d{4})/i)
|| html.match(/Released[:\s]+(\d{4})/i)
|| html.match(/(\d{4})\s*\)/); // " (2024)"
if (yearMatch) {
const y = parseInt(yearMatch[yearMatch[2] ? 2 : 1]);
if (y > 1970 && y < 2030) year = y;
}
// Genre
let genre: string | undefined;
const genreMatch = html.match(/Genre[:\s]+([^<]+)/i)
|| html.match(/<dt>Genre<\/dt>\s*<dd>([^<]+)/i);
if (genreMatch) {
genre = genreMatch[1].replace(/&amp;/g, '&').trim();
}
// Publisher
let publisher: string | undefined;
const pubMatch = html.match(/Publisher[:\s]+([^<]+)/i)
|| html.match(/<dt>Publisher<\/dt>\s*<dd>([^<]+)/i)
|| html.match(/Published by[:\s]+([^<]+)/i);
if (pubMatch) {
publisher = pubMatch[1].replace(/&amp;/g, '&').trim();
}
// Screenshots
const screenshots: string[] = [];
const imgRegex = /<img[^>]*src="([^"]+)"[^>]*>/gi;
let imgMatch;
while ((imgMatch = imgRegex.exec(html)) !== null) {
const src = imgMatch[1];
if (src.includes('screenshot') || src.includes('/screens/') || src.includes('/shots/')) {
const fullUrl = src.startsWith('http') ? src : `https://www.mobygames.com${src}`;
screenshots.push(fullUrl);
}
}
return {
description,
year,
genre,
publisher,
screenshots: screenshots.slice(0, 10),
};
} catch {
return null;
}
}
/** Try to get a screenshot from libretro Named_Snaps (reliable, same source as covers) */
async function tryLibretroScreenshots(title: string, platform: string): Promise<string[]> {
const dir = platformThumbDir[platform];
if (!dir) return [];
const titles = new Set<string>();
titles.add(safeTitle(title));
const stripped = title.replace(/\([^)]*\)/g, '').trim();
const safeStripped = safeTitle(stripped);
if (safeStripped && safeStripped !== safeTitle(title)) titles.add(safeStripped);
const results: string[] = [];
for (const t of titles) {
const url = buildEncodedUrl(dir, 'Named_Snaps', t);
try {
const valid = await urlExists(url);
if (valid) results.push(url);
} catch { /* skip */ }
}
return results;
}
export async function scrapeGameMetadata(romPath: string, title: string, platform: string): Promise<GameMetadata> {
// Check cache first
const cached = getCachedMetadata(romPath);
if (cached) return cached;
// Try MobyGames
let metadata: GameMetadata | null = null;
const gameUrl = await searchMobyGames(title);
if (gameUrl) {
metadata = await scrapeMobyPage(gameUrl);
}
// Try libretro screenshots as fallback
let screenshots = metadata?.screenshots || [];
if (screenshots.length === 0) {
screenshots = await tryLibretroScreenshots(title, platform);
}
const result: GameMetadata = {
description: metadata?.description,
year: metadata?.year,
genre: metadata?.genre,
publisher: metadata?.publisher,
screenshots, rating: metadata?.rating,
};
// Cache and return
cacheMetadata(romPath, result);
return result;
}
+12 -1
View File
@@ -1,5 +1,6 @@
import { autoUpdater } from 'electron-updater';
import { BrowserWindow } from 'electron';
import { platform } from 'os';
autoUpdater.autoDownload = false;
autoUpdater.autoInstallOnAppQuit = false;
@@ -26,7 +27,17 @@ export function setupAutoUpdater() {
});
autoUpdater.on('error', (err) => {
sendToWindows('updates:status', { status: 'error', message: err.message });
const isSigningError = platform() === 'darwin' &&
/code signature|not signed|code object/i.test(err.message);
sendToWindows('updates:status', {
status: 'error',
message: isSigningError
? 'macOS requires a one-time manual download for this update. After that, future updates will install automatically.'
: err.message,
manualLink: isSigningError
? 'https://github.com/mileswolfallen2/OmniEmu2.0/releases/latest'
: undefined,
});
});
autoUpdater.on('download-progress', (progress) => {
+19 -2
View File
@@ -20,9 +20,10 @@ export function applyTheme(theme: string) {
}
export function App() {
useGamepadNav();
const [currentPage, setCurrentPage] = useState<Page>('dashboard');
const { connected, showLegend, dismissLegend } = useGamepadNav(setCurrentPage, currentPage);
useEffect(() => {
window.omni.settings.get().then((s) => applyTheme(s.theme));
}, []);
@@ -47,7 +48,7 @@ export function App() {
const pageTitle: Record<Page, string> = {
dashboard: 'Dashboard',
emulators: 'Emulators',
library: 'Game Library',
library: 'Library',
settings: 'Settings',
controller: 'Controller',
utilities: 'Utilities',
@@ -64,6 +65,22 @@ export function App() {
{renderPage()}
</div>
</div>
{connected && showLegend && (
<div className="controller-legend" onClick={dismissLegend}>
<span>DPad: Navigate</span>
<span className="sep">|</span>
<span>A: Select</span>
<span className="sep">|</span>
<span>B/X/Select: Back</span>
<span className="sep">|</span>
<span>LB/RB: Tabs</span>
<span className="sep">|</span>
<span>Start: Focus</span>
<span className="sep">|</span>
<span>Guide: Dashboard</span>
</div>
)}
</div>
);
}
+204
View File
@@ -0,0 +1,204 @@
import React, { useEffect, useCallback, useState } from 'react';
import type { GameEntry, GameMetadata, AchievementInfo, RetroAchievement } from '../../shared/types';
interface Props {
game: GameEntry;
onClose: () => void;
onLaunch: (game: GameEntry) => void;
}
const platformLabels: Record<string, string> = {
nes: 'NES', snes: 'SNES', n64: 'Nintendo 64',
gb: 'Game Boy', gbc: 'Game Boy Color', gba: 'Game Boy Advance',
nds: 'Nintendo DS', gc: 'GameCube', wii: 'Wii',
ps1: 'PlayStation', ps2: 'PlayStation 2', ps3: 'PlayStation 3',
psp: 'PSP', pce: 'PC Engine',
'sega-md': 'Sega Genesis', 'sega-saturn': 'Sega Saturn', 'sega-dc': 'Sega Dreamcast',
dreamcast: 'Dreamcast', arcade: 'Arcade',
};
const badgeBase = 'https://retroachievements.org/Badge/';
export function GameDetailModal({ game, onClose, onLaunch }: Props) {
const [metadata, setMetadata] = useState<GameMetadata | null>(null);
const [loadingMeta, setLoadingMeta] = useState(false);
const [achievements, setAchievements] = useState<AchievementInfo | null>(null);
const [loadingAchievements, setLoadingAchievements] = useState(false);
const [currentScreenshot, setCurrentScreenshot] = useState(0);
useEffect(() => {
setLoadingMeta(true);
window.omni.game.scrapeMetadata(game.romPath, game.title, game.platform)
.then((m) => { setMetadata(m); setLoadingMeta(false); })
.catch(() => setLoadingMeta(false));
}, [game.romPath, game.title, game.platform]);
useEffect(() => {
setLoadingAchievements(true);
window.omni.game.achievements(game.romPath, game.title, game.platform)
.then((a) => { setAchievements(a); setLoadingAchievements(false); })
.catch(() => setLoadingAchievements(false));
}, [game.romPath, game.title, game.platform]);
const screenshots = metadata?.screenshots || [];
const hasScreenshots = screenshots.length > 0;
const displayScreenshot = hasScreenshots ? screenshots[currentScreenshot] : game.coverUrl;
const handleKeyDown = useCallback((e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
}, [onClose]);
useEffect(() => {
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [handleKeyDown]);
return (
<div className="modal-overlay" onClick={onClose}>
<div className="game-detail-modal" onClick={(e) => e.stopPropagation()}>
<div className="modal-header">
<div style={{ display: 'flex', alignItems: 'center', gap: 10, minWidth: 0 }}>
<h2 style={{ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
{game.title}
</h2>
<span className="platform-tag">{platformLabels[game.platform] || game.platform.toUpperCase()}</span>
</div>
<button className="btn btn-secondary btn-sm" onClick={onClose}>
Close
</button>
</div>
<div className="game-detail-body">
<div className="game-detail-left">
<div className="game-detail-cover">
{displayScreenshot ? (
<img
src={displayScreenshot}
alt={game.title}
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }}
/>
) : (
<div className="game-detail-cover-placeholder">?</div>
)}
</div>
{hasScreenshots && (
<div className="game-detail-thumbnails">
{screenshots.map((src, i) => (
<div
key={i}
className={`game-detail-thumb ${i === currentScreenshot ? 'active' : ''}`}
onClick={() => setCurrentScreenshot(i)}
>
<img src={src} alt={`Screenshot ${i + 1}`} />
</div>
))}
</div>
)}
</div>
<div className="game-detail-right">
{loadingMeta ? (
<div className="text-sm text-muted" style={{ padding: 12 }}>Loading metadata...</div>
) : (
<>
<div className="game-detail-meta">
{metadata?.year && (
<div className="meta-row">
<span className="meta-label">Year</span>
<span>{metadata.year}</span>
</div>
)}
{metadata?.genre && (
<div className="meta-row">
<span className="meta-label">Genre</span>
<span>{metadata.genre}</span>
</div>
)}
{metadata?.publisher && (
<div className="meta-row">
<span className="meta-label">Publisher</span>
<span>{metadata.publisher}</span>
</div>
)}
{metadata?.rating !== undefined && metadata.rating > 0 && (
<div className="meta-row">
<span className="meta-label">Rating</span>
<span>{'★'.repeat(Math.round(metadata.rating))}{'☆'.repeat(5 - Math.round(metadata.rating))}</span>
</div>
)}
<div className="meta-row">
<span className="meta-label">Emulator</span>
<span>{game.emulatorId}</span>
</div>
<div className="meta-row">
<span className="meta-label">Played</span>
<span>{game.playCount} time{game.playCount !== 1 ? 's' : ''}</span>
</div>
</div>
{metadata?.description && (
<div className="game-detail-description">
<h4>About</h4>
<p>{metadata.description}</p>
</div>
)}
</>
)}
<div className="game-detail-achievements">
<h4>
Achievements
{achievements && (
<span className="text-sm text-muted" style={{ fontWeight: 400, marginLeft: 8 }}>
{achievements.userProgress}/{achievements.totalAchievements} · {achievements.totalPoints} pts
</span>
)}
</h4>
{loadingAchievements ? (
<p className="text-sm text-muted">Loading achievements...</p>
) : achievements ? (
<div className="achievement-list">
{achievements.achievements.map((a) => (
<AchievementRow key={a.id} achievement={a} />
))}
</div>
) : (
<p className="text-sm text-muted">
Add your RetroAchievements Web API Key in Settings &gt; Utilities to see achievements here.
</p>
)}
</div>
<button
className="btn btn-primary"
style={{ marginTop: 'auto', width: '100%' }}
onClick={() => { onLaunch(game); onClose(); }}
>
Launch Game
</button>
</div>
</div>
</div>
</div>
);
}
function AchievementRow({ achievement }: { achievement: RetroAchievement }) {
const earned = !!achievement.dateEarned;
return (
<div className={`achievement-row ${earned ? 'earned' : 'locked'}`}>
<img
className="achievement-badge"
src={`${badgeBase}${achievement.badgeName}.png`}
alt={achievement.title}
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }}
/>
<div className="achievement-info">
<div className="achievement-title">{achievement.title}</div>
<div className="achievement-desc">{achievement.description}</div>
</div>
<div className="achievement-points">{achievement.points}</div>
</div>
);
}
@@ -0,0 +1,38 @@
import React, { useEffect, useCallback } from 'react';
import { markdownToHtml } from '../utils/markdown';
interface Props {
version: string;
releaseNotes: string;
onClose: () => void;
}
export function ReleaseNotesModal({ version, releaseNotes, onClose }: Props) {
const handleKeyDown = useCallback((e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
}, [onClose]);
useEffect(() => {
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [handleKeyDown]);
const html = markdownToHtml(releaseNotes);
return (
<div className="modal-overlay" onClick={onClose}>
<div className="modal-full" onClick={(e) => e.stopPropagation()}>
<div className="modal-header">
<h2>v{version} Release Notes</h2>
<button className="btn btn-secondary btn-sm" onClick={onClose}>
Close
</button>
</div>
<div
className="modal-body markdown"
dangerouslySetInnerHTML={{ __html: html }}
/>
</div>
</div>
);
}
+1 -1
View File
@@ -10,7 +10,7 @@ interface SidebarProps {
const navItems: { page: Page; label: string; icon: string }[] = [
{ page: 'dashboard', label: 'Dashboard', icon: '📊' },
{ page: 'emulators', label: 'Emulators', icon: '🕹️' },
{ page: 'library', label: 'Game Library', icon: '📚' },
{ page: 'library', label: 'Library', icon: '📚' },
{ page: 'controller', label: 'Controller', icon: '🎮' },
{ page: 'utilities', label: 'Utilities', icon: '🔧' },
{ page: 'settings', label: 'Settings', icon: '⚙️' },
+142 -57
View File
@@ -1,86 +1,159 @@
import { useEffect, useRef } from 'react';
import { useEffect, useRef, useState, useCallback } from 'react';
const DEBOUNCE_MS = 300;
const LEGEND_TIMEOUT_MS = 4000;
const SIDEBAR_SELECTOR = '.nav-item';
type Page = 'dashboard' | 'emulators' | 'library' | 'settings' | 'controller' | 'utilities';
export function useGamepadNav() {
const pageOrder: Page[] = ['dashboard', 'emulators', 'library', 'controller', 'utilities', 'settings'];
export function useGamepadNav(onNavigate: (page: Page) => void, currentPage: Page) {
const lastTime = useRef<Record<string, number>>({});
const [connected, setConnected] = useState(false);
const [showLegend, setShowLegend] = useState(false);
const legendTimer = useRef<ReturnType<typeof setTimeout>>();
const showLegendTemporarily = useCallback(() => {
setShowLegend(true);
if (legendTimer.current) clearTimeout(legendTimer.current);
legendTimer.current = setTimeout(() => setShowLegend(false), LEGEND_TIMEOUT_MS);
}, []);
useEffect(() => {
let raf = 0;
let lastConnected = false;
let navigationPending = false;
const poll = () => {
const gamepads = navigator.getGamepads();
const now = Date.now();
for (const gp of gamepads) {
if (!gp || !gp.connected) continue;
const activePad = Array.from(gamepads).find(g => g && g.connected);
const debounced = (key: string): boolean => {
if (now - (lastTime.current[key] || 0) < DEBOUNCE_MS) return false;
lastTime.current[key] = now;
return true;
};
if (activePad && !lastConnected) {
lastConnected = true;
setConnected(true);
showLegendTemporarily();
} else if (!activePad && lastConnected) {
lastConnected = false;
setConnected(false);
}
// D-Pad buttons (12-15) and left stick
const dpadUp = gp.buttons[12]?.pressed || gp.axes[1] < -0.6;
const dpadDown = gp.buttons[13]?.pressed || gp.axes[1] > 0.6;
const dpadLeft = gp.buttons[14]?.pressed || gp.axes[0] < -0.6;
const dpadRight = gp.buttons[15]?.pressed || gp.axes[0] > 0.6;
const gp = activePad;
if (!gp) { raf = requestAnimationFrame(poll); return; }
// --- Sidebar navigation with Left/Right (only when sidebar is focused) ---
if (dpadLeft && debounced('left')) {
const focused = document.activeElement;
if (focused?.closest('.sidebar')) cycleSidebar(-1);
if (navigationPending) {
navigationPending = false;
focusFirstOnPage();
}
const debounced = (key: string): boolean => {
if (now - (lastTime.current[key] || 0) < DEBOUNCE_MS) return false;
lastTime.current[key] = now;
return true;
};
// D-Pad (12-15) and left stick
const dpadUp = gp.buttons[12]?.pressed || gp.axes[1] < -0.5;
const dpadDown = gp.buttons[13]?.pressed || gp.axes[1] > 0.5;
const dpadLeft = gp.buttons[14]?.pressed || gp.axes[0] < -0.5;
const dpadRight = gp.buttons[15]?.pressed || gp.axes[0] > 0.5;
// --- Sidebar navigation with Left/Right (only when sidebar is focused) ---
if (dpadLeft && debounced('left')) {
const focused = document.activeElement;
if (focused?.closest('.sidebar')) {
cycleSidebar(-1);
navigationPending = true;
}
if (dpadRight && debounced('right')) {
const focused = document.activeElement;
if (focused?.closest('.sidebar')) cycleSidebar(1);
}
if (dpadRight && debounced('right')) {
const focused = document.activeElement;
if (focused?.closest('.sidebar')) {
cycleSidebar(1);
navigationPending = true;
}
}
// --- Page content navigation with Up/Down (Tab/Shift+Tab) ---
if (dpadUp && debounced('up')) {
const focused = document.activeElement;
const sidebar = focused?.closest('.sidebar');
if (sidebar) {
cycleSidebar(-1);
} else {
const focusable = getFocusableElements();
const idx = focusable.indexOf(focused as HTMLElement);
if (idx > 0) focusable[idx - 1]?.focus();
// --- Page content navigation with Up/Down ---
if (dpadUp && debounced('up')) {
const focused = document.activeElement;
if (focused?.closest('.sidebar')) {
cycleSidebar(-1);
navigationPending = true;
} else {
const focusable = getFocusableElements();
const idx = focusable.indexOf(focused as HTMLElement);
if (idx > 0) {
focusable[idx - 1]?.focus();
focusable[idx - 1]?.scrollIntoView({ block: 'nearest' });
}
}
if (dpadDown && debounced('down')) {
const focused = document.activeElement;
const sidebar = focused?.closest('.sidebar');
if (sidebar) {
cycleSidebar(1);
}
if (dpadDown && debounced('down')) {
const focused = document.activeElement;
if (focused?.closest('.sidebar')) {
cycleSidebar(1);
navigationPending = true;
} else {
const focusable = getFocusableElements();
const idx = focusable.indexOf(focused as HTMLElement);
if (idx < focusable.length - 1) {
focusable[idx + 1]?.focus();
focusable[idx + 1]?.scrollIntoView({ block: 'nearest' });
} else {
const focusable = getFocusableElements();
const idx = focusable.indexOf(focused as HTMLElement);
if (idx < focusable.length - 1) focusable[idx + 1]?.focus();
else focusable[0]?.focus();
focusable[0]?.focus();
focusable[0]?.scrollIntoView({ block: 'nearest' });
}
}
}
// A button (0) → confirm / click
if (gp.buttons[0]?.pressed && debounced('a')) {
const el = document.activeElement as HTMLElement;
if (el) el.click();
// A button (0) → confirm / click
if (gp.buttons[0]?.pressed && debounced('a')) {
const el = document.activeElement as HTMLElement;
if (el) {
const inSidebar = !!el.closest('.sidebar');
el.click();
if (inSidebar) navigationPending = true;
}
}
// B button (1) → back / blur (do not force sidebar)
if (gp.buttons[1]?.pressed && debounced('b')) {
const focused = document.activeElement as HTMLElement;
if (focused) focused.blur();
// B (1), X (2), Select (8) → blur / back
const backPressed = (gp.buttons[1]?.pressed || gp.buttons[2]?.pressed || gp.buttons[8]?.pressed);
if (backPressed && debounced('back')) {
const focused = document.activeElement as HTMLElement;
if (focused?.closest('.sidebar')) {
// If in sidebar, blur and focus first page element
focused.blur();
focusFirstOnPage();
} else if (focused) {
focused.blur();
}
}
// Start (9) → focus first interactive element on page
if (gp.buttons[9]?.pressed && debounced('start')) {
const all = getFocusableElements();
all[0]?.focus();
}
// Start (9) → focus first element on the page
if (gp.buttons[9]?.pressed && debounced('start')) {
focusFirstOnPage();
}
// LB (4) → previous tab, RB (5) → next tab
if (gp.buttons[4]?.pressed && debounced('lb')) {
const idx = pageOrder.indexOf(currentPage);
const prev = idx > 0 ? idx - 1 : pageOrder.length - 1;
onNavigate(pageOrder[prev]);
navigationPending = true;
}
if (gp.buttons[5]?.pressed && debounced('rb')) {
const idx = pageOrder.indexOf(currentPage);
const next = idx < pageOrder.length - 1 ? idx + 1 : 0;
onNavigate(pageOrder[next]);
navigationPending = true;
}
// Home/Guide (16) → go to dashboard
if (gp.buttons[16]?.pressed && debounced('guide')) {
onNavigate('dashboard');
navigationPending = true;
}
raf = requestAnimationFrame(poll);
@@ -88,7 +161,9 @@ export function useGamepadNav() {
raf = requestAnimationFrame(poll);
return () => cancelAnimationFrame(raf);
}, []);
}, [onNavigate, showLegendTemporarily]);
return { connected, showLegend, dismissLegend: () => setShowLegend(false) };
}
function getFocusableElements(): HTMLElement[] {
@@ -96,11 +171,21 @@ function getFocusableElements(): HTMLElement[] {
document.querySelectorAll<HTMLElement>(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"]), .game-card, .nav-item'
)
).filter(el => el.offsetParent !== null); // only visible
).filter(el => el.offsetParent !== null);
}
function focusFirstOnPage() {
requestAnimationFrame(() => {
const focusable = getFocusableElements().filter(el => !el.closest('.sidebar'));
if (focusable.length > 0) {
focusable[0]?.focus();
focusable[0]?.scrollIntoView({ block: 'nearest' });
}
});
}
function cycleSidebar(dir: 1 | -1): void {
const items = document.querySelectorAll<HTMLElement>(SIDEBAR_SELECTOR);
const items = document.querySelectorAll<HTMLElement>('.nav-item');
if (items.length === 0) return;
const activeIdx = Array.from(items).findIndex(el =>
el.classList.contains('active') || el === document.activeElement
+16 -3
View File
@@ -1,5 +1,6 @@
import React, { useEffect, useState, useCallback } from 'react';
import type { GameEntry } from '../../shared/types';
import { GameDetailModal } from '../components/GameDetailModal';
const platformIcons: Record<string, string> = {
nes: '🕹️', snes: '🕹️', n64: '🎮',
@@ -13,6 +14,7 @@ export function LibraryPage() {
const [games, setGames] = useState<GameEntry[]>([]);
const [loading, setLoading] = useState(false);
const [romsDir, setRomsDir] = useState<string>('');
const [selectedGame, setSelectedGame] = useState<GameEntry | null>(null);
useEffect(() => {
(async () => {
@@ -60,6 +62,10 @@ export function LibraryPage() {
await window.omni.game.launch(game.emulatorId, game.romPath);
};
const handleShowDetail = (game: GameEntry) => {
setSelectedGame(game);
};
return (
<div>
<div style={{ display: 'flex', gap: 8, marginBottom: 16, alignItems: 'center' }}>
@@ -121,11 +127,11 @@ export function LibraryPage() {
<div
className="game-card"
key={game.id}
onClick={() => handleLaunch(game)}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') handleLaunch(game); }}
onClick={() => handleShowDetail(game)}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') handleShowDetail(game); }}
tabIndex={0}
role="button"
title={`Launch ${game.title} via ${game.emulatorId}`}
title={`View ${game.title}`}
>
<div className="game-card-cover">
{game.coverUrl ? (
@@ -150,6 +156,13 @@ export function LibraryPage() {
</div>
</div>
)}
{selectedGame && (
<GameDetailModal
game={selectedGame}
onClose={() => setSelectedGame(null)}
onLaunch={handleLaunch}
/>
)}
</div>
);
}
+43 -16
View File
@@ -1,6 +1,7 @@
import React, { useEffect, useState } from 'react';
import type { AppSettings, EmulatorConfig } from '../../shared/types';
import { BiosCheckPanel } from '../components/BiosCheckPanel';
import { ReleaseNotesModal } from '../components/ReleaseNotesModal';
import { applyTheme } from '../App';
const systemLabels: Record<string, string> = {
@@ -26,10 +27,12 @@ export function SettingsPage() {
version?: string;
releaseNotes?: string;
message?: string;
manualLink?: string;
} | null>(null);
const [checking, setChecking] = useState(false);
const [downloading, setDownloading] = useState(false);
const [downloadProgress, setDownloadProgress] = useState(0);
const [showReleaseNotes, setShowReleaseNotes] = useState(false);
useEffect(() => {
const unsubStatus = window.omni.updates.onStatus((s) => {
@@ -48,7 +51,11 @@ export function SettingsPage() {
} else if (s.status === 'error') {
setChecking(false);
setDownloading(false);
setUpdateInfo({ status: 'error', message: s.message as string });
setUpdateInfo({
status: 'error',
message: s.message as string,
manualLink: s.manualLink as string | undefined,
});
}
});
const unsubProgress = window.omni.updates.onDownloadProgress((p) => {
@@ -298,11 +305,25 @@ export function SettingsPage() {
{updateInfo?.status === 'available' && `v${updateInfo.version} available`}
{updateInfo?.status === 'not-available' && `v${updateInfo.version} — up to date`}
{updateInfo?.status === 'downloaded' && 'Ready to install — restart the app to apply'}
{updateInfo?.status === 'error' && `Error: ${updateInfo.message}`}
{updateInfo?.status === 'error' && updateInfo.manualLink && (
<span>
{updateInfo.message}
</span>
)}
{updateInfo?.status === 'error' && !updateInfo.manualLink && (
`Error: ${updateInfo.message}`
)}
{!updateInfo && 'Check GitHub for new releases'}
</div>
</div>
{updateInfo?.status !== 'downloaded' ? (
{updateInfo?.status === 'error' && updateInfo.manualLink ? (
<button
className="btn btn-primary btn-sm"
onClick={() => window.open(updateInfo.manualLink!, '_blank')}
>
Manual Download
</button>
) : updateInfo?.status !== 'downloaded' ? (
<button
className="btn btn-secondary btn-sm"
disabled={checking || downloading}
@@ -343,20 +364,26 @@ export function SettingsPage() {
</div>
)}
{updateInfo?.releaseNotes && (
<details style={{ marginTop: 8 }}>
<summary className="text-sm text-muted" style={{ cursor: 'pointer' }}>
Release notes
</summary>
<pre style={{
marginTop: 8, padding: 8, fontSize: 12,
background: 'var(--surface)', borderRadius: 6,
maxHeight: 200, overflow: 'auto', whiteSpace: 'pre-wrap',
}}>
{typeof updateInfo.releaseNotes === 'string'
<div style={{ marginTop: 8 }}>
<button
className="btn btn-secondary btn-sm"
onClick={() => setShowReleaseNotes(true)}
>
View Release Notes
</button>
</div>
)}
{showReleaseNotes && updateInfo?.releaseNotes && (
<ReleaseNotesModal
version={updateInfo.version || ''}
releaseNotes={
typeof updateInfo.releaseNotes === 'string'
? updateInfo.releaseNotes
: JSON.stringify(updateInfo.releaseNotes, null, 2)}
</pre>
</details>
: JSON.stringify(updateInfo.releaseNotes, null, 2)
}
onClose={() => setShowReleaseNotes(false)}
/>
)}
</div>
+31 -14
View File
@@ -4,6 +4,7 @@ export function UtilitiesPage() {
const [regenerating, setRegenerating] = useState(false);
const [raUsername, setRaUsername] = useState('');
const [raPassword, setRaPassword] = useState('');
const [raApiKey, setRaApiKey] = useState('');
const [raResults, setRaResults] = useState<Record<string, boolean> | null>(null);
const [saving, setSaving] = useState(false);
@@ -11,6 +12,7 @@ export function UtilitiesPage() {
window.omni.settings.get().then((s) => {
setRaUsername(s.retroAchievementsUsername || '');
setRaPassword(s.retroAchievementsPassword || '');
setRaApiKey(s.retroAchievementsApiKey || '');
});
}, []);
@@ -24,6 +26,11 @@ export function UtilitiesPage() {
setSaving(true);
setRaResults(null);
try {
await window.omni.settings.save({
retroAchievementsUsername: raUsername,
retroAchievementsPassword: raPassword,
retroAchievementsApiKey: raApiKey,
});
const results = await window.omni.retroachievements.save(raUsername, raPassword);
setRaResults(results);
} catch {
@@ -34,10 +41,10 @@ export function UtilitiesPage() {
const emuLabels: Record<string, string> = {
retroarch: 'RetroArch',
dolphin: 'Dolphin',
pcsx2: 'PCSX2',
duckstation: 'DuckStation',
flycast: 'Flycast',
melonds: 'melonDS',
};
return (
@@ -66,17 +73,10 @@ export function UtilitiesPage() {
<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").
Enable RetroAchievements across all supported emulators and view
per-game achievements in the game detail modal. The username and
password are used to configure emulators; the Web API Key (from
retroachievements.org/settings) is used to fetch achievement data.
</p>
<div className="setting-row">
@@ -95,14 +95,31 @@ export function UtilitiesPage() {
<div className="setting-row">
<div>
<div className="setting-label">Password / API Key</div>
<div className="setting-label">Password</div>
</div>
<input
type="password"
className="input"
value={raPassword}
onChange={(e) => setRaPassword(e.target.value)}
placeholder="your password or Web API Key"
placeholder="your RetroAchievements password"
style={{ width: 240 }}
/>
</div>
<div className="setting-row">
<div>
<div className="setting-label">Web API Key</div>
<div className="setting-desc">
From retroachievements.org/settings
</div>
</div>
<input
type="password"
className="input"
value={raApiKey}
onChange={(e) => setRaApiKey(e.target.value)}
placeholder="your Web API Key"
style={{ width: 240 }}
/>
</div>
+272
View File
@@ -736,3 +736,275 @@ select:focus, input[type="text"]:focus {
background: rgba(74, 222, 128, 0.15);
color: var(--success);
}
/* Full-page modal */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.6);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
backdrop-filter: blur(4px);
}
.modal-full {
background: var(--bg-primary);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
width: min(800px, 90vw);
height: min(600px, 80vh);
display: flex;
flex-direction: column;
overflow: hidden;
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.4);
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px;
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
.modal-header h2 {
font-size: 16px;
font-weight: 600;
margin: 0;
}
.modal-body {
flex: 1;
overflow-y: auto;
padding: 20px;
font-size: 14px;
line-height: 1.6;
}
/* Markdown rendered content */
.markdown h1 { font-size: 22px; margin: 0 0 12px; font-weight: 700; }
.markdown h2 { font-size: 18px; margin: 20px 0 10px; font-weight: 600; border-bottom: 1px solid var(--border); padding-bottom: 6px; }
.markdown h3 { font-size: 15px; margin: 16px 0 8px; font-weight: 600; }
.markdown p { margin: 0 0 10px; }
.markdown ul { margin: 0 0 10px; padding-left: 24px; }
.markdown li { margin-bottom: 4px; }
.markdown strong { font-weight: 700; color: var(--text-primary); }
.markdown em { font-style: italic; }
.markdown a { color: var(--accent); text-decoration: underline; }
.markdown a:hover { color: var(--accent-hover); }
.markdown code {
background: var(--bg-tertiary);
padding: 2px 6px;
border-radius: 3px;
font-size: 13px;
font-family: 'SF Mono', 'Fira Code', monospace;
}
.markdown pre {
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 12px 16px;
overflow-x: auto;
margin: 0 0 12px;
}
.markdown pre code {
background: none;
padding: 0;
font-size: 13px;
line-height: 1.5;
}
.markdown hr {
border: none;
border-top: 1px solid var(--border);
margin: 16px 0;
}
.markdown table {
width: 100%;
border-collapse: collapse;
margin: 0 0 12px;
font-size: 13px;
}
.markdown td, .markdown th {
border: 1px solid var(--border);
padding: 6px 10px;
text-align: left;
}
.markdown th {
background: var(--bg-tertiary);
font-weight: 600;
}
.markdown tr:nth-child(even) {
background: var(--bg-tertiary);
}
/* Game detail modal */
.game-detail-modal {
background: var(--bg-primary);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
width: min(960px, 92vw);
height: min(680px, 85vh);
display: flex;
flex-direction: column;
overflow: hidden;
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.4);
}
.game-detail-body {
display: flex;
flex: 1;
overflow: hidden;
}
.game-detail-left {
width: 380px;
flex-shrink: 0;
display: flex;
flex-direction: column;
border-right: 1px solid var(--border);
}
.game-detail-cover {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
background: var(--bg-tertiary);
padding: 16px;
min-height: 0;
}
.game-detail-cover img {
max-width: 100%;
max-height: 100%;
object-fit: contain;
border-radius: var(--radius);
}
.game-detail-cover-placeholder {
font-size: 48px;
color: var(--text-muted);
opacity: 0.4;
}
.game-detail-thumbnails {
display: flex;
gap: 6px;
padding: 10px 12px;
overflow-x: auto;
border-top: 1px solid var(--border);
flex-shrink: 0;
}
.game-detail-thumb {
width: 64px;
height: 48px;
border-radius: var(--radius-sm);
overflow: hidden;
cursor: pointer;
border: 2px solid transparent;
flex-shrink: 0;
transition: border-color var(--transition);
}
.game-detail-thumb.active {
border-color: var(--accent);
}
.game-detail-thumb img {
width: 100%;
height: 100%;
object-fit: cover;
}
.game-detail-right {
flex: 1;
display: flex;
flex-direction: column;
padding: 20px;
gap: 16px;
overflow-y: auto;
min-width: 0;
}
.game-detail-meta {
display: grid;
grid-template-columns: auto 1fr;
gap: 4px 16px;
font-size: 13px;
}
.meta-row {
display: contents;
}
.meta-label {
color: var(--text-muted);
font-weight: 500;
}
.game-detail-description h4,
.game-detail-achievements h4 {
font-size: 14px;
font-weight: 600;
margin: 0 0 6px;
}
.game-detail-description p {
font-size: 13px;
line-height: 1.6;
color: var(--text-secondary);
margin: 0;
}
.game-detail-achievements {
margin: 0;
}
.platform-tag {
display: inline-block;
background: var(--accent);
color: #fff;
font-size: 11px;
font-weight: 600;
padding: 2px 8px;
border-radius: var(--radius-sm);
white-space: nowrap;
flex-shrink: 0;
}
/* Controller legend overlay */
.controller-legend {
position: fixed;
bottom: 16px;
left: 50%;
transform: translateX(-50%);
display: flex;
align-items: center;
gap: 6px;
padding: 8px 16px;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
font-size: 12px;
color: var(--text-secondary);
z-index: 999;
box-shadow: var(--shadow);
backdrop-filter: blur(8px);
cursor: pointer;
user-select: none;
animation: legendFadeIn 0.2s ease;
}
.controller-legend .sep {
color: var(--border);
font-size: 10px;
}
@keyframes legendFadeIn {
from { opacity: 0; transform: translateX(-50%) translateY(8px); }
to { opacity: 1; transform: translateX(-50%) translateY(0); }
}
+6
View File
@@ -2,6 +2,8 @@ import type {
EmulatorConfig,
EmulatorState,
GameEntry,
GameMetadata,
AchievementInfo,
SystemInfo,
AppSettings,
InstallProgress,
@@ -32,6 +34,10 @@ declare global {
};
game: {
launch: (emulatorId: string, romPath: string) => Promise<boolean>;
scrapeArt: (title: string, platform: string) => Promise<string | undefined>;
cacheCovers: (entries: { romPath: string; coverUrl: string }[]) => Promise<boolean>;
scrapeMetadata: (romPath: string, title: string, platform: string) => Promise<GameMetadata>;
achievements: (romPath: string, title: string, platform: string) => Promise<AchievementInfo | null>;
};
settings: {
get: () => Promise<AppSettings>;
+89
View File
@@ -0,0 +1,89 @@
export function markdownToHtml(md: string): string {
let html = md;
html = html.replace(/</g, '&lt;').replace(/>/g, '&gt;');
html = html.replace(/```(\w*)\n([\s\S]*?)```/g, (_, _lang, code) => {
return `<pre><code>${code.trim()}</code></pre>`;
});
html = html.replace(/`([^`]+)`/g, '<code>$1</code>');
html = html.replace(/^### (.+)$/gm, '<h3>$1</h3>');
html = html.replace(/^## (.+)$/gm, '<h2>$1</h2>');
html = html.replace(/^# (.+)$/gm, '<h1>$1</h1>');
html = html.replace(/^- (.+)$/gm, '<li>$1</li>');
html = html.replace(/^\* (.+)$/gm, '<li>$1</li>');
html = html.replace(/(<li>.*<\/li>\n?)+/g, '<ul>$&</ul>');
html = html.replace(/^\d+\. (.+)$/gm, '<li>$1</li>');
html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
html = html.replace(/\*(.+?)\*/g, '<em>$1</em>');
html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" rel="noopener noreferrer">$1</a>');
html = html.replace(/^---+\s*$/gm, '<hr>');
html = html.replace(/^\|(.+)\|$/gm, (line) => {
const cells = line.slice(1, -1).split('|').map(c => c.trim());
const isHeader = /^[-: ]+$/.test(cells[0]);
if (isHeader) return '';
const tag = 'td';
return `<tr>${cells.map(c => `<${tag}>${c}</${tag}>`).join('')}</tr>`;
});
const lines = html.split('\n');
const result: string[] = [];
let inParagraph = false;
let inList = false;
let inTable = false;
for (let i = 0; i < lines.length; i++) {
const line = lines[i].trim();
const nextLine = i < lines.length - 1 ? lines[i + 1].trim() : '';
if (!line) {
if (inParagraph) { result.push('</p>'); inParagraph = false; }
if (inList && !nextLine.startsWith('<li>') && !nextLine.startsWith('<tr>')) {
result.push('</ul>'); inList = false;
}
continue;
}
if (line.startsWith('<h') || line.startsWith('<pre') || line.startsWith('<hr') || line.startsWith('<tr')) {
if (inParagraph) { result.push('</p>'); inParagraph = false; }
if (inList) { result.push('</ul>'); inList = false; }
result.push(line);
if (line.startsWith('<tr')) inTable = true;
if (line.startsWith('</table>')) inTable = false;
continue;
}
if (line.startsWith('<li>')) {
if (inParagraph) { result.push('</p>'); inParagraph = false; }
if (!inList) { result.push('<ul>'); inList = true; }
result.push(line);
continue;
}
if (!inParagraph && !inList && !inTable) {
result.push('<p>');
inParagraph = true;
}
if (inParagraph) {
result.push(line);
if (!nextLine || nextLine.startsWith('<h') || nextLine.startsWith('<pre') || nextLine.startsWith('<hr') || nextLine.startsWith('<li>') || nextLine.startsWith('<tr>')) {
result.push('</p>');
inParagraph = false;
}
}
}
if (inParagraph) result.push('</p>');
if (inList) result.push('</ul>');
return result.join('\n');
}
+37
View File
@@ -64,6 +64,15 @@ export interface RomFile {
playCount: number;
}
export interface GameMetadata {
description?: string;
year?: number;
genre?: string;
publisher?: string;
screenshots?: string[];
rating?: number;
}
export interface GameEntry {
id: string;
romPath: string;
@@ -71,6 +80,12 @@ export interface GameEntry {
platform: string;
emulatorId: string;
coverUrl?: string;
description?: string;
year?: number;
genre?: string;
publisher?: string;
screenshots?: string[];
rating?: number;
lastPlayed?: string;
playCount: number;
addedAt: string;
@@ -100,6 +115,28 @@ export interface AppSettings {
retroAchievementsUsername?: string;
/** RetroAchievements password/token */
retroAchievementsPassword?: string;
/** RetroAchievements Web API Key (from retroachievements.org/settings) */
retroAchievementsApiKey?: string;
}
export interface RetroAchievement {
id: number;
title: string;
description: string;
points: number;
badgeName: string;
dateEarned?: string;
dateEarnedHardcore?: string;
}
export interface AchievementInfo {
gameId: number;
gameTitle: string;
consoleName: string;
totalAchievements: number;
totalPoints: number;
userProgress: number;
achievements: RetroAchievement[];
}
export interface SystemInfo {