diff --git a/.github/workflows/experimental.yml b/.github/workflows/experimental.yml index 770daed..ae68047 100644 --- a/.github/workflows/experimental.yml +++ b/.github/workflows/experimental.yml @@ -167,3 +167,80 @@ jobs: with: name: appimage path: pkg/AppImage/dist/*.AppImage + + build-android: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + submodules: true + - 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: setup Java 17 + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: "17" + + - name: setup Android SDK + uses: android-actions/setup-android@v4 + - name: install Android SDK components + run: | + sdkmanager --install "platforms;android-36" "build-tools;36.0.0" "ndk;28.2.13676358" + yes | sdkmanager --licenses + sudo apt-get install -y glslang-tools + + - name: install Rust stable with android target + uses: dtolnay/rust-toolchain@stable + with: + targets: aarch64-linux-android + + - name: Rust cache + uses: swatinem/rust-cache@v2 + with: + workspaces: "./src-tauri -> target" + + - name: install frontend dependencies + run: pnpm install + + - name: download android bundled assets + run: pnpm android:assets + + - name: restore gradle wrapper jar + run: | + VER=$(sed -n 's/.*gradle-\([0-9][0-9.]*\)-bin\.zip.*/\1/p' src-tauri/gen/android/gradle/wrapper/gradle-wrapper.properties) + curl -fL -o src-tauri/gen/android/gradle/wrapper/gradle-wrapper.jar \ + "https://raw.githubusercontent.com/gradle/gradle/v${VER}/gradle/wrapper/gradle-wrapper.jar" + + - name: set up android signing keystore + env: + ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }} + ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }} + ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }} + run: | + printf '%s' "$ANDROID_KEYSTORE_BASE64" | base64 -d > src-tauri/gen/android/app/release.keystore + cat > src-tauri/gen/android/app/key.properties < target" + + - name: install frontend dependencies + run: pnpm install + + - name: download android bundled assets + run: pnpm android:assets + + - name: restore gradle wrapper jar + run: | + VER=$(sed -n 's/.*gradle-\([0-9][0-9.]*\)-bin\.zip.*/\1/p' src-tauri/gen/android/gradle/wrapper/gradle-wrapper.properties) + curl -fL -o src-tauri/gen/android/gradle/wrapper/gradle-wrapper.jar \ + "https://raw.githubusercontent.com/gradle/gradle/v${VER}/gradle/wrapper/gradle-wrapper.jar" + + - name: set up android signing keystore + env: + ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }} + ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }} + ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }} + run: | + printf '%s' "$ANDROID_KEYSTORE_BASE64" | base64 -d > src-tauri/gen/android/app/release.keystore + cat > src-tauri/gen/android/app/key.properties <LCE Emerald Launcher

FOSS cross-platform launcher for Minecraft Legacy Console Edition

- Version + Version License Platforms

@@ -163,7 +163,7 @@ This repository includes a local Portage overlay under [`gentoo/`](gentoo/). Poi location = /path/to/LCE-Emerald-Launcher/gentoo auto-sync = no -sudo emerge games-util/emerald-legacy-launcher # 1.5.1 +sudo emerge games-util/emerald-legacy-launcher # 1.6.0 # sudo emerge =games-util/emerald-legacy-launcher-9999 # live git ``` diff --git a/RELEASE_ARTWORK.png b/RELEASE_ARTWORK.png index a4b44d8..44dd3ed 100644 Binary files a/RELEASE_ARTWORK.png and b/RELEASE_ARTWORK.png differ diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index b3f0347..a49b776 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,11 +1,25 @@ ![Release Artwork](https://raw.githubusercontent.com/LCE-Hub/LCE-Emerald-Launcher/refs/heads/main/RELEASE_ARTWORK.png) -- Fix custom path downloading overwriting non-empty directories ([#127](https://github.com/LCE-Hub/LCE-Emerald-Launcher/issues/127)) -- Add support for version channels in Workshop -- Fix LCEOnline Auth on Linux and macOS -- Add `unzip` fallback for Workshop items on Linux -- Fix critical bug in Workshop that deleted worlds and DLCs (thanks LeGamer!) -- Fix random disconnects on Windows -- Show multiplayer status on Discord RPC -- Fix skipping intro, fullscreen and legacy mode not importing in Settings (thanks Ryuzaki!) -- Remove controls menu from Settings +- Fix title bar and panorama not showing on setup +- **Partnership with LapboardMC!** +- **Removed LCEOnline Client, LCEOnline functionality is built into Emerald** +- Fix audio skipping bug on Windows +- Gentoo, Nix flakes and Homebrew support +- **Xbox 360, PS3 and Java -> LCE world importing support (beta!)** +- Proper Discord RPC shutdown +- Remove click effects when disabling animations +- **FULL Android Port powered by our own DiamondRuntime for running the game!** +- Fix neoLegacy downloading issue +- **A lot of UI polish (thanks piebot!)** +- Add 2 default capes +- Add more default skins +- **Emerald Arguments.Schema support!** +- **Partnership with Amythest LCE** +- Fix Slim skins rendering in-launcher +- Randomize splash texts +- **Partnership with Project Lost Legacy (soon!)** +- Add Player Head to Discord RPC +- Make skin face inwards instead of outwards +- Game crash log viewer support! +- **Intel macOS support** +- And more that I forgot! diff --git a/index.html b/index.html index fe4ae1e..0405fef 100644 --- a/index.html +++ b/index.html @@ -4,6 +4,14 @@ + Emerald Legacy Launcher diff --git a/package.json b/package.json index 86f812c..e4f62a2 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "tauri": "tauri", "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" }, "dependencies": { diff --git a/public/Capes/4J.png b/public/Capes/4J.png new file mode 100644 index 0000000..a2d4883 Binary files /dev/null and b/public/Capes/4J.png differ diff --git a/public/Capes/Unused_Cape_2.png b/public/Capes/Unused_Cape_2.png new file mode 100644 index 0000000..a256f03 Binary files /dev/null and b/public/Capes/Unused_Cape_2.png differ diff --git a/public/Skins/ava.png b/public/Skins/ava.png new file mode 100644 index 0000000..b3236bb Binary files /dev/null and b/public/Skins/ava.png differ diff --git a/public/Skins/byte.png b/public/Skins/byte.png new file mode 100644 index 0000000..877a77d Binary files /dev/null and b/public/Skins/byte.png differ diff --git a/public/Skins/counterr.png b/public/Skins/counterr.png new file mode 100644 index 0000000..edd7d54 Binary files /dev/null and b/public/Skins/counterr.png differ diff --git a/public/Skins/tranq.png b/public/Skins/tranq.png new file mode 100644 index 0000000..67208fe Binary files /dev/null and b/public/Skins/tranq.png differ diff --git a/public/fonts/Mojang Font_11.ttf b/public/fonts/Mojang Font_11.ttf new file mode 100644 index 0000000..969cee5 Binary files /dev/null and b/public/fonts/Mojang Font_11.ttf differ diff --git a/public/fonts/Mojang Font_7.ttf b/public/fonts/Mojang Font_7.ttf new file mode 100644 index 0000000..727c5fb Binary files /dev/null and b/public/fonts/Mojang Font_7.ttf differ diff --git a/public/images/Slider_Handle_bottom.png b/public/images/Slider_Handle_bottom.png new file mode 100644 index 0000000..fc094bb Binary files /dev/null and b/public/images/Slider_Handle_bottom.png differ diff --git a/public/images/Slider_Handle_mid.png b/public/images/Slider_Handle_mid.png new file mode 100644 index 0000000..e298f47 Binary files /dev/null and b/public/images/Slider_Handle_mid.png differ diff --git a/public/images/Slider_Handle_top.png b/public/images/Slider_Handle_top.png new file mode 100644 index 0000000..277e494 Binary files /dev/null and b/public/images/Slider_Handle_top.png differ diff --git a/public/images/amythest.png b/public/images/amythest.png new file mode 100644 index 0000000..1fcfbeb Binary files /dev/null and b/public/images/amythest.png differ diff --git a/public/images/amythest_title.png b/public/images/amythest_title.png new file mode 100644 index 0000000..d342efb Binary files /dev/null and b/public/images/amythest_title.png differ diff --git a/public/images/cafeberry_title.png b/public/images/cafeberry_title.png new file mode 100644 index 0000000..2d93760 Binary files /dev/null and b/public/images/cafeberry_title.png differ diff --git a/public/images/lostlegacy.png b/public/images/lostlegacy.png new file mode 100644 index 0000000..d4ffbfc Binary files /dev/null and b/public/images/lostlegacy.png differ diff --git a/public/images/lostlegacy_title.png b/public/images/lostlegacy_title.png new file mode 100644 index 0000000..116d426 Binary files /dev/null and b/public/images/lostlegacy_title.png differ diff --git a/public/panorama/vanilla_tu1_Panorama_Background_Day.png b/public/panorama/vanilla_tu1_Panorama_Background_Day.png new file mode 100644 index 0000000..df8dc52 Binary files /dev/null and b/public/panorama/vanilla_tu1_Panorama_Background_Day.png differ diff --git a/public/panorama/vanilla_tu1_Panorama_Background_Night.png b/public/panorama/vanilla_tu1_Panorama_Background_Night.png new file mode 100644 index 0000000..645f80c Binary files /dev/null and b/public/panorama/vanilla_tu1_Panorama_Background_Night.png differ diff --git a/schemas/args.json b/schemas/args.json new file mode 100644 index 0000000..2c452cd --- /dev/null +++ b/schemas/args.json @@ -0,0 +1,260 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://emerald-legacy-launcher/schemas/args.json", + "title": "ArgsSchema", + "description": "Schema describing launcher command-line argument options, groups, and conditional dependencies.", + "type": "object", + "additionalProperties": false, + "required": ["$schema", "groups", "options", "dependencies"], + "properties": { + "$schema": { + "type": "string", + "description": "URI of the JSON schema this file conforms to." + }, + "schemaVersion": { + "type": "integer", + "const": 1, + "description": "Version of the schema format. Currently always 1." + }, + "meta": { + "type": "object", + "additionalProperties": true, + "description": "Arbitrary metadata about this schema." + }, + "groups": { + "type": "array", + "description": "Groups used to organize options.", + "items": { + "$ref": "#/$defs/schemaGroup" + } + }, + "options": { + "type": "array", + "description": "The command-line options.", + "items": { + "$ref": "#/$defs/schemaOption" + } + }, + "dependencies": { + "type": "array", + "description": "Conditional dependencies that disable or hide options.", + "items": { + "$ref": "#/$defs/schemaDependency" + } + } + }, + "$defs": { + "schemaGroup": { + "type": "object", + "additionalProperties": false, + "required": ["id", "title"], + "properties": { + "id": { + "type": "string", + "description": "Unique identifier for the group." + }, + "title": { + "type": "string", + "description": "Display title of the group." + }, + "description": { + "type": "string", + "description": "Optional description of the group." + } + } + }, + "schemaOption": { + "type": "object", + "additionalProperties": false, + "required": ["id", "title", "type", "arg"], + "properties": { + "id": { + "type": "string", + "description": "Unique identifier for the option." + }, + "title": { + "type": "string", + "description": "Display title of the option." + }, + "type": { + "enum": ["boolean", "int", "number", "string", "choice"], + "description": "The kind of value the option accepts." + }, + "arg": { + "type": "string", + "description": "The command-line argument string emitted for this option." + }, + "description": { + "type": "string", + "description": "Optional human-readable description." + }, + "group": { + "type": "string", + "description": "Identifier of the group this option belongs to." + }, + "default": { + "description": "Default value for the option. Must match the option's type." + }, + "min": { + "type": "number", + "description": "Minimum value for int/number options." + }, + "max": { + "type": "number", + "description": "Maximum value for int/number options." + }, + "step": { + "type": "number", + "description": "Stepping increment for int/number options." + }, + "placeholder": { + "type": "string", + "description": "Placeholder text for string options." + }, + "choices": { + "type": "array", + "description": "Allowed choices. Required and must be non-empty when type is 'choice'.", + "minItems": 1, + "items": { + "$ref": "#/$defs/schemaChoice" + } + } + }, + "allOf": [ + { + "if": { + "properties": { + "type": { "const": "choice" } + }, + "required": ["type"] + }, + "then": { + "required": ["choices"] + } + }, + { + "if": { + "properties": { + "type": { "const": "boolean" } + }, + "required": ["type"] + }, + "then": { + "properties": { + "default": { "type": "boolean" } + } + } + }, + { + "if": { + "properties": { + "type": { "const": "string" } + }, + "required": ["type"] + }, + "then": { + "properties": { + "default": { "type": "string" } + } + } + } + ] + }, + "schemaChoice": { + "type": "object", + "additionalProperties": false, + "required": ["value"], + "properties": { + "value": { + "type": "string", + "description": "The value stored/emitted for this choice." + }, + "label": { + "type": "string", + "description": "Optional display label. Defaults to the value." + } + } + }, + "schemaDependency": { + "type": "object", + "additionalProperties": false, + "required": ["target", "when"], + "properties": { + "target": { + "type": "string", + "description": "Identifier of the option affected by this dependency." + }, + "when": { + "$ref": "#/$defs/condition", + "description": "Condition that, when true, applies the effect." + }, + "effect": { + "enum": ["disable", "hide"], + "description": "Whether the target option is disabled or hidden. Defaults to 'disable'." + } + } + }, + "condition": { + "oneOf": [ + { + "type": "object", + "additionalProperties": false, + "required": ["option"], + "properties": { + "option": { + "type": "string", + "description": "Identifier of the option to inspect." + }, + "equals": { + "description": "True when the option value strictly equals this." + }, + "in": { + "type": "array", + "description": "True when the option value is one of these.", + "items": true + }, + "not": { + "description": "True when the option value is not strictly equal to this." + }, + "exists": { + "type": "boolean", + "description": "True when the option value is present (or absent when false)." + } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": ["all"], + "properties": { + "all": { + "type": "array", + "minItems": 1, + "items": { "$ref": "#/$defs/condition" } + } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": ["any"], + "properties": { + "any": { + "type": "array", + "minItems": 1, + "items": { "$ref": "#/$defs/condition" } + } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": ["not"], + "properties": { + "not": { "$ref": "#/$defs/condition" } + } + } + ] + } + } +} diff --git a/scripts/download-android-assets.mjs b/scripts/download-android-assets.mjs new file mode 100644 index 0000000..5306983 --- /dev/null +++ b/scripts/download-android-assets.mjs @@ -0,0 +1,109 @@ +#!/usr/bin/env node +import { + createWriteStream, + createReadStream, + existsSync, + unlinkSync, +} from "node:fs"; +import { mkdir, rename } from "node:fs/promises"; +import { createHash } from "node:crypto"; +import { Readable } from "node:stream"; +import { fileURLToPath } from "node:url"; +import path from "node:path"; +const RELEASE_TAG = "android-assets-v1"; +const REPO = "LCE-Hub/LCE-Emerald-Launcher"; +const ASSET_DIR = fileURLToPath( + new URL("../src-tauri/gen/android/app/src/main/assets/", import.meta.url), +); + +const ASSETS = [ + { + file: "imagefs.tar.zst", + sha256: "7838756e6a05c91afff68f4bf12aa2780f815877753f8dd354e203e99b9caf8a", + }, + { + file: "proton-11.0-arm64ec.tar.zst", + sha256: "ceb768396c2d44256518f94ae236f0c1f40738cf050a52e990e2fef67ff161df", + }, +]; + +function sha256File(file) { + return new Promise((resolve, reject) => { + const hash = createHash("sha256"); + const stream = createReadStream(file); + stream.on("data", (chunk) => hash.update(chunk)); + stream.on("end", () => resolve(hash.digest("hex"))); + stream.on("error", reject); + }); +} + +async function download(url, dest) { + const res = await fetch(url, { redirect: "follow" }); + if (!res.ok) { + throw new Error(`HTTP ${res.status} ${res.statusText} for ${url}`); + } + + if (!res.body) { + throw new Error(`no response body for ${url}`); + } + + const fileStream = createWriteStream(dest); + await new Promise((resolve, reject) => { + const body = Readable.fromWeb(res.body); + body.pipe(fileStream); + body.on("error", reject); + fileStream.on("finish", resolve); + fileStream.on("error", reject); + }); +} + +let failed = false; +for (const asset of ASSETS) { + const target = path.join(ASSET_DIR, asset.file); + if (existsSync(target)) { + const actual = await sha256File(target); + if (actual === asset.sha256) { + console.log(`okay ${asset.file} (up to date)`); + continue; + } + + console.error( + `error ${asset.file}: sha256 mismatch (expected ${asset.sha256}, got ${actual}); delete it and rerun`, + ); + + failed = true; + continue; + } + + const url = `https://github.com/${REPO}/releases/download/${RELEASE_TAG}/${asset.file}`; + console.log(`get ${asset.file} <- ${url}`); + await mkdir(path.dirname(target), { recursive: true }); + const tmp = target + ".part"; + try { + await download(url, tmp); + const actual = await sha256File(tmp); + if (actual !== asset.sha256) { + console.error( + `error ${asset.file}: downloaded sha256 mismatch (expected ${asset.sha256}, got ${actual})`, + ); + + failed = true; + if (existsSync(tmp)) { + unlinkSync(tmp); + } + + continue; + } + + await rename(tmp, target); + console.log(`okay ${asset.file} (downloaded)`); + } catch (error) { + console.error(`error ${asset.file}: ${error.message}`); + failed = true; + if (existsSync(tmp)) { + unlinkSync(tmp); + } + } +} + +process.exit(failed ? 1 : 0); diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 6fa754f..2a4b246 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -635,7 +635,7 @@ dependencies = [ "bitflags 2.11.0", "core-foundation 0.10.1", "core-graphics-types", - "foreign-types 0.5.0", + "foreign-types", "libc", ] @@ -1051,6 +1051,7 @@ dependencies = [ "futures-util", "http 1.4.0", "image 0.24.9", + "jni", "libc", "lzxd", "once_cell", @@ -1074,6 +1075,7 @@ dependencies = [ "tokio-util", "url", "uuid 1.23.0", + "wry", ] [[package]] @@ -1264,15 +1266,6 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" -[[package]] -name = "foreign-types" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" -dependencies = [ - "foreign-types-shared 0.1.1", -] - [[package]] name = "foreign-types" version = "0.5.0" @@ -1280,7 +1273,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" dependencies = [ "foreign-types-macros", - "foreign-types-shared 0.3.1", + "foreign-types-shared", ] [[package]] @@ -1294,12 +1287,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "foreign-types-shared" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" - [[package]] name = "foreign-types-shared" version = "0.3.1" @@ -1926,6 +1913,12 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "http-range" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21dec9db110f5f872ed9699c3ecf50cf16f423502706ba5c72462e28d3157573" + [[package]] name = "httparse" version = "1.10.1" @@ -1982,6 +1975,20 @@ dependencies = [ "want", ] +[[package]] +name = "hyper-rustls" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec3efd23720e2049821a693cbc7e65ea87c72f1c58ff2f9522ff332b1491e590" +dependencies = [ + "futures-util", + "http 0.2.12", + "hyper 0.14.32", + "rustls 0.21.12", + "tokio", + "tokio-rustls 0.24.1", +] + [[package]] name = "hyper-rustls" version = "0.27.9" @@ -1991,25 +1998,12 @@ dependencies = [ "http 1.4.0", "hyper 1.9.0", "hyper-util", - "rustls", + "rustls 0.23.40", "tokio", - "tokio-rustls", + "tokio-rustls 0.26.4", "tower-service", ] -[[package]] -name = "hyper-tls" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6183ddfa99b85da61a140bea0efc93fdf56ceaa041b37d553518030827f9905" -dependencies = [ - "bytes", - "hyper 0.14.32", - "native-tls", - "tokio", - "tokio-native-tls", -] - [[package]] name = "hyper-util" version = "0.1.20" @@ -2686,23 +2680,6 @@ dependencies = [ "windows-sys 0.60.2", ] -[[package]] -name = "native-tls" -version = "0.2.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" -dependencies = [ - "libc", - "log", - "openssl", - "openssl-probe", - "openssl-sys", - "schannel", - "security-framework", - "security-framework-sys", - "tempfile", -] - [[package]] name = "ndk" version = "0.9.0" @@ -2980,50 +2957,12 @@ dependencies = [ "pathdiff", ] -[[package]] -name = "openssl" -version = "0.10.76" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "951c002c75e16ea2c65b8c7e4d3d51d5530d8dfa7d060b4776828c88cfb18ecf" -dependencies = [ - "bitflags 2.11.0", - "cfg-if", - "foreign-types 0.3.2", - "libc", - "once_cell", - "openssl-macros", - "openssl-sys", -] - -[[package]] -name = "openssl-macros" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - [[package]] name = "openssl-probe" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" -[[package]] -name = "openssl-sys" -version = "0.9.112" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57d55af3b3e226502be1526dfdba67ab0e9c96fc293004e79576b2b9edb0dbdb" -dependencies = [ - "cc", - "libc", - "pkg-config", - "vcpkg", -] - [[package]] name = "option-ext" version = "0.2.0" @@ -3781,15 +3720,15 @@ dependencies = [ "http 0.2.12", "http-body 0.4.6", "hyper 0.14.32", - "hyper-tls", + "hyper-rustls 0.24.2", "ipnet", "js-sys", "log", "mime", - "native-tls", "once_cell", "percent-encoding", "pin-project-lite", + "rustls 0.21.12", "rustls-pemfile", "serde", "serde_json", @@ -3797,7 +3736,7 @@ dependencies = [ "sync_wrapper 0.1.2", "system-configuration", "tokio", - "tokio-native-tls", + "tokio-rustls 0.24.1", "tokio-util", "tower-service", "url", @@ -3805,6 +3744,7 @@ dependencies = [ "wasm-bindgen-futures", "wasm-streams 0.4.2", "web-sys", + "webpki-roots 0.25.4", "winreg 0.50.0", ] @@ -3822,20 +3762,20 @@ dependencies = [ "http-body 1.0.1", "http-body-util", "hyper 1.9.0", - "hyper-rustls", + "hyper-rustls 0.27.9", "hyper-util", "js-sys", "log", "percent-encoding", "pin-project-lite", - "rustls", + "rustls 0.23.40", "rustls-pki-types", "rustls-platform-verifier", "serde", "serde_json", "sync_wrapper 1.0.2", "tokio", - "tokio-rustls", + "tokio-rustls 0.26.4", "tokio-util", "tower", "tower-http", @@ -3936,6 +3876,32 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "rustls" +version = "0.21.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f56a14d1f48b391359b22f731fd4bd7e43c97f3c50eee276f3aa09c94784d3e" +dependencies = [ + "log", + "ring", + "rustls-webpki 0.101.7", + "sct", +] + +[[package]] +name = "rustls" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf4ef73721ac7bcd79b2b315da7779d8fc09718c6b3d2d1b2d94850eb8c18432" +dependencies = [ + "log", + "ring", + "rustls-pki-types", + "rustls-webpki 0.102.8", + "subtle", + "zeroize", +] + [[package]] name = "rustls" version = "0.23.40" @@ -3945,7 +3911,7 @@ dependencies = [ "once_cell", "ring", "rustls-pki-types", - "rustls-webpki", + "rustls-webpki 0.103.13", "subtle", "zeroize", ] @@ -3991,10 +3957,10 @@ dependencies = [ "jni", "log", "once_cell", - "rustls", + "rustls 0.23.40", "rustls-native-certs", "rustls-platform-verifier-android", - "rustls-webpki", + "rustls-webpki 0.103.13", "security-framework", "security-framework-sys", "webpki-root-certs", @@ -4007,6 +3973,27 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" +[[package]] +name = "rustls-webpki" +version = "0.101.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b6275d1ee7a1cd780b64aca7726599a1dbc893b1e64144529e55c3c2f745765" +dependencies = [ + "ring", + "untrusted", +] + +[[package]] +name = "rustls-webpki" +version = "0.102.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64ca1bc8749bd4cf37b5ce386cc146580777b4e8572c7b97baf22c83f444bee9" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + [[package]] name = "rustls-webpki" version = "0.103.13" @@ -4111,6 +4098,16 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "sct" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da046153aa2352493d6cb7da4b6e5c0c057d8a1d0a9aa8560baffdd945acd414" +dependencies = [ + "ring", + "untrusted", +] + [[package]] name = "security-framework" version = "3.7.0" @@ -4751,6 +4748,7 @@ dependencies = [ "gtk", "heck 0.5.0", "http 1.4.0", + "http-range", "image 0.25.10", "jni", "libc", @@ -4983,7 +4981,7 @@ dependencies = [ "osakit", "percent-encoding", "reqwest 0.13.2", - "rustls", + "rustls 0.23.40", "semver", "serde", "serde_json", @@ -5263,12 +5261,23 @@ dependencies = [ ] [[package]] -name = "tokio-native-tls" -version = "0.3.1" +name = "tokio-rustls" +version = "0.24.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +checksum = "c28327cf380ac148141087fbfb9de9d7bd4e84ab5d2c28fbc911d753de8a7081" dependencies = [ - "native-tls", + "rustls 0.21.12", + "tokio", +] + +[[package]] +name = "tokio-rustls" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "775e0c0f0adb3a2f22a00c4745d728b479985fc15ee7ca6a2608388c5569860f" +dependencies = [ + "rustls 0.22.4", + "rustls-pki-types", "tokio", ] @@ -5278,7 +5287,7 @@ version = "0.26.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" dependencies = [ - "rustls", + "rustls 0.23.40", "tokio", ] @@ -5290,10 +5299,12 @@ checksum = "c83b561d025642014097b66e6c1bb422783339e0909e4429cde4749d1990bc38" dependencies = [ "futures-util", "log", - "native-tls", + "rustls 0.22.4", + "rustls-pki-types", "tokio", - "tokio-native-tls", + "tokio-rustls 0.25.0", "tungstenite", + "webpki-roots 0.26.11", ] [[package]] @@ -5531,8 +5542,9 @@ dependencies = [ "http 1.4.0", "httparse", "log", - "native-tls", "rand 0.8.5", + "rustls 0.22.4", + "rustls-pki-types", "sha1", "thiserror 1.0.69", "url", @@ -5691,12 +5703,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "vcpkg" -version = "0.2.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" - [[package]] name = "vec_map" version = "0.8.2" @@ -6034,6 +6040,30 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "webpki-roots" +version = "0.25.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f20c57d8d7db6d3b86154206ae5d8fba62dd39573114de97c2cb0578251f8e1" + +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.9", +] + +[[package]] +name = "webpki-roots" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "webview2-com" version = "0.38.2" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 25f4d90..7a142c2 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -13,22 +13,20 @@ crate-type = ["staticlib", "cdylib", "rlib"] tauri-build = { version = "2", features = [] } [dependencies] -tauri = { version = "2", features = ["tray-icon", "image-png"] } +tauri = { version = "2", features = ["protocol-asset", "tray-icon", "image-png"] } tauri-plugin-opener = "2" serde = { version = "1", features = ["derive"] } serde_json = "1" tauri-plugin-gamepad = "0.0.5" -tauri-plugin-drpc = "*" -reqwest = { version = "0.11", features = ["stream", "json"] } +reqwest = { version = "0.11", default-features = false, features = ["stream", "json", "rustls-tls-webpki-roots"] } tokio = { version = "1", features = ["full"] } futures-util = "0.3" tokio-util = { version = "0.7.18", features = ["rt"] } base64 = "0.21" -tokio-tungstenite = { version = "0.21", default-features = false, features = ["connect", "native-tls"] } +tokio-tungstenite = { version = "0.21", default-features = false, features = ["connect", "rustls-tls-webpki-roots"] } url = "2" rand = "0.8" http = "1" -rfd = "0.15" libc = "0.2" image = "0.24" steam_shortcuts_util = "1.1.8" @@ -39,10 +37,16 @@ once_cell = "1" lzxd = "0.2.6" uuid = { version = "1", features = ["v4"] } tauri-plugin-deep-link = "2" -tauri-plugin-single-instance = "2" + +[target.'cfg(target_os = "android")'.dependencies] +wry = { version = "0.54", default-features = false } +jni = "0.21" [target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies] tauri-plugin-updater = "2" +tauri-plugin-drpc = "*" +tauri-plugin-single-instance = "2" +rfd = "0.15" #neo: enable portable appimages. revert back to crates.io once the PR is merged #[patch.crates-io] diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 419bf65..5fbf8a0 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -1,6 +1,7 @@ { "identifier": "default", "description": "Default permissions", + "platforms": ["windows", "linux", "macOS"], "windows": ["main"], "permissions": [ "core:default", diff --git a/src-tauri/capabilities/mobile.json b/src-tauri/capabilities/mobile.json new file mode 100644 index 0000000..9ecd892 --- /dev/null +++ b/src-tauri/capabilities/mobile.json @@ -0,0 +1,29 @@ +{ + "identifier": "mobile-capability", + "description": "Mobile permissions", + "platforms": ["android", "iOS"], + "windows": ["main"], + "permissions": [ + "core:default", + "core:app:default", + "core:event:default", + "core:image:default", + "core:path:default", + "core:resources:default", + "core:webview:default", + "core:webview:allow-create-webview-window", + "core:window:default", + "opener:default", + { + "identifier": "opener:allow-open-path", + "allow": [ + { + "path": "**" + } + ] + }, + "gamepad:default", + "process:default", + "deep-link:default" + ] +} diff --git a/src-tauri/gen/android/.editorconfig b/src-tauri/gen/android/.editorconfig new file mode 100644 index 0000000..ebe51d3 --- /dev/null +++ b/src-tauri/gen/android/.editorconfig @@ -0,0 +1,12 @@ +# EditorConfig is awesome: https://EditorConfig.org + +# top-most EditorConfig file +root = true + +[*] +indent_style = space +indent_size = 2 +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = false +insert_final_newline = false \ No newline at end of file diff --git a/src-tauri/gen/android/.gitignore b/src-tauri/gen/android/.gitignore new file mode 100644 index 0000000..6a893da --- /dev/null +++ b/src-tauri/gen/android/.gitignore @@ -0,0 +1,20 @@ +*.iml +.gradle +/local.properties +/.idea/caches +/.idea/libraries +/.idea/modules.xml +/.idea/workspace.xml +/.idea/navEditor.xml +/.idea/assetWizardSettings.xml +.DS_Store +build +/captures +.externalNativeBuild +.cxx +local.properties +key.properties +release.keystore + +/.tauri +/tauri.settings.gradle \ No newline at end of file diff --git a/src-tauri/gen/android/app/.gitignore b/src-tauri/gen/android/app/.gitignore new file mode 100644 index 0000000..6c4d56b --- /dev/null +++ b/src-tauri/gen/android/app/.gitignore @@ -0,0 +1,6 @@ +/src/main/**/generated +/src/main/jniLibs/**/*.so +/src/main/assets/tauri.conf.json +/tauri.build.gradle.kts +/proguard-tauri.pro +/tauri.properties \ No newline at end of file diff --git a/src-tauri/gen/android/app/build.gradle.kts b/src-tauri/gen/android/app/build.gradle.kts new file mode 100644 index 0000000..f44a4ea --- /dev/null +++ b/src-tauri/gen/android/app/build.gradle.kts @@ -0,0 +1,93 @@ +import java.util.Properties + +plugins { + id("com.android.application") + id("org.jetbrains.kotlin.android") + id("rust") +} + +val tauriProperties = Properties().apply { + val propFile = file("tauri.properties") + if (propFile.exists()) { + propFile.inputStream().use { load(it) } + } +} + +val keystoreProperties = Properties().apply { + val propFile = file("key.properties") + if (propFile.exists()) { + propFile.inputStream().use { load(it) } + } +} + +android { + signingConfigs { + create("release") { + keyAlias = keystoreProperties.getProperty("keyAlias") + keyPassword = keystoreProperties.getProperty("keyPassword") + storeFile = file(keystoreProperties.getProperty("storeFile")) + storePassword = keystoreProperties.getProperty("storePassword") + } + } + compileSdk = 36 + namespace = "com.emerald.legacy" + defaultConfig { + manifestPlaceholders["usesCleartextTraffic"] = "false" + applicationId = "com.emerald.legacy" + minSdk = 26 + targetSdk = 28 + versionCode = tauriProperties.getProperty("tauri.android.versionCode", "1").toInt() + versionName = tauriProperties.getProperty("tauri.android.versionName", "1.0") + } + buildTypes { + getByName("debug") { + manifestPlaceholders["usesCleartextTraffic"] = "true" + isDebuggable = true + isJniDebuggable = true + isMinifyEnabled = false + packaging { jniLibs.keepDebugSymbols.add("*/arm64-v8a/*.so") + jniLibs.keepDebugSymbols.add("*/armeabi-v7a/*.so") + jniLibs.keepDebugSymbols.add("*/x86/*.so") + jniLibs.keepDebugSymbols.add("*/x86_64/*.so") + } + } + getByName("release") { + signingConfig = signingConfigs.getByName("release") + isMinifyEnabled = true + proguardFiles( + *fileTree(".") { include("**/*.pro") } + .plus(getDefaultProguardFile("proguard-android-optimize.txt")) + .toList().toTypedArray() + ) + } + } + kotlinOptions { + jvmTarget = "1.8" + } + buildFeatures { + buildConfig = true + } + lint { + disable += "ExpiredTargetSdkVersion" + } + packaging { + resources.excludes += "lib/*/jni/**" + } +} + +rust { + rootDirRel = "../../../" +} + +dependencies { + implementation(project(":diamondruntime")) + implementation("androidx.webkit:webkit:1.14.0") + implementation("androidx.appcompat:appcompat:1.7.1") + implementation("androidx.activity:activity-ktx:1.10.1") + implementation("com.google.android.material:material:1.12.0") + testImplementation("junit:junit:4.13.2") + androidTestImplementation("androidx.test.ext:junit:1.1.4") + androidTestImplementation("androidx.test.espresso:espresso-core:3.5.0") +} + +apply(from = "tauri.build.gradle.kts") \ No newline at end of file diff --git a/src-tauri/gen/android/app/proguard-rules.pro b/src-tauri/gen/android/app/proguard-rules.pro new file mode 100644 index 0000000..efc3900 --- /dev/null +++ b/src-tauri/gen/android/app/proguard-rules.pro @@ -0,0 +1,19 @@ +-keep class dev.lcehub.emerald.** { *; } +-keepclassmembers class dev.lcehub.emerald.** { *; } +-dontwarn dev.lcehub.emerald.** +-keep class com.github.luben.zstd.** { *; } +-keepclassmembers class com.github.luben.zstd.** { *; } +-dontwarn com.github.luben.zstd.** +-keep class com.emerald.legacy.TauriActivity { + public ; + public ; +} +-keep class com.emerald.legacy.MainActivity { + public ; +} + +-dontwarn com.android.org.conscrypt.SSLParametersImpl +-dontwarn org.apache.harmony.xnet.provider.jsse.SSLParametersImpl +-dontwarn org.bouncycastle.jsse.BCSSLParameters +-dontwarn org.bouncycastle.jsse.BCSSLSocket +-dontwarn org.bouncycastle.jsse.provider.BouncyCastleJsseProvider diff --git a/src-tauri/gen/android/app/src/main/AndroidManifest.xml b/src-tauri/gen/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..04986a1 --- /dev/null +++ b/src-tauri/gen/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,50 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src-tauri/gen/android/app/src/main/assets/resources/DLC/Emerald Skin Pack/Skins1.pck b/src-tauri/gen/android/app/src/main/assets/resources/DLC/Emerald Skin Pack/Skins1.pck new file mode 100644 index 0000000..3f2a5ab Binary files /dev/null and b/src-tauri/gen/android/app/src/main/assets/resources/DLC/Emerald Skin Pack/Skins1.pck differ diff --git a/src-tauri/gen/android/app/src/main/java/com/emerald/legacy/LceAuthActivity.kt b/src-tauri/gen/android/app/src/main/java/com/emerald/legacy/LceAuthActivity.kt new file mode 100644 index 0000000..dedf68e --- /dev/null +++ b/src-tauri/gen/android/app/src/main/java/com/emerald/legacy/LceAuthActivity.kt @@ -0,0 +1,124 @@ +package com.emerald.legacy + +import android.content.Intent +import android.graphics.Bitmap +import android.net.Uri +import android.os.Bundle +import android.os.Message +import android.view.Gravity +import android.view.View +import android.view.ViewGroup +import android.webkit.WebResourceRequest +import android.webkit.WebSettings +import android.webkit.WebView +import android.webkit.WebViewClient +import android.widget.FrameLayout +import android.widget.ProgressBar +import androidx.appcompat.app.AppCompatActivity + +class LceAuthActivity : AppCompatActivity() { + private lateinit var webView: WebView + private lateinit var progressBar: ProgressBar + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val authUrl = intent.getStringExtra("authUrl") + ?: "https://mclegacyedition.xyz/internal/auth?appId=emerald_launcher" + + progressBar = ProgressBar(this).apply { + layoutParams = FrameLayout.LayoutParams( + ViewGroup.LayoutParams.WRAP_CONTENT, + ViewGroup.LayoutParams.WRAP_CONTENT + ) + } + + webView = WebView(this).apply { + settings.javaScriptEnabled = true + settings.domStorageEnabled = true + settings.databaseEnabled = true + settings.loadWithOverviewMode = true + settings.useWideViewPort = true + settings.setSupportMultipleWindows(true) + settings.javaScriptCanOpenWindowsAutomatically = true + webViewClient = object : WebViewClient() { + override fun shouldOverrideUrlLoading(view: WebView, url: String): Boolean = handleAuthUrl(url) + override fun shouldOverrideUrlLoading( + view: WebView, + request: WebResourceRequest + ): Boolean = handleAuthUrl(request.url.toString()) + + override fun onPageStarted(view: WebView, url: String, favicon: Bitmap?) { + handleAuthUrl(url) + } + + override fun onPageFinished(view: WebView, url: String) { + progressBar.visibility = View.GONE + } + } + webChromeClient = object : android.webkit.WebChromeClient() { + override fun onCreateWindow( + view: WebView, + isDialog: Boolean, + isUserGesture: Boolean, + resultMsg: Message + ): Boolean { + val transport = resultMsg.obj as WebView.WebViewTransport + transport.webView = webView + resultMsg.sendToTarget() + return true + } + } + } + + val root = FrameLayout(this) + root.addView( + webView, + FrameLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT + ) + ) + val progressFrame = FrameLayout.LayoutParams( + ViewGroup.LayoutParams.WRAP_CONTENT, + ViewGroup.LayoutParams.WRAP_CONTENT + ) + progressFrame.gravity = Gravity.CENTER + root.addView(progressBar, progressFrame) + setContentView(root) + webView.loadUrl(authUrl) + } + + private fun handleAuthUrl(url: String): Boolean { + val uri = try { + Uri.parse(url) + } catch (e: Exception) { + return false + } + if (uri.scheme == "emerald" && uri.host == "lceonline") { + val token = uri.getQueryParameter("token") + if (token != null) { + setResult(RESULT_OK, Intent().putExtra("lceAuthToken", token)) + } else { + setResult(RESULT_CANCELED) + } + finish() + return true + } + return false + } + + @Deprecated("Deprecated in Java") + override fun onBackPressed() { + if (webView.canGoBack()) { + webView.goBack() + } else { + setResult(RESULT_CANCELED) + super.onBackPressed() + } + } + + override fun onDestroy() { + (webView.parent as? ViewGroup)?.removeView(webView) + webView.destroy() + super.onDestroy() + } +} diff --git a/src-tauri/gen/android/app/src/main/java/com/emerald/legacy/LceAuthPlugin.kt b/src-tauri/gen/android/app/src/main/java/com/emerald/legacy/LceAuthPlugin.kt new file mode 100644 index 0000000..04a6a27 --- /dev/null +++ b/src-tauri/gen/android/app/src/main/java/com/emerald/legacy/LceAuthPlugin.kt @@ -0,0 +1,37 @@ +package com.emerald.legacy + +import android.app.Activity +import android.content.Intent +import androidx.activity.result.ActivityResult +import app.tauri.annotation.ActivityCallback +import app.tauri.annotation.Command +import app.tauri.annotation.TauriPlugin +import app.tauri.plugin.Invoke +import app.tauri.plugin.Plugin + +@TauriPlugin +class LceAuthPlugin(private val activity: Activity) : Plugin(activity) { + @Command + fun startAuth(invoke: Invoke) { + val intent = Intent(activity, LceAuthActivity::class.java) + intent.putExtra( + "authUrl", + "https://mclegacyedition.xyz/internal/auth?appId=emerald_launcher" + ) + startActivityForResult(invoke, intent, "authResult") + } + + @ActivityCallback + fun authResult(invoke: Invoke, result: ActivityResult) { + val token = if (result.resultCode == Activity.RESULT_OK) { + result.data?.getStringExtra("lceAuthToken") + } else { + null + } + if (!token.isNullOrEmpty()) { + invoke.resolveObject(token) + } else { + invoke.reject("Authentication cancelled or failed") + } + } +} diff --git a/src-tauri/gen/android/app/src/main/java/com/emerald/legacy/MainActivity.kt b/src-tauri/gen/android/app/src/main/java/com/emerald/legacy/MainActivity.kt new file mode 100644 index 0000000..4d209a6 --- /dev/null +++ b/src-tauri/gen/android/app/src/main/java/com/emerald/legacy/MainActivity.kt @@ -0,0 +1,28 @@ +package com.emerald.legacy + +import android.os.Bundle +import android.webkit.WebView +import androidx.activity.enableEdgeToEdge +import androidx.core.view.WindowCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.WindowInsetsControllerCompat + +class MainActivity : TauriActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + enableEdgeToEdge() + super.onCreate(savedInstanceState) + WindowCompat.getInsetsController(window, window.decorView).apply { + systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE + hide(WindowInsetsCompat.Type.statusBars() or WindowInsetsCompat.Type.navigationBars()) + } + } + + override fun onWebViewCreate(webView: WebView) { + super.onWebViewCreate(webView) + webView.settings.useWideViewPort = true + webView.settings.loadWithOverviewMode = true + webView.settings.setSupportZoom(false) + webView.settings.builtInZoomControls = false + webView.settings.displayZoomControls = false + } +} diff --git a/src-tauri/gen/android/app/src/main/res/drawable-v24/ic_launcher_foreground.xml b/src-tauri/gen/android/app/src/main/res/drawable-v24/ic_launcher_foreground.xml new file mode 100644 index 0000000..2b068d1 --- /dev/null +++ b/src-tauri/gen/android/app/src/main/res/drawable-v24/ic_launcher_foreground.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/src-tauri/gen/android/app/src/main/res/drawable/ic_launcher_background.xml b/src-tauri/gen/android/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..ac3248b --- /dev/null +++ b/src-tauri/gen/android/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,10 @@ + + + + diff --git a/src-tauri/gen/android/app/src/main/res/layout/activity_main.xml b/src-tauri/gen/android/app/src/main/res/layout/activity_main.xml new file mode 100644 index 0000000..4fc2444 --- /dev/null +++ b/src-tauri/gen/android/app/src/main/res/layout/activity_main.xml @@ -0,0 +1,18 @@ + + + + + + \ No newline at end of file diff --git a/src-tauri/gen/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/src-tauri/gen/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..8d11c83 Binary files /dev/null and b/src-tauri/gen/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/src-tauri/gen/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png b/src-tauri/gen/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..b9e5600 Binary files /dev/null and b/src-tauri/gen/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png differ diff --git a/src-tauri/gen/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png b/src-tauri/gen/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png new file mode 100644 index 0000000..05b5f00 Binary files /dev/null and b/src-tauri/gen/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png differ diff --git a/src-tauri/gen/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/src-tauri/gen/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..3694969 Binary files /dev/null and b/src-tauri/gen/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/src-tauri/gen/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png b/src-tauri/gen/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..ce13038 Binary files /dev/null and b/src-tauri/gen/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png differ diff --git a/src-tauri/gen/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png b/src-tauri/gen/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png new file mode 100644 index 0000000..3694969 Binary files /dev/null and b/src-tauri/gen/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png differ diff --git a/src-tauri/gen/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/src-tauri/gen/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..43adc4c Binary files /dev/null and b/src-tauri/gen/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/src-tauri/gen/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png b/src-tauri/gen/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..0a50837 Binary files /dev/null and b/src-tauri/gen/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png differ diff --git a/src-tauri/gen/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/src-tauri/gen/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png new file mode 100644 index 0000000..43adc4c Binary files /dev/null and b/src-tauri/gen/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png differ diff --git a/src-tauri/gen/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/src-tauri/gen/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..d746596 Binary files /dev/null and b/src-tauri/gen/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/src-tauri/gen/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png b/src-tauri/gen/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..a59b865 Binary files /dev/null and b/src-tauri/gen/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png differ diff --git a/src-tauri/gen/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/src-tauri/gen/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..d746596 Binary files /dev/null and b/src-tauri/gen/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png differ diff --git a/src-tauri/gen/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/src-tauri/gen/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..4447cd9 Binary files /dev/null and b/src-tauri/gen/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/src-tauri/gen/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png b/src-tauri/gen/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..e2acd39 Binary files /dev/null and b/src-tauri/gen/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png differ diff --git a/src-tauri/gen/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/src-tauri/gen/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..4447cd9 Binary files /dev/null and b/src-tauri/gen/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png differ diff --git a/src-tauri/gen/android/app/src/main/res/values-night/themes.xml b/src-tauri/gen/android/app/src/main/res/values-night/themes.xml new file mode 100644 index 0000000..349a597 --- /dev/null +++ b/src-tauri/gen/android/app/src/main/res/values-night/themes.xml @@ -0,0 +1,6 @@ + + + + diff --git a/src-tauri/gen/android/app/src/main/res/values/colors.xml b/src-tauri/gen/android/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..f8c6127 --- /dev/null +++ b/src-tauri/gen/android/app/src/main/res/values/colors.xml @@ -0,0 +1,10 @@ + + + #FFBB86FC + #FF6200EE + #FF3700B3 + #FF03DAC5 + #FF018786 + #FF000000 + #FFFFFFFF + \ No newline at end of file diff --git a/src-tauri/gen/android/app/src/main/res/values/strings.xml b/src-tauri/gen/android/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..b14475e --- /dev/null +++ b/src-tauri/gen/android/app/src/main/res/values/strings.xml @@ -0,0 +1,4 @@ + + LCE Emerald Launcher + LCE Emerald Launcher + \ No newline at end of file diff --git a/src-tauri/gen/android/app/src/main/res/values/themes.xml b/src-tauri/gen/android/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..349a597 --- /dev/null +++ b/src-tauri/gen/android/app/src/main/res/values/themes.xml @@ -0,0 +1,6 @@ + + + + diff --git a/src-tauri/gen/android/app/src/main/res/xml/file_paths.xml b/src-tauri/gen/android/app/src/main/res/xml/file_paths.xml new file mode 100644 index 0000000..f2c4376 --- /dev/null +++ b/src-tauri/gen/android/app/src/main/res/xml/file_paths.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/src-tauri/gen/android/build.gradle.kts b/src-tauri/gen/android/build.gradle.kts new file mode 100644 index 0000000..607240b --- /dev/null +++ b/src-tauri/gen/android/build.gradle.kts @@ -0,0 +1,22 @@ +buildscript { + repositories { + google() + mavenCentral() + } + dependencies { + classpath("com.android.tools.build:gradle:8.11.0") + classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:1.9.25") + } +} + +allprojects { + repositories { + google() + mavenCentral() + } +} + +tasks.register("clean").configure { + delete("build") +} + diff --git a/src-tauri/gen/android/buildSrc/build.gradle.kts b/src-tauri/gen/android/buildSrc/build.gradle.kts new file mode 100644 index 0000000..5c55bba --- /dev/null +++ b/src-tauri/gen/android/buildSrc/build.gradle.kts @@ -0,0 +1,23 @@ +plugins { + `kotlin-dsl` +} + +gradlePlugin { + plugins { + create("pluginsForCoolKids") { + id = "rust" + implementationClass = "RustPlugin" + } + } +} + +repositories { + google() + mavenCentral() +} + +dependencies { + compileOnly(gradleApi()) + implementation("com.android.tools.build:gradle:8.11.0") +} + diff --git a/src-tauri/gen/android/buildSrc/src/main/java/com/emerald/legacy/kotlin/BuildTask.kt b/src-tauri/gen/android/buildSrc/src/main/java/com/emerald/legacy/kotlin/BuildTask.kt new file mode 100644 index 0000000..a3de125 --- /dev/null +++ b/src-tauri/gen/android/buildSrc/src/main/java/com/emerald/legacy/kotlin/BuildTask.kt @@ -0,0 +1,68 @@ +import java.io.File +import org.apache.tools.ant.taskdefs.condition.Os +import org.gradle.api.DefaultTask +import org.gradle.api.GradleException +import org.gradle.api.logging.LogLevel +import org.gradle.api.tasks.Input +import org.gradle.api.tasks.TaskAction + +open class BuildTask : DefaultTask() { + @Input + var rootDirRel: String? = null + @Input + var target: String? = null + @Input + var release: Boolean? = null + + @TaskAction + fun assemble() { + val executable = """npm"""; + try { + runTauriCli(executable) + } catch (e: Exception) { + if (Os.isFamily(Os.FAMILY_WINDOWS)) { + // Try different Windows-specific extensions + val fallbacks = listOf( + "$executable.exe", + "$executable.cmd", + "$executable.bat", + ) + + var lastException: Exception = e + for (fallback in fallbacks) { + try { + runTauriCli(fallback) + return + } catch (fallbackException: Exception) { + lastException = fallbackException + } + } + throw lastException + } else { + throw e; + } + } + } + + fun runTauriCli(executable: String) { + val rootDirRel = rootDirRel ?: throw GradleException("rootDirRel cannot be null") + val target = target ?: throw GradleException("target cannot be null") + val release = release ?: throw GradleException("release cannot be null") + val args = listOf("run", "--", "tauri", "android", "android-studio-script"); + + project.exec { + workingDir(File(project.projectDir, rootDirRel)) + executable(executable) + args(args) + if (project.logger.isEnabled(LogLevel.DEBUG)) { + args("-vv") + } else if (project.logger.isEnabled(LogLevel.INFO)) { + args("-v") + } + if (release) { + args("--release") + } + args(listOf("--target", target)) + }.assertNormalExitValue() + } +} \ No newline at end of file diff --git a/src-tauri/gen/android/buildSrc/src/main/java/com/emerald/legacy/kotlin/RustPlugin.kt b/src-tauri/gen/android/buildSrc/src/main/java/com/emerald/legacy/kotlin/RustPlugin.kt new file mode 100644 index 0000000..4aa7fca --- /dev/null +++ b/src-tauri/gen/android/buildSrc/src/main/java/com/emerald/legacy/kotlin/RustPlugin.kt @@ -0,0 +1,85 @@ +import com.android.build.api.dsl.ApplicationExtension +import org.gradle.api.DefaultTask +import org.gradle.api.Plugin +import org.gradle.api.Project +import org.gradle.kotlin.dsl.configure +import org.gradle.kotlin.dsl.get + +const val TASK_GROUP = "rust" + +open class Config { + lateinit var rootDirRel: String +} + +open class RustPlugin : Plugin { + private lateinit var config: Config + + override fun apply(project: Project) = with(project) { + config = extensions.create("rust", Config::class.java) + + val defaultAbiList = listOf("arm64-v8a", "armeabi-v7a", "x86", "x86_64"); + val abiList = (findProperty("abiList") as? String)?.split(',') ?: defaultAbiList + + val defaultArchList = listOf("arm64", "arm", "x86", "x86_64"); + val archList = (findProperty("archList") as? String)?.split(',') ?: defaultArchList + + val targetsList = (findProperty("targetList") as? String)?.split(',') ?: listOf("aarch64", "armv7", "i686", "x86_64") + + extensions.configure { + @Suppress("UnstableApiUsage") + flavorDimensions.add("abi") + productFlavors { + create("universal") { + dimension = "abi" + ndk { + abiFilters += abiList + } + } + defaultArchList.forEachIndexed { index, arch -> + create(arch) { + dimension = "abi" + ndk { + abiFilters.add(defaultAbiList[index]) + } + } + } + } + } + + afterEvaluate { + for (profile in listOf("debug", "release")) { + val profileCapitalized = profile.replaceFirstChar { it.uppercase() } + val buildTask = tasks.maybeCreate( + "rustBuildUniversal$profileCapitalized", + DefaultTask::class.java + ).apply { + group = TASK_GROUP + description = "Build dynamic library in $profile mode for all targets" + } + + tasks["mergeUniversal${profileCapitalized}JniLibFolders"].dependsOn(buildTask) + + for (targetPair in targetsList.withIndex()) { + val targetName = targetPair.value + val targetArch = archList[targetPair.index] + val targetArchCapitalized = targetArch.replaceFirstChar { it.uppercase() } + val targetBuildTask = project.tasks.maybeCreate( + "rustBuild$targetArchCapitalized$profileCapitalized", + BuildTask::class.java + ).apply { + group = TASK_GROUP + description = "Build dynamic library in $profile mode for $targetArch" + rootDirRel = config.rootDirRel + target = targetName + release = profile == "release" + } + + buildTask.dependsOn(targetBuildTask) + tasks["merge$targetArchCapitalized${profileCapitalized}JniLibFolders"].dependsOn( + targetBuildTask + ) + } + } + } + } +} \ No newline at end of file diff --git a/src-tauri/gen/android/gradle.properties b/src-tauri/gen/android/gradle.properties new file mode 100644 index 0000000..2a7ec69 --- /dev/null +++ b/src-tauri/gen/android/gradle.properties @@ -0,0 +1,24 @@ +# Project-wide Gradle settings. +# IDE (e.g. Android Studio) users: +# Gradle settings configured through the IDE *will override* +# any settings specified in this file. +# For more details on how to configure your build environment visit +# http://www.gradle.org/docs/current/userguide/build_environment.html +# Specifies the JVM arguments used for the daemon process. +# The setting is particularly useful for tweaking memory settings. +org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 +# When configured, Gradle will run in incubating parallel mode. +# This option should only be used with decoupled projects. More details, visit +# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects +# org.gradle.parallel=true +# AndroidX package structure to make it clearer which packages are bundled with the +# Android operating system, and which are packaged with your app"s APK +# https://developer.android.com/topic/libraries/support-library/androidx-rn +android.useAndroidX=true +# Kotlin code style for this project: "official" or "obsolete": +kotlin.code.style=official +# Enables namespacing of each library's R class so that its R class includes only the +# resources declared in the library itself and none from the library's dependencies, +# thereby reducing the size of the R class for that library +android.nonTransitiveRClass=true +android.nonFinalResIds=false \ No newline at end of file diff --git a/src-tauri/gen/android/gradle/wrapper/gradle-wrapper.properties b/src-tauri/gen/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..c5f9a53 --- /dev/null +++ b/src-tauri/gen/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,6 @@ +#Tue May 10 19:22:52 CST 2022 +distributionBase=GRADLE_USER_HOME +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip +distributionPath=wrapper/dists +zipStorePath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME diff --git a/src-tauri/gen/android/gradlew b/src-tauri/gen/android/gradlew new file mode 100755 index 0000000..4f906e0 --- /dev/null +++ b/src-tauri/gen/android/gradlew @@ -0,0 +1,185 @@ +#!/usr/bin/env sh + +# +# Copyright 2015 the original author or authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +## +## Gradle start up script for UN*X +## +############################################################################## + +# Attempt to set APP_HOME +# Resolve links: $0 may be a link +PRG="$0" +# Need this for relative symlinks. +while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG=`dirname "$PRG"`"/$link" + fi +done +SAVED="`pwd`" +cd "`dirname \"$PRG\"`/" >/dev/null +APP_HOME="`pwd -P`" +cd "$SAVED" >/dev/null + +APP_NAME="Gradle" +APP_BASE_NAME=`basename "$0"` + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD="maximum" + +warn () { + echo "$*" +} + +die () { + echo + echo "$*" + echo + exit 1 +} + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "`uname`" in + CYGWIN* ) + cygwin=true + ;; + Darwin* ) + darwin=true + ;; + MINGW* ) + msys=true + ;; + NONSTOP* ) + nonstop=true + ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD="java" + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then + MAX_FD_LIMIT=`ulimit -H -n` + if [ $? -eq 0 ] ; then + if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then + MAX_FD="$MAX_FD_LIMIT" + fi + ulimit -n $MAX_FD + if [ $? -ne 0 ] ; then + warn "Could not set maximum file descriptor limit: $MAX_FD" + fi + else + warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" + fi +fi + +# For Darwin, add options to specify how the application appears in the dock +if $darwin; then + GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" +fi + +# For Cygwin or MSYS, switch paths to Windows format before running java +if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then + APP_HOME=`cygpath --path --mixed "$APP_HOME"` + CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` + + JAVACMD=`cygpath --unix "$JAVACMD"` + + # We build the pattern for arguments to be converted via cygpath + ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` + SEP="" + for dir in $ROOTDIRSRAW ; do + ROOTDIRS="$ROOTDIRS$SEP$dir" + SEP="|" + done + OURCYGPATTERN="(^($ROOTDIRS))" + # Add a user-defined pattern to the cygpath arguments + if [ "$GRADLE_CYGPATTERN" != "" ] ; then + OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" + fi + # Now convert the arguments - kludge to limit ourselves to /bin/sh + i=0 + for arg in "$@" ; do + CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` + CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option + + if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition + eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` + else + eval `echo args$i`="\"$arg\"" + fi + i=`expr $i + 1` + done + case $i in + 0) set -- ;; + 1) set -- "$args0" ;; + 2) set -- "$args0" "$args1" ;; + 3) set -- "$args0" "$args1" "$args2" ;; + 4) set -- "$args0" "$args1" "$args2" "$args3" ;; + 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; + 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; + 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; + 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; + 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; + esac +fi + +# Escape application args +save () { + for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done + echo " " +} +APP_ARGS=`save "$@"` + +# Collect all arguments for the java command, following the shell quoting and substitution rules +eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" + +exec "$JAVACMD" "$@" diff --git a/src-tauri/gen/android/gradlew.bat b/src-tauri/gen/android/gradlew.bat new file mode 100644 index 0000000..107acd3 --- /dev/null +++ b/src-tauri/gen/android/gradlew.bat @@ -0,0 +1,89 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/src-tauri/gen/android/settings.gradle b/src-tauri/gen/android/settings.gradle new file mode 100644 index 0000000..9006af6 --- /dev/null +++ b/src-tauri/gen/android/settings.gradle @@ -0,0 +1,5 @@ +include ':app' +include ':diamondruntime' +project(':diamondruntime').projectDir = new File(settingsDir, '../../../DiamondRuntime/app/app') + +apply from: 'tauri.settings.gradle' diff --git a/src-tauri/gen/schemas/capabilities.json b/src-tauri/gen/schemas/capabilities.json index 27f8b0f..f43cc28 100644 --- a/src-tauri/gen/schemas/capabilities.json +++ b/src-tauri/gen/schemas/capabilities.json @@ -1 +1 @@ -{"default":{"identifier":"default","description":"Default permissions","local":true,"windows":["main"],"permissions":["core:default","core:app:default","core:event:default","core:image:default","core:menu:default","core:path:default","core:resources:default","core:tray:default","core:webview:default","core:webview:allow-create-webview-window","core:window:default","core:window:allow-set-decorations","core:window:allow-is-fullscreen","core:window:allow-minimize","core:window:allow-toggle-maximize","core:window:allow-close","opener:default","gamepad:default","drpc:default","updater:default",{"identifier":"opener:allow-open-path","allow":[{"path":"**"}]},{"identifier":"opener:allow-reveal-item-in-dir","allow":[{"path":"**"}]},"process:default","deep-link:default","core:window:allow-start-dragging"]},"desktop-capability":{"identifier":"desktop-capability","description":"","local":true,"windows":["main"],"permissions":["updater:default","process:default","deep-link:default","core:window:allow-start-dragging"],"platforms":["macOS","windows","linux"]}} \ No newline at end of file +{"default":{"identifier":"default","description":"Default permissions","local":true,"windows":["main"],"permissions":["core:default","core:app:default","core:event:default","core:image:default","core:menu:default","core:path:default","core:resources:default","core:tray:default","core:webview:default","core:webview:allow-create-webview-window","core:window:default","core:window:allow-set-decorations","core:window:allow-is-fullscreen","core:window:allow-minimize","core:window:allow-toggle-maximize","core:window:allow-close","opener:default","gamepad:default","drpc:default","updater:default",{"identifier":"opener:allow-open-path","allow":[{"path":"**"}]},{"identifier":"opener:allow-reveal-item-in-dir","allow":[{"path":"**"}]},"process:default","deep-link:default","core:window:allow-start-dragging"],"platforms":["windows","linux","macOS"]},"desktop-capability":{"identifier":"desktop-capability","description":"","local":true,"windows":["main"],"permissions":["updater:default","process:default","deep-link:default","core:window:allow-start-dragging"],"platforms":["macOS","windows","linux"]},"mobile-capability":{"identifier":"mobile-capability","description":"Mobile permissions","local":true,"windows":["main"],"permissions":["core:default","core:app:default","core:event:default","core:image:default","core:path:default","core:resources:default","core:webview:default","core:webview:allow-create-webview-window","core:window:default","opener:default",{"identifier":"opener:allow-open-path","allow":[{"path":"**"}]},"gamepad:default","process:default","deep-link:default"],"platforms":["android","iOS"]}} \ No newline at end of file diff --git a/src-tauri/src/android_runtime.rs b/src-tauri/src/android_runtime.rs new file mode 100644 index 0000000..ffe0ee9 --- /dev/null +++ b/src-tauri/src/android_runtime.rs @@ -0,0 +1,104 @@ +pub enum BridgeAction { + Play, + OpenContainer, + OpenSettings, + SwitchProton, + InstallDriver, + SetAudioBackend, +} + +pub fn launch_bridge( + instance_path: String, + action: BridgeAction, + extra_args: Vec, +) -> Result<(), String> { + #[cfg(target_os = "android")] + { + use jni::objects::{JObject, JValue}; + use jni::sys::jint; + use jni::JNIEnv; + use wry::prelude::{dispatch, find_class}; + fn start_bridge( + env: &mut JNIEnv, + activity: &JObject, + action: &str, + instance_path: &str, + extra_args: &[String], + ) -> jni::errors::Result<()> { + let bridge_class = + find_class(env, activity, "dev.lcehub.emerald.LauncherBridgeActivity".to_string())?; + let intent_class = env.find_class("android/content/Intent")?; + let intent = env.new_object( + intent_class, + "(Landroid/content/Context;Ljava/lang/Class;)V", //neo: i hate smali so much + &[(&activity).into(), (&bridge_class).into()], + )?; + + let extra_action = env.new_string("launcher_action")?; + let action_str = env.new_string(action)?; + env.call_method( + &intent, + "putExtra", + "(Ljava/lang/String;Ljava/lang/String;)Landroid/content/Intent;", + &[(&extra_action).into(), (&action_str).into()], + )?; + + let extra_path = env.new_string("instance_path")?; + let path_str = env.new_string(instance_path)?; + env.call_method( + &intent, + "putExtra", + "(Ljava/lang/String;Ljava/lang/String;)Landroid/content/Intent;", + &[(&extra_path).into(), (&path_str).into()], + )?; + + let extra_args_key = env.new_string("extra_args")?; + let extra_args_json = + env.new_string(&serde_json::to_string(extra_args).unwrap_or_else(|_| "[]".into()))?; + env.call_method( + &intent, + "putExtra", + "(Ljava/lang/String;Ljava/lang/String;)Landroid/content/Intent;", + &[(&extra_args_key).into(), (&extra_args_json).into()], + )?; + + env.call_method( + &intent, + "addFlags", + "(I)Landroid/content/Intent;", + &[JValue::Int(0x10000000 as jint)], //neo: FLAG_ACTIVITY_NEW_TASK + )?; + + env.call_method( + activity, + "startActivity", + "(Landroid/content/Intent;)V", + &[(&intent).into()], + )?; + + Ok(()) + } + + let action_str = match action { + BridgeAction::Play => "play", + BridgeAction::OpenContainer => "open", + BridgeAction::OpenSettings => "settings", + BridgeAction::SwitchProton => "switch_proton", + BridgeAction::InstallDriver => "install_driver", + BridgeAction::SetAudioBackend => "set_audio_backend", + } + .to_string(); + dispatch(move |env, activity, _webview| { + if let Err(e) = start_bridge(env, activity, &action_str, &instance_path, &extra_args) { + eprintln!("[android_bridge] failed to start activity: {e}"); + } + }); + + Ok(()) + } + #[cfg(not(target_os = "android"))] + { + let _ = (instance_path, action, extra_args); + Err("Only supported on Android".into()) + } +} diff --git a/src-tauri/src/commands/config.rs b/src-tauri/src/commands/config.rs index 80a6030..71b3dd3 100644 --- a/src-tauri/src/commands/config.rs +++ b/src-tauri/src/commands/config.rs @@ -33,6 +33,7 @@ pub fn get_external_palettes(app: AppHandle) -> Vec { palettes } +#[cfg(desktop)] #[tauri::command] pub fn import_theme(app: AppHandle) -> Result { let file = rfd::FileDialog::new() @@ -54,6 +55,7 @@ pub fn import_theme(app: AppHandle) -> Result { } } +#[cfg(desktop)] #[tauri::command] pub fn export_settings(app: AppHandle) -> Result<(), String> { let config = config::load_config_raw(app.clone()); @@ -72,6 +74,7 @@ pub fn export_settings(app: AppHandle) -> Result<(), String> { } } +#[cfg(desktop)] #[tauri::command] pub fn import_settings(app: AppHandle) -> Result { let file = rfd::FileDialog::new() diff --git a/src-tauri/src/commands/console2lce.rs b/src-tauri/src/commands/console2lce.rs index fd5a75f..493e714 100644 --- a/src-tauri/src/commands/console2lce.rs +++ b/src-tauri/src/commands/console2lce.rs @@ -429,7 +429,7 @@ fn build_empty_chunk_nbt(chunk_x: i32, chunk_z: i32) -> Vec { out.extend_from_slice(&0i64.to_be_bytes()); out.extend_from_slice(b"\x01\x00\x10TerrainPopulated\x01"); out.extend_from_slice(b"\x09\x00\x08Entities\x0a\x00\x00\x00\x00"); - out.extend_from_slice(b"\x09\x00\x0cTileEntities\x0a\x00\x00\x00\x00"); + out.extend_from_slice(b"\x09\x00\x0cTileEntities\x0a\x00\x00\x00\x00"); //neo: someone send help out.push(0x00); out.push(0x00); out diff --git a/src-tauri/src/commands/download.rs b/src-tauri/src/commands/download.rs index fa7d0d5..7985573 100644 --- a/src-tauri/src/commands/download.rs +++ b/src-tauri/src/commands/download.rs @@ -28,7 +28,7 @@ async fn stream_download( lock.insert(instance_id.to_string(), token); } - let response = reqwest::get(url).await.map_err(|e| e.to_string())?; + let response = reqwest::Client::new().get(url).header(reqwest::header::USER_AGENT, "Emerald-Launcher").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())); @@ -181,7 +181,18 @@ pub async fn download_and_install( } } - #[cfg(not(target_os = "linux"))] + #[cfg(target_os = "android")] + { + let status = std::process::Command::new("unzip") + .args(["-o", zip_path.to_str().unwrap(), "-d", instance_dir.to_str().unwrap()]) + .status() + .map_err(|e| e.to_string())?; + if !status.success() { + return Err("Extraction failed".into()); + } + } + + #[cfg(all(not(target_os = "linux"), not(target_os = "android")))] { let mut cmd = std::process::Command::new("tar"); cmd.args(["-xf", zip_path.to_str().unwrap(), "-C", instance_dir.to_str().unwrap()]); @@ -291,6 +302,7 @@ pub async fn check_game_update( let response = reqwest::Client::new() .head(&url) + .header(reqwest::header::USER_AGENT, "Emerald-Launcher") .send() .await .map_err(|e| e.to_string())?; diff --git a/src-tauri/src/commands/file_dialogs.rs b/src-tauri/src/commands/file_dialogs.rs index cff7e80..80c5ece 100644 --- a/src-tauri/src/commands/file_dialogs.rs +++ b/src-tauri/src/commands/file_dialogs.rs @@ -1,4 +1,5 @@ use std::fs; +#[cfg(desktop)] #[tauri::command] pub fn pick_folder() -> Result { let folder = rfd::FileDialog::new() @@ -12,6 +13,7 @@ pub fn pick_folder() -> Result { } } +#[cfg(desktop)] #[tauri::command] pub fn pick_file(title: String, filters: Vec) -> Result { let mut dialog = rfd::FileDialog::new().set_title(&title); @@ -26,6 +28,7 @@ pub fn pick_file(title: String, filters: Vec) -> Result } } +#[cfg(desktop)] #[tauri::command] pub fn save_file_dialog(title: String, filename: String, filters: Vec) -> Result { let mut dialog = rfd::FileDialog::new().set_title(&title).set_file_name(&filename); diff --git a/src-tauri/src/commands/game.rs b/src-tauri/src/commands/game.rs index a8d7b64..bb7dc56 100644 --- a/src-tauri/src/commands/game.rs +++ b/src-tauri/src/commands/game.rs @@ -24,6 +24,52 @@ use crate::workshop_server; #[tauri::command] #[allow(non_snake_case)] pub async fn launch_game( + app: AppHandle, + state: State<'_, GameState>, + instance_id: String, + servers: Vec, + mut extra_args: Vec, +) -> Result<(), String> { + extra_args.extend(load_instance_args(&app, &instance_id)); + #[cfg(target_os = "android")] + { + let _ = state; + let mut servers = servers; + let working_dir = util::get_instance_working_dir(&app, &instance_id); + if !working_dir.join("Minecraft.Client.exe").exists() { + return Err("Game executable not found in instance folder.".into()); + } + + let config_val = config::load_config_raw(app.clone()); + let lce_online = McServer { name: "LCEOnline Game".into(), ip: "127.0.0.1".into(), port: 61000 }; + if !servers.iter().any(|s| s.ip == lce_online.ip && s.port == lce_online.port) { + servers.push(lce_online); + } + if let Some(ref saved) = config_val.saved_servers { + for s in saved { + if !servers.iter().any(|existing| existing.ip == s.ip && existing.port == s.port) { + servers.push(s.clone()); + } + } + } + ensure_server_list(&working_dir, servers); + + let result = crate::android_runtime::launch_bridge( + working_dir.to_string_lossy().to_string(), + crate::android_runtime::BridgeAction::Play, + extra_args, + ); + if result.is_ok() { + playtime::start_session(&app, &instance_id); + } + result + } + #[cfg(not(target_os = "android"))] + launch_game_desktop(app, state, instance_id, servers, extra_args).await +} + +#[cfg(not(target_os = "android"))] +async fn launch_game_desktop( app: AppHandle, state: State<'_, GameState>, instance_id: String, @@ -155,9 +201,15 @@ pub async fn launch_game( let gptk_no_hud = macos::find_executable_recursive(&toolkit_dir, "gameportingtoolkit-no-hud") .or_else(|| macos::find_executable_recursive(&toolkit_dir, "gameportingtoolkit")); - let wine_binary = macos::find_executable_recursive(&toolkit_dir, "wine64") - .or_else(|| macos::find_executable_recursive(&toolkit_dir, "wine")) - .ok_or_else(|| "Unable to locate wine binary inside runtime.".to_string())?; + let is_intel = std::env::consts::ARCH == "x86_64"; + let wine_binary = if is_intel { + macos::find_executable_recursive(&toolkit_dir, "wine") + .or_else(|| macos::find_executable_recursive(&toolkit_dir, "wine64")) + } else { + macos::find_executable_recursive(&toolkit_dir, "wine64") + .or_else(|| macos::find_executable_recursive(&toolkit_dir, "wine")) + } + .ok_or_else(|| "Unable to locate wine binary inside runtime.".to_string())?; let wine_bin_dir = wine_binary .parent() @@ -231,7 +283,11 @@ pub async fn launch_game( return handle_game_exit(&app, &state, result); } - #[cfg(all(not(target_os = "macos"), not(target_os = "linux")))] + #[cfg(all( + not(target_os = "macos"), + not(target_os = "linux"), + not(target_os = "android") + ))] { let exe_str = game_exe.to_string_lossy().to_string(); let all_args: Vec = extra_args.clone(); @@ -291,8 +347,39 @@ pub fn check_game_installed(app: AppHandle, instance_id: String) -> bool { #[allow(non_snake_case)] pub fn open_instance_folder(app: AppHandle, instance_id: String) { let folder = util::get_instance_working_dir(&app, &instance_id); - if folder.exists() { - let _ = app.opener().open_path(folder.to_str().unwrap(), None::<&str>); + #[cfg(target_os = "android")] + { + let _ = std::fs::create_dir_all(&folder); + let _ = crate::android_runtime::launch_bridge( + folder.to_string_lossy().to_string(), + crate::android_runtime::BridgeAction::OpenContainer, + Vec::new(), + ); + } + #[cfg(not(target_os = "android"))] + { + if folder.exists() { + let _ = app.opener().open_path(folder.to_str().unwrap(), None::<&str>); + } + } +} + +#[tauri::command] +#[allow(non_snake_case)] +pub fn open_container_settings(app: AppHandle, instance_id: String) -> Result<(), String> { + #[cfg(target_os = "android")] + { + let folder = util::get_instance_working_dir(&app, &instance_id); + crate::android_runtime::launch_bridge( + folder.to_string_lossy().to_string(), + crate::android_runtime::BridgeAction::OpenSettings, + Vec::new(), + ) + } + #[cfg(not(target_os = "android"))] + { + let _ = (app, instance_id); + Err("Only supported on Android".into()) } } @@ -331,6 +418,21 @@ pub fn get_instance_path(app: AppHandle, instance_id: String) -> String { .to_string() } +fn load_instance_args(app: &AppHandle, instance_id: &str) -> Vec { + let config_val = config::load_config_raw(app.clone()); + config_val + .instance_launch_args + .and_then(|m| m.get(instance_id).cloned()) + .map(|entry| entry.args) + .unwrap_or_default() +} + +#[tauri::command] +pub fn get_instance_args_schema(app: AppHandle, instance_id: String) -> Option { + let dir = util::get_instance_working_dir(&app, &instance_id); + fs::read_to_string(dir.join("Arguments.Schema.json")).ok() +} + #[tauri::command] pub fn get_playtime(app: AppHandle, instance_id: String) -> PlaytimeResponse { playtime::get_playtime(&app, &instance_id) @@ -341,6 +443,7 @@ pub fn get_playtime_daily(app: AppHandle, instance_id: String, days: u64) -> Vec playtime::get_playtime_daily(&app, &instance_id, days) } +#[cfg(desktop)] #[tauri::command] pub fn backup_instance(app: AppHandle, instance_id: String) -> Result<(), String> { let instance_dir = util::get_instance_working_dir(&app, &instance_id); @@ -374,6 +477,7 @@ pub fn backup_instance(app: AppHandle, instance_id: String) -> Result<(), String } } +#[cfg(desktop)] #[tauri::command] pub fn restore_instance(app: AppHandle) -> Result { let file = rfd::FileDialog::new() @@ -568,6 +672,7 @@ fn apply_launch_env_vars(cmd: &mut tokio::process::Command, config: &AppConfig) const MAX_LOG_BYTES: usize = 1024 * 1024; struct GameRunResult { log: String, + exit_code: i32, } fn spawn_log_reader(mut reader: R, log: Arc>>) -> tokio::task::JoinHandle<()> where R: AsyncRead + Unpin + Send + 'static, { @@ -607,11 +712,13 @@ async fn run_game_and_capture( let mut lock = state.child.lock().await; *lock = Some(child); } + let exit_code; loop { { let mut lock = state.child.lock().await; if let Some(ref mut c) = *lock { - if c.try_wait().map_err(|e| e.to_string())?.is_some() { + if let Some(status) = c.try_wait().map_err(|e| e.to_string())? { + exit_code = status.code().unwrap_or(1); break; } } else { @@ -629,14 +736,21 @@ async fn run_game_and_capture( } let bytes = log.lock().await.clone(); let log_str = String::from_utf8_lossy(&bytes).to_string(); - Ok(Some(GameRunResult { log: log_str })) + Ok(Some(GameRunResult { log: log_str, exit_code })) } -fn game_exited_ok(log: &str) -> bool { - log.lines() - .rev() - .take(3) - .any(|line| line.contains("AppPolicyGetProcessTerminationMethod")) +fn game_exited_ok(result: &GameRunResult) -> bool { + #[cfg(not(target_os = "windows"))] + { + result.log.lines() + .rev() + .take(3) + .any(|line| line.contains("AppPolicyGetProcessTerminationMethod")) + } + #[cfg(target_os = "windows")] + { + result.exit_code == 0 + } } fn handle_game_exit( @@ -644,7 +758,7 @@ fn handle_game_exit( state: &State<'_, GameState>, result: GameRunResult, ) -> Result<(), String> { - if state.manual_stop.swap(false, Ordering::SeqCst) || game_exited_ok(&result.log) { + if state.manual_stop.swap(false, Ordering::SeqCst) || game_exited_ok(&result) { return Ok(()); } let _ = app.emit("game-log", result.log); @@ -669,3 +783,63 @@ async fn perform_instance_sync(app: &AppHandle, instance_id: &str) -> Result<(), perform_dlc_sync(app, &target_dir)?; Ok(()) } + +#[tauri::command] +#[cfg(target_os = "android")] +pub fn switch_proton(app: AppHandle, version: String) -> Result<(), String> { + let instance_id = { + let config_val = config::load_config_raw(app.clone()); + config_val.profile.unwrap_or_else(|| "legacy_evolved".into()) + }; + crate::android_runtime::launch_bridge( + instance_id, + crate::android_runtime::BridgeAction::SwitchProton, + vec![version], + ) +} + +#[tauri::command] +#[cfg(not(target_os = "android"))] +pub fn switch_proton(_app: AppHandle, _version: String) -> Result<(), String> { + Err("Only supported on Android".into()) +} + +#[tauri::command] +#[cfg(target_os = "android")] +pub fn install_latest_driver(app: AppHandle) -> Result<(), String> { + let instance_id = { + let config_val = config::load_config_raw(app.clone()); + config_val.profile.unwrap_or_else(|| "legacy_evolved".into()) + }; + crate::android_runtime::launch_bridge( + instance_id, + crate::android_runtime::BridgeAction::InstallDriver, + Vec::new(), + ) +} + +#[tauri::command] +#[cfg(not(target_os = "android"))] +pub fn install_latest_driver(_app: AppHandle) -> Result<(), String> { + Err("Only supported on Android".into()) +} + +#[tauri::command] +#[cfg(target_os = "android")] +pub fn set_audio_backend(app: AppHandle, backend: String) -> Result<(), String> { + let instance_id = { + let config_val = config::load_config_raw(app.clone()); + config_val.profile.unwrap_or_else(|| "legacy_evolved".into()) + }; + crate::android_runtime::launch_bridge( + instance_id, + crate::android_runtime::BridgeAction::SetAudioBackend, + vec![backend], + ) +} + +#[tauri::command] +#[cfg(not(target_os = "android"))] +pub fn set_audio_backend(_app: AppHandle, _backend: String) -> Result<(), String> { + Err("Only supported on Android".into()) +} diff --git a/src-tauri/src/commands/java2lce/block_mapping.rs b/src-tauri/src/commands/java2lce/block_mapping.rs index cee48fc..a1a69db 100644 --- a/src-tauri/src/commands/java2lce/block_mapping.rs +++ b/src-tauri/src/commands/java2lce/block_mapping.rs @@ -1285,6 +1285,7 @@ static MODERN_DIRECT_MAP: once_cell::sync::Lazy, + pub tags: Vec<(String, NbtValue)>, //neo: if you came here asking about this, dont. please. dont ask why im storing a tuple in a vec. or why im manually resizing a vec. } impl NbtCompound { diff --git a/src-tauri/src/commands/macos_setup.rs b/src-tauri/src/commands/macos_setup.rs index 537de7e..1dee3e3 100644 --- a/src-tauri/src/commands/macos_setup.rs +++ b/src-tauri/src/commands/macos_setup.rs @@ -1,3 +1,4 @@ +//neo: thanks Huckle for the Intel macOS support reference code lol use tauri::AppHandle; #[cfg(target_os = "macos")] use std::io::Write; @@ -55,6 +56,14 @@ pub async fn setup_macos_runtime(window: tauri::Window, app: AppHandle) -> Resul assets: Vec, } + let is_intel = std::env::consts::ARCH == "x86_64"; + + let repo = if is_intel { + "Gcenx/macOS_Wine_builds" + } else { + "Gcenx/game-porting-toolkit" + }; + macos::emit_macos_setup_progress( &window, "resolving", @@ -64,7 +73,7 @@ pub async fn setup_macos_runtime(window: tauri::Window, app: AppHandle) -> Resul let client = reqwest::Client::new(); let release_text = client - .get("https://api.github.com/repos/Gcenx/game-porting-toolkit/releases/latest") + .get(format!("https://api.github.com/repos/{}/releases/latest", repo)) .header("User-Agent", "Emerald-Legacy-Launcher") .send() .await @@ -80,7 +89,15 @@ pub async fn setup_macos_runtime(window: tauri::Window, app: AppHandle) -> Resul let asset = release .assets .iter() - .find(|a| a.name.ends_with(".tar.xz") || a.name.ends_with(".tar.gz")) + .find(|a| { + let is_archive = + a.name.ends_with(".tar.xz") || a.name.ends_with(".tar.gz"); + if is_intel { + is_archive && a.name.contains("staging") + } else { + is_archive + } + }) .ok_or_else(|| "No compatible runtime asset found in latest release.".to_string())?; let runtime_dir = macos::get_macos_runtime_dir(&app); @@ -171,9 +188,14 @@ pub async fn setup_macos_runtime(window: tauri::Window, app: AppHandle) -> Resul } fs::create_dir_all(&prefix_dir).map_err(|e| e.to_string())?; - let wine_binary = macos::find_executable_recursive(&toolkit_dir, "wine64") - .or_else(|| macos::find_executable_recursive(&toolkit_dir, "wine")) - .ok_or_else(|| "Unable to locate wine binary inside runtime.".to_string())?; + let wine_binary = if is_intel { + macos::find_executable_recursive(&toolkit_dir, "wine") + .or_else(|| macos::find_executable_recursive(&toolkit_dir, "wine64")) + } else { + macos::find_executable_recursive(&toolkit_dir, "wine64") + .or_else(|| macos::find_executable_recursive(&toolkit_dir, "wine")) + } + .ok_or_else(|| "Unable to locate wine binary inside runtime.".to_string())?; let wine_bin_dir = wine_binary .parent() @@ -213,6 +235,181 @@ pub async fn setup_macos_runtime(window: tauri::Window, app: AppHandle) -> Resul return Err("Wine prefix initialization failed".into()); } + if is_intel { + macos::emit_macos_setup_progress( + &window, + "dxvk", + "Fetching latest DXVK…".into(), + None, + ); + + #[derive(Deserialize)] + struct DxvkAsset { + name: String, + browser_download_url: String, + } + + #[derive(Deserialize)] + struct DxvkRelease { + assets: Vec, + } + + let dxvk_release_text = client + .get("https://api.github.com/repos/Gcenx/DXVK-macOS/releases/latest") + .header("User-Agent", "Emerald-Legacy-Launcher") + .send() + .await + .map_err(|e| e.to_string())? + .error_for_status() + .map_err(|e| e.to_string())? + .text() + .await + .map_err(|e| e.to_string())?; + + let dxvk_release: DxvkRelease = + serde_json::from_str(&dxvk_release_text).map_err(|e| e.to_string())?; + let dxvk_asset = dxvk_release + .assets + .iter() + .find(|a| { + let is_archive = + a.name.ends_with(".tar.xz") || a.name.ends_with(".tar.gz"); + is_archive && !a.name.contains("builtin") + }) + .ok_or_else(|| "No compatible DXVK asset found.".to_string())?; + + macos::emit_macos_setup_progress( + &window, + "dxvk_downloading", + "Downloading DXVK…".into(), + Some(0.0), + ); + + let dxvk_archive_path = runtime_dir.join(format!("dxvk_{}", dxvk_asset.name)); + let dxvk_response = client + .get(&dxvk_asset.browser_download_url) + .header("User-Agent", "Emerald-Legacy-Launcher") + .send() + .await + .map_err(|e| e.to_string())? + .error_for_status() + .map_err(|e| e.to_string())?; + + let dxvk_total = dxvk_response.content_length().unwrap_or(0) as f64; + let mut dxvk_file = fs::File::create(&dxvk_archive_path).map_err(|e| e.to_string())?; + let mut dxvk_downloaded = 0.0; + let mut dxvk_last_percent: i64 = -1; + let mut dxvk_stream = dxvk_response.bytes_stream(); + while let Some(chunk) = dxvk_stream.next().await { + let chunk = chunk.map_err(|e| e.to_string())?; + dxvk_file.write_all(&chunk).map_err(|e| e.to_string())?; + dxvk_downloaded += chunk.len() as f64; + if dxvk_total > 0.0 { + let percent = (dxvk_downloaded / dxvk_total * 100.0).clamp(0.0, 100.0); + let rounded = percent.floor() as i64; + if rounded != dxvk_last_percent { + dxvk_last_percent = rounded; + macos::emit_macos_setup_progress( + &window, + "dxvk_downloading", + format!("Downloading DXVK… {}%", rounded), + Some(percent), + ); + } + } + } + drop(dxvk_file); + macos::emit_macos_setup_progress( + &window, + "dxvk_extracting", + "Extracting DXVK…".into(), + None, + ); + + let dxvk_dir = runtime_dir.join("dxvk"); + fs::create_dir_all(&dxvk_dir).map_err(|e| e.to_string())?; + let status = Command::new("tar") + .args([ + "-xf", + dxvk_archive_path + .to_str() + .ok_or_else(|| "Invalid DXVK archive path".to_string())?, + "-C", + dxvk_dir + .to_str() + .ok_or_else(|| "Invalid DXVK extraction path".to_string())?, + ]) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::piped()) + .status() + .map_err(|e| e.to_string())?; + let _ = fs::remove_file(&dxvk_archive_path); + if !status.success() { + return Err(format!("DXVK extraction failed with status: {:?}", status)); + } + + macos::emit_macos_setup_progress( + &window, + "dxvk_patching", + "Patching runtime with DXVK…".into(), + None, + ); + + let system32 = prefix_dir.join("drive_c/windows/system32"); + let syswow64 = prefix_dir.join("drive_c/windows/syswow64"); + let _ = fs::create_dir_all(&system32); + let _ = fs::create_dir_all(&syswow64); + let copy_dir = |src_dir: &std::path::Path, dst_dir: &std::path::Path| -> Result<(), String> { + if !src_dir.exists() { + return Ok(()); + } + for entry in fs::read_dir(src_dir).map_err(|e| e.to_string())? { + let entry = entry.map_err(|e| e.to_string())?; + let path = entry.path(); + if path.extension().and_then(|e| e.to_str()) == Some("dll") { + let dest = dst_dir.join(path.file_name().unwrap()); + fs::copy(&path, &dest).map_err(|e| e.to_string())?; + } + } + Ok(()) + }; + + let dxvk_x64 = dxvk_dir.join("x64"); + let dxvk_x32 = dxvk_dir.join("x32"); + copy_dir(&dxvk_x64, &system32)?; + copy_dir(&dxvk_x32, &syswow64)?; + for dll_name in &["d3d11", "dxgi"] { + let mut reg_cmd = Command::new(&wine_binary); + reg_cmd + .args(["reg", "add", "HKCU\\Software\\Wine\\DllOverrides"]) + .arg("/v") + .arg(dll_name) + .arg("/t") + .arg("REG_SZ") + .arg("/d") + .arg("native,builtin") + .arg("/f"); + reg_cmd + .env("WINEPREFIX", &prefix_dir) + .env("WINEDEBUG", "-all") + .env( + "PATH", + format!( + "{}:{}", + wine_bin_dir.to_string_lossy(), + std::env::var("PATH").unwrap_or_default() + ), + ) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + let _ = reg_cmd.status(); + } + + let _ = fs::remove_dir_all(&dxvk_dir); + } + macos::emit_macos_setup_progress(&window, "done", "Setup complete.".into(), Some(100.0)); Ok(()) } diff --git a/src-tauri/src/commands/steam.rs b/src-tauri/src/commands/steam.rs index 6d52494..54e88a3 100644 --- a/src-tauri/src/commands/steam.rs +++ b/src-tauri/src/commands/steam.rs @@ -15,7 +15,7 @@ pub async fn add_to_steam( let (exe_str, launch_options, start_dir) = if in_flatpak { ( "/usr/bin/flatpak".to_string(), - format!("run io.github.Emerald_Legacy_Launcher.Emerald_Legacy_Launcher \"{}\"", instance_id), + format!("run io.github.Emerald_Legacy_Launcher.Emerald_Legacy_Launcher \"{}\"", instance_id), //neo: yes i'm hardcoding it lmfao std::env::var("HOME").unwrap_or_default(), ) } else { diff --git a/src-tauri/src/commands/workshop.rs b/src-tauri/src/commands/workshop.rs index af37514..7eb8b6d 100644 --- a/src-tauri/src/commands/workshop.rs +++ b/src-tauri/src/commands/workshop.rs @@ -152,6 +152,32 @@ fn extract_archive(zip_tmp: &Path, dest_dir: &Path) -> Result<(Vec, bool 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() + .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); + let files: Vec = 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())?; + Ok((files, st.success())) } else { let st = std::process::Command::new("tar") .args(["-xf", zip_str, "-C", dest_dir.to_str().unwrap()]) diff --git a/src-tauri/src/config.rs b/src-tauri/src/config.rs index 160a726..28bd1fb 100644 --- a/src-tauri/src/config.rs +++ b/src-tauri/src/config.rs @@ -37,6 +37,9 @@ pub fn load_config_raw(app: AppHandle) -> AppConfig { extra_launch_args: None, launch_prefix: None, launch_env_vars: None, + instance_launch_args: None, + android_runner: None, + android_audio_backend: None, } } diff --git a/src-tauri/src/lce_auth.rs b/src-tauri/src/lce_auth.rs new file mode 100644 index 0000000..bc23995 --- /dev/null +++ b/src-tauri/src/lce_auth.rs @@ -0,0 +1,35 @@ +use tauri::plugin::{Builder, PluginHandle, TauriPlugin}; +use tauri::Wry; +pub struct LceAuthState(pub PluginHandle); +pub fn init() -> TauriPlugin { + Builder::new("emerald-lce-auth") + .setup(|app, api| { + #[cfg(target_os = "android")] + { + use tauri::Manager; + let handle = api.register_android_plugin("com.emerald.legacy", "LceAuthPlugin")?; + app.manage(LceAuthState(handle)); + } + Ok(()) + }) + .build() +} + +#[tauri::command] +pub async fn start_lce_auth(app: tauri::AppHandle) -> Result { + #[cfg(target_os = "android")] + { + use tauri::Manager; + let state = app.state::(); + state + .0 + .run_mobile_plugin_async("startAuth", ()) + .await + .map_err(|e| e.to_string()) + } + #[cfg(not(target_os = "android"))] + { + let _ = app; + Err("LCE Online auth is only supported on Android".into()) //neo: erm, rust-side only. its supported on desktop on the typescript side. + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 5887f2b..4008f46 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,3 +1,4 @@ +#![cfg_attr(target_os = "android", allow(dead_code, unused))] mod types; mod state; mod config; @@ -6,6 +7,9 @@ mod playtime; mod platform; mod networking; mod workshop_server; +mod lce_auth; +#[cfg(target_os = "android")] +mod android_runtime; mod commands; use std::collections::HashMap; use std::sync::Arc; @@ -30,25 +34,34 @@ use state::{DownloadState, GameState, ProxyGuard}; fn webview_deep_link_interceptor() -> impl tauri::plugin::Plugin { tauri::plugin::Builder::::new("emerald-deep-link-interceptor").on_navigation(|webview, url| { if url.scheme() == "emerald" || url.scheme() == "emeraldlauncher" { let _ = webview.app_handle().emit("deep-link", vec![url.to_string()]); false } else { true }}).build()} #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { - tauri::Builder::default() + let mut builder = tauri::Builder::default() .plugin(tauri_plugin_deep_link::init()) .plugin(webview_deep_link_interceptor()) - .plugin(tauri_plugin_single_instance::init(|app, args, _cwd| { - let urls: Vec = args - .iter() - .filter(|a| { - a.starts_with("emerald://") - || a.starts_with("emeraldlauncher://") - || a.starts_with("discord-1482504445152460871://") - }) - .cloned() - .collect(); - if !urls.is_empty() { - let _ = app.emit("deep-link", urls); - } - })) + .plugin(lce_auth::init()) .plugin(tauri_plugin_process::init()) - .plugin(tauri_plugin_updater::Builder::new().build()) + .plugin(tauri_plugin_gamepad::init()) + .plugin(tauri_plugin_opener::init()); + #[cfg(desktop)] + { + builder = builder + .plugin(tauri_plugin_single_instance::init(|app, args, _cwd| { + let urls: Vec = args + .iter() + .filter(|a| { + a.starts_with("emerald://") + || a.starts_with("emeraldlauncher://") + || a.starts_with("discord-1482504445152460871://") + }) + .cloned() + .collect(); + if !urls.is_empty() { + let _ = app.emit("deep-link", urls); + } + })) + .plugin(tauri_plugin_updater::Builder::new().build()) + .plugin(tauri_plugin_drpc::init()); + } + builder .manage(DownloadState { tokens: Arc::new(Mutex::new(HashMap::new())), }) @@ -61,9 +74,6 @@ pub fn run() { cancel_tokens: Arc::new(Mutex::new(HashMap::new())), local_port: Arc::new(Mutex::new(None)), }) - .plugin(tauri_plugin_gamepad::init()) - .plugin(tauri_plugin_opener::init()) - .plugin(tauri_plugin_drpc::init()) .invoke_handler(tauri::generate_handler![ macos_setup::setup_macos_runtime, dlc::list_git_directory, @@ -75,12 +85,17 @@ pub fn run() { config_cmds::load_config, download::download_and_install, game::open_instance_folder, + game::open_container_settings, download::cancel_download, runners::get_available_runners, config_cmds::get_external_palettes, + #[cfg(desktop)] config_cmds::import_theme, + #[cfg(desktop)] config_cmds::export_settings, + #[cfg(desktop)] config_cmds::import_settings, + #[cfg(desktop)] file_dialogs::pick_folder, download::download_runner, game::delete_instance, @@ -97,7 +112,9 @@ pub fn run() { macos_setup::check_macos_runtime_installed, macos_setup::check_macos_runtime_installed_fast, skin::download_logo, + #[cfg(desktop)] file_dialogs::pick_file, + #[cfg(desktop)] file_dialogs::save_file_dialog, file_dialogs::write_binary_file, file_dialogs::read_binary_file, @@ -107,7 +124,10 @@ pub fn run() { game::get_instance_path, game::get_playtime, game::get_playtime_daily, + game::get_instance_args_schema, + #[cfg(desktop)] game::backup_instance, + #[cfg(desktop)] game::restore_instance, commands::console2lce::import_world, commands::console2lce::import_lce_save, @@ -119,45 +139,67 @@ pub fn run() { relay::stop_proxy, relay::stop_all_proxies, relay::join_game, + lce_auth::start_lce_auth, plugins::get_plugins_dir, plugins::list_directory, plugins::create_plugin_dir, plugins::remove_plugin_dir, + game::switch_proton, + game::install_latest_driver, + game::set_audio_backend, ]) .setup(|app| { let app_handle = app.handle().clone(); - let config = config::load_config_raw(app_handle.clone()); - if config.start_fullscreen.unwrap_or(false) { - if let Some(window) = app_handle.get_webview_window("main") { - let _ = window.set_fullscreen(true); + #[cfg(desktop)] + { + let config = config::load_config_raw(app_handle.clone()); + if config.start_fullscreen.unwrap_or(false) { + if let Some(window) = app_handle.get_webview_window("main") { + let _ = window.set_fullscreen(true); + } } } - let args: Vec = std::env::args().collect(); - if args.len() > 1 && !args[1].starts_with('-') { - let first = &args[1]; - let is_deep_link = first.starts_with("emerald://") - || first.starts_with("emeraldlauncher://") - || first.starts_with("discord-1482504445152460871://"); - if !is_deep_link { - let instance_id = first.clone(); - let app_handle_clone = app.handle().clone(); - tauri::async_runtime::spawn(async move { - if let Some(window) = app_handle_clone.get_webview_window("main") { - let _ = window.hide(); - } - let state = app_handle_clone.state::(); - match game::launch_game(app_handle_clone.clone(), state, instance_id, Vec::new(), vec![]).await { - Ok(_) => app_handle_clone.exit(0), - Err(e) => { - let _ = app_handle_clone.emit("backend-error", format!("Auto-launch: {e}")); - eprintln!("Auto-launch error: {}", e); - app_handle_clone.exit(1); - } + #[cfg(target_os = "android")] + { + let handle = app_handle.clone(); + if let Some(window) = app_handle.get_webview_window("main") { + window.on_window_event(move |event| { + if let tauri::WindowEvent::Focused(true) = event { + playtime::finish_active_session(&handle); } }); } } + + #[cfg(desktop)] + { + let args: Vec = std::env::args().collect(); + if args.len() > 1 && !args[1].starts_with('-') { + let first = &args[1]; + let is_deep_link = first.starts_with("emerald://") + || first.starts_with("emeraldlauncher://") + || first.starts_with("discord-1482504445152460871://"); + if !is_deep_link { + let instance_id = first.clone(); + let app_handle_clone = app.handle().clone(); + tauri::async_runtime::spawn(async move { + if let Some(window) = app_handle_clone.get_webview_window("main") { + let _ = window.hide(); + } + let state = app_handle_clone.state::(); + match game::launch_game(app_handle_clone.clone(), state, instance_id, Vec::new(), vec![]).await { + Ok(_) => app_handle_clone.exit(0), + Err(e) => { + let _ = app_handle_clone.emit("backend-error", format!("Auto-launch: {e}")); + eprintln!("Auto-launch error: {}", e); + app_handle_clone.exit(1); + } + } + }); + } + } + } Ok(()) }) .run(tauri::generate_context!()) diff --git a/src-tauri/src/platform/macos.rs b/src-tauri/src/platform/macos.rs index 8a3ff64..c97ab93 100644 --- a/src-tauri/src/platform/macos.rs +++ b/src-tauri/src/platform/macos.rs @@ -50,6 +50,9 @@ pub fn is_macos_runtime_installed(app: &AppHandle) -> bool { if !toolkit_dir.exists() { return false; } - let candidates = ["Game Porting Toolkit.app"]; - candidates.iter().any(|name| find_executable_recursive(&toolkit_dir, name).is_some()) + if find_executable_recursive(&toolkit_dir, "Game Porting Toolkit.app").is_some() { + return true; + } + find_executable_recursive(&toolkit_dir, "wine").is_some() + || find_executable_recursive(&toolkit_dir, "wine64").is_some() } diff --git a/src-tauri/src/playtime.rs b/src-tauri/src/playtime.rs index a9ae036..957b09a 100644 --- a/src-tauri/src/playtime.rs +++ b/src-tauri/src/playtime.rs @@ -15,6 +15,12 @@ pub struct PlaytimeData { pub sessions: HashMap>, } +#[derive(Serialize, Deserialize)] +struct ActiveSession { + instance_id: String, + start: u64, +} + #[derive(Serialize, Clone, Debug)] #[serde(rename_all = "camelCase")] pub struct PlaytimeResponse { @@ -54,6 +60,34 @@ pub fn record_session(app: &AppHandle, instance_id: &str, start: u64, end: u64) save(app, &data); } +const MAX_SESSION_SECONDS: u64 = 12 * 60 * 60; +fn active_session_path(app: &AppHandle) -> PathBuf { + util::get_app_dir(app).join("active_session.json") +} + +pub fn start_session(app: &AppHandle, instance_id: &str) { + let start = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs(); + let active = ActiveSession { instance_id: instance_id.to_string(), start }; + let path = active_session_path(app); + if let Some(parent) = path.parent() { + let _ = std::fs::create_dir_all(parent); + } + if let Ok(content) = serde_json::to_string(&active) { + let _ = std::fs::write(&path, content); + } +} + +pub fn finish_active_session(app: &AppHandle) { + let path = active_session_path(app); + let Ok(content) = std::fs::read_to_string(&path) else { return }; + let _ = std::fs::remove_file(&path); + let Ok(active) = serde_json::from_str::(&content) else { return }; + let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs(); + if now > active.start && now - active.start <= MAX_SESSION_SECONDS { + record_session(app, &active.instance_id, active.start, now); + } +} + pub fn get_playtime(app: &AppHandle, instance_id: &str) -> PlaytimeResponse { let data = load(app); let sessions = data.sessions.get(instance_id); diff --git a/src-tauri/src/types.rs b/src-tauri/src/types.rs index f51e828..e877357 100644 --- a/src-tauri/src/types.rs +++ b/src-tauri/src/types.rs @@ -32,6 +32,13 @@ pub struct CustomizationEntry { pub panorama: Option, } +#[derive(Serialize, Deserialize, Clone, Debug)] +#[serde(rename_all = "camelCase")] +pub struct InstanceLaunchArgs { + pub values: std::collections::HashMap, + pub args: Vec, +} + #[derive(Serialize, Deserialize, Clone, Debug)] #[serde(rename_all = "camelCase")] pub struct AppConfig { @@ -59,6 +66,9 @@ pub struct AppConfig { pub extra_launch_args: Option>, pub launch_prefix: Option, pub launch_env_vars: Option>, + pub instance_launch_args: Option>, + pub android_runner: Option, + pub android_audio_backend: Option, } #[derive(Serialize, Deserialize, Clone, Debug)] diff --git a/src-tauri/src/workshop_server.rs b/src-tauri/src/workshop_server.rs index c381c53..d1fa70b 100644 --- a/src-tauri/src/workshop_server.rs +++ b/src-tauri/src/workshop_server.rs @@ -3,7 +3,7 @@ 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"; +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, @@ -34,7 +34,7 @@ pub async fn start(app: AppHandle) -> CancellationToken { } async fn serve(app: AppHandle, cancel: CancellationToken) { - let listener = match TcpListener::bind("127.0.0.1:5582").await { + let listener = match TcpListener::bind("127.0.0.1:5582").await { //neo: dont say a word about this weird port. Ok(l) => l, Err(e) => { let _ = app.emit("backend-error", format!("Workshop server failed to bind: {e}")); diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 92cd3bb..473e97c 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -10,6 +10,15 @@ "frontendDist": "../dist" }, "app": { + "security": { + "assetProtocol": { + "enable": true, + "scope": { + "allow": ["**"], + "requireLiteralLeadingDot": false + } + } + }, "windows": [ { "label": "main", @@ -65,6 +74,10 @@ "frameworks": [], "minimumSystemVersion": "10.15", "signingIdentity": null + }, + "android": { + "minSdkVersion": 26, + "versionCode": 1006000 } } } diff --git a/src/components/common/CapePreview.tsx b/src/components/common/CapePreview.tsx index 9b57f99..a383fbe 100644 --- a/src/components/common/CapePreview.tsx +++ b/src/components/common/CapePreview.tsx @@ -144,13 +144,37 @@ const CapePreview = memo(function CapePreview({ render(); } }; + const onTouchStart = (e: TouchEvent) => { + const t = e.touches[0]; + if (!t) return; + isDragging = true; + previousMousePosition = { x: t.clientX, y: t.clientY }; + }; + const onTouchMove = (e: TouchEvent) => { + const t = e.touches[0]; + if (isDragging && groupRef.current && t) { + e.preventDefault(); + groupRef.current.rotation.y += + (t.clientX - previousMousePosition.x) * 0.01; + previousMousePosition = { x: t.clientX, y: t.clientY }; + render(); + } + }; + const onTouchEnd = () => { + isDragging = false; + }; 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); return () => { active = false; window.removeEventListener("mousemove", onMouseMove); window.removeEventListener("mouseup", onMouseUp); + window.removeEventListener("touchmove", onTouchMove); + window.removeEventListener("touchend", onTouchEnd); scene.traverse((object) => { if (object instanceof THREE.Mesh) { if (object.geometry) object.geometry.dispose(); diff --git a/src/components/common/ScreenshotImage.tsx b/src/components/common/ScreenshotImage.tsx index 087bcb4..ed74f76 100644 --- a/src/components/common/ScreenshotImage.tsx +++ b/src/components/common/ScreenshotImage.tsx @@ -1,5 +1,5 @@ -import { useState, useEffect, useRef } from "react"; -import { TauriService } from "../../services/TauriService"; +import { useState } from "react"; +import { convertFileSrc } from "@tauri-apps/api/core"; interface ScreenshotImageProps { path: string; className?: string; @@ -9,50 +9,6 @@ interface ScreenshotImageProps { fallbackSrc?: string; } -const imgCache = new Map(); -let activeLoads = 0; -const MAX_CONCURRENT = 4; -const loadQueue: Array<() => void> = []; -function dequeue() { - while (activeLoads < MAX_CONCURRENT && loadQueue.length > 0) { - const next = loadQueue.shift()!; - activeLoads++; - next(); - } -} - -function enqueueLoad( - path: string, - onLoad: (url: string) => void, - onError: () => void, -) { - const cached = imgCache.get(path); - if (cached) { - onLoad(cached); - return; - } - - const run = () => { - TauriService.readScreenshotAsDataUrl(path) - .then((url) => { - imgCache.set(path, url); - onLoad(url); - }) - .catch(() => onError()) - .finally(() => { - activeLoads--; - dequeue(); - }); - }; - - if (activeLoads < MAX_CONCURRENT) { - activeLoads++; - run(); - } else { - loadQueue.push(run); - } -} - export function ScreenshotImage({ path, className, @@ -61,62 +17,18 @@ export function ScreenshotImage({ style, fallbackSrc, }: ScreenshotImageProps) { - const [src, setSrc] = useState(fallbackSrc); - const imgRef = useRef(null); - const loadedRef = useRef(false); - useEffect(() => { - const el = imgRef.current?.parentElement || imgRef.current; - if (!el) return; - let cancelled = false; - const doLoad = () => { - if (loadedRef.current) return; - loadedRef.current = true; - enqueueLoad( - path, - (url) => { - if (!cancelled) setSrc(url); - }, - () => { - if (!cancelled && fallbackSrc) setSrc(fallbackSrc); - }, - ); - }; - - if (loading === "eager") { - doLoad(); - return () => { - cancelled = true; - }; - } - - const observer = new IntersectionObserver( - ([entry]) => { - if (entry.isIntersecting) { - observer.disconnect(); - doLoad(); - } - }, - { rootMargin: "800px" }, - ); - observer.observe(el); - return () => { - cancelled = true; - observer.disconnect(); - }; - }, [path, fallbackSrc, loading]); - const handleError = () => { - if (fallbackSrc) setSrc(fallbackSrc); - }; + const [hasError, setHasError] = useState(false); return ( {alt} { + if (fallbackSrc) setHasError(true); + }} /> ); } diff --git a/src/components/common/SkinViewer.tsx b/src/components/common/SkinViewer.tsx index a2491c6..e3b409f 100644 --- a/src/components/common/SkinViewer.tsx +++ b/src/components/common/SkinViewer.tsx @@ -16,6 +16,7 @@ interface SkinViewerProps { onNavigateRight: () => void; hideControls?: boolean; style?: React.CSSProperties; + slim?: boolean; } const SkinViewer = memo(function SkinViewer({ @@ -30,6 +31,7 @@ const SkinViewer = memo(function SkinViewer({ onNavigateRight, hideControls, style, + slim, }: SkinViewerProps) { const mountRef = useRef(null); const containerRef = useRef(null); @@ -181,17 +183,19 @@ const SkinViewer = memo(function SkinViewer({ }); const isSlim = - !isLegacy && - (() => { - const canvas = document.createElement("canvas"); - canvas.width = img.width; - canvas.height = img.height; - const ctx = canvas.getContext("2d"); - if (!ctx) return false; - ctx.drawImage(img, 0, 0); - const data = ctx.getImageData(42, 48, 1, 1).data; - return data[3] === 0; - })(); + slim !== undefined + ? slim + : !isLegacy && + (() => { + const canvas = document.createElement("canvas"); + canvas.width = img.width; + canvas.height = img.height; + const ctx = canvas.getContext("2d"); + if (!ctx) return false; + ctx.drawImage(img, 0, 0); + const data = ctx.getImageData(42, 48, 1, 1).data; + return data[3] === 0; + })(); const armW = isSlim ? 3 : 4; const headUv = { top: [8, 0, 8, 8], @@ -379,7 +383,7 @@ const SkinViewer = memo(function SkinViewer({ } const name = username.toLowerCase(); - playerGroup.rotation.y = -0.3; + playerGroup.rotation.y = 0.3; if (name === "dinnerbone" || name === "grumm") { playerGroup.scale.y = -1; playerGroup.position.y = 1.5; @@ -404,15 +408,39 @@ const SkinViewer = memo(function SkinViewer({ requestRenderRef.current?.(); } }; + const onTouchStart = (e: TouchEvent) => { + const t = e.touches[0]; + if (!t) return; + isDragging = true; + previousMousePosition = { x: t.clientX, y: t.clientY }; + }; + const onTouchMove = (e: TouchEvent) => { + const t = e.touches[0]; + if (isDragging && t) { + e.preventDefault(); + const delta = (t.clientX - previousMousePosition.x) * 0.01; + playerGroup.rotation.y += delta; + previousMousePosition = { x: t.clientX, y: t.clientY }; + requestRenderRef.current?.(); + } + }; + const onTouchEnd = () => { + isDragging = false; + }; requestRenderRef.current = () => renderer.render(scene, camera); requestRenderRef.current(); 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); return () => { window.removeEventListener("mousemove", onMouseMove); window.removeEventListener("mouseup", onMouseUp); + window.removeEventListener("touchmove", onTouchMove); + window.removeEventListener("touchend", onTouchEnd); scene.traverse((object) => { if (object instanceof THREE.Mesh) { if (object.geometry) object.geometry.dispose(); @@ -437,7 +465,7 @@ const SkinViewer = memo(function SkinViewer({ easterEggRef.current = null; requestRenderRef.current = null; }; - }, [skinUrl, capeUrl]); + }, [skinUrl, capeUrl, slim]); useEffect(() => { const group = playerGroupRef.current; diff --git a/src/components/modals/OptionsModal.tsx b/src/components/modals/OptionsModal.tsx new file mode 100644 index 0000000..45adac5 --- /dev/null +++ b/src/components/modals/OptionsModal.tsx @@ -0,0 +1,511 @@ +import { useState, useEffect, useMemo, useRef, type RefObject } from "react"; +import { TauriService } from "../../services/TauriService"; +import { + parseSchema, + mergeValues, + defaultValues, + computeEffects, + buildArgs, + type ArgsSchema, + type SchemaOption, +} from "../../utils/argsSchema"; + +export default function OptionsModal({ + isOpen, + onClose, + playPressSound, + playBackSound, + instanceId, + instanceName, + savedValues, + onSave, +}: { + isOpen: boolean; + onClose: () => void; + playPressSound: (s?: string) => void; + playBackSound: (s?: string) => void; + instanceId: string; + instanceName: string; + savedValues?: Record; + onSave: ( + instanceId: string, + values: Record, + args: string[], + ) => void; +}) { + const [schema, setSchema] = useState(null); + const [values, setValues] = useState>({}); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(false); + const [focusIndex, setFocusIndex] = useState(0); + const rowRefs = useRef<(HTMLElement | null)[]>([]); + const inputRefs = useRef<(HTMLElement | null)[]>([]); + const resetRef = useRef(null); + const cancelRef = useRef(null); + const saveRef = useRef(null); + + useEffect(() => { + if (!isOpen) return; + let cancelled = false; + setLoading(true); + setError(null); + setSchema(null); + setValues({}); + setFocusIndex(0); + TauriService.getInstanceArgsSchema(instanceId) + .then((raw) => { + if (cancelled) return; + if (!raw) { + setError("This instance does not provide a launch options schema."); + setLoading(false); + return; + } + const parsed = parseSchema(raw); + if (!parsed) { + setError("The launch options schema is invalid or unsupported."); + setLoading(false); + return; + } + setSchema(parsed); + setValues(mergeValues(parsed, savedValues)); + setLoading(false); + }) + .catch((e) => { + if (cancelled) return; + setError(e instanceof Error ? e.message : String(e)); + setLoading(false); + }); + return () => { + cancelled = true; + }; + }, [isOpen, instanceId, savedValues]); + + const effects = useMemo( + () => (schema ? computeEffects(schema, values) : {}), + [schema, values], + ); + + const visibleOptions = useMemo( + () => + schema + ? schema.options.filter((o) => !effects[o.id]?.hidden) + : ([] as SchemaOption[]), + [schema, effects], + ); + + const optionOrder = useMemo( + () => visibleOptions.map((o) => o.id), + [visibleOptions], + ); + + useEffect(() => { + setFocusIndex((prev) => Math.min(prev, optionOrder.length + 2)); + }, [optionOrder.length]); + + const sections = useMemo(() => { + if (!schema) + return { + sections: [] as { + title: string; + description?: string; + options: SchemaOption[]; + }[], + general: [] as SchemaOption[], + }; + const byGroup = new Map(); + const general: SchemaOption[] = []; + const declared = new Set(schema.groups.map((g) => g.id)); + for (const option of visibleOptions) { + if (option.group && declared.has(option.group)) { + const list = byGroup.get(option.group); + if (list) list.push(option); + else byGroup.set(option.group, [option]); + } else { + general.push(option); + } + } + const sections = schema.groups + .map((group) => ({ + title: group.title, + description: group.description, + options: byGroup.get(group.id) ?? ([] as SchemaOption[]), + })) + .filter((s) => s.options.length > 0); + return { sections, general }; + }, [schema, visibleOptions]); + + const handleReset = () => { + playPressSound(); + if (!schema) return; + setValues(defaultValues(schema)); + }; + + const handleSave = () => { + if (!schema) return; + playPressSound("save_click.wav"); + const finalValues = { ...values }; + onSave( + instanceId, + finalValues, + buildArgs(schema, finalValues, computeEffects(schema, finalValues)), + ); + onClose(); + }; + + useEffect(() => { + if (!isOpen) return; + const handleKey = (e: KeyboardEvent) => { + const activeTag = document.activeElement?.tagName; + if ( + activeTag === "INPUT" || + activeTag === "SELECT" || + activeTag === "TEXTAREA" + ) { + if (e.key === "Escape") { + playBackSound(); + onClose(); + } + return; + } + const total = optionOrder.length + 3; + if (e.key === "Escape") { + playBackSound(); + onClose(); + } else if (e.key === "ArrowDown" || e.key === "Tab") { + e.preventDefault(); + setFocusIndex((prev) => (prev + 1) % total); + } else if (e.key === "ArrowUp") { + e.preventDefault(); + setFocusIndex((prev) => (prev - 1 + total) % total); + } else if (e.key === "Enter") { + e.preventDefault(); + if (focusIndex < optionOrder.length) { + const id = optionOrder[focusIndex]; + const option = schema?.options.find((o) => o.id === id); + if (!option) return; + const effect = effects[id]; + if (option.type === "boolean") { + if (!effect?.disabled) { + setValues((prev) => ({ ...prev, [id]: !prev[id] })); + } + } else { + const input = inputRefs.current[focusIndex]; + if (input) input.focus(); + } + } else if (focusIndex === optionOrder.length) { + handleReset(); + } else if (focusIndex === optionOrder.length + 1) { + playBackSound(); + onClose(); + } else { + handleSave(); + } + } + }; + window.addEventListener("keydown", handleKey); + return () => window.removeEventListener("keydown", handleKey); + }, [ + isOpen, + optionOrder, + focusIndex, + schema, + values, + effects, + playPressSound, + playBackSound, + onClose, + ]); + + useEffect(() => { + if (!isOpen) return; + if (focusIndex < optionOrder.length) { + rowRefs.current[focusIndex]?.focus(); + } else if (focusIndex === optionOrder.length) { + resetRef.current?.focus(); + } else if (focusIndex === optionOrder.length + 1) { + cancelRef.current?.focus(); + } else { + saveRef.current?.focus(); + } + }, [isOpen, focusIndex, optionOrder]); + + if (!isOpen) return null; + + const flatIndex = (optionId: string) => optionOrder.indexOf(optionId); + const titleDesc = (option: SchemaOption) => ( +
+
+ {option.title} +
+ {option.description && ( +
+ {option.description} +
+ )} +
+ ); + + const control = (option: SchemaOption, index: number, disabled: boolean) => { + const value = values[option.id]; + switch (option.type) { + case "boolean": + return null; + case "int": + case "number": + return ( + { + inputRefs.current[index] = el; + }} + type="number" + disabled={disabled} + min={option.min} + max={option.max} + step={option.step ?? (option.type === "int" ? 1 : "any")} + value={typeof value === "number" ? value : ""} + onChange={(e) => { + const raw = e.target.value; + setValues((prev) => ({ + ...prev, + [option.id]: raw === "" ? "" : Number(raw), + })); + }} + 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 ${ + disabled ? "opacity-40 cursor-not-allowed" : "" + }`} + style={{ imageRendering: "pixelated" }} + /> + ); + case "string": + return ( + { + inputRefs.current[index] = el; + }} + type="text" + disabled={disabled} + placeholder={option.placeholder} + value={typeof value === "string" ? value : ""} + onChange={(e) => { + 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] ${ + disabled ? "opacity-40 cursor-not-allowed" : "" + }`} + style={{ imageRendering: "pixelated" }} + /> + ); + case "choice": + return ( + + ); + } + }; + + const renderOption = (option: SchemaOption) => { + const index = flatIndex(option.id); + const effect = effects[option.id]; + const disabled = !!effect?.disabled; + const isFocused = focusIndex === index; + if (option.type === "boolean") { + return ( + + ); + } + return ( +
{ + rowRefs.current[index] = el; + }} + tabIndex={-1} + className={`w-full flex items-center gap-3 px-3 py-2 outline-none border-2 ${ + isFocused ? "border-[#FFFF55] bg-black/10" : "border-transparent" + } ${disabled ? "opacity-50" : ""}`} + > +
+ {control(option, index, disabled)} + {titleDesc(option)} +
+
+ ); + }; + + const actionButton = ( + ref: RefObject, + index: number, + label: string, + onClick: () => void, + danger?: boolean, + ) => ( + + ); + + return ( +
{ + if (e.target === e.currentTarget) { + playBackSound(); + onClose(); + } + }} + > +
+

+ Options +

+

+ {instanceName} +

+ + {loading ? ( +
+
+

+ Loading options... +

+
+ ) : error ? ( +
+

+ {error} +

+
+ {actionButton(cancelRef, optionOrder.length + 1, "OK", () => { + playBackSound(); + onClose(); + })} +
+
+ ) : schema ? ( + <> +
+ {sections.sections.map((section) => ( +
+

+ {section.title} +

+ {section.description && ( +

+ {section.description} +

+ )} + {section.options.map(renderOption)} +
+ ))} + {sections.general.length > 0 && ( +
+

+ General +

+ {sections.general.map(renderOption)} +
+ )} +
+ +
+ {actionButton( + resetRef, + optionOrder.length, + "Reset", + handleReset, + true, + )} + {actionButton(cancelRef, optionOrder.length + 1, "Cancel", () => { + playBackSound(); + onClose(); + })} + {actionButton( + saveRef, + optionOrder.length + 2, + "Save", + handleSave, + )} +
+ + ) : null} +
+
+ ); +} diff --git a/src/components/views/CreditsView.tsx b/src/components/views/CreditsView.tsx index 8599c22..ab67c1e 100644 --- a/src/components/views/CreditsView.tsx +++ b/src/components/views/CreditsView.tsx @@ -1,6 +1,7 @@ -import { useEffect, memo, useState } from "react"; +import { useEffect, useMemo, memo, useState } from "react"; import { motion } from "framer-motion"; import { useUI, useAudio } from "../../context/LauncherContext"; +import { usePlatform } from "../../hooks/usePlatform"; interface CreditCategory { category: string; @@ -31,7 +32,56 @@ interface CreditCategory { const CreditsView = memo(function CreditsView() { const { setActiveView } = useUI(); const { playPressSound } = useAudio(); + const { isAndroid } = usePlatform(); const [isHovered, setIsHovered] = useState(false); + const shuffledAndroid = useMemo( + //neo: yes im shuffling it + () => + [ + "Peppinaramenisblack", + "kacper", + "tjdownchurch", + "tjdownchurch's dad (rip)", + "dadael3", + "bekhens", + "notrainbowsteve", + "moth_scribe", + "leader", + "raymanroy", + "fin", + "liugu", + "Zrox2013 (Zameras2013)", + "kierwa", + "cartox", + "necmi", + "theunknown", + "XeroChunks (double thanks!)", + "bee (quickjinxy_)", + "loss_less", + "ttfly", + "harper", + "dllie (tofou)", + "recycleordie", + "nezzled", + "frenchwith0skill", + "tarknim", + "thehuckle", + "gabrielblast", + "nedjouamario", + "bossanova", + "jayem", + "andrewjcf", + "thingthing", + "toastybaguette", + "goobert", + "Erickk64", + "flamingphoenex", + "Tymszn21", + "DaPogLord", + "turniphead", + ].sort(() => Math.random() - 0.5), + [], + ); const credits: CreditCategory[] = [ { @@ -96,10 +146,6 @@ const CreditsView = memo(function CreditsView() { members: [ { name: "Huckle", url: "https://github.com/TheHuckleDev" }, { name: "Andi_pog", url: "https://github.com/Andi-pog" }, - { - name: "LordCambion", - url: "https://github.com/LordCambion", - }, { name: "neoapps", url: "https://github.com/neoapps-dev" }, { name: "tranqlmao", url: "https://github.com/tranqlmao" }, ], @@ -111,6 +157,10 @@ const CreditsView = memo(function CreditsView() { { name: "Rockefeler", url: "#" }, { name: "CDevJoud", url: "#" }, { name: "Rhys Evolution", url: "#" }, + { + name: "LordCambion", + url: "https://github.com/LordCambion", + }, ], }, ], @@ -172,12 +222,12 @@ const CreditsView = memo(function CreditsView() { ], }, { - name: "Portable LCE", - icon: "", + name: "Project Lost Legacy", + icon: "/images/lostlegacy.png", roles: [ { role: "Founder", - members: [{ name: "TBD", url: "#" }], + members: [{ name: "SailsYT", url: "#" }], }, ], }, @@ -197,6 +247,23 @@ const CreditsView = memo(function CreditsView() { category: "SPECIAL THANKS", icon: "", subcategories: [ + ...(isAndroid + ? [ + { + name: "Android Beta Testers", + icon: "", + roles: [ + { + role: "", + members: shuffledAndroid.map((name) => ({ + name, + url: "#", + })), + }, + ], + }, + ] + : []), { name: "Discord Booster", icon: "/images/Nitro Boost.png", @@ -242,25 +309,27 @@ const CreditsView = memo(function CreditsView() { return (
- + {!isAndroid && ( + + )} e.id === profile); @@ -35,79 +36,85 @@ const HomeView = memo(function HomeView() { const hasAnyInstall = installs.length > 0; - const buttonsVal = useMemo( - () => { - const mainBtn = { - label: !hasAnyInstall - ? "Install a version" - : isGameRunning - ? "Stop Game" - : isDownloading - ? "Installation in progress..." - : isInstalled - ? "Play Game" - : `Download ${selectedVersionName}`, - action: !hasAnyInstall - ? () => setActiveView("versions") - : isGameRunning - ? stopGame - : isDownloading - ? () => {} - : isInstalled - ? handleLaunch - : () => toggleInstall(profile), - isDanger: isGameRunning, - disabled: isDownloading, - id: "main-action", - }; + const buttonsVal = useMemo(() => { + const mainBtn = { + label: !hasAnyInstall + ? "Install a version" + : isGameRunning + ? "Stop Game" + : isDownloading + ? "Installation in progress..." + : isInstalled + ? "Play Game" + : `Download ${selectedVersionName}`, + action: !hasAnyInstall + ? () => setActiveView("versions") + : isGameRunning + ? stopGame + : isDownloading + ? () => {} + : isInstalled + ? handleLaunch + : () => toggleInstall(profile), + isDanger: isGameRunning, + disabled: isDownloading, + id: "main-action", + }; - const pluginBtns = pluginActions.map((a) => ({ - label: a.label, - action: () => a.onClick(), + const pluginBtns = pluginActions.map((a) => ({ + label: a.label, + action: () => a.onClick(), + isDanger: false, + disabled: false, + id: a.id, + })); + + const menuBtns = [ + { + label: "Help & Options", + action: () => setActiveView("settings"), isDanger: false, disabled: false, - id: a.id, - })); + id: "settings", + }, + { + label: "Versions", + action: () => setActiveView("versions"), + isDanger: false, + disabled: false, + id: "versions", + }, + { + label: "Workshop", + action: () => setActiveView("workshop"), + isDanger: false, + disabled: false, + id: "workshop", + }, + { + label: "Developer Tools", + action: () => setActiveView("devtools"), + isDanger: false, + disabled: false, + id: "devtools", + }, + ].filter((b) => !(isAndroid && b.id === "devtools")); - const menuBtns = [ - { - label: "Help & Options", - action: () => setActiveView("settings"), - isDanger: false, - disabled: false, - id: "settings", - }, - { - label: "Versions", - action: () => setActiveView("versions"), - isDanger: false, - disabled: false, - id: "versions", - }, - { - label: "Workshop", - action: () => setActiveView("workshop"), - isDanger: false, - disabled: false, - id: "workshop", - }, - { - label: "Developer Tools", - action: () => setActiveView("devtools"), - isDanger: false, - disabled: false, - id: "devtools", - }, - ]; - - return [mainBtn, ...pluginBtns, ...menuBtns]; - }, - [ - isDownloading, hasAnyInstall, isInstalled, selectedVersionName, - handleLaunch, toggleInstall, profile, setActiveView, - isGameRunning, stopGame, pluginActions, - ], - ); + return [mainBtn, ...pluginBtns, ...menuBtns]; + }, [ + isDownloading, + hasAnyInstall, + isInstalled, + selectedVersionName, + handleLaunch, + toggleInstall, + profile, + setActiveView, + isGameRunning, + stopGame, + pluginActions, + isAndroid, + ]); useEffect(() => { if (!isFocusedSection) { @@ -125,7 +132,8 @@ const HomeView = memo(function HomeView() { prev === null ? buttonsVal.length - 1 : prev > 0 ? prev - 1 : prev, ); if (e.key === "ArrowLeft") onNavigateToSkin(); - if (e.key === "Enter" && menuFocus !== null) buttonsVal[menuFocus].action(); + if (e.key === "Enter" && menuFocus !== null) + buttonsVal[menuFocus].action(); }; window.addEventListener("keydown", handleKey); return () => window.removeEventListener("keydown", handleKey); @@ -187,6 +195,7 @@ const HomeView = memo(function HomeView() { {!legacyMode && (
+
-
-
)} diff --git a/src/components/views/LceOnlineView.tsx b/src/components/views/LceOnlineView.tsx index 53a15f5..4c25415 100644 --- a/src/components/views/LceOnlineView.tsx +++ b/src/components/views/LceOnlineView.tsx @@ -7,6 +7,7 @@ import { useGame, } from "../../context/LauncherContext"; import ChooseInstanceModal from "../modals/ChooseInstanceModal"; +import { usePlatform } from "../../hooks/usePlatform"; import { lceOnlineService, SocialEntry } from "../../services/LceOnlineService"; import { TauriService } from "../../services/TauriService"; import { WebviewWindow } from '@tauri-apps/api/webviewWindow'; @@ -28,6 +29,7 @@ const LceOnlineView = memo(function LceOnlineView({ const { setActiveView, setIsUiHidden } = useUI(); const { animationsEnabled } = useConfig(); const { playPressSound, playBackSound } = useAudio(); + const { isAndroid } = usePlatform(); const game = useGame(); const [isSignedIn, setIsSignedIn] = useState(lceOnlineService.signedIn); const opened = useRef(false); @@ -80,25 +82,41 @@ const LceOnlineView = memo(function LceOnlineView({ if (!opened.current) { opened.current = true; - new WebviewWindow('LCEOnline', { - url: "https://mclegacyedition.xyz/internal/auth?appId=emerald_launcher", - width: 400, - height: 570, - resizable: false, - title: 'Emerald Legacy Launcher - LCEOnline', - }); + if (isAndroid) { + TauriService.startLceOnlineAuth() + .then((token) => { + lceOnlineService + .loginWithTokenAndFetchAccount(token) + .catch((e) => console.error(e)); + setIsSignedIn(true); + }) + .catch((e) => console.error("LCE Online auth failed", e)); + } else { + new WebviewWindow('LCEOnline', { + url: "https://mclegacyedition.xyz/internal/auth?appId=emerald_launcher", + width: 400, + height: 570, + resizable: false, + title: 'Emerald Legacy Launcher - LCEOnline', + }); + } }; const unlisten = listen('deep-link', async (event) => { const authUrl = event.payload.find(u => u.startsWith('emerald://')); if (!authUrl) return; const token = new URL(authUrl).searchParams.get('token'); - if (token) setIsSignedIn(true); + if (token) { + lceOnlineService + .loginWithTokenAndFetchAccount(token) + .catch((e) => console.error(e)); + setIsSignedIn(true); + } (await WebviewWindow.getByLabel('LCEOnline'))?.close(); }); return () => { unlisten.then(f => f()); }; - }, [isSignedIn]); + }, [isSignedIn, isAndroid]); useEffect(() => { if (!addFriendTarget) return; @@ -191,7 +209,7 @@ const LceOnlineView = memo(function LceOnlineView({ items.push({ id: `friend_${f.username}`, type: "friend", - label: f.displayName, + label: f.displayName || f.username, onClick: () => handleAction(() => lceOnlineService.removeFriend(f.username)), onClickSecondary: isHosting ? () => handleAction(() => lceOnlineService.sendInvite(f.username)) @@ -203,7 +221,7 @@ const LceOnlineView = memo(function LceOnlineView({ items.push({ id: `req_in_${r.username}`, type: "request_in", - label: r.displayName, + label: r.displayName || r.username, onClick: () => handleAction(() => lceOnlineService.acceptFriendRequest(r.username)), onClickSecondary: () => @@ -214,7 +232,7 @@ const LceOnlineView = memo(function LceOnlineView({ items.push({ id: `req_out_${r.username}`, type: "request_out", - label: r.displayName, + label: r.displayName || r.username, onClick: () => handleAction(() => lceOnlineService.declineFriendRequest(r.username)), }); diff --git a/src/components/views/ScreenshotsView.tsx b/src/components/views/ScreenshotsView.tsx index 1acdd6b..a2540fa 100644 --- a/src/components/views/ScreenshotsView.tsx +++ b/src/components/views/ScreenshotsView.tsx @@ -1,5 +1,6 @@ import { useState, useEffect, memo } from "react"; import { motion, AnimatePresence } from "framer-motion"; +import { usePlatform } from "../../hooks/usePlatform"; import { useUI, useAudio, @@ -15,6 +16,7 @@ import { ScreenshotImage } from "../common/ScreenshotImage"; const ScreenshotsView = memo(function ScreenshotsView() { const { setActiveView } = useUI(); const { playPressSound, playBackSound } = useAudio(); + const { isAndroid } = usePlatform(); const { editions } = useGame(); const { animationsEnabled } = useConfig(); const [screenshots, setScreenshots] = useState([]); @@ -239,30 +241,32 @@ const ScreenshotsView = memo(function ScreenshotsView() {
-
- -
+ {!isAndroid && ( +
+ +
+ )} {selectedScreenshot && ( @@ -270,7 +274,7 @@ const ScreenshotsView = memo(function ScreenshotsView() { initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} - className="fixed inset-0 z-[200] bg-black/90 flex flex-col items-center justify-center p-8 backdrop-blur-md" + className="fixed inset-0 z-[200] bg-black/90 flex flex-col items-center justify-center p-4 sm:p-8 backdrop-blur-md" onClick={() => setSelectedScreenshot(null)} > e.stopPropagation()} > -
+
@@ -314,7 +318,7 @@ const ScreenshotsView = memo(function ScreenshotsView() {
-
+
@@ -193,7 +193,7 @@ const SettingsView = memo(function SettingsView() { "fixed inset-0 bg-black/80 flex items-center justify-center z-50"; dialog.innerHTML = `
-

CONFIRM RESET

+

CONFIRM RESET

⚠️ This will:

    @@ -202,7 +202,7 @@ const SettingsView = memo(function SettingsView() {
  • Show setup screen again
  • Require reconfiguration
-

This action cannot be undone!

+

This action cannot be undone!

@@ -328,6 +328,18 @@ const SettingsView = memo(function SettingsView() { setFocusIndex(0); }, }); + if (isAndroid) { + items.push({ + id: "android_menu", + label: "Android", + type: "button", + onClick: () => { + playPressSound(); + setCurrentSubMenu("android"); + setFocusIndex(0); + }, + }); + } for (const action of pluginSettingsActions) { items.push({ id: action.id, @@ -339,6 +351,15 @@ const SettingsView = memo(function SettingsView() { }, }); } + items.push({ + id: "credits", + label: "Credits", + type: "button", + onClick: () => { + playPressSound(); + setActiveView("credits"); + }, + }); } else if (currentSubMenu === "audio") { items.push({ id: "music", @@ -424,18 +445,20 @@ const SettingsView = memo(function SettingsView() { }, }); } else if (currentSubMenu === "launcher") { - items.push({ - id: "fullscreen", - label: `Start in Fullscreen: ${startFullscreen ? "ON" : "OFF"}`, - type: "button", - onClick: handleFullscreenToggle, - }); - items.push({ - id: "rpc", - label: `Discord RPC: ${rpcEnabled ? "ON" : "OFF"}`, - type: "button", - onClick: handleRpcToggle, - }); + if (!isAndroid) { + items.push({ + id: "fullscreen", + label: `Start in Fullscreen: ${startFullscreen ? "ON" : "OFF"}`, + type: "button", + onClick: handleFullscreenToggle, + }); + items.push({ + id: "rpc", + label: `Discord RPC: ${rpcEnabled ? "ON" : "OFF"}`, + type: "button", + onClick: handleRpcToggle, + }); + } items.push({ id: "skip_intro", label: `Skip Intro: ${skipIntro ? "ON" : "OFF"}`, @@ -448,7 +471,7 @@ const SettingsView = memo(function SettingsView() { type: "button", onClick: handleLegacyToggle, }); - if (isLinux) { + if (isLinux && !isAndroid) { items.push({ id: "runner", label: `Runner: ${selectedRunnerName}`, @@ -479,33 +502,37 @@ const SettingsView = memo(function SettingsView() { }); } - items.push({ - id: "export_settings", - label: "Export Settings", - type: "button", - onClick: async () => { - playPressSound(); - try { - await TauriService.exportSettings(); - } catch (e) { - if (e !== "CANCELED") console.error(e); - } - }, - }); - items.push({ - id: "import_settings", - label: "Import Settings", - type: "button", - onClick: async () => { - playPressSound(); - try { - await TauriService.importSettings(); - window.location.reload(); - } catch (e) { - if (e !== "CANCELED") console.error(e); - } - }, - }); + if (!isAndroid) { + items.push({ + id: "export_settings", + label: "Export Settings", + type: "button", + onClick: async () => { + playPressSound(); + try { + await TauriService.exportSettings(); + } catch (e) { + if (e !== "CANCELED") console.error(e); + } + }, + }); + } + if (!isAndroid) { + items.push({ + id: "import_settings", + label: "Import Settings", + type: "button", + onClick: async () => { + playPressSound(); + try { + await TauriService.importSettings(); + window.location.reload(); + } catch (e) { + if (e !== "CANCELED") console.error(e); + } + }, + }); + } items.push({ id: "reset_setup", label: "Reset Setup", @@ -513,6 +540,58 @@ const SettingsView = memo(function SettingsView() { onClick: handleResetSetup, color: "orange", }); + } else if (currentSubMenu === "android") { + if (profile) { + items.push({ + id: "android_container_settings", + label: "Container Settings", + type: "button", + onClick: () => { + playPressSound(); + TauriService.openContainerSettings(profile).catch(console.error); + }, + }); + items.push({ + id: "android_open_container", + label: "Open Container", + type: "button", + onClick: () => { + playPressSound(); + TauriService.openInstanceFolder(profile).catch(console.error); + }, + }); + } + /*items.push({ + id: "android_proton", + label: `Proton: ${androidRunner === "proton10" ? "Proton 10" : "Proton 11 (Default)"}`, + type: "button", + onClick: () => { + playPressSound(); + const next = androidRunner === "proton10" ? "proton11" : "proton10"; + setAndroidRunner(next); + TauriService.switchProton(next).catch(console.error); + }, + }); + items.push({ + id: "android_install_driver", + label: "Install Latest Driver", + type: "button", + onClick: () => { + playPressSound(); + TauriService.installLatestDriver().catch(console.error); + }, + });*/ + items.push({ + id: "android_audio", + label: `Audio: ${androidAudioBackend === "alsa" ? "ALSA" : "PulseAudio (Default)"}`, + type: "button", + onClick: () => { + playPressSound(); + const next = androidAudioBackend === "alsa" ? "pulseaudio" : "alsa"; + setAndroidAudioBackend(next); + TauriService.setAudioBackend(next).catch(console.error); + }, + }); } if (isGameRunning) { @@ -553,6 +632,7 @@ const SettingsView = memo(function SettingsView() { animationsEnabled, layout, isLinux, + isAndroid, mangohudEnabled, selectedRunnerName, isRunnerDownloading, @@ -580,6 +660,9 @@ const SettingsView = memo(function SettingsView() { launchPrefix, launchEnvVars, skipIntro, + profile, + androidRunner, + androidAudioBackend, ]); useEffect(() => { @@ -684,7 +767,7 @@ const SettingsView = memo(function SettingsView() { transition={{ duration: animationsEnabled ? 0.3 : 0 }} className="flex flex-col items-center w-full max-w-5xl outline-none" > -

+

{currentSubMenu === "main" ? "Settings" : currentSubMenu === "audio" @@ -918,30 +1001,31 @@ const SettingsView = memo(function SettingsView() {

)} - {(() => { - const backIndex = settingsItems.findIndex((i) => i.id === "back"); - const backItem = settingsItems[backIndex]; - if (!backItem || backItem.type !== "button") return null; + {!isAndroid && + (() => { + const backIndex = settingsItems.findIndex((i) => i.id === "back"); + const backItem = settingsItems[backIndex]; + if (!backItem || backItem.type !== "button") return null; - return ( - - ); - })()} + return ( + + ); + })()} {showModal === "args" && ( = ({ onComplete }) => { - const { isLinux, isMac } = usePlatform(); + const { isLinux, isMac, isAndroid } = usePlatform(); const { - username, setUsername, + username, + setUsername, setHasCompletedSetup, setRpcEnabled: setConfigRpc, setLinuxRunner, @@ -25,15 +26,22 @@ const SetupView: React.FC = ({ onComplete }) => { const [runners, setRunners] = useState([]); const [selectedRunner, setSelectedRunner] = useState(""); const [isSettingUpRuntime, setIsSettingUpRuntime] = useState(false); - const [setupProgress, setSetupProgress] = useState<{ stage: string; message: string; percent?: number } | null>(null); + const [setupProgress, setSetupProgress] = useState<{ + stage: string; + message: string; + percent?: number; + } | null>(null); const [runtimeAlreadyInstalled, setRuntimeAlreadyInstalled] = useState(false); const [enableDiscordRPC, setEnableDiscordRPC] = useState(configRpc); const totalSteps = 4; useEffect(() => { if (isLinux || isMac) { - TauriService.getAvailableRunners().then(availableRunners => { + TauriService.getAvailableRunners().then((availableRunners) => { setRunners(availableRunners); - if (configLinuxRunner && availableRunners.find(r => r.id === configLinuxRunner)) { + if ( + configLinuxRunner && + availableRunners.find((r) => r.id === configLinuxRunner) + ) { setSelectedRunner(configLinuxRunner); } }); @@ -46,7 +54,7 @@ const SetupView: React.FC = ({ onComplete }) => { }); return () => { - unlisten.then(f => f?.()); + unlisten.then((f) => f?.()); }; } }, [isLinux, isMac]); @@ -56,13 +64,13 @@ const SetupView: React.FC = ({ onComplete }) => { const runtimeCheck = await TauriService.checkMacOSRuntimeInstalledFast(); setRuntimeAlreadyInstalled(runtimeCheck); if (runtimeCheck) { - localStorage.setItem('lce-macos-runtime-installed', 'true'); + localStorage.setItem("lce-macos-runtime-installed", "true"); } else { - localStorage.removeItem('lce-macos-runtime-installed'); + localStorage.removeItem("lce-macos-runtime-installed"); } } catch { setRuntimeAlreadyInstalled(false); - localStorage.removeItem('lce-macos-runtime-installed'); + localStorage.removeItem("lce-macos-runtime-installed"); } }; @@ -82,7 +90,7 @@ const SetupView: React.FC = ({ onComplete }) => { setCurrentStep(2); setFocusIndex(0); } else if (currentStep === 2) { - setConfigRpc(enableDiscordRPC); + if (!isAndroid) setConfigRpc(enableDiscordRPC); setCurrentStep(3); setFocusIndex(0); } else if (currentStep === 3) { @@ -108,7 +116,7 @@ const SetupView: React.FC = ({ onComplete }) => { if (isLinux) count = runners.length + 2; else if (isMac) count = 3; else count = 2; - } else if (currentStep === 2) count = 4; + } else if (currentStep === 2) count = isAndroid ? 3 : 4; else if (currentStep === 3) count = 2; if (e.key === "ArrowDown" || e.key === "Tab") { e.preventDefault(); @@ -122,7 +130,8 @@ const SetupView: React.FC = ({ onComplete }) => { else if (focusIndex === 1) handleNext(); } else if (currentStep === 1) { if (isLinux) { - if (focusIndex < runners.length) handleRunnerSelect(runners[focusIndex].id); + if (focusIndex < runners.length) + handleRunnerSelect(runners[focusIndex].id); else if (focusIndex === runners.length) handleBack(); else if (focusIndex === runners.length + 1) handleNext(); } else if (isMac) { @@ -134,10 +143,20 @@ const SetupView: React.FC = ({ onComplete }) => { else if (focusIndex === 1) handleNext(); } } else if (currentStep === 2) { - if (focusIndex === 0) { setEnableDiscordRPC(!enableDiscordRPC); playPressSound(); } - else if (focusIndex === 1) { playPressSound(); } - else if (focusIndex === 2) handleBack(); - else if (focusIndex === 3) handleNext(); + if (isAndroid) { + if (focusIndex === 0) { + playPressSound(); + } else if (focusIndex === 1) handleBack(); + else if (focusIndex === 2) handleNext(); + } else { + if (focusIndex === 0) { + setEnableDiscordRPC(!enableDiscordRPC); + playPressSound(); + } else if (focusIndex === 1) { + playPressSound(); + } else if (focusIndex === 2) handleBack(); + else if (focusIndex === 3) handleNext(); + } } else if (currentStep === 3) { if (focusIndex === 0) handleBack(); else if (focusIndex === 1) handleNext(); @@ -146,16 +165,33 @@ const SetupView: React.FC = ({ onComplete }) => { }; window.addEventListener("keydown", handleKey); return () => window.removeEventListener("keydown", handleKey); - }, [currentStep, focusIndex, runners, enableDiscordRPC, isLinux, isMac, tempUsername]); + }, [ + currentStep, + focusIndex, + runners, + enableDiscordRPC, + isLinux, + isMac, + isAndroid, + tempUsername, + ]); const handleMacosSetup = async () => { playPressSound(); setIsSettingUpRuntime(true); - setSetupProgress({ stage: "preparing", message: "Preparing macOS runtime setup...", percent: 0 }); + setSetupProgress({ + stage: "preparing", + message: "Preparing macOS runtime setup...", + percent: 0, + }); try { await TauriService.setupMacosRuntime(); - setSetupProgress({ stage: "completed", message: "Setup completed successfully!", percent: 100 }); - localStorage.setItem('lce-macos-runtime-installed', 'true'); + setSetupProgress({ + stage: "completed", + message: "Setup completed successfully!", + percent: 100, + }); + localStorage.setItem("lce-macos-runtime-installed", "true"); setRuntimeAlreadyInstalled(true); setTimeout(() => { setCurrentStep(2); @@ -163,7 +199,11 @@ const SetupView: React.FC = ({ onComplete }) => { setSetupProgress(null); }, 2000); } catch (e) { - setSetupProgress({ stage: "error", message: `Setup failed: ${e}`, percent: 0 }); + setSetupProgress({ + stage: "error", + message: `Setup failed: ${e}`, + percent: 0, + }); setIsSettingUpRuntime(false); } }; @@ -194,236 +234,413 @@ const SetupView: React.FC = ({ onComplete }) => { />
-
+
+
+ {currentStep === 0 && ( +

+ Let's configure your launcher +

+ )} + {currentStep === 1 && ( +

+ Compatibility Runtime +

+ )} + {currentStep === 2 && ( +

+ Choose your preferred options and behaviors +

+ )}
{currentStep === 0 && ( -

- Let's configure your launcher -

- )} - {currentStep === 1 && ( -

- Compatibility Runtime -

- )} - {currentStep === 2 && ( -

- Choose your preferred options and behaviors -

- )} -
- - {currentStep === 0 && ( -
-
- {storedCapes.map((cape, i) => { + {savedCapes.map((cape, i) => { const idx = SKINS_START_INDEX + 1 + i; const isActive = activeCapeId ? activeCapeId === cape.id @@ -693,9 +746,10 @@ const SkinsView = memo(function SkinsView() { onChange={(e) => handleCapeNameChange(cape.id, e.target.value) } - className={`bg-transparent text-center outline-none border-none text-base mc-text-shadow w-full truncate transition-colors relative z-10 ${isActive || isFocused ? "text-[#FFFF55]" : "text-white"}`} + className={`bg-transparent text-center outline-none border-none text-base mc-text-shadow w-full truncate transition-colors relative z-10 ${isActive || isFocused ? "text-[#FFFF55]" : "text-white"} ${isDefaultCape(cape.id) ? "pointer-events-none" : ""}`} onClick={(e) => e.stopPropagation()} spellCheck={false} + readOnly={isDefaultCape(cape.id)} />
); @@ -719,28 +773,31 @@ const SkinsView = memo(function SkinsView() { onNavigateRight={() => {}} hideControls style={{ top: "45%" }} + slim={skinIsSlim} />
- + {!isAndroid && ( + + )} {showImportModal && viewMode === "skin" && (
diff --git a/src/components/views/VersionsView.tsx b/src/components/views/VersionsView.tsx index 8bbffd3..ec57fff 100644 --- a/src/components/views/VersionsView.tsx +++ b/src/components/views/VersionsView.tsx @@ -7,6 +7,8 @@ import ImportWorldModal from "../modals/ImportWorldModal"; import PlaytimeModal from "../modals/PlaytimeModal"; import CustomizeModal from "../modals/CustomizeModal"; import DownloadDlcModal from "../modals/DownloadDlcModal"; +import OptionsModal from "../modals/OptionsModal"; +import { parseSchema } from "../../utils/argsSchema"; import { useUI, useConfig, @@ -14,7 +16,9 @@ import { useGame, } from "../../context/LauncherContext"; import { ScreenshotImage } from "../common/ScreenshotImage"; +import { usePlatform } from "../../hooks/usePlatform"; import type { Edition } from "../../types/edition"; +import { HIDDEN_INSTANCE_URL } from "../../types/edition"; interface DeleteConfirmButtonProps { label: string; onClick: () => void; @@ -63,6 +67,8 @@ const VersionsView = memo(function VersionsView() { profile: selectedProfile, setProfile: setSelectedProfile, animationsEnabled, + instanceLaunchArgs, + setInstanceLaunchArgs, } = useConfig(); const { playPressSound, playBackSound } = useAudio(); const { @@ -84,6 +90,12 @@ const VersionsView = memo(function VersionsView() { saveCustomPath, } = useGame(); const { isDayTime } = useConfig(); + const { isAndroid } = usePlatform(); + const visibleEditions = editions.filter( + (e) => + e.url !== HIDDEN_INSTANCE_URL || + installedVersions.includes(e.instanceId), + ); const [focusIndex, setFocusIndex] = useState(0); const [focusBtn, setFocusBtn] = useState(0); const [isImportModalOpen, setIsImportModalOpen] = useState(false); @@ -91,27 +103,56 @@ const VersionsView = memo(function VersionsView() { const [setUidTargetId, setSetUidTargetId] = useState(""); const [editingEdition, setEditingEdition] = useState(null); const [isImportWorldModalOpen, setIsImportWorldModalOpen] = useState(false); - const [importWorldTarget, setImportWorldTarget] = useState<{ id: string; name: string } | null>(null); + const [importWorldTarget, setImportWorldTarget] = useState<{ + id: string; + name: string; + } | null>(null); const [isPlaytimeModalOpen, setIsPlaytimeModalOpen] = useState(false); - const [playtimeTarget, setPlaytimeTarget] = useState<{ id: string; name: string } | null>(null); + const [playtimeTarget, setPlaytimeTarget] = useState<{ + id: string; + name: string; + } | null>(null); const [isCustomizeModalOpen, setIsCustomizeModalOpen] = useState(false); const [customizeTarget, setCustomizeTarget] = useState(null); - const [playtimeMap, setPlaytimeMap] = useState>({}); + const [playtimeMap, setPlaytimeMap] = useState< + Record + >({}); const [initialPath, setInitialPath] = useState(""); const [hoveredBtn, setHoveredBtn] = useState<{ row: number; btn: string; } | null>(null); const [openMenuId, setOpenMenuId] = useState(null); - const [deleteConfirmEdition, setDeleteConfirmEdition] = useState(null); + const [deleteConfirmEdition, setDeleteConfirmEdition] = + useState(null); const [isDlcModalOpen, setIsDlcModalOpen] = useState(false); - const [dlcTargetEdition, setDlcTargetEdition] = useState(null); + const [dlcTargetEdition, setDlcTargetEdition] = useState( + null, + ); + const [isOptionsModalOpen, setIsOptionsModalOpen] = useState(false); + const [optionsTarget, setOptionsTarget] = useState<{ + id: string; + name: string; + } | null>(null); + const [argsSchemas, setArgsSchemas] = useState>({}); const containerRef = useRef(null); const listRef = useRef(null); - const ITEM_COUNT = editions.length + 3; + const ITEM_COUNT = visibleEditions.length + 3; useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if (document.activeElement?.tagName === "INPUT") return; + if ( + isImportModalOpen || + isSetUidModalOpen || + isImportWorldModalOpen || + isPlaytimeModalOpen || + isCustomizeModalOpen || + isDlcModalOpen || + isOptionsModalOpen || + deleteConfirmEdition + ) { + return; + } if (e.key === "Escape" || e.key === "Backspace") { playBackSound(); @@ -129,8 +170,8 @@ const VersionsView = memo(function VersionsView() { setFocusBtn(0); } else if (e.key === "ArrowLeft") { e.preventDefault(); - if (focusIndex < editions.length) { - const edition = editions[focusIndex]; + if (focusIndex < visibleEditions.length) { + const edition = visibleEditions[focusIndex]; const isInstalled = installedVersions.includes(edition.id); const isCustom = edition.id.startsWith("custom_"); const maxBtn = isInstalled ? (isCustom ? 6 : 4) : 1; @@ -138,8 +179,8 @@ const VersionsView = memo(function VersionsView() { } } else if (e.key === "ArrowRight") { e.preventDefault(); - if (focusIndex < editions.length) { - const edition = editions[focusIndex]; + if (focusIndex < visibleEditions.length) { + const edition = visibleEditions[focusIndex]; const isInstalled = installedVersions.includes(edition.id); const isCustom = edition.id.startsWith("custom_"); const maxBtn = isInstalled ? (isCustom ? 6 : 4) : 1; @@ -147,8 +188,8 @@ const VersionsView = memo(function VersionsView() { } } else if (e.key === "Enter") { e.preventDefault(); - if (focusIndex < editions.length) { - const edition = editions[focusIndex]; + if (focusIndex < visibleEditions.length) { + const edition = visibleEditions[focusIndex]; const isInstalled = installedVersions.includes(edition.instanceId); const isDownloading = downloadingIds.includes(edition.instanceId); if (focusBtn === 0) { @@ -170,10 +211,10 @@ const VersionsView = memo(function VersionsView() { playPressSound(); cycleBranch(edition.id); } - } else if (focusIndex === editions.length) { + } else if (focusIndex === visibleEditions.length) { playPressSound(); setIsImportModalOpen(true); - } else if (focusIndex === editions.length + 1) { + } else if (focusIndex === visibleEditions.length + 1) { playPressSound(); handleImportFolder(); } else { @@ -200,10 +241,18 @@ const VersionsView = memo(function VersionsView() { handleCancelDownload, addToSteam, isDayTime, + isImportModalOpen, + isSetUidModalOpen, + isImportWorldModalOpen, + isPlaytimeModalOpen, + isCustomizeModalOpen, + isDlcModalOpen, + isOptionsModalOpen, + deleteConfirmEdition, ]); useEffect(() => { - if (focusIndex < editions.length && listRef.current) { + if (focusIndex < visibleEditions.length && listRef.current) { const el = listRef.current.querySelector( `[data-index="${focusIndex}"]`, ) as HTMLElement; @@ -216,18 +265,43 @@ const VersionsView = memo(function VersionsView() { useEffect(() => { const fetchPlaytimes = async () => { const map: Record = {}; - await Promise.all(installedVersions.map(async (id) => { - try { - map[id] = await TauriService.getPlaytime(id); - } catch (e) { - console.error(e); - } - })); + await Promise.all( + installedVersions.map(async (id) => { + try { + map[id] = await TauriService.getPlaytime(id); + } catch (e) { + console.error(e); + } + }), + ); setPlaytimeMap(map); }; fetchPlaytimes(); }, [installedVersions]); + useEffect(() => { + let cancelled = false; + const checkSchemas = async () => { + const map: Record = {}; + await Promise.all( + installedVersions.map(async (id) => { + try { + const schema = await TauriService.getInstanceArgsSchema(id); + map[id] = !!schema && parseSchema(schema) !== null; + } catch (e) { + console.error(e); + map[id] = false; + } + }), + ); + if (!cancelled) setArgsSchemas(map); + }; + checkSchemas(); + return () => { + cancelled = true; + }; + }, [installedVersions]); + const handleEditionClick = (edition: Edition, index: number) => { const isInstalled = installedVersions.includes(edition.instanceId); if (isInstalled) { @@ -250,7 +324,7 @@ const VersionsView = memo(function VersionsView() { }; const handleImportWorld = (instanceId: string) => { - const edition = editions.find((e: Edition) => e.instanceId === instanceId); + const edition = visibleEditions.find((e: Edition) => e.instanceId === instanceId); setImportWorldTarget({ id: instanceId, name: edition?.name ?? instanceId }); setIsImportWorldModalOpen(true); }; @@ -268,15 +342,13 @@ const VersionsView = memo(function VersionsView() { Versions -
+
- {editions.map((edition: Edition, i: number) => { + {visibleEditions.map((edition: Edition, i: number) => { const isInstalled = installedVersions.includes( edition.instanceId, ); @@ -350,7 +422,10 @@ const VersionsView = memo(function VersionsView() { onClick={(e) => { e.stopPropagation(); playPressSound(); - setPlaytimeTarget({ id: edition.instanceId, name: edition.name }); + setPlaytimeTarget({ + id: edition.instanceId, + name: edition.name, + }); setIsPlaytimeModalOpen(true); }} className="flex items-center gap-1.5 px-2 py-1 bg-black/60 border border-[#555] hover:border-[#FFFF55] group transition-colors flex-shrink-0" @@ -371,7 +446,11 @@ const VersionsView = memo(function VersionsView() { - {playtimeMap[edition.instanceId] ? formatPlaytime(playtimeMap[edition.instanceId].totalSeconds) : ""} + {playtimeMap[edition.instanceId] + ? formatPlaytime( + playtimeMap[edition.instanceId].totalSeconds, + ) + : ""} )} @@ -510,7 +589,7 @@ const VersionsView = memo(function VersionsView() { Update Available! )} - {!isInstalled && ( + {!isAndroid && !isInstalled && (
`; document.body.appendChild(dialog); - const close = () => document.body.removeChild(dialog); - dialog.querySelector("#empty-dir-ok")?.addEventListener("click", close); + const close = () => + document.body.removeChild(dialog); + dialog + .querySelector("#empty-dir-ok") + ?.addEventListener("click", close); dialog.addEventListener("click", (e) => { if (e.target === dialog) close(); }); return; } - await saveCustomPath(edition.instanceId, folder); + await saveCustomPath( + edition.instanceId, + folder, + ); toggleInstall(edition.instanceId); } } catch (err) { @@ -586,6 +673,42 @@ const VersionsView = memo(function VersionsView() { Download DLC ) : null} + {argsSchemas[edition.instanceId] && ( + + )} {Array.isArray(edition.branches) && edition.branches.length > 0 && ( - - - + )} + {!isAndroid && ( + - + )} + {!isAndroid && ( + - + )} + {!isAndroid && ( + + + + + + + Backup + + )} + {!isAndroid && ( + + )} + {!isAndroid && ( + + )} - + {!isAndroid && ( + + )}
-
- -
+ {!isAndroid && ( +
+ +
+ )} { + onImport={(ed: { + name: string; + desc: string; + url: string; + path?: string; + }) => { if (editingEdition) { onUpdateEdition(editingEdition.id, ed); } else { @@ -948,14 +1096,17 @@ const VersionsView = memo(function VersionsView() { onClose={() => setIsSetUidModalOpen(false)} playPressSound={playPressSound} playBackSound={playBackSound} - instances={editions} + instances={visibleEditions} installedVersions={installedVersions} targetInstanceId={setUidTargetId} /> { setIsImportWorldModalOpen(false); setImportWorldTarget(null); }} + onClose={() => { + setIsImportWorldModalOpen(false); + setImportWorldTarget(null); + }} playPressSound={playPressSound} playBackSound={playBackSound} targetInstanceId={importWorldTarget?.id ?? ""} @@ -964,7 +1115,10 @@ const VersionsView = memo(function VersionsView() { { setIsPlaytimeModalOpen(false); setPlaytimeTarget(null); }} + onClose={() => { + setIsPlaytimeModalOpen(false); + setPlaytimeTarget(null); + }} playBackSound={playBackSound} instanceId={playtimeTarget?.id ?? ""} instanceName={playtimeTarget?.name ?? ""} @@ -972,12 +1126,25 @@ const VersionsView = memo(function VersionsView() { { setIsCustomizeModalOpen(false); setCustomizeTarget(null); }} + onClose={() => { + setIsCustomizeModalOpen(false); + setCustomizeTarget(null); + }} playPressSound={playPressSound} playBackSound={playBackSound} editionName={customizeTarget?.name ?? ""} - currentTitleImage={customizeTarget ? customizations[customizeTarget.instanceId]?.titleImage || customizeTarget.titleImage : undefined} - currentPanorama={customizeTarget ? customizations[customizeTarget.instanceId]?.panorama || customizeTarget.panorama : undefined} + currentTitleImage={ + customizeTarget + ? customizations[customizeTarget.instanceId]?.titleImage || + customizeTarget.titleImage + : undefined + } + currentPanorama={ + customizeTarget + ? customizations[customizeTarget.instanceId]?.panorama || + customizeTarget.panorama + : undefined + } onSave={(updates) => { if (customizeTarget) { updateCustomization(customizeTarget.instanceId, updates); @@ -987,7 +1154,10 @@ const VersionsView = memo(function VersionsView() { { setIsDlcModalOpen(false); setDlcTargetEdition(null); }} + onClose={() => { + setIsDlcModalOpen(false); + setDlcTargetEdition(null); + }} playPressSound={playPressSound} playBackSound={playBackSound} editionName={dlcTargetEdition?.name ?? ""} @@ -995,6 +1165,29 @@ const VersionsView = memo(function VersionsView() { officialDLC={dlcTargetEdition?.officialDLC ?? ""} /> + { + setIsOptionsModalOpen(false); + setOptionsTarget(null); + }} + playPressSound={playPressSound} + playBackSound={playBackSound} + instanceId={optionsTarget?.id ?? ""} + instanceName={optionsTarget?.name ?? ""} + savedValues={ + optionsTarget + ? instanceLaunchArgs[optionsTarget.id]?.values + : undefined + } + onSave={(instanceId, values, args) => { + setInstanceLaunchArgs((prev) => ({ + ...prev, + [instanceId]: { values, args }, + })); + }} + /> + {deleteConfirmEdition && (
(null); const gridRef = useRef(null); @@ -722,10 +724,6 @@ const WorkshopView = memo(function WorkshopView({ transition={{ duration: config.animationsEnabled ? 0.3 : 0 }} className="flex flex-col items-center w-full h-full max-h-full relative font-['Mojangles'] text-white select-none outline-none focus:outline-none" > -

- Workshop -

-
-
- -
+ {!isAndroid && ( +
+ +
+ )} {selectedPkg && ( @@ -2035,9 +2035,11 @@ function InstallModal({ const targets: { id: string; zips: number }[] = []; for (const depId of dependencies) { const dep = allPackages.find((p) => p.id === depId); - if (dep?.zips) targets.push({ id: dep.id, zips: Object.keys(dep.zips).length }); + if (dep?.zips) + targets.push({ id: dep.id, zips: Object.keys(dep.zips).length }); } - if (pkg.zips) targets.push({ id: pkg.id, zips: Object.keys(pkg.zips).length }); + if (pkg.zips) + targets.push({ id: pkg.id, zips: Object.keys(pkg.zips).length }); return targets; }, [dependencies, allPackages, pkg.zips, pkg.id]); @@ -2262,7 +2264,9 @@ function InstallModal({
{progressLabel || - (isPluginTab ? "Downloading plugin files" : "Downloading assets")} + (isPluginTab + ? "Downloading plugin files" + : "Downloading assets")} {Math.floor(isPluginTab ? progress : overallProgress)}% diff --git a/src/context/LauncherContext.tsx b/src/context/LauncherContext.tsx index f98acd1..7a13be5 100644 --- a/src/context/LauncherContext.tsx +++ b/src/context/LauncherContext.tsx @@ -1,4 +1,11 @@ -import React, { createContext, useContext, useState, useEffect, useMemo, useCallback } from "react"; +import React, { + createContext, + useContext, + useState, + useEffect, + useMemo, + useCallback, +} from "react"; import { useAppConfig } from "../hooks/useAppConfig"; import { TauriService } from "../services/TauriService"; import { useAudioController } from "../hooks/useAudioController"; @@ -7,6 +14,7 @@ import { useSkinSync } from "../hooks/useSkinSync"; import { useDiscordRPC } from "../hooks/useDiscordRPC"; import { useGamepad } from "../hooks/useGamepad"; import { useUpdateCheck } from "../hooks/useUpdateCheck"; +import { SPLASHES } from "../data/splashes"; import RpcService from "../services/RpcService"; interface UIContextType { @@ -29,10 +37,18 @@ interface UIContextType { clearUpdateMessage: () => void; } const UIContext = createContext(undefined); -export const ConfigContext = createContext | undefined>(undefined); -export const AudioContext = createContext | undefined>(undefined); -export const GameContext = createContext | undefined>(undefined); -export const SkinContext = createContext | undefined>(undefined); +export const ConfigContext = createContext< + ReturnType | undefined +>(undefined); +export const AudioContext = createContext< + ReturnType | undefined +>(undefined); +export const GameContext = createContext< + ReturnType | undefined +>(undefined); +export const SkinContext = createContext< + ReturnType | undefined +>(undefined); export function LauncherProvider({ children }: { children: React.ReactNode }) { const [showIntro, setShowIntro] = useState(true); const [logoAnimDone, setLogoAnimDone] = useState(false); @@ -55,7 +71,11 @@ export function LauncherProvider({ children }: { children: React.ReactNode }) { setCustomizations: configRaw.setCustomizations, extraLaunchArgs: configRaw.extraLaunchArgs, }); - const skinSync = useSkinSync({ username: configRaw.username, profile: configRaw.profile, editions: gameRaw.editions }); + const skinSync = useSkinSync({ + username: configRaw.username, + profile: configRaw.profile, + editions: gameRaw.editions, + }); const audioRaw = useAudioController({ musicVol: configRaw.musicVol, sfxVol: configRaw.sfxVol, @@ -63,33 +83,80 @@ export function LauncherProvider({ children }: { children: React.ReactNode }) { isWindowVisible, }); - const config = useMemo(() => configRaw, [ - configRaw.username, configRaw.theme, configRaw.layout, configRaw.vfxEnabled, - configRaw.rpcEnabled, configRaw.musicVol, configRaw.sfxVol, configRaw.isDayTime, - configRaw.profile, configRaw.linuxRunner, configRaw.perfBoost, configRaw.customEditions, - configRaw.customPaths, - configRaw.customizations, - configRaw.legacyMode, configRaw.animationsEnabled, configRaw.mangohudEnabled, - configRaw.extraLaunchArgs, configRaw.launchPrefix, configRaw.launchEnvVars, configRaw.startFullscreen, - configRaw.skipIntro, - ]); + const config = useMemo( + () => configRaw, + [ + configRaw.username, + configRaw.theme, + configRaw.layout, + configRaw.vfxEnabled, + configRaw.rpcEnabled, + configRaw.musicVol, + configRaw.sfxVol, + configRaw.isDayTime, + configRaw.profile, + configRaw.linuxRunner, + configRaw.perfBoost, + configRaw.customEditions, + configRaw.customPaths, + configRaw.customizations, + configRaw.legacyMode, + configRaw.animationsEnabled, + configRaw.mangohudEnabled, + configRaw.extraLaunchArgs, + configRaw.launchPrefix, + configRaw.launchEnvVars, + configRaw.startFullscreen, + configRaw.skipIntro, + configRaw.instanceLaunchArgs, + configRaw.androidRunner, + configRaw.androidAudioBackend, + ], + ); - const game = useMemo(() => gameRaw, [ - gameRaw.installs, gameRaw.isGameRunning, gameRaw.downloadProgress, - gameRaw.downloadingIds, gameRaw.editions, gameRaw.isRunnerDownloading, - gameRaw.runnerDownloadProgress, gameRaw.error, gameRaw.updateCustomEdition, - gameRaw.handleUninstall, gameRaw.handleCancelDownload, gameRaw.gameUpdateMessage, configRaw.profile, - gameRaw.updatesAvailable, gameRaw.addToSteam, gameRaw.steamSuccessMessage, - gameRaw.cycleBranch, gameRaw.toggleInstall, gameRaw.checkInstalls, - gameRaw.handleLaunch, gameRaw.stopGame, gameRaw.addCustomEdition, - gameRaw.deleteCustomEdition, gameRaw.downloadRunner, - gameRaw.customizations, gameRaw.updateCustomization, - gameRaw.gameLog, gameRaw.clearGameLog, - ]); + const game = useMemo( + () => gameRaw, + [ + gameRaw.installs, + gameRaw.isGameRunning, + gameRaw.downloadProgress, + gameRaw.downloadingIds, + gameRaw.editions, + gameRaw.isRunnerDownloading, + gameRaw.runnerDownloadProgress, + gameRaw.error, + gameRaw.updateCustomEdition, + gameRaw.handleUninstall, + gameRaw.handleCancelDownload, + gameRaw.gameUpdateMessage, + configRaw.profile, + gameRaw.updatesAvailable, + gameRaw.addToSteam, + gameRaw.steamSuccessMessage, + gameRaw.cycleBranch, + gameRaw.toggleInstall, + gameRaw.checkInstalls, + gameRaw.handleLaunch, + gameRaw.stopGame, + gameRaw.addCustomEdition, + gameRaw.deleteCustomEdition, + gameRaw.downloadRunner, + gameRaw.customizations, + gameRaw.updateCustomization, + gameRaw.gameLog, + gameRaw.clearGameLog, + ], + ); - const audio = useMemo(() => audioRaw, [ - audioRaw.currentTrack, audioRaw.splashIndex, audioRaw.tracks, audioRaw.splashes - ]); + const audio = useMemo( + () => audioRaw, + [ + audioRaw.currentTrack, + audioRaw.splashIndex, + audioRaw.tracks, + audioRaw.splashes, + ], + ); useDiscordRPC({ rpcEnabled: config.rpcEnabled, @@ -111,7 +178,7 @@ export function LauncherProvider({ children }: { children: React.ReactNode }) { useEffect(() => { if (activeView === "main") { - audioRaw.setSplashIndex(-1); + audioRaw.setSplashIndex(Math.floor(Math.random() * SPLASHES.length)); } }, [activeView]); @@ -143,31 +210,50 @@ export function LauncherProvider({ children }: { children: React.ReactNode }) { extraLaunchArgs: config.extraLaunchArgs, launchPrefix: config.launchPrefix, launchEnvVars: config.launchEnvVars, - startFullscreen: config.startFullscreen, - skipIntro: config.skipIntro, + startFullscreen: config.startFullscreen, + skipIntro: config.skipIntro, + instanceLaunchArgs: config.instanceLaunchArgs, }).catch(console.error); } }, [ - config.username, skinSync.skinBase64, config.theme, config.linuxRunner, - config.perfBoost, config.customEditions, config.profile, + config.username, + skinSync.skinBase64, + config.theme, + config.linuxRunner, + config.perfBoost, + config.customEditions, + config.profile, config.customPaths, - config.customizations, config.vfxEnabled, config.animationsEnabled, - config.rpcEnabled, config.musicVol, config.sfxVol, config.legacyMode, - config.mangohudEnabled, config.extraLaunchArgs, config.launchPrefix, - config.launchEnvVars, config.isLoaded, config.startFullscreen, + config.customizations, + config.vfxEnabled, + config.animationsEnabled, + config.rpcEnabled, + config.musicVol, + config.sfxVol, + config.legacyMode, + config.mangohudEnabled, + config.extraLaunchArgs, + config.launchPrefix, + config.launchEnvVars, + config.isLoaded, + config.startFullscreen, config.skipIntro, + config.instanceLaunchArgs, ]); useEffect(() => { const setupVisibilityDetection = async () => { try { const { listen } = await import("@tauri-apps/api/event"); - const unlistenClose = await listen("tauri://close-requested", async () => { - setIsWindowVisible(false); - if (config.rpcEnabled) { - await RpcService.StopRPC(); - } - }); + const unlistenClose = await listen( + "tauri://close-requested", + async () => { + setIsWindowVisible(false); + if (config.rpcEnabled) { + await RpcService.StopRPC(); + } + }, + ); const unlistenShow = await listen("tauri://window-shown", () => { setIsWindowVisible(true); @@ -178,14 +264,19 @@ export function LauncherProvider({ children }: { children: React.ReactNode }) { }); const unlistenBlur = await listen("tauri://blur", () => { - console.log("Window blurred - checking visibility"); + setIsWindowVisible(false); }); + const onVisibilityChange = () => { + setIsWindowVisible(!document.hidden); + }; + document.addEventListener("visibilitychange", onVisibilityChange); return () => { unlistenClose(); unlistenShow(); unlistenFocus(); unlistenBlur(); + document.removeEventListener("visibilitychange", onVisibilityChange); }; } catch (error) { console.error("Failed to setup visibility detection:", error); @@ -196,14 +287,41 @@ export function LauncherProvider({ children }: { children: React.ReactNode }) { setupVisibilityDetection(); }, [config.rpcEnabled]); - const uiValue = useMemo(() => ({ - activeView, setActiveView, showIntro, setShowIntro, - logoAnimDone, setLogoAnimDone, isUiHidden, setIsUiHidden, - isWindowVisible, - focusSection, setFocusSection, - onNavigateToSkin, onNavigateToMenu, connected, - updateMessage, updateUrl, clearUpdateMessage - }), [activeView, showIntro, logoAnimDone, isUiHidden, isWindowVisible, focusSection, onNavigateToSkin, onNavigateToMenu, connected, updateMessage, updateUrl, clearUpdateMessage]); + const uiValue = useMemo( + () => ({ + activeView, + setActiveView, + showIntro, + setShowIntro, + logoAnimDone, + setLogoAnimDone, + isUiHidden, + setIsUiHidden, + isWindowVisible, + focusSection, + setFocusSection, + onNavigateToSkin, + onNavigateToMenu, + connected, + updateMessage, + updateUrl, + clearUpdateMessage, + }), + [ + activeView, + showIntro, + logoAnimDone, + isUiHidden, + isWindowVisible, + focusSection, + onNavigateToSkin, + onNavigateToMenu, + connected, + updateMessage, + updateUrl, + clearUpdateMessage, + ], + ); return ( @@ -220,8 +338,28 @@ export function LauncherProvider({ children }: { children: React.ReactNode }) { ); } -export const useUI = () => { const c = useContext(UIContext); if (!c) throw new Error("useUI must be used within LauncherProvider"); return c; }; -export const useConfig = () => { const c = useContext(ConfigContext); if (!c) throw new Error("useConfig must be used within LauncherProvider"); return c; }; -export const useAudio = () => { const c = useContext(AudioContext); if (!c) throw new Error("useAudio must be used within LauncherProvider"); return c; }; -export const useGame = () => { const c = useContext(GameContext); if (!c) throw new Error("useGame must be used within LauncherProvider"); return c; }; -export const useSkin = () => { const c = useContext(SkinContext); if (!c) throw new Error("useSkin must be used within LauncherProvider"); return c; }; +export const useUI = () => { + const c = useContext(UIContext); + if (!c) throw new Error("useUI must be used within LauncherProvider"); + return c; +}; +export const useConfig = () => { + const c = useContext(ConfigContext); + if (!c) throw new Error("useConfig must be used within LauncherProvider"); + return c; +}; +export const useAudio = () => { + const c = useContext(AudioContext); + if (!c) throw new Error("useAudio must be used within LauncherProvider"); + return c; +}; +export const useGame = () => { + const c = useContext(GameContext); + if (!c) throw new Error("useGame must be used within LauncherProvider"); + return c; +}; +export const useSkin = () => { + const c = useContext(SkinContext); + if (!c) throw new Error("useSkin must be used within LauncherProvider"); + return c; +}; diff --git a/src/css/index.css b/src/css/index.css index b9ebacf..bb82e3d 100644 --- a/src/css/index.css +++ b/src/css/index.css @@ -1,5 +1,23 @@ @import "tailwindcss"; +@layer base { + *, + ::before, + ::after { + -webkit-font-smoothing: none; + -moz-osx-font-smoothing: auto; + } + button, + input, + select, + textarea { + font-weight: normal; + } + body { + font-synthesis: none; + } +} + @font-face { font-family: "Mojangles"; src: url("/fonts/Mojangles.ttf") format("truetype"); @@ -7,6 +25,20 @@ font-style: normal; } +@font-face { + font-family: "Mojang7"; + src: url("/fonts/Mojang Font_7.ttf") format("truetype"); + font-weight: normal; + font-style: normal; +} + +@font-face { + font-family: "Mojang11"; + src: url("/fonts/Mojang Font_11.ttf") format("truetype"); + font-weight: normal; + font-style: normal; +} + .gamepad-connected-indicator { position: fixed; bottom: 10px; @@ -122,7 +154,6 @@ body { margin: 0; background-color: #000; font-family: "Mojangles", monospace; - -webkit-font-smoothing: none; } .mc-button { @@ -178,7 +209,7 @@ body { } ::-webkit-scrollbar { - width: 14px; + width: 16px; } ::-webkit-scrollbar-track { @@ -188,14 +219,25 @@ body { } ::-webkit-scrollbar-thumb { - background: url("/images/Slider_Handle.png") no-repeat center; - background-size: 100% 100%; + background-image: + url("/images/Slider_Handle_top.png"), + url("/images/Slider_Handle_mid.png"), + url("/images/Slider_Handle_bottom.png"); + background-repeat: no-repeat, no-repeat, no-repeat; + background-position: + 50% 4px, + 50% 8px, + 50% 100%; + background-size: + 16px 4px, + 16px calc(100% - 14px), + 16px 6px; image-rendering: pixelated; min-height: 44px; } .custom-scrollbar::-webkit-scrollbar { - width: 14px; + width: 16px; } .custom-scrollbar::-webkit-scrollbar-track { @@ -205,8 +247,19 @@ body { } .custom-scrollbar::-webkit-scrollbar-thumb { - background: url("/images/Slider_Handle.png") no-repeat center; - background-size: 100% 100%; + background-image: + url("/images/Slider_Handle_top.png"), + url("/images/Slider_Handle_mid.png"), + url("/images/Slider_Handle_bottom.png"); + background-repeat: no-repeat, no-repeat, no-repeat; + background-position: + 50% 4px, + 50% 8px, + 50% 100%; + background-size: + 16px 4px, + 16px calc(100% - 14px), + 16px 6px; image-rendering: pixelated; min-height: 44px; } diff --git a/src/data/splashes.ts b/src/data/splashes.ts index 8092731..37feebc 100644 --- a/src/data/splashes.ts +++ b/src/data/splashes.ts @@ -1,4 +1,5 @@ export const SPLASHES = [ + "Holy moly! - Counterract", "Legacy is back!", "Pixelated goodness!", "Console Edition vibe!", @@ -232,4 +233,4 @@ export const SPLASHES = [ "I LOVE YURI!!!!!!! oh and yaoi ig", "RIP PrismaChunk0's Dog 2010-2026", "Guess who's back, back again!", -]; +]; \ No newline at end of file diff --git a/src/hooks/useAppConfig.ts b/src/hooks/useAppConfig.ts index ec75e68..0b44df7 100644 --- a/src/hooks/useAppConfig.ts +++ b/src/hooks/useAppConfig.ts @@ -26,6 +26,11 @@ export function useAppConfig() { const [launchPrefix, setLaunchPrefix] = useState(); const [launchEnvVars, setLaunchEnvVars] = useState | undefined>(); const [skipIntro, setSkipIntro] = useLocalStorage("lce-skip-intro", false); + const [instanceLaunchArgs, setInstanceLaunchArgs] = useState< + Record; args: string[] }> + >({}); + const [androidRunner, setAndroidRunner] = useLocalStorage("lce-android-runner", undefined); + const [androidAudioBackend, setAndroidAudioBackend] = useLocalStorage<"alsa" | "pulseaudio">("lce-android-audio", "pulseaudio"); useEffect(() => { TauriService.loadConfig().then((config) => { if (config.username) setUsername(config.username); @@ -49,6 +54,9 @@ export function useAppConfig() { if (config.launchPrefix) setLaunchPrefix(config.launchPrefix); if (config.launchEnvVars) setLaunchEnvVars(config.launchEnvVars); if (config.skipIntro !== undefined) setSkipIntro(config.skipIntro); + if (config.instanceLaunchArgs) setInstanceLaunchArgs(config.instanceLaunchArgs); + if (config.androidRunner) setAndroidRunner(config.androidRunner); + if (config.androidAudioBackend) setAndroidAudioBackend(config.androidAudioBackend); setIsLoaded(true); }); }, []); @@ -76,9 +84,12 @@ export function useAppConfig() { launchPrefix, launchEnvVars, skipIntro, + instanceLaunchArgs, + androidRunner, + androidAudioBackend, }).catch(console.error); } - }, [username, theme, linuxRunner, perfBoost, profile, customEditions, customPaths, customizations, animationsEnabled, vfxEnabled, rpcEnabled, startFullscreen, musicVol, sfxVol, legacyMode, mangohudEnabled, extraLaunchArgs, launchPrefix, launchEnvVars, skipIntro, isLoaded]); + }, [username, theme, linuxRunner, perfBoost, profile, customEditions, customPaths, customizations, animationsEnabled, vfxEnabled, rpcEnabled, startFullscreen, musicVol, sfxVol, legacyMode, mangohudEnabled, extraLaunchArgs, launchPrefix, launchEnvVars, skipIntro, isLoaded, instanceLaunchArgs, androidRunner, androidAudioBackend]); return { username, @@ -128,5 +139,11 @@ export function useAppConfig() { setLaunchEnvVars, skipIntro, setSkipIntro, + instanceLaunchArgs, + setInstanceLaunchArgs, + androidRunner, + setAndroidRunner, + androidAudioBackend, + setAndroidAudioBackend, }; } diff --git a/src/hooks/useAudioController.ts b/src/hooks/useAudioController.ts index 687ae6f..14cb900 100644 --- a/src/hooks/useAudioController.ts +++ b/src/hooks/useAudioController.ts @@ -30,7 +30,9 @@ export function useAudioController({ isWindowVisible, }: AudioControllerProps) { const [currentTrack, setCurrentTrack] = useState(0); - const [splashIndex, setSplashIndex] = useState(-1); + const [splashIndex, setSplashIndex] = useState( + () => Math.floor(Math.random() * SPLASHES.length), + ); const audioContextRef = useRef(null); const musicSourceRef = useRef(null); const musicGainRef = useRef(null); diff --git a/src/hooks/useDiscordRPC.ts b/src/hooks/useDiscordRPC.ts index 5f66430..ccd88b8 100644 --- a/src/hooks/useDiscordRPC.ts +++ b/src/hooks/useDiscordRPC.ts @@ -78,7 +78,22 @@ export function useDiscordRPC({ details = tabNames[activeView] || "In Menus"; } - await RpcService.updateActivity(details, state, isGameRunning, username); + const skinUrl = (() => { + try { + return ( + JSON.parse(localStorage.getItem("lce-skin") || "null") || undefined + ); + } catch { + return undefined; + } + })(); + await RpcService.updateActivity( + details, + state, + isGameRunning, + username, + skinUrl, + ); }; updateRPC(); diff --git a/src/hooks/useGameManager.ts b/src/hooks/useGameManager.ts index 7185811..5184e09 100644 --- a/src/hooks/useGameManager.ts +++ b/src/hooks/useGameManager.ts @@ -9,8 +9,9 @@ import { } from "react"; import { TauriService, type CustomEdition } from "../services/TauriService"; import { getCurrentWindow } from "@tauri-apps/api/window"; +import { usePlatform } from "./usePlatform"; +import { HIDDEN_INSTANCE_URL } from "../types/edition"; import type { Edition } from "../types/edition"; - async function imageUrlToBase64(url: string): Promise { const response = await fetch(url); const blob = await response.blob(); @@ -30,7 +31,7 @@ export const BASE_EDITIONS = [ id: "legacy_evolved", name: "neoLegacy", desc: "Backporting newer title updates and Minigames back to LCE", - url: "https://bucket.ibatv.xyz/neolegacy/Release.zip", + url: "https://git.neolegacy.dev/neoStudiosLCE/neoLegacy/releases/download/latest/neoLegacyWindows64.zip", //neo: fuck IBA (Julia) titleImage: "/images/minecraft_title_neoLegacy.png", supportsSlimSkins: true, logo: "/images/neoLegacy.png", @@ -47,6 +48,28 @@ export const BASE_EDITIONS = [ panorama: "vanilla_tu24", logo: "/images/revelations.png", }, + /*{ + id: "cafeberry", + name: "Cafeberry", + desc: "Project aiming to faithfully backport TUs, add cross-play and more!", + url: "https://gitea.str1k3r.xyz/cafeberry/cafeberry/releases/download/latest/LCEWindows64.zip", + titleImage: "/images/cafeberry_title.png", + supportsSlimSkins: false, + logo: "", //neo: TODO: add Cafeberry logo + panorama: "vanilla_tu24", + lceOnline: false, //neo: for now. + },*/ + { + id: "lostlegacy", + name: "Project Lost Legacy", + desc: "Downporting project aiming at bringing back the feel of Title Update 1 with extra QoL features.", + url: "no", //neo: TODO: update url when sails makes it public + titleImage: "/images/lostlegacy_title.png", + logo: "/images/lostlegacy.png", + supportsSlimSkins: false, + panorama: "vanilla_tu1", + comingSoon: true, + }, { id: "360revived", name: "360 Revived", @@ -56,6 +79,7 @@ export const BASE_EDITIONS = [ supportsSlimSkins: false, logo: "/images/360_revived.png", panorama: "360revived", + hideOnAndroid: true, //neo: 360revived shows a black screen on Android }, { id: "legacy_nether_fork", @@ -78,16 +102,27 @@ export const BASE_EDITIONS = [ panorama: "moonedition", }, { + //neo: disabled. id: "lceonline", name: "LCE Online Client", - desc: "Restoring the classic LCE online experience with friends, world hosting, leaderboards & more.", - url: "https://github.com/lceonline/MCLEClient/releases/latest/download/LCENWindows64.zip", + desc: "[DISCONTINUED!] Restoring the classic LCE online experience with friends, world hosting, leaderboards & more.", + url: HIDDEN_INSTANCE_URL, //neo: was "https://github.com/lceonline/MCLEClient/releases/latest/download/LCENWindows64.zip" titleImage: "/images/lceonline.png", supportsSlimSkins: false, logo: "/images/lce_online.png", panorama: "vanilla_tu19", lceOnline: true, }, + { + id: "amythest", + name: "Amethyst LCE", + desc: "A project aimed towards backporting modern Java edition features and their feel into LCE! ", + logo: "/images/amythest.png", + panorama: "vanilla_tu24", //neo: TODO: use the Amythest's panorama + supportsSlimSkins: false, //neo: TODO: check properly lol + titleImage: "/images/amythest_title.png", + url: "https://github.com/ducttapesucker9000-svg/Amethyst_Source/releases/download/latest/Amethyst-Windows-Release.zip", + }, ]; const PARTNERSHIP_SERVERS = [ @@ -105,7 +140,7 @@ const PARTNERSHIP_SERVERS = [ name: "LapboardMC", ip: "104.168.125.227", port: 4444, - } + }, ]; interface GameManagerProps { @@ -149,6 +184,7 @@ export function useGameManager({ setCustomizations, extraLaunchArgs, }: GameManagerProps) { + const { isAndroid } = usePlatform(); const [installs, setInstalls] = useState([]); const [isGameRunning, setIsGameRunning] = useState(false); const [downloadProgress, setDownloadProgress] = useState< @@ -283,36 +319,38 @@ export function useGameManager({ const editions = useMemo((): Edition[] => { return [ - ...BASE_EDITIONS.map((e) => { - const availableBranches = branches[e.id] || ["Stable"]; - const selectedBranch = selectedBranches[e.id] || availableBranches[0]; - let url = dynamicUrls[e.id] || e.url; - const defaultBranchFromUrl = e.url.includes("/releases/download/") - ? e.url.split("/releases/download/")[1].split("/")[0] - : "nightly"; - const branchToUse = - selectedBranch === "Stable" - ? dynamicUrls[`${e.id}_Stable`] || defaultBranchFromUrl - : selectedBranch; - if (e.url.includes("/releases/download/")) { - const baseUrl = e.url.split("/releases/download/")[0]; - const filename = e.url.split("/").pop(); - url = `${baseUrl}/releases/download/${branchToUse}/${filename}`; - } + ...BASE_EDITIONS.filter((e) => !(isAndroid && e.hideOnAndroid)).map( + (e) => { + const availableBranches = branches[e.id] || ["Stable"]; + const selectedBranch = selectedBranches[e.id] || availableBranches[0]; + let url = dynamicUrls[e.id] || e.url; + const defaultBranchFromUrl = e.url.includes("/releases/download/") + ? e.url.split("/releases/download/")[1].split("/")[0] + : "nightly"; + const branchToUse = + selectedBranch === "Stable" + ? dynamicUrls[`${e.id}_Stable`] || defaultBranchFromUrl + : selectedBranch; + if (e.url.includes("/releases/download/")) { + const baseUrl = e.url.split("/releases/download/")[0]; + const filename = e.url.split("/").pop(); + url = `${baseUrl}/releases/download/${branchToUse}/${filename}`; + } - const edition = { - ...e, - url, - branches: availableBranches, - selectedBranch, - instanceId: - selectedBranch === "Stable" ? e.id : `${e.id}_${selectedBranch}`, - }; - const custom = customizations[e.id]; - if (custom?.titleImage) edition.titleImage = custom.titleImage; - if (custom?.panorama) edition.panorama = custom.panorama; - return edition; - }), + const edition = { + ...e, + url, + branches: availableBranches, + selectedBranch, + instanceId: + selectedBranch === "Stable" ? e.id : `${e.id}_${selectedBranch}`, + }; + const custom = customizations[e.id]; + if (custom?.titleImage) edition.titleImage = custom.titleImage; + if (custom?.panorama) edition.panorama = custom.panorama; + return edition; + }, + ), ...customEditions.map((e) => { const edition: Edition = { ...e, instanceId: e.id }; const custom = customizations[e.id]; @@ -321,7 +359,14 @@ export function useGameManager({ return edition; }), ]; - }, [customEditions, dynamicUrls, branches, selectedBranches, customizations]); + }, [ + customEditions, + dynamicUrls, + branches, + selectedBranches, + customizations, + isAndroid, + ]); const checkInstalls = useCallback(async () => { const results = await Promise.all( @@ -341,6 +386,8 @@ export function useGameManager({ editions.map(async (edition) => { if (!installs.includes(edition.instanceId)) return [edition.instanceId, false] as const; + if (edition.url === HIDDEN_INSTANCE_URL) + return [edition.instanceId, false] as const; try { const isUpdate = await TauriService.checkGameUpdate( edition.instanceId, @@ -401,7 +448,7 @@ export function useGameManager({ gameLogRef.current = true; setError(null); setGameLog(log); - getCurrentWindow().unminimize(); + if (!isAndroid) getCurrentWindow().unminimize(); }); return () => { unlistenDownload.then((u) => u()); @@ -410,7 +457,7 @@ export function useGameManager({ unlistenRetry.then((u) => u()); unlistenGameLog.then((u) => u()); }; - }, [customEditions, checkInstalls]); + }, [customEditions, checkInstalls, isAndroid]); const downloadRunner = useCallback( async (name: string, url: string) => { @@ -508,7 +555,7 @@ export function useGameManager({ setError(null); setIsGameRunning(true); try { - getCurrentWindow().minimize(); + if (!isAndroid) getCurrentWindow().minimize(); const currentEdition = editions.find((e) => e.instanceId === profile); await TauriService.launchGame( profile, @@ -537,7 +584,7 @@ export function useGameManager({ } finally { setIsGameRunning(false); } - }, [isGameRunning, profile, extraLaunchArgs]); + }, [isGameRunning, profile, extraLaunchArgs, isAndroid]); const stopGame = useCallback(async () => { try { @@ -682,4 +729,4 @@ export function useGameManager({ updateCustomization, saveCustomPath, }; -} \ No newline at end of file +} diff --git a/src/hooks/useGamepad.ts b/src/hooks/useGamepad.ts index 17adc0f..740d2cb 100644 --- a/src/hooks/useGamepad.ts +++ b/src/hooks/useGamepad.ts @@ -1,9 +1,20 @@ import { useEffect, useRef, useState, useCallback } from 'react'; + export interface UseGamepadProps { playSfx: (file: string) => void; isWindowVisible: boolean; } +const KEY_CODE_MAP: Record = { + Enter: 13, Escape: 27, Tab: 9, + ArrowDown: 40, ArrowUp: 38, ArrowLeft: 37, ArrowRight: 39, +}; + +const CODE_MAP: Record = { + Enter: 'Enter', Escape: 'Escape', Tab: 'Tab', + ArrowDown: 'ArrowDown', ArrowUp: 'ArrowUp', ArrowLeft: 'ArrowLeft', ArrowRight: 'ArrowRight', +}; + export const useGamepad = ({ playSfx, isWindowVisible }: UseGamepadProps) => { const [connected, setConnected] = useState(false); const requestRef = useRef(undefined); @@ -32,12 +43,16 @@ export const useGamepad = ({ playSfx, isWindowVisible }: UseGamepadProps) => { }, [playSfx]); const dispatchKey = (key: string, shiftKey = false) => { - window.dispatchEvent(new KeyboardEvent('keydown', { - key, shiftKey, bubbles: true, cancelable: true, view: window - })); - window.dispatchEvent(new KeyboardEvent('keyup', { - key, shiftKey, bubbles: true, cancelable: true, view: window - })); + const code = CODE_MAP[key] ?? key; + const keyCode = KEY_CODE_MAP[key] ?? 0; + const opts = { + key, code, keyCode, which: keyCode, shiftKey, + bubbles: true, cancelable: true, view: window, + }; + window.dispatchEvent(new KeyboardEvent('keydown', opts)); + setTimeout(() => { + window.dispatchEvent(new KeyboardEvent('keyup', opts)); + }, 10); }; const update = useCallback(() => { @@ -120,4 +135,4 @@ export const useGamepad = ({ playSfx, isWindowVisible }: UseGamepadProps) => { }, [connected, update, isWindowVisible]); return { connected }; -}; \ No newline at end of file +}; diff --git a/src/hooks/useLceOnlineNotifications.ts b/src/hooks/useLceOnlineNotifications.ts index 7f70dac..d37b6ca 100644 --- a/src/hooks/useLceOnlineNotifications.ts +++ b/src/hooks/useLceOnlineNotifications.ts @@ -20,7 +20,7 @@ export function useLceOnlineNotifications() { requestsData.requests.forEach((r) => { if (!seenRequests.current.has(r.username)) { seenRequests.current.add(r.username); - setFriendRequestMessage(`${r.displayName} wants to be friends!`); + setFriendRequestMessage(`${r.displayName || r.username} wants to be friends!`); } }); } catch (e) {} @@ -30,7 +30,7 @@ export function useLceOnlineNotifications() { invitesData.forEach((i) => { if (!seenInvites.current.has(i.inviteid)) { seenInvites.current.add(i.inviteid); - setInviteMessage(`${i.from.displayName} invited you to play!`); + setInviteMessage(`${i.from.displayName || i.from.username} invited you to play!`); } }); } catch {} @@ -53,7 +53,7 @@ export function useLceOnlineNotifications() { invitesData.forEach((i) => { if (!seenInvites.current.has(i.inviteid)) { seenInvites.current.add(i.inviteid); - setInviteMessage(`${i.from.displayName} invited you to play!`); + setInviteMessage(`${i.from.displayName || i.from.username} invited you to play!`); } }); } catch {} diff --git a/src/hooks/usePlatform.ts b/src/hooks/usePlatform.ts index fa3320a..0af9459 100644 --- a/src/hooks/usePlatform.ts +++ b/src/hooks/usePlatform.ts @@ -1,13 +1,14 @@ import { useMemo } from 'react'; export function usePlatform() { const platform = useMemo(() => { - if (typeof window === 'undefined') return { isLinux: false, isMac: false, isWindows: false }; + if (typeof window === 'undefined') return { isLinux: false, isMac: false, isWindows: false, isAndroid: false }; const ua = window.navigator.userAgent.toLowerCase(); const plat = window.navigator.platform.toLowerCase(); - const isLinux = plat.includes('linux') || ua.includes('linux'); - const isMac = plat.includes('mac') || ua.includes('mac'); - const isWindows = plat.includes('win') || ua.includes('win'); - return { isLinux, isMac, isWindows }; + const isAndroid = ua.includes('android'); + const isLinux = !isAndroid && (plat.includes('linux') || ua.includes('linux')); + const isMac = !isAndroid && (plat.includes('mac') || ua.includes('mac')); + const isWindows = !isAndroid && (plat.includes('win') || ua.includes('win')); + return { isLinux, isMac, isWindows, isAndroid }; }, []); return platform; diff --git a/src/pages/App.tsx b/src/pages/App.tsx index bd8f40e..714747d 100644 --- a/src/pages/App.tsx +++ b/src/pages/App.tsx @@ -261,19 +261,19 @@ export default function App() { if (unlistenEvent) unlistenEvent(); }; }, [queueDeepLink]); - const { isMac } = usePlatform(); + const { isMac, isAndroid } = usePlatform(); const [isFullscreen, setIsFullscreen] = useState(false); useEffect(() => { const appWindow = getCurrentWindow(); - if (!isMac) appWindow.setDecorations(false); + if (!isMac && !isAndroid) appWindow.setDecorations(false); const checkFs = async () => setIsFullscreen(await appWindow.isFullscreen()); checkFs(); const unlisten = appWindow.onResized(checkFs); return () => { unlisten.then((fn: () => void) => fn()); }; - }, [isMac]); - const showHeader = !isMac || isFullscreen; + }, [isMac, isAndroid]); + const showHeader = (!isMac || isFullscreen) && !isAndroid; useEffect(() => { if (config.isLoaded) { const setupCompleted = @@ -290,6 +290,21 @@ export default function App() { const selectedVersionName = selectedEdition?.name ?? ""; const hasAnyInstall = game.installs.length > 0; const titleImage = selectedEdition?.titleImage ?? "/images/MenuTitle.png"; + const TITLE_HIDDEN_VIEWS = new Set([ + //neo: why an entire Set for that? yes. the answer is yes. + "workshop", + "lceonline", + "devtools", + "guides", + "pck-editor", + "arc-editor", + "loc-editor", + "grf-editor", + "col-editor", + "options-editor", + "model-editor", + "swf-editor", + ]); useEffect(() => { const handleContextMenu = (e: MouseEvent) => e.preventDefault(); document.addEventListener("contextmenu", handleContextMenu); @@ -455,7 +470,7 @@ export default function App() { animate={{ opacity: 1 }} className={`flex flex-col h-full z-10 w-full relative ${showHeader ? "pt-12" : ""}`} > - {!config.legacyMode && ( + {!config.legacyMode && !isAndroid && ( + + )} + {!config.legacyMode && (
- {activeView !== "credits" && ( - - )} - {activeView !== "credits" && ( - -
+ )} + {activeView !== "credits" && + !TITLE_HIDDEN_VIEWS.has(activeView) && ( + - {audio.splashIndex === -1 - ? `Welcome ${config.username}!` - : audio.splashes[audio.splashIndex]} -
-
- )} +
+ {audio.splashIndex === -1 + ? `Welcome ${config.username}!` + : audio.splashes[audio.splashIndex]} +
+ + )} {activeView === "main" && hasAnyInstall && titleImage === "/images/MenuTitle.png" && ( @@ -599,6 +659,7 @@ export default function App() { setIsUiHidden={setIsUiHidden} isFocusedSection={focusSection === "skin"} onNavigateRight={onNavigateToMenu} + slim={skin.skinIsSlim} /> )} @@ -681,10 +742,6 @@ export default function App() {
Version: {pkg.version} ({__BUILD_DATE__})
-
- Not affiliated with Mojang AB or Microsoft. "Minecraft" is a - trademark of Mojang Synergies AB. -
{connected && "CONTROLLER CONNECTED"}
diff --git a/src/services/LceOnlineService.ts b/src/services/LceOnlineService.ts index 60d6ba7..30ebc0a 100644 --- a/src/services/LceOnlineService.ts +++ b/src/services/LceOnlineService.ts @@ -83,7 +83,7 @@ export class LceOnlineService { const res = await this.request( "POST", "/login", - `${username}:${password}`, + `${JSON.stringify({ username: username, password: password })}`, AUTH_BASE_URL, ); const text = typeof res === "string" ? res : ""; @@ -98,7 +98,7 @@ export class LceOnlineService { const res = await this.request( "POST", "/register", - `${username}:${password}`, + `${JSON.stringify({ username: username, password: password })}`, AUTH_BASE_URL, ); const text = typeof res === "string" ? res : ""; @@ -128,9 +128,10 @@ export class LceOnlineService { this._notify(); try { const raw: string = await this.request("POST", "/accountinfo"); - if (typeof raw === "string" && raw.startsWith("-")) { - const username = raw.slice(1); - this._session!.account = { username, displayName: username }; + if (typeof raw === "object" && raw !== null) { + const data = JSON.parse(raw); + const username = data.username; + this._session!.account = { username, displayName: data.displayName }; this.saveSession(); this._notify(); } @@ -166,7 +167,7 @@ export class LceOnlineService { ): Promise { const headers: Record = { Accept: "text/plain, application/json", - "User-Agent": "MCLCE-LCEOnline/1.0", + "User-Agent": "MCLCE-LCEOnline/1.1", // str1k3r - changed the user agent to use 1.1 so older versions use older apis & get older responses. }; if (body) { @@ -217,9 +218,9 @@ export class LceOnlineService { try { const res = await this.request("POST", "/refreshtoken", null, AUTH_BASE_URL); if (typeof res === "string" && res.startsWith("-")) { - const [username, token] = res.slice(1).split(":"); - this._session.accessToken = token; - this._session.account = { username, displayName: username }; + const data = JSON.parse(res); + this._session.accessToken = data.token; + this._session.account = { username: data.username, displayName: data.displayName }; this.saveSession(); this._notify(); return true; @@ -273,7 +274,7 @@ export class LceOnlineService { async sendInvite(target: string): Promise { const res = await this.request("POST", "/invite", target); - if (typeof res === "string" && res !== "Invite Sent") { + if (typeof res === "string" && res !== "Successfully Sent Invite") { throw new Error(res); } } @@ -289,7 +290,7 @@ export class LceOnlineService { await this.request("POST", "/declineinvite", from); } catch (e: unknown) { const msg = e instanceof Error ? e.message : ""; - if (msg !== "Declined Invite") throw e; + if (msg !== "Successfully Declined Invite") throw e; } } diff --git a/src/services/OptionsService.ts b/src/services/OptionsService.ts index 34b7f6b..a1def6a 100644 --- a/src/services/OptionsService.ts +++ b/src/services/OptionsService.ts @@ -1,14 +1,17 @@ import { OptionsFile } from "../types/options"; export class OptionsService { - public static readOptions(buffer: ArrayBuffer, endianness: "little" | "big" = "little"): OptionsFile { + public static readOptions( + buffer: ArrayBuffer, + endianness: "little" | "big" = "little", + ): OptionsFile { const rawData = new Uint8Array(buffer).slice(); const view = new DataView(buffer); const little = endianness === "little"; const opt: Partial = { endianness, - rawData + rawData, }; - + //neo: someone save me from these values opt.musicVolume = view.getUint8(0x01); opt.soundVolume = view.getUint8(0x02); opt.gameSensitivity = view.getUint8(0x03); @@ -24,7 +27,7 @@ export class OptionsService { opt.verticalSplitscreen = (val06 & (1 << 8)) !== 0; opt.splitscreenGamertags = (val06 & (1 << 9)) !== 0; opt.hints = (val06 & (1 << 10)) !== 0; - opt.autosaveTimer = (val06 >> 11) & 0xF; + opt.autosaveTimer = (val06 >> 11) & 0xf; opt.inGameTooltips = (val06 & (1 << 15)) !== 0; const val54 = view.getUint32(0x54, little); opt.renderClouds = (val54 & (1 << 0)) !== 0; @@ -45,46 +48,46 @@ export class OptionsService { opt.displaySaveIcon = (val54 & (1 << 29)) !== 0; opt.flyingViewRolling = (val54 & (1 << 30)) === 0; opt.showGlideGhostPath = (val54 & (1 << 31)) !== 0; - opt.chosenSkin = view.getUint32(0x4C, little); - opt.playerCape = view.getUint32(0x5C, little); + opt.chosenSkin = view.getUint32(0x4c, little); + opt.playerCape = view.getUint32(0x5c, little); opt.favoriteSkins = []; for (let i = 0; i < 10; i++) { opt.favoriteSkins.push(view.getUint32(0x60 + i * 4, little)); } opt.actions = { - jump: view.getUint8(0xA4), - use: view.getUint8(0xA5), - action: view.getUint8(0xA6), - cycleHeldItemLeft: view.getUint8(0xA7), - cycleHeldItemRight: view.getUint8(0xA8), - inventory: view.getUint8(0xA9), - drop: view.getUint8(0xAA), - sneakDismount: view.getUint8(0xAB), - crafting: view.getUint8(0xAC), - changeCameraMode: view.getUint8(0xAD), - flyLeft: view.getUint8(0xAE), - flyRight: view.getUint8(0xAF), - flyUp: view.getUint8(0xB0), - flyDown: view.getUint8(0xB1), - sprint: view.getUint8(0xB2), - pickBlock: view.getUint8(0xB3), - previousPlayer: view.getUint8(0xB4), - nextPlayer: view.getUint8(0xB5), - spectateNoise: view.getUint8(0xB6), - cancelSpectating: view.getUint8(0xB7), - confirmReady: view.getUint8(0xB8), - vote: view.getUint8(0xB9), - restartSection: view.getUint8(0xBA), - restartRace: view.getUint8(0xBB), - lookBehind: view.getUint8(0xBC) + jump: view.getUint8(0xa4), + use: view.getUint8(0xa5), + action: view.getUint8(0xa6), + cycleHeldItemLeft: view.getUint8(0xa7), + cycleHeldItemRight: view.getUint8(0xa8), + inventory: view.getUint8(0xa9), + drop: view.getUint8(0xaa), + sneakDismount: view.getUint8(0xab), + crafting: view.getUint8(0xac), + changeCameraMode: view.getUint8(0xad), + flyLeft: view.getUint8(0xae), + flyRight: view.getUint8(0xaf), + flyUp: view.getUint8(0xb0), + flyDown: view.getUint8(0xb1), + sprint: view.getUint8(0xb2), + pickBlock: view.getUint8(0xb3), + previousPlayer: view.getUint8(0xb4), + nextPlayer: view.getUint8(0xb5), + spectateNoise: view.getUint8(0xb6), + cancelSpectating: view.getUint8(0xb7), + confirmReady: view.getUint8(0xb8), + vote: view.getUint8(0xb9), + restartSection: view.getUint8(0xba), + restartRace: view.getUint8(0xbb), + lookBehind: view.getUint8(0xbc), }; return opt as OptionsFile; } public static serializeOptions(opt: OptionsFile): ArrayBuffer { - const minSize = 0xBC + 1; + const minSize = 0xbc + 1; const buffer = new Uint8Array(Math.max(opt.rawData.length, minSize)); buffer.set(opt.rawData); const view = new DataView(buffer.buffer); @@ -97,69 +100,69 @@ export class OptionsService { view.setUint8(0x51, opt.interfaceOpacity); let val06 = view.getUint16(0x06, little); val06 = (val06 & ~0x3) | (opt.difficulty & 0x3); - val06 = (val06 & ~(1 << 2)) | (opt.viewBobbing ? (1 << 2) : 0); - val06 = (val06 & ~(1 << 3)) | (opt.inGameGamertags ? (1 << 3) : 0); - val06 = (val06 & ~(1 << 6)) | (opt.invertLook ? (1 << 6) : 0); - val06 = (val06 & ~(1 << 7)) | (opt.southpaw ? (1 << 7) : 0); - val06 = (val06 & ~(1 << 8)) | (opt.verticalSplitscreen ? (1 << 8) : 0); - val06 = (val06 & ~(1 << 9)) | (opt.splitscreenGamertags ? (1 << 9) : 0); - val06 = (val06 & ~(1 << 10)) | (opt.hints ? (1 << 10) : 0); - val06 = (val06 & ~(0xF << 11)) | ((opt.autosaveTimer & 0xF) << 11); - val06 = (val06 & ~(1 << 15)) | (opt.inGameTooltips ? (1 << 15) : 0); + val06 = (val06 & ~(1 << 2)) | (opt.viewBobbing ? 1 << 2 : 0); + val06 = (val06 & ~(1 << 3)) | (opt.inGameGamertags ? 1 << 3 : 0); + val06 = (val06 & ~(1 << 6)) | (opt.invertLook ? 1 << 6 : 0); + val06 = (val06 & ~(1 << 7)) | (opt.southpaw ? 1 << 7 : 0); + val06 = (val06 & ~(1 << 8)) | (opt.verticalSplitscreen ? 1 << 8 : 0); + val06 = (val06 & ~(1 << 9)) | (opt.splitscreenGamertags ? 1 << 9 : 0); + val06 = (val06 & ~(1 << 10)) | (opt.hints ? 1 << 10 : 0); + val06 = (val06 & ~(0xf << 11)) | ((opt.autosaveTimer & 0xf) << 11); + val06 = (val06 & ~(1 << 15)) | (opt.inGameTooltips ? 1 << 15 : 0); view.setUint16(0x06, val06, little); let val54 = view.getUint32(0x54, little); - val54 = (val54 & ~(1 << 0)) | (opt.renderClouds ? (1 << 0) : 0); - val54 = (val54 & ~(1 << 7)) | (opt.displayHud ? (1 << 7) : 0); - val54 = (val54 & ~(1 << 8)) | (opt.displayHand ? (1 << 8) : 0); - val54 = (val54 & ~(1 << 9)) | (opt.customSkinAnimation ? (1 << 9) : 0); - val54 = (val54 & ~(1 << 10)) | (opt.deathMessages ? (1 << 10) : 0); + val54 = (val54 & ~(1 << 0)) | (opt.renderClouds ? 1 << 0 : 0); + val54 = (val54 & ~(1 << 7)) | (opt.displayHud ? 1 << 7 : 0); + val54 = (val54 & ~(1 << 8)) | (opt.displayHand ? 1 << 8 : 0); + val54 = (val54 & ~(1 << 9)) | (opt.customSkinAnimation ? 1 << 9 : 0); + val54 = (val54 & ~(1 << 10)) | (opt.deathMessages ? 1 << 10 : 0); val54 = (val54 & ~(0x3 << 11)) | ((opt.hudSize & 0x3) << 11); val54 = (val54 & ~(0x3 << 13)) | ((opt.hudSizeSplitscreen & 0x3) << 13); - val54 = (val54 & ~(1 << 15)) | (opt.animatedCharacter ? (1 << 15) : 0); - val54 = (val54 & ~(1 << 18)) | (opt.classicCrafting ? (1 << 18) : 0); - val54 = (val54 & ~(1 << 19)) | (opt.caveSounds ? (1 << 19) : 0); - val54 = (val54 & ~(1 << 20)) | (opt.gameChat ? (1 << 20) : 0); - val54 = (val54 & ~(1 << 21)) | (opt.minecartSounds ? (1 << 21) : 0); - val54 = (val54 & ~(1 << 22)) | (opt.showGlideGhost ? (1 << 22) : 0); - val54 = (val54 & ~(1 << 26)) | (opt.autoJump ? (1 << 26) : 0); - val54 = (val54 & ~(1 << 28)) | (opt.displayGameMessages ? (1 << 28) : 0); - val54 = (val54 & ~(1 << 29)) | (opt.displaySaveIcon ? (1 << 29) : 0); - val54 = (val54 & ~(1 << 30)) | (!opt.flyingViewRolling ? (1 << 30) : 0); - val54 = (val54 & ~(1 << 31)) | (opt.showGlideGhostPath ? (1 << 31) : 0); + val54 = (val54 & ~(1 << 15)) | (opt.animatedCharacter ? 1 << 15 : 0); + val54 = (val54 & ~(1 << 18)) | (opt.classicCrafting ? 1 << 18 : 0); + val54 = (val54 & ~(1 << 19)) | (opt.caveSounds ? 1 << 19 : 0); + val54 = (val54 & ~(1 << 20)) | (opt.gameChat ? 1 << 20 : 0); + val54 = (val54 & ~(1 << 21)) | (opt.minecartSounds ? 1 << 21 : 0); + val54 = (val54 & ~(1 << 22)) | (opt.showGlideGhost ? 1 << 22 : 0); + val54 = (val54 & ~(1 << 26)) | (opt.autoJump ? 1 << 26 : 0); + val54 = (val54 & ~(1 << 28)) | (opt.displayGameMessages ? 1 << 28 : 0); + val54 = (val54 & ~(1 << 29)) | (opt.displaySaveIcon ? 1 << 29 : 0); + val54 = (val54 & ~(1 << 30)) | (!opt.flyingViewRolling ? 1 << 30 : 0); + val54 = (val54 & ~(1 << 31)) | (opt.showGlideGhostPath ? 1 << 31 : 0); view.setUint32(0x54, val54, little); - view.setUint32(0x4C, opt.chosenSkin, little); - view.setUint32(0x5C, opt.playerCape, little); + view.setUint32(0x4c, opt.chosenSkin, little); + view.setUint32(0x5c, opt.playerCape, little); for (let i = 0; i < 10; i++) { if (opt.favoriteSkins[i] !== undefined) { view.setUint32(0x60 + i * 4, opt.favoriteSkins[i], little); } } - view.setUint8(0xA4, opt.actions.jump); - view.setUint8(0xA5, opt.actions.use); - view.setUint8(0xA6, opt.actions.action); - view.setUint8(0xA7, opt.actions.cycleHeldItemLeft); - view.setUint8(0xA8, opt.actions.cycleHeldItemRight); - view.setUint8(0xA9, opt.actions.inventory); - view.setUint8(0xAA, opt.actions.drop); - view.setUint8(0xAB, opt.actions.sneakDismount); - view.setUint8(0xAC, opt.actions.crafting); - view.setUint8(0xAD, opt.actions.changeCameraMode); - view.setUint8(0xAE, opt.actions.flyLeft); - view.setUint8(0xAF, opt.actions.flyRight); - view.setUint8(0xB0, opt.actions.flyUp); - view.setUint8(0xB1, opt.actions.flyDown); - view.setUint8(0xB2, opt.actions.sprint); - view.setUint8(0xB3, opt.actions.pickBlock); - view.setUint8(0xB4, opt.actions.previousPlayer); - view.setUint8(0xB5, opt.actions.nextPlayer); - view.setUint8(0xB6, opt.actions.spectateNoise); - view.setUint8(0xB7, opt.actions.cancelSpectating); - view.setUint8(0xB8, opt.actions.confirmReady); - view.setUint8(0xB9, opt.actions.vote); - view.setUint8(0xBA, opt.actions.restartSection); - view.setUint8(0xBB, opt.actions.restartRace); - view.setUint8(0xBC, opt.actions.lookBehind); + view.setUint8(0xa4, opt.actions.jump); + view.setUint8(0xa5, opt.actions.use); + view.setUint8(0xa6, opt.actions.action); + view.setUint8(0xa7, opt.actions.cycleHeldItemLeft); + view.setUint8(0xa8, opt.actions.cycleHeldItemRight); + view.setUint8(0xa9, opt.actions.inventory); + view.setUint8(0xaa, opt.actions.drop); + view.setUint8(0xab, opt.actions.sneakDismount); + view.setUint8(0xac, opt.actions.crafting); + view.setUint8(0xad, opt.actions.changeCameraMode); + view.setUint8(0xae, opt.actions.flyLeft); + view.setUint8(0xaf, opt.actions.flyRight); + view.setUint8(0xb0, opt.actions.flyUp); + view.setUint8(0xb1, opt.actions.flyDown); + view.setUint8(0xb2, opt.actions.sprint); + view.setUint8(0xb3, opt.actions.pickBlock); + view.setUint8(0xb4, opt.actions.previousPlayer); + view.setUint8(0xb5, opt.actions.nextPlayer); + view.setUint8(0xb6, opt.actions.spectateNoise); + view.setUint8(0xb7, opt.actions.cancelSpectating); + view.setUint8(0xb8, opt.actions.confirmReady); + view.setUint8(0xb9, opt.actions.vote); + view.setUint8(0xba, opt.actions.restartSection); + view.setUint8(0xbb, opt.actions.restartRace); + view.setUint8(0xbc, opt.actions.lookBehind); return buffer.buffer; } } diff --git a/src/services/RpcService.ts b/src/services/RpcService.ts index 53839d4..98c3504 100644 --- a/src/services/RpcService.ts +++ b/src/services/RpcService.ts @@ -1,3 +1,4 @@ +import { invoke } from "@tauri-apps/api/core"; import { setActivity, start, clearActivity, stop } from "tauri-plugin-drpc"; import { Activity, @@ -11,6 +12,95 @@ class RPC { private startTime: number = Date.now(); private initializationPromise: Promise | null = null; private initialized: boolean = false; + private headUrlCache: Map = new Map(); + private headUploadPending: Map> = new Map(); + private async renderHeadBlob(skinUrl: string): Promise { + return new Promise((resolve) => { + const img = new Image(); + img.crossOrigin = "anonymous"; + img.onload = () => { + const canvas = document.createElement("canvas"); + canvas.width = 64; + canvas.height = 64; + const ctx = canvas.getContext("2d"); + if (!ctx) { + resolve(null); + return; + } + ctx.imageSmoothingEnabled = false; + ctx.drawImage(img, 8, 8, 8, 8, 0, 0, 64, 64); + if (img.height !== 32) { + ctx.drawImage(img, 40, 8, 8, 8, 0, 0, 64, 64); + } + canvas.toBlob((blob) => { + resolve(blob); + }, "image/png"); + }; + img.onerror = () => { + resolve(null); + }; + img.src = skinUrl; + }); + } + + private async uploadHead(skinUrl: string): Promise { + const cached = this.headUrlCache.get(skinUrl); + if (cached) { + return cached; + } + const pending = this.headUploadPending.get(skinUrl); + if (pending) { + return pending; + } + const uploadPromise = (async (): Promise => { + const blob = await this.renderHeadBlob(skinUrl); + if (!blob) { + return null; + } + const formData = new FormData(); + formData.append("file", blob, "head.png"); + formData.append("expire", "21600"); + try { + const res = await fetch("https://tmpfiles.org/api/v1/upload", { + method: "POST", + body: formData, + }); + const json = await res.json(); + if (json.status === "success" && json.data?.url) { + const pageUrl = json.data.url; + const pageRes = await invoke<{ status: number; body: string }>( + "http_proxy_request", + { + method: "GET", + url: pageUrl, + body: null, + headers: {}, + }, + ); + const html = pageRes.body; + const match = html.match(/id="img_preview"\s+src="([^"]+)"/); + if (match) { + const directUrl = match[1]; + this.headUrlCache.set(skinUrl, directUrl); + return directUrl; + } + console.error( + "[RPC] uploadHead: could not extract direct image url from page", + ); + } + console.error("[RPC] uploadHead: upload failed or no url in response"); + } catch (e) { + console.error("[RPC] uploadHead: fetch error:", e); + } + return null; + })(); + this.headUploadPending.set(skinUrl, uploadPromise); + try { + return await uploadPromise; + } finally { + this.headUploadPending.delete(skinUrl); + } + } public async StartRPC() { if (this.initialized) return; if (sessionStorage.getItem("lce_rpc_started") === "true") { @@ -38,6 +128,7 @@ class RPC { state: string, isPlaying: boolean = false, username: string, + skinUrl?: string, ) { if (!this.initialized) { await this.StartRPC(); @@ -54,8 +145,15 @@ class RPC { const assets = new Assets(); assets.setLargeImage("logo"); assets.setLargeText("LCE Emerald Launcher"); - assets.setSmallImage("app-icon"); - assets.setSmallText(isPlaying ? "Playing" : "In Menus"); + const headUrl = skinUrl ? await this.uploadHead(skinUrl) : null; + console.log("[RPC] updateActivity: headUrl:", headUrl); + if (headUrl) { + assets.setSmallImage(headUrl); + assets.setSmallText(username); + } else { + assets.setSmallImage("app-icon"); + assets.setSmallText(isPlaying ? "Playing" : "In Menus"); + } activity.setAssets(assets); activity.setTimestamps(new Timestamps(this.startTime)); activity.setButton([ @@ -74,16 +172,16 @@ class RPC { } public async StopRPC() { - try { - await clearActivity(); - await stop(); - } catch (e) { - // no need to handle errors here as the launcher will close anyways! - } finally { - this.initialized = false; - this.initializationPromise = null; - sessionStorage.removeItem("lce_rpc_started"); - } + try { + await clearActivity(); + await stop(); + } catch (e) { + // no need to handle errors here as the launcher will close anyways! + } finally { + this.initialized = false; + this.initializationPromise = null; + sessionStorage.removeItem("lce_rpc_started"); + } } } diff --git a/src/services/SwfService.ts b/src/services/SwfService.ts index 9180e1d..25d7250 100644 --- a/src/services/SwfService.ts +++ b/src/services/SwfService.ts @@ -17,7 +17,12 @@ export interface SwfImage { } export class SwfService { - static parse(buffer: Uint8Array): { version: number; compressed: boolean; frameHeader: Uint8Array; tags: SwfTag[] } { + static parse(buffer: Uint8Array): { + version: number; + compressed: boolean; + frameHeader: Uint8Array; + tags: SwfTag[]; + } { if (buffer.length < 8) throw new Error("Invalid SWF: file too small"); const sig = String.fromCharCode(buffer[0], buffer[1], buffer[2]); const version = buffer[3]; @@ -48,7 +53,11 @@ export class SwfService { const code = tagHeader >> 6; let length = tagHeader & 0x3f; if (length === 0x3f) { - length = data[offset] | (data[offset + 1] << 8) | (data[offset + 2] << 16) | (data[offset + 3] << 24); + length = + data[offset] | + (data[offset + 1] << 8) | + (data[offset + 2] << 16) | + (data[offset + 3] << 24); offset += 4; } const tagData = data.slice(offset, offset + length); @@ -60,7 +69,12 @@ export class SwfService { return { version, compressed: sig === "CWS", frameHeader, tags }; } - static serialize(version: number, compressed: boolean, frameHeader: Uint8Array, tags: SwfTag[]): Uint8Array { + static serialize( + version: number, + compressed: boolean, + frameHeader: Uint8Array, + tags: SwfTag[], + ): Uint8Array { const payloads: Uint8Array[] = [frameHeader]; let bodyLength = frameHeader.length; @@ -89,7 +103,9 @@ export class SwfService { } const fileLength = 8 + uncompressedBody.length; - const finalBody = compressed ? pako.deflate(uncompressedBody) : uncompressedBody; + const finalBody = compressed + ? pako.deflate(uncompressedBody) + : uncompressedBody; const result = new Uint8Array(8 + finalBody.length); result.set(new TextEncoder().encode(compressed ? "CWS" : "FWS"), 0); result[3] = version; @@ -125,15 +141,32 @@ export class SwfService { const charId = tag.data[0] | (tag.data[1] << 8); const imgData = tag.data.slice(2); const fixed = this.fixJpeg(imgData, tag.code === 6 ? jpegTables : null); - images.push({ id: charId, type: "jpeg", data: fixed, name: nameMap[charId] }); + images.push({ + id: charId, + type: "jpeg", + data: fixed, + name: nameMap[charId], + }); } else if (tag.code === 35) { const charId = tag.data[0] | (tag.data[1] << 8); - const alphaOffset = tag.data[2] | (tag.data[3] << 8) | (tag.data[4] << 16) | (tag.data[5] << 24); + const alphaOffset = + tag.data[2] | + (tag.data[3] << 8) | + (tag.data[4] << 16) | + (tag.data[5] << 24); const imgData = tag.data.slice(6, 6 + alphaOffset); const alphaRaw = tag.data.slice(6 + alphaOffset); let alpha = undefined; - try { alpha = pako.inflate(alphaRaw); } catch (e) { } - images.push({ id: charId, type: "jpeg", data: this.fixJpeg(imgData), alphaData: alpha, name: nameMap[charId] }); + try { + alpha = pako.inflate(alphaRaw); + } catch (e) {} + images.push({ + id: charId, + type: "jpeg", + data: this.fixJpeg(imgData), + alphaData: alpha, + name: nameMap[charId], + }); } else if (tag.code === 20 || tag.code === 36) { const charId = tag.data[0] | (tag.data[1] << 8); const format = tag.data[2]; @@ -144,20 +177,23 @@ export class SwfService { images.push({ id: charId, - type: "lossless", + type: "lossless", //neo: aka PNG data: raw, width, height, format, hasAlpha, - name: nameMap[charId] + name: nameMap[charId], }); } } return images; } - private static fixJpeg(data: Uint8Array, jtt: Uint8Array | null = null): Uint8Array { + private static fixJpeg( + data: Uint8Array, + jtt: Uint8Array | null = null, + ): Uint8Array { let combined = data; if (jtt && jtt.length > 0) { let tLen = jtt.length; @@ -172,7 +208,8 @@ export class SwfService { } static async decodeLosslessToRGBA(img: SwfImage): Promise { - if (img.type !== "lossless" || !img.width || !img.height) return new Uint8Array(0); + if (img.type !== "lossless" || !img.width || !img.height) + return new Uint8Array(0); const decoded = pako.inflate(img.data); const rgba = new Uint8Array(img.width * img.height * 4); if (img.format === 3) { @@ -189,7 +226,7 @@ export class SwfService { a > 0 ? Math.min(255, (r_pm * 255) / a) : 0, a > 0 ? Math.min(255, (g_pm * 255) / a) : 0, a > 0 ? Math.min(255, (b_pm * 255) / a) : 0, - a + a, ]); } else { palette.push([decoded[ptr++], decoded[ptr++], decoded[ptr++], 255]); @@ -211,7 +248,9 @@ export class SwfService { const rowStride = Math.ceil((img.width * 2) / 4) * 4; for (let y = 0; y < img.height; y++) { for (let x = 0; x < img.width; x++) { - const p = decoded[y * rowStride + x * 2] | (decoded[y * rowStride + x * 2 + 1] << 8); + const p = + decoded[y * rowStride + x * 2] | + (decoded[y * rowStride + x * 2 + 1] << 8); const r = ((p >> 10) & 0x1f) << 3; const g = ((p >> 5) & 0x1f) << 3; const b = (p & 0x1f) << 3; @@ -248,9 +287,20 @@ export class SwfService { return rgba; } - static updateImageTag(tags: SwfTag[], charId: number, newData: Uint8Array, type: SwfImage["type"]): SwfTag[] { - return tags.map(tag => { - if (tag.code === 6 || tag.code === 21 || tag.code === 35 || tag.code === 20 || tag.code === 36) { + static updateImageTag( + tags: SwfTag[], + charId: number, + newData: Uint8Array, + type: SwfImage["type"], + ): SwfTag[] { + return tags.map((tag) => { + if ( + tag.code === 6 || + tag.code === 21 || + tag.code === 35 || + tag.code === 20 || + tag.code === 36 + ) { const id = tag.data[0] | (tag.data[1] << 8); if (id === charId) { let payload: Uint8Array; diff --git a/src/services/TauriService.ts b/src/services/TauriService.ts index c50640d..34150a6 100644 --- a/src/services/TauriService.ts +++ b/src/services/TauriService.ts @@ -48,6 +48,12 @@ export interface AppConfig { customizations?: Record; customPaths?: Record; skipIntro?: boolean; + instanceLaunchArgs?: Record< + string, + { values: Record; args: string[] } + >; + androidRunner?: string; + androidAudioBackend?: "alsa" | "pulseaudio"; } export interface ThemePalette { @@ -60,7 +66,7 @@ export interface Runner { id: string; name: string; path: string; - type: "wine" | "proton"; + type: "wine" | "proton"; //neo: i beg you please use Proton dont use WINE, WineD3D sickens me } export interface MacOSSetupProgress { @@ -127,6 +133,10 @@ export class TauriService { return invoke("open_instance_folder", { instanceId }); } + static async openContainerSettings(instanceId: string): Promise { + return invoke("open_container_settings", { instanceId }); + } + static async deleteInstance(instanceId: string): Promise { return invoke("delete_instance", { instanceId }); } @@ -188,15 +198,25 @@ export class TauriService { return invoke("workshop_list_installed"); } - static onDownloadProgress(callback: (data: { instanceId: string; percent: number }) => void) { - return listen<{ instanceId: string; percent: number }>("download-progress", (event) => - callback(event.payload), + static onDownloadProgress( + callback: (data: { instanceId: string; percent: number }) => void, + ) { + return listen<{ instanceId: string; percent: number }>( + "download-progress", + (event) => callback(event.payload), ); } - static onWorkshopProgress(callback: (data: { packageId: string; percent: number }) => void) { - return listen<{ instanceId: string; percent: number }>("workshop-progress", (event) => - callback({ packageId: event.payload.instanceId, percent: event.payload.percent }), + static onWorkshopProgress( + callback: (data: { packageId: string; percent: number }) => void, + ) { + return listen<{ instanceId: string; percent: number }>( + "workshop-progress", + (event) => + callback({ + packageId: event.payload.instanceId, + percent: event.payload.percent, + }), ); } @@ -213,27 +233,25 @@ export class TauriService { } static onBackendError(callback: (message: string) => void) { - return listen("backend-error", (event) => - callback(event.payload), - ); + return listen("backend-error", (event) => callback(event.payload)); } static onGameLog(callback: (log: string) => void) { - return listen("game-log", (event) => - callback(event.payload), - ); + return listen("game-log", (event) => callback(event.payload)); } static onDownloadRetry(callback: (attempt: number) => void) { - return listen("download-retry", (event) => - callback(event.payload), - ); + return listen("download-retry", (event) => callback(event.payload)); } static async openUrl(url: string): Promise { return invoke("plugin:opener|open_url", { url }); } + static async startLceOnlineAuth(): Promise { + return invoke("start_lce_auth"); + } + static async restartLauncher(): Promise { return invoke("restart_launcher"); } @@ -379,6 +397,12 @@ export class TauriService { return invoke("get_instance_path", { instanceId }); } + static async getInstanceArgsSchema( + instanceId: string, + ): Promise { + return invoke("get_instance_args_schema", { instanceId }); + } + static async readScreenshotAsDataUrl(path: string): Promise { return invoke("read_screenshot_as_data_url", { path }); } @@ -423,7 +447,12 @@ export class TauriService { branch: string, dlcFolder: string, ): Promise { - return invoke("download_dlc_files", { instanceId, repoUrl, branch, dlcFolder }); + return invoke("download_dlc_files", { + instanceId, + repoUrl, + branch, + dlcFolder, + }); } static async importWorld( @@ -453,4 +482,16 @@ export class TauriService { ): Promise { return invoke("lce_to_java", { inputMsPath, javaWorldOutput }); } + + static async installLatestDriver(): Promise { + return invoke("install_latest_driver"); + } + + static async switchProton(version: string): Promise { + return invoke("switch_proton", { version }); + } + + static async setAudioBackend(backend: string): Promise { + return invoke("set_audio_backend", { backend }); + } } diff --git a/src/types/edition.ts b/src/types/edition.ts index 5ade209..52ada11 100644 --- a/src/types/edition.ts +++ b/src/types/edition.ts @@ -1,3 +1,4 @@ +export const HIDDEN_INSTANCE_URL = "emerald://PLEASE_IM_TIRED_OF_ALL_THIS"; export interface Edition { id: string; name: string; @@ -14,6 +15,7 @@ export interface Edition { category?: string[]; officialDLC?: string; lceOnline?: boolean; + hideOnAndroid?: boolean; } export interface CustomEditionInput { diff --git a/src/utils/argsSchema.ts b/src/utils/argsSchema.ts new file mode 100644 index 0000000..c435d3d --- /dev/null +++ b/src/utils/argsSchema.ts @@ -0,0 +1,360 @@ +export type OptionType = "boolean" | "int" | "number" | "string" | "choice"; +export interface SchemaChoice { + value: string; + label?: string; +} + +export interface SchemaOption { + id: string; + title: string; + type: OptionType; + arg: string; + description?: string; + group?: string; + default?: unknown; + min?: number; + max?: number; + step?: number; + placeholder?: string; + choices?: SchemaChoice[]; +} + +export interface SchemaGroup { + id: string; + title: string; + description?: string; +} + +export type Condition = + | { all: Condition[] } + | { any: Condition[] } + | { not: Condition } + | { + option: string; + equals?: unknown; + in?: unknown[]; + not?: unknown; + exists?: boolean; + }; + +export interface SchemaDependency { + target: string; + when: Condition; + effect?: "disable" | "hide"; +} + +export interface ArgsSchema { + $schema: string; + schemaVersion?: number; + meta?: Record; + groups: SchemaGroup[]; + options: SchemaOption[]; + dependencies: SchemaDependency[]; +} + +export interface OptionEffects { + hidden: boolean; + disabled: boolean; +} + +const VALID_TYPES: OptionType[] = [ + "boolean", + "int", + "number", + "string", + "choice", +]; + +function isObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function isFiniteNumber(value: unknown): value is number { + return typeof value === "number" && Number.isFinite(value); +} + +function isCondition(value: unknown): value is Condition { + if (!isObject(value)) return false; + if ("option" in value) return typeof value.option === "string"; + if ("all" in value) + return Array.isArray(value.all) && value.all.every(isCondition); + if ("any" in value) + return Array.isArray(value.any) && value.any.every(isCondition); + if ("not" in value) return isCondition(value.not); + return false; +} +export function parseSchema(raw: string): ArgsSchema | null { + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + return null; + } + if (!isObject(parsed)) return null; + if (parsed.$schema === undefined) return null; //neo: cheap. i know. + if (!Array.isArray(parsed.options)) return null; + const optionKeys = new Set(); + const options: SchemaOption[] = []; + for (const entry of parsed.options) { + if (!isObject(entry)) continue; + const id = typeof entry.id === "string" ? entry.id : ""; + const title = typeof entry.title === "string" ? entry.title : ""; + const type = entry.type; + const arg = typeof entry.arg === "string" ? entry.arg : ""; + if (!id || !title || !arg || optionKeys.has(id)) continue; + if (typeof type !== "string" || !VALID_TYPES.includes(type as OptionType)) + continue; + if (type === "choice") { + if (!Array.isArray(entry.choices) || entry.choices.length === 0) continue; + const choices: SchemaChoice[] = []; + const seen = new Set(); + for (const c of entry.choices) { + if (!isObject(c) || typeof c.value !== "string" || seen.has(c.value)) + continue; + seen.add(c.value); + choices.push({ + value: c.value, + label: typeof c.label === "string" ? c.label : c.value, + }); + } + if (choices.length === 0) continue; + options.push({ + id, + title, + type: "choice", + arg, + description: + typeof entry.description === "string" ? entry.description : undefined, + group: typeof entry.group === "string" ? entry.group : undefined, + default: typeof entry.default === "string" ? entry.default : undefined, + choices, + }); + continue; + } + const option: SchemaOption = { + id, + title, + type: type as OptionType, + arg, + description: + typeof entry.description === "string" ? entry.description : undefined, + group: typeof entry.group === "string" ? entry.group : undefined, + }; + if (entry.default !== undefined) option.default = entry.default; + if (isFiniteNumber(entry.min)) option.min = entry.min; + if (isFiniteNumber(entry.max)) option.max = entry.max; + if (isFiniteNumber(entry.step)) option.step = entry.step; + if (typeof entry.placeholder === "string") + option.placeholder = entry.placeholder; + options.push(option); + } + + const groups: SchemaGroup[] = []; + if (Array.isArray(parsed.groups)) { + const seen = new Set(); + for (const g of parsed.groups) { + if (!isObject(g) || typeof g.id !== "string" || seen.has(g.id)) continue; + seen.add(g.id); + groups.push({ + id: g.id, + title: typeof g.title === "string" ? g.title : g.id, + description: + typeof g.description === "string" ? g.description : undefined, + }); + } + } + + const dependencies: SchemaDependency[] = []; + if (Array.isArray(parsed.dependencies)) { + for (const d of parsed.dependencies) { + if (!isObject(d) || typeof d.target !== "string") continue; + if (!isCondition(d.when)) continue; + dependencies.push({ + target: d.target, + when: d.when, + effect: + d.effect === "hide" || d.effect === "disable" ? d.effect : "disable", + }); + } + } + + if (options.length === 0) return null; + return { + $schema: parsed.$schema!.toString(), + schemaVersion: 1, + meta: isObject(parsed.meta) ? parsed.meta : undefined, + groups, + options, + dependencies, + }; +} + +export function defaultValues(schema: ArgsSchema): Record { + const values: Record = {}; + for (const option of schema.options) { + values[option.id] = optionDefault(option); + } + return values; +} + +export function optionDefault(option: SchemaOption): unknown { + switch (option.type) { + case "boolean": + return typeof option.default === "boolean" ? option.default : false; + case "int": + case "number": + return isFiniteNumber(option.default) ? option.default : 0; + case "string": + return typeof option.default === "string" ? option.default : ""; + case "choice": { + if (typeof option.default === "string") { + const match = option.choices?.find((c) => c.value === option.default); + if (match) return match.value; + } + return option.choices?.[0]?.value ?? ""; + } + } +} + +export function sanitizeValue(option: SchemaOption, value: unknown): unknown { + switch (option.type) { + case "boolean": + return typeof value === "boolean" ? value : optionDefault(option); + case "int": + return isFiniteNumber(value) ? Math.trunc(value) : optionDefault(option); + case "number": + return isFiniteNumber(value) ? value : optionDefault(option); + case "string": + return typeof value === "string" ? value : optionDefault(option); + case "choice": { + if ( + typeof value === "string" && + option.choices?.some((c) => c.value === value) + ) { + return value; + } + return optionDefault(option); + } + } +} + +export function mergeValues( + schema: ArgsSchema, + saved: Record | undefined, +): Record { + const values = defaultValues(schema); + if (!saved) return values; + for (const option of schema.options) { + if (saved[option.id] !== undefined) { + values[option.id] = sanitizeValue(option, saved[option.id]); + } + } + return values; +} + +function evaluateLeaf( + condition: Record, + values: Record, +): boolean { + const optionId = typeof condition.option === "string" ? condition.option : ""; + const value = optionId in values ? values[optionId] : undefined; + if (condition.equals !== undefined) return value === condition.equals; + if (Array.isArray(condition.in)) { + return condition.in.some((candidate) => candidate === value); + } + if (condition.not !== undefined) return value !== condition.not; + if (condition.exists !== undefined) { + return condition.exists + ? value !== undefined && value !== null && value !== "" && value !== false + : value === undefined || + value === null || + value === "" || + value === false; + } + return false; +} + +export function evaluateCondition( + condition: Condition, + values: Record, +): boolean { + if ("option" in condition) return evaluateLeaf(condition, values); + if ("all" in condition) + return condition.all.every((c) => evaluateCondition(c, values)); + if ("any" in condition) + return condition.any.some((c) => evaluateCondition(c, values)); + if ("not" in condition) return !evaluateCondition(condition.not, values); + return false; +} + +export function computeEffects( + schema: ArgsSchema, + values: Record, +): Record { + const effects: Record = {}; + for (const option of schema.options) { + effects[option.id] = { hidden: false, disabled: false }; + } + for (const dep of schema.dependencies) { + if (!(dep.target in effects)) continue; + if (!evaluateCondition(dep.when, values)) continue; + const effect = effects[dep.target]; + if (dep.effect === "hide") { + effect.hidden = true; + effect.disabled = true; + } else { + effect.disabled = true; + } + } + return effects; +} + +export function buildArgs( + schema: ArgsSchema, + values: Record, + effects: Record, +): string[] { + const args: string[] = []; + for (const option of schema.options) { + const effect = effects[option.id]; + if (!effect) continue; + if (effect.hidden || effect.disabled) continue; + const value = values[option.id]; + switch (option.type) { + case "boolean": + if (value === true) args.push(option.arg); + break; + case "int": + case "number": + if (isFiniteNumber(value)) args.push(option.arg, String(value)); + break; + case "string": + if (typeof value === "string" && value.trim() !== "") { + args.push(option.arg, value); + } + break; + case "choice": + if (typeof value === "string" && value !== "") { + args.push(option.arg, value); + } + break; + } + } + return args; +} + +export function displayValue(option: SchemaOption, value: unknown): string { + switch (option.type) { + case "boolean": + return value === true ? "true" : "false"; + case "int": + case "number": + return isFiniteNumber(value) ? String(value) : ""; + case "string": + return typeof value === "string" ? value : ""; + case "choice": { + if (typeof value !== "string") return ""; + return option.choices?.find((c) => c.value === value)?.label ?? value; + } + } +}