From 433876754bb9408c1c1dfc656994acf2d821ef0a Mon Sep 17 00:00:00 2001 From: Villagers654 <110007851+Villagers654@users.noreply.github.com> Date: Mon, 14 Sep 2026 20:20:10 -0400 Subject: [PATCH 1/4] Reduce launcher size with verified runtime setup --- .github/workflows/launcher-release.yml | 7 +- .github/workflows/launcher-validation.yml | 19 +- launcher/bootstrap/.gitignore | 1 + launcher/bootstrap/Cargo.lock | 809 ++++++++++++++++++ launcher/bootstrap/Cargo.toml | 28 + launcher/bootstrap/build.rs | 9 + launcher/bootstrap/smoke.py | 26 + launcher/bootstrap/src/dialog.rs | 75 ++ launcher/bootstrap/src/main.rs | 741 ++++++++++++++++ launcher/build.gradle | 65 +- .../net/modtale/launcher/RuntimeProbe.java | 21 + 11 files changed, 1785 insertions(+), 16 deletions(-) create mode 100644 launcher/bootstrap/.gitignore create mode 100644 launcher/bootstrap/Cargo.lock create mode 100644 launcher/bootstrap/Cargo.toml create mode 100644 launcher/bootstrap/build.rs create mode 100644 launcher/bootstrap/smoke.py create mode 100644 launcher/bootstrap/src/dialog.rs create mode 100644 launcher/bootstrap/src/main.rs create mode 100644 launcher/src/main/java/net/modtale/launcher/RuntimeProbe.java diff --git a/.github/workflows/launcher-release.yml b/.github/workflows/launcher-release.yml index 405a9b91..e5972efe 100644 --- a/.github/workflows/launcher-release.yml +++ b/.github/workflows/launcher-release.yml @@ -90,9 +90,14 @@ jobs: uses: actions/setup-java@v6.0.1 with: distribution: temurin - java-version: 26 + java-version: | + 25 + 26 check-latest: true + - name: Set up Rust + run: rustup toolchain install stable --profile minimal && rustup default stable + - name: Set up Gradle uses: gradle/actions/setup-gradle@v6.3.0 diff --git a/.github/workflows/launcher-validation.yml b/.github/workflows/launcher-validation.yml index c9264fb7..06388075 100644 --- a/.github/workflows/launcher-validation.yml +++ b/.github/workflows/launcher-validation.yml @@ -34,8 +34,13 @@ jobs: - uses: actions/setup-java@v6.0.1 with: distribution: temurin - java-version: 26 + java-version: | + 25 + 26 check-latest: true + - name: Set up Rust + run: rustup toolchain install stable --profile minimal && rustup default stable + - uses: gradle/actions/setup-gradle@v6.3.0 - name: Install Linux display dependencies if: runner.os == 'Linux' @@ -64,10 +69,18 @@ jobs: sleep 0.1 done test -S "$XDG_RUNTIME_DIR/$WAYLAND_DISPLAY" - env -u DISPLAY GDK_BACKEND=wayland ./gradlew test jpackageImage + env -u DISPLAY GDK_BACKEND=wayland ./gradlew test testBootstrap jpackageAppImage else - ./gradlew test jpackageImage + ./gradlew test testBootstrap jpackageAppImage fi + - name: Validate packaged runtime setup and offline reuse + run: | + case "$RUNNER_OS" in + Windows) launcher="build/jpackage/app-image/Modtale Launcher/Modtale Launcher.exe" ;; + macOS) launcher="build/jpackage/app-image/Modtale Launcher.app/Contents/MacOS/Modtale Launcher" ;; + Linux) launcher="build/jpackage/app-image/Modtale Launcher/bin/Modtale Launcher" ;; + esac + python bootstrap/smoke.py "$launcher" "$JAVA_HOME" - name: Upload validation reports if: always() uses: actions/upload-artifact@v7.0.1 diff --git a/launcher/bootstrap/.gitignore b/launcher/bootstrap/.gitignore new file mode 100644 index 00000000..b83d2226 --- /dev/null +++ b/launcher/bootstrap/.gitignore @@ -0,0 +1 @@ +/target/ diff --git a/launcher/bootstrap/Cargo.lock b/launcher/bootstrap/Cargo.lock new file mode 100644 index 00000000..1b2a340d --- /dev/null +++ b/launcher/bootstrap/Cargo.lock @@ -0,0 +1,809 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cc" +version = "1.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3eb0f42d6c360dc3f8a821f6bf2fdea7f72bfd36b3076eb0e6d1e9e0752fff4" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "filetime" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" +dependencies = [ + "cfg-if", + "libc", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e0f1c7c3a72c66fd80abe965175f7523475c0489a87d3ff9d6e8c87d87a9d2d" + +[[package]] +name = "flate2" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" +dependencies = [ + "crc32fast", + "miniz_oxide", + "zlib-rs", +] + +[[package]] +name = "fs2" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9564fc758e15025b46aa6643b1b77d047d1a56a1aea6e01002ac0c7026876213" +dependencies = [ + "libc", + "winapi", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libloading" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "754ca22de805bb5744484a5b151a9e1a8e837d5dc232c2d7d8c2e3492edc8b60" +dependencies = [ + "cfg-if", + "windows-link", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "miniz_oxide" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "modtale-bootstrap" +version = "0.1.0" +dependencies = [ + "flate2", + "fs2", + "libloading", + "serde", + "serde_json", + "sha2", + "tar", + "tempfile", + "tinyfiledialogs", + "ureq", + "winresource", + "zip", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" +dependencies = [ + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "3.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12df2e0110f65b775f769bb17ef989067a1d931b2eb822bd4346631eeada89f9" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tar" +version = "0.4.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840" +dependencies = [ + "filetime", + "libc", + "xattr", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "tinyfiledialogs" +version = "3.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e25fa0bc43a6566e2cc6d7ac96df3fa5a57beba34445bead1b368ba8fe9ca568" +dependencies = [ + "cc", + "libc", +] + +[[package]] +name = "toml" +version = "1.1.6+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "920602543f0911ab71da12c50d59701da54c196d1a2bf5cb4b75667f137a406a" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_parser" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +dependencies = [ + "winnow", +] + +[[package]] +name = "toml_writer" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" + +[[package]] +name = "typed-path" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e28f89b80c87b8fb0cf04ab448d5dd0dd0ade2f8891bae878de66a75a28600e" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "ureq" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a7ac20be9b7726e0bbdbf974c059676d9acb1cd414961f570a4e8231cacd7fc" +dependencies = [ + "base64", + "log", + "percent-encoding", + "rustls", + "rustls-pki-types", + "ureq-proto", + "utf8-zero", + "webpki-roots", +] + +[[package]] +name = "ureq-proto" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b0809a01d1ca5a51ca70db32bb2a19157582a526505ef3c19e3b343a59aa5ad" +dependencies = [ + "base64", + "http", + "httparse", + "log", +] + +[[package]] +name = "utf8-zero" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[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 = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" + +[[package]] +name = "winresource" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0986a8b1d586b7d3e4fe3d9ea39fb451ae22869dcea4aa109d287a374d866087" +dependencies = [ + "toml", + "version_check", +] + +[[package]] +name = "xattr" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +dependencies = [ + "libc", + "rustix", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + +[[package]] +name = "zip" +version = "8.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d04a6b5381502aa6087c94c669499eb1602eb9c5e8198e534de571f7154809b" +dependencies = [ + "crc32fast", + "flate2", + "indexmap", + "memchr", + "typed-path", + "zopfli", +] + +[[package]] +name = "zlib-rs" +version = "0.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34b31d188d9d685a4f9c7b46d6e36631b07058d2cfe190267adce54dc230bf12" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" + +[[package]] +name = "zopfli" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249" +dependencies = [ + "bumpalo", + "crc32fast", + "log", + "simd-adler32", +] diff --git a/launcher/bootstrap/Cargo.toml b/launcher/bootstrap/Cargo.toml new file mode 100644 index 00000000..d8a703cf --- /dev/null +++ b/launcher/bootstrap/Cargo.toml @@ -0,0 +1,28 @@ +[package] +name = "modtale-bootstrap" +version = "0.1.0" +edition = "2024" + +[dependencies] +ureq = { version = "3.4", default-features = false, features = ["rustls"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +sha2 = "0.10" +flate2 = "1" +tar = "0.4" +zip = { version = "8", default-features = false, features = ["deflate"] } +fs2 = "0.4" +tempfile = "3" +tinyfiledialogs = "3.9" + +[target.'cfg(windows)'.build-dependencies] +winresource = "0.1" + +[profile.release] +opt-level = "s" +lto = true +codegen-units = 1 +strip = true + +[target.'cfg(target_os = "linux")'.dependencies] +libloading = "0.9" diff --git a/launcher/bootstrap/build.rs b/launcher/bootstrap/build.rs new file mode 100644 index 00000000..bad041f9 --- /dev/null +++ b/launcher/bootstrap/build.rs @@ -0,0 +1,9 @@ +fn main() { + #[cfg(windows)] + winresource::WindowsResource::new() + .set_icon("../src/main/resources/net/modtale/launcher/ui/nativefx/assets/favicon.ico") + .set("ProductName", "Modtale Launcher") + .set("FileDescription", "Modtale Launcher") + .compile() + .expect("compile launcher resources"); +} diff --git a/launcher/bootstrap/smoke.py b/launcher/bootstrap/smoke.py new file mode 100644 index 00000000..95ad39d0 --- /dev/null +++ b/launcher/bootstrap/smoke.py @@ -0,0 +1,26 @@ +"""Exercise the packaged bootstrap with isolated runtime locations.""" +import os +from pathlib import Path +import subprocess +import sys +import tempfile + +executable = Path(sys.argv[1]).resolve() +with tempfile.TemporaryDirectory(prefix="modtale-bootstrap-") as directory: + home = Path(directory) + environment = os.environ.copy() + for key in ("MODTALE_JAVA", "MODTALE_HYTALE_HOME", "JAVA_HOME", "PATH", "APPDATA", "LOCALAPPDATA", "XDG_DATA_HOME"): + environment.pop(key, None) + environment.update(HOME=str(home), USERPROFILE=str(home), PATH="", XDG_DATA_HOME=str(home)) + if len(sys.argv) > 2: + # Use a supplied installation; the second run must work after discovery is removed. + environment["JAVA_HOME"] = sys.argv[2] + subprocess.run([executable, "--modtale-bootstrap-check"], env=environment, check=True, timeout=240) + cache = home / ".modtale" / "launcher" / "runtime-25" + assert cache.is_dir(), "A complete private runtime must be published" + assert not list(cache.parent.glob("runtime-download-*")), "Setup debris must be removed" + environment.pop("JAVA_HOME", None) + # A nonworking proxy proves the cached launch does not need network access. + environment.update(HTTPS_PROXY="http://127.0.0.1:1", HTTP_PROXY="http://127.0.0.1:1", ALL_PROXY="http://127.0.0.1:1") + subprocess.run([executable, "--modtale-bootstrap-check"], env=environment, check=True, timeout=45) + print("Packaged runtime setup and offline cache reuse passed") diff --git a/launcher/bootstrap/src/dialog.rs b/launcher/bootstrap/src/dialog.rs new file mode 100644 index 00000000..bd3c29c1 --- /dev/null +++ b/launcher/bootstrap/src/dialog.rs @@ -0,0 +1,75 @@ +// Use the GTK already required by JavaFX on Linux, including inside Flatpak. +// This avoids depending on a separately installed zenity/kdialog executable. +pub fn message(title: &str, text: &str, error: bool) { + #[cfg(target_os = "linux")] + if gtk_message(title, text, error).is_some() { + return; + } + tinyfiledialogs::message_box_ok( + title, + text, + if error { + tinyfiledialogs::MessageBoxIcon::Error + } else { + tinyfiledialogs::MessageBoxIcon::Info + }, + ); +} + +#[cfg(target_os = "linux")] +fn gtk_message(title: &str, text: &str, error: bool) -> Option<()> { + use std::{ + ffi::{CString, c_char, c_int, c_void}, + ptr, + }; + // All pointers belong to GTK; the modal loop finishes before the widget is destroyed. + unsafe { + let library = libloading::Library::new("libgtk-3.so.0").ok()?; + let init = library + .get:: c_int>( + b"gtk_init_check\0", + ) + .ok()?; + if init(ptr::null_mut(), ptr::null_mut()) == 0 { + return None; + } + let create = library + .get:: *mut c_void>(b"gtk_message_dialog_new\0") + .ok()?; + let set_title = library + .get::(b"gtk_window_set_title\0") + .ok()?; + let run = library + .get:: c_int>(b"gtk_dialog_run\0") + .ok()?; + let destroy = library + .get::(b"gtk_widget_destroy\0") + .ok()?; + let title = CString::new(title.replace('\0', " ")).ok()?; + let text = CString::new(text.replace('\0', " ")).ok()?; + let widget = create( + ptr::null_mut(), + 1, + if error { 3 } else { 0 }, + 1, + c"%s".as_ptr(), + text.as_ptr(), + ); + if widget.is_null() { + return None; + } + set_title(widget, title.as_ptr()); + run(widget); + destroy(widget); + // GTK registers process-wide callbacks; do not unload it before process exit. + std::mem::forget(library); + } + Some(()) +} diff --git a/launcher/bootstrap/src/main.rs b/launcher/bootstrap/src/main.rs new file mode 100644 index 00000000..89030fcf --- /dev/null +++ b/launcher/bootstrap/src/main.rs @@ -0,0 +1,741 @@ +#![cfg_attr(target_os = "windows", windows_subsystem = "windows")] + +mod dialog; + +use fs2::FileExt; +use serde::Deserialize; +use sha2::{Digest, Sha256}; +use std::{ + env, + fs::{self, File, OpenOptions}, + io::{self, Read, Write}, + path::{Path, PathBuf}, + process::{Command, Stdio}, + thread, + time::{Duration, Instant}, +}; + +type Result = std::result::Result>; +const MAX_ARCHIVE: u64 = 256 * 1024 * 1024; +const MAX_EXTRACTED: u64 = 1024 * 1024 * 1024; + +#[derive(Deserialize)] +struct Config { + jvm_args: Vec, +} +#[derive(Deserialize)] +struct Asset { + binary: Binary, +} +#[derive(Deserialize)] +struct Binary { + package: Package, +} +#[derive(Deserialize)] +struct Package { + link: String, + checksum: String, + size: u64, +} + +fn main() { + match run() { + Ok(code) => std::process::exit(code), + Err(error) => { + let message = format!( + "Modtale could not start.\n\n{error}\n\nCheck your connection and available disk space, then reopen Modtale to retry. You can also install Java 25 and set JAVA_HOME. Hytale is not required.\n\nDetails: ~/.modtale/launcher/bootstrap.log" + ); + eprintln!("{message}"); + if let Ok(home) = home() + && let Ok(mut log) = OpenOptions::new() + .create(true) + .append(true) + .open(home.join(".modtale/launcher/bootstrap.log")) + { + let _ = writeln!(log, "{message}"); + } + if !diagnostic() { + dialog::message("Modtale Launcher", &message, true); + } + std::process::exit(1); + } + } +} + +fn diagnostic() -> bool { + env::args_os() + .nth(1) + .is_some_and(|a| a == "--modtale-bootstrap-check") +} +fn java_name() -> &'static str { + if cfg!(windows) { "java.exe" } else { "java" } +} +fn home() -> Result { + env::var_os(if cfg!(windows) { "USERPROFILE" } else { "HOME" }) + .map(PathBuf::from) + .ok_or_else(|| "Cannot locate your home directory".into()) +} +fn command(java: &Path) -> Command { + let mut c = Command::new(java); + // Match jpackage: do not let unrelated Java installations inject VM options. + for key in [ + "JAVA_TOOL_OPTIONS", + "_JAVA_OPTIONS", + "JDK_JAVA_OPTIONS", + "CLASSPATH", + ] { + c.env_remove(key); + } + #[cfg(windows)] + { + use std::os::windows::process::CommandExt; + c.creation_flags(0x08000000); // CREATE_NO_WINDOW + } + c +} +fn app_directory(exe: &Path) -> Result { + let parent = exe.parent().ok_or("Launcher has no parent directory")?; + Ok(if cfg!(windows) { + parent.join("app") + } else if cfg!(target_os = "macos") { + parent.join("../app") + } else { + parent.join("../lib/app") + }) +} +fn java_candidates(home: &Path, cache: &Path) -> Vec { + let mut roots = vec![]; + // A custom install can opt in without editing the launcher installation. + if let Some(root) = env::var_os("MODTALE_HYTALE_HOME") { + roots.push(PathBuf::from(root)); + } + if cfg!(windows) { + for key in ["APPDATA", "LOCALAPPDATA"] { + if let Some(root) = env::var_os(key) { + roots.push(PathBuf::from(root).join("Hytale")); + } + } + roots.push(home.join("AppData/Roaming/Hytale")); + } else if cfg!(target_os = "macos") { + roots.push(home.join("Library/Application Support/Hytale")); + } else { + if let Some(root) = env::var_os("XDG_DATA_HOME") { + roots.push(PathBuf::from(root).join("Hytale")); + } + for root in [ + ".var/app/com.hypixel.HytaleLauncher/data/Hytale", + ".local/share/Hytale", + ".config/Hytale", + ".hytale", + ] { + roots.push(home.join(root)); + } + } + roots.push(home.join("Hytale")); + let mut paths = vec![]; + if let Some(java) = env::var_os("MODTALE_JAVA") { + paths.push(PathBuf::from(java)); + } + for root in roots { + for branch in ["release", "pre-release", "prerelease"] { + paths.push(root.join(format!( + "install/{branch}/package/jre/latest/bin/{}", + java_name() + ))); + paths.push(root.join(format!( + "install/{branch}/package/jre/latest/Contents/Home/bin/{}", + java_name() + ))); + } + paths.push(root.join("jre/latest/bin").join(java_name())); + } + if let Some(java) = find_java(cache, 4) { + paths.push(java); + } + if let Some(root) = env::var_os("JAVA_HOME") { + paths.push(PathBuf::from(root).join("bin").join(java_name())); + } + if let Some(path) = env::var_os("PATH") { + paths.extend( + env::split_paths(&path) + .filter(|p| p.is_absolute()) + .map(|p| p.join(java_name())), + ); + } + paths +} +fn find_java(root: &Path, depth: usize) -> Option { + let direct = root.join("bin").join(java_name()); + if direct.is_file() { + return Some(direct); + } + if depth == 0 { + return None; + } + let mut dirs: Vec<_> = fs::read_dir(root) + .ok()? + .filter_map(|e| e.ok()) + .filter(|e| e.file_type().is_ok_and(|t| t.is_dir())) + .map(|e| e.path()) + .collect(); + dirs.sort(); + dirs.into_iter().find_map(|p| find_java(&p, depth - 1)) +} +fn probe(java: &Path, app: &Path, log: &File) -> bool { + probe_with_timeout(java, app, log, Duration::from_secs(15)) +} +fn probe_with_timeout(java: &Path, app: &Path, log: &File, timeout: Duration) -> bool { + if !java.is_file() { + return false; + } + let Ok(output) = log.try_clone() else { + return false; + }; + let Ok(errors) = log.try_clone() else { + return false; + }; + let Ok(mut child) = command(java) + .args(["--enable-native-access=ALL-UNNAMED", "-cp"]) + .arg(app.join("*")) + .arg("net.modtale.launcher.RuntimeProbe") + .stdin(Stdio::null()) + .stdout(output) + .stderr(errors) + .spawn() + else { + return false; + }; + let deadline = Instant::now() + timeout; + loop { + match child.try_wait() { + Ok(Some(status)) => return status.success(), + Ok(None) if Instant::now() < deadline => thread::sleep(Duration::from_millis(50)), + _ => { + let _ = child.kill(); + let _ = child.wait(); + return false; + } + } + } +} +fn run() -> Result { + let home = home()?; + let state = home.join(".modtale/launcher"); + fs::create_dir_all(&state)?; + let log_path = state.join("bootstrap.log"); + if fs::metadata(&log_path).is_ok_and(|m| m.len() > 2 * 1024 * 1024) { + let _ = fs::rename(&log_path, state.join("bootstrap.previous.log")); + } + let mut log = OpenOptions::new() + .create(true) + .append(true) + .open(state.join("bootstrap.log"))?; + let app = app_directory(&env::current_exe()?)?; + let config: Config = serde_json::from_reader(File::open(app.join("bootstrap.json"))?)?; + let cache = state.join("runtime-25"); + let java = install_runtime(&state, &cache, &app, &log)?; + writeln!(log, "Validated runtime: {}", java.display())?; + if diagnostic() { + return Ok(0); + } + writeln!(log, "Starting Modtale with {}", java.display())?; + // Keep arguments as OS strings so deep links, spaces and non-ASCII paths survive unchanged. + let mut child = command(&java) + .args(&config.jvm_args) + .arg("-cp") + .arg(app.join("*")) + .arg("net.modtale.launcher.LauncherMain") + .args(env::args_os().skip(1)) + .stdout(log.try_clone()?) + .stderr(log.try_clone()?) + .spawn()?; + let status = child.wait()?; + if !status.success() { + return Err( + format!("The launcher exited with {status}. See bootstrap.log for details.").into(), + ); + } + Ok(0) +} +fn acquire_setup_lock(state: &Path, timeout: Duration) -> Result { + let lock = OpenOptions::new() + .create(true) + .truncate(false) + .read(true) + .write(true) + .open(state.join("runtime.lock"))?; + let deadline = Instant::now() + timeout; + loop { + match lock.try_lock_exclusive() { + Ok(()) => break, + Err(e) + if e.raw_os_error() == fs2::lock_contended_error().raw_os_error() + && Instant::now() < deadline => + { + thread::sleep(Duration::from_millis(200)) + } + Err(e) => return Err(format!("Could not acquire the Java setup lock: {e}").into()), + } + } + Ok(lock) +} +fn install_runtime(state: &Path, cache: &Path, app: &Path, log: &File) -> Result { + let _lock = acquire_setup_lock(state, Duration::from_secs(660))?; + if let Some(java) = find_java(cache, 4).filter(|j| probe(j, app, log)) { + return Ok(java); + } + // Only the lock holder can create download directories; remove debris from killed setup processes. + for entry in fs::read_dir(state)? { + let entry = entry?; + if entry + .file_name() + .to_string_lossy() + .starts_with("runtime-download-") + && entry.file_type()?.is_dir() + { + fs::remove_dir_all(entry.path())?; + } + } + for java in java_candidates(&home()?, cache) { + if !probe(&java, app, log) { + continue; + } + match adopt_runtime(&java, state, cache, app, log) { + Ok(java) => return Ok(java), + Err(error) => { + let _ = writeln!(&*log, "Could not adopt {}: {error}", java.display()); + } + } + } + if !diagnostic() { + dialog::message( + "Modtale Launcher — Java setup", + "Modtale needs to download Java before it can start. Hytale does not need to be installed.\n\nClick OK to begin. Setup may take several minutes. Modtale will open automatically when it finishes. Future launches can use this Java installation offline.", + false, + ); + } + let agent: ureq::Agent = ureq::Agent::config_builder() + .https_only(true) + .timeout_global(Some(Duration::from_secs(600))) + .timeout_connect(Some(Duration::from_secs(20))) + .timeout_recv_body(Some(Duration::from_secs(30))) + .build() + .into(); + let os = if cfg!(windows) { + "windows" + } else if cfg!(target_os = "macos") { + "mac" + } else { + "linux" + }; + let arch = match env::consts::ARCH { + "x86_64" => "x64", + "aarch64" => "aarch64", + other => return Err(format!("Unsupported architecture: {other}").into()), + }; + let url = format!( + "https://api.adoptium.net/v3/assets/latest/25/hotspot?architecture={arch}&image_type=jre&os={os}&vendor=eclipse" + ); + let assets: Vec = + serde_json::from_str(&agent.get(&url).call()?.body_mut().read_to_string()?)?; + let package = &assets + .first() + .ok_or("No compatible Java download was found")? + .binary + .package; + validate_package(package)?; + let staging = tempfile::Builder::new() + .prefix("runtime-download-") + .tempdir_in(state)?; + let archive = staging.path().join("runtime.archive"); + let mut response = agent.get(&package.link).call()?; + download(response.body_mut().as_reader(), &archive, package)?; + let extracted = staging.path().join("extracted"); + fs::create_dir(&extracted)?; + extract(&archive, &extracted, cfg!(windows))?; + let java = find_java(&extracted, 4).ok_or("Downloaded runtime has no Java executable")?; + if !probe(&java, app, log) { + return Err("Downloaded Java failed the compatibility check".into()); + } + let relative = java.strip_prefix(&extracted)?.to_owned(); + // Publish only a complete, verified runtime. Never touch Hytale's installation. + if cache.exists() { + fs::remove_dir_all(cache)?; + } + fs::rename(&extracted, cache)?; + Ok(cache.join(relative)) +} +// A private snapshot avoids holding files open in Hytale's update directory (especially on Windows). +fn adopt_runtime( + java: &Path, + state: &Path, + cache: &Path, + app: &Path, + log: &File, +) -> Result { + let java = java.canonicalize()?; + let bin = java.parent().ok_or("Java has no bin directory")?; + if bin.file_name().is_none_or(|name| name != "bin") { + return Err("Java is not in a runtime bin directory".into()); + } + let root = bin.parent().ok_or("Java has no runtime directory")?; + let staging = tempfile::Builder::new() + .prefix("runtime-download-") + .tempdir_in(state)?; + let runtime = staging.path().join("runtime"); + fs::create_dir(&runtime)?; + let mut remaining = 2 * MAX_EXTRACTED; + // Retain runtime files and notices; development-only include/jmods directories are not needed. + for name in ["bin", "lib", "conf", "legal", "release", "NOTICE"] { + if root.join(name).exists() { + copy_runtime_tree(&root.join(name), &runtime.join(name), 32, &mut remaining)?; + } + } + let candidate = runtime.join("bin").join(java_name()); + if !probe(&candidate, app, log) { + return Err( + "Copied Java failed validation; the source may have changed during setup".into(), + ); + } + if cache.exists() { + fs::remove_dir_all(cache)?; + } + fs::rename(&runtime, cache)?; + Ok(cache.join("bin").join(java_name())) +} +fn copy_runtime_tree( + source: &Path, + destination: &Path, + depth: usize, + remaining: &mut u64, +) -> Result<()> { + if depth == 0 { + return Err("Runtime contains recursive links".into()); + } + // Materialize links: the cached runtime must survive removal of the original installation. + let metadata = fs::metadata(source)?; + if metadata.is_dir() { + fs::create_dir(destination)?; + for entry in fs::read_dir(source)? { + let entry = entry?; + copy_runtime_tree( + &entry.path(), + &destination.join(entry.file_name()), + depth - 1, + remaining, + )?; + } + } else if metadata.is_file() { + *remaining = remaining + .checked_sub(metadata.len()) + .ok_or("Installed Java runtime is too large")?; + fs::copy(source, destination)?; + } else { + return Err("Unsupported file in Java runtime".into()); + } + Ok(()) +} + +fn validate_package(package: &Package) -> Result<()> { + if !package + .link + .starts_with("https://github.com/adoptium/temurin25-binaries/releases/download/") + || package.checksum.len() != 64 + || !package.checksum.bytes().all(|b| b.is_ascii_hexdigit()) + || package.size == 0 + || package.size > MAX_ARCHIVE + { + return Err("Java provider returned invalid download metadata".into()); + } + Ok(()) +} +fn download(mut source: impl Read, destination: &Path, package: &Package) -> Result<()> { + let mut out = File::create(destination)?; + let mut hash = Sha256::new(); + let mut total = 0_u64; + let mut buffer = [0_u8; 65536]; + loop { + let n = source.read(&mut buffer)?; + if n == 0 { + break; + } + total += n as u64; + if total > package.size { + return Err("Java download exceeds expected size".into()); + } + hash.update(&buffer[..n]); + out.write_all(&buffer[..n])?; + } + if total != package.size || format!("{:x}", hash.finalize()) != package.checksum.to_lowercase() + { + return Err("Java download is incomplete or failed SHA-256 verification".into()); + } + out.sync_all()?; + Ok(()) +} +fn extract(archive: &Path, destination: &Path, windows: bool) -> Result<()> { + let file = File::open(archive)?; + let mut total = 0_u64; + if windows { + let mut zip = zip::ZipArchive::new(file)?; + for index in 0..zip.len() { + let mut entry = zip.by_index(index)?; + total = total.checked_add(entry.size()).ok_or("Archive too large")?; + if total > MAX_EXTRACTED { + return Err("Archive too large".into()); + } + let path = destination.join(entry.enclosed_name().ok_or("Unsafe archive path")?); + if entry.is_symlink() { + return Err("Unexpected link in Java ZIP".into()); + } + if entry.is_dir() { + fs::create_dir_all(path)?; + } else { + fs::create_dir_all(path.parent().ok_or("Invalid archive path")?)?; + io::copy(&mut entry, &mut File::create(path)?)?; + } + } + } else { + let mut tar = tar::Archive::new(flate2::read::GzDecoder::new(file)); + for entry in tar.entries()? { + let mut entry = entry?; + total = total.checked_add(entry.size()).ok_or("Archive too large")?; + if total > MAX_EXTRACTED { + return Err("Archive too large".into()); + } + // tar's unpack_in confines paths and link targets to the staging directory. + if !entry.unpack_in(destination)? { + return Err("Unsafe archive path".into()); + } + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + fn package(bytes: &[u8]) -> Package { + Package { + link: + "https://github.com/adoptium/temurin25-binaries/releases/download/test/java.tar.gz" + .into(), + checksum: format!("{:x}", Sha256::digest(bytes)), + size: bytes.len() as u64, + } + } + #[test] + fn verifies_download_before_use() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("download"); + let data = b"runtime bytes"; + download(&data[..], &path, &package(data)).unwrap(); + assert_eq!(fs::read(&path).unwrap(), data); + assert!(download(&data[..4], &path, &package(data)).is_err()); + assert!(download(&b"runtime bytex"[..], &path, &package(data)).is_err()); + assert!(download(&b"runtime bytes extra"[..], &path, &package(data)).is_err()); + } + #[test] + fn validates_provider_metadata() { + let mut p = package(b"java"); + validate_package(&p).unwrap(); + p.link = "https://github.com.evil.example/adoptium/java".into(); + assert!(validate_package(&p).is_err()); + p = package(b"java"); + p.size = MAX_ARCHIVE + 1; + assert!(validate_package(&p).is_err()); + p = package(b"java"); + p.checksum = "invalid".into(); + assert!(validate_package(&p).is_err()); + } + #[test] + fn finds_runtime_in_macos_archive_and_handles_absence() { + let dir = tempfile::tempdir().unwrap(); + assert!(find_java(dir.path(), 4).is_none()); + let bin = dir.path().join("jdk-25/Contents/Home/bin"); + fs::create_dir_all(&bin).unwrap(); + fs::write(bin.join(java_name()), "java").unwrap(); + assert_eq!(find_java(dir.path(), 4).unwrap(), bin.join(java_name())); + } + #[test] + fn concurrent_setup_is_exclusive_and_recovers_after_release() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("lock"); + let first = File::create(&path).unwrap(); + let second = OpenOptions::new().write(true).open(&path).unwrap(); + first.try_lock_exclusive().unwrap(); + assert!(second.try_lock_exclusive().is_err()); + drop(first); + second.try_lock_exclusive().unwrap(); + } + #[test] + fn setup_waits_for_another_process_and_times_out_cleanly() { + let dir = tempfile::tempdir().unwrap(); + let first = acquire_setup_lock(dir.path(), Duration::from_secs(1)).unwrap(); + assert!(acquire_setup_lock(dir.path(), Duration::from_millis(50)).is_err()); + thread::scope(|scope| { + let waiter = + scope.spawn(|| acquire_setup_lock(dir.path(), Duration::from_secs(2)).is_ok()); + thread::sleep(Duration::from_millis(100)); + drop(first); + assert!(waiter.join().unwrap()); + }); + } + #[cfg(unix)] + #[test] + fn cached_copy_survives_source_removal_and_preserves_executability() { + use std::os::unix::fs::{PermissionsExt, symlink}; + let dir = tempfile::tempdir().unwrap(); + let original = dir.path().join("original"); + fs::create_dir_all(original.join("bin")).unwrap(); + fs::write(original.join("bin/java"), "#!/bin/sh\nexit 0\n").unwrap(); + fs::set_permissions(original.join("bin/java"), fs::Permissions::from_mode(0o755)).unwrap(); + fs::create_dir(original.join("lib")).unwrap(); + fs::write(original.join("lib/data"), "runtime bytes").unwrap(); + symlink("data", original.join("lib/link")).unwrap(); + let cache = dir.path().join("cache"); + let log = File::create(dir.path().join("log")).unwrap(); + let java = adopt_runtime( + &original.join("bin/java"), + dir.path(), + &cache, + dir.path(), + &log, + ) + .unwrap(); + fs::remove_dir_all(original).unwrap(); + assert!(probe(&java, dir.path(), &log)); + assert_eq!( + fs::read_to_string(cache.join("lib/link")).unwrap(), + "runtime bytes" + ); + assert!( + !fs::symlink_metadata(cache.join("lib/link")) + .unwrap() + .is_symlink() + ); + } + #[test] + fn rejects_zip_traversal_without_writing_outside_staging() { + use zip::{ZipWriter, write::SimpleFileOptions}; + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("java.zip"); + let mut zip = ZipWriter::new(File::create(&path).unwrap()); + zip.start_file("../escaped", SimpleFileOptions::default()) + .unwrap(); + zip.write_all(b"bad").unwrap(); + zip.finish().unwrap(); + let staging = dir.path().join("staging"); + fs::create_dir(&staging).unwrap(); + assert!(extract(&path, &staging, true).is_err()); + assert!(!dir.path().join("escaped").exists()); + } + #[test] + fn extracts_verified_tar_runtime() { + let dir = tempfile::tempdir().unwrap(); + let archive = dir.path().join("java.tar.gz"); + let mut tar = tar::Builder::new(flate2::write::GzEncoder::new( + File::create(&archive).unwrap(), + flate2::Compression::fast(), + )); + let mut header = tar::Header::new_gnu(); + header.set_size(4); + header.set_mode(0o755); + header.set_cksum(); + tar.append_data( + &mut header, + format!("jdk/bin/{}", java_name()), + &b"java"[..], + ) + .unwrap(); + tar.into_inner().unwrap().finish().unwrap(); + let staging = dir.path().join("staging"); + fs::create_dir(&staging).unwrap(); + extract(&archive, &staging, false).unwrap(); + assert_eq!(fs::read(find_java(&staging, 4).unwrap()).unwrap(), b"java"); + } + #[cfg(unix)] + #[test] + fn skips_broken_java_and_kills_hung_probe() { + use std::os::unix::fs::PermissionsExt; + let dir = tempfile::tempdir().unwrap(); + let java = dir.path().join("java"); + let log = File::create(dir.path().join("log")).unwrap(); + fs::write(&java, "#!/bin/sh\nexit 1\n").unwrap(); + fs::set_permissions(&java, fs::Permissions::from_mode(0o755)).unwrap(); + assert!(!probe(&java, dir.path(), &log)); + fs::write(&java, "#!/bin/sh\nexec sleep 30\n").unwrap(); + let start = Instant::now(); + assert!(!probe_with_timeout( + &java, + dir.path(), + &log, + Duration::from_millis(100) + )); + assert!(start.elapsed() < Duration::from_secs(2)); + fs::write(&java, "#!/bin/sh\nexit 0\n").unwrap(); + assert!(probe(&java, dir.path(), &log)); + } + #[test] + #[ignore = "Downloads and validates a real Temurin runtime; requires MODTALE_TEST_APP"] + fn real_download_works_without_hytale() { + let app = PathBuf::from(env::var_os("MODTALE_TEST_APP").expect("MODTALE_TEST_APP")); + let dir = tempfile::tempdir().unwrap(); + let agent: ureq::Agent = ureq::Agent::config_builder() + .https_only(true) + .timeout_global(Some(Duration::from_secs(180))) + .build() + .into(); + let os = if cfg!(windows) { + "windows" + } else if cfg!(target_os = "macos") { + "mac" + } else { + "linux" + }; + let arch = if cfg!(target_arch = "aarch64") { + "aarch64" + } else { + "x64" + }; + let url = format!( + "https://api.adoptium.net/v3/assets/latest/25/hotspot?architecture={arch}&image_type=jre&os={os}&vendor=eclipse" + ); + let assets: Vec = serde_json::from_str( + &agent + .get(&url) + .call() + .unwrap() + .body_mut() + .read_to_string() + .unwrap(), + ) + .unwrap(); + let package = &assets[0].binary.package; + validate_package(package).unwrap(); + let archive = dir.path().join("java.archive"); + download( + agent + .get(&package.link) + .call() + .unwrap() + .body_mut() + .as_reader(), + &archive, + package, + ) + .unwrap(); + let runtime = dir.path().join("runtime"); + fs::create_dir(&runtime).unwrap(); + extract(&archive, &runtime, cfg!(windows)).unwrap(); + let java = find_java(&runtime, 4).unwrap(); + let log = File::create(dir.path().join("probe.log")).unwrap(); + assert!( + probe(&java, &app, &log), + "{}", + fs::read_to_string(dir.path().join("probe.log")).unwrap() + ); + // The second check uses only the extracted runtime, with no provider request. + assert!(probe(&find_java(&runtime, 4).unwrap(), &app, &log)); + } +} diff --git a/launcher/build.gradle b/launcher/build.gradle index ef800cb3..34165163 100644 --- a/launcher/build.gradle +++ b/launcher/build.gradle @@ -12,7 +12,7 @@ version = configuredLauncherVersion java { toolchain { - languageVersion = JavaLanguageVersion.of(26) + languageVersion = JavaLanguageVersion.of(25) } } @@ -310,10 +310,46 @@ def metainfoContent = { """ } +def bootstrapTarget = layout.buildDirectory.dir('bootstrap-target') +def bootstrapExecutable = bootstrapTarget.map { + it.file('release/modtale-bootstrap' + (currentOs.isWindows() ? '.exe' : '')) +} +tasks.register('buildBootstrap', Exec) { + inputs.dir('bootstrap/src') + inputs.files('bootstrap/Cargo.toml', 'bootstrap/Cargo.lock', 'bootstrap/build.rs') + outputs.file(bootstrapExecutable) + environment 'CARGO_TARGET_DIR', bootstrapTarget.get().asFile.absolutePath + commandLine 'cargo', 'build', '--locked', '--release', '--manifest-path', file('bootstrap/Cargo.toml').absolutePath +} +tasks.register('testBootstrap', Exec) { + environment 'CARGO_TARGET_DIR', bootstrapTarget.get().asFile.absolutePath + commandLine 'cargo', 'test', '--locked', '--manifest-path', file('bootstrap/Cargo.toml').absolutePath +} +tasks.named('check') { dependsOn tasks.named('testBootstrap') } + +// Keep jpackage's native bundle/installer metadata, then replace the VM-dependent entry point. +def installBootstrap = { File image -> + def appDir = new File(image, currentOs.isWindows() ? 'app' : currentOs.isMacOsX() ? 'Contents/app' : 'lib/app') + def entry = new File(image, currentOs.isWindows() ? "${appDisplayName}.exe" + : currentOs.isMacOsX() ? "Contents/MacOS/${appDisplayName}" : "bin/${appDisplayName}") + delete entry + java.nio.file.Files.copy(bootstrapExecutable.get().asFile.toPath(), entry.toPath()) + entry.setExecutable(true, false) + new File(appDir, 'bootstrap.json').text = groovy.json.JsonOutput.toJson([ + jvm_args: ['-Dfile.encoding=UTF-8'] + launcherJvmArgs + ]) + delete new File(image, currentOs.isWindows() ? 'runtime' : currentOs.isMacOsX() ? 'Contents/runtime' : 'lib/runtime') + if (currentOs.isLinux()) { delete new File(image, 'lib/libapplauncher.so') } + if (currentOs.isMacOsX()) { + // Replacing the executable invalidates jpackage's ad-hoc signature. + runExternalCommand(['codesign', '--force', '--deep', '--sign', '-', image.absolutePath]) + } +} + def configureJpackageTask = { TaskProvider packageTask, String packageType, Closure hostPredicate, Closure> platformArgs -> packageTask.configure { group = 'distribution' - dependsOn tasks.named('prepareJpackageInput') + dependsOn tasks.named('jpackageAppImage') onlyIf { hostPredicate.call() } @@ -330,15 +366,18 @@ def configureJpackageTask = { TaskProvider packageTask, String packageType executable.absolutePath, '--type', packageType, '--dest', distributionDirectory.get().asFile.absolutePath, - '--temp', layout.buildDirectory.dir("jpackage/tmp/${name}").get().asFile.absolutePath - ] + commonJpackageArgs.call() + installerJpackageArgs.call() + platformArgs.call()) + '--temp', layout.buildDirectory.dir("jpackage/tmp/${name}").get().asFile.absolutePath, + '--name', appDisplayName, + '--app-version', jpackageAppVersion, + '--app-image', layout.buildDirectory.dir('jpackage/app-image').get().dir(appDisplayName + (currentOs.isMacOsX() ? '.app' : '')).asFile.absolutePath + ] + installerJpackageArgs.call() + platformArgs.call()) } } } configureJpackageTask( tasks.register('packageWindows', Exec) { - description = 'Builds a self-contained Windows .exe installer with an embedded Java runtime.' + description = 'Builds a self-contained Windows .exe installer with automatic Java runtime setup.' }, windowsPackageType, { currentOs.isWindows() }, @@ -361,7 +400,7 @@ configureJpackageTask( configureJpackageTask( tasks.register('packageMac', Exec) { - description = 'Builds a self-contained macOS .dmg containing a native .app bundle and embedded Java runtime.' + description = 'Builds a self-contained macOS .dmg containing a native .app bundle and automatic Java runtime setup.' }, macPackageType, { currentOs.isMacOsX() }, @@ -376,7 +415,7 @@ configureJpackageTask( tasks.register('prepareLinuxAppImageRuntime', Exec) { group = 'distribution' description = 'Builds the self-contained Linux runtime image used by the AppImage package.' - dependsOn tasks.named('prepareJpackageInput') + dependsOn tasks.named('prepareJpackageInput'), tasks.named('buildBootstrap') onlyIf { currentOs.isLinux() } @@ -458,7 +497,7 @@ exec "$APPDIR/bin/Modtale Launcher" "$@" tasks.register('packageLinux', Exec) { group = 'distribution' - description = 'Builds a self-contained Linux AppImage with an embedded Java runtime.' + description = 'Builds a self-contained Linux AppImage with automatic Java runtime setup.' dependsOn tasks.named('prepareLinuxAppDir') onlyIf { currentOs.isLinux() @@ -840,7 +879,7 @@ tasks.register('packageAll') { tasks.register('jpackageAppImage', Exec) { group = 'distribution' description = 'Builds a self-contained unpacked app image for the current host OS.' - dependsOn tasks.named('prepareJpackageInput') + dependsOn tasks.named('prepareJpackageInput'), tasks.named('buildBootstrap') doFirst { def executable = jpackageExecutable.get() if (!executable.exists()) { @@ -861,10 +900,11 @@ tasks.register('jpackageAppImage', Exec) { ['jpackageAppImage', 'prepareLinuxAppImageRuntime'].each { taskName -> tasks.named(taskName) { doLast { + def imageRoot = taskName == 'jpackageAppImage' + ? layout.buildDirectory.dir('jpackage/app-image').get().asFile + : linuxAppImageDirectory.get().asFile + installBootstrap(new File(imageRoot, appDisplayName + (currentOs.isMacOsX() ? '.app' : ''))) if (currentOs.isLinux()) { - def imageRoot = taskName == 'jpackageAppImage' - ? layout.buildDirectory.dir('jpackage/app-image').get().asFile - : linuxAppImageDirectory.get().asFile def stripTool = resolveExecutable('strip', 'strip', 'Install binutils to package the Linux runtime.') fileTree(imageRoot) { include '**/*.so' }.files.each { nativeLibrary -> runExternalCommand([stripTool, '--strip-unneeded', nativeLibrary.absolutePath]) @@ -895,6 +935,7 @@ tasks.named('installDist') { } tasks.withType(JavaCompile).configureEach { + options.release = 25 options.encoding = 'UTF-8' options.compilerArgs += ['-parameters'] } diff --git a/launcher/src/main/java/net/modtale/launcher/RuntimeProbe.java b/launcher/src/main/java/net/modtale/launcher/RuntimeProbe.java new file mode 100644 index 00000000..25789c89 --- /dev/null +++ b/launcher/src/main/java/net/modtale/launcher/RuntimeProbe.java @@ -0,0 +1,21 @@ +package net.modtale.launcher; + +/** Runs without a display, before the native bootstrap starts the application. */ +public final class RuntimeProbe { + public static void main(String[] args) throws Exception { + int version = Runtime.version().feature(); + if (version < 25 || version > 26) { + throw new IllegalStateException("Modtale requires a validated Java 25 or 26 runtime"); + } + for (String module : new String[] { "java.desktop", "java.logging", "java.net.http", + "java.prefs", "java.xml", "jdk.httpserver", "jdk.unsupported" }) { + if (ModuleLayer.boot().findModule(module).isEmpty()) { + throw new IllegalStateException("Missing Java module: " + module); + } + } + java.security.KeyPairGenerator.getInstance("EC"); + javax.net.ssl.SSLContext.getDefault(); + Class.forName("javafx.scene.control.Control", false, RuntimeProbe.class.getClassLoader()); + Class.forName("com.sun.jna.Native"); + } +} From ffb3a67237d41e4c254682f47aeec6bc4e0aae39 Mon Sep 17 00:00:00 2001 From: Villagers654 <110007851+Villagers654@users.noreply.github.com> Date: Mon, 14 Sep 2026 20:21:41 -0400 Subject: [PATCH 2/4] Use the launcher toolchains in staging and test workflows --- .github/workflows/ci-cd.yml | 7 ++++++- .github/workflows/tests.yml | 2 +- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci-cd.yml b/.github/workflows/ci-cd.yml index b0b92d56..0ebb4a7e 100644 --- a/.github/workflows/ci-cd.yml +++ b/.github/workflows/ci-cd.yml @@ -870,9 +870,14 @@ jobs: uses: actions/setup-java@v6.0.1 with: distribution: temurin - java-version: 26 + java-version: | + 25 + 26 check-latest: true + - name: Set up Rust + run: rustup toolchain install stable --profile minimal && rustup default stable + - name: Set up Gradle uses: gradle/actions/setup-gradle@v6.3.0 diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index e24d5c34..3e979642 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -164,7 +164,7 @@ jobs: uses: actions/setup-java@v4 with: distribution: temurin - java-version: 26 + java-version: 25 check-latest: true - name: Set up Gradle From 2676bb10263590d5c5e39888ffd1943e49e8177a Mon Sep 17 00:00:00 2001 From: Villagers654 <110007851+Villagers654@users.noreply.github.com> Date: Mon, 14 Sep 2026 20:30:30 -0400 Subject: [PATCH 3/4] Trim platform bootstrap dependencies and compress packaged jars --- .github/workflows/launcher-validation.yml | 3 + launcher/bootstrap/Cargo.lock | 204 +++++++++++++++++++--- launcher/bootstrap/Cargo.toml | 29 ++- launcher/bootstrap/smoke.py | 30 +++- launcher/bootstrap/src/main.rs | 124 ++++++++----- launcher/build.gradle | 43 +++++ 6 files changed, 354 insertions(+), 79 deletions(-) diff --git a/.github/workflows/launcher-validation.yml b/.github/workflows/launcher-validation.yml index 06388075..9199061d 100644 --- a/.github/workflows/launcher-validation.yml +++ b/.github/workflows/launcher-validation.yml @@ -81,6 +81,8 @@ jobs: Linux) launcher="build/jpackage/app-image/Modtale Launcher/bin/Modtale Launcher" ;; esac python bootstrap/smoke.py "$launcher" "$JAVA_HOME" + python bootstrap/smoke.py "$launcher" "$JAVA_HOME" --hytale + python bootstrap/smoke.py "$launcher" - name: Upload validation reports if: always() uses: actions/upload-artifact@v7.0.1 @@ -89,3 +91,4 @@ jobs: path: | launcher/build/reports/tests launcher/build/test-results + launcher/build/reports/package-size.json diff --git a/launcher/bootstrap/Cargo.lock b/launcher/bootstrap/Cargo.lock index 1b2a340d..3659b63a 100644 --- a/launcher/bootstrap/Cargo.lock +++ b/launcher/bootstrap/Cargo.lock @@ -14,6 +14,12 @@ version = "0.23.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + [[package]] name = "bitflags" version = "2.13.2" @@ -29,12 +35,6 @@ dependencies = [ "generic-array", ] -[[package]] -name = "bumpalo" -version = "3.20.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" - [[package]] name = "bytes" version = "1.12.1" @@ -57,6 +57,22 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + [[package]] name = "cpufeatures" version = "0.2.17" @@ -85,6 +101,16 @@ dependencies = [ "typenum", ] +[[package]] +name = "der" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a878c850e9e421b20262e9b41f9c860e4785fa07541c266b62ff9d1ef998a80a" +dependencies = [ + "pem-rfc7468", + "zeroize", +] + [[package]] name = "digest" version = "0.10.7" @@ -144,6 +170,21 @@ dependencies = [ "zlib-rs", ] +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + [[package]] name = "fs2" version = "0.4.3" @@ -286,18 +327,93 @@ dependencies = [ "zip", ] +[[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 = "once_cell" version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "openssl" +version = "0.10.81" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77823a27f0babb03091cb9ed9ef80af3b39dbc82f97e8fa530374b7dafd87a45" +dependencies = [ + "bitflags", + "cfg-if", + "foreign-types", + "libc", + "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.119", +] + +[[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.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "pem-rfc7468" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6305423e0e7738146434843d1694d621cce767262b2a86910beab705e4493d9" +dependencies = [ + "base64ct", +] + [[package]] name = "percent-encoding" version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + [[package]] name = "proc-macro2" version = "1.0.107" @@ -384,6 +500,38 @@ dependencies = [ "untrusted", ] +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "serde" version = "1.0.229" @@ -411,7 +559,7 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.5", ] [[package]] @@ -465,6 +613,17 @@ version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "syn" version = "3.0.5" @@ -580,12 +739,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9a7ac20be9b7726e0bbdbf974c059676d9acb1cd414961f570a4e8231cacd7fc" dependencies = [ "base64", + "der", "log", + "native-tls", "percent-encoding", "rustls", "rustls-pki-types", "ureq-proto", "utf8-zero", + "webpki-root-certs", "webpki-roots", ] @@ -607,6 +769,12 @@ version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e" +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + [[package]] name = "version_check" version = "0.9.5" @@ -619,6 +787,15 @@ version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" +[[package]] +name = "webpki-root-certs" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "webpki-roots" version = "1.0.9" @@ -781,7 +958,6 @@ dependencies = [ "indexmap", "memchr", "typed-path", - "zopfli", ] [[package]] @@ -795,15 +971,3 @@ name = "zmij" version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" - -[[package]] -name = "zopfli" -version = "0.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249" -dependencies = [ - "bumpalo", - "crc32fast", - "log", - "simd-adler32", -] diff --git a/launcher/bootstrap/Cargo.toml b/launcher/bootstrap/Cargo.toml index d8a703cf..b6186296 100644 --- a/launcher/bootstrap/Cargo.toml +++ b/launcher/bootstrap/Cargo.toml @@ -4,13 +4,9 @@ version = "0.1.0" edition = "2024" [dependencies] -ureq = { version = "3.4", default-features = false, features = ["rustls"] } serde = { version = "1", features = ["derive"] } serde_json = "1" sha2 = "0.10" -flate2 = "1" -tar = "0.4" -zip = { version = "8", default-features = false, features = ["deflate"] } fs2 = "0.4" tempfile = "3" tinyfiledialogs = "3.9" @@ -19,10 +15,33 @@ tinyfiledialogs = "3.9" winresource = "0.1" [profile.release] -opt-level = "s" +opt-level = "z" +panic = "abort" lto = true codegen-units = 1 strip = true [target.'cfg(target_os = "linux")'.dependencies] libloading = "0.9" + +[target.'cfg(target_os = "linux")'.dependencies.ureq] +version = "3.4" +default-features = false +features = ["rustls"] + +[target.'cfg(any(windows, target_os = "macos"))'.dependencies.ureq] +version = "3.4" +default-features = false +features = ["native-tls"] + +[target.'cfg(windows)'.dependencies] +zip = { version = "8", default-features = false, features = ["deflate-flate2-zlib-rs"] } + +[target.'cfg(unix)'.dependencies] +flate2 = "1" +tar = "0.4" + +[dev-dependencies] +zip = { version = "8", default-features = false, features = ["deflate-flate2-zlib-rs"] } +flate2 = "1" +tar = "0.4" diff --git a/launcher/bootstrap/smoke.py b/launcher/bootstrap/smoke.py index 95ad39d0..6ba44a97 100644 --- a/launcher/bootstrap/smoke.py +++ b/launcher/bootstrap/smoke.py @@ -1,26 +1,44 @@ """Exercise the packaged bootstrap with isolated runtime locations.""" import os from pathlib import Path +import shutil import subprocess import sys import tempfile executable = Path(sys.argv[1]).resolve() -with tempfile.TemporaryDirectory(prefix="modtale-bootstrap-") as directory: +with tempfile.TemporaryDirectory(prefix="modtale bootstrap ü ") as directory: home = Path(directory) environment = os.environ.copy() - for key in ("MODTALE_JAVA", "MODTALE_HYTALE_HOME", "JAVA_HOME", "PATH", "APPDATA", "LOCALAPPDATA", "XDG_DATA_HOME"): + for key in ("MODTALE_JAVA", "MODTALE_HYTALE_HOME", "JAVA_HOME", "PATH", "APPDATA", "LOCALAPPDATA", "XDG_DATA_HOME", "ProgramFiles"): environment.pop(key, None) environment.update(HOME=str(home), USERPROFILE=str(home), PATH="", XDG_DATA_HOME=str(home)) + offline = dict(HTTPS_PROXY="http://127.0.0.1:1", HTTP_PROXY="http://127.0.0.1:1", ALL_PROXY="http://127.0.0.1:1", NO_PROXY="") + hytale = None if len(sys.argv) > 2: - # Use a supplied installation; the second run must work after discovery is removed. - environment["JAVA_HOME"] = sys.argv[2] + if "--hytale" in sys.argv: + if sys.platform == "win32": + environment["APPDATA"] = str(home / "AppData" / "Roaming") + hytale = Path(environment["APPDATA"]) / "Hytale" + elif sys.platform == "darwin": + hytale = home / "Library" / "Application Support" / "Hytale" + else: + hytale = home / ".var" / "app" / "com.hypixel.HytaleLauncher" / "data" / "Hytale" + runtime = hytale / "install" / "pre-release" / "package" / "jre" / "latest" + if sys.platform == "darwin": + runtime = runtime / "Contents" / "Home" + shutil.copytree(sys.argv[2], runtime, symlinks=False, ignore_dangling_symlinks=True) + else: + environment["JAVA_HOME"] = sys.argv[2] + # Discovery and adoption must succeed without downloading a substitute runtime. + environment.update(offline) subprocess.run([executable, "--modtale-bootstrap-check"], env=environment, check=True, timeout=240) cache = home / ".modtale" / "launcher" / "runtime-25" assert cache.is_dir(), "A complete private runtime must be published" assert not list(cache.parent.glob("runtime-download-*")), "Setup debris must be removed" + if hytale: + shutil.rmtree(hytale) environment.pop("JAVA_HOME", None) - # A nonworking proxy proves the cached launch does not need network access. - environment.update(HTTPS_PROXY="http://127.0.0.1:1", HTTP_PROXY="http://127.0.0.1:1", ALL_PROXY="http://127.0.0.1:1") + environment.update(offline) subprocess.run([executable, "--modtale-bootstrap-check"], env=environment, check=True, timeout=45) print("Packaged runtime setup and offline cache reuse passed") diff --git a/launcher/bootstrap/src/main.rs b/launcher/bootstrap/src/main.rs index 89030fcf..2b20262a 100644 --- a/launcher/bootstrap/src/main.rs +++ b/launcher/bootstrap/src/main.rs @@ -8,7 +8,7 @@ use sha2::{Digest, Sha256}; use std::{ env, fs::{self, File, OpenOptions}, - io::{self, Read, Write}, + io::{Read, Write}, path::{Path, PathBuf}, process::{Command, Stdio}, thread, @@ -67,6 +67,23 @@ fn diagnostic() -> bool { .nth(1) .is_some_and(|a| a == "--modtale-bootstrap-check") } +fn http_agent(timeout: Duration) -> ureq::Agent { + let config = ureq::Agent::config_builder(); + #[cfg(any(windows, target_os = "macos"))] + let config = config.tls_config( + ureq::tls::TlsConfig::builder() + .provider(ureq::tls::TlsProvider::NativeTls) + .root_certs(ureq::tls::RootCerts::PlatformVerifier) + .build(), + ); + config + .https_only(true) + .timeout_global(Some(timeout)) + .timeout_connect(Some(Duration::from_secs(20))) + .timeout_recv_body(Some(Duration::from_secs(30))) + .build() + .into() +} fn java_name() -> &'static str { if cfg!(windows) { "java.exe" } else { "java" } } @@ -116,8 +133,14 @@ fn java_candidates(home: &Path, cache: &Path) -> Vec { } } roots.push(home.join("AppData/Roaming/Hytale")); + if let Some(root) = env::var_os("ProgramFiles") { + roots.push(PathBuf::from(root).join("Hypixel Studios/Hytale Launcher")); + } } else if cfg!(target_os = "macos") { roots.push(home.join("Library/Application Support/Hytale")); + roots.push(PathBuf::from( + "/Applications/Hytale Launcher.app/Contents/MacOS", + )); } else { if let Some(root) = env::var_os("XDG_DATA_HOME") { roots.push(PathBuf::from(root).join("Hytale")); @@ -148,6 +171,7 @@ fn java_candidates(home: &Path, cache: &Path) -> Vec { ))); } paths.push(root.join("jre/latest/bin").join(java_name())); + paths.push(root.join("jre/latest/Contents/Home/bin").join(java_name())); } if let Some(java) = find_java(cache, 4) { paths.push(java); @@ -314,13 +338,7 @@ fn install_runtime(state: &Path, cache: &Path, app: &Path, log: &File) -> Result false, ); } - let agent: ureq::Agent = ureq::Agent::config_builder() - .https_only(true) - .timeout_global(Some(Duration::from_secs(600))) - .timeout_connect(Some(Duration::from_secs(20))) - .timeout_recv_body(Some(Duration::from_secs(30))) - .build() - .into(); + let agent = http_agent(Duration::from_secs(600)); let os = if cfg!(windows) { "windows" } else if cfg!(target_os = "macos") { @@ -352,7 +370,7 @@ fn install_runtime(state: &Path, cache: &Path, app: &Path, log: &File) -> Result download(response.body_mut().as_reader(), &archive, package)?; let extracted = staging.path().join("extracted"); fs::create_dir(&extracted)?; - extract(&archive, &extracted, cfg!(windows))?; + extract(&archive, &extracted)?; let java = find_java(&extracted, 4).ok_or("Downloaded runtime has no Java executable")?; if !probe(&java, app, log) { return Err("Downloaded Java failed the compatibility check".into()); @@ -473,40 +491,54 @@ fn download(mut source: impl Read, destination: &Path, package: &Package) -> Res out.sync_all()?; Ok(()) } -fn extract(archive: &Path, destination: &Path, windows: bool) -> Result<()> { +fn extract(archive: &Path, destination: &Path) -> Result<()> { + #[cfg(windows)] + { + extract_zip(archive, destination) + } + #[cfg(unix)] + { + extract_tar(archive, destination) + } +} +#[cfg(any(windows, test))] +fn extract_zip(archive: &Path, destination: &Path) -> Result<()> { + let file = File::open(archive)?; + let mut total = 0_u64; + let mut zip = zip::ZipArchive::new(file)?; + for index in 0..zip.len() { + let mut entry = zip.by_index(index)?; + total = total.checked_add(entry.size()).ok_or("Archive too large")?; + if total > MAX_EXTRACTED { + return Err("Archive too large".into()); + } + let path = destination.join(entry.enclosed_name().ok_or("Unsafe archive path")?); + if entry.is_symlink() { + return Err("Unexpected link in Java ZIP".into()); + } + if entry.is_dir() { + fs::create_dir_all(path)?; + } else { + fs::create_dir_all(path.parent().ok_or("Invalid archive path")?)?; + std::io::copy(&mut entry, &mut File::create(path)?)?; + } + } + Ok(()) +} +#[cfg(any(unix, test))] +fn extract_tar(archive: &Path, destination: &Path) -> Result<()> { let file = File::open(archive)?; let mut total = 0_u64; - if windows { - let mut zip = zip::ZipArchive::new(file)?; - for index in 0..zip.len() { - let mut entry = zip.by_index(index)?; - total = total.checked_add(entry.size()).ok_or("Archive too large")?; - if total > MAX_EXTRACTED { - return Err("Archive too large".into()); - } - let path = destination.join(entry.enclosed_name().ok_or("Unsafe archive path")?); - if entry.is_symlink() { - return Err("Unexpected link in Java ZIP".into()); - } - if entry.is_dir() { - fs::create_dir_all(path)?; - } else { - fs::create_dir_all(path.parent().ok_or("Invalid archive path")?)?; - io::copy(&mut entry, &mut File::create(path)?)?; - } + let mut tar = tar::Archive::new(flate2::read::GzDecoder::new(file)); + for entry in tar.entries()? { + let mut entry = entry?; + total = total.checked_add(entry.size()).ok_or("Archive too large")?; + if total > MAX_EXTRACTED { + return Err("Archive too large".into()); } - } else { - let mut tar = tar::Archive::new(flate2::read::GzDecoder::new(file)); - for entry in tar.entries()? { - let mut entry = entry?; - total = total.checked_add(entry.size()).ok_or("Archive too large")?; - if total > MAX_EXTRACTED { - return Err("Archive too large".into()); - } - // tar's unpack_in confines paths and link targets to the staging directory. - if !entry.unpack_in(destination)? { - return Err("Unsafe archive path".into()); - } + // tar's unpack_in confines paths and link targets to the staging directory. + if !entry.unpack_in(destination)? { + return Err("Unsafe archive path".into()); } } Ok(()) @@ -627,7 +659,7 @@ mod tests { zip.finish().unwrap(); let staging = dir.path().join("staging"); fs::create_dir(&staging).unwrap(); - assert!(extract(&path, &staging, true).is_err()); + assert!(extract_zip(&path, &staging).is_err()); assert!(!dir.path().join("escaped").exists()); } #[test] @@ -651,7 +683,7 @@ mod tests { tar.into_inner().unwrap().finish().unwrap(); let staging = dir.path().join("staging"); fs::create_dir(&staging).unwrap(); - extract(&archive, &staging, false).unwrap(); + extract_tar(&archive, &staging).unwrap(); assert_eq!(fs::read(find_java(&staging, 4).unwrap()).unwrap(), b"java"); } #[cfg(unix)] @@ -681,11 +713,7 @@ mod tests { fn real_download_works_without_hytale() { let app = PathBuf::from(env::var_os("MODTALE_TEST_APP").expect("MODTALE_TEST_APP")); let dir = tempfile::tempdir().unwrap(); - let agent: ureq::Agent = ureq::Agent::config_builder() - .https_only(true) - .timeout_global(Some(Duration::from_secs(180))) - .build() - .into(); + let agent = http_agent(Duration::from_secs(180)); let os = if cfg!(windows) { "windows" } else if cfg!(target_os = "macos") { @@ -727,7 +755,7 @@ mod tests { .unwrap(); let runtime = dir.path().join("runtime"); fs::create_dir(&runtime).unwrap(); - extract(&archive, &runtime, cfg!(windows)).unwrap(); + extract(&archive, &runtime).unwrap(); let java = find_java(&runtime, 4).unwrap(); let log = File::create(dir.path().join("probe.log")).unwrap(); assert!( diff --git a/launcher/build.gradle b/launcher/build.gradle index 34165163..858658cf 100644 --- a/launcher/build.gradle +++ b/launcher/build.gradle @@ -235,6 +235,34 @@ tasks.register('prepareJpackageInput', Sync) { exclude 'jna-*.jar' } from(packagedJna) + inputs.property('jarCompressionLevel', 9) + doLast { + long before = 0 + long after = 0 + fileTree(destinationDir) { include '*.jar' }.files.each { archive -> + before += archive.length() + def optimized = new File(temporaryDir, archive.name) + optimized.parentFile.mkdirs() + new java.util.zip.ZipFile(archive).withCloseable { source -> + new java.util.zip.ZipOutputStream(new FileOutputStream(optimized)).withCloseable { output -> + output.setLevel(9) + source.entries().each { original -> + def entry = new java.util.zip.ZipEntry(original) + entry.setMethod(java.util.zip.ZipEntry.DEFLATED) + entry.setCompressedSize(-1) + output.putNextEntry(entry) + source.getInputStream(original).withCloseable { it.transferTo(output) } + output.closeEntry() + } + } + } + if (optimized.length() < archive.length()) { + java.nio.file.Files.move(optimized.toPath(), archive.toPath(), java.nio.file.StandardCopyOption.REPLACE_EXISTING) + } + after += archive.length() + } + logger.lifecycle("Packaged JARs: ${before} -> ${after} bytes (lossless compression)") + } } def jpackageExecutable = packagingJavaLauncher.map { @@ -329,6 +357,12 @@ tasks.named('check') { dependsOn tasks.named('testBootstrap') } // Keep jpackage's native bundle/installer metadata, then replace the VM-dependent entry point. def installBootstrap = { File image -> + def treeSize = { File root -> + long bytes = 0 + root.eachFileRecurse(groovy.io.FileType.FILES) { bytes += it.length() } + bytes + } + long originalBytes = treeSize(image) def appDir = new File(image, currentOs.isWindows() ? 'app' : currentOs.isMacOsX() ? 'Contents/app' : 'lib/app') def entry = new File(image, currentOs.isWindows() ? "${appDisplayName}.exe" : currentOs.isMacOsX() ? "Contents/MacOS/${appDisplayName}" : "bin/${appDisplayName}") @@ -344,6 +378,15 @@ def installBootstrap = { File image -> // Replacing the executable invalidates jpackage's ad-hoc signature. runExternalCommand(['codesign', '--force', '--deep', '--sign', '-', image.absolutePath]) } + long finalBytes = treeSize(image) + def report = layout.buildDirectory.file('reports/package-size.json').get().asFile + report.parentFile.mkdirs() + report.text = groovy.json.JsonOutput.prettyPrint(groovy.json.JsonOutput.toJson([ + os: System.getProperty('os.name'), arch: System.getProperty('os.arch'), + withRuntimeBytes: originalBytes, packageBytes: finalBytes, + bootstrapBytes: entry.length(), savedBytes: originalBytes - finalBytes + ])) + logger.lifecycle("Application image: ${originalBytes} -> ${finalBytes} bytes") } def configureJpackageTask = { TaskProvider packageTask, String packageType, Closure hostPredicate, Closure> platformArgs -> From d87f63bb9a5f1521d8435c5c5bdc0fb361e841a9 Mon Sep 17 00:00:00 2001 From: Villagers654 <110007851+Villagers654@users.noreply.github.com> Date: Mon, 14 Sep 2026 20:33:22 -0400 Subject: [PATCH 4/4] Preserve desktop identity and allow Flatpak runtime discovery --- launcher/bootstrap/smoke.py | 2 +- launcher/bootstrap/src/main.rs | 86 +++++++++++++++++++++++++++------- launcher/build.gradle | 1 + 3 files changed, 72 insertions(+), 17 deletions(-) diff --git a/launcher/bootstrap/smoke.py b/launcher/bootstrap/smoke.py index 6ba44a97..8fb6f7c3 100644 --- a/launcher/bootstrap/smoke.py +++ b/launcher/bootstrap/smoke.py @@ -10,7 +10,7 @@ with tempfile.TemporaryDirectory(prefix="modtale bootstrap ü ") as directory: home = Path(directory) environment = os.environ.copy() - for key in ("MODTALE_JAVA", "MODTALE_HYTALE_HOME", "JAVA_HOME", "PATH", "APPDATA", "LOCALAPPDATA", "XDG_DATA_HOME", "ProgramFiles"): + for key in ("MODTALE_JAVA", "MODTALE_HYTALE_HOME", "JAVA_HOME", "PATH", "APPDATA", "LOCALAPPDATA", "XDG_DATA_HOME", "HOST_XDG_DATA_HOME", "ProgramFiles"): environment.pop(key, None) environment.update(HOME=str(home), USERPROFILE=str(home), PATH="", XDG_DATA_HOME=str(home)) offline = dict(HTTPS_PROXY="http://127.0.0.1:1", HTTP_PROXY="http://127.0.0.1:1", ALL_PROXY="http://127.0.0.1:1", NO_PROXY="") diff --git a/launcher/bootstrap/src/main.rs b/launcher/bootstrap/src/main.rs index 2b20262a..246d6f99 100644 --- a/launcher/bootstrap/src/main.rs +++ b/launcher/bootstrap/src/main.rs @@ -142,8 +142,10 @@ fn java_candidates(home: &Path, cache: &Path) -> Vec { "/Applications/Hytale Launcher.app/Contents/MacOS", )); } else { - if let Some(root) = env::var_os("XDG_DATA_HOME") { - roots.push(PathBuf::from(root).join("Hytale")); + for key in ["XDG_DATA_HOME", "HOST_XDG_DATA_HOME"] { + if let Some(root) = env::var_os(key) { + roots.push(PathBuf::from(root).join("Hytale")); + } } for root in [ ".var/app/com.hypixel.HytaleLauncher/data/Hytale", @@ -263,24 +265,54 @@ fn run() -> Result { return Ok(0); } writeln!(log, "Starting Modtale with {}", java.display())?; - // Keep arguments as OS strings so deep links, spaces and non-ASCII paths survive unchanged. - let mut child = command(&java) - .args(&config.jvm_args) - .arg("-cp") - .arg(app.join("*")) - .arg("net.modtale.launcher.LauncherMain") + // Replace the Unix bootstrap process so the Dock/taskbar does not retain a second launcher. + let mut launch = application_command(&java, &app, &config); + launch .args(env::args_os().skip(1)) .stdout(log.try_clone()?) - .stderr(log.try_clone()?) - .spawn()?; - let status = child.wait()?; - if !status.success() { - return Err( - format!("The launcher exited with {status}. See bootstrap.log for details.").into(), - ); + .stderr(log.try_clone()?); + #[cfg(unix)] + { + use std::os::unix::process::CommandExt; + Err(launch.exec().into()) + } + #[cfg(windows)] + { + let status = launch.spawn()?.wait()?; + if !status.success() { + return Err(format!( + "The launcher exited with {status}. See bootstrap.log for details." + ) + .into()); + } + Ok(0) } - Ok(0) } +fn application_command(java: &Path, app: &Path, config: &Config) -> Command { + let mut launch = command(java); + launch.args(&config.jvm_args); + #[cfg(target_os = "macos")] + { + launch.arg("-Xdock:name=Modtale Launcher"); + if let Ok(icons) = fs::read_dir(app.join("../Resources")) { + if let Some(icon) = icons + .filter_map(|entry| entry.ok()) + .map(|entry| entry.path()) + .find(|path| path.extension().is_some_and(|ext| ext == "icns")) + { + let mut argument = std::ffi::OsString::from("-Xdock:icon="); + argument.push(icon); + launch.arg(argument); + } + } + } + launch + .arg("-cp") + .arg(app.join("*")) + .arg("net.modtale.launcher.LauncherMain"); + launch +} + fn acquire_setup_lock(state: &Path, timeout: Duration) -> Result { let lock = OpenOptions::new() .create(true) @@ -557,6 +589,28 @@ mod tests { } } #[test] + fn preserves_argument_boundaries_for_native_launch() { + let config = Config { + jvm_args: vec!["-Dmodtale.launcherVersion=1.0".into()], + }; + let mut launch = + application_command(Path::new("java"), Path::new("app with spaces ü"), &config); + launch.arg("modtale://install/project?name=space and ü"); + let args: Vec<_> = launch.get_args().collect(); + assert_eq!(args[0], "-Dmodtale.launcherVersion=1.0"); + assert_eq!( + args[args.len() - 3], + Path::new("app with spaces ü").join("*").as_os_str() + ); + assert_eq!(args[args.len() - 2], "net.modtale.launcher.LauncherMain"); + assert_eq!( + args[args.len() - 1], + "modtale://install/project?name=space and ü" + ); + #[cfg(target_os = "macos")] + assert!(args.contains(&std::ffi::OsStr::new("-Xdock:name=Modtale Launcher"))); + } + #[test] fn verifies_download_before_use() { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("download"); diff --git a/launcher/build.gradle b/launcher/build.gradle index 858658cf..880191be 100644 --- a/launcher/build.gradle +++ b/launcher/build.gradle @@ -887,6 +887,7 @@ exec "\$APPDIR/bin/${appDisplayName}" "\$@" '--socket=wayland', '--device=dri', '--filesystem=home', + '--filesystem=~/.var/app/com.hypixel.HytaleLauncher/data/Hytale/install:ro', '--talk-name=org.freedesktop.portal.Desktop', buildDir.absolutePath ])