diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 8755b82..799dfc7 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -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') diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..a107989 --- /dev/null +++ b/.npmrc @@ -0,0 +1,2 @@ +# Allow electron's postinstall script to download the binary +ignore-scripts=false diff --git a/assets/icon.png b/assets/icon.png new file mode 100644 index 0000000..799ff7a Binary files /dev/null and b/assets/icon.png differ diff --git a/package-lock.json b/package-lock.json index a67487d..9edacff 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index f81a688..9aee35d 100644 --- a/package.json +++ b/package.json @@ -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": { + "electron@43.1.0": true, + "esbuild@0.25.12": true, + "fsevents@2.3.3": true } } diff --git a/scripts/build-mac.sh b/scripts/build-mac.sh index cd73cbe..97901b4 100755 --- a/scripts/build-mac.sh +++ b/scripts/build-mac.sh @@ -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 diff --git a/scripts/generate-icons.js b/scripts/generate-icons.js new file mode 100644 index 0000000..201402a --- /dev/null +++ b/scripts/generate-icons.js @@ -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'); diff --git a/scripts/generate-icons.py b/scripts/generate-icons.py new file mode 100644 index 0000000..caa8103 --- /dev/null +++ b/scripts/generate-icons.py @@ -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)') diff --git a/src/main/emulators.ts b/src/main/emulators.ts index e20bf39..9006757 100644 --- a/src/main/emulators.ts +++ b/src/main/emulators.ts @@ -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 = { dolphin: [ join(omniEmuDir, 'Dolphin.exe'), diff --git a/src/main/index.ts b/src/main/index.ts index 8a4887e..9659a7e 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -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'); - mainWindow.webContents.openDevTools(); + 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(); diff --git a/src/main/installer.ts b/src/main/installer.ts index ee366a1..5bd9aeb 100644 --- a/src/main/installer.ts +++ b/src/main/installer.ts @@ -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 { return new Promise((resolve, reject) => { - const protocol = url.startsWith('https') ? get : getHttp; - protocol(url, (response) => { - const code = response.statusCode ?? 500; - if (code >= 300 && code < 400 && response.headers.location) { - downloadFile(response.headers.location, dest, onProgress).then(resolve).catch(reject); - return; - } - if (code !== 200) { - reject(new Error(`HTTP ${code} downloading ${url}`)); - return; - } - - const total = parseInt(response.headers['content-length'] ?? '0', 10); - let downloaded = 0; - const file = 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); + 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) { + doRequest(response.headers.location); + return; + } + if (code !== 200) { + reject(new Error(`HTTP ${code} โ€” ${currentUrl}`)); + return; + } + const total = parseInt(response.headers['content-length'] ?? '0', 10); + let downloaded = 0; + const fileStream = createWriteStream(dest); + response.on('data', (chunk: Buffer) => { + downloaded += chunk.length; + if (total > 0) onProgress(Math.round((downloaded / total) * 100)); + }); + 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 { - // 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); +} diff --git a/src/main/ipc.ts b/src/main/ipc.ts index 9d8beab..6aedc86 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -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); diff --git a/src/renderer/pages/EmulatorsPage.tsx b/src/renderer/pages/EmulatorsPage.tsx index 7612804..83129f2 100644 --- a/src/renderer/pages/EmulatorsPage.tsx +++ b/src/renderer/pages/EmulatorsPage.tsx @@ -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([]); const [loading, setLoading] = useState(true); + const [progress, setProgress] = useState({}); + const [actioning, setActioning] = useState(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
Checking emulators...
; } @@ -31,74 +96,164 @@ export function EmulatorsPage() {
๐Ÿ•น๏ธ

No emulators configured

-

Add emulator definitions to get started.

+

Ready to install and configure emulators automatically.

); } + const currentProgress = (id: string) => progress[id]; + return (
-
+
+ + {states.filter((s) => s.installed).length} installed + {' ยท '} + {states.filter((s) => s.configured).length} configured + {' ยท '} + {Object.keys(progress).length > 0 && ' Installing...'} +
- {states.map((state) => ( -
-
-

{state.config.name}

- - {!state.config.supported - ? 'Unsupported' - : state.installed - ? 'Installed' - : 'Not installed'} - -
+ {states.map((state) => { + const prog = currentProgress(state.config.id); + const isActioning = actioning === state.config.id; -

{state.config.description}

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

- Version: {state.version} -

- )} - -
- {!state.installed && state.config.installUrl && ( - - )} - {state.installed && state.path && ( - - {state.path} + {!state.config.supported + ? 'Unsupported' + : state.installed + ? state.configured + ? 'Configured' + : 'Installed' + : 'Not installed'} +
+ +

{state.config.description}

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

Version: {state.version}

)} + + {prog && ( +
+
+
+
+

+ {prog.stage === 'error' + ? `Error: ${prog.error || prog.message}` + : prog.message} +

+
+ )} + +
+ {!state.installed && state.config.supported && ( + <> + + + + )} + + {!state.installed && !state.config.supported && ( + Not supported on this platform + )} + + {state.installed && !state.configured && ( + + )} + + {state.installed && state.configured && ( + <> + + + Configured + + + )} + + {state.config.websiteUrl && ( + + )} +
-
- ))} + ); + })}
); diff --git a/src/renderer/pages/LibraryPage.tsx b/src/renderer/pages/LibraryPage.tsx index df0e297..201deaa 100644 --- a/src/renderer/pages/LibraryPage.tsx +++ b/src/renderer/pages/LibraryPage.tsx @@ -2,79 +2,97 @@ import React, { useEffect, useState, useCallback } from 'react'; import type { GameEntry } from '../../shared/types'; const platformIcons: Record = { - nes: '๐Ÿ•น๏ธ', - snes: '๐Ÿ•น๏ธ', - n64: '๐ŸŽฎ', - gba: '๐ŸŽฎ', - gbc: '๐ŸŽฎ', - ps1: '๐Ÿ’ฟ', - ps2: '๐Ÿ’ฟ', - ps3: '๐Ÿ’ฟ', - wii: '๐Ÿ“€', - gc: '๐Ÿ“€', - switch: '๐Ÿ•น๏ธ', - arcade: '๐Ÿ•น๏ธ', + nes: '๐Ÿ•น๏ธ', snes: '๐Ÿ•น๏ธ', n64: '๐ŸŽฎ', + gba: '๐ŸŽฎ', gbc: '๐ŸŽฎ', + ps1: '๐Ÿ’ฟ', ps2: '๐Ÿ’ฟ', ps3: '๐Ÿ’ฟ', + wii: '๐Ÿ“€', gc: '๐Ÿ“€', + switch: '๐Ÿ•น๏ธ', arcade: '๐Ÿ•น๏ธ', }; export function LibraryPage() { const [games, setGames] = useState([]); const [loading, setLoading] = useState(false); - const [selectedDir, setSelectedDir] = useState(null); + const [romsDir, setRomsDir] = useState(''); - 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 (
-
+
- {selectedDir && ( - - {selectedDir} + {romsDir && ( + + {romsDir} )}
{loading &&
Scanning for games...
} - {!loading && games.length === 0 && ( -
+ {!loading && games.length === 0 && !romsDir && ( +
๐Ÿ“‚
-

Click to select a ROM directory

+

Select a folder to scan for ROMs

- Supported formats: .nes, .sfc, .n64, .gba, .iso, .wbfs, .nsp, .ps2, and more + Your ROM directory will be saved for next time

)} + {!loading && games.length === 0 && romsDir && ( +
+
๐Ÿ”
+

No ROMs found in that folder

+

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

+ +
+ )} + {games.length > 0 && (
{games.length} games found - {(games.length / 10).toFixed(1)} GB estimated +
@@ -91,9 +109,7 @@ export function LibraryPage() {
{game.title}
- {game.platform.toUpperCase()} - {' ยท '} - {game.emulatorId} + {game.platform.toUpperCase()} ยท {game.emulatorId}
diff --git a/src/renderer/pages/SettingsPage.tsx b/src/renderer/pages/SettingsPage.tsx index 3f62c91..66a3c94 100644 --- a/src/renderer/pages/SettingsPage.tsx +++ b/src/renderer/pages/SettingsPage.tsx @@ -84,6 +84,25 @@ export function SettingsPage() {
+
+

Presets

+ +
+
+
Preset Source URL
+
+ URL to fetch recommended emulator config presets from +
+
+ update({ presetSourceUrl: e.target.value })} + style={{ width: 300 }} + /> +
+
+

Behavior

diff --git a/src/renderer/styles.css b/src/renderer/styles.css index d284423..09a7dc3 100644 --- a/src/renderer/styles.css +++ b/src/renderer/styles.css @@ -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; } diff --git a/src/renderer/types.ts b/src/renderer/types.ts index f65e5e5..3005192 100644 --- a/src/renderer/types.ts +++ b/src/renderer/types.ts @@ -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; + platformName: () => Promise; + }; + emulators: { + list: () => Promise; + states: () => Promise; + check: (id: string) => Promise; + install: (id: string) => Promise; + configure: (id: string, installPath: string) => Promise<{ success: boolean; state: EmulatorState }>; + presets: (id: string) => Promise; + configured: (id: string, installPath?: string) => Promise; + openWebsite: (id: string) => Promise; + onInstallProgress: (cb: (p: InstallProgress) => void) => () => void; + }; + roms: { + scan: (directory?: string) => Promise; + selectDirectory: () => Promise; + }; + game: { + launch: (emulatorId: string, romPath: string) => Promise; + }; + settings: { + get: () => Promise; + save: (s: Partial) => Promise; + reset: () => Promise; + }; + paths: { + romsDirectory: () => Promise; + emulatorsDirectory: () => Promise; + }; + }; } } diff --git a/tsconfig.renderer.json b/tsconfig.renderer.json index 2604eaf..9577ae3 100644 --- a/tsconfig.renderer.json +++ b/tsconfig.renderer.json @@ -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/**/*"] }