diff --git a/.github/workflows/experimental.yml b/.github/workflows/experimental.yml index ae68047..995228c 100644 --- a/.github/workflows/experimental.yml +++ b/.github/workflows/experimental.yml @@ -3,7 +3,39 @@ on: workflow_dispatch: jobs: + build-goldmapper: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + submodules: true + - name: install mingw toolchain + run: | + sudo apt-get update + sudo apt-get install -y gcc-mingw-w64-x86-64 g++-mingw-w64-x86-64 + - name: setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 10 + - name: setup node + uses: actions/setup-node@v4 + with: + node-version: lts/* + cache: "pnpm" + - name: build GoldMapper + run: | + pnpm install + pnpm goldmapper + - uses: actions/upload-artifact@v4 + with: + name: goldmapper + path: | + src-tauri/resources/GoldMapperLib.dll + src-tauri/resources/GoldMapperLauncher.exe + src-tauri/resources/SDL2.dll + build-tauri: + needs: [build-goldmapper] strategy: fail-fast: false matrix: @@ -14,9 +46,12 @@ jobs: - platform: "macos-latest" args: "--target x86_64-apple-darwin" artifact-name: macos-x86_64 - - platform: "ubuntu-22.04" - args: "" + - platform: "ubuntu-24.04" + args: "--bundles deb,rpm" artifact-name: linux + - platform: "ubuntu-24.04-arm" + args: "--target aarch64-unknown-linux-gnu --bundles deb,rpm" + artifact-name: linux-arm64 - platform: "windows-latest" args: "" artifact-name: windows @@ -24,12 +59,14 @@ jobs: runs-on: ${{ matrix.platform }} steps: - uses: actions/checkout@v4 + with: + submodules: true - name: install dependencies (ubuntu only) - if: matrix.platform == 'ubuntu-22.04' + if: startsWith(matrix.platform, 'ubuntu') run: | sudo apt-get update - sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf libudev-dev + sudo apt-get install -y libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev patchelf libudev-dev - name: setup pnpm uses: pnpm/action-setup@v4 @@ -41,10 +78,16 @@ jobs: node-version: lts/* cache: "pnpm" + - name: download GoldMapper + uses: actions/download-artifact@v4 + with: + name: goldmapper + path: src-tauri/resources + - name: install Rust stable uses: dtolnay/rust-toolchain@stable with: - targets: ${{ matrix.platform == 'macos-latest' && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }} + targets: ${{ matrix.platform == 'macos-latest' && 'aarch64-apple-darwin,x86_64-apple-darwin' || matrix.platform == 'ubuntu-24.04-arm' && 'aarch64-unknown-linux-gnu' || '' }} - name: Rust cache uses: swatinem/rust-cache@v2 @@ -55,11 +98,15 @@ jobs: run: pnpm install - name: build Tauri app - run: pnpm run tauri:build -- ${{ matrix.args }} + run: pnpm tauri build ${{ matrix.args }} env: TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} + - name: run macOS postbuild + run: pnpm post-build:macos + if: matrix.platform == 'macos-latest' + - name: Create PKG with post-install script (macOS only) if: matrix.platform == 'macos-latest' run: | @@ -88,6 +135,7 @@ jobs: src-tauri/target/**/release/bundle/**/* build-flatpak: + needs: [build-goldmapper] runs-on: ubuntu-latest container: image: bilelmoussaoui/flatpak-github-actions:freedesktop-23.08 @@ -96,6 +144,11 @@ jobs: - uses: actions/checkout@v4 with: submodules: true + - name: download GoldMapper + uses: actions/download-artifact@v4 + with: + name: goldmapper + path: src-tauri/resources - name: sleep for 10 seconds run: sleep 10 - uses: flatpak/flatpak-github-actions/flatpak-builder@v6 @@ -110,6 +163,7 @@ jobs: path: Emerald.Legacy.Launcher.flatpak build-appimage: + needs: [build-goldmapper] runs-on: ubuntu-latest container: image: archlinux:latest @@ -140,6 +194,12 @@ jobs: gtk3 libayatana-appindicator webkit2gtk-4.1 glib-networking librsvg \ xorg-server-xvfb + - name: download GoldMapper + uses: actions/download-artifact@v4 + with: + name: goldmapper + path: src-tauri/resources + - name: setup pnpm uses: pnpm/action-setup@v4 with: @@ -170,6 +230,7 @@ jobs: build-android: runs-on: ubuntu-latest + needs: [build-goldmapper] steps: - uses: actions/checkout@v4 with: @@ -184,6 +245,12 @@ jobs: node-version: lts/* cache: "pnpm" + - name: download GoldMapper + uses: actions/download-artifact@v4 + with: + name: goldmapper + path: src-tauri/resources + - name: setup Java 17 uses: actions/setup-java@v4 with: diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 9a371e5..c59af80 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -17,8 +17,39 @@ jobs: cat RELEASE_NOTES.md >> "$GITHUB_OUTPUT" echo "EOF" >> "$GITHUB_OUTPUT" + build-goldmapper: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + submodules: true + - name: install mingw toolchain + run: | + sudo apt-get update + sudo apt-get install -y gcc-mingw-w64-x86-64 g++-mingw-w64-x86-64 + - name: setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 10 + - name: setup node + uses: actions/setup-node@v4 + with: + node-version: lts/* + cache: "pnpm" + - name: build GoldMapper + run: | + pnpm install + pnpm goldmapper + - uses: actions/upload-artifact@v4 + with: + name: goldmapper + path: | + src-tauri/resources/GoldMapperLib.dll + src-tauri/resources/GoldMapperLauncher.exe + src-tauri/resources/SDL2.dll + publish-tauri: - needs: get-version + needs: [get-version, build-goldmapper] permissions: contents: write strategy: @@ -29,20 +60,24 @@ jobs: args: "--target aarch64-apple-darwin" - platform: "macos-latest" args: "--target x86_64-apple-darwin" - - platform: "ubuntu-22.04" - args: "" + - platform: "ubuntu-24.04" + args: "--bundles deb,rpm" + - platform: "ubuntu-24.04-arm" + args: "--target aarch64-unknown-linux-gnu --bundles deb,rpm" - platform: "windows-latest" args: "" runs-on: ${{ matrix.platform }} steps: - uses: actions/checkout@v4 + with: + submodules: true - name: install dependencies (ubuntu only) - if: matrix.platform == 'ubuntu-22.04' + if: startsWith(matrix.platform, 'ubuntu') run: | sudo apt-get update - sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf libudev-dev + sudo apt-get install -y libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev patchelf libudev-dev - name: setup pnpm uses: pnpm/action-setup@v4 @@ -54,10 +89,16 @@ jobs: node-version: lts/* cache: "pnpm" + - name: download GoldMapper + uses: actions/download-artifact@v4 + with: + name: goldmapper + path: src-tauri/resources + - name: install Rust stable uses: dtolnay/rust-toolchain@stable with: - targets: ${{ matrix.platform == 'macos-latest' && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }} + targets: ${{ matrix.platform == 'macos-latest' && 'aarch64-apple-darwin,x86_64-apple-darwin' || matrix.platform == 'ubuntu-24.04-arm' && 'aarch64-unknown-linux-gnu' || '' }} - name: Rust cache uses: swatinem/rust-cache@v2 @@ -81,6 +122,7 @@ jobs: args: ${{ matrix.args }} build-appimage: + needs: [build-goldmapper] runs-on: ubuntu-latest container: image: archlinux:latest @@ -111,6 +153,12 @@ jobs: gtk3 libayatana-appindicator webkit2gtk-4.1 glib-networking librsvg \ xorg-server-xvfb + - name: download GoldMapper + uses: actions/download-artifact@v4 + with: + name: goldmapper + path: src-tauri/resources + - name: setup pnpm uses: pnpm/action-setup@v4 with: @@ -141,6 +189,7 @@ jobs: build-android: runs-on: ubuntu-latest + needs: [build-goldmapper] steps: - uses: actions/checkout@v4 with: @@ -155,6 +204,12 @@ jobs: node-version: lts/* cache: "pnpm" + - name: download GoldMapper + uses: actions/download-artifact@v4 + with: + name: goldmapper + path: src-tauri/resources + - name: setup Java 17 uses: actions/setup-java@v4 with: @@ -219,7 +274,7 @@ jobs: path: src-tauri/gen/android/app/build/outputs/apk/universal/release/*.apk publish-flatpak: - needs: [get-version, build-appimage, build-android] + needs: [get-version, build-goldmapper, build-appimage, build-android] runs-on: ubuntu-latest permissions: contents: write @@ -230,6 +285,11 @@ jobs: - uses: actions/checkout@v4 with: submodules: true + - name: download GoldMapper + uses: actions/download-artifact@v4 + with: + name: goldmapper + path: src-tauri/resources - name: sleep for 20 seconds run: sleep 20 - uses: flatpak/flatpak-github-actions/flatpak-builder@v6 diff --git a/.gitignore b/.gitignore index 7ab7f97..1697782 100644 --- a/.gitignore +++ b/.gitignore @@ -37,6 +37,10 @@ Thumbs.db /emerald-repo /ignore/* +# GoldMapper binaries (generated by pnpm goldmapper) +/src-tauri/resources/GoldMapperLib.dll +/src-tauri/resources/GoldMapperLauncher.exe + # Android bundled runtime assets (too large for GitHub; must be present in the working tree) /src-tauri/gen/android/app/src/main/assets/imagefs.tar.zst /src-tauri/gen/android/app/src/main/assets/proton-*.tar.zst diff --git a/.gitmodules b/.gitmodules index 601b9ca..2f14daa 100644 --- a/.gitmodules +++ b/.gitmodules @@ -4,3 +4,6 @@ [submodule "DiamondRuntime"] path = DiamondRuntime url = https://github.com/LCE-Hub/DiamondRuntime.git +[submodule "GoldMapper"] + path = GoldMapper + url = https://github.com/LCE-Hub/GoldMapper diff --git a/GoldMapper b/GoldMapper new file mode 160000 index 0000000..8d92ece --- /dev/null +++ b/GoldMapper @@ -0,0 +1 @@ +Subproject commit 8d92ece7c2d9894bfde514f89d5ddb5651716eff diff --git a/README.md b/README.md index 3f21c51..a18c0f4 100644 --- a/README.md +++ b/README.md @@ -57,7 +57,7 @@ LCE Emerald Launcher is the easiest way to play Minecraft Legacy Console Edition | Feature | Description | |---------|-------------| -| **Automated Setup** | One-click installation for neoLegacy, Revelations, 360 Revived, Hellish Ends and others | +| **Automated Setup** | One-click installation for neoLegacy, Revelations, Hellish Ends, Moon Edition and others | | **Cross-Platform** | Native support for Windows, macOS (Intel & Apple Silicon), and Linux (Steam Deck is also supported!) | | **Lightweight** | Very light RAM usage thanks to Rust backend and Tauri framework | | **Easy Configuration** | Built-in settings for username, game parameters, and profiles | @@ -68,7 +68,7 @@ LCE Emerald Launcher is the easiest way to play Minecraft Legacy Console Edition | **Workshop** | Community content like DLCs, Textures, Skins and more | | **Free Multiplayer** | Powered by LCEOnline, Emerald provides a free multiplayer service so you can play with anyone without port forwarding! | | **Developer Tools** | Create and edit 4J's propriatery file formats with ease! | -| **World conversion** | Import `.ms` files, Java worlds or even an Xbox 360/PS3 world! | +| **World conversion** | Import `.ms` files, Java worlds or even an Xbox 360/PS3 world! (Beta) | | **Playtime counting** | Count your playtime and see your most active days! | --- @@ -220,15 +220,15 @@ sudo apt install --reinstall libwebkit2gtk-4.1-0 ### Game Not Launching -1. Verify game files are properly installed via the launcher +1. Verify game files are properly installed via the launcher (also try clicking Repair which will reinstall it but keeps your data) 2. Check that Wine/Proton is installed (Linux only) 3. Check that Wine/GPTK3 are installed (macOS only) 4. Ensure your GPU drivers are up to date ### Controller Not Detected -- Connect controller before launching the launcher -- PlayStation controllers are not supported in-game but work in launcher. Use Steam Input. +- Connect controller before launching the launcher or game. +- PlayStation controllers are not supported in-game but work in launcher. **Enable GoldMapper from Help & Options -> Controls**, or use Steam Input. --- @@ -240,6 +240,10 @@ sudo apt install --reinstall libwebkit2gtk-4.1-0 - **The LCE Community** - Research and foundations for LCE on PC - **Str1k3r** - Original creator of LCEOnline - **DTention** - Original creator of [LCE-Save-Converter](https://github.com/dtentiion/LCE-Save-Converter) +- **dydymiku** - French translation +- **Hvrket** - Russian translation +- **HuntedRaven (Robin)** - Homebrew, Nix and Gentoo packaging +- **AntiApple4life** - AUR packaging --- diff --git a/package.json b/package.json index 3d2974b..e5da8b8 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,8 @@ "tauri:build": "tauri build && pnpm run post-build:macos", "post-build:macos": "bash src-tauri/scripts/post-build-macos.sh", "android:assets": "node scripts/download-android-assets.mjs", - "flatpak": "flatpak-builder --user --install-deps-from=flathub --repo=emerald-repo --force-clean build-flatpak flatpak/io.github.Emerald_Legacy_Launcher.Emerald_Legacy_Launcher.yml && flatpak build-bundle emerald-repo emerald.flatpak io.github.Emerald_Legacy_Launcher.Emerald_Legacy_Launcher" + "flatpak": "flatpak-builder --user --install-deps-from=flathub --repo=emerald-repo --force-clean build-flatpak flatpak/io.github.Emerald_Legacy_Launcher.Emerald_Legacy_Launcher.yml && flatpak build-bundle emerald-repo emerald.flatpak io.github.Emerald_Legacy_Launcher.Emerald_Legacy_Launcher", + "goldmapper": "node scripts/build-goldmapper.mjs" }, "dependencies": { "@tauri-apps/api": "^2", @@ -20,10 +21,13 @@ "@tauri-apps/plugin-process": "~2.3.1", "@tauri-apps/plugin-updater": "~2.10.1", "framer-motion": "^12.36.0", + "i18next": "^26.4.0", + "i18next-browser-languagedetector": "^8.2.1", "pako": "^2.1.0", "qrcode": "^1.5.4", "react": "^19.1.0", "react-dom": "^19.1.0", + "react-i18next": "^17.0.12", "react-markdown": "^10.1.0", "rehype-raw": "^7.0.0", "rehype-sanitize": "^6.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e6f2b89..fe09479 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -26,6 +26,12 @@ importers: framer-motion: specifier: ^12.36.0 version: 12.38.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + i18next: + specifier: ^26.4.0 + version: 26.4.0(typescript@5.8.3) + i18next-browser-languagedetector: + specifier: ^8.2.1 + version: 8.2.1 pako: specifier: ^2.1.0 version: 2.1.0 @@ -38,6 +44,9 @@ importers: react-dom: specifier: ^19.1.0 version: 19.2.4(react@19.2.4) + react-i18next: + specifier: ^17.0.12 + version: 17.0.12(i18next@26.4.0(typescript@5.8.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.8.3) react-markdown: specifier: ^10.1.0 version: 10.1.0(@types/react@19.2.14)(react@19.2.4) @@ -180,6 +189,10 @@ packages: peerDependencies: '@babel/core': ^7.0.0-0 + '@babel/runtime@7.29.7': + resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} + engines: {node: '>=6.9.0'} + '@babel/template@7.28.6': resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==} engines: {node: '>=6.9.0'} @@ -962,12 +975,26 @@ packages: hastscript@9.0.1: resolution: {integrity: sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==} + html-parse-stringify@4.0.1: + resolution: {integrity: sha512-0zHsZJrK7S3K2aucXWL6ycoYJ/iNtIcFHC/nYQgFklPtrv5LpJctIiSCroWZWeuoXvuyFdzp6KzjJQ+OT5MfFw==} + html-url-attributes@3.0.1: resolution: {integrity: sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==} html-void-elements@3.0.0: resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==} + i18next-browser-languagedetector@8.2.1: + resolution: {integrity: sha512-bZg8+4bdmaOiApD7N7BPT9W8MLZG+nPTOFlLiJiT8uzKXFjhxw4v2ierCXOwB5sFDMtuA5G4kgYZ0AznZxQ/cw==} + + i18next@26.4.0: + resolution: {integrity: sha512-rsmK5bFqsD1AetSFSIa43wtNR4WpvvH4p0tLEsTxkC7QTrfdFm06nbQ95bh8Og4wwaCnUEcm9DVYL2cgxitiQg==} + peerDependencies: + typescript: ^5 || ^6 || ^7 + peerDependenciesMeta: + typescript: + optional: true + inline-style-parser@0.2.7: resolution: {integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==} @@ -1299,6 +1326,22 @@ packages: peerDependencies: react: ^19.2.4 + react-i18next@17.0.12: + resolution: {integrity: sha512-lFWPEGkxQ6RhusdUkysFBD58VHfSSzvHBzqMgN0SvfVpdQGfwtNkStTqdy08/sJd7s807qqutgx93fRpD0DJ3Q==} + peerDependencies: + i18next: '>= 26.2.0' + react: '>= 16.8.0' + react-dom: '*' + react-native: '*' + typescript: ^5 || ^6 || ^7 + peerDependenciesMeta: + react-dom: + optional: true + react-native: + optional: true + typescript: + optional: true + react-markdown@10.1.0: resolution: {integrity: sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==} peerDependencies: @@ -1440,6 +1483,11 @@ packages: peerDependencies: browserslist: '>= 4.21.0' + use-sync-external-store@1.6.0: + resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + vfile-location@5.0.3: resolution: {integrity: sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg==} @@ -1609,6 +1657,8 @@ snapshots: '@babel/core': 7.29.0 '@babel/helper-plugin-utils': 7.28.6 + '@babel/runtime@7.29.7': {} + '@babel/template@7.28.6': dependencies: '@babel/code-frame': 7.29.0 @@ -2285,10 +2335,20 @@ snapshots: property-information: 7.1.0 space-separated-tokens: 2.0.2 + html-parse-stringify@4.0.1: {} + html-url-attributes@3.0.1: {} html-void-elements@3.0.0: {} + i18next-browser-languagedetector@8.2.1: + dependencies: + '@babel/runtime': 7.29.7 + + i18next@26.4.0(typescript@5.8.3): + optionalDependencies: + typescript: 5.8.3 + inline-style-parser@0.2.7: {} is-alphabetical@2.0.1: {} @@ -2792,6 +2852,17 @@ snapshots: react: 19.2.4 scheduler: 0.27.0 + react-i18next@17.0.12(i18next@26.4.0(typescript@5.8.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.8.3): + dependencies: + '@babel/runtime': 7.29.7 + html-parse-stringify: 4.0.1 + i18next: 26.4.0(typescript@5.8.3) + react: 19.2.4 + use-sync-external-store: 1.6.0(react@19.2.4) + optionalDependencies: + react-dom: 19.2.4(react@19.2.4) + typescript: 5.8.3 + react-markdown@10.1.0(@types/react@19.2.14)(react@19.2.4): dependencies: '@types/hast': 3.0.4 @@ -2996,6 +3067,10 @@ snapshots: escalade: 3.2.0 picocolors: 1.1.1 + use-sync-external-store@1.6.0(react@19.2.4): + dependencies: + react: 19.2.4 + vfile-location@5.0.3: dependencies: '@types/unist': 3.0.3 diff --git a/public/Skins/ency.png b/public/Skins/ency.png new file mode 100644 index 0000000..a5f8ee5 Binary files /dev/null and b/public/Skins/ency.png differ diff --git a/public/Skins/individul.png b/public/Skins/individul.png new file mode 100644 index 0000000..f31ba40 Binary files /dev/null and b/public/Skins/individul.png differ diff --git a/public/fonts/rus/COPYRIGHT.txt b/public/fonts/rus/COPYRIGHT.txt new file mode 100644 index 0000000..9b80eef --- /dev/null +++ b/public/fonts/rus/COPYRIGHT.txt @@ -0,0 +1 @@ +Copyright © 2004-2012, BOGDAN Software \ No newline at end of file diff --git a/public/fonts/rus/minecraft.ttf b/public/fonts/rus/minecraft.ttf new file mode 100755 index 0000000..93c7491 Binary files /dev/null and b/public/fonts/rus/minecraft.ttf differ diff --git a/public/images/Button_Background.png b/public/images/Button_Background.png index 28a3269..333eecd 100644 Binary files a/public/images/Button_Background.png and b/public/images/Button_Background.png differ diff --git a/public/images/Layout_Button_Bmp.png b/public/images/Layout_Button_Bmp.png new file mode 100644 index 0000000..27fa8c4 Binary files /dev/null and b/public/images/Layout_Button_Bmp.png differ diff --git a/public/images/Layout_Button_Over.png b/public/images/Layout_Button_Over.png new file mode 100644 index 0000000..d4fc594 Binary files /dev/null and b/public/images/Layout_Button_Over.png differ diff --git a/public/images/MinecraftPS4Edition.png b/public/images/MinecraftPS4Edition.png new file mode 100644 index 0000000..f7f3b22 Binary files /dev/null and b/public/images/MinecraftPS4Edition.png differ diff --git a/public/images/Update_Icon.png b/public/images/Update_Icon.png index 3874d32..328c8ae 100644 Binary files a/public/images/Update_Icon.png and b/public/images/Update_Icon.png differ diff --git a/public/images/button_highlighted.png b/public/images/button_highlighted.png index af20c13..ca98e16 100644 Binary files a/public/images/button_highlighted.png and b/public/images/button_highlighted.png differ diff --git a/public/images/cafeberry_title.png b/public/images/cafeberry_title.png index 2d93760..fbf875c 100644 Binary files a/public/images/cafeberry_title.png and b/public/images/cafeberry_title.png differ diff --git a/public/images/empty.png b/public/images/empty.png new file mode 100644 index 0000000..0e31a71 Binary files /dev/null and b/public/images/empty.png differ diff --git a/public/images/gear.png b/public/images/gear.png new file mode 100644 index 0000000..d114271 Binary files /dev/null and b/public/images/gear.png differ diff --git a/public/images/goldmapper.png b/public/images/goldmapper.png new file mode 100644 index 0000000..93622c3 Binary files /dev/null and b/public/images/goldmapper.png differ diff --git a/public/images/helpbg.png b/public/images/helpbg.png new file mode 100644 index 0000000..9fa27aa Binary files /dev/null and b/public/images/helpbg.png differ diff --git a/public/images/neoLegacy.png b/public/images/neoLegacy.png index 7ec2425..b47ea29 100644 Binary files a/public/images/neoLegacy.png and b/public/images/neoLegacy.png differ diff --git a/public/images/projectlce.png b/public/images/projectlce.png new file mode 100644 index 0000000..7337b30 Binary files /dev/null and b/public/images/projectlce.png differ diff --git a/public/images/recess.png b/public/images/recess.png new file mode 100644 index 0000000..41d93a6 Binary files /dev/null and b/public/images/recess.png differ diff --git a/public/images/versionrecess.png b/public/images/versionrecess.png new file mode 100644 index 0000000..4fa0de3 Binary files /dev/null and b/public/images/versionrecess.png differ diff --git a/public/panorama/legacyorbis_Panorama_Background_Day.png b/public/panorama/legacyorbis_Panorama_Background_Day.png new file mode 100644 index 0000000..1760c0c Binary files /dev/null and b/public/panorama/legacyorbis_Panorama_Background_Day.png differ diff --git a/public/panorama/legacyorbis_Panorama_Background_Night.png b/public/panorama/legacyorbis_Panorama_Background_Night.png new file mode 100644 index 0000000..2cf58ff Binary files /dev/null and b/public/panorama/legacyorbis_Panorama_Background_Night.png differ diff --git a/scripts/build-goldmapper.mjs b/scripts/build-goldmapper.mjs new file mode 100644 index 0000000..41a4cc6 --- /dev/null +++ b/scripts/build-goldmapper.mjs @@ -0,0 +1,76 @@ +import { spawnSync } from "node:child_process"; +import { existsSync, copyFileSync, mkdirSync, readdirSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url)); +const ROOT_DIR = path.resolve(SCRIPT_DIR, ".."); +const GOLDMAPPER_DIR = path.join(ROOT_DIR, "GoldMapper"); +const BUILD_DIR = path.join(GOLDMAPPER_DIR, "build"); +const RESOURCES_DIR = path.join(ROOT_DIR, "src-tauri", "resources"); +const TOOLCHAIN_MINGW = path.join(SCRIPT_DIR, "toolchain-mingw.cmake"); +const IS_WINDOWS = process.platform === "win32"; +function run(cmd, args, cwd) { + console.log(`> ${cmd} ${args.join(" ")}`); + const res = spawnSync(cmd, args, { + cwd, + stdio: "inherit", + shell: IS_WINDOWS, + }); + if (res.status !== 0) { + process.exit(res.status ?? 1); + } +} + +if ( + !existsSync( + path.join(GOLDMAPPER_DIR, "third_party", "minhook", "include", "MinHook.h"), + ) +) { + console.log("Initializing minhook submodule..."); + run( + "git", + ["-C", GOLDMAPPER_DIR, "submodule", "update", "--init", "--recursive"], + ROOT_DIR, + ); +} + +console.log("Configuring GoldMapper..."); +const configureArgs = ["-B", BUILD_DIR, "-DCMAKE_BUILD_TYPE=Release"]; +if (!IS_WINDOWS) { + configureArgs.push("-DCMAKE_TOOLCHAIN_FILE=" + TOOLCHAIN_MINGW); +} +configureArgs.push(GOLDMAPPER_DIR); +run("cmake", configureArgs, ROOT_DIR); +console.log("Building GoldMapper..."); +run("cmake", ["--build", BUILD_DIR, "--config", "Release"], ROOT_DIR); +console.log("Copying binaries to resources..."); +mkdirSync(RESOURCES_DIR, { recursive: true }); +const outputs = [ + { + source: "GoldMapperLib.dll", + names: ["GoldMapperLib.dll", "libGoldMapperLib.dll"], //neo: dont ask me vro idk why it does that + }, + { source: "GoldMapperLauncher.exe", names: ["GoldMapperLauncher.exe"] }, +]; + +for (const entry of outputs) { + const dir = entry.dir ?? BUILD_DIR; + const file = path.join(dir, entry.source); + if (existsSync(file)) { + copyFileSync(file, path.join(RESOURCES_DIR, entry.source)); + continue; + } + const candidate = readdirSync(dir).find((f) => + entry.names.some((n) => f.toLowerCase() === n.toLowerCase()), + ); + if (!candidate) { + console.error(`Missing build output: ${entry.source}`); + process.exit(1); + } + copyFileSync( + path.join(dir, candidate), + path.join(RESOURCES_DIR, entry.source), + ); +} + +console.log("GoldMapper build complete."); diff --git a/scripts/toolchain-mingw.cmake b/scripts/toolchain-mingw.cmake new file mode 100644 index 0000000..b4a1291 --- /dev/null +++ b/scripts/toolchain-mingw.cmake @@ -0,0 +1,5 @@ +set(CMAKE_SYSTEM_NAME Windows) +set(CMAKE_SYSTEM_PROCESSOR x86_64) +set(CMAKE_C_COMPILER x86_64-w64-mingw32-gcc) +set(CMAKE_CXX_COMPILER x86_64-w64-mingw32-g++) +set(CMAKE_RC_COMPILER x86_64-w64-mingw32-windres) \ No newline at end of file diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 692282c..70dbfe2 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -3737,6 +3737,7 @@ dependencies = [ "system-configuration", "tokio", "tokio-rustls 0.24.1", + "tokio-socks", "tokio-util", "tower-service", "url", @@ -5291,6 +5292,18 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-socks" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7e2948f60dbe26b35f2c7fb74ac2854c1fddded0fe9d7548fcc674a246f7615" +dependencies = [ + "either", + "futures-util", + "thiserror 1.0.69", + "tokio", +] + [[package]] name = "tokio-tungstenite" version = "0.21.0" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index b676131..e275f51 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -18,7 +18,7 @@ tauri-plugin-opener = "2" serde = { version = "1", features = ["derive"] } serde_json = "1" tauri-plugin-gamepad = "0.0.5" -reqwest = { version = "0.11", default-features = false, features = ["stream", "json", "rustls-tls-webpki-roots"] } +reqwest = { version = "0.11", default-features = false, features = ["stream", "json", "rustls-tls-webpki-roots", "socks"] } tokio = { version = "1", features = ["full"] } futures-util = "0.3" tokio-util = { version = "0.7.18", features = ["rt"] } diff --git a/src-tauri/resources/DLC/Emerald Skin Pack/Skins1.pck b/src-tauri/resources/DLC/Emerald Skin Pack/Skins1.pck index 3f2a5ab..d4316f5 100644 Binary files a/src-tauri/resources/DLC/Emerald Skin Pack/Skins1.pck and b/src-tauri/resources/DLC/Emerald Skin Pack/Skins1.pck differ diff --git a/src-tauri/resources/SDL2.dll b/src-tauri/resources/SDL2.dll new file mode 100644 index 0000000..686d996 Binary files /dev/null and b/src-tauri/resources/SDL2.dll differ diff --git a/src-tauri/src/commands/config.rs b/src-tauri/src/commands/config.rs index 71b3dd3..327508f 100644 --- a/src-tauri/src/commands/config.rs +++ b/src-tauri/src/commands/config.rs @@ -5,6 +5,11 @@ use crate::types::{AppConfig, ThemePalette}; use crate::util; #[tauri::command] pub fn save_config(app: AppHandle, config_val: AppConfig) { + let mut config_val = config_val; + if config_val.goldmapper_mappings.is_none() { + let existing = config::load_config_raw(app.clone()); + config_val.goldmapper_mappings = existing.goldmapper_mappings; + } config::save_config_raw(&app, &config_val); } diff --git a/src-tauri/src/commands/dlc.rs b/src-tauri/src/commands/dlc.rs index 88f3f04..a0f0782 100644 --- a/src-tauri/src/commands/dlc.rs +++ b/src-tauri/src/commands/dlc.rs @@ -45,14 +45,16 @@ fn get_raw_url(host: &str, owner: &str, repo: &str, branch: &str, path: &str, is #[tauri::command] pub async fn list_git_directory( + app: tauri::AppHandle, repo_url: String, branch: String, path: String, ) -> Result, String> { - list_git_directory_inner(repo_url, branch, path).await + list_git_directory_inner(app, repo_url, branch, path).await } fn list_git_directory_inner( + app: tauri::AppHandle, repo_url: String, branch: String, path: String, @@ -60,9 +62,8 @@ fn list_git_directory_inner( Box::pin(async move { let (owner, repo, host, is_github) = parse_git_url(&repo_url)?; let api_url = get_api_url(&host, &owner, &repo, &path, &branch, is_github); - let client = reqwest::Client::new(); + let client = util::build_http_client_from_app(&app).map_err(|e| e.to_string())?; let response = client.get(&api_url) - .header("User-Agent", "Emerald-Launcher") .send() .await .map_err(|e| format!("Failed to fetch directory listing: {}", e))?; @@ -82,7 +83,7 @@ fn list_git_directory_inner( let is_dir = json.get("type").and_then(|v| v.as_str()) == Some("dir"); if is_dir { let sub_path = json.get("path").and_then(|v| v.as_str()).unwrap_or("").to_string(); - return list_git_directory_inner(repo_url, branch, sub_path).await; + return list_git_directory_inner(app, repo_url, branch, sub_path).await; } return Ok(Vec::new()); } @@ -107,6 +108,7 @@ fn list_git_directory_inner( } async fn collect_files( + app: &tauri::AppHandle, host: &str, owner: &str, repo: &str, @@ -116,11 +118,10 @@ async fn collect_files( ) -> Result, String> { let mut files = Vec::new(); let mut dirs_to_list = vec![root_path.to_string()]; - let client = reqwest::Client::new(); + let client = util::build_http_client_from_app(app).map_err(|e| e.to_string())?; while let Some(dir) = dirs_to_list.pop() { let api_url = get_api_url(host, owner, repo, &dir, branch, is_github); let response = client.get(&api_url) - .header("User-Agent", "Emerald-Launcher") .send() .await .map_err(|e| format!("Failed to list {}: {}", dir, e))?; @@ -174,18 +175,17 @@ pub async fn download_dlc_files( let instance_dir = util::get_instance_working_dir(&app, &instance_id); let dlc_dest = instance_dir.join("Windows64Media").join("DLC").join(&dlc_folder); let (owner, repo, host, is_github) = parse_git_url(&repo_url)?; - let files_to_download = collect_files(&host, &owner, &repo, &branch, &dlc_folder, is_github).await?; + let files_to_download = collect_files(&app, &host, &owner, &repo, &branch, &dlc_folder, is_github).await?; if files_to_download.is_empty() { return Err(format!("No files found in '{}' folder", dlc_folder)); } fs::create_dir_all(&dlc_dest).map_err(|e| e.to_string())?; - let client = reqwest::Client::new(); + let client = util::build_http_client_from_app(&app).map_err(|e| e.to_string())?; let total = files_to_download.len(); for (i, file_path) in files_to_download.iter().enumerate() { let raw_url = get_raw_url(&host, &owner, &repo, &branch, file_path, is_github); let response = client.get(&raw_url) - .header("User-Agent", "Emerald-Launcher") .send() .await .map_err(|e| format!("Failed to download {}: {}", file_path, e))?; diff --git a/src-tauri/src/commands/download.rs b/src-tauri/src/commands/download.rs index 7985573..d39a483 100644 --- a/src-tauri/src/commands/download.rs +++ b/src-tauri/src/commands/download.rs @@ -28,7 +28,8 @@ async fn stream_download( lock.insert(instance_id.to_string(), token); } - let response = reqwest::Client::new().get(url).header(reqwest::header::USER_AGENT, "Emerald-Launcher").send().await.map_err(|e| e.to_string())?; + let client = util::build_http_client_from_app(app).map_err(|e| e.to_string())?; + let response = client.get(url).send().await.map_err(|e| e.to_string())?; if !response.status().is_success() { { state.tokens.lock().await.remove(instance_id); } return Err(format!("Download failed: {}", response.status())); @@ -300,9 +301,9 @@ pub async fn check_game_update( return Ok(true); } - let response = reqwest::Client::new() + let client = util::build_http_client_from_app(&app).map_err(|e| e.to_string())?; + let response = client .head(&url) - .header(reqwest::header::USER_AGENT, "Emerald-Launcher") .send() .await .map_err(|e| e.to_string())?; diff --git a/src-tauri/src/commands/game.rs b/src-tauri/src/commands/game.rs index bb7dc56..822131d 100644 --- a/src-tauri/src/commands/game.rs +++ b/src-tauri/src/commands/game.rs @@ -9,6 +9,7 @@ use tauri_plugin_opener::OpenerExt; use tokio::io::AsyncRead; use tokio::io::AsyncReadExt; use tokio::sync::Mutex; +use crate::commands::goldmapper; use crate::commands::runners; use crate::config; use crate::types::AppConfig; @@ -139,8 +140,14 @@ async fn launch_game_desktop( all_args.push(a.to_string()); } all_args.extend(args.clone()); - all_args.push(game_exe.to_string_lossy().to_string()); - all_args.extend(extra_args.clone()); + let goldmapper_args = + goldmapper::build_launch_args(&app, &config_val, &game_exe, &extra_args)?; + if let Some(gm_args) = goldmapper_args { + all_args.extend(gm_args); + } else { + all_args.push(game_exe.to_string_lossy().to_string()); + all_args.extend(extra_args.clone()); + } let (final_prog, final_args) = apply_launch_prefix(prog, all_args, &config_val); let mut cmd = tokio::process::Command::new(&final_prog); for a in &final_args { @@ -228,7 +235,13 @@ async fn launch_game_desktop( ]) }; - mac_args.extend(extra_args.clone()); + let goldmapper_args = + goldmapper::build_launch_args(&app, &config_val, &game_exe, &extra_args)?; + if let Some(gm_args) = goldmapper_args { + mac_args.extend(gm_args); + } else { + mac_args.extend(extra_args.clone()); + } let (final_prog, final_args) = apply_launch_prefix(&mac_prog, mac_args, &config_val); let mut cmd = tokio::process::Command::new(&final_prog); @@ -289,13 +302,22 @@ async fn launch_game_desktop( not(target_os = "android") ))] { - let exe_str = game_exe.to_string_lossy().to_string(); - let all_args: Vec = extra_args.clone(); - let (final_prog, final_args) = apply_launch_prefix(&exe_str, all_args, &config_val); + let goldmapper_args = + goldmapper::build_launch_args(&app, &config_val, &game_exe, &extra_args)?; + let (prog_str, all_args): (String, Vec) = match goldmapper_args { + Some(gm_args) => ( + gm_args[0].clone(), + gm_args[1..].to_vec(), + ), + None => (game_exe.to_string_lossy().to_string(), extra_args.clone()), + }; + let (final_prog, final_args) = apply_launch_prefix(&prog_str, all_args, &config_val); let mut cmd = tokio::process::Command::new(&final_prog); for a in &final_args { cmd.arg(a); } + #[cfg(target_os = "windows")] + cmd.creation_flags(0x08000000); #[cfg(unix)] cmd.process_group(0); apply_launch_env_vars(&mut cmd, &config_val); diff --git a/src-tauri/src/commands/goldmapper.rs b/src-tauri/src/commands/goldmapper.rs new file mode 100644 index 0000000..a928c3c --- /dev/null +++ b/src-tauri/src/commands/goldmapper.rs @@ -0,0 +1,212 @@ +use std::fs; +use std::path::PathBuf; +use tauri::{AppHandle, Manager}; +use crate::config; +use crate::types::{AppConfig, GoldMapperMapping}; +use crate::util; + +const CONTROLLER_TARGETS: [&str; 14] = [ + "PAD_A", + "PAD_B", + "PAD_X", + "PAD_Y", + "PAD_LB", + "PAD_RB", + "PAD_BACK", + "PAD_START", + "PAD_LTHUMB", + "PAD_RTHUMB", + "PAD_DPAD_UP", + "PAD_DPAD_DOWN", + "PAD_DPAD_LEFT", + "PAD_DPAD_RIGHT", +]; + +fn push_key(mappings: &mut Vec, name: String) { + mappings.push(GoldMapperMapping { + to: name.clone(), + from: name, + }); +} + +pub fn default_mappings() -> Vec { + let mut mappings = Vec::new(); + for b in b'A'..=b'Z' { + push_key(&mut mappings, format!("KEY_{}", b as char)); + } + for n in 0..=9 { + push_key(&mut mappings, format!("KEY_{n}")); + } + for f in 1..=12 { + push_key(&mut mappings, format!("KEY_F{f}")); + } + for k in [ + "KEY_SPACE", + "KEY_RETURN", + "KEY_ESCAPE", + "KEY_TAB", + "KEY_BACKSPACE", + "KEY_DELETE", + "KEY_INSERT", + "KEY_HOME", + "KEY_END", + "KEY_PAGEUP", + "KEY_PAGEDOWN", + "KEY_UP", + "KEY_DOWN", + "KEY_LEFT", + "KEY_RIGHT", + "KEY_PRINTSCREEN", + "KEY_PAUSE", + "KEY_CAPSLOCK", + "KEY_NUMLOCK", + "KEY_SCROLLLOCK", + "KEY_LSHIFT", + "KEY_RSHIFT", + "KEY_LCTRL", + "KEY_RCTRL", + "KEY_LALT", + "KEY_RALT", + "KEY_LWIN", + "KEY_RWIN", + "KEY_APPS", + ] { + push_key(&mut mappings, k.to_string()); + } + for n in 0..=9 { + push_key(&mut mappings, format!("KEY_NUMPAD{n}")); + } + for k in [ + "KEY_MULTIPLY", + "KEY_ADD", + "KEY_SUBTRACT", + "KEY_DECIMAL", + "KEY_DIVIDE", + "KEY_SEMICOLON", + "KEY_EQUALS", + "KEY_COMMA", + "KEY_MINUS", + "KEY_PERIOD", + "KEY_SLASH", + "KEY_GRAVE", + "KEY_LBRACKET", + "KEY_BACKSLASH", + "KEY_RBRACKET", + "KEY_APOSTROPHE", + ] { + push_key(&mut mappings, k.to_string()); + } + for m in ["MOUSE_LEFT", "MOUSE_RIGHT", "MOUSE_MIDDLE"] { + push_key(&mut mappings, m.to_string()); + } + for (i, target) in CONTROLLER_TARGETS.iter().enumerate() { + mappings.push(GoldMapperMapping { + from: format!("DINPUT_{i}"), + to: (*target).to_string(), + }); + } + mappings +} + +pub fn resolve_resource(app: &AppHandle, name: &str) -> Option { + use tauri::path::BaseDirectory; + if let Ok(p) = app.path().resolve(format!("resources/{name}"), BaseDirectory::Resource) { + if p.exists() { + return Some(p); + } + } + if let Ok(current) = std::env::current_dir() { + let p = current.join("src-tauri").join("resources").join(name); + if p.exists() { + return Some(p); + } + let p = current.join("resources").join(name); + if p.exists() { + return Some(p); + } + } + None +} + +pub fn write_runtime_config(app: &AppHandle, config_val: &AppConfig) -> Result { + let dir = util::get_app_dir(app).join("GoldMapper"); + fs::create_dir_all(&dir).map_err(|e| e.to_string())?; + let path = dir.join("config.json"); + let mappings = config_val + .goldmapper_mappings + .clone() + .unwrap_or_else(default_mappings); + let json = serde_json::to_string(&serde_json::json!({ "mappings": mappings })) + .map_err(|e| e.to_string())?; + fs::write(&path, json).map_err(|e| e.to_string())?; + Ok(path) +} + +pub fn build_launch_args( + app: &AppHandle, + config_val: &AppConfig, + game_exe: &PathBuf, + extra_args: &[String], +) -> Result>, String> { + if !config_val.goldmapper_enabled.unwrap_or(true) { + return Ok(None); + } + let launcher = + resolve_resource(app, "GoldMapperLauncher.exe").ok_or_else(|| "GoldMapperLauncher.exe not found in resources".to_string())?; + let config_path = write_runtime_config(app, config_val)?; + #[cfg(windows)] + { + let mut args = vec![ + launcher.to_string_lossy().to_string(), + config_path.to_string_lossy().to_string(), + game_exe.to_string_lossy().to_string(), + ]; + args.extend(extra_args.iter().cloned()); + Ok(Some(args)) + } + #[cfg(unix)] + { + let mut args = vec![ + util::unix_path_to_wine_z_path(&launcher), + util::unix_path_to_wine_z_path(&config_path), + util::unix_path_to_wine_z_path(game_exe), + ]; + args.extend(extra_args.iter().cloned()); + Ok(Some(args)) + } +} + +#[tauri::command] +pub fn goldmapper_get_defaults() -> Vec { + default_mappings() +} + +#[tauri::command] +pub fn goldmapper_load_config(app: AppHandle) -> Vec { + config::load_config_raw(app.clone()) + .goldmapper_mappings + .unwrap_or_else(default_mappings) +} + +#[tauri::command] +pub fn goldmapper_save_config( + app: AppHandle, + mappings: Vec, +) -> Result<(), String> { + let mut config_val = config::load_config_raw(app.clone()); + config_val.goldmapper_mappings = Some(mappings); + println!( + "[GoldMapper] config saved: {}", + serde_json::json!({ "mappings": config_val.goldmapper_mappings }) + ); + config::save_config_raw(&app, &config_val); + Ok(()) +} + +#[tauri::command] +pub fn goldmapper_reset_config(app: AppHandle) -> Vec { + let mut config_val = config::load_config_raw(app.clone()); + config_val.goldmapper_mappings = None; + config::save_config_raw(&app, &config_val); + default_mappings() +} diff --git a/src-tauri/src/commands/java2lce/block_mapping.rs b/src-tauri/src/commands/java2lce/block_mapping.rs index a1a69db..0de2b71 100644 --- a/src-tauri/src/commands/java2lce/block_mapping.rs +++ b/src-tauri/src/commands/java2lce/block_mapping.rs @@ -9,14 +9,15 @@ pub struct LegacyBlockState { pub fn map_modern_block_state(compound: &NbtCompound) -> LegacyBlockState { map_modern_block_state_inner(compound, None) } - +/* +//neo: unused pub fn map_modern_block_state_with_context( compound: &NbtCompound, unknown_blocks: Option<&mut Vec>, ) -> LegacyBlockState { map_modern_block_state_inner(compound, unknown_blocks) } - +*/ fn map_modern_block_state_inner( compound: &NbtCompound, mut unknown_blocks: Option<&mut Vec>, diff --git a/src-tauri/src/commands/java2lce/chunk.rs b/src-tauri/src/commands/java2lce/chunk.rs index 6582ebb..7947f51 100644 --- a/src-tauri/src/commands/java2lce/chunk.rs +++ b/src-tauri/src/commands/java2lce/chunk.rs @@ -1,5 +1,5 @@ use std::collections::HashMap; -use super::block_mapping::{self, LegacyBlockState}; +use super::block_mapping::{self}; use super::nbt; use super::payload; const CHUNK_BLOCKS: usize = 32768; @@ -19,10 +19,10 @@ pub enum JavaChunkFormat { #[derive(Debug, Clone, Copy)] pub struct JavaChunkFormatInfo { pub format: JavaChunkFormat, - pub has_level_wrapper: bool, - pub data_version: i32, - pub min_section_y: Option, - pub max_section_y: Option, + pub _has_level_wrapper: bool, + pub _data_version: i32, + pub _min_section_y: Option, + pub _max_section_y: Option, pub has_modern_entity_tags: bool, } @@ -110,10 +110,10 @@ pub fn inspect_chunk(root_tag: &nbt::NbtCompound) -> JavaChunkFormatInfo { JavaChunkFormatInfo { format, - has_level_wrapper, - data_version, - min_section_y, - max_section_y, + _has_level_wrapper: has_level_wrapper, + _data_version: data_version, + _min_section_y: min_section_y, + _max_section_y: max_section_y, has_modern_entity_tags, } } @@ -406,7 +406,8 @@ pub fn convert_chunk( build_legacy_chunk_level(root_tag, new_chunk_x, new_chunk_z, preserve_dynamic, global_section_shift); payload::encode_legacy_nbt(&level) } - +/* +//neo: unused pub fn convert_chunk_for_save( root_tag: &nbt::NbtCompound, new_chunk_x: i32, @@ -416,7 +417,7 @@ pub fn convert_chunk_for_save( ) -> Vec { let level = build_legacy_chunk_level(root_tag, new_chunk_x, new_chunk_z, preserve_dynamic, global_section_shift); payload::encode_compressed_storage(&level) -} +}*/ fn build_legacy_chunk_level( root_tag: &nbt::NbtCompound, @@ -430,7 +431,7 @@ fn build_legacy_chunk_level( .compound("Level") .unwrap_or(root_tag); let is_modern = format_info.uses_modern_content_schema(); - let (mut blocks, mut data, mut sky_light, mut block_light) = + let (mut blocks, mut data, mut sky_light, block_light) = if format_info.is_section_based() { flatten_anvil_sections(source_level, &format_info, global_section_shift) } else { diff --git a/src-tauri/src/commands/java2lce/nbt.rs b/src-tauri/src/commands/java2lce/nbt.rs index 4a10e24..2db4003 100644 --- a/src-tauri/src/commands/java2lce/nbt.rs +++ b/src-tauri/src/commands/java2lce/nbt.rs @@ -59,14 +59,15 @@ impl NbtCompound { _ => None, }) } - + /* + //neo: unused pub fn compound_mut(&mut self, name: &str) -> Option<&mut NbtCompound> { self.get_mut(name).and_then(|v| match v { NbtValue::Compound(c) => Some(c), _ => None, }) } - +*/ pub fn byte(&self, name: &str) -> Option { self.get(name).and_then(|v| match v { NbtValue::Byte(b) => Some(*b), @@ -94,7 +95,8 @@ impl NbtCompound { _ => None, }) } - + /* + //neo: unused pub fn float(&self, name: &str) -> Option { self.get(name).and_then(|v| match v { NbtValue::Float(f) => Some(*f), @@ -108,6 +110,7 @@ impl NbtCompound { _ => None, }) } +*/ pub fn string(&self, name: &str) -> Option<&str> { self.get(name).and_then(|v| match v { @@ -143,7 +146,8 @@ impl NbtCompound { _ => None, }) } - + /* + //neo: unused pub fn list_compounds(&self, name: &str) -> Vec<&NbtCompound> { self.list(name) .unwrap_or(&[]) @@ -153,7 +157,7 @@ impl NbtCompound { _ => None, }) .collect() - } + }*/ } fn tag_type_id(v: &NbtValue) -> u8 { @@ -405,6 +409,8 @@ pub fn write_gzip_nbt(compound: &NbtCompound) -> Vec { encoder.finish().unwrap() } +/* +//neo: unused pub fn read_zlib_nbt(data: &[u8]) -> Result { use flate2::read::ZlibDecoder; let mut decoder = ZlibDecoder::new(data); @@ -461,7 +467,7 @@ pub fn get_long_array_or(compound: &NbtCompound, name: &str, default_len: usize) }) .unwrap_or_else(|| vec![0i64; default_len]) } - +*/ pub fn get_nibble(data: &[u8], index: usize) -> u8 { let byte_index = index >> 1; if byte_index >= data.len() { @@ -487,10 +493,12 @@ pub fn set_nibble(data: &mut [u8], index: usize, value: u8) { data[byte_index] = (data[byte_index] & 0x0F) | (val << 4); } } - +/* +//neo: unused pub fn clone_or_empty_list(compound: &NbtCompound, name: &str) -> Vec { compound .list(name) .map(|l| l.to_vec()) .unwrap_or_default() } +*/ diff --git a/src-tauri/src/commands/java2lce/payload.rs b/src-tauri/src/commands/java2lce/payload.rs index 5238467..0bff1d1 100644 --- a/src-tauri/src/commands/java2lce/payload.rs +++ b/src-tauri/src/commands/java2lce/payload.rs @@ -28,7 +28,8 @@ fn read_be_i64(data: &[u8], off: usize) -> i64 { data[off + 6], data[off + 7], ]) } - +/* +//neo: unused fn write_be_i16(out: &mut Vec, v: i16) { out.extend_from_slice(&v.to_be_bytes()); } @@ -39,14 +40,15 @@ fn write_be_i32(out: &mut Vec, v: i32) { fn write_be_i64(out: &mut Vec, v: i64) { out.extend_from_slice(&v.to_be_bytes()); -} +}*/ fn get_compressed_tile_index(block: usize, tile: usize) -> usize { let mut index = ((block & 0x180) << 6) | ((block & 0x060) << 4) | ((block & 0x01F) << 2); index |= ((tile & 0x30) << 7) | ((tile & 0x0C) << 5) | (tile & 0x03); index } - +/* +//neo: unused fn get_nibble_value(nibble_data: &[u8], xz: usize, y: usize) -> u8 { let pos = (xz << 7) | y; let slot = pos >> 1; @@ -60,7 +62,7 @@ fn get_nibble_value(nibble_data: &[u8], xz: usize, y: usize) -> u8 { } else { (b >> 4) & 0x0F } -} +}*/ fn set_nibble_value(nibble_data: &mut [u8], xz: usize, y: usize, value: u8) { let pos = (xz << 7) | y; @@ -77,6 +79,8 @@ fn set_nibble_value(nibble_data: &mut [u8], xz: usize, y: usize, value: u8) { } } +/* +//neo: unused fn ensure_length(input: &[u8], expected_len: usize) -> Vec { if input.len() == expected_len { return input.to_vec(); @@ -115,7 +119,7 @@ fn extract_upper_block_section(full_blocks: &[u8]) -> Vec { upper[dst_start..dst_start + COMPRESSED_CHUNK_SECTION_HEIGHT].copy_from_slice(&full_blocks[src_start..src_start + COMPRESSED_CHUNK_SECTION_HEIGHT]); } upper -} +}*/ fn extract_lower_nibble_section(full_nibbles: &[u8]) -> Vec { let nibble_height = COMPRESSED_CHUNK_SECTION_HEIGHT / 2; @@ -135,7 +139,8 @@ fn extract_lower_nibble_section(full_nibbles: &[u8]) -> Vec { } lower } - +/* +//neo: unused fn extract_upper_nibble_section(full_nibbles: &[u8]) -> Vec { let nibble_height = COMPRESSED_CHUNK_SECTION_HEIGHT / 2; if full_nibbles.len() < FULL_CHUNK_NIBBLES { @@ -149,7 +154,7 @@ fn extract_upper_nibble_section(full_nibbles: &[u8]) -> Vec { .copy_from_slice(&full_nibbles[src_start..src_start + nibble_height]); } upper -} +}*/ fn combine_block_sections(lower: &[u8], upper: &[u8]) -> Vec { if lower.len() == FULL_CHUNK_BLOCKS { @@ -205,6 +210,8 @@ pub fn encode_legacy_nbt(level: &nbt::NbtCompound) -> Vec { nbt::write_nbt(&root) } +/* +//neo: unused fn write_compressed_tile_storage(out: &mut Vec, blocks: &[u8]) { let normalized = ensure_length(blocks, BLOCKS_PER_SECTION); let mut blob = vec![0u8; 1024 + BLOCKS_PER_SECTION]; @@ -321,7 +328,7 @@ pub fn encode_compressed_storage(level: &nbt::NbtCompound) -> Vec { let dynamic_bytes = nbt::write_nbt(&dynamic_root); out.extend_from_slice(&dynamic_bytes); out -} +}*/ fn is_compressed_chunk_storage(data: &[u8]) -> bool { if data.len() < 2 + 4 + 4 + 8 { @@ -330,7 +337,8 @@ fn is_compressed_chunk_storage(data: &[u8]) -> bool { let version = read_be_i16(data, 0); version == SAVE_FILE_VERSION_COMPRESSED_CHUNK_STORAGE || version == SAVE_FILE_VERSION_CHUNK_INHABITED_TIME } - +/* +//neo: unused pub fn try_read_chunk_coordinates( data: &[u8], ) -> Option<(i32, i32, bool)> { @@ -382,7 +390,7 @@ pub fn force_chunk_coordinates(data: &[u8], expected_x: i32, expected_z: i32) -> Vec::new() } - +*/ fn read_compressed_tile_storage(data: &[u8], offset: &mut usize) -> Result, String> { let allocated_size = read_be_i32(data, *offset) as usize; *offset += 4; @@ -445,7 +453,8 @@ fn read_compressed_tile_storage(data: &[u8], offset: &mut usize) -> Result Result<(), String> { let allocated_size = read_be_i32(data, *offset) as usize; *offset += 4; @@ -455,6 +464,7 @@ fn skip_compressed_tile_storage(data: &[u8], offset: &mut usize) -> Result<(), S *offset += allocated_size; Ok(()) } +*/ fn read_sparse_nibble_storage(data: &[u8], offset: &mut usize, supports_all_fifteen: bool) -> Result, String> { let count = read_be_i32(data, *offset) as usize; @@ -496,7 +506,8 @@ fn read_sparse_nibble_storage(data: &[u8], offset: &mut usize, supports_all_fift Ok(nibble_data) } - +/* +//neo: unused fn skip_sparse_nibble_storage(data: &[u8], offset: &mut usize) -> Result<(), String> { let count = read_be_i32(data, *offset) as usize; *offset += 4; @@ -507,7 +518,7 @@ fn skip_sparse_nibble_storage(data: &[u8], offset: &mut usize) -> Result<(), Str *offset += storage_bytes; Ok(()) } - +*/ fn read_sized_bytes(data: &[u8], offset: &mut usize, length: usize) -> Vec { let end = (*offset + length).min(data.len()); let result = data[*offset..end].to_vec(); @@ -636,7 +647,8 @@ fn decode_compressed_chunk_to_legacy_root(data: &[u8]) -> Result Option { if !is_compressed_chunk_storage(data) { return None; @@ -681,3 +693,4 @@ pub fn try_get_compressed_chunk_nbt_offset(data: &[u8]) -> Option { Some(offset) } +*/ diff --git a/src-tauri/src/commands/java2lce/region.rs b/src-tauri/src/commands/java2lce/region.rs index 2c09d21..f00df26 100644 --- a/src-tauri/src/commands/java2lce/region.rs +++ b/src-tauri/src/commands/java2lce/region.rs @@ -72,7 +72,8 @@ impl JavaWorldReader { result } - + /* + //neo: unused pub fn is_anvil_world(&self) -> bool { let region_dir = self.get_region_dir(""); let dir_path = Path::new(®ion_dir); @@ -89,7 +90,7 @@ impl JavaWorldReader { } false } - +*/ fn get_or_create_reader(&mut self, region_path: &str) -> Result<&JavaRegionReader, String> { if !self.region_readers.contains_key(region_path) { let reader = JavaRegionReader::open(region_path)?; @@ -97,7 +98,8 @@ impl JavaWorldReader { } Ok(self.region_readers.get(region_path).unwrap()) } - +/* + //neo: unused pub fn has_chunk(&mut self, region_path: &str, local_x: i32, local_z: i32) -> bool { let reader = match self.get_or_create_reader(region_path) { Ok(r) => r, @@ -106,7 +108,7 @@ impl JavaWorldReader { let index = (local_x & 31) + (local_z & 31) * 32; reader.offsets[index as usize] != 0 } - +*/ pub fn read_chunk_nbt( &mut self, region_path: &str, @@ -721,7 +723,8 @@ pub fn decompress_zlib(data: &[u8]) -> Result, String> { decoder.read_to_end(&mut buf).map_err(|e| format!("zlib decompress failed: {}", e))?; Ok(buf) } - +/* +//neo: unused pub fn decompress_rle_zlib(data: &[u8], decompressed_size: usize) -> Result, String> { let rle_data = decompress_zlib(data)?; Ok(rle_decode(&rle_data, decompressed_size)) @@ -800,3 +803,4 @@ pub fn rle_decode(data: &[u8], expected_size: usize) -> Vec { output } +*/ diff --git a/src-tauri/src/commands/macos_setup.rs b/src-tauri/src/commands/macos_setup.rs index 1dee3e3..0482891 100644 --- a/src-tauri/src/commands/macos_setup.rs +++ b/src-tauri/src/commands/macos_setup.rs @@ -15,6 +15,8 @@ use serde::Deserialize; #[cfg(target_os = "macos")] use crate::platform::macos; #[cfg(target_os = "macos")] +use crate::util; +#[cfg(target_os = "macos")] use std::fs; #[tauri::command] @@ -71,7 +73,7 @@ pub async fn setup_macos_runtime(window: tauri::Window, app: AppHandle) -> Resul None, ); - let client = reqwest::Client::new(); + let client = util::build_http_client_from_app(&app).map_err(|e| e.to_string())?; let release_text = client .get(format!("https://api.github.com/repos/{}/releases/latest", repo)) .header("User-Agent", "Emerald-Legacy-Launcher") diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 4bebe11..379e62f 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -4,6 +4,7 @@ pub mod dlc; pub mod download; pub mod file_dialogs; pub mod game; +pub mod goldmapper; pub mod java2lce; pub mod macos_setup; pub mod platform; diff --git a/src-tauri/src/commands/platform.rs b/src-tauri/src/commands/platform.rs index 4856833..890a48b 100644 --- a/src-tauri/src/commands/platform.rs +++ b/src-tauri/src/commands/platform.rs @@ -7,24 +7,26 @@ pub struct PlatformInfo { pub is_mac: bool, pub is_windows: bool, pub is_android: bool, + pub arch: String, } #[tauri::command] pub fn get_platform() -> PlatformInfo { + let arch = std::env::consts::ARCH.to_string(); #[cfg(target_os = "linux")] { - PlatformInfo { is_linux: true, is_mac: false, is_windows: false, is_android: false } + PlatformInfo { is_linux: true, is_mac: false, is_windows: false, is_android: false, arch } } #[cfg(target_os = "macos")] { - PlatformInfo { is_linux: false, is_mac: true, is_windows: false, is_android: false } + PlatformInfo { is_linux: false, is_mac: true, is_windows: false, is_android: false, arch } } #[cfg(target_os = "windows")] { - PlatformInfo { is_linux: false, is_mac: false, is_windows: true, is_android: false } + PlatformInfo { is_linux: false, is_mac: false, is_windows: true, is_android: false, arch } } #[cfg(target_os = "android")] { - PlatformInfo { is_linux: false, is_mac: false, is_windows: false, is_android: true } + PlatformInfo { is_linux: false, is_mac: false, is_windows: false, is_android: true, arch } } } diff --git a/src-tauri/src/commands/proxy_cmd.rs b/src-tauri/src/commands/proxy_cmd.rs index 5002c82..a516815 100644 --- a/src-tauri/src/commands/proxy_cmd.rs +++ b/src-tauri/src/commands/proxy_cmd.rs @@ -1,12 +1,14 @@ use crate::types::HttpResponse; +use crate::util; #[tauri::command] pub async fn http_proxy_request( + app: tauri::AppHandle, method: String, url: String, body: Option, headers: std::collections::HashMap, ) -> Result { - let client = reqwest::Client::new(); + let client = util::build_http_client_from_app(&app)?; let mut req = match method.to_uppercase().as_str() { "GET" => client.get(&url), "POST" => client.post(&url), diff --git a/src-tauri/src/commands/skin.rs b/src-tauri/src/commands/skin.rs index 2108c20..c97cc8a 100644 --- a/src-tauri/src/commands/skin.rs +++ b/src-tauri/src/commands/skin.rs @@ -7,8 +7,8 @@ use crate::types::ScreenshotInfo; use crate::config; use crate::util; #[tauri::command] -pub async fn fetch_skin(username: String) -> Result<(String, String), String> { - let client = reqwest::Client::new(); +pub async fn fetch_skin(app: AppHandle, username: String) -> Result<(String, String), String> { + let client = util::build_http_client_from_app(&app).map_err(|e| e.to_string())?; let mojang_url = format!("https://api.mojang.com/users/profiles/minecraft/{}", username); let mojang_res = client.get(&mojang_url).send().await.map_err(|e| format!("Failed request to mojang: {}", e))?; if !mojang_res.status().is_success() { @@ -25,12 +25,23 @@ pub async fn fetch_skin(username: String) -> Result<(String, String), String> { } let mc_api_text = mc_api_res.text().await.map_err(|e| format!("Failed to read mc api text: {}", e))?; let mc_api_data: serde_json::Value = serde_json::from_str(&mc_api_text).map_err(|e| format!("Invalid MC API JSON: {}", e))?; - let image_b64 = mc_api_data.get("skin") + if let Some(b64) = mc_api_data.get("skin") .and_then(|s| s.get("image")) .and_then(|v| v.as_str()) + { + return Ok((b64.to_string(), name_exact)); + } + let skin_url = mc_api_data.get("skin") + .and_then(|s| s.get("url")) + .and_then(|v| v.as_str()) .ok_or_else(|| "No skin found".to_string())?; - - Ok((image_b64.to_string(), name_exact)) + let skin_resp = client.get(skin_url).send().await.map_err(|e| format!("Failed to fetch skin image: {}", e))?; + if !skin_resp.status().is_success() { + return Err(format!("Failed to download skin image: {}", skin_resp.status())); + } + let skin_bytes = skin_resp.bytes().await.map_err(|e| format!("Failed to read skin image bytes: {}", e))?; + let image_b64 = base64::engine::general_purpose::STANDARD.encode(&skin_bytes); + Ok((image_b64, name_exact)) } #[tauri::command] @@ -47,7 +58,8 @@ pub async fn download_logo(app: AppHandle, id: String, url: String) -> Result = Vec::new(); for part in parts { let url = format!("{}/{}", raw_base, part); - let response = reqwest::get(&url).await.map_err(|e| e.to_string())?; + let client = util::build_http_client_from_app(app).map_err(|e| e.to_string())?; + let response = client.get(&url).send().await.map_err(|e| e.to_string())?; if !response.status().is_success() { return Err(format!("Failed to download {}: HTTP {}", part, response.status())); } @@ -101,58 +102,21 @@ async fn download_and_assemble( Ok(combined) } -fn extract_archive(zip_tmp: &Path, dest_dir: &Path) -> Result<(Vec, bool), String> { +fn list_archive(zip_tmp: &Path) -> Result, String> { let zip_str = zip_tmp.to_str().unwrap_or(""); if cfg!(target_os = "linux") { - let mut extract_ok = false; - let mut files: Vec = Vec::new(); if let Ok(out) = std::process::Command::new("bsdtar") .args(["-tf", zip_str]) .output() { if out.status.success() { let listing = String::from_utf8_lossy(&out.stdout); - files = listing.lines() - .map(|l| l.trim()) + return Ok(listing.lines() + .map(|l| l.trim().to_string()) .filter(|l| !l.is_empty() && !l.ends_with('/')) - .map(|l| dest_dir.join(l).to_string_lossy().to_string()) - .collect(); - let st = std::process::Command::new("bsdtar") - .args(["-xf", zip_str, "-C", dest_dir.to_str().unwrap()]) - .status() - .map_err(|e| e.to_string())?; - extract_ok = st.success(); + .collect()); } } - if !extract_ok { - let unzip_list = std::process::Command::new("unzip") - .args(["-l", zip_str]) - .output() - .map_err(|e| e.to_string())?; - if !unzip_list.status.success() { - return Err(format!("Failed to list contents of {}", zip_str)); - } - let listing = String::from_utf8_lossy(&unzip_list.stdout); - files = listing.lines() - .filter_map(|l| { - let mut parts = l.trim().split_whitespace(); - let size_str = parts.next()?; - size_str.parse::().ok()?; - parts.next()?; - parts.next()?; - Some(parts.collect::>().join(" ")) - }) - .filter(|l| !l.ends_with('/') && !l.contains('*')) - .map(|l| dest_dir.join(l).to_string_lossy().to_string()) - .collect(); - let st = std::process::Command::new("unzip") - .args(["-o", zip_str, "-d", dest_dir.to_str().unwrap()]) - .status() - .map_err(|e| e.to_string())?; - extract_ok = st.success(); - } - Ok((files, extract_ok)) - } else if cfg!(target_os = "android") { let unzip_list = std::process::Command::new("unzip") .args(["-l", zip_str]) .output() @@ -161,7 +125,7 @@ fn extract_archive(zip_tmp: &Path, dest_dir: &Path) -> Result<(Vec, bool return Err(format!("Failed to list contents of {}", zip_str)); } let listing = String::from_utf8_lossy(&unzip_list.stdout); - let files: Vec = listing.lines() + Ok(listing.lines() .filter_map(|l| { let mut parts = l.trim().split_whitespace(); let size_str = parts.next()?; @@ -171,29 +135,76 @@ fn extract_archive(zip_tmp: &Path, dest_dir: &Path) -> Result<(Vec, bool Some(parts.collect::>().join(" ")) }) .filter(|l| !l.ends_with('/') && !l.contains('*')) - .map(|l| dest_dir.join(l).to_string_lossy().to_string()) - .collect(); - let st = std::process::Command::new("unzip") - .args(["-o", zip_str, "-d", dest_dir.to_str().unwrap()]) - .status() - .map_err(|e| e.to_string())?; - Ok((files, st.success())) - } else { - let st = std::process::Command::new("tar") - .args(["-xf", zip_str, "-C", dest_dir.to_str().unwrap()]) + .collect()) + } else if cfg!(target_os = "android") { + let unzip_list = std::process::Command::new("unzip") + .args(["-l", zip_str]) .output() .map_err(|e| e.to_string())?; + if !unzip_list.status.success() { + return Err(format!("Failed to list contents of {}", zip_str)); + } + let listing = String::from_utf8_lossy(&unzip_list.stdout); + Ok(listing.lines() + .filter_map(|l| { + let mut parts = l.trim().split_whitespace(); + let size_str = parts.next()?; + size_str.parse::().ok()?; + parts.next()?; + parts.next()?; + Some(parts.collect::>().join(" ")) + }) + .filter(|l| !l.ends_with('/') && !l.contains('*')) + .collect()) + } else { let listing = std::process::Command::new("tar") .args(["-tf", zip_str]) .output() .map_err(|e| e.to_string())?; let listing_str = String::from_utf8_lossy(&listing.stdout); - let files: Vec = listing_str.lines() - .map(|l| l.trim()) + Ok(listing_str.lines() + .map(|l| l.trim().to_string()) .filter(|l| !l.is_empty() && !l.ends_with('/')) - .map(|l| dest_dir.join(l).to_string_lossy().to_string()) - .collect(); - Ok((files, st.status.success())) + .collect()) + } +} + +fn extract_archive(zip_tmp: &Path, dest_dir: &Path) -> Result { + let zip_str = zip_tmp.to_str().unwrap_or(""); + if cfg!(target_os = "linux") { + let mut extract_ok = false; + if let Ok(out) = std::process::Command::new("bsdtar") + .args(["-tf", zip_str]) + .output() + { + if out.status.success() { + let st = std::process::Command::new("bsdtar") + .args(["-xf", zip_str, "-C", dest_dir.to_str().unwrap()]) + .status() + .map_err(|e| e.to_string())?; + extract_ok = st.success(); + } + } + if !extract_ok { + let st = std::process::Command::new("unzip") + .args(["-o", zip_str, "-d", dest_dir.to_str().unwrap()]) + .status() + .map_err(|e| e.to_string())?; + extract_ok = st.success(); + } + Ok(extract_ok) + } else if cfg!(target_os = "android") { + let st = std::process::Command::new("unzip") + .args(["-o", zip_str, "-d", dest_dir.to_str().unwrap()]) + .status() + .map_err(|e| e.to_string())?; + Ok(st.success()) + } else { + let st = std::process::Command::new("tar") + .args(["-xf", zip_str, "-C", dest_dir.to_str().unwrap()]) + .status() + .map_err(|e| e.to_string())?; + Ok(st.success()) } } @@ -241,11 +252,24 @@ pub async fn workshop_install(app: AppHandle, request: WorkshopInstallRequest) - fs::create_dir_all(&dest_dir).map_err(|e| e.to_string())?; let archive_tmp = download_and_assemble(&app, &tmp_dir, &parts, &raw_base, &request.package_id, &mut done_parts, total_parts).await?; - let (extracted_files, extract_ok) = extract_archive(&archive_tmp, &dest_dir)?; + let files = list_archive(&archive_tmp)?; + for f in &files { + let full = dest_dir.join(f); + if full.is_file() { + let bak = format!("{}.bak", full.to_string_lossy()); + if !Path::new(&bak).exists() { + let _ = fs::rename(&full, &bak); + } + } + } + let extract_ok = extract_archive(&archive_tmp, &dest_dir)?; if !extract_ok { let _ = fs::remove_dir_all(&tmp_dir); return Err(format!("Extraction failed for {}", parts[0])); } + let extracted_files: Vec = files.iter() + .map(|f| dest_dir.join(f).to_string_lossy().to_string()) + .collect(); for f in &extracted_files { if !workshop_files.contains(f) { @@ -291,6 +315,10 @@ pub async fn workshop_uninstall(app: AppHandle, instance_id: String, package_id: if path.is_file() { let _ = fs::remove_file(&path); } + let bak = format!("{}.bak", path.to_string_lossy()); + if Path::new(&bak).is_file() { + let _ = fs::rename(&bak, &path); + } } } diff --git a/src-tauri/src/config.rs b/src-tauri/src/config.rs index 28bd1fb..b6e30d0 100644 --- a/src-tauri/src/config.rs +++ b/src-tauri/src/config.rs @@ -40,6 +40,9 @@ pub fn load_config_raw(app: AppHandle) -> AppConfig { instance_launch_args: None, android_runner: None, android_audio_backend: None, + goldmapper_enabled: Some(true), + goldmapper_mappings: None, + http_proxy: None, } } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index e4547ab..6b760be 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -21,6 +21,7 @@ use commands::dlc; use commands::download; use commands::file_dialogs; use commands::game; +use commands::goldmapper; use commands::macos_setup; use commands::platform as platform_cmd; use commands::plugins; @@ -149,6 +150,10 @@ pub fn run() { game::switch_proton, game::install_latest_driver, game::set_audio_backend, + goldmapper::goldmapper_get_defaults, + goldmapper::goldmapper_load_config, + goldmapper::goldmapper_save_config, + goldmapper::goldmapper_reset_config, ]) .setup(|app| { let app_handle = app.handle().clone(); diff --git a/src-tauri/src/networking/relay.rs b/src-tauri/src/networking/relay.rs index 1046cf0..0f308c8 100644 --- a/src-tauri/src/networking/relay.rs +++ b/src-tauri/src/networking/relay.rs @@ -32,7 +32,7 @@ async fn run_host_relay( .map_err(|e| format!("Proxy connect failed: {}", e))?; write_line(&mut host_conn, &format!("HOST {} 0", auth_token)).await?; - let game_stream = tokio::time::timeout( + let mut game_stream = tokio::time::timeout( std::time::Duration::from_secs(30), async { loop { @@ -59,41 +59,15 @@ async fn run_host_relay( .await .map_err(|e| format!("Proxy connect failed: {}", e))?; write_line(&mut accept_conn, &format!("ACCEPT {} 0 {}", auth_token, joiner_id)).await?; - let (mut g_read, mut g_write) = game_stream.into_split(); - let (mut a_read, mut a_write) = accept_conn.into_split(); - let c1 = cancel.clone(); - let c2 = cancel.clone(); - let t1 = tokio::spawn(async move { - let mut buf = [0u8; 65536]; - loop { - tokio::select! { - r = g_read.read(&mut buf) => { - match r { - Ok(0) | Err(_) => break, - Ok(n) => { if a_write.write_all(&buf[..n]).await.is_err() { break; } } - } - } - _ = c1.cancelled() => break, + tokio::select! { + res = tokio::io::copy_bidirectional(&mut game_stream, &mut accept_conn) => { + if let Err(e) = res { + eprintln!("Host Relay error: {}", e); } - } - }); + }, + _ = cancel.cancelled() => {}, + } - let t2 = tokio::spawn(async move { - let mut buf = [0u8; 65536]; - loop { - tokio::select! { - r = a_read.read(&mut buf) => { - match r { - Ok(0) | Err(_) => break, - Ok(n) => { if g_write.write_all(&buf[..n]).await.is_err() { break; } } - } - } - _ = c2.cancelled() => break, - } - } - }); - - let _ = tokio::join!(t1, t2); Ok(()) } @@ -118,46 +92,20 @@ async fn run_relay_proxy( *port = Some(local_port); } - let (local_stream, _) = tokio::select! { + let (mut local_stream, _) = tokio::select! { r = listener.accept() => r.map_err(|e| format!("Accept failed: {}", e))?, _ = cancel.cancelled() => return Err("Cancelled".into()), }; - let (mut l_read, mut l_write) = local_stream.into_split(); - let (mut s_read, mut s_write) = stream.into_split(); - let c1 = cancel.clone(); - let c2 = cancel.clone(); - let t1 = tokio::spawn(async move { - let mut buf = [0u8; 65536]; - loop { - tokio::select! { - r = l_read.read(&mut buf) => { - match r { - Ok(0) | Err(_) => break, - Ok(n) => { if s_write.write_all(&buf[..n]).await.is_err() { break; } } - } - } - _ = c1.cancelled() => break, + tokio::select! { + res = tokio::io::copy_bidirectional(&mut local_stream, &mut stream) => { + if let Err(e) = res { + eprintln!("Relay Proxy error: {}", e); } - } - }); + }, + _ = cancel.cancelled() => {}, + } - let t2 = tokio::spawn(async move { - let mut buf = [0u8; 65536]; - loop { - tokio::select! { - r = s_read.read(&mut buf) => { - match r { - Ok(0) | Err(_) => break, - Ok(n) => { if l_write.write_all(&buf[..n]).await.is_err() { break; } } - } - } - _ = c2.cancelled() => break, - } - } - }); - - let _ = tokio::join!(t1, t2); Ok(local_port) } diff --git a/src-tauri/src/types.rs b/src-tauri/src/types.rs index e877357..74164f2 100644 --- a/src-tauri/src/types.rs +++ b/src-tauri/src/types.rs @@ -39,6 +39,12 @@ pub struct InstanceLaunchArgs { pub args: Vec, } +#[derive(Serialize, Deserialize, Clone, Debug)] +pub struct GoldMapperMapping { + pub from: String, + pub to: String, +} + #[derive(Serialize, Deserialize, Clone, Debug)] #[serde(rename_all = "camelCase")] pub struct AppConfig { @@ -69,6 +75,9 @@ pub struct AppConfig { pub instance_launch_args: Option>, pub android_runner: Option, pub android_audio_backend: Option, + pub goldmapper_enabled: Option, + pub goldmapper_mappings: Option>, + pub http_proxy: Option, } #[derive(Serialize, Deserialize, Clone, Debug)] diff --git a/src-tauri/src/util.rs b/src-tauri/src/util.rs index 774bc9a..1a14eea 100644 --- a/src-tauri/src/util.rs +++ b/src-tauri/src/util.rs @@ -44,6 +44,33 @@ pub fn copy_dir_all(src: impl AsRef, dst: impl AsRef) -> Result { + let mut builder = reqwest::Client::builder(); + builder = builder.user_agent("Emerald-Launcher"); + builder = builder + .connect_timeout(std::time::Duration::from_secs(15)) + .timeout(std::time::Duration::from_secs(500)); + if let Some(p) = proxy { + let trimmed = p.trim(); + if !trimmed.is_empty() { + let has_scheme = trimmed.contains("://"); + let full = if has_scheme { + trimmed.to_string() + } else { + format!("http://{}", trimmed) + }; + let proxy = reqwest::Proxy::all(&full).map_err(|e| format!("Invalid proxy: {e}"))?; + builder = builder.proxy(proxy); + } + } + builder.build().map_err(|e| e.to_string()) +} + +pub fn build_http_client_from_app(app: &AppHandle) -> Result { + let config = config::load_config_raw(app.clone()); + build_http_client(config.http_proxy.as_deref()) +} + #[cfg(unix)] pub fn unix_path_to_wine_z_path(unix_path: &PathBuf) -> String { let p = unix_path.to_string_lossy(); diff --git a/src-tauri/src/workshop_server.rs b/src-tauri/src/workshop_server.rs index d1fa70b..24e0382 100644 --- a/src-tauri/src/workshop_server.rs +++ b/src-tauri/src/workshop_server.rs @@ -1,10 +1,8 @@ -use once_cell::sync::Lazy; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; use tokio::net::TcpListener; use tokio_util::sync::CancellationToken; use tauri::{AppHandle, Emitter}; const REGISTRY_URL: &str = "https://raw.githubusercontent.com/LCE-Hub/LCE-Workshop/refs/heads/main"; //neo: more hardcoding! -static CLIENT: Lazy = Lazy::new(|| reqwest::Client::new()); pub struct Guard { cancel: Option, } @@ -48,7 +46,8 @@ async fn serve(app: AppHandle, cancel: CancellationToken) { result = listener.accept() => { match result { Ok((stream, _)) => { - tokio::spawn(handle(stream)); + let handle_app = app.clone(); + tokio::spawn(handle(stream, handle_app)); } Err(e) => { let _ = app.emit("backend-error", format!("Workshop server accept error: {e}")); @@ -61,7 +60,7 @@ async fn serve(app: AppHandle, cancel: CancellationToken) { } } -async fn handle(stream: tokio::net::TcpStream) { +async fn handle(stream: tokio::net::TcpStream, app: AppHandle) { let (reader, mut writer) = stream.into_split(); let mut buf_reader = BufReader::new(reader); let mut request_line = String::new(); @@ -81,7 +80,7 @@ async fn handle(stream: tokio::net::TcpStream) { if request_line.starts_with("GET /workshop/") && request_line.ends_with(" HTTP/1.1") { let path = &request_line["GET /workshop/".len()..request_line.len() - " HTTP/1.1".len()]; - match fetch_workshop_file(path).await { + match fetch_workshop_file(&app, path).await { Ok(body) => { let body_bytes = &body; let content_type = if path.ends_with(".json") { @@ -123,9 +122,10 @@ async fn handle(stream: tokio::net::TcpStream) { } } -async fn fetch_workshop_file(path: &str) -> Result, String> { +async fn fetch_workshop_file(app: &AppHandle, path: &str) -> Result, String> { let url = format!("{}/{}", REGISTRY_URL, path); - let resp = CLIENT.get(&url).send().await.map_err(|e| e.to_string())?; + let client = crate::util::build_http_client_from_app(app)?; + let resp = client.get(&url).send().await.map_err(|e| e.to_string())?; if resp.status().is_success() { resp.bytes().await.map(|b| b.to_vec()).map_err(|e| e.to_string()) } else { diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 5a75eb9..15bd370 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -58,7 +58,7 @@ "bundleMediaFramework": true } }, - "resources": ["resources/DLC"], + "resources": ["resources/DLC", "resources/GoldMapperLib.dll", "resources/GoldMapperLauncher.exe", "resources/SDL2.dll"], "icon": [ "icons/32x32.png", "icons/64x64.png", diff --git a/src/components/common/AchievementToast.tsx b/src/components/common/AchievementToast.tsx index 8bc1908..4f240d8 100644 --- a/src/components/common/AchievementToast.tsx +++ b/src/components/common/AchievementToast.tsx @@ -1,4 +1,5 @@ import { useEffect, useRef } from "react"; +import { useTranslation } from "react-i18next"; import { motion, AnimatePresence } from "framer-motion"; import { useAudio } from "../../context/LauncherContext"; @@ -14,10 +15,12 @@ export function AchievementToast({ message, onClose, onClick, - title = "Error!", + title, variant = "error", }: AchievementToastProps) { + const { t } = useTranslation(); const { playSfx } = useAudio(); + const displayTitle = title ?? t("common.error"); const prevMessage = useRef(message); useEffect(() => { const wasNull = !prevMessage.current; @@ -47,19 +50,14 @@ export function AchievementToast({ const getIcon = () => { if (variant === "update") { return ( - - - + Update ); } if (variant === "steam") { @@ -109,25 +107,17 @@ export function AchievementToast({ } : undefined } - className={`fixed top-6 right-6 z-[9999] ${onClick ? "cursor-pointer" : ""}`} + className={onClick ? "cursor-pointer" : ""} > -
-
+
+
{getIcon()}
-
- - {title} +
+ + {displayTitle} - + {message}
diff --git a/src/components/common/PanoramaBackground.tsx b/src/components/common/PanoramaBackground.tsx index a55828b..5dbebf6 100644 --- a/src/components/common/PanoramaBackground.tsx +++ b/src/components/common/PanoramaBackground.tsx @@ -1,5 +1,5 @@ import React, { useEffect, useRef, useState } from "react"; -import { useUI } from "../../context/LauncherContext"; + interface PanoramaProps { profile: string; @@ -7,7 +7,6 @@ interface PanoramaProps { } const PanoramaBackground = React.memo(({ profile, isDay }: PanoramaProps) => { - const { isWindowVisible } = useUI(); const baseId = profile; const profileId = baseId ? baseId : "vanilla_tu19"; const isCustomUrl = profile.startsWith("data:") || profile.startsWith("blob:"); @@ -54,18 +53,16 @@ const PanoramaBackground = React.memo(({ profile, isDay }: PanoramaProps) => { ref={containerRef} className="absolute inset-0 overflow-hidden pointer-events-none transition-opacity duration-500" > - {isWindowVisible && ( -
- )} +
diff --git a/src/components/common/SkinModel3D.tsx b/src/components/common/SkinModel3D.tsx new file mode 100644 index 0000000..698a7bb --- /dev/null +++ b/src/components/common/SkinModel3D.tsx @@ -0,0 +1,614 @@ +import { useEffect, useRef, memo } from "react"; +import * as THREE from "three"; +type UVSet = Record; +interface SkinModel3DProps { + src: string; + slim?: boolean; + capeUrl?: string | null; + animate?: boolean; + className?: string; +} + +const clamp = (v: number, min: number, max: number) => + Math.min(max, Math.max(min, v)); + +let snapshotChain: Promise = Promise.resolve(); +const snapshotCache = new Map(); +function createFaceMaterial( + tex: THREE.Texture, + x: number, + y: number, + w: number, + h: number, + flipX = false, + flipY = false, +) { + const img = tex.image as { width: number; height: number } | undefined; + const imgH = img && img.height ? img.height : 64; + const matTex = tex.clone(); + matTex.repeat.set((flipX ? -w : w) / 64, (flipY ? -h : h) / imgH); + matTex.offset.set((flipX ? x + w : x) / 64, 1 - (flipY ? y : y + h) / imgH); + matTex.needsUpdate = true; + return new THREE.MeshLambertMaterial({ + map: matTex, + transparent: true, + alphaTest: 0.5, + side: THREE.FrontSide, + }); +} + +function createPart( + tex: THREE.Texture, + w: number, + h: number, + d: number, + uv: UVSet, + overlayUv?: UVSet, + swapMats = false, + isLegacyMirror = false, +) { + const group = new THREE.Group(); + const geo = new THREE.BoxGeometry(w, h, d); + const getMats = (uvSet: UVSet) => { + const flipX = isLegacyMirror; + return [ + createFaceMaterial( + tex, + swapMats ? uvSet.right[0] : uvSet.left[0], + uvSet.left[1], + uvSet.left[2], + uvSet.left[3], + flipX, + ), + createFaceMaterial( + tex, + swapMats ? uvSet.left[0] : uvSet.right[0], + uvSet.right[1], + uvSet.right[2], + uvSet.right[3], + flipX, + ), + createFaceMaterial( + tex, + uvSet.top[0], + uvSet.top[1], + uvSet.top[2], + uvSet.top[3], + flipX, + true, + ), + createFaceMaterial( + tex, + uvSet.bottom[0], + uvSet.bottom[1], + uvSet.bottom[2], + uvSet.bottom[3], + flipX, + true, + ), + createFaceMaterial( + tex, + uvSet.front[0], + uvSet.front[1], + uvSet.front[2], + uvSet.front[3], + flipX, + ), + createFaceMaterial( + tex, + uvSet.back[0], + uvSet.back[1], + uvSet.back[2], + uvSet.back[3], + !flipX, + ), + ]; + }; + group.add(new THREE.Mesh(geo, getMats(uv))); + if (overlayUv) { + const oGeo = new THREE.BoxGeometry(w + 0.5, h + 0.5, d + 0.5); + group.add(new THREE.Mesh(oGeo, getMats(overlayUv))); + } + return group; +} + +const limbUv = (x: number, y: number, w = 4): UVSet => ({ + top: [x + 4, y, w, 4], + bottom: [x + 4 + w, y, w, 4], + right: [x, y + 4, 4, 12], + front: [x + 4, y + 4, w, 12], + left: [x + 4 + w, y + 4, 4, 12], + back: [x + 8 + w, y + 4, w, 12], +}); + +function buildPlayer( + playerGroup: THREE.Group, + texture: THREE.Texture, + slimOpt?: boolean, +) { + const img = texture.image as HTMLImageElement; + const isLegacy = (img?.height || 64) === 32; + const isSlim = + slimOpt !== undefined + ? slimOpt + : !isLegacy && + (() => { + const canvas = document.createElement("canvas"); + canvas.width = img?.width || 64; + canvas.height = img?.height || 64; + const ctx = canvas.getContext("2d"); + if (!ctx) return false; + ctx.drawImage(img, 0, 0, canvas.width, canvas.height); + try { + return ctx.getImageData(42, 48, 1, 1).data[3] === 0; + } catch { + return false; + } + })(); + const armW = isSlim ? 3 : 4; + const headUv: UVSet = { + top: [8, 0, 8, 8], + bottom: [16, 0, 8, 8], + right: [0, 8, 8, 8], + left: [16, 8, 8, 8], + front: [8, 8, 8, 8], + back: [24, 8, 8, 8], + }; + const hatUv: UVSet = { + top: [40, 0, 8, 8], + bottom: [48, 0, 8, 8], + right: [32, 8, 8, 8], + left: [48, 8, 8, 8], + front: [40, 8, 8, 8], + back: [56, 8, 8, 8], + }; + const head = createPart(texture, 8, 8, 8, headUv, hatUv); + head.position.y = 10; + playerGroup.add(head); + const bodyUv: UVSet = { + top: [20, 16, 8, 4], + bottom: [28, 16, 8, 4], + right: [16, 20, 4, 12], + left: [28, 20, 4, 12], + front: [20, 20, 8, 12], + back: [32, 20, 8, 12], + }; + const jacketUv: UVSet = { + top: [20, 32, 8, 4], + bottom: [28, 32, 8, 4], + right: [16, 36, 4, 12], + left: [28, 36, 4, 12], + front: [20, 36, 8, 12], + back: [32, 36, 8, 12], + }; + playerGroup.add( + createPart(texture, 8, 12, 4, bodyUv, isLegacy ? undefined : jacketUv), + ); + + const rightArm = createPart( + texture, + armW, + 12, + 4, + limbUv(40, 16, armW), + isLegacy ? undefined : limbUv(40, 32, armW), + ); + rightArm.position.set(isSlim ? -5.5 : -6, 0, 0); + playerGroup.add(rightArm); + const leftArm = createPart( + texture, + armW, + 12, + 4, + isLegacy ? limbUv(40, 16, armW) : limbUv(32, 48, armW), + isLegacy ? undefined : limbUv(48, 48, armW), + isLegacy, + isLegacy, + ); + leftArm.position.set(isSlim ? 5.5 : 6, 0, 0); + playerGroup.add(leftArm); + const rightLeg = createPart( + texture, + 4, + 12, + 4, + limbUv(0, 16), + isLegacy ? undefined : limbUv(0, 32), + ); + rightLeg.position.set(-2, -12, 0); + playerGroup.add(rightLeg); + const leftLeg = createPart( + texture, + 4, + 12, + 4, + isLegacy ? limbUv(0, 16) : limbUv(16, 48), + isLegacy ? undefined : limbUv(0, 48), + isLegacy, + isLegacy, + ); + leftLeg.position.set(2, -12, 0); + playerGroup.add(leftLeg); +} + +function addCape(playerGroup: THREE.Group, texture: THREE.Texture) { + const capeUv: UVSet = { + top: [1, 0, 10, 1], + bottom: [11, 0, 10, 1], + right: [0, 1, 1, 16], + front: [1, 1, 10, 16], + left: [11, 1, 1, 16], + back: [12, 1, 10, 16], + }; + const capeGroup = new THREE.Group(); + const capeGeo = new THREE.BoxGeometry(10, 16, 1); + const capeMats = [ + createFaceMaterial( + texture, + capeUv.left[0], + capeUv.left[1], + capeUv.left[2], + capeUv.left[3], + ), + createFaceMaterial( + texture, + capeUv.right[0], + capeUv.right[1], + capeUv.right[2], + capeUv.right[3], + ), + createFaceMaterial( + texture, + capeUv.top[0], + capeUv.top[1], + capeUv.top[2], + capeUv.top[3], + false, + true, + ), + createFaceMaterial( + texture, + capeUv.bottom[0], + capeUv.bottom[1], + capeUv.bottom[2], + capeUv.bottom[3], + false, + true, + ), + createFaceMaterial( + texture, + capeUv.back[0], + capeUv.back[1], + capeUv.back[2], + capeUv.back[3], + ), + createFaceMaterial( + texture, + capeUv.front[0], + capeUv.front[1], + capeUv.front[2], + capeUv.front[3], + ), + ]; + const capeMesh = new THREE.Mesh(capeGeo, capeMats); + capeMesh.position.set(0, -8, -0.5); + capeGroup.add(capeMesh); + capeGroup.position.set(0, 6, -2.35); + capeGroup.rotation.x = 0.15; + playerGroup.add(capeGroup); +} + +function fitCamera(camera: THREE.PerspectiveCamera, target: THREE.Object3D) { + const box = new THREE.Box3().setFromObject(target); + const center = box.getCenter(new THREE.Vector3()); + const size = box.getSize(new THREE.Vector3()); + const maxDim = Math.max(size.x, size.y, size.z) || 1; + const vFov = THREE.MathUtils.degToRad(camera.fov); + const distance = maxDim / (2 * Math.tan(vFov / 2) * 0.72); + camera.position.set(center.x, center.y, center.z + distance); + camera.lookAt(center); +} + +function addLights(scene: THREE.Scene) { + scene.add(new THREE.AmbientLight(0xffffff, 0.4)); + scene.add(new THREE.HemisphereLight(0xffffff, 0x444444, 0.6)); + const dl = new THREE.DirectionalLight(0xffffff, 0.8); + dl.position.set(10, 20, 10); + scene.add(dl); +} + +function disposeScene( + scene: THREE.Scene, + renderer: THREE.WebGLRenderer, + extraTextures: THREE.Texture[], +) { + scene.traverse((object) => { + if (object instanceof THREE.Mesh) { + if (object.geometry) object.geometry.dispose(); + const mats = Array.isArray(object.material) + ? object.material + : [object.material]; + mats.forEach((m) => { + if (m.map) m.map.dispose(); + m.dispose(); + }); + } + }); + extraTextures.forEach((t) => t.dispose()); + renderer.dispose(); +} + +function renderSkinSnapshot(opts: { + src: string; + slim?: boolean; + capeUrl?: string | null; + width: number; + height: number; +}): Promise { + return new Promise((resolve, reject) => { + const canvas = document.createElement("canvas"); + canvas.width = opts.width; + canvas.height = opts.height; + const ctx = canvas.getContext("2d"); + if (!ctx) { + reject(new Error("no 2d context")); + return; + } + const scene = new THREE.Scene(); + const camera = new THREE.PerspectiveCamera( + 35, + opts.width / opts.height, + 0.1, + 1000, + ); + const renderer = new THREE.WebGLRenderer({ + antialias: true, + alpha: true, + preserveDrawingBuffer: true, + }); + renderer.setPixelRatio(1); + renderer.setSize(opts.width, opts.height); + addLights(scene); + const playerGroup = new THREE.Group(); + scene.add(playerGroup); + const extraTextures: THREE.Texture[] = []; + let active = true; + let skinDone = false; + let capeDone = !opts.capeUrl; + const finish = () => { + if (!active || !skinDone || !capeDone) return; + fitCamera(camera, playerGroup); + renderer.render(scene, camera); + ctx.drawImage(renderer.domElement, 0, 0); + const dataUrl = canvas.toDataURL("image/png"); + disposeScene(scene, renderer, extraTextures); + active = false; + resolve(dataUrl); + }; + const fail = (err: unknown) => { + if (!active) return; + active = false; + disposeScene(scene, renderer, extraTextures); + reject(err); + }; + const loader = new THREE.TextureLoader(); + loader.load( + opts.src, + (tex) => { + if (!active) return; + tex.magFilter = THREE.NearestFilter; + tex.minFilter = THREE.NearestFilter; + tex.colorSpace = THREE.SRGBColorSpace; + buildPlayer(playerGroup, tex, opts.slim); + extraTextures.push(tex); + skinDone = true; + finish(); + }, + undefined, + fail, + ); + if (opts.capeUrl) { + loader.load( + opts.capeUrl, + (tex) => { + if (!active) return; + tex.magFilter = THREE.NearestFilter; + tex.minFilter = THREE.NearestFilter; + tex.colorSpace = THREE.SRGBColorSpace; + addCape(playerGroup, tex); + extraTextures.push(tex); + capeDone = true; + finish(); + }, + undefined, + fail, + ); + } + }); +} + +export function getSkinModelSnapshot(opts: { + src: string; + slim?: boolean; + capeUrl?: string | null; + width: number; + height: number; +}): Promise { + const key = `${opts.src}\u0000${opts.slim ? 1 : 0}\u0000${opts.capeUrl || ""}`; + const hit = snapshotCache.get(key); + if (hit) return Promise.resolve(hit); + const result = snapshotChain.then(() => renderSkinSnapshot(opts)); + snapshotChain = result.then( + () => undefined, + () => undefined, + ); + result.then((dataUrl) => snapshotCache.set(key, dataUrl)).catch(() => {}); + return result; +} + +const SkinModel3D = memo(function SkinModel3D({ + src, + slim, + capeUrl, + animate, + className, +}: SkinModel3DProps) { + const mountRef = useRef(null); + + useEffect(() => { + const el = mountRef.current; + if (!el) return; + const width = el.clientWidth || 240; + const height = el.clientHeight || 320; + const scene = new THREE.Scene(); + const camera = new THREE.PerspectiveCamera(35, width / height, 0.1, 1000); + const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true }); + renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2)); + renderer.setSize(width, height); + el.innerHTML = ""; + el.appendChild(renderer.domElement); + addLights(scene); + const playerGroup = new THREE.Group(); + scene.add(playerGroup); + const extraTextures: THREE.Texture[] = []; + let built = false; + let disposed = false; + let rotY = -0.35; + let rotX = 0.06; + let dragging = false; + let raf = 0; + let lastX = 0; + let lastY = 0; + + const render = () => { + if (!disposed) renderer.render(scene, camera); + }; + + const onMouseDown = (e: MouseEvent) => { + dragging = true; + lastX = e.clientX; + lastY = e.clientY; + }; + const onMouseMove = (e: MouseEvent) => { + if (!dragging || disposed) return; + rotY += (e.clientX - lastX) * 0.01; + rotX = clamp(rotX + (e.clientY - lastY) * 0.01, -0.6, 0.6); + lastX = e.clientX; + lastY = e.clientY; + }; + const onMouseUp = () => { + dragging = false; + }; + const onTouchStart = (e: TouchEvent) => { + const t = e.touches[0]; + if (!t) return; + dragging = true; + lastX = t.clientX; + lastY = t.clientY; + }; + const onTouchMove = (e: TouchEvent) => { + if (!dragging || disposed) return; + const t = e.touches[0]; + if (!t) return; + rotY += (t.clientX - lastX) * 0.01; + rotX = clamp(rotX + (t.clientY - lastY) * 0.01, -0.6, 0.6); + lastX = t.clientX; + lastY = t.clientY; + }; + const onTouchEnd = () => { + dragging = false; + }; + + const loop = () => { + raf = requestAnimationFrame(loop); + if (animate && !dragging) rotY += 0.004; + if (animate || dragging) { + playerGroup.rotation.y = rotY; + playerGroup.rotation.x = rotX; + render(); + } + }; + + const handleResize = () => { + if (!mountRef.current || disposed) return; + const w = mountRef.current.clientWidth || width; + const h = mountRef.current.clientHeight || height; + camera.aspect = w / h; + camera.updateProjectionMatrix(); + renderer.setSize(w, h); + if (built) fitCamera(camera, playerGroup); + render(); + }; + + const dispose = () => { + if (disposed) return; + disposed = true; + cancelAnimationFrame(raf); + window.removeEventListener("mousemove", onMouseMove); + window.removeEventListener("mouseup", onMouseUp); + window.removeEventListener("touchmove", onTouchMove); + window.removeEventListener("touchend", onTouchEnd); + window.removeEventListener("resize", handleResize); + disposeScene(scene, renderer, extraTextures); + }; + + const loader = new THREE.TextureLoader(); + let skinLoaded = false; + let capeLoaded = !capeUrl; + const tryFinish = () => { + if (!skinLoaded || !capeLoaded || disposed) return; + built = true; + fitCamera(camera, playerGroup); + render(); + loop(); + }; + loader.load( + src, + (tex) => { + if (disposed) return; + tex.magFilter = THREE.NearestFilter; + tex.minFilter = THREE.NearestFilter; + tex.colorSpace = THREE.SRGBColorSpace; + buildPlayer(playerGroup, tex, slim); + extraTextures.push(tex); + skinLoaded = true; + tryFinish(); + }, + undefined, + () => dispose(), + ); + if (capeUrl) { + loader.load( + capeUrl, + (tex) => { + if (disposed) return; + tex.magFilter = THREE.NearestFilter; + tex.minFilter = THREE.NearestFilter; + tex.colorSpace = THREE.SRGBColorSpace; + addCape(playerGroup, tex); + extraTextures.push(tex); + capeLoaded = true; + tryFinish(); + }, + undefined, + () => dispose(), + ); + } + + renderer.domElement.addEventListener("mousedown", onMouseDown); + window.addEventListener("mousemove", onMouseMove); + window.addEventListener("mouseup", onMouseUp); + renderer.domElement.addEventListener("touchstart", onTouchStart, { + passive: true, + }); + window.addEventListener("touchmove", onTouchMove, { passive: false }); + window.addEventListener("touchend", onTouchEnd); + window.addEventListener("resize", handleResize); + + return dispose; + }, [src, slim, capeUrl, animate]); + + return
; +}); + +export default SkinModel3D; diff --git a/src/components/common/SkinViewer.tsx b/src/components/common/SkinViewer.tsx index b5a1e6d..2c2f606 100644 --- a/src/components/common/SkinViewer.tsx +++ b/src/components/common/SkinViewer.tsx @@ -36,7 +36,7 @@ const SkinViewer = memo(function SkinViewer({ const mountRef = useRef(null); const containerRef = useRef(null); const [focusIndex, setFocusIndex] = useState(0); - const { legacyMode } = useConfig(); + const { legacyMode, animationsEnabled } = useConfig(); const overlaysRef = useRef([]); const capeRef = useRef(null); const capeOrigRef = useRef<{ y: number; rx: number; meshY: number } | null>( @@ -433,7 +433,9 @@ const SkinViewer = memo(function SkinViewer({ renderer.domElement.addEventListener("mousedown", onMouseDown); window.addEventListener("mousemove", onMouseMove); window.addEventListener("mouseup", onMouseUp); - renderer.domElement.addEventListener("touchstart", onTouchStart, { passive: true }); + renderer.domElement.addEventListener("touchstart", onTouchStart, { + passive: true, + }); window.addEventListener("touchmove", onTouchMove, { passive: false }); window.addEventListener("touchend", onTouchEnd); return () => { @@ -523,6 +525,7 @@ const SkinViewer = memo(function SkinViewer({ } else if (e.key === "ArrowUp") { setFocusIndex((prev) => (prev > 0 ? prev - 1 : prev)); } else if (e.key === "Enter") { + e.preventDefault(); if (focusIndex === 0) { ( containerRef.current?.querySelector("input") as HTMLElement @@ -678,10 +681,10 @@ const SkinViewer = memo(function SkinViewer({ return ( @@ -691,20 +694,22 @@ const SkinViewer = memo(function SkinViewer({ data-focus="0" tabIndex={0} > - setUsername(e.target.value)} - onKeyDown={(e) => { - if (e.key === "Enter") { - e.currentTarget.blur(); - e.stopPropagation(); - } - }} - className="bg-transparent text-white focus:text-[#FFFF55] outline-none border-none text-center font-['Mojangles'] mc-text-shadow tracking-widest text-xl cursor-text" - /> +
+ setUsername(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.currentTarget.blur(); + e.stopPropagation(); + } + }} + className="text-white focus:text-[#FFFF55] outline-none text-center font-[var(--font-base)] tracking-widest text-xl cursor-text" + /> +
)} {!legacyMode && ( diff --git a/src/components/layout/AppHeader.tsx b/src/components/layout/AppHeader.tsx index e953180..0b45c67 100644 --- a/src/components/layout/AppHeader.tsx +++ b/src/components/layout/AppHeader.tsx @@ -1,6 +1,7 @@ import { getCurrentWindow } from "@tauri-apps/api/window"; import { motion } from "framer-motion"; import { memo } from "react"; +import { useTranslation } from "react-i18next"; const appWindow = getCurrentWindow(); @@ -9,7 +10,11 @@ interface AppHeaderProps { uiFade: Record; } -export const AppHeader = memo(function AppHeader({ playPressSound, uiFade }: AppHeaderProps) { +export const AppHeader = memo(function AppHeader({ + playPressSound, + uiFade, +}: AppHeaderProps) { + const { t } = useTranslation(); return ( - - Emerald Legacy Launcher + + {t("header.title")}
diff --git a/src/components/layout/DownloadOverlay.tsx b/src/components/layout/DownloadOverlay.tsx index 0038738..0d9d630 100644 --- a/src/components/layout/DownloadOverlay.tsx +++ b/src/components/layout/DownloadOverlay.tsx @@ -1,5 +1,6 @@ import { motion } from "framer-motion"; import { memo } from "react"; +import { useTranslation } from "react-i18next"; import { ScreenshotImage } from "../common/ScreenshotImage"; import type { Edition } from "../../types/edition"; @@ -9,7 +10,12 @@ interface DownloadOverlayProps { editions: Edition[]; } -export const DownloadOverlay = memo(function DownloadOverlay({ downloadProgress, downloadingIds, editions }: DownloadOverlayProps) { +export const DownloadOverlay = memo(function DownloadOverlay({ + downloadProgress, + downloadingIds, + editions, +}: DownloadOverlayProps) { + const { t } = useTranslation(); if (downloadingIds.length === 0) return null; return ( @@ -18,76 +24,92 @@ export const DownloadOverlay = memo(function DownloadOverlay({ downloadProgress, animate={{ opacity: 1, scale: 1, y: 0 }} exit={{ opacity: 0, scale: 0.95, y: -10 }} transition={{ duration: 0.15 }} - className="absolute top-14 right-8 z-100 w-72 bg-[#1a1a1a] border-2 border-[#555] shadow-2xl" + className="absolute top-14 right-8 z-100 w-100 mc-options-bg" style={{ imageRendering: "pixelated" }} > -
- - Downloads - +
+ {t("download.title")}
-
+
{downloadingIds.map((id) => { const pct = downloadProgress[id] ?? 0; - const edition = editions.find((e) => e.instanceId === id || e.id === id); - const name = edition?.name || "Game Files"; + const edition = editions.find( + (e) => e.instanceId === id || e.id === id, + ); + const name = + edition?.name || + (id.startsWith("runner_") + ? id.replace(/^runner_/, "") + : t("download.gameFiles")); return ( -
- {edition?.logo ? ( - edition.logo.startsWith("http") || edition.logo.startsWith("/images") ? ( - - ) : ( - - ) - ) : edition?.titleImage ? ( - edition.titleImage.startsWith("http") || edition.titleImage.startsWith("/images") ? ( - - ) : ( - - ) - ) : ( -
- - - - - -
- )} +
- - {name} - +
+
+ {edition?.logo ? ( + edition.logo.startsWith("http") || + edition.logo.startsWith("/images") ? ( + + ) : ( + + ) + ) : edition?.titleImage ? ( + edition.titleImage.startsWith("http") || + edition.titleImage.startsWith("/images") ? ( + + ) : ( + + ) + ) : ( +
+ + + + + +
+ )} + + {name} + +
+ + {Math.floor(pct)}% + +
-
+
- - {Math.floor(pct)}% -
diff --git a/src/components/modals/ChooseInstanceModal.tsx b/src/components/modals/ChooseInstanceModal.tsx index 6c65134..105130b 100644 --- a/src/components/modals/ChooseInstanceModal.tsx +++ b/src/components/modals/ChooseInstanceModal.tsx @@ -1,4 +1,5 @@ import { useState, useEffect } from "react"; +import { useTranslation } from "react-i18next"; import { motion } from "framer-motion"; import { TauriService } from "../../services/TauriService"; import { lceOnlineService } from "../../services/LceOnlineService"; @@ -30,6 +31,7 @@ export default function ChooseInstanceModal({ installs: string[]; invite: GameInvite | null; }) { + const { t } = useTranslation(); const [selectedInstance, setSelectedInstance] = useState(""); const [status, setStatus] = useState(""); const [error, setError] = useState(""); @@ -60,17 +62,17 @@ export default function ChooseInstanceModal({ playPressSound(); setIsJoining(true); setError(""); - setStatus("Launching game..."); + setStatus(t("modals.chooseInstance.launching")); try { const sessionId = invite.sessionId || ""; if (sessionId) { - setStatus("Connecting via relay..."); + setStatus(t("modals.chooseInstance.connectingRelay")); const accessToken = lceOnlineService.accessToken ?? ""; const relayPromise = TauriService.startRelayProxy( accessToken, sessionId, ); - setStatus("Launching game..."); + setStatus(t("modals.chooseInstance.launching")); await TauriService.launchGame( selectedInstance, [ @@ -117,6 +119,7 @@ export default function ChooseInstanceModal({ const max = 1 + (validInstances.length > 0 ? 1 : 0) + 1; setFocusIndex((prev) => (prev - 1 + max) % max); } else if (e.key === "Enter") { + e.preventDefault(); if (focusIndex === 0 && validInstances.length > 0) { const currentIdx = validInstances.findIndex( (i: Edition) => i.instanceId === selectedInstance, @@ -163,10 +166,12 @@ export default function ChooseInstanceModal({ {!isJoining ? ( <>

- Join Game + {t("modals.chooseInstance.title")}

- Joining {hostName}'s game. Choose an instance: + {t("modals.chooseInstance.joinPrompt", { + name: hostName, + })}

{validInstances.length > 0 ? ( @@ -213,7 +218,7 @@ export default function ChooseInstanceModal({
) : (

- No installed instances available. Install a version first. + {t("modals.chooseInstance.noInstances")}

)} @@ -250,7 +255,7 @@ export default function ChooseInstanceModal({ imageRendering: "pixelated", }} > - Cancel + {t("modals.chooseInstance.cancel")} {validInstances.length > 0 && ( )}
@@ -274,7 +279,7 @@ export default function ChooseInstanceModal({ ) : ( <>

- Joining Game + {t("modals.chooseInstance.joiningTitle")}

diff --git a/src/components/modals/CustomTUModal.tsx b/src/components/modals/CustomTUModal.tsx index 271c6f0..f5a5c4e 100644 --- a/src/components/modals/CustomTUModal.tsx +++ b/src/components/modals/CustomTUModal.tsx @@ -1,4 +1,5 @@ import { useState, useEffect, memo } from "react"; +import { useTranslation } from "react-i18next"; interface ModalButtonProps { label: string; @@ -50,6 +51,7 @@ export default function CustomTUModal({ editingEdition?: { name: string; desc: string; url: string; path?: string } | null; initialPath?: string; }) { + const { t } = useTranslation(); const [name, setName] = useState(""); const [desc, setDesc] = useState(""); const [url, setUrl] = useState(""); @@ -84,6 +86,7 @@ export default function CustomTUModal({ playBackSound("close_click.wav"); onClose(); } else if (e.key === "Enter") { + e.preventDefault(); if (focusIndex === 3) { playBackSound("close_click.wav"); onClose(); @@ -107,19 +110,19 @@ export default function CustomTUModal({ const handleImport = () => { if (!name) { - setError("Name is required"); + setError(t("modals.customTu.nameRequired")); return; } if (!url && !path) { - setError("URL or Path is required"); + setError(t("modals.customTu.urlOrPathRequired")); return; } if (url && !url.startsWith("http")) { - setError("Invalid URL"); + setError(t("modals.customTu.invalidUrl")); return; } setError(""); - onImport({ name, desc: desc || "Custom imported TU", url, path: path || undefined }); + onImport({ name, desc: desc || t("modals.customTu.defaultDesc"), url, path: path || undefined }); onClose(); setName(""); setDesc(""); @@ -133,68 +136,76 @@ export default function CustomTUModal({ className="relative w-[400px] p-6 flex flex-col items-center mc-options-bg" >

- {editingEdition ? "Edit Custom TU" : "Import Custom TU"} + {editingEdition ? t("modals.customTu.editTitle") : t("modals.customTu.importTitle")}

+
setName(e.target.value)} onFocus={() => setFocusIndex(0)} - placeholder="e.g. My Awesome Mod" - className="w-full h-10 px-3 bg-black/40 border-2 border-[#373737] text-white text-base outline-none font-['Mojangles']" + placeholder={t("modals.customTu.namePlaceholder")} + className="mc-textinput w-full h-10 px-3 text-white text-base outline-none font-[var(--font-base)]" style={{ imageRendering: "pixelated" }} /> +
+
setDesc(e.target.value)} onFocus={() => setFocusIndex(1)} - placeholder="A brief description..." - className="w-full h-10 px-3 bg-black/40 border-2 border-[#373737] text-white text-base outline-none font-['Mojangles']" + placeholder={t("modals.customTu.descPlaceholder")} + className="mc-textinput w-full h-10 px-3 text-white text-base outline-none font-[var(--font-base)]" style={{ imageRendering: "pixelated" }} /> +
+
setUrl(e.target.value)} onFocus={() => setFocusIndex(2)} - placeholder="optional if path is set" - className="w-full h-10 px-3 bg-black/40 border-2 border-[#373737] text-white text-base outline-none font-['Mojangles']" + placeholder={t("modals.customTu.urlPlaceholder")} + className="mc-textinput w-full h-10 px-3 text-white text-base outline-none font-[var(--font-base)]" style={{ imageRendering: "pixelated" }} /> +
{path && (
+
+
)} @@ -207,14 +218,14 @@ export default function CustomTUModal({
{ playBackSound("close_click.wav"); onClose(); }} /> { playPressSound("save_click.wav"); handleImport(); diff --git a/src/components/modals/CustomizeModal.tsx b/src/components/modals/CustomizeModal.tsx index 5febcb1..228602e 100644 --- a/src/components/modals/CustomizeModal.tsx +++ b/src/components/modals/CustomizeModal.tsx @@ -1,4 +1,5 @@ import { useState, useEffect } from "react"; +import { useTranslation } from "react-i18next"; import { motion } from "framer-motion"; import { TauriService } from "../../services/TauriService"; @@ -37,6 +38,7 @@ export default function CustomizeModal({ currentPanorama?: string; onSave: (updates: { titleImage?: string; panorama?: string }) => void; }) { + const { t } = useTranslation(); const [titleImage, setTitleImage] = useState(currentTitleImage || ""); const [panorama, setPanorama] = useState(currentPanorama || ""); const [focusIndex, setFocusIndex] = useState(0); @@ -56,6 +58,7 @@ export default function CustomizeModal({ playBackSound("close_click.wav"); onClose(); } else if (e.key === "Enter") { + e.preventDefault(); if (focusIndex === 4) { playBackSound("close_click.wav"); onClose(); @@ -90,7 +93,7 @@ export default function CustomizeModal({ const handlePickFile = async (field: "titleImage" | "panorama") => { try { const path = await TauriService.pickFile( - field === "titleImage" ? "Select Title Image" : "Select Panorama Background", + field === "titleImage" ? t("modals.customize.selectTitleImage") : t("modals.customize.selectPanorama"), ["png", "jpg", "jpeg", "bmp"], ); if (path) { @@ -119,7 +122,7 @@ export default function CustomizeModal({ ) => (
{value && ( )}
@@ -159,7 +162,7 @@ export default function CustomizeModal({
Preview { @@ -190,7 +193,7 @@ export default function CustomizeModal({ }} >

- Customize + {t("modals.customize.title")}

{editionName} @@ -220,7 +223,7 @@ export default function CustomizeModal({ imageRendering: "pixelated", }} > - Cancel + {t("modals.customize.cancel")}

diff --git a/src/components/modals/DownloadDlcModal.tsx b/src/components/modals/DownloadDlcModal.tsx index 4c43847..60f61d9 100644 --- a/src/components/modals/DownloadDlcModal.tsx +++ b/src/components/modals/DownloadDlcModal.tsx @@ -1,4 +1,5 @@ import { useState, useEffect, useRef, useCallback } from "react"; +import { useTranslation } from "react-i18next"; import { motion } from "framer-motion"; import { TauriService } from "../../services/TauriService"; interface DlcEntry { @@ -23,6 +24,7 @@ export default function DownloadDlcModal({ instanceId: string; officialDLC: string; }) { + const { t } = useTranslation(); const [dlcList, setDlcList] = useState([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); @@ -185,7 +187,11 @@ export default function DownloadDlcModal({ succeeded.push(selected[i].name); } else { setError( - `Failed to download ${selected[i].name}: ${r.reason instanceof Error ? r.reason.message : String(r.reason)}`, + t("modals.downloadDlc.failedToDownload", { + name: selected[i].name, + reason: + r.reason instanceof Error ? r.reason.message : String(r.reason), + }), ); } } @@ -211,7 +217,7 @@ export default function DownloadDlcModal({ }} >

- Download DLC + {t("modals.downloadDlc.title")}

{editionName} @@ -219,7 +225,7 @@ export default function DownloadDlcModal({ {loading && (

- Loading available DLCs... + {t("modals.downloadDlc.loading")}
)} @@ -231,7 +237,7 @@ export default function DownloadDlcModal({ {!loading && !error && dlcList.length === 0 && !downloading && (
- No DLC folders found in the repository. + {t("modals.downloadDlc.noneFound")}
)} @@ -239,7 +245,7 @@ export default function DownloadDlcModal({ <>
- {dlcList.length} available + {t("modals.downloadDlc.available", { count: dlcList.length })}
@@ -327,7 +333,10 @@ export default function DownloadDlcModal({
{downloading && (
- Downloaded {downloaded.length}/{selectedCount} + {t("modals.downloadDlc.downloaded", { + downloaded: downloaded.length, + selected: selectedCount, + })}
)}
@@ -343,7 +352,7 @@ export default function DownloadDlcModal({ imageRendering: "pixelated", }} > - Download ({selectedCount}) + {t("modals.downloadDlc.download", { count: selectedCount })} )} {downloading && downloaded.length === selectedCount && ( @@ -359,7 +368,7 @@ export default function DownloadDlcModal({ imageRendering: "pixelated", }} > - Done + {t("modals.downloadDlc.done")} )}
diff --git a/src/components/modals/GameLogModal.tsx b/src/components/modals/GameLogModal.tsx index 04b3f10..e8fb917 100644 --- a/src/components/modals/GameLogModal.tsx +++ b/src/components/modals/GameLogModal.tsx @@ -1,4 +1,5 @@ import { useEffect, useMemo, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; import { motion } from "framer-motion"; import { TauriService } from "../../services/TauriService"; const MAX_DISPLAY_LINES = 5000; @@ -62,6 +63,7 @@ export default function GameLogModal({ onClose: () => void; playBackSound: () => void; }) { + const { t } = useTranslation(); const scrollRef = useRef(null); const [feedback, setFeedback] = useState(""); const lines = useMemo(() => (log ?? "").split("\n"), [log]); @@ -92,16 +94,16 @@ export default function GameLogModal({ if (!log) return; try { const path = await TauriService.saveFileDialog( - "Save Game Log", + t("modals.gameLog.saveDialog"), "game-log.txt", ["txt", "log"], ); if (!path) return; await TauriService.writeBinaryFile(path, new TextEncoder().encode(log)); - setFeedback("Saved!"); + setFeedback(t("modals.gameLog.saved")); } catch (e) { console.error(e); - setFeedback("Failed to save."); + setFeedback(t("modals.gameLog.failedToSave")); } }; @@ -109,10 +111,10 @@ export default function GameLogModal({ if (!log) return; try { await navigator.clipboard.writeText(log); - setFeedback("Copied to clipboard!"); + setFeedback(t("modals.gameLog.copied")); } catch (e) { console.error(e); - setFeedback("Failed to copy."); + setFeedback(t("modals.gameLog.failedToCopy")); } }; @@ -133,15 +135,18 @@ export default function GameLogModal({ exit={{ y: 20, opacity: 0 }} transition={{ duration: 0.15 }} onClick={(e) => e.stopPropagation()} - className="flex flex-col w-[640px] max-w-[92vw] max-h-[85vh] p-5 font-['Mojangles'] mc-options-bg" + className="flex flex-col w-[640px] max-w-[92vw] max-h-[85vh] p-5 font-[var(--font-base)] mc-options-bg" >

- Game Crash Log + {t("modals.gameLog.title")}

{truncated && (

- Showing last {MAX_DISPLAY_LINES} lines of {lines.length}. + {t("modals.gameLog.showingLast", { + shown: MAX_DISPLAY_LINES, + total: lines.length, + })}

)} @@ -161,10 +166,10 @@ export default function GameLogModal({
- Save As File + {t("modals.gameLog.saveAsFile")} - Copy + {t("modals.gameLog.copy")} { @@ -173,7 +178,7 @@ export default function GameLogModal({ }} className="flex-1" > - Close + {t("modals.gameLog.close")}
{feedback && ( diff --git a/src/components/modals/ImportWorldModal.tsx b/src/components/modals/ImportWorldModal.tsx index b6d2435..4b1007f 100644 --- a/src/components/modals/ImportWorldModal.tsx +++ b/src/components/modals/ImportWorldModal.tsx @@ -1,4 +1,5 @@ import { useState, useEffect } from "react"; +import { useTranslation } from "react-i18next"; import { motion } from "framer-motion"; import { TauriService } from "../../services/TauriService"; export default function ImportWorldModal({ @@ -16,6 +17,7 @@ export default function ImportWorldModal({ targetInstanceId: string; targetInstanceName: string; }) { + const { t } = useTranslation(); const [status, setStatus] = useState(""); const [error, setError] = useState(""); const [isImporting, setIsImporting] = useState(false); @@ -32,9 +34,9 @@ export default function ImportWorldModal({ playPressSound(); setIsImporting(true); setError(""); - setStatus("Selecting source..."); + setStatus(t("modals.importWorld.selectingSource")); try { - setStatus("Selecting saveData.ms file..."); + setStatus(t("modals.importWorld.selectingMsFile")); const picked = await TauriService.pickFile("Select saveData.ms", [ "*.ms", "*", @@ -44,13 +46,15 @@ export default function ImportWorldModal({ return; } const worldName = deriveWorldName(picked); - setStatus("Copying LCE save..."); + setStatus(t("modals.importWorld.copyingLceSave")); const instancePath = await TauriService.getInstancePath(targetInstanceId); const saveDir = `${instancePath}/Windows64/GameHDD/${worldName}`; await TauriService.importWorld(picked, `${saveDir}/saveData.ms`); - setStatus(`World imported into "${targetInstanceName}"!`); + setStatus( + t("modals.importWorld.worldImported", { name: targetInstanceName }), + ); setTimeout(() => { onClose(); }, 2000); @@ -66,9 +70,9 @@ export default function ImportWorldModal({ playPressSound(); setIsImporting(true); setError(""); - setStatus("Selecting source..."); + setStatus(t("modals.importWorld.selectingSource")); try { - setStatus("Selecting Xbox 360 save (.bin)..."); + setStatus(t("modals.importWorld.selectingXboxSave")); const picked = await TauriService.pickFile( "Select Xbox 360 Minecraft save", ["*.bin", "*"], @@ -77,11 +81,13 @@ export default function ImportWorldModal({ setIsImporting(false); return; } - setStatus("Converting Xbox 360 save..."); + setStatus(t("modals.importWorld.convertingXbox")); const instancePath = await TauriService.getInstancePath(targetInstanceId); const gameHdd = `${instancePath}/Windows64/GameHDD`; await TauriService.importLceSave(picked, gameHdd); - setStatus(`Xbox 360 save converted into "${targetInstanceName}"!`); + setStatus( + t("modals.importWorld.xboxConverted", { name: targetInstanceName }), + ); setTimeout(() => { onClose(); }, 2000); @@ -97,19 +103,21 @@ export default function ImportWorldModal({ playPressSound(); setIsImporting(true); setError(""); - setStatus("Selecting source..."); + setStatus(t("modals.importWorld.selectingSource")); try { - setStatus("Selecting PS3 save folder..."); + setStatus(t("modals.importWorld.selectingPs3Folder")); const picked = await TauriService.pickFolder(); if (!picked) { setIsImporting(false); return; } - setStatus("Converting PS3 save..."); + setStatus(t("modals.importWorld.convertingPs3")); const instancePath = await TauriService.getInstancePath(targetInstanceId); const gameHdd = `${instancePath}/Windows64/GameHDD`; await TauriService.importLceSave(picked, gameHdd); - setStatus(`PS3 save converted into "${targetInstanceName}"!`); + setStatus( + t("modals.importWorld.ps3Converted", { name: targetInstanceName }), + ); setTimeout(() => { onClose(); }, 2000); @@ -125,20 +133,22 @@ export default function ImportWorldModal({ playPressSound(); setIsImporting(true); setError(""); - setStatus("Selecting source..."); + setStatus(t("modals.importWorld.selectingSource")); try { - setStatus("Selecting Java world folder..."); + setStatus(t("modals.importWorld.selectingJavaFolder")); const picked = await TauriService.pickFolder(); if (!picked) { setIsImporting(false); return; } const worldName = deriveWorldName(picked); - setStatus("Converting Java world to LCE..."); + setStatus(t("modals.importWorld.convertingJava")); const instancePath = await TauriService.getInstancePath(targetInstanceId); const saveDir = `${instancePath}/Windows64/GameHDD/${worldName}`; await TauriService.javaToLce(picked, `${saveDir}/saveData.ms`); - setStatus(`Java world converted into "${targetInstanceName}"!`); + setStatus( + t("modals.importWorld.javaConverted", { name: targetInstanceName }), + ); setTimeout(() => { onClose(); }, 2000); @@ -154,9 +164,9 @@ export default function ImportWorldModal({ playPressSound(); setIsImporting(true); setError(""); - setStatus("Selecting source..."); + setStatus(t("modals.importWorld.selectingSource")); try { - setStatus("Selecting saveData.ms file..."); + setStatus(t("modals.importWorld.selectingMsFile")); const picked = await TauriService.pickFile("Select saveData.ms", [ "*.ms", "*", @@ -165,15 +175,17 @@ export default function ImportWorldModal({ setIsImporting(false); return; } - setStatus("Selecting output folder for Java world..."); + setStatus(t("modals.importWorld.selectingOutputFolder")); const outputFolder = await TauriService.pickFolder(); if (!outputFolder) { setIsImporting(false); return; } - setStatus("Converting LCE save to Java world..."); + setStatus(t("modals.importWorld.convertingLceToJava")); await TauriService.lceToJava(picked, outputFolder); - setStatus(`Java world exported to "${outputFolder}"!`); + setStatus( + t("modals.importWorld.javaExported", { path: outputFolder }), + ); setTimeout(() => { onClose(); }, 2000); diff --git a/src/components/modals/OptionsModal.tsx b/src/components/modals/OptionsModal.tsx index 45adac5..55ddad7 100644 --- a/src/components/modals/OptionsModal.tsx +++ b/src/components/modals/OptionsModal.tsx @@ -1,4 +1,5 @@ import { useState, useEffect, useMemo, useRef, type RefObject } from "react"; +import { useTranslation } from "react-i18next"; import { TauriService } from "../../services/TauriService"; import { parseSchema, @@ -33,6 +34,7 @@ export default function OptionsModal({ args: string[], ) => void; }) { + const { t } = useTranslation(); const [schema, setSchema] = useState(null); const [values, setValues] = useState>({}); const [error, setError] = useState(null); @@ -56,13 +58,13 @@ export default function OptionsModal({ .then((raw) => { if (cancelled) return; if (!raw) { - setError("This instance does not provide a launch options schema."); + setError(t("modals.options.noSchema")); setLoading(false); return; } const parsed = parseSchema(raw); if (!parsed) { - setError("The launch options schema is invalid or unsupported."); + setError(t("modals.options.invalidSchema")); setLoading(false); return; } @@ -253,6 +255,7 @@ export default function OptionsModal({ case "int": case "number": return ( +
{ inputRefs.current[index] = el; @@ -271,14 +274,16 @@ export default function OptionsModal({ })); }} onFocus={() => setFocusIndex(index)} - className={`w-24 h-8 bg-black/40 border-2 border-[#373737] text-white text-sm px-2 outline-none text-center font-['Mojangles'] focus:border-[#FFFF55] [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none ${ + className={`mc-textinput w-full h-10 px-3 text-white text-sm outline-none text-center font-[var(--font-base)] [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none ${ disabled ? "opacity-40 cursor-not-allowed" : "" }`} style={{ imageRendering: "pixelated" }} /> +
); case "string": return ( +
{ inputRefs.current[index] = el; @@ -291,11 +296,12 @@ export default function OptionsModal({ setValues((prev) => ({ ...prev, [option.id]: e.target.value })); }} onFocus={() => setFocusIndex(index)} - className={`w-44 h-8 bg-black/40 border-2 border-[#373737] text-white text-sm px-2 outline-none font-['Mojangles'] focus:border-[#FFFF55] ${ + className={`mc-textinput w-full h-10 px-3 text-white text-sm outline-none font-[var(--font-base)] ${ disabled ? "opacity-40 cursor-not-allowed" : "" }`} style={{ imageRendering: "pixelated" }} /> +
); case "choice": return ( @@ -309,7 +315,7 @@ export default function OptionsModal({ setValues((prev) => ({ ...prev, [option.id]: e.target.value })); }} onFocus={() => setFocusIndex(index)} - className={`w-44 h-8 bg-white border-2 border-[#373737] text-black text-sm px-2 outline-none font-['Mojangles'] focus:border-[#FFFF55] ${ + className={`w-44 h-8 bg-white border-2 border-[#373737] text-black text-sm px-2 outline-none font-[var(--font-base)] focus:border-[#FFFF55] ${ disabled ? "opacity-40 cursor-not-allowed" : "" }`} style={{ imageRendering: "pixelated" }} @@ -431,9 +437,9 @@ export default function OptionsModal({ } }} > -
+

- Options + {t("modals.options.title")}

{instanceName} @@ -443,7 +449,7 @@ export default function OptionsModal({

- Loading options... + {t("modals.options.loading")}

) : error ? ( @@ -452,7 +458,7 @@ export default function OptionsModal({ {error}

- {actionButton(cancelRef, optionOrder.length + 1, "OK", () => { + {actionButton(cancelRef, optionOrder.length + 1, t("modals.options.ok"), () => { playBackSound(); onClose(); })} @@ -477,7 +483,7 @@ export default function OptionsModal({ {sections.general.length > 0 && (

- General + {t("modals.options.general")}

{sections.general.map(renderOption)}
@@ -488,18 +494,18 @@ export default function OptionsModal({ {actionButton( resetRef, optionOrder.length, - "Reset", + t("modals.options.reset"), handleReset, true, )} - {actionButton(cancelRef, optionOrder.length + 1, "Cancel", () => { + {actionButton(cancelRef, optionOrder.length + 1, t("modals.options.cancel"), () => { playBackSound(); onClose(); })} {actionButton( saveRef, optionOrder.length + 2, - "Save", + t("modals.options.save"), handleSave, )}
diff --git a/src/components/modals/PlaytimeModal.tsx b/src/components/modals/PlaytimeModal.tsx index 5dd3e1f..d407283 100644 --- a/src/components/modals/PlaytimeModal.tsx +++ b/src/components/modals/PlaytimeModal.tsx @@ -1,4 +1,5 @@ import { useState, useEffect, useRef } from "react"; +import { useTranslation } from "react-i18next"; import { motion } from "framer-motion"; import { TauriService, @@ -75,6 +76,7 @@ export default function PlaytimeModal({ instanceName: string; playBackSound: (s?: string) => void; }) { + const { t } = useTranslation(); const [playtime, setPlaytime] = useState(null); const [dailyData, setDailyData] = useState([]); const [loading, setLoading] = useState(false); @@ -127,7 +129,7 @@ export default function PlaytimeModal({ }} >

- Playtime + {t("modals.playtime.title")}

@@ -136,14 +138,14 @@ export default function PlaytimeModal({ {loading ? (

- Loading... + {t("modals.playtime.loading")}
) : playtime ? ( <>
- Total + {t("modals.playtime.total")} {formatTime(playtime.totalSeconds)} @@ -151,7 +153,7 @@ export default function PlaytimeModal({
- This Week + {t("modals.playtime.thisWeek")} {formatTime(playtime.weekSeconds)} @@ -159,7 +161,7 @@ export default function PlaytimeModal({
- Today + {t("modals.playtime.today")} {formatTime(playtime.daySeconds)} @@ -171,7 +173,7 @@ export default function PlaytimeModal({
- Daily Breakdown + {t("modals.playtime.dailyBreakdown")}
{[3, 7, 14].map((d) => ( @@ -197,7 +199,7 @@ export default function PlaytimeModal({ ) : (
- Failed to load playtime data. + {t("modals.playtime.failedToLoad")}
)} @@ -213,7 +215,7 @@ export default function PlaytimeModal({ imageRendering: "pixelated", }} > - Close + {t("modals.playtime.close")}
diff --git a/src/components/modals/SetUidModal.tsx b/src/components/modals/SetUidModal.tsx index 269c2fb..adeb3b7 100644 --- a/src/components/modals/SetUidModal.tsx +++ b/src/components/modals/SetUidModal.tsx @@ -1,4 +1,5 @@ import { useState, useEffect } from "react"; +import { useTranslation } from "react-i18next"; import { motion } from "framer-motion"; import { TauriService } from "../../services/TauriService"; import type { Edition } from "../../types/edition"; @@ -20,6 +21,7 @@ export default function SetUidModal({ installedVersions: string[]; targetInstanceId: string; }) { + const { t } = useTranslation(); const [mode, setMode] = useState<"manual" | "copy">("manual"); const [uid, setUid] = useState("0xFF02F0C87E8AC1F2"); const [selectedInstance, setSelectedInstance] = useState(""); @@ -60,7 +62,7 @@ export default function SetUidModal({ let finalUid = uid; if (mode === "copy") { if (!selectedInstance) { - setError("Select an instance to copy from."); + setError(t("modals.setUid.selectToCopy")); return; } const sourcePath = await TauriService.getInstancePath(selectedInstance); @@ -68,13 +70,13 @@ export default function SetUidModal({ const sourceData = await TauriService.readBinaryFile(`${sourcePath}/uid.dat`); finalUid = new TextDecoder().decode(sourceData); } catch (e) { - setError("Source instance has no uid.dat or it could not be read."); + setError(t("modals.setUid.sourceNoUid")); return; } } if (!finalUid) { - setError("UID cannot be empty."); + setError(t("modals.setUid.uidEmpty")); return; } @@ -101,6 +103,7 @@ export default function SetUidModal({ e.preventDefault(); setFocusIndex((prev) => (prev - 1 + 5) % 5); } else if (e.key === "Enter") { + e.preventDefault(); if (focusIndex === 0) { playPressSound(); setMode("manual"); @@ -140,7 +143,7 @@ export default function SetUidModal({ }} >

- Set UID + {t("modals.setUid.title")}

@@ -156,7 +159,7 @@ export default function SetUidModal({ imageRendering: "pixelated", }} > - Manual + {t("modals.setUid.manual")}
@@ -178,8 +181,9 @@ export default function SetUidModal({ {mode === "manual" ? (
+
setUid(e.target.value)} onFocus={() => setFocusIndex(2)} placeholder="0xFF02F0C87E8AC1F2" - className={`w-full h-10 px-3 bg-black/40 border-2 ${focusIndex === 2 ? 'border-white' : 'border-[#373737]'} text-white text-base outline-none font-['Mojangles'] text-center`} + className="mc-textinput w-full h-10 px-3 text-white text-base outline-none font-[var(--font-base)] text-center" style={{ imageRendering: "pixelated", filter: focusIndex === 2 ? 'brightness(1.2)' : 'none' }} /> +
) : (
{ playPressSound(); setIsDropdownOpen(!isDropdownOpen); }} onFocus={() => { setFocusIndex(2); setIsDropdownOpen(false); }} tabIndex={0} - className={`w-full h-10 px-3 bg-black/40 border-2 ${focusIndex === 2 ? 'border-white' : 'border-[#373737]'} flex items-center justify-between text-white text-base outline-none font-['Mojangles'] cursor-pointer`} + className={`w-full h-10 px-3 bg-black/40 border-2 ${focusIndex === 2 ? 'border-white' : 'border-[#373737]'} flex items-center justify-between text-white text-base outline-none font-[var(--font-base)] cursor-pointer`} style={{ imageRendering: "pixelated", filter: focusIndex === 2 ? 'brightness(1.2)' : 'none' }} > {selectedInstance ? (() => { const i = validInstances.find((inst: Edition) => inst.instanceId === selectedInstance); - return i ? `${i.name} ${i.selectedBranch ? `(${i.selectedBranch})` : ""}` : "-- Select an Instance --"; + return i ? `${i.name} ${i.selectedBranch ? `(${i.selectedBranch})` : ""}` : t("modals.setUid.selectPlaceholder"); })() - : "-- Select an Instance --"} + : t("modals.setUid.selectPlaceholder")} ▼
@@ -225,7 +230,7 @@ export default function SetUidModal({ setSelectedInstance(i.instanceId); setIsDropdownOpen(false); }} - className="px-3 py-2 text-white text-sm cursor-pointer truncate font-['Mojangles']" + className="px-3 py-2 text-white text-sm cursor-pointer truncate font-[var(--font-base)]" > {i.name} {i.selectedBranch ? `(${i.selectedBranch})` : ""}
@@ -234,7 +239,7 @@ export default function SetUidModal({ )} {validInstances.length === 0 && ( -

No other installed instances available.

+

{t("modals.setUid.noInstances")}

)}
)} @@ -262,7 +267,7 @@ export default function SetUidModal({ imageRendering: "pixelated", }} > - Cancel + {t("modals.setUid.cancel")}
diff --git a/src/components/views/ArcEditorView.tsx b/src/components/views/ArcEditorView.tsx index 2b1f7de..1b6feff 100644 --- a/src/components/views/ArcEditorView.tsx +++ b/src/components/views/ArcEditorView.tsx @@ -1,4 +1,5 @@ import { useState, useRef, useMemo } from "react"; +import { useTranslation } from "react-i18next"; import { motion, AnimatePresence } from "framer-motion"; import { useUI, useAudio, useConfig } from "../../context/LauncherContext"; import { ArcService } from "../../services/ArcService"; @@ -6,6 +7,7 @@ import { ArcFile, ArcEntry, LocFile, LocLanguage } from "../../types/arc"; import { TauriService } from "../../services/TauriService"; export const ArcEditorView: React.FC = () => { + const { t } = useTranslation(); const { setActiveView } = useUI(); const { playPressSound, playBackSound } = useAudio(); const { animationsEnabled } = useConfig(); @@ -48,7 +50,7 @@ export const ArcEditorView: React.FC = () => { const handleFileLoad = async () => { try { - const path = await TauriService.pickFile("Open ARC", ["arc"]); + const path = await TauriService.pickFile(t("arcEditor.openArc"), ["arc"]); if (!path) return; playPressSound(); const bytes = await TauriService.readBinaryFile(path); @@ -56,7 +58,7 @@ export const ArcEditorView: React.FC = () => { parsed.name = path.split(/[\/\\]/).pop() || "archive.arc"; setArc(parsed); setOpenedPath(path); - + const locEntry = parsed.entries.find(entry => entry.filename.toLowerCase() === "languages.loc"); if (locEntry) { try { @@ -70,11 +72,11 @@ export const ArcEditorView: React.FC = () => { setLoc(null); } setSelectedEntryIdx(null); - showNotification(`Loaded ${parsed.name}`); + showNotification(t("arcEditor.loaded", { name: parsed.name })); } catch (err: unknown) { if (err !== "CANCELED") { console.error("Failed to parse ARC", err); - showNotification("Failed to parse ARC", "error"); + showNotification(t("arcEditor.failedToParse"), "error"); } } }; @@ -88,29 +90,29 @@ export const ArcEditorView: React.FC = () => { try { let targetPath = openedPath; if (!targetPath) { - targetPath = await TauriService.saveFileDialog("Save ARC", arc.name || "archive.arc", ["arc"]); + targetPath = await TauriService.saveFileDialog(t("arcEditor.saveArcDialog"), arc.name || "archive.arc", ["arc"]); } - + if (targetPath) { await TauriService.writeBinaryFile(targetPath, data); setOpenedPath(targetPath); - showNotification("ARC Saved Successfully"); + showNotification(t("arcEditor.arcSaved")); } } catch (err: unknown) { - if (err !== "CANCELED") showNotification("Save failed", "error"); + if (err !== "CANCELED") showNotification(t("arcEditor.saveFailed"), "error"); } }; const handleExtractEntry = async (entry: ArcEntry) => { try { const fileName = entry.filename.split("/").pop() || "asset"; - const path = await TauriService.saveFileDialog("Export Asset", fileName, []); + const path = await TauriService.saveFileDialog(t("arcEditor.exportAssetDialog"), fileName, []); if (!path) return; playPressSound(); await TauriService.writeBinaryFile(path, entry.data); - showNotification(`Extracted: ${entry.filename}`); + showNotification(t("arcEditor.extracted", { name: entry.filename })); } catch (err: unknown) { - if (err !== "CANCELED") showNotification("Extraction failed", "error"); + if (err !== "CANCELED") showNotification(t("arcEditor.extractionFailed"), "error"); } }; @@ -122,7 +124,7 @@ export const ArcEditorView: React.FC = () => { newEntries.splice(idx, 1); setArc({ ...arc, entries: newEntries }); setSelectedEntryIdx(null); - showNotification(`Deleted: ${name}`); + showNotification(t("arcEditor.deleted", { name })); }; const handleSaveLocToArc = () => { @@ -133,10 +135,10 @@ export const ArcEditorView: React.FC = () => { const newEntries = [...arc.entries]; if (locIdx >= 0) { newEntries[locIdx] = { ...newEntries[locIdx], data, size: data.length }; - showNotification("languages.loc updated in archive"); + showNotification(t("arcEditor.locUpdatedInArchive")); } else { newEntries.push({ filename: "languages.loc", ptr: 0, size: data.length, isCompressed: false, data }); - showNotification("languages.loc added to archive"); + showNotification(t("arcEditor.locAddedToArchive")); } setArc({ ...arc, entries: newEntries }); }; @@ -157,7 +159,7 @@ export const ArcEditorView: React.FC = () => { }; setArc({ ...arc, entries: [...arc.entries, newEntry] }); e.target.value = ""; - showNotification("Entry Added"); + showNotification(t("arcEditor.entryAdded")); setIsAddModalOpen(false); }; @@ -172,7 +174,7 @@ export const ArcEditorView: React.FC = () => { newEntries[selectedEntryIdx] = { ...newEntries[selectedEntryIdx], data, size: data.length }; setArc({ ...arc, entries: newEntries }); e.target.value = ""; - showNotification("Entry Replaced"); + showNotification(t("arcEditor.entryReplaced")); setIsReplaceModalOpen(false); }; @@ -183,7 +185,7 @@ export const ArcEditorView: React.FC = () => { newEntries[selectedEntryIdx] = { ...newEntries[selectedEntryIdx], filename: newPath, isCompressed }; setArc({ ...arc, entries: newEntries }); setIsRenameModalOpen(false); - showNotification("Entry Renamed"); + showNotification(t("arcEditor.entryRenamed")); }; const handleLocStringEdit = (langIdx: number, strIdx: number, isNew: boolean, key: string, value: string) => { @@ -199,7 +201,7 @@ export const ArcEditorView: React.FC = () => { } setLoc(newLoc); setIsLocEditModalOpen(null); - showNotification(isNew ? "String Added" : "String Updated"); + showNotification(isNew ? t("arcEditor.stringAdded") : t("arcEditor.stringUpdated")); }; const handleLocStringDelete = (langIdx: number, strIdx: number) => { @@ -208,7 +210,7 @@ export const ArcEditorView: React.FC = () => { const newLoc = { ...loc }; newLoc.languages[langIdx].strings.splice(strIdx, 1); setLoc(newLoc); - showNotification("String Deleted"); + showNotification(t("arcEditor.stringDeleted")); }; const treeData = useMemo(() => { @@ -254,7 +256,7 @@ export const ArcEditorView: React.FC = () => { {node.filename.split("/").pop()} {node.isCompressed && ( - ZLIB + {t("arcEditor.zlib")} )}
); @@ -299,15 +301,15 @@ export const ArcEditorView: React.FC = () => { const baseFolder = await TauriService.pickFolder(); if (!baseFolder) return; playPressSound(); - showNotification("Exporting all archive entries..."); - + showNotification(t("arcEditor.exportingAll")); + for (const entry of arc.entries) { const fileName = entry.filename.replace(/\//g, "_"); await TauriService.writeBinaryFile(`${baseFolder}/${fileName}`, entry.data); } - showNotification("All Entries Exported"); + showNotification(t("arcEditor.allEntriesExported")); } catch (err: unknown) { - if (err !== "CANCELED") showNotification("Export failed", "error"); + if (err !== "CANCELED") showNotification(t("arcEditor.exportFailed"), "error"); } }; @@ -323,31 +325,31 @@ export const ArcEditorView: React.FC = () => { >

- ARC Editor + {t("arcEditor.title")}

@@ -359,7 +361,7 @@ export const ArcEditorView: React.FC = () => {
-

Open an ARC file to begin editing

+

{t("arcEditor.openArcFile")}

) : (
@@ -369,14 +371,14 @@ export const ArcEditorView: React.FC = () => { className={`flex items-center gap-3 px-6 py-2 mc-text-shadow ${activeTab === "arc" ? "text-[#FFFF55] opacity-100" : "text-white opacity-40"}`} > - Archive + {t("arcEditor.archive")}
@@ -385,19 +387,21 @@ export const ArcEditorView: React.FC = () => {
+
setSearchTerm(e.target.value)} - className="flex-1 bg-black/40 border-2 border-[#373737] text-white px-4 py-2 outline-none focus:border-[#FFFF55] transition-colors" + className="mc-textinput w-full h-10 px-3 text-white text-base outline-none font-[var(--font-base)]" /> +
@@ -408,17 +412,17 @@ export const ArcEditorView: React.FC = () => { {selectedEntry ? (
-

Entry Details

+

{t("arcEditor.entryDetails")}

{selectedEntry.filename}

- Size + {t("arcEditor.size")} {(selectedEntry.size / 1024).toFixed(1)} KB
- Format - {selectedEntry.isCompressed ? "Compressed" : "Raw"} + {t("arcEditor.format")} + {selectedEntry.isCompressed ? t("arcEditor.compressed") : t("arcEditor.raw")}
@@ -426,23 +430,23 @@ export const ArcEditorView: React.FC = () => {
@@ -471,7 +475,7 @@ export const ArcEditorView: React.FC = () => { ) : (
-

Select an entry to view details

+

{t("arcEditor.selectEntry")}

)}
@@ -480,16 +484,16 @@ export const ArcEditorView: React.FC = () => {
{!loc ? (
-

No languages.loc found in archive

+

{t("arcEditor.noLocFound")}

) : ( @@ -501,38 +505,40 @@ export const ArcEditorView: React.FC = () => { className="bg-black/40 border-2 border-[#373737] text-white px-4 py-2 outline-none focus:border-[#FFFF55] transition-colors" > {loc.languages.map((lang, idx) => ( - + ))} +
setSearchTerm(e.target.value)} - className="flex-1 bg-black/40 border-2 border-[#373737] text-white px-4 py-2 outline-none focus:border-[#FFFF55] transition-colors" + className="mc-textinput w-full h-10 px-3 text-white text-base outline-none font-[var(--font-base)]" /> +
- - - + + + @@ -548,7 +554,7 @@ export const ArcEditorView: React.FC = () => { onClick={() => setIsLocEditModalOpen({ langIdx: selectedLocLangIdx, strIdx: str.originalIdx, isNew: false })} className="px-2 py-1 text-[10px] bg-white/10 hover:bg-[#FFFF55]/20 hover:text-[#FFFF55] border border-white/20 uppercase" > - Edit + {t("arcEditor.edit")} @@ -594,17 +600,17 @@ export const ArcEditorView: React.FC = () => { {isAddModalOpen && (
-

Add File to Archive

+

{t("arcEditor.addFileToArchive")}

- +
@@ -614,7 +620,7 @@ export const ArcEditorView: React.FC = () => { {isReplaceModalOpen && selectedEntryIdx !== null && (
-

Replace File Data

+

{t("arcEditor.replaceFileData")}

{arc?.entries[selectedEntryIdx].filename}

- +
@@ -653,34 +659,37 @@ export const ArcEditorView: React.FC = () => { } function RenameModal({ initialName, initialCompressed, onClose, onConfirm }: { initialName: string, initialCompressed: boolean, onClose: () => void, onConfirm: (name: string, comp: boolean) => void }) { + const { t } = useTranslation(); const [name, setName] = useState(initialName); const [comp, setComp] = useState(initialCompressed); return (
-

Rename Entry

+

{t("arcEditor.renameEntry")}

- + +
setName(e.target.value)} - className="w-full bg-black/40 border-2 border-[#373737] text-white px-4 py-3 outline-none focus:border-[#FFFF55] transition-colors" + className="mc-textinput w-full h-10 px-3 text-white text-base outline-none font-[var(--font-base)]" /> +
- +
@@ -690,28 +699,31 @@ function RenameModal({ initialName, initialCompressed, onClose, onConfirm }: { i } function LocEditModal({ data, lang, onClose, onConfirm }: { data: { langIdx: number, strIdx: number, isNew: boolean }, lang: LocLanguage, onClose: () => void, onConfirm: (langIdx: number, strIdx: number, isNew: boolean, key: string, val: string) => void }) { + const { t } = useTranslation(); const [key, setKey] = useState(!data.isNew ? (lang.strings[data.strIdx].key || "") : ""); const [val, setVal] = useState(!data.isNew ? lang.strings[data.strIdx].value : ""); return (
-

{data.isNew ? "Add" : "Edit"} String

+

{data.isNew ? t("arcEditor.add") : t("arcEditor.edit")} {t("arcEditor.stringTitle")}

{!lang.isStatic ? (
- + +
setKey(e.target.value)} - className="w-full bg-black/40 border-2 border-[#373737] text-white px-4 py-3 outline-none focus:border-[#FFFF55] transition-colors font-mono" + className="mc-textinput w-full h-10 px-3 text-white text-base outline-none font-[var(--font-base)] font-mono" />
+
) : ( -
Static entry - Index: {data.isNew ? lang.strings.length : data.strIdx}
+
{t("arcEditor.staticEntry", { index: data.isNew ? lang.strings.length : data.strIdx })}
)}
- +
Key / IndexValueActions{t("arcEditor.keyOrIndex")}{t("arcEditor.value")}{t("arcEditor.actions")}