mirror of
https://github.com/mileswolfallen2/OmniEmu2.0.git
synced 2026-09-08 11:23:17 +00:00
feat: add core functionality for emulator management and game library
- Implement IPC handlers for system info, emulator management, ROM scanning, and settings. - Create platform utilities to retrieve system information. - Develop preload script to expose API to renderer process. - Establish settings management with file read/write capabilities. - Build main application layout with React, including sidebar navigation and multiple pages (Dashboard, Emulators, Library, Settings). - Design and implement styles for a cohesive user interface. - Add type definitions for shared data structures between main and renderer processes. - Configure TypeScript and Vite for building the application.
This commit is contained in:
@@ -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
|
||||||
+10
@@ -0,0 +1,10 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
release/
|
||||||
|
.vite/
|
||||||
|
*.tsbuildinfo
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
*.log
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
@@ -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}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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/"
|
||||||
@@ -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/"
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# Build OmniEmu for Windows
|
||||||
|
# Usage: .\scripts\build-win.ps1 [-Arch <x64|arm64>]
|
||||||
|
# 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
|
||||||
@@ -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
|
||||||
@@ -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<string, string[]> = {
|
||||||
|
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<string, string> = {
|
||||||
|
'.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<string, string> = {
|
||||||
|
'.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';
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
});
|
||||||
@@ -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<AppSettings>) =>
|
||||||
|
settings.save(s)
|
||||||
|
);
|
||||||
|
ipcMain.handle('settings:reset', () => settings.reset());
|
||||||
|
|
||||||
|
// Paths
|
||||||
|
ipcMain.handle('paths:roms-directory', () => getRomsDirectory());
|
||||||
|
ipcMain.handle('paths:emulators-directory', () => getEmulatorsDirectory());
|
||||||
|
}
|
||||||
@@ -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<Platform, string> = {
|
||||||
|
win32: 'Windows',
|
||||||
|
darwin: 'macOS',
|
||||||
|
linux: 'Linux',
|
||||||
|
};
|
||||||
|
return map[getPlatform()];
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { contextBridge, ipcRenderer } from 'electron';
|
||||||
|
import {
|
||||||
|
EmulatorConfig,
|
||||||
|
EmulatorState,
|
||||||
|
GameEntry,
|
||||||
|
SystemInfo,
|
||||||
|
AppSettings,
|
||||||
|
} from '../shared/types';
|
||||||
|
|
||||||
|
const api = {
|
||||||
|
system: {
|
||||||
|
info: (): Promise<SystemInfo> => ipcRenderer.invoke('system:info'),
|
||||||
|
platformName: (): Promise<string> => ipcRenderer.invoke('system:platform-name'),
|
||||||
|
},
|
||||||
|
|
||||||
|
emulators: {
|
||||||
|
list: (): Promise<EmulatorConfig[]> => ipcRenderer.invoke('emulators:list'),
|
||||||
|
states: (): Promise<EmulatorState[]> => ipcRenderer.invoke('emulators:states'),
|
||||||
|
check: (id: string): Promise<EmulatorState> =>
|
||||||
|
ipcRenderer.invoke('emulators:check', id),
|
||||||
|
openInstallUrl: (id: string): Promise<string | null> =>
|
||||||
|
ipcRenderer.invoke('emulators:install-url', id),
|
||||||
|
},
|
||||||
|
|
||||||
|
roms: {
|
||||||
|
scan: (directory?: string): Promise<GameEntry[]> =>
|
||||||
|
ipcRenderer.invoke('roms:scan', directory),
|
||||||
|
selectDirectory: (): Promise<string | null> =>
|
||||||
|
ipcRenderer.invoke('roms:select-directory'),
|
||||||
|
},
|
||||||
|
|
||||||
|
game: {
|
||||||
|
launch: (emulatorId: string, romPath: string): Promise<boolean> =>
|
||||||
|
ipcRenderer.invoke('game:launch', emulatorId, romPath),
|
||||||
|
},
|
||||||
|
|
||||||
|
settings: {
|
||||||
|
get: (): Promise<AppSettings> => ipcRenderer.invoke('settings:get'),
|
||||||
|
save: (s: Partial<AppSettings>): Promise<AppSettings> =>
|
||||||
|
ipcRenderer.invoke('settings:save', s),
|
||||||
|
reset: (): Promise<AppSettings> => ipcRenderer.invoke('settings:reset'),
|
||||||
|
},
|
||||||
|
|
||||||
|
paths: {
|
||||||
|
romsDirectory: (): Promise<string> => ipcRenderer.invoke('paths:roms-directory'),
|
||||||
|
emulatorsDirectory: (): Promise<string> =>
|
||||||
|
ipcRenderer.invoke('paths:emulators-directory'),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
contextBridge.exposeInMainWorld('omni', api);
|
||||||
|
|
||||||
|
export type OmniApi = typeof api;
|
||||||
@@ -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>): 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 };
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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<Page>('dashboard');
|
||||||
|
|
||||||
|
const renderPage = () => {
|
||||||
|
switch (currentPage) {
|
||||||
|
case 'dashboard':
|
||||||
|
return <Dashboard />;
|
||||||
|
case 'emulators':
|
||||||
|
return <EmulatorsPage />;
|
||||||
|
case 'library':
|
||||||
|
return <LibraryPage />;
|
||||||
|
case 'settings':
|
||||||
|
return <SettingsPage />;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const pageTitle: Record<Page, string> = {
|
||||||
|
dashboard: 'Dashboard',
|
||||||
|
emulators: 'Emulators',
|
||||||
|
library: 'Game Library',
|
||||||
|
settings: 'Settings',
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="app-layout">
|
||||||
|
<Sidebar currentPage={currentPage} onNavigate={setCurrentPage} />
|
||||||
|
<div className="main-content">
|
||||||
|
<div className="topbar">
|
||||||
|
<h2>{pageTitle[currentPage]}</h2>
|
||||||
|
</div>
|
||||||
|
<div className="page-content">
|
||||||
|
{renderPage()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div className="sidebar">
|
||||||
|
<div className="sidebar-header">
|
||||||
|
<h1>OmniEmu</h1>
|
||||||
|
<p>Cross-platform emulator manager</p>
|
||||||
|
</div>
|
||||||
|
<nav className="sidebar-nav">
|
||||||
|
{navItems.map((item) => (
|
||||||
|
<button
|
||||||
|
key={item.page}
|
||||||
|
className={`nav-item ${currentPage === item.page ? 'active' : ''}`}
|
||||||
|
onClick={() => onNavigate(item.page)}
|
||||||
|
>
|
||||||
|
<span className="nav-icon">{item.icon}</span>
|
||||||
|
{item.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
<div className="sidebar-footer">
|
||||||
|
v0.1.0
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'"/>
|
||||||
|
<title>OmniEmu</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="./main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -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(
|
||||||
|
<React.StrictMode>
|
||||||
|
<App />
|
||||||
|
</React.StrictMode>
|
||||||
|
);
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import type { EmulatorState, SystemInfo } from '../../shared/types';
|
||||||
|
|
||||||
|
export function Dashboard() {
|
||||||
|
const [emulators, setEmulators] = useState<EmulatorState[]>([]);
|
||||||
|
const [system, setSystem] = useState<SystemInfo | null>(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 <div className="loading">Loading dashboard...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="info-bar">
|
||||||
|
<span className="info-item">
|
||||||
|
{system?.platform === 'darwin' ? '🍎' : system?.platform === 'win32' ? '🪟' : '🐧'}
|
||||||
|
{system?.platform} ({system?.arch})
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card-grid">
|
||||||
|
<div className="card">
|
||||||
|
<div className="card-header">
|
||||||
|
<h3>Emulators</h3>
|
||||||
|
</div>
|
||||||
|
<p>
|
||||||
|
{installed} of {total} supported emulators installed
|
||||||
|
</p>
|
||||||
|
<div style={{ marginTop: 12, display: 'flex', gap: 4 }}>
|
||||||
|
{Array.from({ length: total }).map((_, i) => (
|
||||||
|
<span
|
||||||
|
key={i}
|
||||||
|
style={{
|
||||||
|
width: 8,
|
||||||
|
height: 8,
|
||||||
|
borderRadius: '50%',
|
||||||
|
background: i < installed
|
||||||
|
? 'var(--success)'
|
||||||
|
: 'var(--border)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card">
|
||||||
|
<div className="card-header">
|
||||||
|
<h3>Game Library</h3>
|
||||||
|
</div>
|
||||||
|
<p style={{ fontSize: 32, fontWeight: 700, color: 'var(--accent)' }}>
|
||||||
|
{gamesCount}
|
||||||
|
</p>
|
||||||
|
<p>games in your library</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card">
|
||||||
|
<div className="card-header">
|
||||||
|
<h3>Platform</h3>
|
||||||
|
</div>
|
||||||
|
<p>
|
||||||
|
{system?.arch === 'arm64' ? 'ARM64' : 'x86-64'} architecture
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-muted mt-2">
|
||||||
|
Running on {system?.platform === 'darwin'
|
||||||
|
? 'macOS'
|
||||||
|
: system?.platform === 'win32'
|
||||||
|
? 'Windows'
|
||||||
|
: 'Linux'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 className="mt-4 mb-2" style={{ fontSize: 16, fontWeight: 600 }}>
|
||||||
|
Quick Actions
|
||||||
|
</h3>
|
||||||
|
<div style={{ display: 'flex', gap: 8 }}>
|
||||||
|
<button className="btn btn-primary" onClick={() => {
|
||||||
|
const nav = document.querySelector('[data-nav-emulators]') as HTMLButtonElement;
|
||||||
|
nav?.click();
|
||||||
|
}}>
|
||||||
|
Manage Emulators
|
||||||
|
</button>
|
||||||
|
<button className="btn btn-secondary" onClick={() => {
|
||||||
|
window.omni.roms.selectDirectory();
|
||||||
|
}}>
|
||||||
|
Scan for ROMs
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import React, { useEffect, useState, useCallback } from 'react';
|
||||||
|
import type { EmulatorState } from '../../shared/types';
|
||||||
|
|
||||||
|
export function EmulatorsPage() {
|
||||||
|
const [states, setStates] = useState<EmulatorState[]>([]);
|
||||||
|
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 <div className="loading">Checking emulators...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (states.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="empty-state">
|
||||||
|
<div className="empty-state-icon">🕹️</div>
|
||||||
|
<h3>No emulators configured</h3>
|
||||||
|
<p>Add emulator definitions to get started.</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: 16 }}>
|
||||||
|
<button className="btn btn-secondary btn-sm" onClick={handleRefresh}>
|
||||||
|
Refresh
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card-grid">
|
||||||
|
{states.map((state) => (
|
||||||
|
<div className="card" key={state.config.id}>
|
||||||
|
<div className="card-header">
|
||||||
|
<h3>{state.config.name}</h3>
|
||||||
|
<span
|
||||||
|
className={`badge ${
|
||||||
|
!state.config.supported
|
||||||
|
? 'badge-unsupported'
|
||||||
|
: state.installed
|
||||||
|
? 'badge-installed'
|
||||||
|
: 'badge-missing'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{!state.config.supported
|
||||||
|
? 'Unsupported'
|
||||||
|
: state.installed
|
||||||
|
? 'Installed'
|
||||||
|
: 'Not installed'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p>{state.config.description}</p>
|
||||||
|
|
||||||
|
<div style={{ marginTop: 8 }}>
|
||||||
|
{state.config.platforms.map((p) => (
|
||||||
|
<span className="platform-tag" key={p}>
|
||||||
|
{p}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{state.version && (
|
||||||
|
<p className="text-sm text-muted mt-2">
|
||||||
|
Version: {state.version}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div style={{ marginTop: 12, display: 'flex', gap: 8 }}>
|
||||||
|
{!state.installed && state.config.installUrl && (
|
||||||
|
<button
|
||||||
|
className="btn btn-primary btn-sm"
|
||||||
|
onClick={() => handleInstall(state.config.id)}
|
||||||
|
>
|
||||||
|
Install
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{state.installed && state.path && (
|
||||||
|
<span className="text-sm text-muted" style={{ padding: '4px 0' }}>
|
||||||
|
{state.path}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
import React, { useEffect, useState, useCallback } from 'react';
|
||||||
|
import type { GameEntry } from '../../shared/types';
|
||||||
|
|
||||||
|
const platformIcons: Record<string, string> = {
|
||||||
|
nes: '🕹️',
|
||||||
|
snes: '🕹️',
|
||||||
|
n64: '🎮',
|
||||||
|
gba: '🎮',
|
||||||
|
gbc: '🎮',
|
||||||
|
ps1: '💿',
|
||||||
|
ps2: '💿',
|
||||||
|
ps3: '💿',
|
||||||
|
wii: '📀',
|
||||||
|
gc: '📀',
|
||||||
|
switch: '🕹️',
|
||||||
|
arcade: '🕹️',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function LibraryPage() {
|
||||||
|
const [games, setGames] = useState<GameEntry[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [selectedDir, setSelectedDir] = useState<string | null>(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 (
|
||||||
|
<div>
|
||||||
|
<div style={{ display: 'flex', gap: 8, marginBottom: 16 }}>
|
||||||
|
<button className="btn btn-primary" onClick={handleScan}>
|
||||||
|
Scan for ROMs
|
||||||
|
</button>
|
||||||
|
{selectedDir && (
|
||||||
|
<span className="text-sm text-muted" style={{ padding: '8px 0' }}>
|
||||||
|
{selectedDir}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading && <div className="loading">Scanning for games...</div>}
|
||||||
|
|
||||||
|
{!loading && games.length === 0 && (
|
||||||
|
<div className="scan-area" onClick={handleOpenDirectory}>
|
||||||
|
<div style={{ fontSize: 48 }}>📂</div>
|
||||||
|
<p>Click to select a ROM directory</p>
|
||||||
|
<p className="text-sm text-muted mt-2">
|
||||||
|
Supported formats: .nes, .sfc, .n64, .gba, .iso, .wbfs, .nsp, .ps2, and more
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{games.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<div className="info-bar">
|
||||||
|
<span>{games.length} games found</span>
|
||||||
|
<span>{(games.length / 10).toFixed(1)} GB estimated</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="library-grid">
|
||||||
|
{games.map((game) => (
|
||||||
|
<div
|
||||||
|
className="game-card"
|
||||||
|
key={game.id}
|
||||||
|
onClick={() => handleLaunch(game)}
|
||||||
|
title={`Launch ${game.title} via ${game.emulatorId}`}
|
||||||
|
>
|
||||||
|
<div className="game-card-cover">
|
||||||
|
{platformIcons[game.platform] || '🎮'}
|
||||||
|
</div>
|
||||||
|
<div className="game-card-info">
|
||||||
|
<div className="game-card-title">{game.title}</div>
|
||||||
|
<div className="game-card-platform">
|
||||||
|
{game.platform.toUpperCase()}
|
||||||
|
{' · '}
|
||||||
|
{game.emulatorId}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import type { AppSettings } from '../../shared/types';
|
||||||
|
|
||||||
|
export function SettingsPage() {
|
||||||
|
const [settings, setSettings] = useState<AppSettings | null>(null);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
window.omni.settings.get().then(setSettings);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const update = async (partial: Partial<AppSettings>) => {
|
||||||
|
if (!settings) return;
|
||||||
|
setSaving(true);
|
||||||
|
const updated = await window.omni.settings.save(partial);
|
||||||
|
setSettings(updated);
|
||||||
|
setSaving(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!settings) {
|
||||||
|
return <div className="loading">Loading settings...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="settings-section">
|
||||||
|
<h3>Directories</h3>
|
||||||
|
|
||||||
|
<div className="setting-row">
|
||||||
|
<div>
|
||||||
|
<div className="setting-label">ROMs Directory</div>
|
||||||
|
<div className="setting-desc">
|
||||||
|
{settings.romsDirectory || 'Default (~/OmniEmu/roms)'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className="btn btn-secondary btn-sm"
|
||||||
|
onClick={async () => {
|
||||||
|
const dir = await window.omni.roms.selectDirectory();
|
||||||
|
if (dir) update({ romsDirectory: dir });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Browse
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="setting-row">
|
||||||
|
<div>
|
||||||
|
<div className="setting-label">Emulators Directory</div>
|
||||||
|
<div className="setting-desc">
|
||||||
|
{settings.emulatorsDirectory || 'Default (~/OmniEmu/emulators)'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className="btn btn-secondary btn-sm"
|
||||||
|
onClick={async () => {
|
||||||
|
const dir = await window.omni.roms.selectDirectory();
|
||||||
|
if (dir) update({ emulatorsDirectory: dir });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Browse
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="settings-section">
|
||||||
|
<h3>Appearance</h3>
|
||||||
|
|
||||||
|
<div className="setting-row">
|
||||||
|
<div>
|
||||||
|
<div className="setting-label">Theme</div>
|
||||||
|
<div className="setting-desc">Application color scheme</div>
|
||||||
|
</div>
|
||||||
|
<select
|
||||||
|
value={settings.theme}
|
||||||
|
onChange={(e) =>
|
||||||
|
update({ theme: e.target.value as AppSettings['theme'] })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value="dark">Dark</option>
|
||||||
|
<option value="light">Light</option>
|
||||||
|
<option value="system">System</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="settings-section">
|
||||||
|
<h3>Behavior</h3>
|
||||||
|
|
||||||
|
<div className="setting-row">
|
||||||
|
<div>
|
||||||
|
<div className="setting-label">Minimise to tray</div>
|
||||||
|
<div className="setting-desc">
|
||||||
|
Minimise to system tray instead of taskbar
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<label className="toggle">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={settings.minimiseToTray}
|
||||||
|
onChange={(e) => update({ minimiseToTray: e.target.checked })}
|
||||||
|
/>
|
||||||
|
<span className="toggle-slider" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="setting-row">
|
||||||
|
<div>
|
||||||
|
<div className="setting-label">Close to tray</div>
|
||||||
|
<div className="setting-desc">
|
||||||
|
Closing the window keeps the app running in the tray
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<label className="toggle">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={settings.closeToTray}
|
||||||
|
onChange={(e) => update({ closeToTray: e.target.checked })}
|
||||||
|
/>
|
||||||
|
<span className="toggle-slider" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="setting-row">
|
||||||
|
<div>
|
||||||
|
<div className="setting-label">Launch in fullscreen</div>
|
||||||
|
<div className="setting-desc">Start the app in fullscreen mode</div>
|
||||||
|
</div>
|
||||||
|
<label className="toggle">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={settings.launchInFullscreen}
|
||||||
|
onChange={(e) => update({ launchInFullscreen: e.target.checked })}
|
||||||
|
/>
|
||||||
|
<span className="toggle-slider" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="settings-section">
|
||||||
|
<h3>About</h3>
|
||||||
|
<p className="text-sm text-muted">
|
||||||
|
OmniEmu v0.1.0 · Cross-platform emulator manager
|
||||||
|
<br />
|
||||||
|
Built with Electron + React + TypeScript
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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<Platform, string>;
|
||||||
|
installUrl: Record<Platform, string | null>;
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"module": "CommonJS",
|
||||||
|
"outDir": "dist/main",
|
||||||
|
"rootDir": "src/main",
|
||||||
|
"types": ["node"]
|
||||||
|
},
|
||||||
|
"include": ["src/main/**/*", "src/shared/**/*"]
|
||||||
|
}
|
||||||
@@ -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/**/*"]
|
||||||
|
}
|
||||||
@@ -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'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user