still workign to make it this is just a back up

This commit is contained in:
2026-07-08 21:38:57 -05:00
parent e1ee2173d2
commit 59cd586e3b
18 changed files with 798 additions and 567 deletions
+6 -2
View File
@@ -36,6 +36,9 @@ jobs:
node-version: 22
cache: 'npm'
- name: Generate icons
run: python3 scripts/generate-icons.py
- name: Install dependencies
run: npm ci
@@ -43,7 +46,7 @@ jobs:
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 }}
run: npx electron-builder --${{ matrix.target == 'win-x64' && 'win' || contains(matrix.target, 'mac') && 'mac' || 'linux' }} --${{ matrix.arch }}
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -51,7 +54,8 @@ jobs:
uses: actions/upload-artifact@v4
with:
name: omniemu-${{ matrix.target }}
path: release/*
path: release/*.dmg
if-no-files-found: ignore
release:
if: startsWith(github.ref, 'refs/tags/v')
+2
View File
@@ -0,0 +1,2 @@
# Allow electron's postinstall script to download the binary
ignore-scripts=false
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

+73 -362
View File
@@ -17,7 +17,7 @@
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^4.4.0",
"concurrently": "^9.1.0",
"electron": "^33.0.0",
"electron": "^43.1.0",
"electron-builder": "^25.1.0",
"typescript": "^5.7.0",
"vite": "^6.0.0"
@@ -323,6 +323,16 @@
"url": "https://opencollective.com/webpack"
}
},
"node_modules/@electron-internal/extract-zip": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@electron-internal/extract-zip/-/extract-zip-1.0.4.tgz",
"integrity": "sha512-Zr1Vs7E9tpCNhZHDAbFVXc2gEVCG9RqPDjrno5+bdgB6LRAuvgyMHJut4NCVyYwtAieapMzc3fiQ3CSTi75ARg==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=22.12.0"
}
},
"node_modules/@electron/asar": {
"version": "3.4.1",
"resolved": "https://registry.npmjs.org/@electron/asar/-/asar-3.4.1.tgz",
@@ -373,25 +383,50 @@
}
},
"node_modules/@electron/get": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/@electron/get/-/get-2.0.3.tgz",
"integrity": "sha512-Qkzpg2s9GnVV2I2BjRksUi43U5e6+zaQMcjoJy0C+C5oxaKl+fmckGDQFtRpZpZV0NQekuZZ+tGz7EA9TVnQtQ==",
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/@electron/get/-/get-5.0.0.tgz",
"integrity": "sha512-pjoBpru1KdEtcExBnuHAP1cAc/5faoedw0hzJkL3o4/IJp7HNF1+fbrdxT3gMYRX2oJfvnA/WXeCTVQpYYxyJA==",
"dev": true,
"license": "MIT",
"dependencies": {
"debug": "^4.1.1",
"env-paths": "^2.2.0",
"fs-extra": "^8.1.0",
"got": "^11.8.5",
"env-paths": "^3.0.0",
"graceful-fs": "^4.2.11",
"progress": "^2.0.3",
"semver": "^6.2.0",
"semver": "^7.6.3",
"sumchecker": "^3.0.1"
},
"engines": {
"node": ">=12"
"node": ">=22.12.0"
},
"optionalDependencies": {
"global-agent": "^3.0.0"
"undici": "^7.24.4"
}
},
"node_modules/@electron/get/node_modules/env-paths": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/env-paths/-/env-paths-3.0.0.tgz",
"integrity": "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/@electron/get/node_modules/semver": {
"version": "7.8.5",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
"dev": true,
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/@electron/notarize": {
@@ -1966,13 +2001,13 @@
"license": "MIT"
},
"node_modules/@types/node": {
"version": "20.19.43",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz",
"integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==",
"version": "24.13.3",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz",
"integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
"undici-types": "~7.18.0"
}
},
"node_modules/@types/plist": {
@@ -2025,17 +2060,6 @@
"license": "MIT",
"optional": true
},
"node_modules/@types/yauzl": {
"version": "2.10.3",
"resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz",
"integrity": "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@vitejs/plugin-react": {
"version": "4.7.0",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
@@ -2514,15 +2538,6 @@
"bluebird": "^3.5.5"
}
},
"node_modules/boolean": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/boolean/-/boolean-3.2.0.tgz",
"integrity": "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==",
"deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.",
"dev": true,
"license": "MIT",
"optional": true
},
"node_modules/brace-expansion": {
"version": "5.0.7",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz",
@@ -2601,6 +2616,7 @@
"integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": "*"
}
@@ -3340,44 +3356,6 @@
"node": ">=10"
}
},
"node_modules/define-data-property": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz",
"integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"es-define-property": "^1.0.0",
"es-errors": "^1.3.0",
"gopd": "^1.0.1"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/define-properties": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz",
"integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"define-data-property": "^1.0.1",
"has-property-descriptors": "^1.0.0",
"object-keys": "^1.1.1"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/delayed-stream": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
@@ -3405,14 +3383,6 @@
"node": ">=8"
}
},
"node_modules/detect-node": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz",
"integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==",
"dev": true,
"license": "MIT",
"optional": true
},
"node_modules/dir-compare": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/dir-compare/-/dir-compare-4.2.0.tgz",
@@ -3606,22 +3576,22 @@
}
},
"node_modules/electron": {
"version": "33.4.11",
"resolved": "https://registry.npmjs.org/electron/-/electron-33.4.11.tgz",
"integrity": "sha512-xmdAs5QWRkInC7TpXGNvzo/7exojubk+72jn1oJL7keNeIlw7xNglf8TGtJtkR4rWC5FJq0oXiIXPS9BcK2Irg==",
"version": "43.1.0",
"resolved": "https://registry.npmjs.org/electron/-/electron-43.1.0.tgz",
"integrity": "sha512-DPfxpQLd4NL3BJ8DBxYAfmLUKKesF5Rx9dQx5FyczAP8bhOPScjHE48GArVeXu68LlAainuwkmQTQvdZwpIIAQ==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"@electron/get": "^2.0.0",
"@types/node": "^20.9.0",
"extract-zip": "^2.0.1"
"@electron-internal/extract-zip": "^1.0.1",
"@electron/get": "^5.0.0",
"@types/node": "^24.9.0"
},
"bin": {
"electron": "cli.js"
"electron": "cli.js",
"install-electron": "install.js"
},
"engines": {
"node": ">= 12.20.55"
"node": ">= 22.12.0"
}
},
"node_modules/electron-builder": {
@@ -3898,14 +3868,6 @@
"node": ">= 0.4"
}
},
"node_modules/es6-error": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/es6-error/-/es6-error-4.1.1.tgz",
"integrity": "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==",
"dev": true,
"license": "MIT",
"optional": true
},
"node_modules/esbuild": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
@@ -3958,20 +3920,6 @@
"node": ">=6"
}
},
"node_modules/escape-string-regexp": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
"integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
"dev": true,
"license": "MIT",
"optional": true,
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/exponential-backoff": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.3.tgz",
@@ -3979,27 +3927,6 @@
"dev": true,
"license": "Apache-2.0"
},
"node_modules/extract-zip": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz",
"integrity": "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==",
"dev": true,
"license": "BSD-2-Clause",
"dependencies": {
"debug": "^4.1.1",
"get-stream": "^5.1.0",
"yauzl": "^2.10.0"
},
"bin": {
"extract-zip": "cli.js"
},
"engines": {
"node": ">= 10.17.0"
},
"optionalDependencies": {
"@types/yauzl": "^2.9.1"
}
},
"node_modules/extsprintf": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.4.1.tgz",
@@ -4025,16 +3952,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/fd-slicer": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz",
"integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==",
"dev": true,
"license": "MIT",
"dependencies": {
"pend": "~1.2.0"
}
},
"node_modules/fdir": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
@@ -4148,21 +4065,6 @@
"license": "MIT",
"peer": true
},
"node_modules/fs-extra": {
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz",
"integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==",
"dev": true,
"license": "MIT",
"dependencies": {
"graceful-fs": "^4.2.0",
"jsonfile": "^4.0.0",
"universalify": "^0.1.0"
},
"engines": {
"node": ">=6 <7 || >=8"
}
},
"node_modules/fs-minipass": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz",
@@ -4357,57 +4259,6 @@
"node": "*"
}
},
"node_modules/global-agent": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/global-agent/-/global-agent-3.0.0.tgz",
"integrity": "sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==",
"dev": true,
"license": "BSD-3-Clause",
"optional": true,
"dependencies": {
"boolean": "^3.0.1",
"es6-error": "^4.1.1",
"matcher": "^3.0.0",
"roarr": "^2.15.3",
"semver": "^7.3.2",
"serialize-error": "^7.0.1"
},
"engines": {
"node": ">=10.0"
}
},
"node_modules/global-agent/node_modules/semver": {
"version": "7.8.5",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
"dev": true,
"license": "ISC",
"optional": true,
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/globalthis": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz",
"integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"define-properties": "^1.2.1",
"gopd": "^1.0.1"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/gopd": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
@@ -4464,20 +4315,6 @@
"node": ">=8"
}
},
"node_modules/has-property-descriptors": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz",
"integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"es-define-property": "^1.0.0"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-symbols": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
@@ -4899,14 +4736,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/json-stringify-safe": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz",
"integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==",
"dev": true,
"license": "ISC",
"optional": true
},
"node_modules/json5": {
"version": "2.2.3",
"resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
@@ -4920,16 +4749,6 @@
"node": ">=6"
}
},
"node_modules/jsonfile": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz",
"integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==",
"dev": true,
"license": "MIT",
"optionalDependencies": {
"graceful-fs": "^4.1.6"
}
},
"node_modules/keyv": {
"version": "4.5.4",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
@@ -5161,20 +4980,6 @@
"node": ">=12"
}
},
"node_modules/matcher": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/matcher/-/matcher-3.0.0.tgz",
"integrity": "sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"escape-string-regexp": "^4.0.0"
},
"engines": {
"node": ">=10"
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@@ -5590,17 +5395,6 @@
"node": "^12.13.0 || ^14.15.0 || >=16.0.0"
}
},
"node_modules/object-keys": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz",
"integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==",
"dev": true,
"license": "MIT",
"optional": true,
"engines": {
"node": ">= 0.4"
}
},
"node_modules/once": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
@@ -5769,13 +5563,6 @@
"url": "https://github.com/sponsors/jet2jet"
}
},
"node_modules/pend": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz",
"integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==",
"dev": true,
"license": "MIT"
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -6105,25 +5892,6 @@
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/roarr": {
"version": "2.15.4",
"resolved": "https://registry.npmjs.org/roarr/-/roarr-2.15.4.tgz",
"integrity": "sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A==",
"dev": true,
"license": "BSD-3-Clause",
"optional": true,
"dependencies": {
"boolean": "^3.0.1",
"detect-node": "^2.0.4",
"globalthis": "^1.0.1",
"json-stringify-safe": "^5.0.1",
"semver-compare": "^1.0.0",
"sprintf-js": "^1.1.2"
},
"engines": {
"node": ">=8.0"
}
},
"node_modules/rollup": {
"version": "4.62.2",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz",
@@ -6243,31 +6011,6 @@
"semver": "bin/semver.js"
}
},
"node_modules/semver-compare": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/semver-compare/-/semver-compare-1.0.0.tgz",
"integrity": "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow==",
"dev": true,
"license": "MIT",
"optional": true
},
"node_modules/serialize-error": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-7.0.1.tgz",
"integrity": "sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"type-fest": "^0.13.1"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/set-blocking": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
@@ -6445,14 +6188,6 @@
"source-map": "^0.6.0"
}
},
"node_modules/sprintf-js": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz",
"integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==",
"dev": true,
"license": "BSD-3-Clause",
"optional": true
},
"node_modules/ssri": {
"version": "9.0.1",
"resolved": "https://registry.npmjs.org/ssri/-/ssri-9.0.1.tgz",
@@ -6740,20 +6475,6 @@
"dev": true,
"license": "0BSD"
},
"node_modules/type-fest": {
"version": "0.13.1",
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.13.1.tgz",
"integrity": "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==",
"dev": true,
"license": "(MIT OR CC0-1.0)",
"optional": true,
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
@@ -6768,10 +6489,21 @@
"node": ">=14.17"
}
},
"node_modules/undici": {
"version": "7.28.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz",
"integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==",
"dev": true,
"license": "MIT",
"optional": true,
"engines": {
"node": ">=20.18.1"
}
},
"node_modules/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"version": "7.18.2",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
"integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
"dev": true,
"license": "MIT"
},
@@ -6801,16 +6533,6 @@
"node": "^12.13.0 || ^14.15.0 || >=16.0.0"
}
},
"node_modules/universalify": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz",
"integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 4.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",
@@ -7093,17 +6815,6 @@
"node": ">=12"
}
},
"node_modules/yauzl": {
"version": "2.10.0",
"resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz",
"integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==",
"dev": true,
"license": "MIT",
"dependencies": {
"buffer-crc32": "~0.2.3",
"fd-slicer": "~1.1.0"
}
},
"node_modules/yocto-queue": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
+47 -12
View File
@@ -4,7 +4,7 @@
"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": "concurrently -k \"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",
@@ -12,10 +12,14 @@
"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",
"prepare": "python3 scripts/generate-icons.py",
"postinstall": "node node_modules/electron/install.js",
"package:mac": "npm run build && electron-builder --mac --universal",
"package:mac:x64": "npm run build && electron-builder --mac --x64",
"package:mac:arm64": "npm run build && electron-builder --mac --arm64",
"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",
"package:all": "npm run build && electron-builder --mac --universal --win --linux",
"typecheck": "tsc --noEmit -p tsconfig.main.json && tsc --noEmit -p tsconfig.renderer.json"
},
"author": "",
@@ -25,7 +29,7 @@
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^4.4.0",
"concurrently": "^9.1.0",
"electron": "^33.0.0",
"electron": "^43.1.0",
"electron-builder": "^25.1.0",
"typescript": "^5.7.0",
"vite": "^6.0.0"
@@ -38,30 +42,61 @@
"appId": "com.omniemu.app",
"productName": "OmniEmu",
"directories": {
"output": "release"
"output": "release",
"buildResources": "assets"
},
"files": [
"dist/**/*",
"package.json"
],
"extraResources": [
{
"from": "assets",
"to": "assets",
"filter": [
"*.png",
"*.icns",
"*.ico"
]
}
],
"mac": {
"category": "public.app-category.utilities",
"target": ["dmg", "zip"],
"icon": "assets/icon.png",
"target": [
"dmg",
"zip"
],
"artifactName": "${productName}-${version}-mac-${arch}.${ext}",
"hardenedRuntime": true
"hardenedRuntime": false
},
"win": {
"target": ["nsis", "zip"],
"icon": "assets/icon.png",
"target": [
"nsis",
"zip"
],
"artifactName": "${productName}-${version}-win-${arch}.${ext}"
},
"nsis": {
"oneClick": false,
"allowToChangeInstallationDirectory": true
"oneClick": true,
"allowToChangeInstallationDirectory": false,
"createDesktopShortcut": true,
"createStartMenuShortcut": true
},
"linux": {
"target": ["AppImage", "deb"],
"category": "Utility",
"icon": "assets/icon.png",
"target": [
"AppImage",
"deb"
],
"category": "Game",
"artifactName": "${productName}-${version}-linux-${arch}.${ext}"
}
},
"allowScripts": {
"[email protected]": true,
"[email protected]": true,
"[email protected]": true
}
}
+22 -11
View File
@@ -3,27 +3,38 @@ set -euo pipefail
# Build OmniEmu for macOS
# Usage: ./scripts/build-mac.sh [arch]
# arch: x64 | arm64 (default: current arch)
# arch: x64 | arm64 | universal (default: universal)
# Requires: Node.js 20+, npm
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
ARCH="${1:-universal}"
echo "==> Installing dependencies..."
npm install
echo "==> Building for macOS ($ARCH_FLAG)..."
echo "==> Building..."
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 "==> Packaging for macOS ($ARCH)..."
case "$ARCH" in
universal)
npx electron-builder --mac --universal
;;
x64)
npx electron-builder --mac --x64
;;
arm64)
npx electron-builder --mac --arm64
;;
*)
echo "Unknown arch: $ARCH (use: x64, arm64, universal)"
exit 1
;;
esac
echo ""
echo "==> Done! Artifacts in ./release/"
ls -lh release/*.dmg release/*.zip 2>/dev/null || true
+87
View File
@@ -0,0 +1,87 @@
#!/usr/bin/env node
/**
* Generates minimal placeholder icons for all platforms.
* Creates a simple app icon: purple gradient on transparent.
* Outputs: assets/icon.png (1024x1024)
* Uses pure Node.js — no dependencies.
*/
const { writeFileSync, mkdirSync } = require('fs');
const { join } = require('path');
const { createCanvas } = (() => {
// Minimal PNG encoder — no canvas library needed
class Canvas {
constructor(w, h) { this.w = w; this.h = h; this.pixels = Buffer.alloc(w * h * 4, 0); }
fillRect(x, y, w, h, r, g, b, a) {
for (let py = y; py < y + h; py++) {
for (let px = x; px < x + w; px++) {
const i = (py * this.w + px) * 4;
this.pixels[i] = r; this.pixels[i+1] = g; this.pixels[i+2] = b; this.pixels[i+3] = a;
}
}
}
fillCircle(cx, cy, rad, r, g, b, a) {
for (let py = cy - rad; py <= cy + rad; py++) {
for (let px = cx - rad; px <= cx + rad; px++) {
if ((px-cx)**2 + (py-cy)**2 <= rad*rad) {
const i = (py * this.w + px) * 4;
if (i >= 0 && i < this.pixels.length) {
this.pixels[i] = r; this.pixels[i+1] = g; this.pixels[i+2] = b; this.pixels[i+3] = a;
}
}
}
}
}
toPNG() {
const { w, h, pixels } = this;
const data = [];
// PNG signature
data.push(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]));
// IHDR chunk
const ihdr = Buffer.alloc(13);
ihdr.writeUInt32BE(w, 0); ihdr.writeUInt32BE(h, 4);
ihdr[8] = 8; // bit depth
ihdr[9] = 6; // color type RGBA
data.push(this._chunk('IHDR', ihdr));
// IDAT chunk (raw pixel data with filter bytes)
const raw = Buffer.alloc(h * (1 + w * 4));
for (let y = 0; y < h; y++) {
raw[y * (1 + w * 4)] = 0; // no filter
pixels.copy(raw, y * (1 + w * 4) + 1, y * w * 4, (y + 1) * w * 4);
}
const zlib = require('zlib');
const compressed = zlib.deflateSync(raw);
data.push(this._chunk('IDAT', compressed));
// IEND chunk
data.push(this._chunk('IEND', Buffer.alloc(0)));
return Buffer.concat(data);
}
_chunk(type, buf) {
const len = Buffer.alloc(4); len.writeUInt32BE(buf.length);
const typeB = Buffer.from(type, 'ascii');
const crcData = Buffer.concat([typeB, buf]);
const crc = require('zlib').crc32(crcData);
const crcB = Buffer.alloc(4); crcB.writeUInt32BE(crc >>> 0);
return Buffer.concat([len, typeB, buf, crcB]);
}
}
return { createCanvas: (w, h) => new Canvas(w, h) };
})();
const SIZE = 1024;
const canvas = createCanvas(SIZE, SIZE);
const c = SIZE / 2;
const r = SIZE * 0.42;
// Background circle
canvas.fillCircle(c, c, r, 108, 99, 255, 255);
// Inner circle (slightly darker)
canvas.fillCircle(c, c, r * 0.7, 90, 80, 220, 255);
// Letter "O" representation — two circles to make a ring
canvas.fillCircle(c, c, r * 0.5, 108, 99, 255, 255);
canvas.fillCircle(c, c, r * 0.3, 90, 80, 220, 255);
const outDir = join(__dirname, '..', 'assets');
mkdirSync(outDir, { recursive: true });
writeFileSync(join(outDir, 'icon.png'), canvas.toPNG());
console.log('Generated assets/icon.png');
+30
View File
@@ -0,0 +1,30 @@
#!/usr/bin/env python3
"""Generate app icon for all platforms using Pillow."""
import os
from PIL import Image, ImageDraw
SIZE = 1024
img = Image.new('RGBA', (SIZE, SIZE), (0, 0, 0, 0))
draw = ImageDraw.Draw(img)
cx = cy = SIZE // 2
r = int(SIZE * 0.42)
# Outer circle (purple)
draw.ellipse([cx - r, cy - r, cx + r, cy + r], fill=(108, 99, 255, 255))
# Inner circle (slightly darker)
ir = int(r * 0.7)
draw.ellipse([cx - ir, cy - ir, cx + ir, cy + ir], fill=(90, 80, 220, 255))
# Ring hole
hr = int(r * 0.28)
draw.ellipse([cx - hr, cy - hr, cx + hr, cy + hr], fill=(108, 99, 255, 255))
# Center hole (transparent center of 'O')
cr = int(r * 0.17)
draw.ellipse([cx - cr, cy - cr, cx + cr, cy + cr], fill=(90, 80, 220, 255))
os.makedirs('assets', exist_ok=True)
img.save('assets/icon.png')
print(f'Generated assets/icon.png ({os.path.getsize("assets/icon.png")} bytes)')
+7
View File
@@ -288,6 +288,13 @@ function alternativePaths(emulatorId: string): string[] {
const userData = app.getPath('userData');
const omniEmuDir = join(userData, 'emulators', emulatorId);
// Check marker file first (written after a successful install)
const markerFile = join(omniEmuDir, '.installed');
try {
const markerPath = require('fs').readFileSync(markerFile, 'utf-8').trim();
if (markerPath && existsSync(markerPath)) return [markerPath];
} catch { /* no marker */ }
const common: Record<string, string[]> = {
dolphin: [
join(omniEmuDir, 'Dolphin.exe'),
+49 -7
View File
@@ -1,15 +1,40 @@
import { app, BrowserWindow, Tray, Menu, nativeImage } from 'electron';
import { join } from 'path';
import { existsSync } from 'fs';
import { registerIpcHandlers } from './ipc';
import { settings } from './settings';
import { isMacOS, isWindows } from './platform';
import { isMacOS } from './platform';
let mainWindow: BrowserWindow | null = null;
let tray: Tray | null = null;
const isDev = !app.isPackaged;
const VITE_DEV_SERVER_URL = process.env.VITE_DEV_SERVER_URL;
function createAppIcon() {
const iconPaths = [
join(__dirname, '../../assets/icon.png'),
join(__dirname, '../renderer/assets/icon.png'),
join(__dirname, '../../build/icon.png'),
];
for (const p of iconPaths) {
try {
if (existsSync(p)) return nativeImage.createFromPath(p);
} catch { /* skip */ }
}
// Fallback: 16x16 purple pixel
const size = 16;
const buf = Buffer.alloc(size * size * 4 + 8);
buf.writeUInt32LE(size, 0);
buf.writeUInt32LE(size, 4);
for (let i = 8; i < buf.length; i += 4) {
buf[i] = 108; buf[i + 1] = 99; buf[i + 2] = 255; buf[i + 3] = 255;
}
return nativeImage.createFromBuffer(buf, { width: size, height: size });
}
function createWindow(): void {
const icon = createAppIcon();
mainWindow = new BrowserWindow({
width: 1200,
height: 800,
@@ -18,6 +43,7 @@ function createWindow(): void {
title: 'OmniEmu',
backgroundColor: '#1a1a2e',
show: false,
icon,
webPreferences: {
preload: join(__dirname, 'preload.js'),
contextIsolation: true,
@@ -26,9 +52,11 @@ function createWindow(): void {
},
});
if (isDev) {
mainWindow.loadURL('http://localhost:5173');
if (VITE_DEV_SERVER_URL) {
mainWindow.loadURL(VITE_DEV_SERVER_URL);
if (process.env.OMNIEMU_DEVTOOLS) {
mainWindow.webContents.openDevTools();
}
} else {
mainWindow.loadFile(join(__dirname, '../renderer/index.html'));
}
@@ -51,10 +79,10 @@ function createWindow(): void {
}
function createTray(): void {
const iconSize = isMacOS() ? 16 : 24;
const icon = nativeImage.createEmpty();
const icon = createAppIcon();
const trayIcon = icon.resize({ width: 22, height: 22 });
tray = new Tray(icon);
tray = new Tray(trayIcon);
tray.setToolTip('OmniEmu');
const contextMenu = Menu.buildFromTemplate([
@@ -67,6 +95,20 @@ function createTray(): void {
tray.on('click', () => mainWindow?.show());
}
// Prevent multiple instances
const gotLock = app.requestSingleInstanceLock();
if (!gotLock) {
app.quit();
} else {
app.on('second-instance', () => {
if (mainWindow) {
if (mainWindow.isMinimized()) mainWindow.restore();
mainWindow.show();
mainWindow.focus();
}
});
}
app.whenReady().then(() => {
registerIpcHandlers();
createWindow();
+104 -56
View File
@@ -1,10 +1,9 @@
import { createWriteStream, existsSync, mkdirSync, createReadStream } from 'fs';
import { join, dirname, basename, extname } from 'path';
import { get } from 'https';
import { get as getHttp } from 'http';
import { execSync, spawn } from 'child_process';
import { createWriteStream, existsSync, mkdirSync, readdirSync, statSync } from 'fs';
import { join, basename, extname, dirname } from 'path';
import { get as httpsGet, RequestOptions } from 'https';
import { get as httpGet } from 'http';
import { execSync } from 'child_process';
import { pipeline } from 'stream/promises';
import { createGunzip, createBrotliDecompress, createInflate } from 'zlib';
import { randomBytes } from 'crypto';
import { app } from 'electron';
import { EmulatorDownload, InstallProgress, Platform, Arch } from '../shared/types';
@@ -24,113 +23,158 @@ function tempName(suffix: string): string {
function downloadFile(url: string, dest: string, onProgress: (pct: number) => void): Promise<void> {
return new Promise((resolve, reject) => {
const protocol = url.startsWith('https') ? get : getHttp;
protocol(url, (response) => {
const doRequest = (currentUrl: string) => {
const protocol = currentUrl.startsWith('https') ? httpsGet : httpGet;
const opts: RequestOptions = {
headers: { 'User-Agent': 'OmniEmu/0.1.0' },
timeout: 30000,
};
protocol(currentUrl, opts, (response) => {
const code = response.statusCode ?? 500;
if (code >= 300 && code < 400 && response.headers.location) {
downloadFile(response.headers.location, dest, onProgress).then(resolve).catch(reject);
doRequest(response.headers.location);
return;
}
if (code !== 200) {
reject(new Error(`HTTP ${code} downloading ${url}`));
reject(new Error(`HTTP ${code} ${currentUrl}`));
return;
}
const total = parseInt(response.headers['content-length'] ?? '0', 10);
let downloaded = 0;
const file = createWriteStream(dest);
const fileStream = createWriteStream(dest);
response.on('data', (chunk: Buffer) => {
downloaded += chunk.length;
if (total > 0) onProgress(Math.round((downloaded / total) * 100));
});
pipeline(response, file)
.then(() => {
onProgress(100);
resolve();
})
.catch(reject);
}).on('error', reject);
pipeline(response, fileStream).then(resolve).catch(reject);
}).on('error', reject).on('timeout', function (this: any) { this.destroy(); reject(new Error('Download timed out')); });
};
doRequest(url);
});
}
function execOrThrow(cmd: string): string {
try {
return execSync(cmd, { stdio: 'pipe', timeout: 120000 }).toString();
} catch (e: any) {
throw new Error(`Command failed: ${cmd}\n${e.stderr?.toString() || e.message}`);
}
}
function findAppInDir(dir: string): string | undefined {
const entries = readdirSync(dir, { withFileTypes: true });
for (const e of entries) {
const full = join(dir, e.name);
if (e.isDirectory()) {
if (e.name.endsWith('.app')) {
const macosBin = join(full, 'Contents', 'MacOS', basename(e.name, '.app'));
if (existsSync(macosBin)) return macosBin;
}
const found = findAppInDir(full);
if (found) return found;
} else if (e.isFile()) {
const isExec = e.name.endsWith('.exe') || e.name.endsWith('.AppImage')
|| e.name === 'retroarch' || e.name === 'dolphin-emu'
|| e.name === 'rpcs3' || e.name === 'Ryujinx'
|| e.name === 'mame' || e.name === 'mame64' || e.name === 'PCSX2';
if (isExec) return full;
// Check if it's executable
try {
if (statSync(full).mode & 0o111) return full;
} catch { /* skip */ }
}
}
return undefined;
}
function extractArchive(archivePath: string, destDir: string, format: string, emulatorId: string, onProgress: (msg: string) => void): void {
if (!existsSync(destDir)) mkdirSync(destDir, { recursive: true });
onProgress(`Extracting ${basename(archivePath)}...`);
switch (format) {
case 'zip': {
execSync(`unzip -o "${archivePath}" -d "${destDir}"`, { stdio: 'pipe' });
case 'zip':
execOrThrow(`unzip -o "${archivePath}" -d "${destDir}"`);
break;
}
case 'tar.gz':
case 'tar.bz2': {
const decompress = format === 'tar.bz2' ? '-j' : '-z';
execSync(`tar -x${decompress}f "${archivePath}" -C "${destDir}"`, { stdio: 'pipe' });
execOrThrow(`tar -xzf "${archivePath}" -C "${destDir}"`);
break;
case 'tar.bz2':
execOrThrow(`tar -xjf "${archivePath}" -C "${destDir}"`);
break;
}
case '7z': {
execSync(`7z x "${archivePath}" -o"${destDir}" -y`, { stdio: 'pipe' });
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}"`);
}
break;
}
case 'dmg': {
// Attach DMG and copy .app out
const mountPoint = `/tmp/omniemu_${emulatorId}`;
execSync(`hdiutil attach "${archivePath}" -mountpoint "${mountPoint}" -nobrowse -quiet`, { stdio: 'pipe' });
execSync(`cp -R "${mountPoint}"/*.app "${destDir}/" 2>/dev/null; cp -R "${mountPoint}"/*/*.app "${destDir}/" 2>/dev/null; true`, { stdio: 'pipe' });
execSync(`hdiutil detach "${mountPoint}" -quiet 2>/dev/null; true`, { stdio: 'pipe' });
if (!isMacOS()) throw new Error('DMG files can only be extracted on macOS');
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);
if (item.endsWith('.app') || (statSync(src).isDirectory() && !item.startsWith('.'))) {
execOrThrow(`cp -R "${src}" "${destDir}/"`);
}
}
} finally {
execOrThrow(`hdiutil detach "${mountPoint}" -quiet 2>/dev/null; true`);
}
break;
}
case 'exe':
case 'msi':
case 'appimage':
case 'pkg': {
// These are direct installers, not archives
case 'pkg':
// These are installers, not archives
break;
}
default:
throw new Error(`Unknown archive format: ${format}`);
}
onProgress('Extraction complete');
}
function runInstaller(installerPath: string, format: string, emulatorId: string, installDir: string, onProgress: (msg: string) => void): void {
onProgress(`Running installer for ${emulatorId}...`);
onProgress(`Running installer...`);
switch (format) {
case 'exe': {
if (isWindows()) {
execSync(`"${installerPath}" /S /D="${installDir}"`, { stdio: 'pipe', timeout: 300000 });
execOrThrow(`"${installerPath}" /S /D="${installDir}"`);
} else {
execSync(`chmod +x "${installerPath}" && "${installerPath}" -- "${installDir}"`, { stdio: 'pipe', timeout: 300000 });
execOrThrow(`chmod +x "${installerPath}" && "${installerPath}"`);
}
break;
}
case 'msi': {
execSync(`msiexec /i "${installerPath}" /quiet /norestart INSTALLDIR="${installDir}"`, { stdio: 'pipe', timeout: 300000 });
execOrThrow(`msiexec /i "${installerPath}" /quiet /norestart INSTALLDIR="${installDir}"`);
break;
}
case 'appimage': {
execSync(`chmod +x "${installerPath}"`, { stdio: 'pipe' });
// Copy AppImage to install dir
execSync(`cp "${installerPath}" "${installDir}/${emulatorId}.AppImage"`, { stdio: 'pipe' });
execOrThrow(`chmod +x "${installerPath}"`);
execOrThrow(`cp "${installerPath}" "${installDir}/${emulatorId}.AppImage"`);
break;
}
case 'pkg': {
execSync(`installer -pkg "${installerPath}" -target /`, { stdio: 'pipe', timeout: 300000 });
execOrThrow(`installer -pkg "${installerPath}" -target /`);
break;
}
case 'dmg': {
case 'dmg':
case 'zip':
case 'tar.gz':
case 'tar.bz2':
case '7z':
// Already handled in extractArchive
break;
}
default:
throw new Error(`Cannot install format: ${format}`);
}
onProgress('Installer finished');
}
@@ -143,26 +187,24 @@ export async function installEmulator(
arch: Arch,
onProgress: ProgressCallback
): Promise<string> {
// Pick the right download for this platform + arch
const candidates = downloads.filter((d) => !d.arch || d.arch === arch);
const download = candidates[0];
if (!download) throw new Error(`No download available for ${platform}/${arch}`);
if (!download) 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 });
};
report('downloading', 0, `Downloading ${download.url}...`);
report('downloading', 0, `Downloading ${emulatorId}...`);
const downloadPath = tempName(`.${download.format}`);
await downloadFile(download.url, downloadPath, (pct) => {
report('downloading', pct, `Downloading ${emulatorId}... ${pct}%`);
});
report('downloading', 100, 'Download complete');
const installDir = join(app.getPath('userData'), 'emulators', emulatorId);
if (!existsSync(installDir)) mkdirSync(installDir, { recursive: true });
// Determine if this is an archive or an installer
const archiveFormats = ['zip', 'tar.gz', 'tar.bz2', '7z', 'dmg'];
const installerFormats = ['exe', 'msi', 'pkg', 'appimage'];
@@ -185,7 +227,13 @@ export async function installEmulator(
// Cleanup
try { execSync(`rm -f "${downloadPath}"`); } catch { /* ignore */ }
report('done', 100, `${emulatorId} installed successfully`);
report('done', 100, `${emulatorId} installed`);
return installDir;
}
/** After install, find the executable in the install dir */
export function findInstalledBinary(emulatorId: string, installDir: string): string | undefined {
if (!existsSync(installDir)) return undefined;
return findAppInDir(installDir);
}
+11 -2
View File
@@ -1,4 +1,6 @@
import { ipcMain, shell, dialog, BrowserWindow } from 'electron';
import { join } from 'path';
import { writeFileSync } from 'fs';
import {
getAllEmulatorStates,
checkEmulator,
@@ -9,7 +11,7 @@ import {
getRomsDirectory,
getEmulatorsDirectory,
} from './emulators';
import { installEmulator } from './installer';
import { installEmulator, findInstalledBinary } from './installer';
import { applyRecommendedConfig, getPresets, checkConfigured } from './configurator';
import { settings } from './settings';
import { getSystemInfo, platformName, getPlatform, getArch } from './platform';
@@ -44,7 +46,14 @@ export function registerIpcHandlers(): void {
win?.webContents.send('emulators:install-progress', p);
};
await installEmulator(emulatorId, downloads, platform, arch, sendProgress);
const installDir = await installEmulator(emulatorId, downloads, platform, arch, sendProgress);
// Try to find the binary and create a symlink or record it
const binary = findInstalledBinary(emulatorId, installDir);
if (binary) {
const marker = join(installDir, '.installed');
writeFileSync(marker, binary);
}
// Re-check state after install
return checkEmulator(emulatorId);
+175 -20
View File
@@ -1,9 +1,16 @@
import React, { useEffect, useState, useCallback } from 'react';
import type { EmulatorState } from '../../shared/types';
import React, { useEffect, useState, useCallback, useRef } from 'react';
import type { EmulatorState, InstallProgress } from '../../shared/types';
interface ProgressMap {
[emulatorId: string]: InstallProgress;
}
export function EmulatorsPage() {
const [states, setStates] = useState<EmulatorState[]>([]);
const [loading, setLoading] = useState(true);
const [progress, setProgress] = useState<ProgressMap>({});
const [actioning, setActioning] = useState<string | null>(null);
const cleanups = useRef<(() => void)[]>([]);
const load = useCallback(async () => {
setLoading(true);
@@ -12,16 +19,74 @@ export function EmulatorsPage() {
setLoading(false);
}, []);
useEffect(() => { load(); }, [load]);
useEffect(() => {
load();
const unsub = window.omni.emulators.onInstallProgress((p: InstallProgress) => {
setProgress((prev) => ({ ...prev, [p.emulatorId]: p }));
if (p.stage === 'done' || p.stage === 'error') {
setActioning(null);
}
});
cleanups.current.push(unsub);
return () => {
cleanups.current.forEach((fn) => fn());
};
}, [load]);
const handleInstall = async (id: string) => {
await window.omni.emulators.openInstallUrl(id);
setActioning(id);
setProgress((prev) => ({
...prev,
[id]: { emulatorId: id, stage: 'downloading', percent: 0, message: 'Starting...' },
}));
await window.omni.emulators.install(id);
await load();
};
const handleRefresh = () => {
load();
const handleInstallAndConfigure = async (id: string) => {
setActioning(id);
setProgress((prev) => ({
...prev,
[id]: { emulatorId: id, stage: 'downloading', percent: 0, message: 'Starting...' },
}));
const result = await window.omni.emulators.install(id);
if (result.installed && result.path) {
await window.omni.emulators.configure(id, result.path);
}
await load();
};
const handleConfigure = async (state: EmulatorState) => {
if (!state.path) return;
setActioning(state.config.id);
setProgress((prev) => ({
...prev,
[state.config.id]: {
emulatorId: state.config.id,
stage: 'configuring', percent: 0, message: 'Applying recommended settings...',
},
}));
await window.omni.emulators.configure(state.config.id, state.path);
setProgress((prev) => ({
...prev,
[state.config.id]: {
emulatorId: state.config.id,
stage: 'done', percent: 100,
message: 'Configuration applied',
},
}));
setActioning(null);
await load();
};
const handleOpenWebsite = async (id: string) => {
await window.omni.emulators.openWebsite(id);
};
const handleRefresh = () => load();
if (loading) {
return <div className="loading">Checking emulators...</div>;
}
@@ -31,21 +96,34 @@ export function EmulatorsPage() {
<div className="empty-state">
<div className="empty-state-icon">🕹</div>
<h3>No emulators configured</h3>
<p>Add emulator definitions to get started.</p>
<p>Ready to install and configure emulators automatically.</p>
</div>
);
}
const currentProgress = (id: string) => progress[id];
return (
<div>
<div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: 16 }}>
<div className="info-bar">
<span>
{states.filter((s) => s.installed).length} installed
{' · '}
{states.filter((s) => s.configured).length} configured
{' · '}
{Object.keys(progress).length > 0 && ' Installing...'}
</span>
<button className="btn btn-secondary btn-sm" onClick={handleRefresh}>
Refresh
</button>
</div>
<div className="card-grid">
{states.map((state) => (
{states.map((state) => {
const prog = currentProgress(state.config.id);
const isActioning = actioning === state.config.id;
return (
<div className="card" key={state.config.id}>
<div className="card-header">
<h3>{state.config.name}</h3>
@@ -54,14 +132,18 @@ export function EmulatorsPage() {
!state.config.supported
? 'badge-unsupported'
: state.installed
? state.configured
? 'badge-installed'
: 'badge-installed'
: 'badge-missing'
}`}
>
{!state.config.supported
? 'Unsupported'
: state.installed
? 'Installed'
? state.configured
? 'Configured'
: 'Installed'
: 'Not installed'}
</span>
</div>
@@ -77,28 +159,101 @@ export function EmulatorsPage() {
</div>
{state.version && (
<p className="text-sm text-muted mt-2">
Version: {state.version}
</p>
<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 && (
{prog && (
<div className="mt-2">
<div className="progress-bar">
<div
className="progress-fill"
style={{
width: `${prog.percent}%`,
background:
prog.stage === 'error'
? 'var(--error)'
: 'var(--accent)',
}}
/>
</div>
<p className="text-sm text-muted mt-2">
{prog.stage === 'error'
? `Error: ${prog.error || prog.message}`
: prog.message}
</p>
</div>
)}
<div
style={{
marginTop: 12,
display: 'flex',
flexWrap: 'wrap',
gap: 6,
alignItems: 'center',
}}
>
{!state.installed && state.config.supported && (
<>
<button
className="btn btn-primary btn-sm"
disabled={isActioning}
onClick={() => handleInstallAndConfigure(state.config.id)}
>
{isActioning ? 'Working...' : 'Install & Configure'}
</button>
<button
className="btn btn-secondary btn-sm"
disabled={isActioning}
onClick={() => handleInstall(state.config.id)}
>
Install
Install Only
</button>
</>
)}
{!state.installed && !state.config.supported && (
<span className="text-sm text-muted">Not supported on this platform</span>
)}
{state.installed && !state.configured && (
<button
className="btn btn-primary btn-sm"
disabled={isActioning}
onClick={() => handleConfigure(state)}
>
Apply Recommended Settings
</button>
)}
{state.installed && state.path && (
<span className="text-sm text-muted" style={{ padding: '4px 0' }}>
{state.path}
{state.installed && state.configured && (
<>
<button
className="btn btn-secondary btn-sm"
disabled={isActioning}
onClick={() => handleConfigure(state)}
>
Re-apply Settings
</button>
<span className="badge badge-installed" style={{ fontSize: 11 }}>
Configured
</span>
</>
)}
{state.config.websiteUrl && (
<button
className="btn-icon"
title="Open website"
onClick={() => handleOpenWebsite(state.config.id)}
>
</button>
)}
</div>
</div>
))}
);
})}
</div>
</div>
);
+56 -40
View File
@@ -2,79 +2,97 @@ 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: '🕹️',
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 [romsDir, setRomsDir] = useState<string>('');
const handleScan = useCallback(async () => {
const dir = await window.omni.roms.selectDirectory();
if (!dir) return;
setSelectedDir(dir);
// Load saved ROM directory on mount
useEffect(() => {
(async () => {
const settings = await window.omni.settings.get();
if (settings.romsDirectory) {
setRomsDir(settings.romsDirectory);
setLoading(true);
const results = await window.omni.roms.scan(settings.romsDirectory);
setGames(results);
setLoading(false);
}
})();
}, []);
const scanDirectory = useCallback(async (dir: string) => {
setRomsDir(dir);
setLoading(true);
// Save to persistent settings
await window.omni.settings.save({ romsDirectory: dir });
const results = await window.omni.roms.scan(dir);
setGames(results);
setLoading(false);
}, []);
const handleScan = useCallback(async () => {
const dir = await window.omni.roms.selectDirectory();
if (!dir) return;
await scanDirectory(dir);
}, [scanDirectory]);
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 }}>
<div style={{ display: 'flex', gap: 8, marginBottom: 16, alignItems: 'center' }}>
<button className="btn btn-primary" onClick={handleScan}>
Scan for ROMs
Select ROM Folder
</button>
{selectedDir && (
<span className="text-sm text-muted" style={{ padding: '8px 0' }}>
{selectedDir}
{romsDir && (
<span className="text-sm text-muted" style={{ padding: '8px 0', maxWidth: 400, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{romsDir}
</span>
)}
</div>
{loading && <div className="loading">Scanning for games...</div>}
{!loading && games.length === 0 && (
<div className="scan-area" onClick={handleOpenDirectory}>
{!loading && games.length === 0 && !romsDir && (
<div className="scan-area" onClick={handleScan}>
<div style={{ fontSize: 48 }}>📂</div>
<p>Click to select a ROM directory</p>
<p>Select a folder to scan for ROMs</p>
<p className="text-sm text-muted mt-2">
Supported formats: .nes, .sfc, .n64, .gba, .iso, .wbfs, .nsp, .ps2, and more
Your ROM directory will be saved for next time
</p>
</div>
)}
{!loading && games.length === 0 && romsDir && (
<div className="empty-state">
<div className="empty-state-icon">🔍</div>
<p>No ROMs found in that folder</p>
<p className="text-sm text-muted mt-2">
Supported: .nes, .sfc, .n64, .gba, .iso, .wbfs, .nsp, .ps2, .chd, and more
</p>
<button className="btn btn-secondary mt-4" onClick={handleScan}>
Choose another folder
</button>
</div>
)}
{games.length > 0 && (
<div>
<div className="info-bar">
<span>{games.length} games found</span>
<span>{(games.length / 10).toFixed(1)} GB estimated</span>
<button className="btn btn-secondary btn-sm" onClick={handleScan}>
Rescan
</button>
</div>
<div className="library-grid">
@@ -91,9 +109,7 @@ export function LibraryPage() {
<div className="game-card-info">
<div className="game-card-title">{game.title}</div>
<div className="game-card-platform">
{game.platform.toUpperCase()}
{' · '}
{game.emulatorId}
{game.platform.toUpperCase()} · {game.emulatorId}
</div>
</div>
</div>
+19
View File
@@ -84,6 +84,25 @@ export function SettingsPage() {
</div>
</div>
<div className="settings-section">
<h3>Presets</h3>
<div className="setting-row">
<div>
<div className="setting-label">Preset Source URL</div>
<div className="setting-desc">
URL to fetch recommended emulator config presets from
</div>
</div>
<input
type="text"
value={settings.presetSourceUrl}
onChange={(e) => update({ presetSourceUrl: e.target.value })}
style={{ width: 300 }}
/>
</div>
</div>
<div className="settings-section">
<h3>Behavior</h3>
+15
View File
@@ -447,6 +447,21 @@ select:focus, input[type="text"]:focus {
gap: 6px;
}
/* Progress bar */
.progress-bar {
width: 100%;
height: 6px;
background: var(--bg-tertiary);
border-radius: 3px;
overflow: hidden;
}
.progress-fill {
height: 100%;
border-radius: 3px;
transition: width 300ms ease, background 300ms ease;
}
/* Utility */
.mt-2 { margin-top: 8px; }
.mt-4 { margin-top: 16px; }
+42 -2
View File
@@ -1,8 +1,48 @@
import type { OmniApi } from '../main/preload';
import type {
EmulatorConfig,
EmulatorState,
GameEntry,
SystemInfo,
AppSettings,
InstallProgress,
ConfigPreset,
} from '../shared/types';
declare global {
interface Window {
omni: OmniApi;
omni: {
system: {
info: () => Promise<SystemInfo>;
platformName: () => Promise<string>;
};
emulators: {
list: () => Promise<EmulatorConfig[]>;
states: () => Promise<EmulatorState[]>;
check: (id: string) => Promise<EmulatorState>;
install: (id: string) => Promise<EmulatorState>;
configure: (id: string, installPath: string) => Promise<{ success: boolean; state: EmulatorState }>;
presets: (id: string) => Promise<ConfigPreset[]>;
configured: (id: string, installPath?: string) => Promise<boolean>;
openWebsite: (id: string) => Promise<boolean>;
onInstallProgress: (cb: (p: InstallProgress) => void) => () => void;
};
roms: {
scan: (directory?: string) => Promise<GameEntry[]>;
selectDirectory: () => Promise<string | null>;
};
game: {
launch: (emulatorId: string, romPath: string) => Promise<boolean>;
};
settings: {
get: () => Promise<AppSettings>;
save: (s: Partial<AppSettings>) => Promise<AppSettings>;
reset: () => Promise<AppSettings>;
};
paths: {
romsDirectory: () => Promise<string>;
emulatorsDirectory: () => Promise<string>;
};
};
}
}
+2 -2
View File
@@ -2,9 +2,9 @@
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "dist/renderer",
"rootDir": "src/renderer",
"types": ["vite/client"],
"jsx": "react-jsx"
"jsx": "react-jsx",
"noEmit": true
},
"include": ["src/renderer/**/*", "src/shared/**/*"]
}