mirror of
https://github.com/mileswolfallen2/OmniEmu2.0.git
synced 2026-09-08 11:23:17 +00:00
still working
This commit is contained in:
@@ -44,6 +44,7 @@
|
||||
"build": {
|
||||
"appId": "com.omniemu.app",
|
||||
"productName": "OmniEmu",
|
||||
"afterPack": "./scripts/adhoc-sign.cjs",
|
||||
"publish": ["github"],
|
||||
"directories": {
|
||||
"output": "release",
|
||||
|
||||
@@ -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
@@ -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--;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
);
|
||||
|
||||
@@ -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
@@ -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
@@ -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(/&/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(/&/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
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 > 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>
|
||||
);
|
||||
}
|
||||
@@ -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: '⚙️' },
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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); }
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
export function markdownToHtml(md: string): string {
|
||||
let html = md;
|
||||
|
||||
html = html.replace(/</g, '<').replace(/>/g, '>');
|
||||
|
||||
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');
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user