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/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..9199061d 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,20 @@ 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" + 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 @@ -76,3 +91,4 @@ jobs: path: | launcher/build/reports/tests launcher/build/test-results + launcher/build/reports/package-size.json 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 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..3659b63a --- /dev/null +++ b/launcher/bootstrap/Cargo.lock @@ -0,0 +1,973 @@ +# 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 = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[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 = "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 = "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" +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 = "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" +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 = "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" +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 = "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" +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 = "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" +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 3.0.5", +] + +[[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 = "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" +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", + "der", + "log", + "native-tls", + "percent-encoding", + "rustls", + "rustls-pki-types", + "ureq-proto", + "utf8-zero", + "webpki-root-certs", + "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 = "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" +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-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" +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", +] + +[[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" diff --git a/launcher/bootstrap/Cargo.toml b/launcher/bootstrap/Cargo.toml new file mode 100644 index 00000000..b6186296 --- /dev/null +++ b/launcher/bootstrap/Cargo.toml @@ -0,0 +1,47 @@ +[package] +name = "modtale-bootstrap" +version = "0.1.0" +edition = "2024" + +[dependencies] +serde = { version = "1", features = ["derive"] } +serde_json = "1" +sha2 = "0.10" +fs2 = "0.4" +tempfile = "3" +tinyfiledialogs = "3.9" + +[target.'cfg(windows)'.build-dependencies] +winresource = "0.1" + +[profile.release] +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/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..8fb6f7c3 --- /dev/null +++ b/launcher/bootstrap/smoke.py @@ -0,0 +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: + home = Path(directory) + environment = os.environ.copy() + 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="") + hytale = None + if len(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) + 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/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..246d6f99 --- /dev/null +++ b/launcher/bootstrap/src/main.rs @@ -0,0 +1,823 @@ +#![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::{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 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" } +} +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")); + 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 { + 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", + ".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())); + paths.push(root.join("jre/latest/Contents/Home/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())?; + // 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()?); + #[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) + } +} +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) + .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 = http_agent(Duration::from_secs(600)); + 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)?; + 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) -> 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; + 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 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"); + 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_zip(&path, &staging).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_tar(&archive, &staging).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 = http_agent(Duration::from_secs(180)); + 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).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..880191be 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) } } @@ -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 { @@ -310,10 +338,61 @@ 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 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}") + 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]) + } + 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 -> packageTask.configure { group = 'distribution' - dependsOn tasks.named('prepareJpackageInput') + dependsOn tasks.named('jpackageAppImage') onlyIf { hostPredicate.call() } @@ -330,15 +409,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 +443,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 +458,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 +540,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() @@ -805,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 ]) @@ -840,7 +923,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 +944,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 +979,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"); + } +}