diff --git a/.github/workflows/flatpak.yml b/.github/workflows/flatpak.yml index 2a01d78a..8780994f 100644 --- a/.github/workflows/flatpak.yml +++ b/.github/workflows/flatpak.yml @@ -45,7 +45,7 @@ jobs: METAINFO_PATH: dev.mrquantumoff.mcmodpackmanager.metainfo.xml GH_TOKEN: ${{ secrets.FLATHUB_GH_TOKEN }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 with: ref: ${{ env.TAG }} diff --git a/.github/workflows/msstore.yml b/.github/workflows/msstore.yml index 03d99203..9c918d97 100644 --- a/.github/workflows/msstore.yml +++ b/.github/workflows/msstore.yml @@ -22,7 +22,7 @@ jobs: # args: "" runs-on: ${{ matrix.platform }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Setup bun uses: oven-sh/setup-bun@v2 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4d843ddc..86a5e3e6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -11,7 +11,36 @@ on: # This workflow will trigger on each push to the `release` branch to create or update a GitHub release, build your app, and upload the artifacts to the release. jobs: + ensure-release: + permissions: + contents: write + runs-on: ubuntu-24.04 + steps: + - name: Ensure GitHub release exists + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAG_NAME: ${{ github.ref_name }} + REPO: ${{ github.repository }} + shell: bash + run: | + if gh release view "$TAG_NAME" --repo "$REPO" >/dev/null 2>&1; then + exit 0 + fi + + version="${TAG_NAME#v}" + prerelease_flag=() + if [[ "$TAG_NAME" != *stable ]]; then + prerelease_flag+=(--prerelease) + fi + + gh release create "$TAG_NAME" \ + --repo "$REPO" \ + --title "Quadrant v${version}" \ + --notes "See the assets to download this version and install. Check out the [changelog on my blog](https://blog.mrquantumoff.dev)" \ + "${prerelease_flag[@]}" + publish-tauri: + needs: ensure-release permissions: contents: write strategy: @@ -32,7 +61,7 @@ jobs: assets: ${{steps.tauri.outputs.releaseUploadUrl}} version: ${{steps.tauri.outputs.appVersion}} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Setup bun uses: oven-sh/setup-bun@v2 @@ -74,6 +103,7 @@ jobs: tagName: ${{github.ref_name}} publish-electron: + needs: ensure-release permissions: contents: write strategy: @@ -94,7 +124,7 @@ jobs: arch: "arm64" runs-on: ${{ matrix.platform }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Setup bun uses: oven-sh/setup-bun@v2 @@ -106,7 +136,7 @@ jobs: if: matrix.os == 'linux' run: | sudo apt update - sudo apt install -y libsecret-1-dev libarchive-tools rpm + sudo apt install -y xdg-utils libappindicator3-dev librsvg2-dev patchelf libdbus-1-dev pkg-config libsecret-1-dev libarchive-tools rpm - name: install frontend dependencies (bun) run: bun install @@ -128,16 +158,64 @@ jobs: env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} TAG_NAME: ${{ github.ref_name }} + REPO: ${{ github.repository }} shell: bash run: | - mapfile -d '' files < <(find dist-electron -maxdepth 1 -type f -print0) - gh release upload "$TAG_NAME" "${files[@]}" --clobber + mapfile -d '' files < <( + if [[ "${{ matrix.os }}" == "windows" ]]; then + find dist-electron -maxdepth 1 -type f -not -name 'latest.yml' -print0 + else + find dist-electron -maxdepth 1 -type f -print0 + fi + ) + gh release upload "$TAG_NAME" "${files[@]}" --clobber --repo "$REPO" + + - name: Upload Windows Electron update metadata + if: matrix.os == 'windows' + uses: actions/upload-artifact@v4 + with: + name: electron-windows-${{ matrix.arch }}-latest-yml + path: dist-electron/latest.yml + if-no-files-found: error + retention-days: 1 + + publish-electron-windows-metadata: + needs: publish-electron + permissions: + contents: write + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v6 + + - name: Download Windows x64 update metadata + uses: actions/download-artifact@v4 + with: + name: electron-windows-x64-latest-yml + path: artifacts/windows-x64 + + - name: Download Windows arm64 update metadata + uses: actions/download-artifact@v4 + with: + name: electron-windows-arm64-latest-yml + path: artifacts/windows-arm64 + + - name: Merge Windows Electron update metadata + run: | + python3 scripts/merge-electron-latest-yml.py \ + --base artifacts/windows-x64/latest.yml \ + --extra artifacts/windows-arm64/latest.yml \ + --output artifacts/windows/latest.yml + + - name: Upload merged Windows latest.yml to release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAG_NAME: ${{ github.ref_name }} + REPO: ${{ github.repository }} + run: gh release upload "$TAG_NAME" artifacts/windows/latest.yml --clobber --repo "$REPO" sync_flathub: name: Sync Flathub - needs: - - publish-tauri - - publish-electron + needs: publish-electron-windows-metadata if: endsWith(github.ref_name, '-stable') || endsWith(github.ref_name, '-flatpak') uses: ./.github/workflows/flatpak.yml secrets: inherit diff --git a/.github/workflows/validate-desktop.yml b/.github/workflows/validate-desktop.yml index fde1430a..5a924b90 100644 --- a/.github/workflows/validate-desktop.yml +++ b/.github/workflows/validate-desktop.yml @@ -19,7 +19,7 @@ jobs: QUADRANT_OAUTH2_CLIENT_SECRET: dev ETERNAL_API_TOKEN: dev steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Setup bun uses: oven-sh/setup-bun@v2 diff --git a/.gitignore b/.gitignore index b90e8dab..dc53890b 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,7 @@ lerna-debug.log* node_modules dist dist-electron +dist-electron-shell dist-ssr *.local @@ -41,4 +42,7 @@ target/ AppxContent AppxBundles electron/runtime-config.generated.json +dist-electron-shell/runtime-config.generated.json +packages/quadrant-node/index.js +packages/quadrant-node/index.d.ts packages/quadrant-node/native/ diff --git a/DEVELOP.md b/DEVELOP.md index 0d60d695..5487b5e8 100644 --- a/DEVELOP.md +++ b/DEVELOP.md @@ -8,6 +8,6 @@ You can develop the Quadrant Next client by: - Tauri development is available through `bun run dev:tauri`. - Electron development is available through `bun run dev:electron`. - To build the shared renderer only, run `bun run build`. -- To build the native Electron addon explicitly, run `bun run build:napi`. +- To build the native Electron addon explicitly, run `bun run build:napi`. This also regenerates the `packages/quadrant-node` wrapper files. - To package the Electron app, run `bun run package:electron`. - To build the Tauri app without any of the proprietary features, run `bun tauri dev -- -- --no-default-features`. diff --git a/bun.lock b/bun.lock index f9a30a92..355cd6a6 100644 --- a/bun.lock +++ b/bun.lock @@ -21,6 +21,8 @@ "@tauri-apps/plugin-os": "~2.3.2", "@tauri-apps/plugin-store": "2.4.2", "@tauri-apps/plugin-updater": "2.10.1", + "chokidar": "^5.0.0", + "electron-updater": "^6.8.3", "i18next": "26.0.3", "motion": "12.38.0", "react": "19.2.4", @@ -33,6 +35,7 @@ "@eslint/css": "^1.1.0", "@eslint/js": "^10.0.1", "@eslint/json": "^1.2.0", + "@rolldown/plugin-babel": "^0.2.0", "@tailwindcss/postcss": "4.2.2", "@tauri-apps/cli": "^2.10.1", "@types/node": "^25.5.2", @@ -41,10 +44,8 @@ "@vitejs/plugin-react": "^6.0.1", "autoprefixer": "^10.4.27", "babel-plugin-react-compiler": "1.0.0", - "chokidar": "^5.0.0", "electron": "^41.1.1", "electron-builder": "^26.8.1", - "electron-updater": "^6.8.3", "eslint": "^10.2.0", "eslint-config-prettier": "^10.1.8", "eslint-plugin-import": "^2.32.0", @@ -64,7 +65,6 @@ }, "packages/quadrant-node": { "name": "@quadrant/quadrant-node", - "version": "26.4.0", }, }, "trustedDependencies": [ @@ -272,6 +272,8 @@ "@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.0.0-rc.12", "", { "os": "win32", "cpu": "x64" }, "sha512-PyqoipaswDLAZtot351MLhrlrh6lcZPo2LSYE+VDxbVk24LVKAGOuE4hb8xZQmrPAuEtTZW8E6D2zc5EUZX4Lw=="], + "@rolldown/plugin-babel": ["@rolldown/plugin-babel@0.2.2", "", { "dependencies": { "picomatch": "^4.0.3" }, "peerDependencies": { "@babel/core": "^7.29.0 || ^8.0.0-rc.1", "@babel/plugin-transform-runtime": "^7.29.0 || ^8.0.0-rc.1", "@babel/runtime": "^7.27.0 || ^8.0.0-rc.1", "rolldown": "^1.0.0-rc.5", "vite": "^8.0.0" }, "optionalPeers": ["@babel/plugin-transform-runtime", "@babel/runtime", "vite"] }, "sha512-q9pE8+47bQNHb5eWVcE6oXppA+JTSwvnrhH53m0ZuHuK5MLvwsLoWrWzBTFQqQ06BVxz1gp0HblLsch8o6pvZw=="], + "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.7", "", {}, "sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA=="], "@rtsao/scc": ["@rtsao/scc@1.1.0", "", {}, "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g=="], @@ -1328,7 +1330,7 @@ "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], - "semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], + "semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], "semver-compare": ["semver-compare@1.0.0", "", {}, "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow=="], @@ -1546,12 +1548,8 @@ "zod-validation-error": ["zod-validation-error@4.0.2", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ=="], - "@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], - "@babel/helper-compilation-targets/lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], - "@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], - "@electron/asar/commander": ["commander@5.1.0", "", {}, "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg=="], "@electron/asar/glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="], @@ -1562,12 +1560,12 @@ "@electron/get/fs-extra": ["fs-extra@8.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g=="], - "@electron/get/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], - "@electron/notarize/fs-extra": ["fs-extra@9.1.0", "", { "dependencies": { "at-least-node": "^1.0.0", "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ=="], "@electron/osx-sign/isbinaryfile": ["isbinaryfile@4.0.10", "", {}, "sha512-iHrqe5shvBUcFbmZq9zOQHBoeOhZJu6RQGrDpBgenUm/Am+F3JM2MgQj+rK3Z601fzrL5gLZWtAPH2OBaSVcyw=="], + "@electron/rebuild/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], + "@electron/universal/fs-extra": ["fs-extra@11.3.4", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-CTXd6rk/M3/ULNQj8FBqBWHYBVYybQ3VPBw0xGKFe3tuH7ytT6ACnvzpIQ3UZtB8yvUKC2cXn1a+x+5EVQLovA=="], "@electron/universal/minimatch": ["minimatch@9.0.9", "", { "dependencies": { "brace-expansion": "^2.0.2" } }, "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg=="], @@ -1594,6 +1592,8 @@ "@npmcli/agent/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], + "@npmcli/fs/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], + "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.9.2", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" }, "bundled": true }, "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA=="], "@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.9.2", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw=="], @@ -1636,6 +1636,8 @@ "@typescript-eslint/typescript-estree/@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.58.0", "", { "dependencies": { "@typescript-eslint/types": "8.58.0", "eslint-visitor-keys": "^5.0.0" } }, "sha512-XJ9UD9+bbDo4a4epraTwG3TsNPeiB9aShrUneAVXy8q4LuwowN+qu89/6ByLMINqvIMeI9H9hOHQtg/ijrYXzQ=="], + "@typescript-eslint/typescript-estree/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], + "@typescript-eslint/utils/@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.58.0", "", { "dependencies": { "@typescript-eslint/types": "8.58.0", "@typescript-eslint/visitor-keys": "8.58.0" } }, "sha512-W1Lur1oF50FxSnNdGp3Vs6P+yBRSmZiw4IIjEeYxd8UQJwhUF0gDgDD/W/Tgmh73mxgEU3qX0Bzdl/NGuSPEpQ=="], "@typescript-eslint/utils/@typescript-eslint/types": ["@typescript-eslint/types@8.58.0", "", {}, "sha512-O9CjxypDT89fbHxRfETNoAnHj/i6IpRK0CvbVN3qibxlLdo5p5hcLmUuCCrHMpxiWSwKyI8mCP7qRNYuOJ0Uww=="], @@ -1646,6 +1648,8 @@ "app-builder-lib/ci-info": ["ci-info@4.3.1", "", {}, "sha512-Wdy2Igu8OcBpI2pZePZ5oWjPC38tmDVx5WKUXKwlLYkA0ozo85sLsLvkBbBn/sZaSCMFOGZJ14fvW9t5/d7kdA=="], + "app-builder-lib/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], + "cacache/glob": ["glob@10.5.0", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg=="], "cacache/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], @@ -1658,6 +1662,8 @@ "electron/@types/node": ["@types/node@24.12.2", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g=="], + "electron-updater/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], + "electron-winstaller/fs-extra": ["fs-extra@7.0.1", "", { "dependencies": { "graceful-fs": "^4.1.2", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw=="], "eslint/@eslint/plugin-kit": ["@eslint/plugin-kit@0.7.0", "", { "dependencies": { "@eslint/core": "^1.2.0", "levn": "^0.4.1" } }, "sha512-ejvBr8MQCbVsWNZnCwDXjUKq40MDmHalq7cJ6e9s/qzTUFIIo/afzt1Vui9T97FM/V/pN4YsFVoed5NIa96RDg=="], @@ -1670,20 +1676,18 @@ "eslint-plugin-import/minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="], - "eslint-plugin-import/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], - "eslint-plugin-jsx-a11y/minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="], "eslint-plugin-react/minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="], - "eslint-plugin-react/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], - "fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], "filelist/minimatch": ["minimatch@5.1.9", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw=="], "foreground-child/signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], + "global-agent/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], + "has-ansi/ansi-regex": ["ansi-regex@2.1.1", "", {}, "sha512-TIGnTpdo+E3+pCyAluZvtED5p5wCqLdezCyhPZzKPcxvFplEt4i+W7OONCKgeZFT3+y5NZZfOOS/Bdcanm1MYA=="], "hosted-git-info/lru-cache": ["lru-cache@6.0.0", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA=="], @@ -1700,7 +1704,11 @@ "ml-random/ml-xsadd": ["ml-xsadd@2.0.0", "", {}, "sha512-VoAYUqmPRmzKbbqRejjqceGFp3VF81Qe8XXFGU0UXLxB7Mf4GGvyGq5Qn3k4AiQgDEV6WzobqlPOd+j0+m6IrA=="], - "node-exports-info/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + "node-abi/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], + + "node-api-version/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], + + "node-gyp/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], "postject/commander": ["commander@9.5.0", "", {}, "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ=="], @@ -1716,6 +1724,8 @@ "serialize-error/type-fest": ["type-fest@0.13.1", "", {}, "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg=="], + "simple-update-notifier/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], + "string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], "string-width-cjs/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], @@ -1734,6 +1744,8 @@ "vue-eslint-parser/espree": ["espree@9.6.1", "", { "dependencies": { "acorn": "^8.9.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^3.4.1" } }, "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ=="], + "vue-eslint-parser/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], + "@babel/helper-compilation-targets/lru-cache/yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], "@electron/asar/minimatch/brace-expansion": ["brace-expansion@1.1.13", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w=="], @@ -1774,6 +1786,8 @@ "@typescript-eslint/parser/@typescript-eslint/typescript-estree/minimatch": ["minimatch@9.0.3", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg=="], + "@typescript-eslint/parser/@typescript-eslint/typescript-estree/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], + "@typescript-eslint/parser/@typescript-eslint/typescript-estree/ts-api-utils": ["ts-api-utils@1.4.3", "", { "peerDependencies": { "typescript": ">=4.2.0" } }, "sha512-i3eMG77UTMD0hZhgRS562pv83RC6ukSAC2GMNWc+9dieh/+jDM5u5YG+NHX6VNDRHQcHwmsTHctP9LhbC3WxVw=="], "@typescript-eslint/utils/@typescript-eslint/scope-manager/@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.58.0", "", { "dependencies": { "@typescript-eslint/types": "8.58.0", "eslint-visitor-keys": "^5.0.0" } }, "sha512-XJ9UD9+bbDo4a4epraTwG3TsNPeiB9aShrUneAVXy8q4LuwowN+qu89/6ByLMINqvIMeI9H9hOHQtg/ijrYXzQ=="], diff --git a/dev.mrquantumoff.mcmodpackmanager.metainfo.xml b/dev.mrquantumoff.mcmodpackmanager.metainfo.xml index e66866ee..6e2805db 100644 --- a/dev.mrquantumoff.mcmodpackmanager.metainfo.xml +++ b/dev.mrquantumoff.mcmodpackmanager.metainfo.xml @@ -44,7 +44,7 @@ - + https://blog.mrquantumoff.dev

Add Electron support as an alternative runtime

diff --git a/docs/desktop-backends.md b/docs/desktop-backends.md index fbae66e8..7a42eaf6 100644 --- a/docs/desktop-backends.md +++ b/docs/desktop-backends.md @@ -24,8 +24,9 @@ Quadrant supports two desktop shells that share one renderer and one backend con ## Electron Backend -- Shell entrypoint: `electron/main.mjs` -- Preload bridge: `electron/preload.mjs` +- Shell entrypoint source: `electron/main.ts` +- Preload bridge source: `electron/preload.ts` +- Built Electron shell output: `dist-electron-shell/*.js` - Renderer bridge: `src/desktop/electron.ts` - Backend implementation: `@quadrant/quadrant-node` -> `quadrant-napi` -> `quadrant-host` diff --git a/electron-builder.json b/electron-builder.json index 2e07e4f1..b7569beb 100644 --- a/electron-builder.json +++ b/electron-builder.json @@ -6,13 +6,13 @@ }, "files": [ "dist/**/*", - "electron/**/*", + "dist-electron-shell/**/*", "packages/quadrant-node/**/*", "public/tray.png", "package.json" ], "extraMetadata": { - "main": "electron/main.mjs" + "main": "dist-electron-shell/main.js" }, "asar": true, "asarUnpack": ["packages/quadrant-node/native/**/*.node"], diff --git a/electron/main.mjs b/electron/main.ts similarity index 57% rename from electron/main.mjs rename to electron/main.ts index f03f23e0..33ac8784 100644 --- a/electron/main.mjs +++ b/electron/main.ts @@ -1,6 +1,28 @@ -/** @format */ - +/** + * eslint-disable no-undef + * + * @format + */ + +import electron, { + type BrowserWindow as BrowserWindowType, + type OpenDialogOptions, + type Tray as TrayType, +} from "electron"; +import chokidar, { type FSWatcher } from "chokidar"; +import electronUpdater from "electron-updater"; import { + createQuadrantClient, + type QuadrantClient, +} from "@quadrant/quadrant-node"; +import fs from "node:fs"; +import fsPromises from "node:fs/promises"; +import http from "node:http"; +import { randomUUID } from "node:crypto"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const { app, BrowserWindow, Menu, @@ -10,25 +32,57 @@ import { ipcMain, nativeTheme, shell, -} from "electron"; -import electronUpdater from "electron-updater"; -import chokidar from "chokidar"; -import { createQuadrantClient } from "@quadrant/quadrant-node"; -import fs from "node:fs"; -import fsPromises from "node:fs/promises"; -import http from "node:http"; -import { randomUUID } from "node:crypto"; -import path from "node:path"; -import { fileURLToPath } from "node:url"; +} = electron; +const { autoUpdater } = electronUpdater; + +interface RuntimeConfig { + oauthClientId: string; + oauthClientSecret: string; + quadrantApiKey: string; + apiBaseUrl: string | null; +} + +type RuntimeConfigFile = Partial; + +interface StoreData { + [key: string]: unknown; +} + +interface FsWatchOptions { + delayMs?: number; +} + +interface DesktopDialogOptions { + mode?: "open" | "save"; + multiple?: boolean; + directory?: boolean; + recursive?: boolean; + title?: string; + defaultPath?: string; +} + +interface WindowProgressState { + progress: number; + status?: "none" | "normal" | "error" | "paused" | "indeterminate"; +} + +interface OAuthStartOptions { + response?: string; + ports?: number[]; +} + +interface BackendEventEnvelope { + event: string; + payload: T; +} const __dirname = path.dirname(fileURLToPath(import.meta.url)); const rootDir = path.resolve(__dirname, ".."); const appId = "dev.mrquantumoff.mcmodpackmanager"; const schemes = ["quadrantnext", "curseforge", "modrinth"]; -const { autoUpdater } = electronUpdater; const packageMetadata = JSON.parse( fs.readFileSync(path.join(rootDir, "package.json"), "utf8"), -); +) as { version?: unknown }; const quadrantAppVersion = ( typeof packageMetadata.version === "string" && @@ -44,31 +98,32 @@ const isAutostart = argv.includes("--autostart"); const updaterDisabledByCli = argv.includes("--noupdater"); const devServerUrl = process.env.QUADRANT_ELECTRON_DEV_SERVER_URL; -let mainWindow = null; -let tray = null; -let quadrantClient = null; +let mainWindow: BrowserWindowType | null = null; +// eslint-disable-next-line @typescript-eslint/no-unused-vars +let tray: TrayType | null = null; +let quadrantClient: QuadrantClient | null = null; let updaterConfigured = false; let updateDownloaded = false; -const storeCache = new Map(); -const storeWriteQueues = new Map(); -const watchRegistry = new Map(); -const oauthServers = new Map(); -const pendingDeepLinks = []; +const storeCache = new Map(); +const storeWriteQueues = new Map>(); +const watchRegistry = new Map(); +const oauthServers = new Map(); +const pendingDeepLinks: string[] = []; let openUrlRendererReady = false; -function broadcast(channel, payload) { +function broadcast(channel: string, payload: unknown): void { for (const window of BrowserWindow.getAllWindows()) { window.webContents.send(channel, payload); } } -function parseDeepLinkUrls(values) { +function parseDeepLinkUrls(values: string[]): string[] { return values.filter((value) => schemes.some((scheme) => value.startsWith(`${scheme}:`)), ); } -function flushPendingDeepLinks() { +function flushPendingDeepLinks(): void { if (!mainWindow || !openUrlRendererReady || pendingDeepLinks.length === 0) { return; } @@ -76,10 +131,9 @@ function flushPendingDeepLinks() { mainWindow.webContents.send("quadrant:open-url", urls); } -function loadGeneratedRuntimeConfig() { +function loadGeneratedRuntimeConfig(): RuntimeConfigFile { const runtimeConfigPath = path.join( - rootDir, - "electron", + __dirname, "runtime-config.generated.json", ); @@ -87,19 +141,21 @@ function loadGeneratedRuntimeConfig() { return {}; } - return JSON.parse(fs.readFileSync(runtimeConfigPath, "utf8")); + return JSON.parse( + fs.readFileSync(runtimeConfigPath, "utf8"), + ) as RuntimeConfigFile; } -function normalizeOptionalConfigValue(value) { +function normalizeOptionalConfigValue(value: unknown): string | null { if (typeof value !== "string") { - return value ?? null; + return value == null ? null : String(value); } const trimmed = value.trim(); return trimmed.length > 0 ? trimmed : null; } -function normalizeRequiredConfigValue(value) { +function normalizeRequiredConfigValue(value: unknown): string { if (typeof value !== "string") { return ""; } @@ -107,7 +163,7 @@ function normalizeRequiredConfigValue(value) { return value.trim(); } -function getRuntimeConfig() { +function getRuntimeConfig(): RuntimeConfig { const generated = loadGeneratedRuntimeConfig(); return { oauthClientId: normalizeRequiredConfigValue( @@ -125,7 +181,7 @@ function getRuntimeConfig() { }; } -function ensureRuntimeSecrets(config) { +function ensureRuntimeSecrets(config: RuntimeConfig): void { if ( !config.oauthClientId || !config.oauthClientSecret || @@ -137,23 +193,23 @@ function ensureRuntimeSecrets(config) { } } -function getWindow() { +function getWindow(): BrowserWindowType { if (!mainWindow) { throw new Error("Main window is not ready"); } return mainWindow; } -function resolveIconPath() { +function resolveIconPath(): string { return path.join(rootDir, "src-tauri", "icons", "128x128.png"); } -function resolveTrayIconPath() { +function resolveTrayIconPath(): string { const trayIconPath = path.join(rootDir, "public", "tray.png"); return fs.existsSync(trayIconPath) ? trayIconPath : resolveIconPath(); } -function normalizeDesktopPlatform(platform) { +function normalizeDesktopPlatform(platform: NodeJS.Platform): string { switch (platform) { case "win32": return "windows"; @@ -164,22 +220,24 @@ function normalizeDesktopPlatform(platform) { } } -function storeFilePath(storeName) { +function storeFilePath(storeName: string): string { return path.join(app.getPath("userData"), storeName); } -async function readStore(storeName) { +async function readStore(storeName: string): Promise { const filePath = storeFilePath(storeName); await fsPromises.mkdir(path.dirname(filePath), { recursive: true }); - let data = {}; + let data: StoreData = {}; if (fs.existsSync(filePath)) { try { - data = JSON.parse(await fsPromises.readFile(filePath, "utf8")); + data = JSON.parse( + await fsPromises.readFile(filePath, "utf8"), + ) as StoreData; } catch (error) { if (storeCache.has(storeName)) { console.warn(`Failed to parse ${filePath}, using cached copy`, error); - return storeCache.get(storeName); + return storeCache.get(storeName) ?? {}; } console.warn(`Failed to parse ${filePath}`, error); } @@ -189,13 +247,19 @@ async function readStore(storeName) { return data; } -async function writeStoreFile(filePath, data) { +async function writeStoreFile( + filePath: string, + data: StoreData, +): Promise { const tempPath = `${filePath}.${randomUUID()}.tmp`; await fsPromises.writeFile(tempPath, JSON.stringify(data, null, 2)); await fsPromises.rename(tempPath, filePath); } -async function saveStore(storeName, dataOverride) { +async function saveStore( + storeName: string, + dataOverride?: StoreData, +): Promise { const data = dataOverride ?? storeCache.get(storeName) ?? (await readStore(storeName)); const filePath = storeFilePath(storeName); @@ -203,9 +267,12 @@ async function saveStore(storeName, dataOverride) { await writeStoreFile(filePath, data); } -function queueStoreWrite(storeName, operation) { +function queueStoreWrite( + storeName: string, + operation: () => Promise, +): Promise { const previous = storeWriteQueues.get(storeName) ?? Promise.resolve(); - const next = previous.catch(() => {}).then(operation); + const next = previous.catch(() => undefined).then(operation); storeWriteQueues.set(storeName, next); return next.finally(() => { if (storeWriteQueues.get(storeName) === next) { @@ -214,7 +281,11 @@ function queueStoreWrite(storeName, operation) { }); } -async function setStoreValue(storeName, key, value) { +async function setStoreValue( + storeName: string, + key: string, + value: unknown, +): Promise { await queueStoreWrite(storeName, async () => { const data = await readStore(storeName); data[key] = value; @@ -224,7 +295,7 @@ async function setStoreValue(storeName, key, value) { broadcast("quadrant:store:changed", { storeName, key, value }); } -function queueDeepLinks(urls) { +function queueDeepLinks(urls: string[]): void { if (urls.length === 0) { return; } @@ -234,13 +305,11 @@ function queueDeepLinks(urls) { } } -async function createQuadrantHostClient() { +async function createQuadrantHostClient(): Promise { if (quadrantClient) { return quadrantClient; } - // Electron talks to the same QuadrantHost backend contract as Tauri, but it - // instantiates it through the N-API addon instead of Rust invoke handlers. const runtimeConfig = getRuntimeConfig(); ensureRuntimeSecrets(runtimeConfig); @@ -275,9 +344,10 @@ async function createQuadrantHostClient() { return quadrantClient; } -async function configureUpdater() { +async function configureUpdater(): Promise { const updateStore = await readStore("updateConfig.json"); - const channel = updateStore.channel ?? "stable"; + const channel = + typeof updateStore.channel === "string" ? updateStore.channel : "stable"; autoUpdater.allowPrerelease = channel !== "stable"; if (updaterConfigured) { @@ -292,7 +362,7 @@ async function configureUpdater() { broadcast("quadrant:backend-event", { event: "updateDownloadProgress", payload: progress.percent / 100, - }); + } satisfies BackendEventEnvelope); }); autoUpdater.on("update-downloaded", () => { @@ -300,7 +370,7 @@ async function configureUpdater() { broadcast("quadrant:backend-event", { event: "updateDownloadProgress", payload: 1, - }); + } satisfies BackendEventEnvelope); }); autoUpdater.on("update-not-available", () => { @@ -308,11 +378,11 @@ async function configureUpdater() { }); } -function isAutoupdateEnabled() { +function isAutoupdateEnabled(): boolean { return app.isPackaged && !updaterDisabledByCli; } -async function requestCheckForUpdates() { +async function requestCheckForUpdates(): Promise { if (!isAutoupdateEnabled()) { return; } @@ -321,7 +391,7 @@ async function requestCheckForUpdates() { await autoUpdater.checkForUpdates(); } -function createMainWindow() { +function createMainWindow(): BrowserWindowType { openUrlRendererReady = false; nativeTheme.themeSource = "dark"; @@ -336,7 +406,7 @@ function createMainWindow() { backgroundColor: "#020617", icon: resolveIconPath(), webPreferences: { - preload: path.join(__dirname, "preload.mjs"), + preload: path.join(__dirname, "preload.js"), contextIsolation: true, nodeIntegration: false, sandbox: false, @@ -365,16 +435,16 @@ function createMainWindow() { }); if (devServerUrl) { - window.loadURL(devServerUrl); + void window.loadURL(devServerUrl); window.webContents.openDevTools({ mode: "detach" }); } else { - window.loadFile(path.join(rootDir, "dist", "index.html")); + void window.loadFile(path.join(rootDir, "dist", "index.html")); } return window; } -function showMainWindow() { +function showMainWindow(): void { if (!mainWindow) { return; } @@ -385,7 +455,7 @@ function showMainWindow() { } } -function toggleMainWindowVisibility() { +function toggleMainWindowVisibility(): void { if (!mainWindow) { return; } @@ -397,7 +467,7 @@ function toggleMainWindowVisibility() { } } -function createTray() { +function createTray(): TrayType { const nextTray = new Tray(resolveTrayIconPath()); const menu = Menu.buildFromTemplate([ { @@ -423,12 +493,12 @@ function createTray() { return nextTray; } -function registerProtocolHandlers() { +function registerProtocolHandlers(): void { for (const scheme of schemes) { if (process.defaultApp) { if (process.argv.length >= 2) { app.setAsDefaultProtocolClient(scheme, process.execPath, [ - path.resolve(process.argv[1]), + path.resolve(process.argv[1] ?? ""), ]); } continue; @@ -496,101 +566,131 @@ app.on("before-quit", async () => { } }); -ipcMain.handle("quadrant:invoke", async (_event, { command, payload }) => { - const client = await createQuadrantHostClient(); - return client.invoke(command, payload ?? null); -}); +ipcMain.handle( + "quadrant:invoke", + async (_event, payload: { command: string; payload?: unknown }) => { + const client = await createQuadrantHostClient(); + return client.invoke(payload.command, payload.payload ?? null); + }, +); -ipcMain.handle("quadrant:store:get", async (_event, { storeName, key }) => { - const store = await readStore(storeName); - return store[key]; -}); +ipcMain.handle( + "quadrant:store:get", + async (_event, payload: { storeName: string; key: string }) => { + const store = await readStore(payload.storeName); + return store[payload.key]; + }, +); ipcMain.handle( "quadrant:store:set", - async (_event, { storeName, key, value }) => { - await setStoreValue(storeName, key, value); + async ( + _event, + payload: { storeName: string; key: string; value: unknown }, + ) => { + await setStoreValue(payload.storeName, payload.key, payload.value); }, ); -ipcMain.handle("quadrant:store:save", async (_event, { storeName }) => { - await queueStoreWrite(storeName, async () => { - await saveStore(storeName); - }); -}); +ipcMain.handle( + "quadrant:store:save", + async (_event, payload: { storeName: string }) => { + await queueStoreWrite(payload.storeName, async () => { + await saveStore(payload.storeName); + }); + }, +); ipcMain.handle( "quadrant:fs-watch:start", - async (event, { watchId, targetPath, options }) => { - const watcher = chokidar.watch(targetPath, { + async ( + event, + payload: { watchId: string; targetPath: string; options?: FsWatchOptions }, + ) => { + const watcher = chokidar.watch(payload.targetPath, { ignoreInitial: true, awaitWriteFinish: - options?.delayMs ? + payload.options?.delayMs ? { - stabilityThreshold: options.delayMs, - pollInterval: Math.max(50, Math.floor(options.delayMs / 2)), + stabilityThreshold: payload.options.delayMs, + pollInterval: Math.max(50, Math.floor(payload.options.delayMs / 2)), } : false, }); const sendChange = () => { - event.sender.send("quadrant:fs-watch:event", { watchId }); + event.sender.send("quadrant:fs-watch:event", { + watchId: payload.watchId, + }); }; watcher.on("add", sendChange); watcher.on("change", sendChange); watcher.on("unlink", sendChange); watcher.on("addDir", sendChange); watcher.on("unlinkDir", sendChange); - watchRegistry.set(watchId, watcher); + watchRegistry.set(payload.watchId, watcher); }, ); -ipcMain.handle("quadrant:fs-watch:stop", async (_event, { watchId }) => { - const watcher = watchRegistry.get(watchId); - if (watcher) { - await watcher.close(); - watchRegistry.delete(watchId); - } -}); +ipcMain.handle( + "quadrant:fs-watch:stop", + async (_event, payload: { watchId: string }) => { + const watcher = watchRegistry.get(payload.watchId); + if (watcher) { + await watcher.close(); + watchRegistry.delete(payload.watchId); + } + }, +); -ipcMain.handle("quadrant:path:join", async (_event, { segments }) => { - return path.join(...segments); -}); +ipcMain.handle( + "quadrant:path:join", + async (_event, payload: { segments: string[] }) => + path.join(...payload.segments), +); -ipcMain.handle("quadrant:dialog:open", async (_event, { options }) => { - if (options?.mode === "save") { - const result = await dialog.showSaveDialog(getWindow(), { +ipcMain.handle( + "quadrant:dialog:open", + async (_event, payload: { options?: DesktopDialogOptions }) => { + const { options } = payload; + + if (options?.mode === "save") { + const result = await dialog.showSaveDialog(getWindow(), { + title: options.title, + defaultPath: options.defaultPath, + }); + + if (result.canceled) { + return null; + } + + return result.filePath ?? null; + } + + const properties: OpenDialogOptions["properties"] = [ + options?.directory ? "openDirectory" : "openFile", + ...(options?.multiple ? (["multiSelections"] as const) : []), + ...(options?.directory && options?.recursive ? + (["createDirectory"] as const) + : []), + ]; + + const result = await dialog.showOpenDialog(getWindow(), { title: options?.title, + properties, defaultPath: options?.defaultPath, - showOverwriteConfirmation: true, }); if (result.canceled) { return null; } - return result.filePath ?? null; - } - - const result = await dialog.showOpenDialog(getWindow(), { - title: options?.title, - properties: [ - options?.directory ? "openDirectory" : "openFile", - ...(options?.multiple ? ["multiSelections"] : []), - ...(options?.directory && options?.recursive ? ["createDirectory"] : []), - ], - defaultPath: options?.defaultPath, - }); - - if (result.canceled) { - return null; - } - - if (options?.multiple) { - return result.filePaths; - } + if (options?.multiple) { + return result.filePaths; + } - return result.filePaths[0] ?? null; -}); + return result.filePaths[0] ?? null; + }, +); ipcMain.handle("quadrant:open-url:listener-ready", (event) => { if (mainWindow && event.sender.id === mainWindow.webContents.id) { @@ -599,23 +699,32 @@ ipcMain.handle("quadrant:open-url:listener-ready", (event) => { } }); -ipcMain.handle("quadrant:shell:open-external", async (_event, { url }) => { - await shell.openExternal(url); -}); +ipcMain.handle( + "quadrant:shell:open-external", + async (_event, payload: { url: string }) => { + await shell.openExternal(payload.url); + }, +); -ipcMain.handle("quadrant:shell:open-path", async (_event, { targetPath }) => { - const errorMessage = await shell.openPath(targetPath); - if (errorMessage) { - throw new Error(errorMessage); - } -}); +ipcMain.handle( + "quadrant:shell:open-path", + async (_event, payload: { targetPath: string }) => { + const errorMessage = await shell.openPath(payload.targetPath); + if (errorMessage) { + throw new Error(errorMessage); + } + }, +); ipcMain.handle("quadrant:clipboard:read-text", async () => clipboard.readText(), ); -ipcMain.handle("quadrant:clipboard:write-text", async (_event, { text }) => { - clipboard.writeText(text); -}); +ipcMain.handle( + "quadrant:clipboard:write-text", + async (_event, payload: { text: string }) => { + clipboard.writeText(payload.text); + }, +); ipcMain.handle("quadrant:platform", async () => normalizeDesktopPlatform(process.platform), @@ -649,9 +758,12 @@ ipcMain.handle("quadrant:window:hide", async () => { getWindow().hide(); }); -ipcMain.handle("quadrant:window:set-enabled", async (_event, { enabled }) => { - getWindow().setEnabled(enabled); -}); +ipcMain.handle( + "quadrant:window:set-enabled", + async (_event, payload: { enabled: boolean }) => { + getWindow().setEnabled(payload.enabled); + }, +); ipcMain.handle("quadrant:window:set-focus", async () => { getWindow().focus(); @@ -666,61 +778,74 @@ ipcMain.handle("quadrant:window:unminimize", async () => { ipcMain.handle( "quadrant:window:set-progress-bar", - async (_event, { state }) => { + async (_event, payload: { state: WindowProgressState }) => { const window = getWindow(); const normalizedProgress = - typeof state.progress === "number" && state.progress <= 1 ? - state.progress - : state.progress / 100; - const progress = state.status === "none" ? -1 : normalizedProgress; + ( + typeof payload.state.progress === "number" && + payload.state.progress <= 1 + ) ? + payload.state.progress + : payload.state.progress / 100; + const progress = payload.state.status === "none" ? -1 : normalizedProgress; window.setProgressBar(progress); }, ); -ipcMain.handle("quadrant:oauth:start", async (_event, { options }) => { - const responseHtml = - options?.response ?? - "

You can return to Quadrant now.

"; - const ports = options?.ports ?? [4000, 4001, 4002, 4003, 4004, 4005]; - - for (const port of ports) { - try { - const server = http.createServer((request, response) => { - const url = `http://127.0.0.1:${port}${request.url ?? "/"}`; - broadcast("quadrant:oauth:url", url); - response.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }); - response.end(responseHtml); - }); - - await new Promise((resolve, reject) => { - server.once("error", reject); - server.listen(port, "127.0.0.1", () => resolve(undefined)); - }); - - oauthServers.set(port, server); - return port; - } catch (error) { - console.warn(`Failed to bind OAuth server on ${port}`, error); +ipcMain.handle( + "quadrant:oauth:start", + async (_event, payload: { options?: OAuthStartOptions }) => { + const responseHtml = + payload.options?.response ?? + "

You can return to Quadrant now.

"; + const ports = payload.options?.ports ?? [ + 4000, 4001, 4002, 4003, 4004, 4005, + ]; + + for (const port of ports) { + try { + const server = http.createServer((request, response) => { + const url = `http://127.0.0.1:${port}${request.url ?? "/"}`; + broadcast("quadrant:oauth:url", url); + response.writeHead(200, { + "Content-Type": "text/html; charset=utf-8", + }); + response.end(responseHtml); + }); + + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(port, "127.0.0.1", () => resolve()); + }); + + oauthServers.set(port, server); + return port; + } catch (error) { + console.warn(`Failed to bind OAuth server on ${port}`, error); + } } - } - throw new Error("No available OAuth callback port"); -}); + throw new Error("No available OAuth callback port"); + }, +); -ipcMain.handle("quadrant:oauth:cancel", async (_event, { port }) => { - const server = oauthServers.get(port); - if (!server) { - return; - } +ipcMain.handle( + "quadrant:oauth:cancel", + async (_event, payload: { port: number }) => { + const server = oauthServers.get(payload.port); + if (!server) { + return; + } - await new Promise((resolve, reject) => { - server.close((error) => { - if (error) { - reject(error); - return; - } - resolve(undefined); + await new Promise((resolve, reject) => { + server.close((error) => { + if (error) { + reject(error); + return; + } + resolve(); + }); }); - }); - oauthServers.delete(port); -}); + oauthServers.delete(payload.port); + }, +); diff --git a/electron/preload.mjs b/electron/preload.ts similarity index 54% rename from electron/preload.mjs rename to electron/preload.ts index 6686340f..c2e80640 100644 --- a/electron/preload.mjs +++ b/electron/preload.ts @@ -1,15 +1,101 @@ -import { contextBridge, ipcRenderer } from "electron"; +import electron from "electron"; import { randomUUID } from "node:crypto"; -function listen(channel, listener) { - const wrapped = (_event, payload) => listener(payload); +const { contextBridge, ipcRenderer } = electron; + +type UnlistenFn = () => void | Promise; + +interface DesktopWatchOptions { + delayMs?: number; +} + +interface DesktopDialogOptions { + mode?: "open" | "save"; + multiple?: boolean; + directory?: boolean; + recursive?: boolean; + title?: string; + defaultPath?: string; +} + +interface DesktopWindowProgressState { + progress: number; + status?: "none" | "normal" | "error" | "paused" | "indeterminate"; +} + +interface DesktopOAuthStartOptions { + response?: string; + ports?: number[]; +} + +interface DesktopBackendEventEnvelope { + event: string; + payload: T; +} + +interface DesktopStoreChangeEvent { + storeName: string; + key: string; + value: unknown; +} + +interface FsWatchEventPayload { + watchId: string; +} + +interface ElectronBridge { + invoke(command: string, payload?: unknown): Promise; + addBackendEventListener( + listener: (event: DesktopBackendEventEnvelope) => void, + ): UnlistenFn; + storeGet(storeName: string, key: string): Promise; + storeSet(storeName: string, key: string, value: unknown): Promise; + storeSave(storeName: string): Promise; + addStoreChangeListener( + listener: (event: DesktopStoreChangeEvent) => void, + ): UnlistenFn; + watchPath( + targetPath: string, + options: DesktopWatchOptions | undefined, + listener: () => void, + ): Promise; + joinPath(...segments: string[]): Promise; + openDialog(options: DesktopDialogOptions): Promise; + openExternal(url: string): Promise; + openPath(targetPath: string): Promise; + readClipboardText(): Promise; + writeClipboardText(text: string): Promise; + platform(): Promise; + getAppVersion(): Promise; + getRuntimeVersion(): Promise; + requestCheckForUpdates(): Promise; + installUpdate(): Promise; + isAutoupdateEnabled(): Promise; + windowMinimize(): Promise; + windowHide(): Promise; + windowSetEnabled(enabled: boolean): Promise; + windowSetFocus(): Promise; + windowUnminimize(): Promise; + windowSetProgressBar(state: DesktopWindowProgressState): Promise; + addOpenUrlListener(listener: (urls: string[]) => void): UnlistenFn; + startOAuthServer(options: DesktopOAuthStartOptions): Promise; + cancelOAuthServer(port: number): Promise; + addOAuthUrlListener(listener: (url: string) => void): UnlistenFn; +} + +function listen( + channel: string, + listener: (payload: T) => void, +): UnlistenFn { + const wrapped = (_event: Electron.IpcRendererEvent, payload: T) => + listener(payload); ipcRenderer.on(channel, wrapped); return () => { ipcRenderer.removeListener(channel, wrapped); }; } -const api = { +const api: ElectronBridge = { invoke(command, payload) { return ipcRenderer.invoke("quadrant:invoke", { command, payload }); }, @@ -30,11 +116,14 @@ const api = { }, async watchPath(targetPath, options, listener) { const watchId = randomUUID(); - const stopListening = listen("quadrant:fs-watch:event", (payload) => { - if (payload.watchId === watchId) { - listener(); - } - }); + const stopListening = listen( + "quadrant:fs-watch:event", + (payload) => { + if (payload.watchId === watchId) { + listener(); + } + }, + ); await ipcRenderer.invoke("quadrant:fs-watch:start", { watchId, targetPath, diff --git a/electron/tsconfig.json b/electron/tsconfig.json deleted file mode 100644 index 1a96a3a8..00000000 --- a/electron/tsconfig.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2022", - "module": "NodeNext", - "moduleResolution": "NodeNext", - "allowJs": true, - "checkJs": false, - "types": ["node"], - "skipLibCheck": true - }, - "include": ["./*.mjs"] -} diff --git a/package.json b/package.json index 09d33ea5..6911ad3d 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "quadrant-next", "private": true, - "version": "26.4.1-preview.0", + "version": "26.4.1-preview.2", "type": "module", "description": "An easy way to manage your Minecraft mods and modpacks.", "author": "Demir Yerli ", @@ -11,14 +11,17 @@ "scripts": { "dev": "vite", "build": "tsc && vite build", + "build:electron-shell": "tsc -p tsconfig.electron.json", "lint": "eslint .", "preview": "vite preview", - "build:napi": "node scripts/build-napi.mjs", + "typecheck:scripts": "tsc -p tsconfig.scripts.json", + "typecheck:electron": "tsc -p tsconfig.electron.json --noEmit", + "build:napi": "bun scripts/build-napi.ts", "dev:tauri": "tauri dev", "build:tauri": "tauri build", - "dev:electron": "node scripts/dev-electron.mjs", - "build:electron": "node scripts/build-electron.mjs", - "package:electron": "node scripts/package-electron.mjs", + "dev:electron": "bun scripts/dev-electron.ts", + "build:electron": "bun scripts/build-electron.ts", + "package:electron": "bun scripts/package-electron.ts", "tauri": "tauri", "tauri:windows:build": "tauri-windows-bundle build" }, @@ -39,6 +42,8 @@ "@tauri-apps/plugin-os": "~2.3.2", "@tauri-apps/plugin-store": "2.4.2", "@tauri-apps/plugin-updater": "2.10.1", + "chokidar": "^5.0.0", + "electron-updater": "^6.8.3", "i18next": "26.0.3", "motion": "12.38.0", "react": "19.2.4", @@ -50,11 +55,10 @@ "@eslint/css": "^1.1.0", "@eslint/js": "^10.0.1", "@eslint/json": "^1.2.0", + "@rolldown/plugin-babel": "^0.2.0", "@tailwindcss/postcss": "4.2.2", - "chokidar": "^5.0.0", "electron": "^41.1.1", "electron-builder": "^26.8.1", - "electron-updater": "^6.8.3", "@tauri-apps/cli": "^2.10.1", "@types/node": "^25.5.2", "@types/react": "^19.2.14", diff --git a/packages/quadrant-node/package.json b/packages/quadrant-node/package.json index 9f3a203f..b2766512 100644 --- a/packages/quadrant-node/package.json +++ b/packages/quadrant-node/package.json @@ -1,6 +1,6 @@ { "name": "@quadrant/quadrant-node", - "version": "26.4.1-preview.0", + "private": true, "type": "module", "main": "index.js", "exports": { diff --git a/scripts/build-electron.mjs b/scripts/build-electron.mjs deleted file mode 100644 index 4537bc88..00000000 --- a/scripts/build-electron.mjs +++ /dev/null @@ -1,19 +0,0 @@ -/** @format */ - -import path from "node:path"; -import { runCommand } from "./command-utils.mjs"; - -const rootDir = path.resolve(import.meta.dirname, ".."); -const extraArgs = process.argv.slice(2); - -function run(command, args) { - runCommand(command, args, { - cwd: rootDir, - stdio: "inherit", - shell: process.platform === "win32", - }); -} - -run("node", ["scripts/write-electron-runtime-config.mjs"]); -run("bun", ["run", "build"]); -run("node", ["scripts/build-napi.mjs", "--release", ...extraArgs]); diff --git a/scripts/build-electron.ts b/scripts/build-electron.ts new file mode 100644 index 00000000..8b6cc6cd --- /dev/null +++ b/scripts/build-electron.ts @@ -0,0 +1,22 @@ +/** @format */ + +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { runCommand } from "./command-utils.ts"; + +const scriptDir = path.dirname(fileURLToPath(import.meta.url)); +const rootDir = path.resolve(scriptDir, ".."); +const extraArgs = process.argv.slice(2); + +function run(command: string, args: string[]): void { + runCommand(command, args, { + cwd: rootDir, + stdio: "inherit", + shell: process.platform === "win32", + }); +} + +run("bun", ["run", "build"]); +run("bun", ["run", "build:electron-shell"]); +run("bun", ["scripts/write-electron-runtime-config.ts"]); +run("bun", ["scripts/build-napi.ts", "--release", ...extraArgs]); diff --git a/scripts/build-napi.mjs b/scripts/build-napi.ts similarity index 84% rename from scripts/build-napi.mjs rename to scripts/build-napi.ts index e487523c..da30489c 100644 --- a/scripts/build-napi.mjs +++ b/scripts/build-napi.ts @@ -1,14 +1,17 @@ import { cpSync, existsSync, mkdirSync, readdirSync } from "node:fs"; import path from "node:path"; -import { resolveCargoCommand, runCommand } from "./command-utils.mjs"; +import { fileURLToPath } from "node:url"; +import { resolveCargoCommand, runCommand } from "./command-utils.ts"; +import { syncQuadrantNodePackage } from "./sync-quadrant-node-package.ts"; -const rootDir = path.resolve(import.meta.dirname, ".."); +const scriptDir = path.dirname(fileURLToPath(import.meta.url)); +const rootDir = path.resolve(scriptDir, ".."); const srcTauriDir = path.join(rootDir, "src-tauri"); const args = process.argv.slice(2); const release = args.includes("--release"); const profile = release ? "release" : "debug"; -function getArgValue(flag) { +function getArgValue(flag: string): string | undefined { const exactMatch = args.find((arg) => arg.startsWith(`${flag}=`)); if (exactMatch) { return exactMatch.slice(flag.length + 1); @@ -22,7 +25,7 @@ function getArgValue(flag) { return undefined; } -function normalizePlatform(platform) { +function normalizePlatform(platform: string | undefined): string { if (!platform) { return process.platform; } @@ -38,7 +41,7 @@ function normalizePlatform(platform) { } } -function normalizeArch(arch) { +function normalizeArch(arch: string | undefined): string { if (!arch) { return process.arch; } @@ -53,7 +56,7 @@ function normalizeArch(arch) { } } -function getRustTargetTriple(platform, arch) { +function getRustTargetTriple(platform: string, arch: string): string { const key = `${platform}-${arch}`; switch (key) { case "win32-x64": @@ -75,7 +78,7 @@ function getRustTargetTriple(platform, arch) { } } -function findNativeBinary(directory) { +function findNativeBinary(directory: string): string | null { const entries = readdirSync(directory, { withFileTypes: true }); for (const entry of entries) { const fullPath = path.join(directory, entry.name); @@ -108,6 +111,8 @@ const rustTarget = getArgValue("--target") ?? getRustTargetTriple(targetPlatform, targetArch); const cargoCommand = resolveCargoCommand(); +syncQuadrantNodePackage(); + runCommand( cargoCommand, [ @@ -149,11 +154,12 @@ try { cpSync(builtNode, path.join(nativeDir, "index.node")); } catch (error) { if ( - error && + error instanceof Error && + "code" in error && (error.code === "EIO" || error.code === "EPERM" || error.code === "EBUSY") ) { throw new Error( - `Built Quadrant N-API successfully, but could not update packages/quadrant-node/native/index.node because it is locked. Close Electron or any process using the addon, then rerun \`node scripts/build-napi.mjs\`. Original error: ${error.message}`, + `Built Quadrant N-API successfully, but could not update packages/quadrant-node/native/index.node because it is locked. Close Electron or any process using the addon, then rerun \`bun scripts/build-napi.ts\`. Original error: ${error.message}`, ); } throw error; diff --git a/scripts/command-utils.mjs b/scripts/command-utils.ts similarity index 58% rename from scripts/command-utils.mjs rename to scripts/command-utils.ts index 8ca22340..ff992cc7 100644 --- a/scripts/command-utils.mjs +++ b/scripts/command-utils.ts @@ -1,13 +1,19 @@ +/** @format */ + +import { spawnSync, type SpawnSyncOptions } from "node:child_process"; import { existsSync } from "node:fs"; import { homedir } from "node:os"; import path from "node:path"; -import { spawnSync } from "node:child_process"; -function formatCommand(command, args) { +export interface RunCommandOptions extends SpawnSyncOptions { + notFoundMessage?: string; +} + +function formatCommand(command: string, args: readonly string[]): string { return [command, ...args].join(" "); } -export function resolveCargoCommand() { +export function resolveCargoCommand(): string { if (process.env.CARGO?.trim()) { return process.env.CARGO.trim(); } @@ -25,12 +31,17 @@ export function resolveCargoCommand() { return "cargo"; } -export function runCommand(command, args, options = {}) { +export function runCommand( + command: string, + args: string[], + options: RunCommandOptions = {}, +): ReturnType { const { notFoundMessage, ...spawnOptions } = options; const result = spawnSync(command, args, spawnOptions); if (result.error) { - if (result.error.code === "ENOENT") { + const error = result.error as NodeJS.ErrnoException; + if (error.code === "ENOENT") { throw new Error( notFoundMessage ?? `Failed to run \`${formatCommand(command, args)}\`: command not found.`, @@ -38,7 +49,7 @@ export function runCommand(command, args, options = {}) { } throw new Error( - `Failed to run \`${formatCommand(command, args)}\`: ${result.error.message}`, + `Failed to run \`${formatCommand(command, args)}\`: ${error.message}`, ); } diff --git a/scripts/dev-electron.mjs b/scripts/dev-electron.ts similarity index 65% rename from scripts/dev-electron.mjs rename to scripts/dev-electron.ts index 52709826..f640e5da 100644 --- a/scripts/dev-electron.mjs +++ b/scripts/dev-electron.ts @@ -1,17 +1,26 @@ -import { spawn } from "node:child_process"; +import { spawn, type ChildProcess } from "node:child_process"; import http from "node:http"; import path from "node:path"; -import chokidar from "chokidar"; +import { fileURLToPath } from "node:url"; +import chokidar, { type FSWatcher } from "chokidar"; -const rootDir = path.resolve(import.meta.dirname, ".."); +const scriptDir = path.dirname(fileURLToPath(import.meta.url)); +const rootDir = path.resolve(scriptDir, ".."); const devUrl = "http://127.0.0.1:1420"; const electronWatchGlobs = [ - "electron/**/*", + "electron/**/*.ts", "package.json", - "packages/quadrant-node/**/*", - "scripts/build-napi.mjs", - "scripts/command-utils.mjs", - "scripts/write-electron-runtime-config.mjs", + "electron-builder.json", + "tsconfig.electron.json", + "tsconfig.scripts.json", + "scripts/build-napi.ts", + "scripts/build-electron.ts", + "scripts/command-utils.ts", + "scripts/dev-electron.ts", + "scripts/package-electron.ts", + "scripts/sync-quadrant-node-package.ts", + "scripts/quadrant-node/**/*", + "scripts/write-electron-runtime-config.ts", "src-tauri/Cargo.toml", "src-tauri/Cargo.lock", "src-tauri/crates/quadrant-napi/**/*", @@ -19,7 +28,7 @@ const electronWatchGlobs = [ const ignoredWatchGlobs = [ "**/.DS_Store", "**/node_modules/**", - "electron/runtime-config.generated.json", + "dist-electron-shell/runtime-config.generated.json", "packages/quadrant-node/native/**", "src-tauri/target/**", ]; @@ -27,10 +36,14 @@ const ignoredWatchGlobs = [ let isShuttingDown = false; let isRestartingElectron = false; let electronRestartQueued = false; -let electronRestartTimer = null; -let electronProcess = null; - -function spawnProcess(command, args, extraEnv = {}) { +let electronRestartTimer: ReturnType | null = null; +let electronProcess: ChildProcess | null = null; + +function spawnProcess( + command: string, + args: string[], + extraEnv: NodeJS.ProcessEnv = {}, +): ChildProcess { return spawn(command, args, { cwd: rootDir, stdio: "inherit", @@ -42,14 +55,17 @@ function spawnProcess(command, args, extraEnv = {}) { }); } -function waitForChildProcess(childProcess, label) { +function waitForChildProcess( + childProcess: ChildProcess, + label: string, +): Promise { return new Promise((resolve, reject) => { childProcess.on("error", (error) => { reject(new Error(`Failed to start ${label}: ${error.message}`)); }); childProcess.on("exit", (code, signal) => { if (code === 0) { - resolve(undefined); + resolve(); return; } if (signal) { @@ -61,17 +77,17 @@ function waitForChildProcess(childProcess, label) { }); } -async function waitForUrl(url, timeoutMs = 120000) { +async function waitForUrl(url: string, timeoutMs = 120_000): Promise { const startedAt = Date.now(); while (Date.now() - startedAt < timeoutMs) { - const isReady = await new Promise((resolve) => { + const isReady = await new Promise((resolve) => { const request = http.get(url, (response) => { response.resume(); resolve(response.statusCode !== undefined && response.statusCode < 500); }); request.on("error", () => resolve(false)); - request.setTimeout(2000, () => { + request.setTimeout(2_000, () => { request.destroy(); resolve(false); }); @@ -81,34 +97,39 @@ async function waitForUrl(url, timeoutMs = 120000) { return; } - await new Promise((resolve) => setTimeout(resolve, 1000)); + await new Promise((resolve) => setTimeout(resolve, 1_000)); } throw new Error(`Timed out waiting for ${url}`); } -async function runPrepareStep(command, args, label) { +async function runPrepareStep( + command: string, + args: string[], + label: string, +): Promise { const childProcess = spawnProcess(command, args); await waitForChildProcess(childProcess, label); } -async function runElectronPrepareSteps() { +async function runElectronPrepareSteps(): Promise { + await runPrepareStep("bun", ["run", "build:electron-shell"], "build:electron-shell"); await runPrepareStep( - "node", - ["scripts/write-electron-runtime-config.mjs"], - "scripts/write-electron-runtime-config.mjs", + "bun", + ["scripts/write-electron-runtime-config.ts"], + "scripts/write-electron-runtime-config.ts", ); await runPrepareStep( - "node", - ["scripts/build-napi.mjs"], - "scripts/build-napi.mjs", + "bun", + ["scripts/build-napi.ts"], + "scripts/build-napi.ts", ); } -function launchElectron() { +function launchElectron(): void { const nextElectronProcess = spawnProcess( "bunx", - ["electron", "electron/main.mjs"], + ["electron", "dist-electron-shell/main.js"], { QUADRANT_ELECTRON_DEV_SERVER_URL: devUrl, }, @@ -136,7 +157,7 @@ function launchElectron() { }); } -async function stopElectronProcess() { +async function stopElectronProcess(): Promise { if (!electronProcess || electronProcess.exitCode !== null) { electronProcess = null; return; @@ -144,7 +165,7 @@ async function stopElectronProcess() { const runningElectronProcess = electronProcess; - await new Promise((resolve) => { + await new Promise((resolve) => { let resolved = false; const finalize = () => { @@ -153,14 +174,14 @@ async function stopElectronProcess() { } resolved = true; clearTimeout(forceKillTimeout); - resolve(undefined); + resolve(); }; const forceKillTimeout = setTimeout(() => { if (!runningElectronProcess.killed) { runningElectronProcess.kill("SIGKILL"); } - }, 10000); + }, 10_000); runningElectronProcess.once("exit", finalize); runningElectronProcess.kill("SIGTERM"); @@ -171,7 +192,7 @@ async function stopElectronProcess() { } } -async function restartElectron(reason) { +async function restartElectron(reason: string): Promise { if (isShuttingDown) { return; } @@ -194,16 +215,16 @@ async function restartElectron(reason) { launchElectron(); } } catch (error) { - console.error( - `[dev:electron] Failed to restart Electron: ${error.message}`, - ); + const message = + error instanceof Error ? error.message : `Unknown error: ${String(error)}`; + console.error(`[dev:electron] Failed to restart Electron: ${message}`); } } while (electronRestartQueued && !isShuttingDown); isRestartingElectron = false; } -function scheduleElectronRestart(reason) { +function scheduleElectronRestart(reason: string): void { if (isShuttingDown) { return; } @@ -215,9 +236,9 @@ function scheduleElectronRestart(reason) { electronRestartTimer = setTimeout(() => { electronRestartTimer = null; restartElectron(reason).catch((error) => { - console.error( - `[dev:electron] Unexpected restart failure: ${error.message}`, - ); + const message = + error instanceof Error ? error.message : `Unknown error: ${String(error)}`; + console.error(`[dev:electron] Unexpected restart failure: ${message}`); }); }, 250); } @@ -230,7 +251,7 @@ const viteProcess = spawnProcess("bun", [ "127.0.0.1", ]); -const electronWatcher = chokidar.watch(electronWatchGlobs, { +const electronWatcher: FSWatcher = chokidar.watch(electronWatchGlobs, { cwd: rootDir, ignoreInitial: true, ignored: ignoredWatchGlobs, @@ -240,7 +261,7 @@ electronWatcher.on("all", (eventName, filePath) => { scheduleElectronRestart(`${eventName} ${filePath}`); }); -const cleanup = async () => { +const cleanup = async (): Promise => { if (isShuttingDown) { return; } @@ -252,10 +273,7 @@ const cleanup = async () => { electronRestartTimer = null; } - await Promise.allSettled([ - electronWatcher.close(), - stopElectronProcess(), - ]); + await Promise.allSettled([electronWatcher.close(), stopElectronProcess()]); if (viteProcess.exitCode === null) { viteProcess.kill("SIGTERM"); diff --git a/scripts/merge-electron-latest-yml.py b/scripts/merge-electron-latest-yml.py new file mode 100644 index 00000000..30b70102 --- /dev/null +++ b/scripts/merge-electron-latest-yml.py @@ -0,0 +1,133 @@ +#!/usr/bin/env python3 + +from __future__ import annotations + +import argparse +import copy +from datetime import datetime +from pathlib import Path +from typing import Any + +import yaml + + +def load_metadata(path: Path) -> dict[str, Any]: + data = yaml.safe_load(path.read_text(encoding="utf-8")) + if not isinstance(data, dict): + raise ValueError(f"{path} did not contain a YAML object") + return data + + +def extract_files(metadata: dict[str, Any]) -> list[dict[str, Any]]: + files = metadata.get("files") + if isinstance(files, list) and files: + return [copy.deepcopy(item) for item in files if isinstance(item, dict)] + + path = metadata.get("path") + sha512 = metadata.get("sha512") + if not path or not sha512: + raise ValueError("metadata must contain either a non-empty files list or path/sha512 fields") + + file_info: dict[str, Any] = {"url": path, "sha512": sha512} + for key in ("size", "blockMapSize"): + value = metadata.get(key) + if value is not None: + file_info[key] = value + return [file_info] + + +def merge_files(base: dict[str, Any], extra: dict[str, Any]) -> list[dict[str, Any]]: + merged: dict[str, dict[str, Any]] = {} + + for metadata in (base, extra): + for file_info in extract_files(metadata): + url = file_info.get("url") + if not isinstance(url, str) or not url: + raise ValueError(f"invalid file entry without a url: {file_info!r}") + existing = merged.get(url) + if existing is not None and existing != file_info: + raise ValueError(f"conflicting file metadata for {url}") + merged[url] = file_info + + return list(merged.values()) + + +def merge_packages(base: dict[str, Any], extra: dict[str, Any]) -> dict[str, Any] | None: + merged: dict[str, Any] = {} + + for metadata in (base, extra): + packages = metadata.get("packages") + if packages is None: + continue + if not isinstance(packages, dict): + raise ValueError("packages must be a mapping when present") + for arch, package_info in packages.items(): + existing = merged.get(arch) + if existing is not None and existing != package_info: + raise ValueError(f"conflicting package metadata for architecture {arch}") + merged[arch] = copy.deepcopy(package_info) + + return merged or None + + +def merge_release_date(base: dict[str, Any], extra: dict[str, Any]) -> str | None: + candidates = [] + for metadata in (base, extra): + release_date = metadata.get("releaseDate") + if isinstance(release_date, str): + candidates.append(release_date) + + if not candidates: + return None + + try: + return max(candidates, key=lambda value: datetime.fromisoformat(value.replace("Z", "+00:00"))) + except ValueError: + return candidates[0] + + +def main() -> None: + parser = argparse.ArgumentParser( + description="Merge two electron-builder update metadata files into one Windows latest.yml", + ) + parser.add_argument("--base", required=True, help="The canonical metadata file to keep as top-level path/sha512, usually x64 latest.yml") + parser.add_argument("--extra", required=True, help="The secondary metadata file to merge in, usually arm64 latest.yml") + parser.add_argument("--output", required=True, help="Path to the merged YAML file") + args = parser.parse_args() + + base_path = Path(args.base) + extra_path = Path(args.extra) + output_path = Path(args.output) + + base = load_metadata(base_path) + extra = load_metadata(extra_path) + + base_version = base.get("version") + extra_version = extra.get("version") + if base_version != extra_version: + raise ValueError( + f"refusing to merge metadata from different versions: {base_version!r} != {extra_version!r}", + ) + + merged = copy.deepcopy(base) + merged["files"] = merge_files(base, extra) + + packages = merge_packages(base, extra) + if packages is not None: + merged["packages"] = packages + elif "packages" in merged: + del merged["packages"] + + release_date = merge_release_date(base, extra) + if release_date is not None: + merged["releaseDate"] = release_date + + output_path.parent.mkdir(parents=True, exist_ok=True) + output_path.write_text( + yaml.safe_dump(merged, sort_keys=False, allow_unicode=False), + encoding="utf-8", + ) + + +if __name__ == "__main__": + main() diff --git a/scripts/package-electron.mjs b/scripts/package-electron.ts similarity index 56% rename from scripts/package-electron.mjs rename to scripts/package-electron.ts index df2a4c4d..6878d71d 100644 --- a/scripts/package-electron.mjs +++ b/scripts/package-electron.ts @@ -1,10 +1,12 @@ import path from "node:path"; -import { runCommand } from "./command-utils.mjs"; +import { fileURLToPath } from "node:url"; +import { runCommand } from "./command-utils.ts"; -const rootDir = path.resolve(import.meta.dirname, ".."); +const scriptDir = path.dirname(fileURLToPath(import.meta.url)); +const rootDir = path.resolve(scriptDir, ".."); const extraArgs = process.argv.slice(2); -function getArgValue(flag) { +function getArgValue(flag: string): string | undefined { const exactMatch = extraArgs.find((arg) => arg.startsWith(`${flag}=`)); if (exactMatch) { return exactMatch.slice(flag.length + 1); @@ -18,7 +20,7 @@ function getArgValue(flag) { return undefined; } -function normalizeArch(arch) { +function normalizeArch(arch: string | undefined): string { if (!arch) { return process.arch; } @@ -33,7 +35,13 @@ function normalizeArch(arch) { } } -function run(command, args) { +function hasPublishFlag(args: string[]): boolean { + return args.some( + (arg) => arg === "--publish" || arg.startsWith("--publish="), + ); +} + +function run(command: string, args: string[]): void { runCommand(command, args, { cwd: rootDir, stdio: "inherit", @@ -43,11 +51,13 @@ function run(command, args) { const targetArch = normalizeArch(getArgValue("--arch")); const electronBuilderArchFlag = `--${targetArch}`; +const publishArgs = hasPublishFlag(extraArgs) ? [] : ["--publish", "never"]; -run("node", ["scripts/build-electron.mjs", ...extraArgs]); +run("bun", ["scripts/build-electron.ts", ...extraArgs]); run("bunx", [ "electron-builder", "--config", "electron-builder.json", electronBuilderArchFlag, + ...publishArgs, ]); diff --git a/packages/quadrant-node/index.d.ts b/scripts/quadrant-node/index.d.ts similarity index 100% rename from packages/quadrant-node/index.d.ts rename to scripts/quadrant-node/index.d.ts diff --git a/packages/quadrant-node/index.js b/scripts/quadrant-node/index.js similarity index 100% rename from packages/quadrant-node/index.js rename to scripts/quadrant-node/index.js diff --git a/scripts/sync-quadrant-node-package.ts b/scripts/sync-quadrant-node-package.ts new file mode 100644 index 00000000..3822193a --- /dev/null +++ b/scripts/sync-quadrant-node-package.ts @@ -0,0 +1,47 @@ +import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const scriptDir = path.dirname(fileURLToPath(import.meta.url)); +const rootDir = path.resolve(scriptDir, ".."); +const sourceDir = path.join(rootDir, "scripts", "quadrant-node"); +const targetDir = path.join(rootDir, "packages", "quadrant-node"); + +function readUtf8(filePath: string): string { + return readFileSync(filePath, "utf8"); +} + +function writeIfChanged(filePath: string, contents: string): boolean { + try { + if (readUtf8(filePath) === contents) { + return false; + } + } catch { + // Fall through and create the file when it does not exist yet. + } + + writeFileSync(filePath, contents); + return true; +} + +export function syncQuadrantNodePackage(): { + indexJs: boolean; + indexTypes: boolean; +} { + mkdirSync(targetDir, { recursive: true }); + + return { + indexJs: writeIfChanged( + path.join(targetDir, "index.js"), + readUtf8(path.join(sourceDir, "index.js")), + ), + indexTypes: writeIfChanged( + path.join(targetDir, "index.d.ts"), + readUtf8(path.join(sourceDir, "index.d.ts")), + ), + }; +} + +if (process.argv[1] === fileURLToPath(import.meta.url)) { + syncQuadrantNodePackage(); +} diff --git a/scripts/tsconfig.json b/scripts/tsconfig.json deleted file mode 100644 index 1a96a3a8..00000000 --- a/scripts/tsconfig.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2022", - "module": "NodeNext", - "moduleResolution": "NodeNext", - "allowJs": true, - "checkJs": false, - "types": ["node"], - "skipLibCheck": true - }, - "include": ["./*.mjs"] -} diff --git a/scripts/write-electron-runtime-config.mjs b/scripts/write-electron-runtime-config.ts similarity index 75% rename from scripts/write-electron-runtime-config.mjs rename to scripts/write-electron-runtime-config.ts index 71daabf8..c97aae53 100644 --- a/scripts/write-electron-runtime-config.mjs +++ b/scripts/write-electron-runtime-config.ts @@ -1,14 +1,16 @@ import { mkdirSync, writeFileSync } from "node:fs"; import path from "node:path"; +import { fileURLToPath } from "node:url"; -const rootDir = path.resolve(import.meta.dirname, ".."); +const scriptDir = path.dirname(fileURLToPath(import.meta.url)); +const rootDir = path.resolve(scriptDir, ".."); const outputPath = path.join( rootDir, - "electron", + "dist-electron-shell", "runtime-config.generated.json", ); -function normalizeOptionalConfigValue(value) { +function normalizeOptionalConfigValue(value: unknown): string | null { if (typeof value !== "string") { return null; } @@ -17,7 +19,7 @@ function normalizeOptionalConfigValue(value) { return trimmed.length > 0 ? trimmed : null; } -function normalizeRequiredConfigValue(value) { +function normalizeRequiredConfigValue(value: unknown): string { if (typeof value !== "string") { return ""; } diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 010d1fd8..8396d47a 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -4406,7 +4406,7 @@ checksum = "b5a041e753da8b807c9255f28de81879c78c876392ff2469cde94799b2896b9d" [[package]] name = "quadrant-core" -version = "26.4.1-preview.0" +version = "26.4.1-preview.2" dependencies = [ "anyhow", "chrono", @@ -4433,7 +4433,7 @@ dependencies = [ [[package]] name = "quadrant-host" -version = "26.4.1-preview.0" +version = "26.4.1-preview.2" dependencies = [ "anyhow", "colog", @@ -4452,7 +4452,7 @@ dependencies = [ [[package]] name = "quadrant-napi" -version = "26.4.1-preview.0" +version = "26.4.1-preview.2" dependencies = [ "napi", "napi-build", @@ -4464,7 +4464,7 @@ dependencies = [ [[package]] name = "quadrant_next" -version = "26.4.1-preview.0" +version = "26.4.1-preview.2" dependencies = [ "anyhow", "chrono", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index e235e400..72b59b73 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -13,7 +13,7 @@ members = [ ] [workspace.package] -version = "26.4.1-preview.0" +version = "26.4.1-preview.2" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index a9276f10..824f62ae 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,14 +1,17 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Quadrant", - "version": "26.4.1-preview.0", + "version": "26.4.1-preview.2", "identifier": "dev.mrquantumoff.mcmodpackmanager", "build": { "beforeDevCommand": "bun dev", "devUrl": "http://localhost:1420", "beforeBuildCommand": "bun run build", "frontendDist": "../dist", - "features": ["proprietary", "updater"] + "features": [ + "proprietary", + "updater" + ] }, "app": { "windows": [ @@ -39,7 +42,11 @@ "plugins": { "deep-link": { "desktop": { - "schemes": ["quadrantnext", "curseforge", "modrinth"] + "schemes": [ + "quadrantnext", + "curseforge", + "modrinth" + ] } }, "cli": { @@ -67,7 +74,12 @@ }, "bundle": { "active": true, - "targets": ["nsis", "appimage", "deb", "rpm"], + "targets": [ + "nsis", + "appimage", + "deb", + "rpm" + ], "linux": { "appimage": { "bundleMediaFramework": false @@ -111,11 +123,17 @@ "longDescription": "This is a powerful tool to manage your Minecraft: Java Edition mods. With this app you can easily install mods, resourcepacks, shaders. You can also share you modpacks with your friends, and you can back up your modpacks to the cloud!", "fileAssociations": [ { - "ext": ["modpackconfig.json", "modpackConfig", "quadrantModpack.json"], + "ext": [ + "modpackconfig.json", + "modpackConfig", + "quadrantModpack.json" + ], "description": "Quadrant Modpack Config File" } ], - "resources": ["../public"], + "resources": [ + "../public" + ], "windows": { "webviewInstallMode": { "type": "embedBootstrapper", @@ -123,7 +141,11 @@ }, "allowDowngrades": true, "nsis": { - "languages": ["Ukrainian", "English", "Turkish"] + "languages": [ + "Ukrainian", + "English", + "Turkish" + ] } }, "publisher": "MrQuantumOFF (Demir Yerli)", @@ -132,4 +154,4 @@ "license": "MPL-2.0", "createUpdaterArtifacts": true } -} +} \ No newline at end of file diff --git a/src/App.css b/src/App.css index 0fda02b2..d8047632 100644 --- a/src/App.css +++ b/src/App.css @@ -24,7 +24,7 @@ option { @font-face { font-family: "Inter"; - src: local("Inter"), url("$assets/Inter-Font.ttf"), url("./Inter-Font.ttf"); + src: local("Inter"), url("/Inter-Font.ttf") format("truetype"); } ::-webkit-scrollbar { diff --git a/tsconfig.electron.json b/tsconfig.electron.json new file mode 100644 index 00000000..a79f9e8e --- /dev/null +++ b/tsconfig.electron.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2023", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "types": ["node"], + "lib": ["ES2023"], + "strict": true, + "skipLibCheck": true, + "outDir": "dist-electron-shell", + "rootDir": "electron", + "allowSyntheticDefaultImports": true, + "esModuleInterop": true, + "resolveJsonModule": true + }, + "include": ["electron/**/*.ts"] +} diff --git a/tsconfig.scripts.json b/tsconfig.scripts.json new file mode 100644 index 00000000..b9cdeba6 --- /dev/null +++ b/tsconfig.scripts.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2023", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "types": ["node"], + "lib": ["ES2023"], + "strict": true, + "skipLibCheck": true, + "noEmit": true, + "allowImportingTsExtensions": true, + "allowSyntheticDefaultImports": true, + "resolveJsonModule": true + }, + "include": ["scripts/**/*.ts"] +} diff --git a/vite.config.ts b/vite.config.ts index 03c54501..5f30e8ce 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,24 +1,16 @@ /** @format */ +import process from "node:process"; +import babel from "@rolldown/plugin-babel"; import { defineConfig } from "vite"; -import react from "@vitejs/plugin-react"; +import react, { reactCompilerPreset } from "@vitejs/plugin-react"; -// @ts-expect-error process is a nodejs global const host = process.env.TAURI_DEV_HOST; -const ReactCompilerConfig = { - /* ... */ -}; - // https://vitejs.dev/config/ -export default defineConfig(async () => ({ - plugins: [ - react({ - babel: { - plugins: ["react-compiler", ReactCompilerConfig], - }, - }), - ], +export default defineConfig(async ({ command }) => ({ + plugins: [react(), babel({ presets: [reactCompilerPreset()] })], + base: command === "build" ? "./" : "/", // Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build` // @@ -41,11 +33,6 @@ export default defineConfig(async () => ({ ignored: ["**/src-tauri/**"], }, }, - resolve: { - alias: { - $assets: "./public", - }, - }, build: { chunkSizeWarningLimit: 4096, },