From c5ad6619213f678a4c67ec15b33942e45445e04c Mon Sep 17 00:00:00 2001 From: mileswa1q22 Date: Sat, 11 Jul 2026 00:05:56 -0500 Subject: [PATCH] still working --- package.json | 1 + scripts/adhoc-sign.cjs | 15 + src/main/configurator.ts | 185 +++++++++--- src/main/emulators.ts | 13 + src/main/ipc.ts | 19 +- src/main/preload.ts | 6 + src/main/ra.ts | 218 ++++++++++++++ src/main/scraper.ts | 192 ++++++++++++- src/main/updater.ts | 13 +- src/renderer/App.tsx | 21 +- src/renderer/components/GameDetailModal.tsx | 204 +++++++++++++ src/renderer/components/ReleaseNotesModal.tsx | 38 +++ src/renderer/components/Sidebar.tsx | 2 +- src/renderer/hooks/useGamepadNav.ts | 199 +++++++++---- src/renderer/pages/LibraryPage.tsx | 19 +- src/renderer/pages/SettingsPage.tsx | 59 ++-- src/renderer/pages/UtilitiesPage.tsx | 45 ++- src/renderer/styles.css | 272 ++++++++++++++++++ src/renderer/types.ts | 6 + src/renderer/utils/markdown.ts | 89 ++++++ src/shared/types.ts | 37 +++ 21 files changed, 1507 insertions(+), 146 deletions(-) create mode 100644 scripts/adhoc-sign.cjs create mode 100644 src/main/ra.ts create mode 100644 src/renderer/components/GameDetailModal.tsx create mode 100644 src/renderer/components/ReleaseNotesModal.tsx create mode 100644 src/renderer/utils/markdown.ts diff --git a/package.json b/package.json index 7793fa5..ce9eb56 100644 --- a/package.json +++ b/package.json @@ -44,6 +44,7 @@ "build": { "appId": "com.omniemu.app", "productName": "OmniEmu", + "afterPack": "./scripts/adhoc-sign.cjs", "publish": ["github"], "directories": { "output": "release", diff --git a/scripts/adhoc-sign.cjs b/scripts/adhoc-sign.cjs new file mode 100644 index 0000000..0a4e779 --- /dev/null +++ b/scripts/adhoc-sign.cjs @@ -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)'); + } +}; diff --git a/src/main/configurator.ts b/src/main/configurator.ts index 9392dd0..7b4527e 100644 --- a/src/main/configurator.ts +++ b/src/main/configurator.ts @@ -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 { + 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((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 = { 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 { +export async function applyRetroAchievements(username: string, password: string): Promise> { const results: Record = {}; + // 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--; + } + } + } +} diff --git a/src/main/emulators.ts b/src/main/emulators.ts index 1162e40..1aa6ce6 100644 --- a/src/main/emulators.ts +++ b/src/main/emulators.ts @@ -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)]; } diff --git a/src/main/ipc.ts b/src/main/ipc.ts index 71dccdc..0e3a693 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -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) => @@ -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; } ); diff --git a/src/main/preload.ts b/src/main/preload.ts index d96774b..72fab5c 100644 --- a/src/main/preload.ts +++ b/src/main/preload.ts @@ -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 => ipcRenderer.invoke('games:cache-covers', entries), + scrapeMetadata: (romPath: string, title: string, platform: string): Promise => + ipcRenderer.invoke('games:scrape-metadata', romPath, title, platform), + achievements: (romPath: string, title: string, platform: string): Promise => + ipcRenderer.invoke('games:achievements', romPath, title, platform), }, bios: { diff --git a/src/main/ra.ts b/src/main/ra.ts new file mode 100644 index 0000000..2f6fd0e --- /dev/null +++ b/src/main/ra.ts @@ -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 = { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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); +} diff --git a/src/main/scraper.ts b/src/main/scraper.ts index a979c06..3611f13 100644 --- a/src/main/scraper.ts +++ b/src/main/scraper.ts @@ -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 { + 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 { + try { + const html = await mobyFetch(buildMobySearchUrl(title)); + const match = html.match(/]*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 { + try { + const html = await mobyFetch(url); + + // Description + let description: string | undefined; + const descMatch = html.match(/]*class="[^"]*description[^"]*"[^>]*>([\s\S]*?)<\/div>/i) + || html.match(/]*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(/
Genre<\/dt>\s*
([^<]+)/i); + if (genreMatch) { + genre = genreMatch[1].replace(/&/g, '&').trim(); + } + + // Publisher + let publisher: string | undefined; + const pubMatch = html.match(/Publisher[:\s]+([^<]+)/i) + || html.match(/
Publisher<\/dt>\s*
([^<]+)/i) + || html.match(/Published by[:\s]+([^<]+)/i); + if (pubMatch) { + publisher = pubMatch[1].replace(/&/g, '&').trim(); + } + + // Screenshots + const screenshots: string[] = []; + const imgRegex = /]*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 { + const dir = platformThumbDir[platform]; + if (!dir) return []; + + const titles = new Set(); + 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 { + // 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; +} diff --git a/src/main/updater.ts b/src/main/updater.ts index 8039519..ca33208 100644 --- a/src/main/updater.ts +++ b/src/main/updater.ts @@ -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) => { diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 305b989..0508847 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -20,9 +20,10 @@ export function applyTheme(theme: string) { } export function App() { - useGamepadNav(); const [currentPage, setCurrentPage] = useState('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 = { dashboard: 'Dashboard', emulators: 'Emulators', - library: 'Game Library', + library: 'Library', settings: 'Settings', controller: 'Controller', utilities: 'Utilities', @@ -64,6 +65,22 @@ export function App() { {renderPage()} + + {connected && showLegend && ( +
+ DPad: Navigate + | + A: Select + | + B/X/Select: Back + | + LB/RB: Tabs + | + Start: Focus + | + Guide: Dashboard +
+ )} ); } diff --git a/src/renderer/components/GameDetailModal.tsx b/src/renderer/components/GameDetailModal.tsx new file mode 100644 index 0000000..cc82591 --- /dev/null +++ b/src/renderer/components/GameDetailModal.tsx @@ -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 = { + 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(null); + const [loadingMeta, setLoadingMeta] = useState(false); + const [achievements, setAchievements] = useState(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 ( +
+
e.stopPropagation()}> +
+
+

+ {game.title} +

+ {platformLabels[game.platform] || game.platform.toUpperCase()} +
+ +
+ +
+
+
+ {displayScreenshot ? ( + {game.title} { (e.target as HTMLImageElement).style.display = 'none'; }} + /> + ) : ( +
?
+ )} +
+ {hasScreenshots && ( +
+ {screenshots.map((src, i) => ( +
setCurrentScreenshot(i)} + > + {`Screenshot +
+ ))} +
+ )} +
+ +
+ {loadingMeta ? ( +
Loading metadata...
+ ) : ( + <> +
+ {metadata?.year && ( +
+ Year + {metadata.year} +
+ )} + {metadata?.genre && ( +
+ Genre + {metadata.genre} +
+ )} + {metadata?.publisher && ( +
+ Publisher + {metadata.publisher} +
+ )} + {metadata?.rating !== undefined && metadata.rating > 0 && ( +
+ Rating + {'โ˜…'.repeat(Math.round(metadata.rating))}{'โ˜†'.repeat(5 - Math.round(metadata.rating))} +
+ )} +
+ Emulator + {game.emulatorId} +
+
+ Played + {game.playCount} time{game.playCount !== 1 ? 's' : ''} +
+
+ + {metadata?.description && ( +
+

About

+

{metadata.description}

+
+ )} + + )} + +
+

+ Achievements + {achievements && ( + + {achievements.userProgress}/{achievements.totalAchievements} ยท {achievements.totalPoints} pts + + )} +

+ + {loadingAchievements ? ( +

Loading achievements...

+ ) : achievements ? ( +
+ {achievements.achievements.map((a) => ( + + ))} +
+ ) : ( +

+ Add your RetroAchievements Web API Key in Settings > Utilities to see achievements here. +

+ )} +
+ + +
+
+
+
+ ); +} + +function AchievementRow({ achievement }: { achievement: RetroAchievement }) { + const earned = !!achievement.dateEarned; + return ( +
+ {achievement.title} { (e.target as HTMLImageElement).style.display = 'none'; }} + /> +
+
{achievement.title}
+
{achievement.description}
+
+
{achievement.points}
+
+ ); +} diff --git a/src/renderer/components/ReleaseNotesModal.tsx b/src/renderer/components/ReleaseNotesModal.tsx new file mode 100644 index 0000000..1e379ed --- /dev/null +++ b/src/renderer/components/ReleaseNotesModal.tsx @@ -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 ( +
+
e.stopPropagation()}> +
+

v{version} Release Notes

+ +
+
+
+
+ ); +} diff --git a/src/renderer/components/Sidebar.tsx b/src/renderer/components/Sidebar.tsx index 5b07ae7..2738861 100644 --- a/src/renderer/components/Sidebar.tsx +++ b/src/renderer/components/Sidebar.tsx @@ -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: 'โš™๏ธ' }, diff --git a/src/renderer/hooks/useGamepadNav.ts b/src/renderer/hooks/useGamepadNav.ts index 13b322f..3328a3e 100644 --- a/src/renderer/hooks/useGamepadNav.ts +++ b/src/renderer/hooks/useGamepadNav.ts @@ -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>({}); + const [connected, setConnected] = useState(false); + const [showLegend, setShowLegend] = useState(false); + const legendTimer = useRef>(); + + 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( '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(SIDEBAR_SELECTOR); + const items = document.querySelectorAll('.nav-item'); if (items.length === 0) return; const activeIdx = Array.from(items).findIndex(el => el.classList.contains('active') || el === document.activeElement diff --git a/src/renderer/pages/LibraryPage.tsx b/src/renderer/pages/LibraryPage.tsx index fc3515e..ce6ad8f 100644 --- a/src/renderer/pages/LibraryPage.tsx +++ b/src/renderer/pages/LibraryPage.tsx @@ -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 = { nes: '๐Ÿ•น๏ธ', snes: '๐Ÿ•น๏ธ', n64: '๐ŸŽฎ', @@ -13,6 +14,7 @@ export function LibraryPage() { const [games, setGames] = useState([]); const [loading, setLoading] = useState(false); const [romsDir, setRomsDir] = useState(''); + const [selectedGame, setSelectedGame] = useState(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 (
@@ -121,11 +127,11 @@ export function LibraryPage() {
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}`} >
{game.coverUrl ? ( @@ -150,6 +156,13 @@ export function LibraryPage() {
)} + {selectedGame && ( + setSelectedGame(null)} + onLaunch={handleLaunch} + /> + )}
); } diff --git a/src/renderer/pages/SettingsPage.tsx b/src/renderer/pages/SettingsPage.tsx index adec388..bdf7b40 100644 --- a/src/renderer/pages/SettingsPage.tsx +++ b/src/renderer/pages/SettingsPage.tsx @@ -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 = { @@ -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 && ( + + {updateInfo.message} + + )} + {updateInfo?.status === 'error' && !updateInfo.manualLink && ( + `Error: ${updateInfo.message}` + )} {!updateInfo && 'Check GitHub for new releases'}
- {updateInfo?.status !== 'downloaded' ? ( + {updateInfo?.status === 'error' && updateInfo.manualLink ? ( + + ) : updateInfo?.status !== 'downloaded' ? ( + + )} + + {showReleaseNotes && updateInfo?.releaseNotes && ( + - + : JSON.stringify(updateInfo.releaseNotes, null, 2) + } + onClose={() => setShowReleaseNotes(false)} + /> )} diff --git a/src/renderer/pages/UtilitiesPage.tsx b/src/renderer/pages/UtilitiesPage.tsx index e9791c7..582d612 100644 --- a/src/renderer/pages/UtilitiesPage.tsx +++ b/src/renderer/pages/UtilitiesPage.tsx @@ -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 | 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 = { retroarch: 'RetroArch', - dolphin: 'Dolphin', pcsx2: 'PCSX2', duckstation: 'DuckStation', flycast: 'Flycast', + melonds: 'melonDS', }; return ( @@ -66,17 +73,10 @@ export function UtilitiesPage() {

RetroAchievements

- Enable RetroAchievements across all supported emulators. Get your - password from{' '} - - retroachievements.org/settings - - {' '}(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.

@@ -95,14 +95,31 @@ export function UtilitiesPage() {
-
Password / API Key
+
Password
setRaPassword(e.target.value)} - placeholder="your password or Web API Key" + placeholder="your RetroAchievements password" + style={{ width: 240 }} + /> +
+ +
+
+
Web API Key
+
+ From retroachievements.org/settings +
+
+ setRaApiKey(e.target.value)} + placeholder="your Web API Key" style={{ width: 240 }} />
diff --git a/src/renderer/styles.css b/src/renderer/styles.css index ef4b632..5f94d6d 100644 --- a/src/renderer/styles.css +++ b/src/renderer/styles.css @@ -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); } +} diff --git a/src/renderer/types.ts b/src/renderer/types.ts index 3005192..a279670 100644 --- a/src/renderer/types.ts +++ b/src/renderer/types.ts @@ -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; + scrapeArt: (title: string, platform: string) => Promise; + cacheCovers: (entries: { romPath: string; coverUrl: string }[]) => Promise; + scrapeMetadata: (romPath: string, title: string, platform: string) => Promise; + achievements: (romPath: string, title: string, platform: string) => Promise; }; settings: { get: () => Promise; diff --git a/src/renderer/utils/markdown.ts b/src/renderer/utils/markdown.ts new file mode 100644 index 0000000..79fd68e --- /dev/null +++ b/src/renderer/utils/markdown.ts @@ -0,0 +1,89 @@ +export function markdownToHtml(md: string): string { + let html = md; + + html = html.replace(//g, '>'); + + html = html.replace(/```(\w*)\n([\s\S]*?)```/g, (_, _lang, code) => { + return `
${code.trim()}
`; + }); + + html = html.replace(/`([^`]+)`/g, '$1'); + + html = html.replace(/^### (.+)$/gm, '

$1

'); + html = html.replace(/^## (.+)$/gm, '

$1

'); + html = html.replace(/^# (.+)$/gm, '

$1

'); + + html = html.replace(/^- (.+)$/gm, '
  • $1
  • '); + html = html.replace(/^\* (.+)$/gm, '
  • $1
  • '); + html = html.replace(/(
  • .*<\/li>\n?)+/g, '
      $&
    '); + + html = html.replace(/^\d+\. (.+)$/gm, '
  • $1
  • '); + + html = html.replace(/\*\*(.+?)\*\*/g, '$1'); + html = html.replace(/\*(.+?)\*/g, '$1'); + + html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1'); + + html = html.replace(/^---+\s*$/gm, '
    '); + + 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 `${cells.map(c => `<${tag}>${c}`).join('')}`; + }); + + 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('

    '); inParagraph = false; } + if (inList && !nextLine.startsWith('
  • ') && !nextLine.startsWith('')) { + result.push(''); inList = false; + } + continue; + } + + if (line.startsWith(''); inParagraph = false; } + if (inList) { result.push(''); inList = false; } + result.push(line); + if (line.startsWith('')) inTable = false; + continue; + } + + if (line.startsWith('
  • ')) { + if (inParagraph) { result.push('

    '); inParagraph = false; } + if (!inList) { result.push('
      '); inList = true; } + result.push(line); + continue; + } + + if (!inParagraph && !inList && !inTable) { + result.push('

      '); + inParagraph = true; + } + + if (inParagraph) { + result.push(line); + if (!nextLine || nextLine.startsWith('') || nextLine.startsWith('')) { + result.push('

      '); + inParagraph = false; + } + } + } + + if (inParagraph) result.push('

      '); + if (inList) result.push('
    '); + + return result.join('\n'); +} diff --git a/src/shared/types.ts b/src/shared/types.ts index d9a6d83..6e2cec2 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -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 {