diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..8755b82 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,73 @@ +name: Build and Release + +on: + push: + branches: [main] + tags: + - 'v*' + pull_request: + branches: [main] + +jobs: + build: + strategy: + matrix: + include: + - os: macos-latest + arch: x64 + target: mac-x64 + - os: macos-latest + arch: arm64 + target: mac-arm64 + - os: windows-latest + arch: x64 + target: win-x64 + - os: ubuntu-latest + arch: x64 + target: linux-x64 + + runs-on: ${{ matrix.os }} + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Build + run: npm run build + + - name: Package + run: npx electron-builder --${{ matrix.target == 'win-x64' && 'win' || matrix.target == 'mac-x64' && 'mac' || matrix.target == 'mac-arm64' && 'mac' || 'linux' }} --${{ matrix.arch }} + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Upload artifacts + uses: actions/upload-artifact@v4 + with: + name: omniemu-${{ matrix.target }} + path: release/* + + release: + if: startsWith(github.ref, 'refs/tags/v') + needs: [build] + runs-on: ubuntu-latest + permissions: + contents: write + + steps: + - uses: actions/checkout@v4 + + - name: Download all artifacts + uses: actions/download-artifact@v4 + + - name: Create Release + uses: softprops/action-gh-release@v2 + with: + files: omniemu-*/* + generate_release_notes: true diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..344da0f --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +node_modules/ +dist/ +release/ +.vite/ +*.tsbuildinfo +.DS_Store +Thumbs.db +*.log +.env +.env.local diff --git a/package.json b/package.json new file mode 100644 index 0000000..f81a688 --- /dev/null +++ b/package.json @@ -0,0 +1,67 @@ +{ + "name": "omniemu2", + "version": "0.1.0", + "description": "Cross-platform emulator manager, game launcher and ROM manager", + "main": "dist/main/index.js", + "scripts": { + "dev": "concurrently \"npm run dev:main\" \"npm run dev:renderer\"", + "dev:main": "tsc -p tsconfig.main.json --watch", + "dev:renderer": "vite", + "build": "npm run build:renderer && npm run build:main", + "build:main": "tsc -p tsconfig.main.json", + "build:renderer": "vite build", + "start": "electron dist/main/index.js", + "preview": "npm run build && npm run start", + "package:mac": "npm run build && electron-builder --mac", + "package:win": "npm run build && electron-builder --win", + "package:linux": "npm run build && electron-builder --linux", + "package:all": "npm run build && electron-builder --mac --win --linux", + "typecheck": "tsc --noEmit -p tsconfig.main.json && tsc --noEmit -p tsconfig.renderer.json" + }, + "author": "", + "license": "MIT", + "devDependencies": { + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "@vitejs/plugin-react": "^4.4.0", + "concurrently": "^9.1.0", + "electron": "^33.0.0", + "electron-builder": "^25.1.0", + "typescript": "^5.7.0", + "vite": "^6.0.0" + }, + "dependencies": { + "react": "^19.0.0", + "react-dom": "^19.0.0" + }, + "build": { + "appId": "com.omniemu.app", + "productName": "OmniEmu", + "directories": { + "output": "release" + }, + "files": [ + "dist/**/*", + "package.json" + ], + "mac": { + "category": "public.app-category.utilities", + "target": ["dmg", "zip"], + "artifactName": "${productName}-${version}-mac-${arch}.${ext}", + "hardenedRuntime": true + }, + "win": { + "target": ["nsis", "zip"], + "artifactName": "${productName}-${version}-win-${arch}.${ext}" + }, + "nsis": { + "oneClick": false, + "allowToChangeInstallationDirectory": true + }, + "linux": { + "target": ["AppImage", "deb"], + "category": "Utility", + "artifactName": "${productName}-${version}-linux-${arch}.${ext}" + } + } +} diff --git a/scripts/build-linux.sh b/scripts/build-linux.sh new file mode 100644 index 0000000..713994a --- /dev/null +++ b/scripts/build-linux.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Build OmniEmu for Linux +# Usage: ./scripts/build-linux.sh [arch] +# arch: x64 | arm64 (default: current arch) +# Targets: AppImage, deb + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_DIR="$(dirname "$SCRIPT_DIR")" + +cd "$PROJECT_DIR" + +ARCH="${1:-$(uname -m)}" +case "$ARCH" in + x86_64|amd64) ARCH_FLAG="x64" ;; + aarch64) ARCH_FLAG="arm64" ;; + *) echo "Unknown arch: $ARCH"; exit 1 ;; +esac + +echo "==> Installing dependencies..." +npm install + +echo "==> Building for Linux ($ARCH_FLAG)..." +npm run build + +echo "==> Packaging for Linux ($ARCH_FLAG)..." +# Build AppImage and deb +npx electron-builder --linux --${ARCH_FLAG} + +echo "==> Done! Artifacts in ./release/" diff --git a/scripts/build-mac.sh b/scripts/build-mac.sh new file mode 100644 index 0000000..cd73cbe --- /dev/null +++ b/scripts/build-mac.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Build OmniEmu for macOS +# Usage: ./scripts/build-mac.sh [arch] +# arch: x64 | arm64 (default: current arch) + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_DIR="$(dirname "$SCRIPT_DIR")" + +cd "$PROJECT_DIR" + +ARCH="${1:-$(uname -m)}" +case "$ARCH" in + x86_64) ARCH_FLAG="x64" ;; + arm64) ARCH_FLAG="arm64" ;; + *) echo "Unknown arch: $ARCH"; exit 1 ;; +esac + +echo "==> Installing dependencies..." +npm install + +echo "==> Building for macOS ($ARCH_FLAG)..." +npm run build + +echo "==> Packaging for macOS ($ARCH_FLAG)..." +npx electron-builder --mac --arm64="$([ "$ARCH_FLAG" = "arm64" ] && echo true || echo false)" --x64="$([ "$ARCH_FLAG" = "x64" ] && echo true || echo false)" + +echo "==> Done! Artifacts in ./release/" diff --git a/scripts/build-win.ps1 b/scripts/build-win.ps1 new file mode 100644 index 0000000..ac16927 --- /dev/null +++ b/scripts/build-win.ps1 @@ -0,0 +1,27 @@ +# Build OmniEmu for Windows +# Usage: .\scripts\build-win.ps1 [-Arch ] +# Requires: Node.js, npm + +param( + [ValidateSet("x64", "arm64")] + [string]$Arch = "x64" +) + +$ErrorActionPreference = "Stop" +Push-Location (Split-Path $PSScriptRoot -Parent) + +Write-Host "==> Installing dependencies..." -ForegroundColor Cyan +npm install + +Write-Host "==> Building for Windows ($Arch)..." -ForegroundColor Cyan +npm run build + +Write-Host "==> Packaging for Windows ($Arch)..." -ForegroundColor Cyan +if ($Arch -eq "arm64") { + npx electron-builder --win --arm64 +} else { + npx electron-builder --win --x64 +} + +Write-Host "==> Done! Artifacts in ./release/" -ForegroundColor Green +Pop-Location diff --git a/scripts/install.sh b/scripts/install.sh new file mode 100644 index 0000000..1b2278f --- /dev/null +++ b/scripts/install.sh @@ -0,0 +1,59 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Cross-platform install script for OmniEmu +# Detects OS/arch and downloads the appropriate artifact from GitHub releases + +REPO="mileswolfallen2/OmniEmu2.0" +VERSION="${1:-latest}" + +die() { + echo "Error: $*" >&2 + exit 1 +} + +detect_platform() { + local os arch + + case "$(uname -s)" in + Darwin) os="mac" ;; + Linux) os="linux" ;; + *) die "Unsupported OS: $(uname -s)" ;; + esac + + case "$(uname -m)" in + x86_64|amd64) arch="x64" ;; + arm64|aarch64) arch="arm64" ;; + *) die "Unsupported arch: $(uname -m)" ;; + esac + + echo "${os}-${arch}" +} + +main() { + local platform + platform=$(detect_platform) + echo "==> Detected platform: $platform" + + echo "==> Downloading OmniEmu for $platform..." + + local url + if [ "$VERSION" = "latest" ]; then + url="https://github.com/$REPO/releases/latest/download/OmniEmu-${platform}.AppImage" + else + url="https://github.com/$REPO/releases/download/v${VERSION}/OmniEmu-${platform}.AppImage" + fi + + local dest="/tmp/OmniEmu.AppImage" + curl -fsSL "$url" -o "$dest" || die "Download failed" + chmod +x "$dest" + + local install_dir="${HOME}/Applications" + mkdir -p "$install_dir" + mv "$dest" "${install_dir}/OmniEmu.AppImage" + + echo "==> Installed to ${install_dir}/OmniEmu.AppImage" + echo "==> Run it with: ${install_dir}/OmniEmu.AppImage" +} + +main diff --git a/src/main/emulators.ts b/src/main/emulators.ts new file mode 100644 index 0000000..82d32da --- /dev/null +++ b/src/main/emulators.ts @@ -0,0 +1,395 @@ +import { execSync, exec, ChildProcess } from 'child_process'; +import { existsSync, mkdirSync } from 'fs'; +import { join } from 'path'; +import { app } from 'electron'; +import { + EmulatorConfig, + EmulatorState, + Platform, + GameEntry, + RomFile, +} from '../shared/types'; +import { getPlatform, getArch, isWindows, isMacOS, isLinux } from './platform'; +import { settings } from './settings'; + +const platform = getPlatform(); + +export const knownEmulators: EmulatorConfig[] = [ + { + id: 'dolphin', + name: 'Dolphin', + description: 'GameCube & Wii emulator', + platforms: ['gc', 'wii'], + defaultPath: { + win32: 'C:\\Program Files\\Dolphin\\Dolphin.exe', + darwin: '/Applications/Dolphin.app/Contents/MacOS/Dolphin', + linux: '/usr/bin/dolphin-emu', + }, + installUrl: { + win32: 'https://dolphin-emu.org/download/', + darwin: 'https://dolphin-emu.org/download/', + linux: null, + }, + installVia: 'download', + supported: true, + }, + { + id: 'rpcs3', + name: 'RPCS3', + description: 'PlayStation 3 emulator', + platforms: ['ps3'], + defaultPath: { + win32: 'C:\\Program Files\\RPCS3\\rpcs3.exe', + darwin: '/Applications/RPCS3.app/Contents/MacOS/rpcs3', + linux: '/usr/bin/rpcs3', + }, + installUrl: { + win32: 'https://rpcs3.net/download', + darwin: null, + linux: null, + }, + installVia: 'download', + supported: true, + }, + { + id: 'yuzu', + name: 'Yuzu', + description: 'Nintendo Switch emulator', + platforms: ['switch'], + defaultPath: { + win32: 'C:\\Program Files\\Yuzu\\yuzu.exe', + darwin: '/Applications/Yuzu.app/Contents/MacOS/yuzu', + linux: '/usr/bin/yuzu', + }, + installUrl: { + win32: 'https://yuzu-emu.org/downloads/', + darwin: null, + linux: null, + }, + installVia: 'download', + supported: false, + }, + { + id: 'ryujinx', + name: 'Ryujinx', + description: 'Nintendo Switch emulator', + platforms: ['switch'], + defaultPath: { + win32: 'C:\\Program Files\\Ryujinx\\Ryujinx.exe', + darwin: '/Applications/Ryujinx.app/Contents/MacOS/Ryujinx', + linux: '/usr/bin/Ryujinx', + }, + installUrl: { + win32: 'https://ryujinx.org/download', + darwin: 'https://ryujinx.org/download', + linux: 'https://ryujinx.org/download', + }, + installVia: 'download', + supported: true, + }, + { + id: 'pcsx2', + name: 'PCSX2', + description: 'PlayStation 2 emulator', + platforms: ['ps2'], + defaultPath: { + win32: 'C:\\Program Files\\PCSX2\\pcsx2.exe', + darwin: '/Applications/PCSX2.app/Contents/MacOS/PCSX2', + linux: '/usr/bin/pcsx2', + }, + installUrl: { + win32: 'https://pcsx2.net/downloads/', + darwin: 'https://pcsx2.net/downloads/', + linux: null, + }, + installVia: 'downloacd', + supported: true, + }, + { + id: 'mame', + name: 'MAME', + description: 'Multi Arcade Machine Emulator', + platforms: ['arcade'], + defaultPath: { + win32: 'C:\\Program Files\\MAME\\mame.exe', + darwin: '/Applications/MAME.app/Contents/MacOS/mame', + linux: '/usr/bin/mame', + }, + installUrl: { + win32: 'https://www.mamedev.org/release.html', + darwin: null, + linux: null, + }, + installVia: 'download', + supported: true, + }, + { + id: 'retroarch', + name: 'RetroArch', + description: 'Multi-system emulator frontend', + platforms: [ + 'nes', 'snes', 'n64', 'gb', 'gba', 'gbc', + 'ps1', 'pce', 'sega-md', 'sega-saturn', 'sega-dc', + ], + defaultPath: { + win32: 'C:\\Program Files\\RetroArch\\retroarch.exe', + darwin: '/Applications/RetroArch.app/Contents/MacOS/RetroArch', + linux: '/usr/bin/retroarch', + }, + installUrl: { + win32: 'https://retroarch.com/?page=platforms', + darwin: 'https://retroarch.com/?page=platforms', + linux: 'https://retroarch.com/?page=platforms', + }, + installVia: 'download', + supported: true, + }, +]; + +function findEmulator(id: string): EmulatorConfig | undefined { + return knownEmulators.find((e) => e.id === id); +} + +function detectEmulatorPath(config: EmulatorConfig): string | undefined { + const path = config.defaultPath[platform]; + if (path && existsSync(path)) return path; + + // Search common alternative paths + const alternatives = alternativePaths(config.id); + for (const alt of alternatives) { + if (existsSync(alt)) return alt; + } + + // Try which/where command + try { + const cmd = isWindows() ? 'where' : 'which'; + const result = execSync(`${cmd} ${config.id} 2>${isWindows() ? 'nul' : '/dev/null'}`) + .toString().trim(); + if (result) return result.split('\n')[0]; + } catch { + // not found via path + } + + return undefined; +} + +function alternativePaths(emulatorId: string): string[] { + const home = require('os').homedir(); + const common: Record = { + dolphin: [ + join(home, 'Applications', 'Dolphin.app', 'Contents', 'MacOS', 'Dolphin'), + '/usr/local/bin/dolphin-emu', + '/snap/bin/dolphin-emu', + ], + retroarch: [ + join(home, 'Applications', 'RetroArch.app', 'Contents', 'MacOS', 'RetroArch'), + '/usr/local/bin/retroarch', + '/snap/bin/retroarch', + ], + }; + return common[emulatorId] || []; +} + +export function checkEmulator(id: string): EmulatorState { + const config = findEmulator(id); + if (!config) { + return { + installed: false, + config: { + id, + name: id, + description: '', + platforms: [], + defaultPath: { win32: '', darwin: '', linux: '' }, + installUrl: { win32: null, darwin: null, linux: null }, + installVia: 'manual', + supported: false, + }, + }; + } + + const path = detectEmulatorPath(config); + let version: string | undefined; + + if (path) { + try { + const result = execSync(`"${path}" --version 2>&1 || "${path}" -v 2>&1`) + .toString().trim().split('\n')[0]; + version = result || undefined; + } catch { + version = undefined; + } + } + + return { + installed: !!path, + version, + path, + config, + }; +} + +export function getAllEmulatorStates(): EmulatorState[] { + return knownEmulators.map((e) => checkEmulator(e.id)); +} + +export function launchGame(emulatorId: string, romPath: string): ChildProcess | null { + const state = checkEmulator(emulatorId); + if (!state.installed || !state.path) return null; + + const emu = findEmulator(emulatorId); + if (!emu) return null; + + const args = launchArgs(emulatorId, romPath); + const cmd = `"${state.path}" ${args}`; + + const proc = exec(cmd, { + cwd: require('path').dirname(state.path), + }); + + return proc; +} + +function launchArgs(emulatorId: string, romPath: string): string { + switch (emulatorId) { + case 'dolphin': + return `--exec="${romPath}"`; + case 'rpcs3': + return `"${romPath}"`; + case 'ryujinx': + return `"${romPath}"`; + case 'pcsx2': + return `"${romPath}"`; + case 'mame': + return `"${romPath}"`; + case 'retroarch': + return `-L "${romPath}"`; + default: + return `"${romPath}"`; + } +} + +export function getRomsDirectory(): string { + const s = settings.get(); + if (s.romsDirectory && existsSync(s.romsDirectory)) return s.romsDirectory; + + const home = require('os').homedir(); + const defaultDir = join(home, 'OmniEmu', 'roms'); + if (!existsSync(defaultDir)) { + mkdirSync(defaultDir, { recursive: true }); + } + return defaultDir; +} + +export function getEmulatorsDirectory(): string { + const s = settings.get(); + if (s.emulatorsDirectory && existsSync(s.emulatorsDirectory)) return s.emulatorsDirectory; + + const home = require('os').homedir(); + const defaultDir = join(home, 'OmniEmu', 'emulators'); + if (!existsSync(defaultDir)) { + mkdirSync(defaultDir, { recursive: true }); + } + return defaultDir; +} + +export function scanRoms(directory: string): GameEntry[] { + const { readdirSync, statSync } = require('fs'); + const { extname, basename, join: pathJoin } = require('path'); + + const romExtensions = [ + '.nes', '.sfc', '.smc', '.n64', '.z64', '.v64', + '.gba', '.gb', '.gbc', '.nds', '.iso', '.bin', '.cue', + '.wbfs', '.wad', '.nsp', '.xci', '.pkg', '.chd', + '.gcm', '.gcz', '.rvz', '.m3u', '.ps2', '.cso', + '.rom', '.zip', '.7z', + ]; + + const entries: GameEntry[] = []; + + function scanDir(dir: string) { + try { + const files = readdirSync(dir); + for (const file of files) { + const fullPath = pathJoin(dir, file); + const stat = statSync(fullPath); + if (stat.isDirectory()) { + scanDir(fullPath); + } else { + const ext = extname(file).toLowerCase(); + if (romExtensions.includes(ext)) { + entries.push({ + id: fullPath, + romPath: fullPath, + title: basename(file, ext), + platform: guessPlatform(ext), + emulatorId: guessEmulator(ext), + playCount: 0, + addedAt: new Date().toISOString(), + }); + } + } + } + } catch { + // skip unreadable dirs + } + } + + scanDir(directory); + return entries; +} + +function guessPlatform(ext: string): string { + const map: Record = { + '.nes': 'nes', + '.sfc': 'snes', + '.smc': 'snes', + '.n64': 'n64', + '.z64': 'n64', + '.v64': 'n64', + '.gba': 'gba', + '.gb': 'gb', + '.gbc': 'gbc', + '.nds': 'nds', + '.iso': 'ps1', + '.bin': 'ps1', + '.cue': 'ps1', + '.wbfs': 'wii', + '.wad': 'wii', + '.nsp': 'switch', + '.xci': 'switch', + '.pkg': 'ps3', + '.chd': 'ps1', + '.gcm': 'gc', + '.gcz': 'gc', + '.rvz': 'gc', + '.ps2': 'ps2', + '.cso': 'ps2', + }; + return map[ext] || 'other'; +} + +function guessEmulator(ext: string): string { + const map: Record = { + '.nes': 'retroarch', + '.sfc': 'retroarch', + '.smc': 'retroarch', + '.n64': 'retroarch', + '.z64': 'retroarch', + '.v64': 'retroarch', + '.gba': 'retroarch', + '.gb': 'retroarch', + '.gbc': 'retroarch', + '.wbfs': 'dolphin', + '.wad': 'dolphin', + '.gcm': 'dolphin', + '.gcz': 'dolphin', + '.rvz': 'dolphin', + '.nsp': 'ryujinx', + '.xci': 'ryujinx', + '.pkg': 'rpcs3', + '.ps2': 'pcsx2', + '.cso': 'pcsx2', + }; + return map[ext] || 'retroarch'; +} diff --git a/src/main/index.ts b/src/main/index.ts new file mode 100644 index 0000000..8a4887e --- /dev/null +++ b/src/main/index.ts @@ -0,0 +1,91 @@ +import { app, BrowserWindow, Tray, Menu, nativeImage } from 'electron'; +import { join } from 'path'; +import { registerIpcHandlers } from './ipc'; +import { settings } from './settings'; +import { isMacOS, isWindows } from './platform'; + +let mainWindow: BrowserWindow | null = null; +let tray: Tray | null = null; + +const isDev = !app.isPackaged; + +function createWindow(): void { + mainWindow = new BrowserWindow({ + width: 1200, + height: 800, + minWidth: 900, + minHeight: 600, + title: 'OmniEmu', + backgroundColor: '#1a1a2e', + show: false, + webPreferences: { + preload: join(__dirname, 'preload.js'), + contextIsolation: true, + nodeIntegration: false, + sandbox: false, + }, + }); + + if (isDev) { + mainWindow.loadURL('http://localhost:5173'); + mainWindow.webContents.openDevTools(); + } else { + mainWindow.loadFile(join(__dirname, '../renderer/index.html')); + } + + mainWindow.once('ready-to-show', () => { + mainWindow?.show(); + }); + + mainWindow.on('close', (event) => { + const s = settings.get(); + if (s.closeToTray && tray) { + event.preventDefault(); + mainWindow?.hide(); + } + }); + + mainWindow.on('closed', () => { + mainWindow = null; + }); +} + +function createTray(): void { + const iconSize = isMacOS() ? 16 : 24; + const icon = nativeImage.createEmpty(); + + tray = new Tray(icon); + tray.setToolTip('OmniEmu'); + + const contextMenu = Menu.buildFromTemplate([ + { label: 'Show OmniEmu', click: () => mainWindow?.show() }, + { type: 'separator' }, + { label: 'Quit', click: () => { tray = null; app.quit(); } }, + ]); + + tray.setContextMenu(contextMenu); + tray.on('click', () => mainWindow?.show()); +} + +app.whenReady().then(() => { + registerIpcHandlers(); + createWindow(); + createTray(); + + app.on('activate', () => { + if (BrowserWindow.getAllWindows().length === 0) createWindow(); + else mainWindow?.show(); + }); +}); + +app.on('window-all-closed', () => { + if (tray) { + // Keep app alive in tray + return; + } + if (!isMacOS()) app.quit(); +}); + +app.on('before-quit', () => { + tray = null; +}); diff --git a/src/main/ipc.ts b/src/main/ipc.ts new file mode 100644 index 0000000..ede96f6 --- /dev/null +++ b/src/main/ipc.ts @@ -0,0 +1,69 @@ +import { ipcMain, shell, dialog } from 'electron'; +import { + getAllEmulatorStates, + checkEmulator, + launchGame, + scanRoms, + knownEmulators, + getRomsDirectory, + getEmulatorsDirectory, +} from './emulators'; +import { settings } from './settings'; +import { getSystemInfo, platformName } from './platform'; +import { GameEntry, AppSettings } from '../shared/types'; + +export function registerIpcHandlers(): void { + // System + ipcMain.handle('system:info', () => getSystemInfo()); + ipcMain.handle('system:platform-name', () => platformName()); + + // Emulators + ipcMain.handle('emulators:list', () => knownEmulators); + ipcMain.handle('emulators:states', () => getAllEmulatorStates()); + ipcMain.handle('emulators:check', (_event, id: string) => checkEmulator(id)); + ipcMain.handle('emulators:install-url', (_event, id: string) => { + const emu = knownEmulators.find((e) => e.id === id); + if (emu?.installUrl) { + const platform = getSystemInfo().platform; + const url = emu.installUrl[platform]; + if (url) shell.openExternal(url); + return url; + } + return null; + }); + + // ROMs / Games + ipcMain.handle('roms:scan', (_event, directory?: string) => { + const dir = directory || getRomsDirectory(); + return scanRoms(dir); + }); + + ipcMain.handle('roms:select-directory', async () => { + const result = await dialog.showOpenDialog({ + properties: ['openDirectory'], + }); + if (!result.canceled && result.filePaths.length > 0) { + return result.filePaths[0]; + } + return null; + }); + + // Launch + ipcMain.handle( + 'game:launch', + (_event, emulatorId: string, romPath: string) => { + return !!launchGame(emulatorId, romPath); + } + ); + + // Settings + ipcMain.handle('settings:get', () => settings.get()); + ipcMain.handle('settings:save', (_event, s: Partial) => + settings.save(s) + ); + ipcMain.handle('settings:reset', () => settings.reset()); + + // Paths + ipcMain.handle('paths:roms-directory', () => getRomsDirectory()); + ipcMain.handle('paths:emulators-directory', () => getEmulatorsDirectory()); +} diff --git a/src/main/platform.ts b/src/main/platform.ts new file mode 100644 index 0000000..08ef0a6 --- /dev/null +++ b/src/main/platform.ts @@ -0,0 +1,45 @@ +import { app } from 'electron'; +import { arch as osArch, platform as osPlatform, homedir } from 'os'; +import { Platform, Arch, SystemInfo } from '../shared/types'; + +export function getPlatform(): Platform { + const p = osPlatform(); + if (p === 'win32' || p === 'darwin' || p === 'linux') return p; + return 'linux'; +} + +export function getArch(): Arch { + const a = osArch(); + if (a === 'arm64') return 'arm64'; + return 'x64'; +} + +export function getSystemInfo(): SystemInfo { + return { + platform: getPlatform(), + arch: getArch(), + homeDir: homedir(), + appDataDir: app.getPath('userData'), + }; +} + +export function isWindows(): boolean { + return getPlatform() === 'win32'; +} + +export function isMacOS(): boolean { + return getPlatform() === 'darwin'; +} + +export function isLinux(): boolean { + return getPlatform() === 'linux'; +} + +export function platformName(): string { + const map: Record = { + win32: 'Windows', + darwin: 'macOS', + linux: 'Linux', + }; + return map[getPlatform()]; +} diff --git a/src/main/preload.ts b/src/main/preload.ts new file mode 100644 index 0000000..e6a50ec --- /dev/null +++ b/src/main/preload.ts @@ -0,0 +1,53 @@ +import { contextBridge, ipcRenderer } from 'electron'; +import { + EmulatorConfig, + EmulatorState, + GameEntry, + SystemInfo, + AppSettings, +} from '../shared/types'; + +const api = { + system: { + info: (): Promise => ipcRenderer.invoke('system:info'), + platformName: (): Promise => ipcRenderer.invoke('system:platform-name'), + }, + + emulators: { + list: (): Promise => ipcRenderer.invoke('emulators:list'), + states: (): Promise => ipcRenderer.invoke('emulators:states'), + check: (id: string): Promise => + ipcRenderer.invoke('emulators:check', id), + openInstallUrl: (id: string): Promise => + ipcRenderer.invoke('emulators:install-url', id), + }, + + roms: { + scan: (directory?: string): Promise => + ipcRenderer.invoke('roms:scan', directory), + selectDirectory: (): Promise => + ipcRenderer.invoke('roms:select-directory'), + }, + + game: { + launch: (emulatorId: string, romPath: string): Promise => + ipcRenderer.invoke('game:launch', emulatorId, romPath), + }, + + settings: { + get: (): Promise => ipcRenderer.invoke('settings:get'), + save: (s: Partial): Promise => + ipcRenderer.invoke('settings:save', s), + reset: (): Promise => ipcRenderer.invoke('settings:reset'), + }, + + paths: { + romsDirectory: (): Promise => ipcRenderer.invoke('paths:roms-directory'), + emulatorsDirectory: (): Promise => + ipcRenderer.invoke('paths:emulators-directory'), + }, +}; + +contextBridge.exposeInMainWorld('omni', api); + +export type OmniApi = typeof api; diff --git a/src/main/settings.ts b/src/main/settings.ts new file mode 100644 index 0000000..2445767 --- /dev/null +++ b/src/main/settings.ts @@ -0,0 +1,58 @@ +import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs'; +import { join, dirname } from 'path'; +import { app } from 'electron'; +import { AppSettings } from '../shared/types'; +import { getPlatform } from './platform'; + +const SETTINGS_FILE = 'settings.json'; + +function getSettingsPath(): string { + return join(app.getPath('userData'), SETTINGS_FILE); +} + +const defaultSettings: AppSettings = { + romsDirectory: '', + emulatorsDirectory: '', + theme: 'dark', + minimiseToTray: true, + launchInFullscreen: false, + closeToTray: true, +}; + +let cached: AppSettings | null = null; + +export const settings = { + get(): AppSettings { + if (cached) return { ...cached }; + + const settingsPath = getSettingsPath(); + try { + const data = readFileSync(settingsPath, 'utf-8'); + cached = { ...defaultSettings, ...JSON.parse(data) }; + } catch { + cached = { ...defaultSettings }; + settings.save(cached); + } + + return { ...cached! }; + }, + + save(s: Partial): AppSettings { + const current = settings.get(); + cached = { ...current, ...s }; + + const settingsPath = getSettingsPath(); + const dir = dirname(settingsPath); + if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); + + writeFileSync(settingsPath, JSON.stringify(cached, null, 2), 'utf-8'); + return { ...cached }; + }, + + reset(): AppSettings { + cached = { ...defaultSettings }; + const settingsPath = getSettingsPath(); + writeFileSync(settingsPath, JSON.stringify(cached, null, 2), 'utf-8'); + return { ...cached }; + }, +}; diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx new file mode 100644 index 0000000..d2c19c3 --- /dev/null +++ b/src/renderer/App.tsx @@ -0,0 +1,46 @@ +import React, { useState } from 'react'; +import { Sidebar } from './components/Sidebar'; +import { Dashboard } from './pages/Dashboard'; +import { EmulatorsPage } from './pages/EmulatorsPage'; +import { LibraryPage } from './pages/LibraryPage'; +import { SettingsPage } from './pages/SettingsPage'; + +type Page = 'dashboard' | 'emulators' | 'library' | 'settings'; + +export function App() { + const [currentPage, setCurrentPage] = useState('dashboard'); + + const renderPage = () => { + switch (currentPage) { + case 'dashboard': + return ; + case 'emulators': + return ; + case 'library': + return ; + case 'settings': + return ; + } + }; + + const pageTitle: Record = { + dashboard: 'Dashboard', + emulators: 'Emulators', + library: 'Game Library', + settings: 'Settings', + }; + + return ( +
+ +
+
+

{pageTitle[currentPage]}

+
+
+ {renderPage()} +
+
+
+ ); +} diff --git a/src/renderer/components/Sidebar.tsx b/src/renderer/components/Sidebar.tsx new file mode 100644 index 0000000..c389424 --- /dev/null +++ b/src/renderer/components/Sidebar.tsx @@ -0,0 +1,41 @@ +import React from 'react'; + +type Page = 'dashboard' | 'emulators' | 'library' | 'settings'; + +interface SidebarProps { + currentPage: Page; + onNavigate: (page: Page) => void; +} + +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: 'settings', label: 'Settings', icon: 'โš™๏ธ' }, +]; + +export function Sidebar({ currentPage, onNavigate }: SidebarProps) { + return ( +
+
+

OmniEmu

+

Cross-platform emulator manager

+
+ +
+ v0.1.0 +
+
+ ); +} diff --git a/src/renderer/index.html b/src/renderer/index.html new file mode 100644 index 0000000..9b0ec24 --- /dev/null +++ b/src/renderer/index.html @@ -0,0 +1,13 @@ + + + + + + + OmniEmu + + +
+ + + diff --git a/src/renderer/main.tsx b/src/renderer/main.tsx new file mode 100644 index 0000000..067ae30 --- /dev/null +++ b/src/renderer/main.tsx @@ -0,0 +1,11 @@ +import React from 'react'; +import { createRoot } from 'react-dom/client'; +import { App } from './App'; +import './styles.css'; + +const root = createRoot(document.getElementById('root')!); +root.render( + + + +); diff --git a/src/renderer/pages/Dashboard.tsx b/src/renderer/pages/Dashboard.tsx new file mode 100644 index 0000000..8747658 --- /dev/null +++ b/src/renderer/pages/Dashboard.tsx @@ -0,0 +1,109 @@ +import React, { useEffect, useState } from 'react'; +import type { EmulatorState, SystemInfo } from '../../shared/types'; + +export function Dashboard() { + const [emulators, setEmulators] = useState([]); + const [system, setSystem] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + async function load() { + const [states, info] = await Promise.all([ + window.omni.emulators.states(), + window.omni.system.info(), + ]); + setEmulators(states); + setSystem(info); + setLoading(false); + } + load(); + }, []); + + const installed = emulators.filter((e) => e.installed).length; + const total = emulators.filter((e) => e.config.supported).length; + const gamesCount = 0; // would come from library scan + + if (loading) { + return
Loading dashboard...
; + } + + return ( +
+
+ + {system?.platform === 'darwin' ? '๐ŸŽ' : system?.platform === 'win32' ? '๐ŸชŸ' : '๐Ÿง'} + {system?.platform} ({system?.arch}) + +
+ +
+
+
+

Emulators

+
+

+ {installed} of {total} supported emulators installed +

+
+ {Array.from({ length: total }).map((_, i) => ( + + ))} +
+
+ +
+
+

Game Library

+
+

+ {gamesCount} +

+

games in your library

+
+ +
+
+

Platform

+
+

+ {system?.arch === 'arm64' ? 'ARM64' : 'x86-64'} architecture +

+

+ Running on {system?.platform === 'darwin' + ? 'macOS' + : system?.platform === 'win32' + ? 'Windows' + : 'Linux'} +

+
+
+ +

+ Quick Actions +

+
+ + +
+
+ ); +} diff --git a/src/renderer/pages/EmulatorsPage.tsx b/src/renderer/pages/EmulatorsPage.tsx new file mode 100644 index 0000000..7612804 --- /dev/null +++ b/src/renderer/pages/EmulatorsPage.tsx @@ -0,0 +1,105 @@ +import React, { useEffect, useState, useCallback } from 'react'; +import type { EmulatorState } from '../../shared/types'; + +export function EmulatorsPage() { + const [states, setStates] = useState([]); + const [loading, setLoading] = useState(true); + + const load = useCallback(async () => { + setLoading(true); + const result = await window.omni.emulators.states(); + setStates(result); + setLoading(false); + }, []); + + useEffect(() => { load(); }, [load]); + + const handleInstall = async (id: string) => { + await window.omni.emulators.openInstallUrl(id); + }; + + const handleRefresh = () => { + load(); + }; + + if (loading) { + return
Checking emulators...
; + } + + if (states.length === 0) { + return ( +
+
๐Ÿ•น๏ธ
+

No emulators configured

+

Add emulator definitions to get started.

+
+ ); + } + + return ( +
+
+ +
+ +
+ {states.map((state) => ( +
+
+

{state.config.name}

+ + {!state.config.supported + ? 'Unsupported' + : state.installed + ? 'Installed' + : 'Not installed'} + +
+ +

{state.config.description}

+ +
+ {state.config.platforms.map((p) => ( + + {p} + + ))} +
+ + {state.version && ( +

+ Version: {state.version} +

+ )} + +
+ {!state.installed && state.config.installUrl && ( + + )} + {state.installed && state.path && ( + + {state.path} + + )} +
+
+ ))} +
+
+ ); +} diff --git a/src/renderer/pages/LibraryPage.tsx b/src/renderer/pages/LibraryPage.tsx new file mode 100644 index 0000000..df0e297 --- /dev/null +++ b/src/renderer/pages/LibraryPage.tsx @@ -0,0 +1,106 @@ +import React, { useEffect, useState, useCallback } from 'react'; +import type { GameEntry } from '../../shared/types'; + +const platformIcons: Record = { + nes: '๐Ÿ•น๏ธ', + snes: '๐Ÿ•น๏ธ', + n64: '๐ŸŽฎ', + gba: '๐ŸŽฎ', + gbc: '๐ŸŽฎ', + ps1: '๐Ÿ’ฟ', + ps2: '๐Ÿ’ฟ', + ps3: '๐Ÿ’ฟ', + wii: '๐Ÿ“€', + gc: '๐Ÿ“€', + switch: '๐Ÿ•น๏ธ', + arcade: '๐Ÿ•น๏ธ', +}; + +export function LibraryPage() { + const [games, setGames] = useState([]); + const [loading, setLoading] = useState(false); + const [selectedDir, setSelectedDir] = useState(null); + + const handleScan = useCallback(async () => { + const dir = await window.omni.roms.selectDirectory(); + if (!dir) return; + setSelectedDir(dir); + setLoading(true); + const results = await window.omni.roms.scan(dir); + setGames(results); + setLoading(false); + }, []); + + const handleLaunch = async (game: GameEntry) => { + await window.omni.game.launch(game.emulatorId, game.romPath); + }; + + const handleOpenDirectory = async () => { + const dir = await window.omni.roms.selectDirectory(); + if (!dir) return; + setSelectedDir(dir); + setLoading(true); + const results = await window.omni.roms.scan(dir); + setGames(results); + setLoading(false); + }; + + return ( +
+
+ + {selectedDir && ( + + {selectedDir} + + )} +
+ + {loading &&
Scanning for games...
} + + {!loading && games.length === 0 && ( +
+
๐Ÿ“‚
+

Click to select a ROM directory

+

+ Supported formats: .nes, .sfc, .n64, .gba, .iso, .wbfs, .nsp, .ps2, and more +

+
+ )} + + {games.length > 0 && ( +
+
+ {games.length} games found + {(games.length / 10).toFixed(1)} GB estimated +
+ +
+ {games.map((game) => ( +
handleLaunch(game)} + title={`Launch ${game.title} via ${game.emulatorId}`} + > +
+ {platformIcons[game.platform] || '๐ŸŽฎ'} +
+
+
{game.title}
+
+ {game.platform.toUpperCase()} + {' ยท '} + {game.emulatorId} +
+
+
+ ))} +
+
+ )} +
+ ); +} diff --git a/src/renderer/pages/SettingsPage.tsx b/src/renderer/pages/SettingsPage.tsx new file mode 100644 index 0000000..3f62c91 --- /dev/null +++ b/src/renderer/pages/SettingsPage.tsx @@ -0,0 +1,150 @@ +import React, { useEffect, useState } from 'react'; +import type { AppSettings } from '../../shared/types'; + +export function SettingsPage() { + const [settings, setSettings] = useState(null); + const [saving, setSaving] = useState(false); + + useEffect(() => { + window.omni.settings.get().then(setSettings); + }, []); + + const update = async (partial: Partial) => { + if (!settings) return; + setSaving(true); + const updated = await window.omni.settings.save(partial); + setSettings(updated); + setSaving(false); + }; + + if (!settings) { + return
Loading settings...
; + } + + return ( +
+
+

Directories

+ +
+
+
ROMs Directory
+
+ {settings.romsDirectory || 'Default (~/OmniEmu/roms)'} +
+
+ +
+ +
+
+
Emulators Directory
+
+ {settings.emulatorsDirectory || 'Default (~/OmniEmu/emulators)'} +
+
+ +
+
+ +
+

Appearance

+ +
+
+
Theme
+
Application color scheme
+
+ +
+
+ +
+

Behavior

+ +
+
+
Minimise to tray
+
+ Minimise to system tray instead of taskbar +
+
+ +
+ +
+
+
Close to tray
+
+ Closing the window keeps the app running in the tray +
+
+ +
+ +
+
+
Launch in fullscreen
+
Start the app in fullscreen mode
+
+ +
+
+ +
+

About

+

+ OmniEmu v0.1.0 ยท Cross-platform emulator manager +
+ Built with Electron + React + TypeScript +

+
+
+ ); +} diff --git a/src/renderer/styles.css b/src/renderer/styles.css new file mode 100644 index 0000000..d284423 --- /dev/null +++ b/src/renderer/styles.css @@ -0,0 +1,525 @@ +*, +*::before, +*::after { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +:root { + --bg-primary: #0f0f1a; + --bg-secondary: #1a1a2e; + --bg-tertiary: #16213e; + --bg-card: #1e1e36; + --bg-hover: #252542; + --text-primary: #e0e0f0; + --text-secondary: #8888aa; + --text-muted: #555577; + --accent: #6c63ff; + --accent-hover: #7b73ff; + --accent-dim: rgba(108, 99, 255, 0.15); + --success: #4ade80; + --warning: #fbbf24; + --error: #f87171; + --border: #2a2a44; + --radius: 8px; + --radius-sm: 4px; + --shadow: 0 2px 8px rgba(0, 0, 0, 0.3); + --transition: 150ms ease; +} + +html, body, #root { + height: 100%; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; + background: var(--bg-primary); + color: var(--text-primary); + overflow: hidden; +} + +/* Layout */ +.app-layout { + display: flex; + height: 100vh; +} + +.sidebar { + width: 240px; + background: var(--bg-secondary); + border-right: 1px solid var(--border); + display: flex; + flex-direction: column; + flex-shrink: 0; +} + +.sidebar-header { + padding: 20px 16px; + border-bottom: 1px solid var(--border); +} + +.sidebar-header h1 { + font-size: 20px; + font-weight: 700; + background: linear-gradient(135deg, var(--accent), #a78bfa); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; +} + +.sidebar-header p { + font-size: 12px; + color: var(--text-muted); + margin-top: 2px; +} + +.sidebar-nav { + flex: 1; + padding: 12px 8px; + display: flex; + flex-direction: column; + gap: 2px; +} + +.nav-item { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 12px; + border-radius: var(--radius); + border: none; + background: transparent; + color: var(--text-secondary); + font-size: 14px; + cursor: pointer; + transition: all var(--transition); + width: 100%; + text-align: left; +} + +.nav-item:hover { + background: var(--bg-hover); + color: var(--text-primary); +} + +.nav-item.active { + background: var(--accent-dim); + color: var(--accent-hover); + font-weight: 600; +} + +.nav-icon { + font-size: 18px; + width: 24px; + text-align: center; +} + +.sidebar-footer { + padding: 12px 16px; + border-top: 1px solid var(--border); + font-size: 12px; + color: var(--text-muted); +} + +/* Main content */ +.main-content { + flex: 1; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.topbar { + display: flex; + align-items: center; + justify-content: space-between; + padding: 16px 24px; + border-bottom: 1px solid var(--border); + background: var(--bg-secondary); +} + +.topbar h2 { + font-size: 18px; + font-weight: 600; +} + +.page-content { + flex: 1; + padding: 24px; + overflow-y: auto; +} + +/* Cards */ +.card-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: 16px; +} + +.card { + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 20px; + transition: all var(--transition); +} + +.card:hover { + border-color: var(--accent); + transform: translateY(-1px); + box-shadow: var(--shadow); +} + +.card-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 12px; +} + +.card h3 { + font-size: 16px; + font-weight: 600; +} + +.card p { + font-size: 13px; + color: var(--text-secondary); + line-height: 1.5; +} + +/* Badge */ +.badge { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 2px 8px; + border-radius: 999px; + font-size: 11px; + font-weight: 600; +} + +.badge-installed { + background: rgba(74, 222, 128, 0.15); + color: var(--success); +} + +.badge-missing { + background: rgba(248, 113, 113, 0.15); + color: var(--error); +} + +.badge-unsupported { + background: rgba(251, 191, 36, 0.15); + color: var(--warning); +} + +/* Buttons */ +.btn { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 8px 16px; + border-radius: var(--radius); + border: none; + font-size: 13px; + font-weight: 500; + cursor: pointer; + transition: all var(--transition); +} + +.btn-primary { + background: var(--accent); + color: white; +} + +.btn-primary:hover { + background: var(--accent-hover); +} + +.btn-secondary { + background: var(--bg-tertiary); + color: var(--text-primary); + border: 1px solid var(--border); +} + +.btn-secondary:hover { + background: var(--bg-hover); + border-color: var(--accent); +} + +.btn-sm { + padding: 4px 10px; + font-size: 12px; +} + +.btn-icon { + width: 32px; + height: 32px; + padding: 0; + display: flex; + align-items: center; + justify-content: center; + border-radius: var(--radius); + border: 1px solid var(--border); + background: transparent; + color: var(--text-secondary); + cursor: pointer; + transition: all var(--transition); +} + +.btn-icon:hover { + background: var(--bg-hover); + color: var(--text-primary); +} + +/* Game library */ +.library-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); + gap: 16px; +} + +.game-card { + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: var(--radius); + overflow: hidden; + cursor: pointer; + transition: all var(--transition); +} + +.game-card:hover { + border-color: var(--accent); + transform: translateY(-2px); + box-shadow: var(--shadow); +} + +.game-card-cover { + width: 100%; + aspect-ratio: 1; + background: var(--bg-tertiary); + display: flex; + align-items: center; + justify-content: center; + font-size: 40px; + color: var(--text-muted); +} + +.game-card-info { + padding: 12px; +} + +.game-card-title { + font-size: 13px; + font-weight: 600; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.game-card-platform { + font-size: 11px; + color: var(--text-muted); + margin-top: 2px; +} + +/* Scan area */ +.scan-area { + border: 2px dashed var(--border); + border-radius: var(--radius); + padding: 48px; + text-align: center; + cursor: pointer; + transition: all var(--transition); +} + +.scan-area:hover { + border-color: var(--accent); + background: var(--accent-dim); +} + +.scan-area p { + color: var(--text-secondary); + margin-top: 8px; +} + +/* Settings */ +.settings-section { + margin-bottom: 32px; +} + +.settings-section h3 { + font-size: 16px; + font-weight: 600; + margin-bottom: 16px; + padding-bottom: 8px; + border-bottom: 1px solid var(--border); +} + +.setting-row { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 0; +} + +.setting-label { + font-size: 14px; +} + +.setting-desc { + font-size: 12px; + color: var(--text-muted); + margin-top: 2px; +} + +/* Select / Input */ +select, input[type="text"] { + background: var(--bg-tertiary); + color: var(--text-primary); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + padding: 8px 12px; + font-size: 13px; + outline: none; + transition: border var(--transition); +} + +select:focus, input[type="text"]:focus { + border-color: var(--accent); +} + +/* Toggle */ +.toggle { + position: relative; + width: 44px; + height: 24px; + cursor: pointer; +} + +.toggle input { + opacity: 0; + width: 0; + height: 0; +} + +.toggle-slider { + position: absolute; + inset: 0; + background: var(--bg-tertiary); + border-radius: 24px; + transition: all var(--transition); +} + +.toggle-slider::before { + content: ''; + position: absolute; + width: 18px; + height: 18px; + left: 3px; + top: 3px; + background: var(--text-muted); + border-radius: 50%; + transition: all var(--transition); +} + +.toggle input:checked + .toggle-slider { + background: var(--accent); +} + +.toggle input:checked + .toggle-slider::before { + transform: translateX(20px); + background: white; +} + +/* Info bar */ +.info-bar { + display: flex; + align-items: center; + gap: 16px; + padding: 12px 0; + font-size: 13px; + color: var(--text-secondary); +} + +.info-item { + display: flex; + align-items: center; + gap: 6px; +} + +/* Utility */ +.mt-2 { margin-top: 8px; } +.mt-4 { margin-top: 16px; } +.mb-2 { margin-bottom: 8px; } +.mb-4 { margin-bottom: 16px; } +.flex { display: flex; } +.items-center { align-items: center; } +.justify-between { justify-content: space-between; } +.gap-2 { gap: 8px; } +.gap-4 { gap: 16px; } +.text-sm { font-size: 13px; } +.text-muted { color: var(--text-muted); } +.text-center { text-align: center; } + +/* Scrollbar */ +::-webkit-scrollbar { + width: 8px; +} + +::-webkit-scrollbar-track { + background: transparent; +} + +::-webkit-scrollbar-thumb { + background: var(--border); + border-radius: 4px; +} + +::-webkit-scrollbar-thumb:hover { + background: var(--text-muted); +} + +/* Platform tags */ +.platform-tag { + display: inline-block; + padding: 1px 6px; + border-radius: 3px; + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + background: var(--bg-tertiary); + color: var(--text-muted); + margin-right: 4px; + margin-top: 4px; +} + +/* Loading */ +.loading { + display: flex; + align-items: center; + justify-content: center; + height: 200px; + color: var(--text-muted); + font-size: 14px; +} + +/* Empty state */ +.empty-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 300px; + color: var(--text-muted); + text-align: center; +} + +.empty-state-icon { + font-size: 48px; + margin-bottom: 16px; +} + +.empty-state p { + font-size: 14px; + margin-top: 8px; +} diff --git a/src/renderer/types.ts b/src/renderer/types.ts new file mode 100644 index 0000000..f65e5e5 --- /dev/null +++ b/src/renderer/types.ts @@ -0,0 +1,13 @@ +import type { OmniApi } from '../main/preload'; + +declare global { + interface Window { + omni: OmniApi; + } +} + +export interface NavItem { + page: string; + label: string; + icon: string; +} diff --git a/src/shared/types.ts b/src/shared/types.ts new file mode 100644 index 0000000..0e8c329 --- /dev/null +++ b/src/shared/types.ts @@ -0,0 +1,63 @@ +export type Platform = 'win32' | 'darwin' | 'linux'; +export type Arch = 'x64' | 'arm64'; + +export interface EmulatorConfig { + id: string; + name: string; + description: string; + platforms: string[]; + defaultPath: Record; + installUrl: Record; + installVia: 'download' | 'brew' | 'apt' | 'winget' | 'manual'; + supported: boolean; +} + +export interface RomFile { + path: string; + name: string; + size: number; + format: string; + platform: string; + lastPlayed?: string; + playCount: number; +} + +export interface GameEntry { + id: string; + romPath: string; + title: string; + platform: string; + emulatorId: string; + coverUrl?: string; + lastPlayed?: string; + playCount: number; + addedAt: string; +} + +export interface ScanResult { + emulatorId: string; + games: GameEntry[]; +} + +export interface AppSettings { + romsDirectory: string; + emulatorsDirectory: string; + theme: 'light' | 'dark' | 'system'; + minimiseToTray: boolean; + launchInFullscreen: boolean; + closeToTray: boolean; +} + +export interface SystemInfo { + platform: Platform; + arch: Arch; + homeDir: string; + appDataDir: string; +} + +export interface EmulatorState { + installed: boolean; + version?: string; + path?: string; + config: EmulatorConfig; +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..0cbfb28 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true + } +} diff --git a/tsconfig.main.json b/tsconfig.main.json new file mode 100644 index 0000000..f7994b2 --- /dev/null +++ b/tsconfig.main.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "module": "CommonJS", + "outDir": "dist/main", + "rootDir": "src/main", + "types": ["node"] + }, + "include": ["src/main/**/*", "src/shared/**/*"] +} diff --git a/tsconfig.renderer.json b/tsconfig.renderer.json new file mode 100644 index 0000000..2604eaf --- /dev/null +++ b/tsconfig.renderer.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "dist/renderer", + "rootDir": "src/renderer", + "types": ["vite/client"], + "jsx": "react-jsx" + }, + "include": ["src/renderer/**/*", "src/shared/**/*"] +} diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..20f384d --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,19 @@ +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; +import path from 'path'; + +export default defineConfig({ + plugins: [react()], + root: 'src/renderer', + base: './', + build: { + outDir: '../../dist/renderer', + emptyOutDir: true, + }, + resolve: { + alias: { + '@': path.resolve(__dirname, 'src/renderer'), + '@shared': path.resolve(__dirname, 'src/shared'), + }, + }, +});