diff --git a/README.md b/README.md index 0b0453a..7fc5f01 100644 --- a/README.md +++ b/README.md @@ -1,96 +1,97 @@ + + + OmniEmu + + # OmniEmu -Cross-platform emulator manager and ROM library — for Windows, macOS, and Linux. +**All your games. One place.** + +OmniEmu is a cross-platform emulator manager, game launcher, and ROM library for Windows, macOS, and Linux. Install emulators with one click, scan your ROMs, and launch games from a unified library — no terminal required. + +--- ## Features -- **Emulator Management** — Detect installed emulators, download new ones, manage versions -- **ROM Library** — Scan directories for ROMs, organise by platform, launch games -- **Cross-Platform** — Same experience on Windows, macOS, and Linux (x86-64 & ARM64) -- **Built-in Launcher** — Launch games directly from the library with proper emulator arguments +- **One-Click Emulator Setup** — Download, install, and auto-configure 9 emulators from inside the app +- **ROM Library** — Scan your ROMs, browse by platform, and launch games directly +- **Auto-Update** — Built-in updater keeps OmniEmu current without manual downloads +- **Game Art Scraping** — Automatically fetches covers, screenshots, and titles +- **Controller Navigation** — Navigate the entire UI with a gamepad (DPad, A, B, Start) +- **BIOS Manager** — Scan your BIOS files and update RetroArch's config in one click +- **Light & Dark Mode** — Choose your theme or follow the system preference +- **Cross-Platform** — Identical experience on Windows, macOS, and Linux (x64 & ARM64) ## Supported Emulators -| Emulator | Systems | Windows | macOS | Linux | -|------------|-------------------|---------|-------|-------| -| Dolphin | GameCube, Wii | ✅ | ✅ | ✅ | -| RPCS3 | PlayStation 3 | ✅ | ✅ | ✅ | -| Ryujinx | Nintendo Switch | ✅ | ✅ | ✅ | -| PCSX2 | PlayStation 2 | ✅ | ✅ | ✅ | -| MAME | Arcade | ✅ | ✅ | ✅ | -| RetroArch | Multi-system | ✅ | ✅ | ✅ | +| Emulator | Systems | Windows | macOS | Linux | +|---|---|---|---|---| +| Dolphin | GameCube, Wii | ✅ | ✅ | ✅ | +| RPCS3 | PlayStation 3 | ✅ | ✅ | ✅ | +| Eden | Nintendo Switch | ✅ | ✅ | ✅ | +| PCSX2 | PlayStation 2 | ✅ | ✅ | ✅ | +| MAME | Arcade | ✅ | ✅ | ✅ | +| PPSSPP | PSP | ✅ | ✅ | ✅ | +| DuckStation | PlayStation 1 | ✅ | ✅ | ✅ | +| melonDS | Nintendo DS | ✅ | ✅ | ✅ | +| RetroArch | Multi-system (NES, SNES, N64, GB/GBA, PS1, and more) | ✅ | ✅ | ✅ | -## Development +## Quick Start + +1. Download the latest release for your platform +2. Launch OmniEmu and go to **Library** → install an emulator +3. Go to **Settings** → set your ROMs directory → **Scan** +4. Browse your library and launch any game + +### Downloads + +Download the latest build from the [releases page](https://github.com/mileswolfallen2/OmniEmu2.0/releases). + +**Note:** The `.yml` files you see in releases are metadata used by the built-in auto-updater — you can ignore them. + +### Install Guide + +**macOS** — `OmniEmu-*.dmg`: Drag to Applications. Right-click → Open on first launch (unsigned). +```bash +sudo xattr -rd com.apple.quarantine /Applications/OmniEmu.app +``` + +**Windows** — `OmniEmu-*-win-*.exe`. SmartScreen → More Info → Run anyway. Portable `.zip` versions also available. + +**Linux** — `OmniEmu-*-linux-*.AppImage`: `chmod +x` then `./OmniEmu*.AppImage`. Install `libfuse2` if needed. + +## Building from Source ### Prerequisites - Node.js 20+ (recommended: 22) - npm 10+ -### Setup +### Setup & Run ```bash npm install npm run start ``` -This starts the Vite dev server for the renderer and the TypeScript compiler for the main process in watch mode. - -### Build for production +### Package for Distribution ```bash -npm run build -``` - -### Package for distribution - -```bash -# Current platform +# macOS (universal) npm run package:mac + +# Windows npm run package:win + +# Linux npm run package:linux -# All platforms +# All at once npm run package:all ``` Output goes to `./release/`. -### Platform-specific scripts - -```bash -# macOS (specify arch: x64 or arm64) -./scripts/build-mac.sh arm64 - -# Windows (PowerShell) -.\scripts\build-win.ps1 -Arch x64 - -# Linux -./scripts/build-linux.sh x64 -``` - -## Project Structure - -``` -src/ -├── main/ # Electron main process -│ ├── index.ts # App entry, window creation, tray -│ ├── preload.ts # Context bridge API -│ ├── ipc.ts # IPC handler registration -│ ├── platform.ts # OS/arch detection utilities -│ ├── emulators.ts # Emulator detection, ROM scanning, launching -│ └── settings.ts # Persistent settings -├── renderer/ # React UI (Vite) -│ ├── App.tsx -│ ├── components/ -│ ├── pages/ -│ └── styles.css -└── shared/ # Types shared between main & renderer - └── types.ts -scripts/ # Build and install scripts -.github/workflows/ # CI configuration -``` - ## License MIT diff --git a/package-lock.json b/package-lock.json index 9edacff..67ed49d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,14 +1,16 @@ { "name": "omniemu2", - "version": "0.1.0", + "version": "0.1.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "omniemu2", - "version": "0.1.0", + "version": "0.1.1", + "hasInstallScript": true, "license": "MIT", "dependencies": { + "electron-updater": "^6.8.9", "react": "^19.0.0", "react-dom": "^19.0.0" }, @@ -16,6 +18,7 @@ "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", "@vitejs/plugin-react": "^4.4.0", + "7zip-bin": "^5.2.0", "concurrently": "^9.1.0", "electron": "^43.1.0", "electron-builder": "^25.1.0", @@ -460,29 +463,6 @@ "node": ">=10" } }, - "node_modules/@electron/notarize/node_modules/jsonfile": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", - "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "universalify": "^2.0.0" - }, - "optionalDependencies": { - "graceful-fs": "^4.1.6" - } - }, - "node_modules/@electron/notarize/node_modules/universalify": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", - "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 10.0.0" - } - }, "node_modules/@electron/osx-sign": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/@electron/osx-sign/-/osx-sign-1.3.1.tgz", @@ -505,21 +485,6 @@ "node": ">=12.0.0" } }, - "node_modules/@electron/osx-sign/node_modules/fs-extra": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", - "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "graceful-fs": "^4.2.0", - "jsonfile": "^6.0.1", - "universalify": "^2.0.0" - }, - "engines": { - "node": ">=12" - } - }, "node_modules/@electron/osx-sign/node_modules/isbinaryfile": { "version": "4.0.10", "resolved": "https://registry.npmjs.org/isbinaryfile/-/isbinaryfile-4.0.10.tgz", @@ -533,29 +498,6 @@ "url": "https://github.com/sponsors/gjtorikian/" } }, - "node_modules/@electron/osx-sign/node_modules/jsonfile": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", - "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "universalify": "^2.0.0" - }, - "optionalDependencies": { - "graceful-fs": "^4.1.6" - } - }, - "node_modules/@electron/osx-sign/node_modules/universalify": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", - "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 10.0.0" - } - }, "node_modules/@electron/rebuild": { "version": "3.6.1", "resolved": "https://registry.npmjs.org/@electron/rebuild/-/rebuild-3.6.1.tgz", @@ -585,34 +527,6 @@ "node": ">=12.13.0" } }, - "node_modules/@electron/rebuild/node_modules/fs-extra": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", - "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "graceful-fs": "^4.2.0", - "jsonfile": "^6.0.1", - "universalify": "^2.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/@electron/rebuild/node_modules/jsonfile": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", - "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "universalify": "^2.0.0" - }, - "optionalDependencies": { - "graceful-fs": "^4.1.6" - } - }, "node_modules/@electron/rebuild/node_modules/semver": { "version": "7.8.5", "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", @@ -626,16 +540,6 @@ "node": ">=10" } }, - "node_modules/@electron/rebuild/node_modules/universalify": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", - "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 10.0.0" - } - }, "node_modules/@electron/universal": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/@electron/universal/-/universal-2.0.1.tgz", @@ -687,19 +591,6 @@ "node": ">=14.14" } }, - "node_modules/@electron/universal/node_modules/jsonfile": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", - "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "universalify": "^2.0.0" - }, - "optionalDependencies": { - "graceful-fs": "^4.1.6" - } - }, "node_modules/@electron/universal/node_modules/minimatch": { "version": "9.0.9", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", @@ -716,16 +607,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/@electron/universal/node_modules/universalify": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", - "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 10.0.0" - } - }, "node_modules/@esbuild/aix-ppc64": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", @@ -1383,29 +1264,6 @@ "node": ">=10" } }, - "node_modules/@malept/flatpak-bundler/node_modules/jsonfile": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", - "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "universalify": "^2.0.0" - }, - "optionalDependencies": { - "graceful-fs": "^4.1.6" - } - }, - "node_modules/@malept/flatpak-bundler/node_modules/universalify": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", - "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 10.0.0" - } - }, "node_modules/@npmcli/fs": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/@npmcli/fs/-/fs-2.1.2.tgz", @@ -2250,34 +2108,6 @@ "electron-builder-squirrel-windows": "25.1.8" } }, - "node_modules/app-builder-lib/node_modules/fs-extra": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", - "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "graceful-fs": "^4.2.0", - "jsonfile": "^6.0.1", - "universalify": "^2.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/app-builder-lib/node_modules/jsonfile": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", - "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "universalify": "^2.0.0" - }, - "optionalDependencies": { - "graceful-fs": "^4.1.6" - } - }, "node_modules/app-builder-lib/node_modules/semver": { "version": "7.8.5", "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", @@ -2291,16 +2121,6 @@ "node": ">=10" } }, - "node_modules/app-builder-lib/node_modules/universalify": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", - "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 10.0.0" - } - }, "node_modules/aproba": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/aproba/-/aproba-2.1.0.tgz", @@ -2406,7 +2226,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true, "license": "Python-2.0" }, "node_modules/assert-plus": { @@ -2667,44 +2486,6 @@ "node": ">=12.0.0" } }, - "node_modules/builder-util/node_modules/fs-extra": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", - "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "graceful-fs": "^4.2.0", - "jsonfile": "^6.0.1", - "universalify": "^2.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/builder-util/node_modules/jsonfile": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", - "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "universalify": "^2.0.0" - }, - "optionalDependencies": { - "graceful-fs": "^4.1.6" - } - }, - "node_modules/builder-util/node_modules/universalify": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", - "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 10.0.0" - } - }, "node_modules/cacache": { "version": "16.1.3", "resolved": "https://registry.npmjs.org/cacache/-/cacache-16.1.3.tgz", @@ -3290,7 +3071,6 @@ "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -3443,44 +3223,6 @@ "dmg-license": "^1.0.11" } }, - "node_modules/dmg-builder/node_modules/fs-extra": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", - "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "graceful-fs": "^4.2.0", - "jsonfile": "^6.0.1", - "universalify": "^2.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/dmg-builder/node_modules/jsonfile": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", - "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "universalify": "^2.0.0" - }, - "optionalDependencies": { - "graceful-fs": "^4.1.6" - } - }, - "node_modules/dmg-builder/node_modules/universalify": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", - "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 10.0.0" - } - }, "node_modules/dmg-license": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/dmg-license/-/dmg-license-1.0.11.tgz", @@ -3634,85 +3376,6 @@ "fs-extra": "^10.1.0" } }, - "node_modules/electron-builder-squirrel-windows/node_modules/fs-extra": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", - "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "graceful-fs": "^4.2.0", - "jsonfile": "^6.0.1", - "universalify": "^2.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/electron-builder-squirrel-windows/node_modules/jsonfile": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", - "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "universalify": "^2.0.0" - }, - "optionalDependencies": { - "graceful-fs": "^4.1.6" - } - }, - "node_modules/electron-builder-squirrel-windows/node_modules/universalify": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", - "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">= 10.0.0" - } - }, - "node_modules/electron-builder/node_modules/fs-extra": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", - "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "graceful-fs": "^4.2.0", - "jsonfile": "^6.0.1", - "universalify": "^2.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/electron-builder/node_modules/jsonfile": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", - "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "universalify": "^2.0.0" - }, - "optionalDependencies": { - "graceful-fs": "^4.1.6" - } - }, - "node_modules/electron-builder/node_modules/universalify": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", - "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 10.0.0" - } - }, "node_modules/electron-publish": { "version": "25.1.7", "resolved": "https://registry.npmjs.org/electron-publish/-/electron-publish-25.1.7.tgz", @@ -3729,44 +3392,6 @@ "mime": "^2.5.2" } }, - "node_modules/electron-publish/node_modules/fs-extra": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", - "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "graceful-fs": "^4.2.0", - "jsonfile": "^6.0.1", - "universalify": "^2.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/electron-publish/node_modules/jsonfile": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", - "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "universalify": "^2.0.0" - }, - "optionalDependencies": { - "graceful-fs": "^4.1.6" - } - }, - "node_modules/electron-publish/node_modules/universalify": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", - "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 10.0.0" - } - }, "node_modules/electron-to-chromium": { "version": "1.5.389", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.389.tgz", @@ -3774,6 +3399,47 @@ "dev": true, "license": "ISC" }, + "node_modules/electron-updater": { + "version": "6.8.9", + "resolved": "https://registry.npmjs.org/electron-updater/-/electron-updater-6.8.9.tgz", + "integrity": "sha512-ZhVxM9iGONUpZGI1FxdMRgJjUFXi7AYGVa5PwKlO1tV1/4zDxQmfKpXOHVztKrd6L9rLcFjERvi1Mf2vxyTkig==", + "license": "MIT", + "dependencies": { + "builder-util-runtime": "9.7.0", + "fs-extra": "^10.1.0", + "js-yaml": "^4.1.0", + "lazy-val": "^1.0.5", + "lodash.escaperegexp": "^4.1.2", + "lodash.isequal": "^4.5.0", + "semver": "~7.7.3", + "tiny-typed-emitter": "^2.1.0" + } + }, + "node_modules/electron-updater/node_modules/builder-util-runtime": { + "version": "9.7.0", + "resolved": "https://registry.npmjs.org/builder-util-runtime/-/builder-util-runtime-9.7.0.tgz", + "integrity": "sha512-g/kR520giAFYkSXTzcmF3kqQq7wi8F6N6SzeDgZrqTBN+VHdmgWOyTdD1yD7AATDId/yXLvuP34CxW46/BwCdw==", + "license": "MIT", + "dependencies": { + "debug": "^4.3.4", + "sax": "^1.2.4" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/electron-updater/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", @@ -4065,6 +3731,20 @@ "license": "MIT", "peer": true }, + "node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/fs-minipass": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz", @@ -4302,7 +3982,6 @@ "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "dev": true, "license": "ISC" }, "node_modules/has-flag": { @@ -4690,7 +4369,6 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", - "dev": true, "funding": [ { "type": "github", @@ -4749,6 +4427,18 @@ "node": ">=6" } }, + "node_modules/jsonfile": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -4763,7 +4453,6 @@ "version": "1.0.5", "resolved": "https://registry.npmjs.org/lazy-val/-/lazy-val-1.0.5.tgz", "integrity": "sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q==", - "dev": true, "license": "MIT" }, "node_modules/lazystream": { @@ -4839,6 +4528,12 @@ "license": "MIT", "peer": true }, + "node_modules/lodash.escaperegexp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/lodash.escaperegexp/-/lodash.escaperegexp-4.1.2.tgz", + "integrity": "sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw==", + "license": "MIT" + }, "node_modules/lodash.flatten": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/lodash.flatten/-/lodash.flatten-4.4.0.tgz", @@ -4847,6 +4542,13 @@ "license": "MIT", "peer": true }, + "node_modules/lodash.isequal": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz", + "integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==", + "deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead.", + "license": "MIT" + }, "node_modules/lodash.isplainobject": { "version": "4.0.6", "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", @@ -5200,7 +4902,6 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, "license": "MIT" }, "node_modules/nanoid": { @@ -5989,7 +5690,6 @@ "version": "1.6.0", "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.0.tgz", "integrity": "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==", - "dev": true, "license": "BlueOak-1.0.0", "engines": { "node": ">=11.0.0" @@ -6373,43 +6073,11 @@ "fs-extra": "^10.0.0" } }, - "node_modules/temp-file/node_modules/fs-extra": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", - "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "graceful-fs": "^4.2.0", - "jsonfile": "^6.0.1", - "universalify": "^2.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/temp-file/node_modules/jsonfile": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", - "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "universalify": "^2.0.0" - }, - "optionalDependencies": { - "graceful-fs": "^4.1.6" - } - }, - "node_modules/temp-file/node_modules/universalify": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", - "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 10.0.0" - } + "node_modules/tiny-typed-emitter": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/tiny-typed-emitter/-/tiny-typed-emitter-2.1.0.tgz", + "integrity": "sha512-qVtvMxeXbVej0cQWKqVSSAHmKZEHAvxdF8HEUBFWts8h+xEo5m/lEiPakuyZ3BnCBjOD8i24kzNOiOLLgsSxhA==", + "license": "MIT" }, "node_modules/tinyglobby": { "version": "0.2.17", @@ -6533,6 +6201,15 @@ "node": "^12.13.0 || ^14.15.0 || >=16.0.0" } }, + "node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, "node_modules/update-browserslist-db": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", diff --git a/package.json b/package.json index 9388408..bf87db4 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", "@vitejs/plugin-react": "^4.4.0", + "7zip-bin": "^5.2.0", "concurrently": "^9.1.0", "electron": "^43.1.0", "electron-builder": "^25.1.0", @@ -36,12 +37,14 @@ "vite": "^6.0.0" }, "dependencies": { + "electron-updater": "^6.8.9", "react": "^19.0.0", "react-dom": "^19.0.0" }, "build": { "appId": "com.omniemu.app", "productName": "OmniEmu", + "publish": ["github"], "directories": { "output": "release", "buildResources": "assets" diff --git a/presets.json b/presets.json index be07c81..bb16ce8 100644 --- a/presets.json +++ b/presets.json @@ -41,7 +41,7 @@ "name": "OmniEmu Recommended", "description": "Universal RetroArch config with optimal defaults", "files": { - "retroarch.cfg": "video_driver = \"vulkan\"\naudio_driver = \"pulseaudio\"\nvideo_fullscreen = true\nvideo_vsync = true\nvideo_scale_integer = false\nvideo_smooth = true\naudio_sync = true\nsavestate_thumbnail_enable = true\nnotification_show_autoconfig = false\ninput_autodetect_enable = \"true\"\ninput_player1_joypad_index = \"0\"\n" + "retroarch.cfg": "video_driver = \"vulkan\"\naudio_driver = \"pulseaudio\"\nvideo_fullscreen = true\nvideo_vsync = true\nvideo_scale_integer = false\nvideo_smooth = true\naudio_sync = true\nsavestate_thumbnail_enable = true\nnotification_show_autoconfig = false\ninput_autodetect_enable = \"true\"\ninput_player1_joypad_index = \"0\"\n# Default cores per system\nnes_default_core = \"nestopia_libretro\"\nsnes_default_core = \"snes9x_libretro\"\nn64_default_core = \"mupen64plus_next_libretro\"\ngb_default_core = \"gambatte_libretro\"\ngbc_default_core = \"gambatte_libretro\"\ngba_default_core = \"mgba_libretro\"\nnds_default_core = \"melonds_libretro\"\n" } }, { @@ -69,5 +69,23 @@ "settings.ini": "[General]\nUserMode = 0\nStartFullscreen = True\n[Display]\nRenderToMain = True\nFullscreen = True\nVSync = True\n[GPU]\nRenderer = Vulkan\nResolutionScale = 3\nMultisamples = 1\nPGXPEnable = True\nPGXPCulling = True\nWidescreenHack = True\n[Input]\nControllerBackend = SDL\n[ControllerPort0]\nMultitapPort1 = false\n" } } + ], + "ppsspp": [ + { + "name": "OmniEmu Recommended", + "description": "PPSSPP optimized settings", + "files": { + "ppsspp.ini": "[Graphics]\nBackend = Vulkan\nDisplayRes = Auto\nFrameSkip = 0\nVSync = True\n[System]\nLanguage = en\n[Control]\nShowTouchControls = False\n" + } + } + ], + "melonds": [ + { + "name": "OmniEmu Recommended", + "description": "melonDS optimized settings", + "files": { + "melonDS.ini": "[General]\nfullscreen = 1\n[Video]\nrenderer = OpenGL\nvsync = 1\n[Audio]\nvolume = 100\n[Controls]\n" + } + } ] } diff --git a/src/main/configurator.ts b/src/main/configurator.ts index d0a5f7e..842a6ae 100644 --- a/src/main/configurator.ts +++ b/src/main/configurator.ts @@ -135,6 +135,14 @@ savestate_thumbnail_enable = true notification_show_autoconfig = false input_autodetect_enable = "true" input_player1_joypad_index = "0" +# Default cores per system +nes_default_core = "nestopia_libretro" +snes_default_core = "snes9x_libretro" +n64_default_core = "mupen64plus_next_libretro" +gb_default_core = "gambatte_libretro" +gbc_default_core = "gambatte_libretro" +gba_default_core = "mgba_libretro" +nds_default_core = "melonds_libretro" `, }, }, diff --git a/src/main/emulators.ts b/src/main/emulators.ts index d50df7b..a175f38 100644 --- a/src/main/emulators.ts +++ b/src/main/emulators.ts @@ -1,5 +1,5 @@ import { execSync, exec, ChildProcess } from 'child_process'; -import { existsSync, mkdirSync, readdirSync } from 'fs'; +import { existsSync, mkdirSync, readdirSync, renameSync } from 'fs'; import { join, dirname, basename, extname } from 'path'; import { app } from 'electron'; import { @@ -29,7 +29,7 @@ export const knownEmulators: EmulatorConfig[] = [ downloads: { win32: [ { - url: 'https://dl.dolphin-emu.org/releases/2412/dolphin-2412-x64.7z', + url: 'https://dl.dolphin-emu.org/releases/2606/dolphin-2606-x64.7z', format: '7z', executablePath: 'Dolphin.exe', arch: 'x64', @@ -37,14 +37,8 @@ export const knownEmulators: EmulatorConfig[] = [ ], darwin: [ { - url: 'https://dl.dolphin-emu.org/releases/2412/dolphin-2412-universal.dmg', + url: 'https://dl.dolphin-emu.org/releases/2606/dolphin-2606-universal.dmg', format: 'dmg', - arch: 'arm64', - }, - { - url: 'https://dl.dolphin-emu.org/releases/2412/dolphin-2412-x64.dmg', - format: 'dmg', - arch: 'x64', }, ], linux: [ @@ -75,14 +69,14 @@ export const knownEmulators: EmulatorConfig[] = [ downloads: { win32: [ { - url: 'https://github.com/RPCS3/rpcs3-binaries-win/releases/download/build-d2c3b344332efc6e545a09ad44b9f083c2a1a519/rpcs3-v0.0.34-17491-d2c3b344_win64.7z', + url: 'https://github.com/RPCS3/rpcs3-binaries-win/releases/download/build-4003b017f55f6ba3793469f35927cb5a2815cd22/rpcs3-v0.0.41-19563-4003b017_win64_msvc.7z', format: '7z', executablePath: 'rpcs3.exe', }, ], linux: [ { - url: 'https://github.com/RPCS3/rpcs3-binaries-linux/releases/download/build-d2c3b344332efc6e545a09ad44b9f083c2a1a519/rpcs3-v0.0.34-17491-d2c3b344_linux64.AppImage', + url: 'https://github.com/RPCS3/rpcs3-binaries-linux/releases/download/build-4003b017f55f6ba3793469f35927cb5a2815cd22/rpcs3-v0.0.41-19563-4003b017_linux64.AppImage', format: 'appimage', }, ], @@ -145,25 +139,21 @@ export const knownEmulators: EmulatorConfig[] = [ downloads: { win32: [ { - url: 'https://github.com/PCSX2/pcsx2/releases/download/v2.3.200/pcsx2-v2.3.200-windows-x64-installer.exe', - format: 'exe', + url: 'https://github.com/PCSX2/pcsx2/releases/download/v2.6.3/pcsx2-v2.6.3-windows-x64-Qt.7z', + format: '7z', + executablePath: 'pcsx2-qt.exe', }, ], darwin: [ { - url: 'https://github.com/PCSX2/pcsx2/releases/download/v2.3.200/pcsx2-v2.3.200-macos-arm64.dmg', - format: 'dmg', - arch: 'arm64', - }, - { - url: 'https://github.com/PCSX2/pcsx2/releases/download/v2.3.200/pcsx2-v2.3.200-macos-x64.dmg', - format: 'dmg', - arch: 'x64', + url: 'https://github.com/PCSX2/pcsx2/releases/download/v2.6.3/pcsx2-v2.6.3-macos-Qt.tar.xz', + format: 'tar.xz', + executablePath: 'PCSX2.app/Contents/MacOS/PCSX2', }, ], linux: [ { - url: 'https://github.com/PCSX2/pcsx2/releases/download/v2.3.200/pcsx2-v2.3.200-linux-x86_64.AppImage', + url: 'https://github.com/PCSX2/pcsx2/releases/download/v2.6.3/pcsx2-v2.6.3-linux-appimage-x64-Qt.AppImage', format: 'appimage', }, ], @@ -189,19 +179,21 @@ export const knownEmulators: EmulatorConfig[] = [ downloads: { win32: [ { - url: 'https://github.com/mamedev/mame/releases/download/mame0276/mame0276_64bit.7z', - format: '7z', + url: 'https://github.com/mamedev/mame/releases/download/mame0288/mame0288b_x64.exe', + format: 'exe', + installerType: 'nsis', executablePath: 'mame64.exe', }, ], linux: [ { - url: 'https://github.com/mamedev/mame/releases/download/mame0276/mame0276-x86_64.AppImage', - format: 'appimage', + url: 'https://github.com/mamedev/mame/releases/download/mame0288/mame0288lx.zip', + format: 'zip', + executablePath: 'mame', }, ], }, - packageNames: { linux: 'mame' }, + packageNames: { linux: 'mame', darwin: 'mame' }, supported: true, websiteUrl: { win32: 'https://www.mamedev.org/release.html', @@ -279,7 +271,7 @@ export const knownEmulators: EmulatorConfig[] = [ { url: 'https://buildbot.libretro.com/stable/1.22.2/windows/x86_64/RetroArch.7z', format: '7z', - executablePath: 'RetroArch.exe', + executablePath: 'retroarch.exe', }, { url: 'https://buildbot.libretro.com/stable/1.22.2/windows/x86_64/RetroArch_cores.7z', @@ -325,6 +317,78 @@ export const knownEmulators: EmulatorConfig[] = [ linux: 'https://retroarch.com/?page=platforms', }, }, + { + id: 'ppsspp', + name: 'PPSSPP', + description: 'PlayStation Portable emulator', + platforms: ['psp'], + defaultPath: { + win32: 'C:\\Program Files\\PPSSPP\\PPSSPP.exe', + darwin: '/Applications/PPSSPP.app/Contents/MacOS/PPSSPP', + linux: '/usr/bin/ppsspp', + }, + downloads: { + win32: [ + { + url: 'https://github.com/hrydgard/ppsspp/releases/download/v1.20.4/ppsspp_win64_v1.20.4.7z', + format: '7z', + executablePath: 'PPSSPPWindows64.exe', + }, + ], + darwin: [ + { + url: 'https://github.com/hrydgard/ppsspp/releases/download/v1.20.4/PPSSPPSDL-macOS-v1.20.4.zip', + format: 'zip', + executablePath: 'PPSSPPSDL.app/Contents/MacOS/PPSSPPSDL', + }, + ], + linux: [ + { + url: 'https://github.com/hrydgard/ppsspp/releases/download/v1.20.4/PPSSPPSDL_Linux-x64_v1.20.4.AppImage', + format: 'appimage', + }, + ], + }, + supported: true, + websiteUrl: { + win32: 'https://www.ppsspp.org/download/', + darwin: 'https://www.ppsspp.org/download/', + linux: 'https://www.ppsspp.org/download/', + }, + }, + { + id: 'melonds', + name: 'melonDS', + description: 'Nintendo DS emulator', + platforms: ['nds'], + defaultPath: { + win32: 'C:\\Program Files\\melonDS\\melonDS.exe', + darwin: '/Applications/melonDS.app/Contents/MacOS/melonDS', + linux: '/usr/bin/melonds', + }, + downloads: { + win32: [ + { + url: 'https://github.com/melonDS-emu/melonDS/releases/download/1.1/melonDS-1.1-win64.zip', + format: 'zip', + executablePath: 'melonDS.exe', + }, + ], + linux: [ + { + url: 'https://github.com/melonDS-emu/melonDS/releases/download/1.1/melonDS-1.1-appimage-x86_64.zip', + format: 'zip', + executablePath: 'melonDS-x86_64.AppImage', + }, + ], + }, + supported: true, + websiteUrl: { + win32: 'https://melonds.kuribo64.net/', + darwin: 'https://melonds.kuribo64.net/', + linux: 'https://melonds.kuribo64.net/', + }, + }, ]; export function findEmulator(id: string): EmulatorConfig | undefined { @@ -460,11 +524,15 @@ export function checkEmulator(id: string): EmulatorState { const path = detectEmulatorPath(config); const version = path ? detectVersion(path) : undefined; + const hasDownload = config.downloads[platform] && config.downloads[platform]!.length > 0; + const hasPackage = !!config.packageNames?.[platform]; + const configClone = { ...config, supported: hasDownload || hasPackage }; + return { installed: !!path, version, path, - config, + config: configClone, configured: !!path && existsSync(join(app.getPath('userData'), 'configs', `${id}.configured`)), }; } @@ -573,16 +641,34 @@ function launchArgs(emulatorId: string, romPath: string): string { } } +const defaultRomsDir = () => join(require('os').homedir(), 'Documents', 'roms'); + +const romsSubdirs = [ + 'nes', 'snes', 'n64', 'gb', 'gbc', 'gba', 'nds', + 'ps1', 'ps2', 'ps3', 'psp', 'gc', 'wii', 'switch', + 'arcade', 'pce', 'sega-md', 'sega-saturn', 'sega-dc', 'dreamcast', + 'other', +]; + +export function ensureRomsStructure(romsDir?: string): void { + const dir = romsDir || settings.get().romsDirectory || defaultRomsDir(); + if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); + for (const sub of romsSubdirs) { + const subDir = join(dir, sub); + if (!existsSync(subDir)) mkdirSync(subDir, { recursive: true }); + } +} + 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 }); + if (s.romsDirectory && existsSync(s.romsDirectory)) { + ensureRomsStructure(s.romsDirectory); + return s.romsDirectory; } - return defaultDir; + + const dir = defaultRomsDir(); + ensureRomsStructure(dir); + return dir; } export function getEmulatorsDirectory(): string { @@ -595,15 +681,17 @@ export function getEmulatorsDirectory(): string { } export function scanRoms(directory: string): GameEntry[] { + ensureRomsStructure(directory); 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', + '.rom', '.zip', '.7z', '.gdi', '.pbp', ]; const entries: GameEntry[] = []; + const emuPrefs = settings.get().systemEmulators ?? {}; function scanDir(dir: string) { try { @@ -616,12 +704,27 @@ export function scanRoms(directory: string): GameEntry[] { } else { const ext = extname(file).toLowerCase(); if (romExtensions.includes(ext)) { + const platform = guessPlatform(ext, dir); + const dirName = basename(dir).toLowerCase(); + const inCorrectDir = romsSubdirs.includes(dirName) && (dirHints[dirName] === platform || dirName === platform); + + let finalPath = fullPath; + if (!inCorrectDir) { + const targetDir = join(directory, platform); + if (!existsSync(targetDir)) mkdirSync(targetDir, { recursive: true }); + const targetPath = join(targetDir, file); + try { + renameSync(fullPath, targetPath); + finalPath = targetPath; + } catch { /* skip if move fails */ } + } + entries.push({ - id: fullPath, - romPath: fullPath, + id: finalPath, + romPath: finalPath, title: basename(file, ext), - platform: guessPlatform(ext), - emulatorId: guessEmulator(ext), + platform, + emulatorId: emuPrefs[platform] ?? guessEmulator(ext, platform), playCount: 0, addedAt: new Date().toISOString(), }); @@ -635,17 +738,47 @@ export function scanRoms(directory: string): GameEntry[] { return applyCachedCovers(entries); } -function guessPlatform(ext: string): string { +const dirHints: Record = { + psp: 'psp', ppsspp: 'psp', + dreamcast: 'dreamcast', dc: 'dreamcast', + saturn: 'sega-saturn', 'sega-saturn': 'sega-saturn', 'sega saturn': 'sega-saturn', + ps1: 'ps1', psx: 'ps1', playstation: 'ps1', 'playstation 1': 'ps1', + ps2: 'ps2', 'playstation 2': 'ps2', + ps3: 'ps3', 'playstation 3': 'ps3', + gc: 'gc', gamecube: 'gc', 'game cube': 'gc', + wii: 'wii', + switch: 'switch', nsw: 'switch', + nes: 'nes', famicom: 'nes', + snes: 'snes', 'super nintendo': 'snes', superfamicom: 'snes', sfc: 'snes', + n64: 'n64', 'nintendo 64': 'n64', + gba: 'gba', 'game boy advance': 'gba', + gb: 'gb', 'game boy': 'gb', + gbc: 'gbc', 'game boy color': 'gbc', + nds: 'nds', ds: 'nds', 'nintendo ds': 'nds', + arcade: 'arcade', mame: 'arcade', + 'sega-md': 'sega-md', 'sega genesis': 'sega-md', genesis: 'sega-md', megadrive: 'sega-md', 'mega drive': 'sega-md', + 'sega-dc': 'dreamcast', +}; + +function guessPlatform(ext: string, dirPath?: string): string { + if (dirPath) { + const dirName = basename(dirPath).toLowerCase().replace(/[^a-z0-9\s-]/g, ''); + const hint = dirHints[dirName]; + if (hint) return hint; + } + 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', + '.iso': 'ps1', '.bin': 'ps1', '.cue': 'ps1', '.chd': 'ps1', '.wbfs': 'wii', '.wad': 'wii', '.nsp': 'switch', '.xci': 'switch', - '.pkg': 'ps3', '.chd': 'ps1', + '.pkg': 'ps3', '.gcm': 'gc', '.gcz': 'gc', '.rvz': 'gc', '.ps2': 'ps2', '.cso': 'ps2', + '.gdi': 'dreamcast', + '.pbp': 'psp', }; return map[ext] || 'other'; } @@ -667,7 +800,23 @@ export function uninstallEmulator(id: string): boolean { return removed; } -function guessEmulator(ext: string): string { +function guessEmulator(ext: string, platform?: string): string { + if (platform) { + const platformMap: Record = { + psp: 'ppsspp', + dreamcast: 'retroarch', + 'sega-saturn': 'retroarch', + 'sega-dc': 'retroarch', + 'sega-md': 'retroarch', + pce: 'retroarch', + nes: 'retroarch', snes: 'retroarch', n64: 'retroarch', + gb: 'retroarch', gbc: 'retroarch', gba: 'retroarch', + nds: 'retroarch', + }; + const mapped = platformMap[platform]; + if (mapped) return mapped; + } + const map: Record = { '.nes': 'retroarch', '.sfc': 'retroarch', '.smc': 'retroarch', '.n64': 'retroarch', '.z64': 'retroarch', '.v64': 'retroarch', @@ -677,9 +826,22 @@ function guessEmulator(ext: string): string { '.nsp': 'eden', '.xci': 'eden', '.nca': 'eden', '.pkg': 'rpcs3', '.bin': 'duckstation', '.cue': 'duckstation', '.iso': 'duckstation', - '.img': 'duckstation', '.m3u': 'duckstation', '.pbp': 'duckstation', + '.img': 'duckstation', '.m3u': 'duckstation', '.chd': 'duckstation', '.ecm': 'duckstation', '.mds': 'duckstation', '.ps2': 'pcsx2', '.cso': 'pcsx2', + '.gdi': 'retroarch', + '.pbp': 'ppsspp', }; return map[ext] || 'retroarch'; } + +export function getSystemEmulators(): Record { + const map: Record = {}; + for (const emu of knownEmulators) { + for (const sys of emu.platforms) { + if (!map[sys]) map[sys] = []; + map[sys].push(emu.id); + } + } + return map; +} diff --git a/src/main/index.ts b/src/main/index.ts index 9659a7e..a46d6ff 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -4,6 +4,8 @@ import { existsSync } from 'fs'; import { registerIpcHandlers } from './ipc'; import { settings } from './settings'; import { isMacOS } from './platform'; +import { ensureRomsStructure } from './emulators'; +import { setupAutoUpdater } from './updater'; let mainWindow: BrowserWindow | null = null; let tray: Tray | null = null; @@ -35,6 +37,10 @@ function createAppIcon() { function createWindow(): void { const icon = createAppIcon(); + if (isMacOS()) { + app.dock?.setIcon(icon); + } + mainWindow = new BrowserWindow({ width: 1200, height: 800, @@ -81,6 +87,7 @@ function createWindow(): void { function createTray(): void { const icon = createAppIcon(); const trayIcon = icon.resize({ width: 22, height: 22 }); + if (isMacOS()) trayIcon.setTemplateImage(true); tray = new Tray(trayIcon); tray.setToolTip('OmniEmu'); @@ -110,6 +117,8 @@ if (!gotLock) { } app.whenReady().then(() => { + ensureRomsStructure(); + setupAutoUpdater(); registerIpcHandlers(); createWindow(); createTray(); diff --git a/src/main/installer.ts b/src/main/installer.ts index 1d513c1..cbd31ad 100644 --- a/src/main/installer.ts +++ b/src/main/installer.ts @@ -1,8 +1,8 @@ -import { createWriteStream, existsSync, mkdirSync, readdirSync, statSync } from 'fs'; -import { join, basename, extname, dirname } from 'path'; +import { createWriteStream, existsSync, mkdirSync, readdirSync, statSync, unlinkSync, writeFileSync } from 'fs'; +import { join, basename, dirname } from 'path'; import { get as httpsGet, RequestOptions } from 'https'; import { get as httpGet } from 'http'; -import { execSync } from 'child_process'; +import { spawn, execSync } from 'child_process'; import { pipeline } from 'stream/promises'; import { randomBytes } from 'crypto'; import { app } from 'electron'; @@ -53,20 +53,36 @@ function downloadFile(url: string, dest: string, onProgress: (pct: number) => vo }); } -function execOrThrow(cmd: string): string { +function execOrThrow(cmd: string, timeoutMs = 300000): string { try { - return execSync(cmd, { stdio: 'pipe', timeout: 120000 }).toString(); + return execSync(cmd, { stdio: 'pipe', timeout: timeoutMs }).toString(); } catch (e: any) { throw new Error(`Command failed: ${cmd}\n${e.stderr?.toString() || e.message}`); } } +/** Run a command with args asynchronously with a timeout */ +function runAsync(cmd: string, args: string[], timeoutMs = 300000): Promise { + return new Promise((resolve, reject) => { + const child = spawn(cmd, args, { + stdio: 'pipe', + timeout: timeoutMs, + }); + let stderr = ''; + child.stderr?.on('data', (data: Buffer) => { stderr += data.toString(); }); + child.on('close', (code) => { + if (code === 0) resolve(); + else reject(new Error(`Exit code ${code}: ${stderr}`)); + }); + child.on('error', reject); + }); +} + function findAppInDir(dir: string): string | undefined { const entries = readdirSync(dir, { withFileTypes: true }); const dirs = entries.filter(e => e.isDirectory()); const files = entries.filter(e => e.isFile()); - // Check .app bundles first (before cores or other dirs) for (const e of dirs) { if (e.name.endsWith('.app')) { const macosBin = join(dir, e.name, 'Contents', 'MacOS', basename(e.name, '.app')); @@ -74,7 +90,6 @@ function findAppInDir(dir: string): string | undefined { } } - // Then check known executable files in root for (const e of files) { const lowerName = e.name.toLowerCase(); const knownNames = ['retroarch', 'dolphin-emu', 'dolphin', 'rpcs3', 'ryujinx', 'eden', 'mame', 'mame64', 'pcsx2', 'duckstation']; @@ -83,7 +98,6 @@ function findAppInDir(dir: string): string | undefined { if (isExec) return join(dir, e.name); } - // Recurse into non-.app directories (skip .app bundles) for (const e of dirs) { if (!e.name.endsWith('.app')) { const found = findAppInDir(join(dir, e.name)); @@ -91,7 +105,6 @@ function findAppInDir(dir: string): string | undefined { } } - // Last resort: any executable file in root for (const e of files) { try { if (statSync(join(dir, e.name)).mode & 0o111) return join(dir, e.name); @@ -101,27 +114,40 @@ function findAppInDir(dir: string): string | undefined { return undefined; } -function extractArchive(archivePath: string, destDir: string, format: string, emulatorId: string, onProgress: (msg: string) => void): void { +function get7zaPath(): string { + try { + return require('7zip-bin').path7za; + } catch { + throw new Error('7z extraction requires 7zip-bin package — run: npm install 7zip-bin'); + } +} + +async function extractArchive( + archivePath: string, + destDir: string, + format: string, + emulatorId: string, + onProgress: (msg: string) => void +): Promise { if (!existsSync(destDir)) mkdirSync(destDir, { recursive: true }); onProgress(`Extracting ${basename(archivePath)}...`); switch (format) { case 'zip': - execOrThrow(`unzip -o "${archivePath}" -d "${destDir}"`); + await runAsync('unzip', ['-o', archivePath, '-d', destDir]); break; case 'tar.gz': - execOrThrow(`tar -xzf "${archivePath}" -C "${destDir}"`); + await runAsync('tar', ['-xzf', archivePath, '-C', destDir]); break; case 'tar.bz2': - execOrThrow(`tar -xjf "${archivePath}" -C "${destDir}"`); + await runAsync('tar', ['-xjf', archivePath, '-C', destDir]); + break; + case 'tar.xz': + await runAsync('tar', ['-xJf', archivePath, '-C', destDir]); break; case '7z': { - try { - execOrThrow(`7z x "${archivePath}" -o"${destDir}" -y`); - } catch { - // Try unzip as fallback (some 7z files are actually zip) - execOrThrow(`unzip -o "${archivePath}" -d "${destDir}"`); - } + const sevenZa = get7zaPath(); + await runAsync(sevenZa, ['x', archivePath, `-o${destDir}`, '-y']); break; } case 'dmg': { @@ -129,7 +155,6 @@ function extractArchive(archivePath: string, destDir: string, format: string, em const mountPoint = `/tmp/omniemu_${emulatorId}_${randomBytes(4).toString('hex')}`; try { execOrThrow(`hdiutil attach "${archivePath}" -mountpoint "${mountPoint}" -nobrowse -quiet`); - // Copy any .app bundles found in the mounted volume const result = execSync(`ls "${mountPoint}" 2>/dev/null || true`).toString(); for (const item of result.split('\n').filter(Boolean)) { const src = join(mountPoint, item); @@ -146,7 +171,6 @@ function extractArchive(archivePath: string, destDir: string, format: string, em case 'msi': case 'appimage': case 'pkg': - // These are installers, not archives break; default: throw new Error(`Unknown archive format: ${format}`); @@ -154,13 +178,24 @@ function extractArchive(archivePath: string, destDir: string, format: string, em onProgress('Extraction complete'); } -function runInstaller(installerPath: string, format: string, emulatorId: string, installDir: string, onProgress: (msg: string) => void): void { - onProgress(`Running installer...`); +function runInstaller( + installerPath: string, + format: string, + emulatorId: string, + installDir: string, + installerType?: 'nsis' | 'inno', + onProgress?: (msg: string) => void +): void { + onProgress?.(`Running installer...`); switch (format) { case 'exe': { if (isWindows()) { - execOrThrow(`"${installerPath}" /S /D="${installDir}"`); + if (installerType === 'inno') { + execOrThrow(`"${installerPath}" /VERYSILENT /SUPPRESSMSGBOXES /DIR="${installDir}"`); + } else { + execOrThrow(`"${installerPath}" /S /D="${installDir}"`); + } } else { execOrThrow(`chmod +x "${installerPath}" && "${installerPath}"`); } @@ -183,13 +218,13 @@ function runInstaller(installerPath: string, format: string, emulatorId: string, case 'zip': case 'tar.gz': case 'tar.bz2': + case 'tar.xz': case '7z': - // Already handled in extractArchive break; default: throw new Error(`Cannot install format: ${format}`); } - onProgress('Installer finished'); + onProgress?.('Installer finished'); } export type { ProgressCallback }; @@ -199,10 +234,16 @@ export async function installEmulator( downloads: EmulatorDownload[], platform: Platform, arch: Arch, - onProgress: ProgressCallback + onProgress: ProgressCallback, + packageName?: string, ): Promise { const candidates = downloads.filter((d) => !d.arch || d.arch === arch); - if (candidates.length === 0) throw new Error(`No download available for ${emulatorId} on ${platform} (${arch})`); + if (candidates.length === 0) { + if (packageName) { + return installViaPackageManager(emulatorId, packageName, platform, onProgress); + } + throw new Error(`No download available for ${emulatorId} on ${platform} (${arch})`); + } const report = (stage: InstallProgress['stage'], percent: number, message: string) => { onProgress({ emulatorId, stage, percent, message }); @@ -211,7 +252,7 @@ export async function installEmulator( const installDir = join(app.getPath('userData'), 'emulators', emulatorId); if (!existsSync(installDir)) mkdirSync(installDir, { recursive: true }); - const archiveFormats = ['zip', 'tar.gz', 'tar.bz2', '7z', 'dmg']; + const archiveFormats = ['zip', 'tar.gz', 'tar.bz2', 'tar.xz', '7z', 'dmg']; const installerFormats = ['exe', 'msi', 'pkg', 'appimage']; let totalSteps = candidates.length; @@ -222,25 +263,27 @@ export async function installEmulator( report('downloading', Math.round((completedSteps / totalSteps) * 100), `Downloading ${download.url.split('/').pop()}${stepLabel}...`); const downloadPath = tempName(`.${download.format}`); - await downloadFile(download.url, downloadPath, (pct) => { - report('downloading', Math.round(((completedSteps + pct / 100) / totalSteps) * 100), `Downloading ${download.url.split('/').pop()}${stepLabel}... ${pct}%`); - }); - - if (archiveFormats.includes(download.format)) { - report('extracting', Math.round((completedSteps / totalSteps) * 100), `Extracting ${download.url.split('/').pop()}${stepLabel}...`); - extractArchive(downloadPath, installDir, download.format, emulatorId, (msg) => { - report('extracting', Math.round(((completedSteps + 0.5) / totalSteps) * 100), msg); + try { + await downloadFile(download.url, downloadPath, (pct) => { + report('downloading', Math.round(((completedSteps + pct / 100) / totalSteps) * 100), `Downloading ${download.url.split('/').pop()}${stepLabel}... ${pct}%`); }); - } - if (installerFormats.includes(download.format)) { - report('installing', Math.round((completedSteps / totalSteps) * 100), `Installing ${download.url.split('/').pop()}${stepLabel}...`); - runInstaller(downloadPath, download.format, emulatorId, installDir, (msg) => { - report('installing', Math.round(((completedSteps + 0.5) / totalSteps) * 100), msg); - }); - } + if (archiveFormats.includes(download.format)) { + report('extracting', Math.round((completedSteps / totalSteps) * 100), `Extracting ${download.url.split('/').pop()}${stepLabel}...`); + await extractArchive(downloadPath, installDir, download.format, emulatorId, (msg) => { + report('extracting', Math.round(((completedSteps + 0.5) / totalSteps) * 100), msg); + }); + } - try { execSync(`rm -f "${downloadPath}"`); } catch { /* ignore */ } + if (installerFormats.includes(download.format)) { + report('installing', Math.round((completedSteps / totalSteps) * 100), `Installing ${download.url.split('/').pop()}${stepLabel}...`); + runInstaller(downloadPath, download.format, emulatorId, installDir, download.installerType, (msg) => { + report('installing', Math.round(((completedSteps + 0.5) / totalSteps) * 100), msg); + }); + } + } finally { + try { unlinkSync(downloadPath); } catch { /* ignore */ } + } completedSteps++; } @@ -249,26 +292,57 @@ export async function installEmulator( return installDir; } -/** After install, find the executable in the install dir */ +async function installViaPackageManager( + emulatorId: string, + packageName: string, + platform: Platform, + onProgress: ProgressCallback, +): Promise { + const report = (stage: InstallProgress['stage'], percent: number, message: string) => { + onProgress({ emulatorId, stage, percent, message }); + }; + const installDir = join(app.getPath('userData'), 'emulators', emulatorId); + if (!existsSync(installDir)) mkdirSync(installDir, { recursive: true }); + + if (platform === 'darwin') { + report('installing', 10, `Installing ${packageName} via Homebrew...`); + execOrThrow(`brew install ${packageName}`, 600000); + try { + const binary = execSync(`which ${emulatorId} 2>/dev/null || which ${packageName} 2>/dev/null`).toString().trim(); + if (binary) { + writeFileSync(join(installDir, '.installed'), binary); + } + } catch { /* binary not found via which, ok */ } + report('done', 100, `${emulatorId} installed via Homebrew`); + } else if (platform === 'linux') { + report('installing', 10, `Installing ${packageName} via package manager...`); + try { + execOrThrow(`snap install ${packageName} 2>/dev/null || apt-get install -y ${packageName} 2>/dev/null || echo "fallback"`, 600000); + } catch { /* package manager not available */ } + report('done', 100, `${emulatorId} installed`); + } else { + throw new Error(`Package manager install not supported on ${platform}`); + } + + return installDir; +} + export function findInstalledBinary(emulatorId: string, installDir: string, executablePath?: string): string | undefined { if (!existsSync(installDir)) return undefined; - // Try explicit executablePath first (from download config) if (executablePath) { const explicit = join(installDir, executablePath); if (existsSync(explicit)) return explicit; } - // macOS: check for .app bundle with several naming variants if (isMacOS()) { const appNames = [ - `${capitalize(emulatorId)}.app`, // Retroarch.app - `${emulatorId}.app`, // retroarch.app - `${emulatorId.charAt(0).toUpperCase()}${emulatorId.slice(1).toLowerCase()}.app`, // Retroarch.app + `${capitalize(emulatorId)}.app`, + `${emulatorId}.app`, + `${emulatorId.charAt(0).toUpperCase()}${emulatorId.slice(1).toLowerCase()}.app`, ]; - // Also check common overrides if (emulatorId === 'retroarch') { - appNames.unshift('RetroArch.app'); // actual macOS app name + appNames.unshift('RetroArch.app'); } for (const appName of appNames) { diff --git a/src/main/ipc.ts b/src/main/ipc.ts index d9a5eb5..ec66fb4 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -12,11 +12,14 @@ import { findEmulator, getRomsDirectory, getEmulatorsDirectory, + getSystemEmulators, + ensureRomsStructure, } from './emulators'; import { installEmulator, findInstalledBinary } from './installer'; import { applyRecommendedConfig, getPresets, checkConfigured, applyControllerConfig } from './configurator'; import { settings } from './settings'; import { getSystemInfo, platformName, getPlatform, getArch } from './platform'; +import { checkForUpdates, downloadUpdate, quitAndInstall } from './updater'; import { InstallProgress, AppSettings, GameEntry } from '../shared/types'; import { addRecentGame, parseGameTitle, buildScrapeTitle, findValidThumbnail, cacheCovers } from './scraper'; import { scanBiosDirectory, getKnownBiosList, getDefaultBiosDir, updateRetroarchBiosPath } from './bios'; @@ -27,7 +30,15 @@ export function registerIpcHandlers(): void { ipcMain.handle('system:platform-name', () => platformName()); // Emulators - ipcMain.handle('emulators:list', () => knownEmulators); + ipcMain.handle('emulators:list', () => { + const plat = getPlatform(); + return knownEmulators.filter(e => { + const hasDownload = e.downloads?.[plat] && e.downloads[plat]!.length > 0; + const hasPackage = !!e.packageNames?.[plat]; + return hasDownload || hasPackage; + }); + }); + ipcMain.handle('emulators:system-assignments', () => getSystemEmulators()); ipcMain.handle('emulators:states', () => getAllEmulatorStates()); ipcMain.handle('emulators:check', (_event, id: string) => checkEmulator(id)); @@ -42,7 +53,8 @@ export function registerIpcHandlers(): void { const platform = getPlatform(); const arch = getArch(); const downloads = config.downloads[platform]; - if (!downloads || downloads.length === 0) + const packageName = config.packageNames?.[platform]; + if ((!downloads || downloads.length === 0) && !packageName) throw new Error(`No downloads available for ${emulatorId} on ${platform}`); const win = BrowserWindow.fromWebContents(event.sender); @@ -50,10 +62,10 @@ export function registerIpcHandlers(): void { win?.webContents.send('emulators:install-progress', p); }; - const installDir = await installEmulator(emulatorId, downloads, platform, arch, sendProgress); + const installDir = await installEmulator(emulatorId, downloads ?? [], platform, arch, sendProgress, config.packageNames?.[platform]); // Try to find the binary and create a symlink or record it - const download = downloads.filter((d) => !d.arch || d.arch === arch)[0]; + const download = (downloads ?? []).filter((d) => !d.arch || d.arch === arch)[0]; const binary = findInstalledBinary(emulatorId, installDir, download?.executablePath); if (binary) { const marker = join(installDir, '.installed'); @@ -217,4 +229,26 @@ export function registerIpcHandlers(): void { // Paths ipcMain.handle('paths:roms-directory', () => getRomsDirectory()); ipcMain.handle('paths:emulators-directory', () => getEmulatorsDirectory()); + + // Utilities + ipcMain.handle('utilities:regenerate-roms-structure', () => { + ensureRomsStructure(); + return true; + }); + + // Updates + ipcMain.handle('updates:check', async () => { + await checkForUpdates(); + return true; + }); + + ipcMain.handle('updates:download', async () => { + await downloadUpdate(); + return true; + }); + + ipcMain.handle('updates:quit-and-install', async () => { + quitAndInstall(); + return true; + }); } diff --git a/src/main/preload.ts b/src/main/preload.ts index e35d770..a90244d 100644 --- a/src/main/preload.ts +++ b/src/main/preload.ts @@ -17,6 +17,8 @@ const api = { emulators: { list: (): Promise => ipcRenderer.invoke('emulators:list'), + systemAssignments: (): Promise> => + ipcRenderer.invoke('emulators:system-assignments'), states: (): Promise => ipcRenderer.invoke('emulators:states'), check: (id: string): Promise => ipcRenderer.invoke('emulators:check', id), @@ -101,6 +103,27 @@ const api = { emulatorsDirectory: (): Promise => ipcRenderer.invoke('paths:emulators-directory'), }, + + utilities: { + regenerateRomsStructure: (): Promise => + ipcRenderer.invoke('utilities:regenerate-roms-structure'), + }, + + updates: { + check: (): Promise => ipcRenderer.invoke('updates:check'), + download: (): Promise => ipcRenderer.invoke('updates:download'), + quitAndInstall: (): Promise => ipcRenderer.invoke('updates:quit-and-install'), + onStatus: (cb: (status: Record) => void) => { + const handler = (_event: Electron.IpcRendererEvent, s: Record) => cb(s); + ipcRenderer.on('updates:status', handler); + return () => ipcRenderer.removeListener('updates:status', handler); + }, + onDownloadProgress: (cb: (progress: Record) => void) => { + const handler = (_event: Electron.IpcRendererEvent, p: Record) => cb(p); + ipcRenderer.on('updates:download-progress', handler); + return () => ipcRenderer.removeListener('updates:download-progress', handler); + }, + }, }; contextBridge.exposeInMainWorld('omni', api); diff --git a/src/main/scraper.ts b/src/main/scraper.ts index 552ec90..f416e6d 100644 --- a/src/main/scraper.ts +++ b/src/main/scraper.ts @@ -46,50 +46,90 @@ const platformThumbDir: Record = { 'gc': 'Nintendo_-_GameCube', 'wii': 'Nintendo_-_Wii', 'arcade': 'MAME', + 'dreamcast': 'Sega_-_Dreamcast', }; function safeTitle(title: string): string { return title.replace(/[:]/g, '').replace(/[/\\?*]/g, '_').trim(); } -/** Build a properly URL-encoded thumbnail URL */ function buildEncodedUrl(dir: string, subdir: string, title: string): string { const safe = safeTitle(title); const raw = `${thumbBase}/${dir}/${subdir}/${safe}.png`; return encodeURI(raw); } -/** Try multiple URL patterns via GET and return the first valid URL */ +function buildMobyGamesUrl(title: string): string { + const q = encodeURIComponent(title.trim()); + return `https://www.mobygames.com/search/quick?q=${q}&search=Go`; +} + +async function tryFetchImage(url: string): Promise { + try { + const valid = await urlExists(url); + if (valid) return url; + } catch { /* skip */ } + return undefined; +} + +async function tryMobyGames(title: string): Promise { + const searchUrl = buildMobyGamesUrl(title); + try { + const html = await fetchText(searchUrl); + // Extract first game cover image from search results + const match = html.match(/]*class="[^"]*cover[^"]*"[^>]*src="([^"]+)"/i) + || html.match(/]*src="([^"]+)"[^>]*alt="[^"]*Cover[^"]*"/i); + if (match) { + const imgUrl = match[1].startsWith('http') ? match[1] : `https://www.mobygames.com${match[1]}`; + const valid = await urlExists(imgUrl); + if (valid) return imgUrl; + } + } catch { /* skip */ } + return undefined; +} + +/** Try multiple URL patterns and return the first valid URL */ export async function findValidThumbnail(title: string, platform: string): Promise { + // Source 1: libretro-thumbnails const dir = platformThumbDir[platform]; - if (!dir) return undefined; + if (dir) { + const subdirs = ['Named_Boxarts', 'Named_Snaps', 'Named_Titles', 'Named_Logos']; + const titles = new Set(); - const urls: string[] = []; - const subdirs = ['Named_Boxarts', 'Named_Snaps']; - const titles = new Set(); + titles.add(safeTitle(title)); + // Without region codes + const stripped = title.replace(/\([^)]*\)/g, '').trim(); + const safeStripped = safeTitle(stripped); + if (safeStripped && safeStripped !== safeTitle(title)) titles.add(safeStripped); - // Try raw title (with region codes) - titles.add(safeTitle(title)); - // Try title without region codes - const stripped = title.replace(/\([^)]*\)/g, '').trim(); - const safeStripped = safeTitle(stripped); - if (safeStripped && safeStripped !== safeTitle(title)) titles.add(safeStripped); - - for (const sub of subdirs) { - for (const t of titles) { - urls.push(buildEncodedUrl(dir, sub, t)); + for (const sub of subdirs) { + for (const t of titles) { + const url = buildEncodedUrl(dir, sub, t); + const result = await tryFetchImage(url); + if (result) return result; + } } } - for (const url of urls) { - try { - const valid = await urlExists(url); - if (valid) return url; - } catch { /* try next */ } - } + // Source 2: MobyGames (generic) + const mbResult = await tryMobyGames(title); + if (mbResult) return mbResult; + return undefined; } +function fetchText(url: string): Promise { + return new Promise((resolve, reject) => { + const req = httpsGet(url, { headers: { 'User-Agent': 'OmniEmu/0.1.1' }, timeout: 10000 }, (res) => { + let data = ''; + res.on('data', (chunk: string) => data += chunk); + res.on('end', () => resolve(data)); + }); + req.on('error', reject); + req.on('timeout', () => { req.destroy(); reject(new Error('timeout')); }); + }); +} + function urlExists(url: string): Promise { return new Promise((resolve) => { const req = httpsGet(url, { diff --git a/src/main/settings.ts b/src/main/settings.ts index 8cc7f93..11cd333 100644 --- a/src/main/settings.ts +++ b/src/main/settings.ts @@ -9,8 +9,17 @@ function getSettingsPath(): string { return join(app.getPath('userData'), SETTINGS_FILE); } +function defaultRomsDir(): string { + try { + const { homedir } = require('os'); + return join(homedir(), 'Documents', 'roms'); + } catch { + return join(app.getPath('userData'), 'roms'); + } +} + const defaultSettings: AppSettings = { - romsDirectory: '', + romsDirectory: defaultRomsDir(), emulatorsDirectory: '', theme: 'dark', minimiseToTray: true, diff --git a/src/main/updater.ts b/src/main/updater.ts new file mode 100644 index 0000000..8039519 --- /dev/null +++ b/src/main/updater.ts @@ -0,0 +1,65 @@ +import { autoUpdater } from 'electron-updater'; +import { BrowserWindow } from 'electron'; + +autoUpdater.autoDownload = false; +autoUpdater.autoInstallOnAppQuit = false; + +export function setupAutoUpdater() { + autoUpdater.on('checking-for-update', () => { + sendToWindows('updates:status', { status: 'checking' }); + }); + + autoUpdater.on('update-available', (info) => { + sendToWindows('updates:status', { + status: 'available', + version: info.version, + releaseDate: info.releaseDate, + releaseNotes: info.releaseNotes, + }); + }); + + autoUpdater.on('update-not-available', (info) => { + sendToWindows('updates:status', { + status: 'not-available', + version: info.version, + }); + }); + + autoUpdater.on('error', (err) => { + sendToWindows('updates:status', { status: 'error', message: err.message }); + }); + + autoUpdater.on('download-progress', (progress) => { + sendToWindows('updates:download-progress', { + percent: Math.round(progress.percent), + bytesPerSecond: progress.bytesPerSecond, + transferred: progress.transferred, + total: progress.total, + }); + }); + + autoUpdater.on('update-downloaded', (info) => { + sendToWindows('updates:status', { + status: 'downloaded', + version: info.version, + }); + }); +} + +function sendToWindows(channel: string, data: unknown) { + BrowserWindow.getAllWindows().forEach((w) => w.webContents.send(channel, data)); +} + +export function checkForUpdates() { + return autoUpdater.checkForUpdates(); +} + +export function downloadUpdate() { + return autoUpdater.downloadUpdate(); +} + +export function quitAndInstall() { + autoUpdater.quitAndInstall(); +} + +export { autoUpdater }; diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 02442e0..305b989 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -1,18 +1,32 @@ -import React, { useState } from 'react'; +import React, { useState, useEffect } 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'; import { ControllerPage } from './pages/ControllerPage'; +import { UtilitiesPage } from './pages/UtilitiesPage'; import { useGamepadNav } from './hooks/useGamepadNav'; -type Page = 'dashboard' | 'emulators' | 'library' | 'settings' | 'controller'; +type Page = 'dashboard' | 'emulators' | 'library' | 'settings' | 'controller' | 'utilities'; + +export function applyTheme(theme: string) { + if (theme === 'system') { + const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches; + document.documentElement.setAttribute('data-theme', prefersDark ? 'dark' : 'light'); + } else { + document.documentElement.setAttribute('data-theme', theme); + } +} export function App() { useGamepadNav(); const [currentPage, setCurrentPage] = useState('dashboard'); + useEffect(() => { + window.omni.settings.get().then((s) => applyTheme(s.theme)); + }, []); + const renderPage = () => { switch (currentPage) { case 'dashboard': @@ -25,6 +39,8 @@ export function App() { return ; case 'controller': return ; + case 'utilities': + return ; } }; @@ -34,6 +50,7 @@ export function App() { library: 'Game Library', settings: 'Settings', controller: 'Controller', + utilities: 'Utilities', }; return ( diff --git a/src/renderer/components/Sidebar.tsx b/src/renderer/components/Sidebar.tsx index 337bd6c..5f6de84 100644 --- a/src/renderer/components/Sidebar.tsx +++ b/src/renderer/components/Sidebar.tsx @@ -1,6 +1,6 @@ import React from 'react'; -type Page = 'dashboard' | 'emulators' | 'library' | 'settings' | 'controller'; +type Page = 'dashboard' | 'emulators' | 'library' | 'settings' | 'controller' | 'utilities'; interface SidebarProps { currentPage: Page; @@ -12,6 +12,7 @@ const navItems: { page: Page; label: string; icon: string }[] = [ { page: 'emulators', label: 'Emulators', icon: '🕹️' }, { page: 'library', label: 'Game Library', icon: '📚' }, { page: 'controller', label: 'Controller', icon: '🎮' }, + { page: 'utilities', label: 'Utilities', icon: '🔧' }, { page: 'settings', label: 'Settings', icon: '⚙️' }, ]; diff --git a/src/renderer/hooks/useGamepadNav.ts b/src/renderer/hooks/useGamepadNav.ts index d38f8c0..13b322f 100644 --- a/src/renderer/hooks/useGamepadNav.ts +++ b/src/renderer/hooks/useGamepadNav.ts @@ -29,12 +29,14 @@ export function useGamepadNav() { const dpadLeft = gp.buttons[14]?.pressed || gp.axes[0] < -0.6; const dpadRight = gp.buttons[15]?.pressed || gp.axes[0] > 0.6; - // --- Sidebar navigation with Left/Right --- + // --- Sidebar navigation with Left/Right (only when sidebar is focused) --- if (dpadLeft && debounced('left')) { - cycleSidebar(-1); + const focused = document.activeElement; + if (focused?.closest('.sidebar')) cycleSidebar(-1); } if (dpadRight && debounced('right')) { - cycleSidebar(1); + const focused = document.activeElement; + if (focused?.closest('.sidebar')) cycleSidebar(1); } // --- Page content navigation with Up/Down (Tab/Shift+Tab) --- @@ -44,7 +46,6 @@ export function useGamepadNav() { if (sidebar) { cycleSidebar(-1); } else { - // Focus previous focusable element const focusable = getFocusableElements(); const idx = focusable.indexOf(focused as HTMLElement); if (idx > 0) focusable[idx - 1]?.focus(); @@ -63,17 +64,16 @@ export function useGamepadNav() { } } - // A button (0) → click + // A button (0) → confirm / click if (gp.buttons[0]?.pressed && debounced('a')) { const el = document.activeElement as HTMLElement; if (el) el.click(); } - // B button (1) → focus sidebar (return to nav) + // B button (1) → back / blur (do not force sidebar) if (gp.buttons[1]?.pressed && debounced('b')) { - const sideItems = document.querySelectorAll(SIDEBAR_SELECTOR); - const active = document.querySelector('.nav-item.active') as HTMLElement; - (active || sideItems[0])?.focus(); + const focused = document.activeElement as HTMLElement; + if (focused) focused.blur(); } // Start (9) → focus first interactive element on page diff --git a/src/renderer/pages/SettingsPage.tsx b/src/renderer/pages/SettingsPage.tsx index f20992b..6f9ba28 100644 --- a/src/renderer/pages/SettingsPage.tsx +++ b/src/renderer/pages/SettingsPage.tsx @@ -1,13 +1,74 @@ import React, { useEffect, useState } from 'react'; -import type { AppSettings } from '../../shared/types'; +import type { AppSettings, EmulatorConfig } from '../../shared/types'; import { BiosCheckPanel } from '../components/BiosCheckPanel'; +import { applyTheme } from '../App'; + +const systemLabels: Record = { + nes: 'NES', snes: 'SNES', n64: 'N64', + gb: 'GB', gbc: 'GBC', gba: 'GBA', + nds: 'NDS', ps1: 'PlayStation', ps2: 'PS2', + ps3: 'PS3', psp: 'PSP', gc: 'GameCube', + wii: 'Wii', switch: 'Switch', + arcade: 'Arcade', pce: 'PC Engine', + 'sega-md': 'Sega Mega Drive', + 'sega-saturn': 'Sega Saturn', + 'sega-dc': 'Sega Dreamcast', + dreamcast: 'Dreamcast', +}; export function SettingsPage() { const [settings, setSettings] = useState(null); + const [systemAssignments, setSystemAssignments] = useState | null>(null); + const [emuNameMap, setEmuNameMap] = useState>({}); const [saving, setSaving] = useState(false); + const [updateInfo, setUpdateInfo] = useState<{ + status: string; + version?: string; + releaseNotes?: string; + message?: string; + } | null>(null); + const [checking, setChecking] = useState(false); + const [downloading, setDownloading] = useState(false); + const [downloadProgress, setDownloadProgress] = useState(0); useEffect(() => { - window.omni.settings.get().then(setSettings); + const unsubStatus = window.omni.updates.onStatus((s) => { + setChecking(false); + if (s.status === 'available') { + setUpdateInfo({ + status: 'available', + version: s.version as string, + releaseNotes: s.releaseNotes as string | undefined, + }); + } else if (s.status === 'not-available') { + setUpdateInfo({ status: 'not-available', version: s.version as string }); + } else if (s.status === 'downloaded') { + setDownloading(false); + setUpdateInfo((prev) => prev ? { ...prev, status: 'downloaded' } : null); + } else if (s.status === 'error') { + setChecking(false); + setDownloading(false); + setUpdateInfo({ status: 'error', message: s.message as string }); + } + }); + const unsubProgress = window.omni.updates.onDownloadProgress((p) => { + setDownloadProgress(p.percent as number); + }); + return () => { unsubStatus(); unsubProgress(); }; + }, []); + + useEffect(() => { + Promise.all([ + window.omni.settings.get(), + window.omni.emulators.systemAssignments(), + window.omni.emulators.list(), + ]).then(([s, assignments, emulators]) => { + setSettings(s); + setSystemAssignments(assignments); + const nameMap: Record = {}; + for (const e of emulators) nameMap[e.id] = e.name; + setEmuNameMap(nameMap); + }); }, []); const update = async (partial: Partial) => { @@ -18,10 +79,30 @@ export function SettingsPage() { setSaving(false); }; - if (!settings) { + const handleCheckUpdates = async () => { + setChecking(true); + setUpdateInfo(null); + await window.omni.updates.check(); + }; + + const handleDownloadUpdate = async () => { + setDownloading(true); + setDownloadProgress(0); + await window.omni.updates.download(); + }; + + const handleQuitAndInstall = async () => { + await window.omni.updates.quitAndInstall(); + }; + + if (!settings || !systemAssignments) { return
Loading settings...
; } + const multiSystems = Object.entries(systemAssignments) + .filter(([, emus]) => emus.length > 1) + .sort(([a], [b]) => a.localeCompare(b)); + return (
@@ -72,6 +153,47 @@ export function SettingsPage() { />
+
+

Emulator Assignments

+

+ Choose which emulator handles each system when multiple options exist. + Re-scan your ROMs after changing these preferences. +

+ {multiSystems.length === 0 ? ( +

No systems with multiple emulator options available.

+ ) : ( + multiSystems.map(([system, emus]) => ( +
+
+
+ {systemLabels[system] || system} +
+
+ {emus.map((e) => emuNameMap[e] || e).join(', ')} +
+
+ +
+ )) + )} +
+

Appearance

@@ -82,9 +204,11 @@ export function SettingsPage() {