diff --git a/.github/workflows/copr_publish.yml b/.github/workflows/copr_publish.yml new file mode 100644 index 00000000..29e43a37 --- /dev/null +++ b/.github/workflows/copr_publish.yml @@ -0,0 +1,60 @@ +name: Publish COPR Package + +on: + release: + types: [published] + workflow_dispatch: + inputs: + tag: + description: 'Release tag (e.g. oneclient-2.0.1)' + required: true + +jobs: + resolve: + runs-on: ubuntu-latest + outputs: + version: ${{ steps.parse.outputs.version }} + steps: + - name: Parse release tag + id: parse + run: | + TAG="${{ github.event.release.tag_name || inputs.tag }}" + if [[ "$TAG" =~ ^oneclient-(.+)$ ]]; then + echo "version=${BASH_REMATCH[1]}" >> "$GITHUB_OUTPUT" + else + echo "::error::Tag '$TAG' does not match oneclient-*" + exit 1 + fi + + publish-bin: + needs: resolve + runs-on: ubuntu-latest + container: fedora:latest + steps: + - name: Install tooling + run: dnf -y install rpm-build rpmdevtools copr-cli bsdtar git + + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + + - name: Prepare spec + env: + VERSION: ${{ needs.resolve.outputs.version }} + run: | + SPEC="distribution/copr/oneclient-bin.spec" + sed -i "s/^Version:.*/Version: ${VERSION}/" "$SPEC" + sed -i "s/^Release:.*/Release: 1%{?dist}/" "$SPEC" + + - name: Build SRPM + run: | + rpmdev-setuptree + spectool -g -R distribution/copr/oneclient-bin.spec + rpmbuild -bs distribution/copr/oneclient-bin.spec + + - name: Submit to COPR + env: + COPR_CONFIG_CONTENT: ${{ secrets.COPR_CONFIG }} + run: | + mkdir -p ~/.config + printf '%s' "$COPR_CONFIG_CONTENT" > ~/.config/copr + chmod 600 ~/.config/copr + copr-cli build polyfrost/OneClient ~/rpmbuild/SRPMS/oneclient-bin-*.src.rpm diff --git a/.github/workflows/oneclient_nightly.yml b/.github/workflows/oneclient_nightly.yml index c73906df..657cbb10 100644 --- a/.github/workflows/oneclient_nightly.yml +++ b/.github/workflows/oneclient_nightly.yml @@ -189,6 +189,8 @@ jobs: shell: bash run: | set -euo pipefail + cp target/release/.cargo-packager/deb/*/data/usr/share/applications/oneclient_app.desktop \ + target/release/oneclient_app.desktop cargo generate-rpm -p packages/oneclient_app cp target/generate-rpm/*.rpm "${{ steps.outdir.outputs.dir }}/" diff --git a/.github/workflows/oneclient_release.yml b/.github/workflows/oneclient_release.yml index 1ad5909d..fec605a1 100644 --- a/.github/workflows/oneclient_release.yml +++ b/.github/workflows/oneclient_release.yml @@ -407,6 +407,8 @@ jobs: shell: bash run: | set -euo pipefail + cp target/release/.cargo-packager/deb/*/data/usr/share/applications/oneclient_app.desktop \ + target/release/oneclient_app.desktop cargo generate-rpm -p packages/oneclient_app cp target/generate-rpm/*.rpm "${{ steps.outdir.outputs.dir }}/" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 68b329f7..19de9da8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -7,9 +7,9 @@ on: workflow_dispatch: inputs: tag: - description: 'Release Tag (e.g. v2.3.1)' + description: 'Release Tag (e.g. v2.4.2)' required: false - default: 'v2.3.1-c2' + default: 'v2.4.2-c1' type: string permissions: diff --git a/.sqlx/query-af0567c14b689a3f53207179ba9ee2e369b99542369fa5401aa4057d0b326b86.json b/.sqlx/query-af0567c14b689a3f53207179ba9ee2e369b99542369fa5401aa4057d0b326b86.json new file mode 100644 index 00000000..32177647 --- /dev/null +++ b/.sqlx/query-af0567c14b689a3f53207179ba9ee2e369b99542369fa5401aa4057d0b326b86.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "\n DELETE FROM cluster_bundle_overrides\n WHERE cluster_id = ? AND package_id = ? AND override_type = 'disabled'\n ", + "describe": { + "columns": [], + "parameters": { + "Right": 2 + }, + "nullable": [] + }, + "hash": "af0567c14b689a3f53207179ba9ee2e369b99542369fa5401aa4057d0b326b86" +} diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a5b328e0..0e7e670f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -32,7 +32,7 @@ To find an issue that interests you, you can browse through our [existing issues #### Making Changes Locally This project is a pure Rust [`Cargo`] workspace (edition 2024). Make sure you have a -recent Rust toolchain (`rustc` **1.85+**) installed before proceeding - the easiest way +recent Rust toolchain (`rustc` **1.97+**) installed before proceeding - the easiest way is via [rustup]. To make changes locally, follow these steps: diff --git a/Cargo.lock b/Cargo.lock index 61b19605..be1fd5ef 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -158,7 +158,7 @@ dependencies = [ "bytestring", "derive_more", "encoding_rs", - "foldhash", + "foldhash 0.2.0", "futures-core", "http 0.2.12", "httparse", @@ -253,7 +253,7 @@ dependencies = [ "cfg-if", "derive_more", "encoding_rs", - "foldhash", + "foldhash 0.2.0", "futures-core", "futures-util", "impl-more", @@ -428,6 +428,7 @@ dependencies = [ "parking_lot", "percent-encoding", "windows-sys 0.60.2", + "wl-clipboard-rs", "x11rb", ] @@ -2022,6 +2023,12 @@ dependencies = [ "winapi", ] +[[package]] +name = "fixedbitset" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" + [[package]] name = "flate2" version = "1.1.9" @@ -2049,6 +2056,12 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + [[package]] name = "foldhash" version = "0.2.0" @@ -2094,7 +2107,7 @@ dependencies = [ [[package]] name = "freya" version = "0.5.0-rc.4" -source = "git+https://github.com/marc2332/freya?branch=fix%2Fskip-basic-render-driver#631db7cd14befdf6daf3b7c73e6e36117bffaf59" +source = "git+https://github.com/marc2332/freya?branch=fix%2Fskip-basic-render-driver#56c6e694084210a8405a8cf9e50a1e236a64efd3" dependencies = [ "cfg-if", "freya-android", @@ -2117,7 +2130,7 @@ dependencies = [ [[package]] name = "freya-android" version = "0.5.0-rc.4" -source = "git+https://github.com/marc2332/freya?branch=fix%2Fskip-basic-render-driver#631db7cd14befdf6daf3b7c73e6e36117bffaf59" +source = "git+https://github.com/marc2332/freya?branch=fix%2Fskip-basic-render-driver#56c6e694084210a8405a8cf9e50a1e236a64efd3" dependencies = [ "freya-components", "freya-core", @@ -2130,7 +2143,7 @@ dependencies = [ [[package]] name = "freya-animation" version = "0.5.0-rc.4" -source = "git+https://github.com/marc2332/freya?branch=fix%2Fskip-basic-render-driver#631db7cd14befdf6daf3b7c73e6e36117bffaf59" +source = "git+https://github.com/marc2332/freya?branch=fix%2Fskip-basic-render-driver#56c6e694084210a8405a8cf9e50a1e236a64efd3" dependencies = [ "easer", "freya-core", @@ -2139,7 +2152,7 @@ dependencies = [ [[package]] name = "freya-clipboard" version = "0.5.0-rc.4" -source = "git+https://github.com/marc2332/freya?branch=fix%2Fskip-basic-render-driver#631db7cd14befdf6daf3b7c73e6e36117bffaf59" +source = "git+https://github.com/marc2332/freya?branch=fix%2Fskip-basic-render-driver#56c6e694084210a8405a8cf9e50a1e236a64efd3" dependencies = [ "copypasta", "freya-core", @@ -2148,7 +2161,7 @@ dependencies = [ [[package]] name = "freya-components" version = "0.5.0-rc.4" -source = "git+https://github.com/marc2332/freya?branch=fix%2Fskip-basic-render-driver#631db7cd14befdf6daf3b7c73e6e36117bffaf59" +source = "git+https://github.com/marc2332/freya?branch=fix%2Fskip-basic-render-driver#56c6e694084210a8405a8cf9e50a1e236a64efd3" dependencies = [ "accesskit", "anyhow", @@ -2174,7 +2187,7 @@ dependencies = [ [[package]] name = "freya-core" version = "0.5.0-rc.4" -source = "git+https://github.com/marc2332/freya?branch=fix%2Fskip-basic-render-driver#631db7cd14befdf6daf3b7c73e6e36117bffaf59" +source = "git+https://github.com/marc2332/freya?branch=fix%2Fskip-basic-render-driver#56c6e694084210a8405a8cf9e50a1e236a64efd3" dependencies = [ "accesskit", "async-io", @@ -2204,7 +2217,7 @@ dependencies = [ [[package]] name = "freya-edit" version = "0.5.0-rc.4" -source = "git+https://github.com/marc2332/freya?branch=fix%2Fskip-basic-render-driver#631db7cd14befdf6daf3b7c73e6e36117bffaf59" +source = "git+https://github.com/marc2332/freya?branch=fix%2Fskip-basic-render-driver#56c6e694084210a8405a8cf9e50a1e236a64efd3" dependencies = [ "bitflags 2.13.1", "freya-clipboard", @@ -2218,7 +2231,7 @@ dependencies = [ [[package]] name = "freya-engine" version = "0.5.0-rc.4" -source = "git+https://github.com/marc2332/freya?branch=fix%2Fskip-basic-render-driver#631db7cd14befdf6daf3b7c73e6e36117bffaf59" +source = "git+https://github.com/marc2332/freya?branch=fix%2Fskip-basic-render-driver#56c6e694084210a8405a8cf9e50a1e236a64efd3" dependencies = [ "freya-skia-safe", ] @@ -2226,7 +2239,7 @@ dependencies = [ [[package]] name = "freya-markdown" version = "0.5.0-rc.4" -source = "git+https://github.com/marc2332/freya?branch=fix%2Fskip-basic-render-driver#631db7cd14befdf6daf3b7c73e6e36117bffaf59" +source = "git+https://github.com/marc2332/freya?branch=fix%2Fskip-basic-render-driver#56c6e694084210a8405a8cf9e50a1e236a64efd3" dependencies = [ "freya-components", "freya-core", @@ -2237,7 +2250,7 @@ dependencies = [ [[package]] name = "freya-performance-plugin" version = "0.5.0-rc.4" -source = "git+https://github.com/marc2332/freya?branch=fix%2Fskip-basic-render-driver#631db7cd14befdf6daf3b7c73e6e36117bffaf59" +source = "git+https://github.com/marc2332/freya?branch=fix%2Fskip-basic-render-driver#56c6e694084210a8405a8cf9e50a1e236a64efd3" dependencies = [ "freya-core", "freya-engine", @@ -2247,7 +2260,7 @@ dependencies = [ [[package]] name = "freya-query" version = "0.5.0-rc.4" -source = "git+https://github.com/marc2332/freya?branch=fix%2Fskip-basic-render-driver#631db7cd14befdf6daf3b7c73e6e36117bffaf59" +source = "git+https://github.com/marc2332/freya?branch=fix%2Fskip-basic-render-driver#56c6e694084210a8405a8cf9e50a1e236a64efd3" dependencies = [ "freya-core", "futures-util", @@ -2256,7 +2269,7 @@ dependencies = [ [[package]] name = "freya-radio" version = "0.5.0-rc.4" -source = "git+https://github.com/marc2332/freya?branch=fix%2Fskip-basic-render-driver#631db7cd14befdf6daf3b7c73e6e36117bffaf59" +source = "git+https://github.com/marc2332/freya?branch=fix%2Fskip-basic-render-driver#56c6e694084210a8405a8cf9e50a1e236a64efd3" dependencies = [ "freya-core", ] @@ -2264,7 +2277,7 @@ dependencies = [ [[package]] name = "freya-router" version = "0.5.0-rc.4" -source = "git+https://github.com/marc2332/freya?branch=fix%2Fskip-basic-render-driver#631db7cd14befdf6daf3b7c73e6e36117bffaf59" +source = "git+https://github.com/marc2332/freya?branch=fix%2Fskip-basic-render-driver#56c6e694084210a8405a8cf9e50a1e236a64efd3" dependencies = [ "freya-core", "freya-router-macro", @@ -2276,7 +2289,7 @@ dependencies = [ [[package]] name = "freya-router-macro" version = "0.5.0-rc.4" -source = "git+https://github.com/marc2332/freya?branch=fix%2Fskip-basic-render-driver#631db7cd14befdf6daf3b7c73e6e36117bffaf59" +source = "git+https://github.com/marc2332/freya?branch=fix%2Fskip-basic-render-driver#56c6e694084210a8405a8cf9e50a1e236a64efd3" dependencies = [ "proc-macro2", "quote", @@ -2287,7 +2300,7 @@ dependencies = [ [[package]] name = "freya-sdk" version = "0.5.0-rc.4" -source = "git+https://github.com/marc2332/freya?branch=fix%2Fskip-basic-render-driver#631db7cd14befdf6daf3b7c73e6e36117bffaf59" +source = "git+https://github.com/marc2332/freya?branch=fix%2Fskip-basic-render-driver#56c6e694084210a8405a8cf9e50a1e236a64efd3" dependencies = [ "freya-core", ] @@ -2336,7 +2349,7 @@ dependencies = [ [[package]] name = "freya-testing" version = "0.5.0-rc.4" -source = "git+https://github.com/marc2332/freya?branch=fix%2Fskip-basic-render-driver#631db7cd14befdf6daf3b7c73e6e36117bffaf59" +source = "git+https://github.com/marc2332/freya?branch=fix%2Fskip-basic-render-driver#56c6e694084210a8405a8cf9e50a1e236a64efd3" dependencies = [ "accesskit", "freya-clipboard", @@ -2351,7 +2364,7 @@ dependencies = [ [[package]] name = "freya-winit" version = "0.5.0-rc.4" -source = "git+https://github.com/marc2332/freya?branch=fix%2Fskip-basic-render-driver#631db7cd14befdf6daf3b7c73e6e36117bffaf59" +source = "git+https://github.com/marc2332/freya?branch=fix%2Fskip-basic-render-driver#56c6e694084210a8405a8cf9e50a1e236a64efd3" dependencies = [ "accesskit", "accesskit_winit", @@ -2741,6 +2754,9 @@ name = "hashbrown" version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash 0.1.5", +] [[package]] name = "hashbrown" @@ -2750,7 +2766,7 @@ checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" dependencies = [ "allocator-api2", "equivalent", - "foldhash", + "foldhash 0.2.0", ] [[package]] @@ -3231,9 +3247,9 @@ dependencies = [ [[package]] name = "interfrost" -version = "1.3.2" +version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3449eb9b8a587b832db2eb3a45776928ce0927c0efa7d0be9a7f4c619140a12a" +checksum = "76a14453cd2d9454beb9b410dc7b845b2d62ff67a6f42460bce6b9bc6e34d3b9" dependencies = [ "chrono", "serde", @@ -4464,7 +4480,7 @@ checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" [[package]] name = "oneclient_app" -version = "2.3.1-c1" +version = "2.4.2-c1" dependencies = [ "anyhow", "arboard", @@ -4514,7 +4530,7 @@ dependencies = [ [[package]] name = "oneclient_auth" -version = "2.3.1-c1" +version = "2.4.2-c1" dependencies = [ "base64 0.22.1", "chrono", @@ -4538,7 +4554,7 @@ dependencies = [ [[package]] name = "oneclient_cluster" -version = "2.3.1-c1" +version = "2.4.2-c1" dependencies = [ "bytes 1.11.1", "chrono", @@ -4561,7 +4577,7 @@ dependencies = [ [[package]] name = "oneclient_common" -version = "2.3.1-c1" +version = "2.4.2-c1" dependencies = [ "directories", "interfrost", @@ -4574,7 +4590,7 @@ dependencies = [ [[package]] name = "oneclient_content" -version = "2.3.1-c1" +version = "2.4.2-c1" dependencies = [ "async-trait", "async_zip", @@ -4602,7 +4618,7 @@ dependencies = [ [[package]] name = "oneclient_core" -version = "2.3.1-c1" +version = "2.4.2-c1" dependencies = [ "async-compression", "async-stream", @@ -4623,6 +4639,7 @@ dependencies = [ "image", "indicatif", "interfrost", + "libc", "md-5 0.10.6", "oneclient_auth", "oneclient_cluster", @@ -4666,7 +4683,7 @@ dependencies = [ [[package]] name = "oneclient_db" -version = "2.3.1-c1" +version = "2.4.2-c1" dependencies = [ "async-tempfile", "chrono", @@ -4681,7 +4698,7 @@ dependencies = [ [[package]] name = "oneclient_discord" -version = "2.3.1-c1" +version = "2.4.2-c1" dependencies = [ "chrono", "discord-rich-presence", @@ -4693,7 +4710,7 @@ dependencies = [ [[package]] name = "oneclient_events" -version = "2.3.1-c1" +version = "2.4.2-c1" dependencies = [ "thiserror 2.0.18", "tokio", @@ -4703,13 +4720,14 @@ dependencies = [ [[package]] name = "oneclient_java" -version = "2.3.1-c1" +version = "2.4.2-c1" dependencies = [ "astral-tokio-tar", "async-tempfile", "async-trait", "async_zip", "futures-lite", + "futures-util", "oneclient_common", "oneclient_events", "oneclient_net", @@ -4725,7 +4743,7 @@ dependencies = [ [[package]] name = "oneclient_macro" -version = "2.3.1-c1" +version = "2.4.2-c1" dependencies = [ "proc-macro2", "quote", @@ -4734,7 +4752,7 @@ dependencies = [ [[package]] name = "oneclient_mc" -version = "2.3.1-c1" +version = "2.4.2-c1" dependencies = [ "base64 0.22.1", "chrono", @@ -4756,7 +4774,7 @@ dependencies = [ [[package]] name = "oneclient_net" -version = "2.3.1-c1" +version = "2.4.2-c1" dependencies = [ "arc-swap", "async-trait", @@ -4778,7 +4796,7 @@ dependencies = [ [[package]] name = "oneclient_polyplus" -version = "2.3.1-c1" +version = "2.4.2-c1" dependencies = [ "async-tungstenite", "bytes 1.11.1", @@ -4857,6 +4875,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "os_pipe" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" +dependencies = [ + "libc", + "windows-sys 0.48.0", +] + [[package]] name = "owned_ttf_parser" version = "0.25.1" @@ -4910,7 +4938,7 @@ checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec" [[package]] name = "pathgraph" version = "0.5.0-rc.4" -source = "git+https://github.com/marc2332/freya?branch=fix%2Fskip-basic-render-driver#631db7cd14befdf6daf3b7c73e6e36117bffaf59" +source = "git+https://github.com/marc2332/freya?branch=fix%2Fskip-basic-render-driver#56c6e694084210a8405a8cf9e50a1e236a64efd3" [[package]] name = "percent-encoding" @@ -4918,6 +4946,17 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "petgraph" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455" +dependencies = [ + "fixedbitset", + "hashbrown 0.15.5", + "indexmap", +] + [[package]] name = "phf" version = "0.13.1" @@ -5055,7 +5094,7 @@ checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3" [[package]] name = "polyio" -version = "2.3.1-c1" +version = "2.4.2-c1" dependencies = [ "astral-tokio-tar", "async-compression", @@ -5077,6 +5116,7 @@ dependencies = [ "tokio", "tokio-util", "tracing", + "windows-sys 0.61.2", ] [[package]] @@ -5337,7 +5377,7 @@ checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" [[package]] name = "ragnarok" version = "0.5.0-rc.4" -source = "git+https://github.com/marc2332/freya?branch=fix%2Fskip-basic-render-driver#631db7cd14befdf6daf3b7c73e6e36117bffaf59" +source = "git+https://github.com/marc2332/freya?branch=fix%2Fskip-basic-render-driver#56c6e694084210a8405a8cf9e50a1e236a64efd3" dependencies = [ "euclid", "hotpath", @@ -7138,7 +7178,7 @@ checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" [[package]] name = "torin" version = "0.5.0-rc.4" -source = "git+https://github.com/marc2332/freya?branch=fix%2Fskip-basic-render-driver#631db7cd14befdf6daf3b7c73e6e36117bffaf59" +source = "git+https://github.com/marc2332/freya?branch=fix%2Fskip-basic-render-driver#56c6e694084210a8405a8cf9e50a1e236a64efd3" dependencies = [ "euclid", "itertools 0.14.0", @@ -7300,6 +7340,17 @@ dependencies = [ "windows 0.56.0", ] +[[package]] +name = "tree_magic_mini" +version = "3.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8765b90061cba6c22b5831f675da109ae5561588290f9fa2317adab2714d5a6" +dependencies = [ + "memchr", + "nom 8.0.0", + "petgraph", +] + [[package]] name = "try-lock" version = "0.2.5" @@ -8358,6 +8409,24 @@ version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" +[[package]] +name = "wl-clipboard-rs" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9651471a32e87d96ef3a127715382b2d11cc7c8bb9822ded8a7cc94072eb0a3" +dependencies = [ + "libc", + "log", + "os_pipe", + "rustix 1.1.4", + "thiserror 2.0.18", + "tree_magic_mini", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-protocols-wlr", +] + [[package]] name = "writeable" version = "0.6.3" diff --git a/Cargo.toml b/Cargo.toml index 0546a477..6ef6e423 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,7 +15,8 @@ homepage = "https://polyfrost.org/projects/oneclient" license = "GPL-3.0-only" readme = "README.md" repository = "https://github.com/oiupoyt/OneLauncher-Cracked" -version = "2.3.1-c2" +rust-version = "1.97" +version = "2.4.2-c1" [workspace.dependencies] # core @@ -36,7 +37,7 @@ polyio = {path = "./packages/polyio"} # core (extra) discord-rich-presence = {version = "=1.1.0"} -interfrost = {version = "=1.3.2"} +interfrost = {version = "=1.4.0"} # logging sentry = {version = "=0.48.5", default-features = false, features = [ @@ -103,7 +104,7 @@ sha1 = {version = "=0.10.6", default-features = false} sha2 = {version = "=0.10.9", default-features = false} # util -arboard = {version = "=3.6.1"} +arboard = {version = "=3.6.1", features = ["wayland-data-control"]} arc-swap = {version = "=1.7.1"} base64 = {version = "=0.22.1"} bytes = {version = "=1.11.1"} @@ -113,6 +114,7 @@ dunce = {version = "=1.0.5"} htmd = {version = "=0.5.4"} indicatif = {version = "=0.18.4", features = ["tokio"]} junction = {version = "=2.0.0"} +libc = {version = "=0.2.189"} notify = {version = "=8.2.0"} parking_lot = {version = "=0.12.5"} pulldown-cmark = {version = "=0.13.4", default-features = false} @@ -129,6 +131,7 @@ trash = {version = "=5.2.6"} percent-encoding = {version = "=2.3.2"} url = {version = "=2.5.8", features = ["serde"]} uuid = {version = "=1.23.1", features = ["serde", "v4"]} +windows-sys = {version = "=0.61.2", features = ["Win32_Foundation", "Win32_Storage_FileSystem"]} # util (codegen/macro) anyhow = {version = "=1.0.102"} @@ -160,10 +163,17 @@ inherits = "release" debug = true strip = false +[profile.release-fastprofiling] +inherits = "release" + +codegen-units = 16 +debug = true +lto = false +strip = false + [profile.dev] debug = 1 [profile.dev.package."*"] opt-level = 3 debug = false - diff --git a/README.md b/README.md index 70ea5635..d0134c83 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,73 @@
- OneLauncher Logo +Repository Banner -forked from [OneLauncher](https://github.com/Polyfrost/OneLauncher) allowing for uh offline authentication +# OneClient | OneLauncher +The monorepo containing the code for OneLauncher, OneClient, and their core backend. + +OneClient is a Minecraft client featuring fully 100% open-source components, offering many packaged and pre-configured mods in one click. +OneLauncher is a WIP Minecraft launcher giving power-users the greatest customization whilst featuring a clean UI.
-## download from [**Releases Page**](https://github.com/oiupoyt/OneLauncher-Cracked/releases). -yes the actual client performs normally aswell +## Installing + +You can install the latest release of OneClient from our website: [https://polyfrost.org/projects/oneclient](https://polyfrost.org/projects/oneclient) +as well as our [GitHub releases](https://github.com/Polyfrost/OneLauncher/releases/latest). + +| Windows (x86_64) | macOS (Intel & Apple Silicon) | Linux (x86_64) | +|------------------|-------------------------------|---------------------------------------------------------| +| Installer 🔄 | DMG 🔄 | AppImage 🔄 | +| | App Bundle 🔄 | DEB | +| | | RPM | +| | | [AUR](https://aur.archlinux.org/packages/oneclient-bin) | + +> 🔄 = Has support for autoupdating built-in +## Contributing -intended for offline usage only. +We welcome contributions! Please read our [contributing guidelines](CONTRIBUTING.md) before getting started. +### Requirements + +The project targets **Rust 1.97** or later. You can install Rust via [rustup](https://rustup.rs/). + + +### Building & Running + +```sh +# Run the app +cargo run -p oneclient_app + +# Build a release binary +cargo build -p oneclient_app --release +``` + + +### Packaging / Releasing + +Installers are produced with [**cargo-packager**](https://github.com/crabnebula-dev/cargo-packager) +(the standalone bundler spun out of the Tauri bundler). Config lives in +[`packages/oneclient_app/Cargo.toml`](./packages/oneclient_app/Cargo.toml) under +`[package.metadata.packager]`. + +```sh +cargo install cargo-packager --locked + +# Build the binary, then bundle it for the current OS: +cargo build --release -p oneclient_app +cargo packager --release -p oneclient_app --formats +# Windows: nsis macOS: app,dmg Linux: deb,appimage +``` + + +### Versioning + +The workspace shares a single version, defined in the root [`Cargo.toml`](./Cargo.toml) under `[workspace.package]`. + + +## Code signing + +This program uses free code signing provided by [SignPath.io](https://signpath.io?utm_source=foundation&utm_medium=github&utm_campaign=0install), and a certificate by the [SignPath Foundation](https://signpath.org?utm_source=foundation&utm_medium=github&utm_campaign=0install). We thank them very much to their contributions to OSS software! diff --git a/distribution/copr/oneclient-bin.spec b/distribution/copr/oneclient-bin.spec new file mode 100644 index 00000000..4edf422c --- /dev/null +++ b/distribution/copr/oneclient-bin.spec @@ -0,0 +1,43 @@ +%global debug_package %{nil} +%global __strip /bin/true +%global _appname OneClient +%global _rpmfile %{_appname}_%{version}_linux_x86_64.rpm + +Name: oneclient-bin +Version: 2.0.1 +Release: 1%{?dist} +Summary: Next-generation open source Minecraft launcher (prebuilt) + +License: GPL-3.0-only +URL: https://github.com/Polyfrost/OneLauncher +Source0: %{url}/releases/download/oneclient-%{version}/%{_rpmfile} + +ExclusiveArch: x86_64 +BuildRequires: bsdtar +Requires: gtk3 +Requires: dbus-libs +Provides: oneclient = %{version}-%{release} + +%description +OneClient is a next-generation open source Minecraft launcher. +This package ships the prebuilt binaries from the upstream release. + +%prep +%setup -q -c -T +# Repackages the upstream release rpm; bsdtar reads its cpio payload directly. +bsdtar -xf %{SOURCE0} + +%install +mkdir -p %{buildroot} +cp -a usr %{buildroot}/ +ln -s oneclient_app %{buildroot}%{_bindir}/oneclient + +%files +%{_bindir}/oneclient_app +%{_bindir}/oneclient +%{_datadir}/applications/*.desktop +%{_datadir}/icons/hicolor/*/apps/* + +%changelog +* Wed Sep 09 2026 Polyfrost - 2.0.1-1 +- See upstream release notes. diff --git a/packages/oneclient_app/Cargo.toml b/packages/oneclient_app/Cargo.toml index 332d3406..07c920e7 100644 --- a/packages/oneclient_app/Cargo.toml +++ b/packages/oneclient_app/Cargo.toml @@ -159,7 +159,7 @@ desktop-template = "distribution/linux/org.polyfrost.oneclient.template" auto-req = "builtin" assets = [ { source = "target/release/oneclient_app", dest = "/usr/bin/oneclient_app", mode = "755" }, - { source = "target/release/.cargo-packager/deb/*/data/usr/share/applications/oneclient_app.desktop", dest = "/usr/share/applications/", mode = "644" }, + { source = "target/release/oneclient_app.desktop", dest = "/usr/share/applications/oneclient_app.desktop", mode = "644" }, { source = "icons/32x32.png", dest = "/usr/share/icons/hicolor/32x32/apps/oneclient_app.png", mode = "644" }, { source = "icons/64x64.png", dest = "/usr/share/icons/hicolor/64x64/apps/oneclient_app.png", mode = "644" }, { source = "icons/128x128.png", dest = "/usr/share/icons/hicolor/128x128/apps/oneclient_app.png", mode = "644" }, diff --git a/packages/oneclient_app/assets/backgrounds/CavesAndCliffs.jpg b/packages/oneclient_app/assets/backgrounds/CavesAndCliffs.jpg index 7a951f7b..8b0f2e1e 100644 Binary files a/packages/oneclient_app/assets/backgrounds/CavesAndCliffs.jpg and b/packages/oneclient_app/assets/backgrounds/CavesAndCliffs.jpg differ diff --git a/packages/oneclient_app/assets/icons/icon-logo.svg b/packages/oneclient_app/assets/icons/icon-logo.svg new file mode 100644 index 00000000..84f43670 --- /dev/null +++ b/packages/oneclient_app/assets/icons/icon-logo.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/oneclient_app/assets/icons/line-chart-up-01.svg b/packages/oneclient_app/assets/icons/line-chart-up-01.svg new file mode 100644 index 00000000..6b077ab1 --- /dev/null +++ b/packages/oneclient_app/assets/icons/line-chart-up-01.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/oneclient_app/src/components/account_switcher.rs b/packages/oneclient_app/src/components/account_switcher.rs index 11d9c827..673b7dbf 100644 --- a/packages/oneclient_app/src/components/account_switcher.rs +++ b/packages/oneclient_app/src/components/account_switcher.rs @@ -6,14 +6,14 @@ use freya::{ use oneclient_auth::MinecraftAccount; use crate::{ + ui::{divider}, Route, components::{Avatar, Button, Icon, IconType, OverlayPopup}, hooks::{ - try_accounts, try_default_account, use_account_switcher_open, use_accounts, - use_current_account, use_dispatch, + SetDefaultAccountKeys, try_accounts, try_default_account, use_account_switcher_open, + use_accounts, use_current_account, use_dispatch, use_set_default_account, }, theme::colors, - ui::divider, }; #[derive(PartialEq)] @@ -90,7 +90,8 @@ impl Component for AccountPanel { .spacing(8.) .opacity(progress) .margin(Gaps::new((1.0 - progress) * -8.0, 0., 0., 0.)) - .background(colors::page_elevated()) + .background(colors::page_elevated().with_a(220)) + .blur(12.) .corner_radius(CornerRadius::new_all(12.)) .border( Border::new() @@ -156,19 +157,15 @@ impl Component for AccountRow { let active = self.active; let dispatch = use_dispatch(); + let set_default = use_set_default_account(); let mut hovered = use_state(|| false); let switch = move |_| { if active { return; } + set_default.mutate(SetDefaultAccountKeys { id: Some(id) }); dispatch.close_account_switcher(); - spawn(async move { - if let Ok(state) = crate::launcher::state() { - let _ = state.auth.set_default_account(Some(id)).await; - crate::hooks::invalidate_auth_queries(Some(id)).await; - } - }); }; rect() diff --git a/packages/oneclient_app/src/components/active_cluster_panel.rs b/packages/oneclient_app/src/components/active_cluster_panel.rs index 7e5cccaa..9b81eb45 100644 --- a/packages/oneclient_app/src/components/active_cluster_panel.rs +++ b/packages/oneclient_app/src/components/active_cluster_panel.rs @@ -141,6 +141,7 @@ fn cluster_settings_button(cluster_id: i64) -> impl IntoElement { Button::new() .ghost() .icon() + .tooltip("Cluster settings") .on_press(move |_| { let _ = RouterContext::get().push(Route::ClusterOverview { cluster_id }); }) diff --git a/packages/oneclient_app/src/components/button.rs b/packages/oneclient_app/src/components/button.rs index c8163a43..30a28be8 100644 --- a/packages/oneclient_app/src/components/button.rs +++ b/packages/oneclient_app/src/components/button.rs @@ -4,7 +4,7 @@ use std::borrow::Cow; use freya::prelude::*; use crate::{ - components::{Icon, IconTint, IconType}, + components::{Icon, IconTint, IconType, TooltipPlacement, tooltip::use_tooltip_anchor}, theme::colors, }; @@ -53,6 +53,8 @@ pub struct Button { enabled: bool, focusable: bool, alt: Option>, + tooltip: Option>, + tooltip_placement: TooltipPlacement, on_press: Option>>, elements: Vec, @@ -111,6 +113,8 @@ impl Button { enabled: true, focusable: true, alt: None, + tooltip: None, + tooltip_placement: TooltipPlacement::default(), on_press: None, elements: Vec::new(), key: DiffKey::None, @@ -203,6 +207,20 @@ impl Button { self } + pub fn tooltip(mut self, tooltip: impl Into>) -> Self { + let tooltip = tooltip.into(); + if self.alt.is_none() { + self.alt = Some(tooltip.clone()); + } + self.tooltip = Some(tooltip); + self + } + + pub fn tooltip_placement(mut self, placement: TooltipPlacement) -> Self { + self.tooltip_placement = placement; + self + } + pub fn cursor_icon(mut self, cursor_icon: CursorIcon) -> Self { self.cursor_icon = cursor_icon; self @@ -247,6 +265,8 @@ impl Component for Button { let a11y_id = use_a11y(); let focus = use_focus(a11y_id); + let tooltip = use_tooltip_anchor(self.tooltip.clone(), self.tooltip_placement); + let enabled = use_reactive(&self.enabled); let cursor_icon = self.cursor_icon; @@ -335,7 +355,7 @@ impl Component for Button { rect = rect.cursor(CursorIcon::NotAllowed); } - rect + tooltip.attach(rect) } fn render_key(&self) -> DiffKey { @@ -444,6 +464,7 @@ pub fn open_folder_button(folder: std::path::PathBuf) -> Button { Button::new() .secondary() .icon() + .tooltip("Open folder") .on_press(move |_| { std::fs::create_dir_all(&folder).ok(); crate::platform::open_path(&folder.to_string_lossy()); diff --git a/packages/oneclient_app/src/components/charts.rs b/packages/oneclient_app/src/components/charts.rs index 8e94c22b..7b6d89c1 100644 --- a/packages/oneclient_app/src/components/charts.rs +++ b/packages/oneclient_app/src/components/charts.rs @@ -2,7 +2,7 @@ use freya::engine::prelude::{Paint, PaintStyle, PathBuilder, SkColor, SkRect}; use freya::prelude::*; use crate::theme::colors; -use crate::utils::format_duration_hm; +use crate::utils::format_duration; #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum ValueUnit { @@ -13,7 +13,7 @@ pub enum ValueUnit { impl ValueUnit { pub fn format(self, value: i64) -> String { match self { - ValueUnit::Duration => format_duration_hm(value), + ValueUnit::Duration => format_duration(value), ValueUnit::Count => format!("{value}×"), } } @@ -264,6 +264,7 @@ fn slice_at(values: &[i64], x: f32, y: f32, size: f32) -> Option { pub struct BarChart { values: Vec, labels: Vec, + readout_labels: Option>, highlight: Option, unit: ValueUnit, height: f32, @@ -275,6 +276,7 @@ impl BarChart { Self { values, labels, + readout_labels: None, highlight: None, unit: ValueUnit::Duration, height: DEFAULT_HEIGHT, @@ -282,6 +284,11 @@ impl BarChart { } } + pub fn readout_labels(mut self, labels: Vec) -> Self { + self.readout_labels = Some(labels); + self + } + pub fn highlight(mut self, highlight: Option) -> Self { self.highlight = highlight; self @@ -304,7 +311,9 @@ impl BarChart { } fn readout_name(&self, i: usize) -> String { - self.labels + self.readout_labels + .as_ref() + .unwrap_or(&self.labels) .get(i) .cloned() .filter(|s| !s.is_empty()) @@ -343,13 +352,9 @@ impl Component for BarChart { for (i, &v) in self.values.iter().enumerate() { let frac = if max > 0 { v as f32 / max as f32 } else { 0.0 }; let h = if v > 0 { (frac * height).max(4.0) } else { 0.0 }; - let is_active = Some(i) == focus; - let color = if is_active { - colors::brand() - } else if active.is_some() { - colors::component_bg_hover().with_a(150) - } else { - colors::component_bg_hover() + let color = match active { + Some(a) if a != i => colors::component_bg_hover(), + _ => colors::brand(), }; bars = bars.child( @@ -381,14 +386,13 @@ impl Component for BarChart { label_row = label_row.child( rect() .width(Size::flex(1.0)) + .horizontal() .main_align(Alignment::Center) .child( label() .text(if shown { text.clone() } else { String::new() }) .font_size(10.) .max_lines(1) - .width(Size::fill()) - .text_align(TextAlign::Center) .color(colors::fg_secondary()), ), ); @@ -455,7 +459,7 @@ fn label_stride(labels: &[String], plot_width: f32, gap: f32) -> usize { .unwrap_or(0) .max(1); // ~6.2px per char at font_size 10 - let slot_px = max_chars as f32 * 6.2 + gap.max(8.0) + 8.0; + let slot_px = max_chars as f32 * 6.2 + gap.max(8.0) + 12.0; let max_labels = (plot_width / slot_px).floor().max(1.0) as usize; if n <= max_labels { @@ -487,7 +491,7 @@ fn gridlines(height: f32) -> Element { fn y_axis(max: i64, unit: ValueUnit, height: f32) -> Element { let fmt = |v: i64| match unit { - ValueUnit::Duration => format_duration_hm(v), + ValueUnit::Duration => format_duration(v), ValueUnit::Count => v.to_string(), }; diff --git a/packages/oneclient_app/src/components/context_menu.rs b/packages/oneclient_app/src/components/context_menu.rs index 7f0edbe8..6f41d9c7 100644 --- a/packages/oneclient_app/src/components/context_menu.rs +++ b/packages/oneclient_app/src/components/context_menu.rs @@ -2,6 +2,7 @@ use freya::prelude::*; use crate::components::{Icon, IconType, OVERLAY_BASE_LEVEL, OverlayPopup}; use crate::theme::colors; +use crate::ui::clamp_to_window; const MENU_BG: Color = Color::from_rgb(25, 32, 38); const MENU_BORDER: Color = Color::from_argb(26, 255, 255, 255); @@ -10,8 +11,6 @@ const MENU_DANGER: Color = Color::from_rgb(242, 84, 90); /// Panel padding and border on both axes, which the measured list sits inside of const PANEL_INSET: f32 = 14.; -/// Gap the clamped menu keeps from the window edges -const EDGE_MARGIN: f32 = 8.; enum Entry { Action { @@ -215,26 +214,6 @@ impl Component for ContextMenu { } } -/// `root_size` is physical while the press position is logical, so it has to be -/// scaled down before the two are compared -fn clamp_to_window(x: f32, y: f32, menu_width: f32, menu_height: f32) -> (f32, f32) { - let platform = Platform::get(); - let scale = *platform.scale_factor.peek() as f32; - if scale <= 0. { - return (x, y); - } - - let window = *platform.root_size.peek(); - let clamp = |pos: f32, len: f32, limit: f32| { - pos.clamp(EDGE_MARGIN, (limit - len - EDGE_MARGIN).max(EDGE_MARGIN)) - }; - - ( - clamp(x, menu_width, window.width / scale), - clamp(y, menu_height, window.height / scale), - ) -} - #[derive(PartialEq)] struct ContextMenuRow { icon: IconType, diff --git a/packages/oneclient_app/src/components/date_range_picker.rs b/packages/oneclient_app/src/components/date_range_picker.rs new file mode 100644 index 00000000..56410f5c --- /dev/null +++ b/packages/oneclient_app/src/components/date_range_picker.rs @@ -0,0 +1,410 @@ +use chrono::{Datelike, Days, Months, NaiveDate}; +use freya::prelude::*; + +use crate::components::{Icon, IconType, OverlayPopup}; +use crate::theme::colors; +use crate::ui::{border_all_color, clamp_to_window}; +use crate::utils::format_day; + +const CELL: f32 = 30.; +const CELL_GAP: f32 = 2.; +const WEEKS: u32 = 6; +const POPUP_WIDTH: f32 = 7. * CELL + 6. * CELL_GAP + 24. + 2.; +const POPUP_OFFSET: f32 = 4.; + +pub type DateRange = (NaiveDate, NaiveDate); + +#[derive(PartialEq)] +pub struct DateRangePicker { + range: State>, + bounds: DateRange, + height: f32, +} + +impl DateRangePicker { + pub fn new(range: State>, bounds: DateRange) -> Self { + Self { + range, + bounds, + height: 30., + } + } + + pub fn height(mut self, height: f32) -> Self { + self.height = height; + self + } +} + +impl Component for DateRangePicker { + fn render(&self) -> impl IntoElement { + let mut range = self.range; + let (min, max) = self.bounds; + + let mut open = use_state(|| false); + let mut anchor = use_state(|| Option::::None); + let mut month = use_state(move || first_of_month(max)); + let mut hovering = use_state(|| false); + let mut trigger = use_state(Area::zero); + let mut panel_height = use_state(|| 0f32); + + let is_open = open(); + + let selected = *range.read(); + let pending = *anchor.read(); + let shown = *month.read(); + + let can_prev = shown > first_of_month(min); + let can_next = shown < first_of_month(max); + + let pick = move |day: NaiveDate| { + let started = *anchor.peek(); + match started { + Some(start) => { + range.set(Some((start.min(day), start.max(day)))); + anchor.set(None); + open.set(false); + } + None => anchor.set(Some(day)), + } + }; + + let trigger_text = match selected { + Some((a, b)) if a == b => format_day(a), + Some((a, b)) => format!("{} – {}", format_day(a), format_day(b)), + None => "Pick dates".to_string(), + }; + + rect() + .child( + rect() + .horizontal() + .height(Size::px(self.height)) + .cross_align(Alignment::Center) + .spacing(6.) + .padding(Gaps::new_symmetric(0., 10.)) + .corner_radius(CornerRadius::new_all(9.)) + .background(if hovering() { + colors::component_bg_hover() + } else { + colors::component_bg() + }) + .border(border_all_color( + 1., + if selected.is_some() { + colors::brand() + } else { + colors::component_border() + }, + )) + .cursor(CursorIcon::Pointer) + .a11y_role(AccessibilityRole::Button) + .on_pointer_enter(move |_| hovering.set(true)) + .on_pointer_leave(move |_| hovering.set(false)) + .on_sized(move |e: Event| { + trigger.set_if_modified(e.area); + }) + .on_press(move |e: Event| { + e.stop_propagation(); + anchor.set_if_modified(None); + open.toggle(); + }) + .child( + Icon::new(IconType::Calendar) + .size(13.) + .color(colors::fg_secondary()), + ) + .child( + label() + .text(trigger_text) + .font_size(12.) + .max_lines(1) + .color(colors::fg_primary()), + ), + ) + .maybe_child(is_open.then(|| { + let area = *trigger.read(); + let height = *panel_height.read(); + let (x, y) = clamp_to_window( + area.max_x() - POPUP_WIDTH, + area.max_y() + POPUP_OFFSET, + POPUP_WIDTH, + height, + ); + + let panel = rect() + .width(Size::px(POPUP_WIDTH)) + .vertical() + .spacing(8.) + .padding(12.) + .corner_radius(CornerRadius::new_all(10.)) + .border(border_all_color(1., colors::component_border())) + .background(colors::page_elevated()) + .on_sized(move |e: Event| { + panel_height.set_if_modified(e.area.height()); + }) + .opacity(if height > 0. { 1. } else { 0. }) + .child(month_header(shown, can_prev, can_next, move |step| { + let next = if step < 0 { + shown.checked_sub_months(Months::new(1)) + } else { + shown.checked_add_months(Months::new(1)) + }; + if let Some(next) = next { + month.set(next.clamp(first_of_month(min), first_of_month(max))); + } + })) + .child(weekday_header()) + .child(month_grid(shown, min, max, selected, pending, pick)) + .child(footer(selected.is_some(), pending, move || { + range.set(None); + anchor.set(None); + open.set(false); + })); + + OverlayPopup::new() + .backdrop(false) + .position(Position::new_global().top(y).left(x)) + .on_close(move |()| { + anchor.set(None); + open.set(false); + }) + .child(panel.into_element()) + .into_element() + })) + } +} + +fn first_of_month(date: NaiveDate) -> NaiveDate { + date.with_day(1).unwrap_or(date) +} + +fn month_header( + shown: NaiveDate, + can_prev: bool, + can_next: bool, + step: impl FnMut(i32) + Clone + 'static, +) -> Element { + let back = step.clone(); + rect() + .horizontal() + .content(Content::Flex) + .width(Size::fill()) + .cross_align(Alignment::Center) + .child(arrow(IconType::ChevronsLeft, can_prev, back, -1)) + .child( + label() + .text(format!("{} {}", month_name(shown.month0()), shown.year())) + .font_size(13.) + .font_weight(FontWeight::SEMI_BOLD) + .width(Size::flex(1.0)) + .text_align(TextAlign::Center) + .color(colors::fg_primary()), + ) + .child(arrow(IconType::ChevronsRight, can_next, step, 1)) + .into_element() +} + +fn arrow( + icon: IconType, + enabled: bool, + mut step: impl FnMut(i32) + 'static, + delta: i32, +) -> Element { + rect() + .width(Size::px(24.)) + .height(Size::px(24.)) + .center() + .corner_radius(CornerRadius::new_all(6.)) + .background(colors::component_bg()) + .maybe(enabled, |el| { + el.cursor(CursorIcon::Pointer) + .on_press(move |_| step(delta)) + }) + .child(Icon::new(icon).size(12.).color(if enabled { + colors::fg_secondary() + } else { + colors::fg_secondary().with_a(70) + })) + .into_element() +} + +fn weekday_header() -> Element { + let mut row = rect().horizontal().spacing(CELL_GAP); + for day in ["M", "T", "W", "T", "F", "S", "S"] { + row = row.child( + rect().width(Size::px(CELL)).center().child( + label() + .text(day.to_string()) + .font_size(10.) + .color(colors::fg_secondary()), + ), + ); + } + row.into_element() +} + +fn month_grid( + shown: NaiveDate, + min: NaiveDate, + max: NaiveDate, + selected: Option, + pending: Option, + pick: impl FnMut(NaiveDate) + Clone + 'static, +) -> Element { + let lead = shown.weekday().num_days_from_monday(); + let first_cell = shown + .checked_sub_days(Days::new(u64::from(lead))) + .unwrap_or(shown); + + let mut grid = rect().vertical().spacing(CELL_GAP); + for week in 0..WEEKS { + let mut row = rect().horizontal().spacing(CELL_GAP); + for weekday in 0..7u32 { + let offset = u64::from(week * 7 + weekday); + let Some(day) = first_cell.checked_add_days(Days::new(offset)) else { + continue; + }; + row = row.child(DayCell { + day, + outside: day.month0() != shown.month0(), + enabled: (min..=max).contains(&day), + in_range: selected.is_some_and(|(a, b)| (a..=b).contains(&day)), + edge: selected.is_some_and(|(a, b)| day == a || day == b) + || pending == Some(day), + pick: pick.clone(), + }); + } + grid = grid.child(row); + } + grid.into_element() +} + +struct DayCell { + day: NaiveDate, + outside: bool, + enabled: bool, + in_range: bool, + edge: bool, + pick: F, +} + +impl PartialEq for DayCell { + fn eq(&self, other: &Self) -> bool { + self.day == other.day + && self.outside == other.outside + && self.enabled == other.enabled + && self.in_range == other.in_range + && self.edge == other.edge + } +} + +impl Component for DayCell { + fn render(&self) -> impl IntoElement { + let mut hovering = use_state(|| false); + + let day = self.day; + let enabled = self.enabled; + let mut pick = self.pick.clone(); + + let background = if self.edge { + colors::brand() + } else if self.in_range { + colors::brand().with_a(50) + } else if hovering() && enabled { + colors::component_bg_hover() + } else { + Color::TRANSPARENT + }; + + let color = if self.edge { + colors::fg_primary() + } else if !enabled || self.outside { + colors::fg_secondary().with_a(90) + } else { + colors::fg_primary() + }; + + rect() + .width(Size::px(CELL)) + .height(Size::px(CELL - 2.)) + .center() + .corner_radius(CornerRadius::new_all(6.)) + .background(background) + .maybe(enabled, |el| { + el.cursor(CursorIcon::Pointer) + .on_pointer_enter(move |_| hovering.set(true)) + .on_pointer_leave(move |_| hovering.set(false)) + .on_press(move |e: Event| { + e.stop_propagation(); + pick(day); + }) + }) + .child( + label() + .text(day.day().to_string()) + .font_size(11.) + .color(color), + ) + } +} + +fn footer(has_range: bool, pending: Option, mut clear: impl FnMut() + 'static) -> Element { + let hint = match pending { + Some(day) => format!("From {} — pick an end date", format_day(day)), + None => "Click a start date, then an end date".to_string(), + }; + + rect() + .horizontal() + .content(Content::Flex) + .width(Size::fill()) + .cross_align(Alignment::Center) + .spacing(8.) + .child( + label() + .text(hint) + .font_size(10.) + .max_lines(2) + .width(Size::flex(1.0)) + .color(colors::fg_secondary()), + ) + .maybe_child(has_range.then(|| { + rect() + .padding(Gaps::new_symmetric(4., 8.)) + .corner_radius(CornerRadius::new_all(6.)) + .background(colors::component_bg()) + .cursor(CursorIcon::Pointer) + .a11y_role(AccessibilityRole::Button) + .on_press(move |e: Event| { + e.stop_propagation(); + clear(); + }) + .child( + label() + .text("Reset") + .font_size(11.) + .color(colors::fg_secondary()), + ) + .into_element() + })) + .into_element() +} + +fn month_name(month0: u32) -> &'static str { + const MONTHS: [&str; 12] = [ + "January", + "February", + "March", + "April", + "May", + "June", + "July", + "August", + "September", + "October", + "November", + "December", + ]; + MONTHS[(month0 as usize).min(11)] +} diff --git a/packages/oneclient_app/src/components/dynamic_art.rs b/packages/oneclient_app/src/components/dynamic_art.rs index f533c1e7..261814aa 100644 --- a/packages/oneclient_app/src/components/dynamic_art.rs +++ b/packages/oneclient_app/src/components/dynamic_art.rs @@ -8,6 +8,7 @@ use oneclient_common::{VersionKey, parse_mc_version}; use crate::AppAssets; use crate::hooks::{loaded_image, use_cached_image, use_version_metadata}; use crate::layout::HOME_BACKGROUND_ASSET; +use crate::ui::ImageFallbackExt; /// The size cluster cards request so it is usually already cached when anything else wants it pub const ART_PREVIEW_EDGE: u32 = 512; @@ -121,6 +122,7 @@ impl Component for DynamicArt { .height(Size::fill()) .aspect_ratio(AspectRatio::Max) .image_cover(ImageCover::Center) + .fallback(rect().width(Size::fill()).height(Size::fill())) .into_element() } } diff --git a/packages/oneclient_app/src/components/generic_prompt.rs b/packages/oneclient_app/src/components/generic_prompt.rs index e1468bf0..96b22e2b 100644 --- a/packages/oneclient_app/src/components/generic_prompt.rs +++ b/packages/oneclient_app/src/components/generic_prompt.rs @@ -7,6 +7,7 @@ use oneclient_java::JAVA_CHOICE_DOWNLOAD; use crate::components::{Button, OverlayPopup}; use crate::hooks::{use_dispatch, use_notifications_snapshot}; +use crate::microsoft_java::MICROSOFT_JAVA_CHOICE_INSTALL; use crate::notifications::PendingPromptView; use crate::theme::colors; use crate::ui::border_all_color; @@ -15,7 +16,9 @@ use crate::updater::UPDATE_CHOICE_INSTALL; const CARD_BG: Color = Color::from_rgb(26, 34, 41); fn claimed_elsewhere(prompt: &PendingPromptView) -> bool { - prompt.has_choice(JAVA_CHOICE_DOWNLOAD) || prompt.has_choice(UPDATE_CHOICE_INSTALL) + prompt.has_choice(JAVA_CHOICE_DOWNLOAD) + || prompt.has_choice(UPDATE_CHOICE_INSTALL) + || prompt.has_choice(MICROSOFT_JAVA_CHOICE_INSTALL) } #[derive(PartialEq)] @@ -149,15 +152,17 @@ mod tests { } #[test] - fn the_two_rich_overlays_keep_their_own_prompts() { - assert!(claimed_elsewhere(&prompt(vec![Choice::primary( + fn the_rich_overlays_keep_their_own_prompts() { + for id in [ JAVA_CHOICE_DOWNLOAD, - "Download", - )]))); - assert!(claimed_elsewhere(&prompt(vec![Choice::primary( UPDATE_CHOICE_INSTALL, - "Download", - )]))); + MICROSOFT_JAVA_CHOICE_INSTALL, + ] { + assert!( + claimed_elsewhere(&prompt(vec![Choice::primary(id, "Download")])), + "{id} has its own overlay" + ); + } } #[test] diff --git a/packages/oneclient_app/src/components/grid_columns.rs b/packages/oneclient_app/src/components/grid_columns.rs deleted file mode 100644 index a5a1b3c1..00000000 --- a/packages/oneclient_app/src/components/grid_columns.rs +++ /dev/null @@ -1,27 +0,0 @@ -use freya::prelude::*; - -use super::Dropdown; - -pub const GRID_COLUMNS_MIN: u8 = 1; -pub const GRID_COLUMNS_MAX: u8 = 5; - -pub fn resolved_columns(columns: u8) -> usize { - columns.clamp(GRID_COLUMNS_MIN, GRID_COLUMNS_MAX) as usize -} - -pub fn grid_columns_picker(mut columns: State, height: f32) -> impl IntoElement { - let options: Vec = (GRID_COLUMNS_MIN..=GRID_COLUMNS_MAX) - .map(|n| n.to_string()) - .collect(); - let selected = resolved_columns(*columns.read()).to_string(); - - Dropdown::new(selected, options) - .width(Size::px(60.)) - .height(Size::px(height)) - .on_select(move |idx: usize| columns.set(GRID_COLUMNS_MIN + idx as u8)) -} - -pub fn cell_width(total: f32, cols: usize, gap: f32) -> f32 { - let cols = cols.max(1); - ((total - (cols - 1) as f32 * gap) / cols as f32).max(0.) -} diff --git a/packages/oneclient_app/src/components/icons.rs b/packages/oneclient_app/src/components/icons.rs index 4eab4960..4847de28 100644 --- a/packages/oneclient_app/src/components/icons.rs +++ b/packages/oneclient_app/src/components/icons.rs @@ -115,9 +115,11 @@ pub enum IconType { FolderDownload, Globe01, HelpCircle, + IconLogo, InfoCircle, Key01, LayoutTop, + LineChartUp01, Link03, LinkExternal01, Loading02, diff --git a/packages/oneclient_app/src/components/java_install_manager.rs b/packages/oneclient_app/src/components/java_install_manager.rs index 16e5987e..27a0e02b 100644 --- a/packages/oneclient_app/src/components/java_install_manager.rs +++ b/packages/oneclient_app/src/components/java_install_manager.rs @@ -8,6 +8,7 @@ use crate::ui::border_all_color; fn providers() -> Vec<(JavaVendor, &'static str)> { vec![ + (JavaVendor::Microsoft, "Microsoft JDK (Fastest/Recommended)"), (JavaVendor::Zulu, "Azul Zulu"), (JavaVendor::Adoptium, "Eclipse Temurin"), (JavaVendor::Corretto, "Amazon Corretto"), diff --git a/packages/oneclient_app/src/components/memory_field.rs b/packages/oneclient_app/src/components/memory_field.rs index 5827501f..40702f4e 100644 --- a/packages/oneclient_app/src/components/memory_field.rs +++ b/packages/oneclient_app/src/components/memory_field.rs @@ -4,14 +4,17 @@ use crate::components::{Dropdown, TextInput, validate_memory}; use crate::theme::colors; use crate::utils::{format_memory_gb, memory_presets_mb}; -const UNSET_LABEL: &str = "Default"; const CUSTOM_LABEL: &str = "Custom"; /// Presets picker with an input for memory allocation -pub fn memory_field(mut memory: State) -> impl IntoElement { +pub fn memory_field( + mut memory: State, + unset_label: &str, + placeholder: u32, +) -> impl IntoElement { let presets = memory_presets_mb(); let selected = match memory.read().trim() { - "" => UNSET_LABEL.to_string(), + "" => unset_label.to_string(), value => value .parse::() .ok() @@ -20,7 +23,8 @@ pub fn memory_field(mut memory: State) -> impl IntoElement { .unwrap_or_else(|| CUSTOM_LABEL.to_string()), }; - let options: Vec = presets.iter().copied().map(format_memory_gb).collect(); + let mut options: Vec = vec![unset_label.to_string()]; + options.extend(presets.iter().copied().map(format_memory_gb)); rect() .horizontal() @@ -31,7 +35,9 @@ pub fn memory_field(mut memory: State) -> impl IntoElement { .width(Size::px(100.)) .height(Size::px(34.)) .on_select(move |idx: usize| { - if let Some(mb) = presets.get(idx).copied() { + if idx == 0 { + memory.set(String::new()); + } else if let Some(mb) = presets.get(idx - 1).copied() { memory.set(mb.to_string()); } }), @@ -39,7 +45,7 @@ pub fn memory_field(mut memory: State) -> impl IntoElement { .child( TextInput::new(memory) .width(Size::px(90.)) - .placeholder(oneclient_common::default_mem_max().to_string()) + .placeholder(placeholder.to_string()) .on_validate(validate_memory) .trailing( label() diff --git a/packages/oneclient_app/src/components/microsoft_java_prompt.rs b/packages/oneclient_app/src/components/microsoft_java_prompt.rs new file mode 100644 index 00000000..10e6513d --- /dev/null +++ b/packages/oneclient_app/src/components/microsoft_java_prompt.rs @@ -0,0 +1,189 @@ +use std::time::Duration; + +use freya::prelude::*; +use oneclient_events::Answer; + +use crate::components::{Button, Icon, IconType, OverlayPopup}; +use crate::hooks::{use_dispatch, use_notifications_snapshot}; +use crate::microsoft_java::{MICROSOFT_JAVA_CHOICE_INSTALL, MICROSOFT_JAVA_CHOICE_NEVER}; +use crate::theme::colors; +use crate::ui::border_all_color; + +const CARD_BG: Color = Color::from_rgb(26, 34, 41); + +const HOLD_SECONDS: u8 = 3; + +#[derive(PartialEq)] +pub struct MicrosoftJavaPromptOverlay; + +impl Component for MicrosoftJavaPromptOverlay { + fn render(&self) -> impl IntoElement { + let snapshot = use_notifications_snapshot(); + let dispatch = use_dispatch(); + let remaining = use_state(|| HOLD_SECONDS); + + let showing = snapshot + .pending_prompt + .as_ref() + .is_some_and(|prompt| prompt.has_choice(MICROSOFT_JAVA_CHOICE_INSTALL)); + + use_side_effect_with_deps(&showing, move |&showing| { + let mut remaining = remaining; + + if !showing { + remaining.set(HOLD_SECONDS); + return; + } + + spawn(async move { + let mut remaining = remaining; + for step in (0..HOLD_SECONDS).rev() { + tokio::time::sleep(Duration::from_secs(1)).await; + + // Re-armed while this was sleeping so a newer prompt owns the + // hold now and this countdown is stale + if { *remaining.peek() } != step + 1 { + return; + } + + remaining.set(step); + } + }); + }); + + let Some(prompt) = snapshot.pending_prompt.clone() else { + return rect().into_element(); + }; + + if !prompt.has_choice(MICROSOFT_JAVA_CHOICE_INSTALL) { + return rect().into_element(); + } + + let held = *remaining.read(); + let locked = held > 0; + + let dismiss_label = prompt + .dismiss + .clone() + .unwrap_or_else(|| "Cancel".to_string()); + let never_label = prompt + .choice(MICROSOFT_JAVA_CHOICE_NEVER) + .map(|choice| choice.label.clone()) + .unwrap_or_else(|| "Don't ask again".to_string()); + let install_label = prompt + .choice(MICROSOFT_JAVA_CHOICE_INSTALL) + .map(|choice| choice.label.clone()) + .unwrap_or_else(|| "Proceed".to_string()); + + let close = dispatch.clone(); + let cancel = dispatch.clone(); + let never = dispatch.clone(); + let accept = dispatch.clone(); + + OverlayPopup::new() + // Closing from the backdrop is a dismissal too so it waits as well + .on_close(move |_| { + if !locked { + close.dismiss_prompt(); + } + }) + .child( + rect() + .width(Size::window_percent(100.)) + .height(Size::window_percent(100.)) + .center() + .child( + rect() + .vertical() + .width(Size::px(440.)) + .max_width(Size::window_percent(90.)) + .spacing(14.) + .padding(Gaps::new_all(20.)) + .corner_radius(CornerRadius::new_all(14.)) + .background(CARD_BG) + .border(border_all_color(1., colors::component_border())) + .child( + rect() + .horizontal() + .cross_align(Alignment::Center) + .spacing(10.) + .child( + Icon::new(IconType::DownloadCloud02) + .size(20.) + .color(colors::brand()), + ) + .child( + label() + .text(prompt.title.clone()) + .font_size(16.) + .font_weight(FontWeight::SEMI_BOLD) + .color(colors::fg_primary()), + ), + ) + .child( + label() + .text(prompt.question.clone()) + .font_size(12.) + .max_lines(6) + .width(Size::fill()) + .color(colors::fg_secondary()), + ) + .child( + rect() + .horizontal() + .width(Size::fill()) + .cross_align(Alignment::Center) + .main_align(Alignment::SpaceBetween) + .child( + label() + .text(if locked { + held.to_string() + } else { + String::new() + }) + .font_size(12.) + .color(colors::fg_secondary()), + ) + .child( + rect() + .horizontal() + .spacing(8.) + .child( + Button::new() + .secondary() + .disabled(locked) + .on_press(move |_| cancel.dismiss_prompt()) + .text(dismiss_label), + ) + .child( + Button::new() + .secondary() + .disabled(locked) + .on_press(move |_| { + never.answer_prompt(Answer::new( + MICROSOFT_JAVA_CHOICE_NEVER, + )) + }) + .text(never_label), + ) + .child( + Button::new() + .primary() + .on_press(move |_| { + accept.answer_prompt(Answer::new( + MICROSOFT_JAVA_CHOICE_INSTALL, + )) + }) + .child( + Icon::new(IconType::DownloadCloud02) + .size(14.), + ) + .text(install_label), + ), + ), + ), + ), + ) + .into_element() + } +} diff --git a/packages/oneclient_app/src/components/microsoft_login.rs b/packages/oneclient_app/src/components/microsoft_login.rs index 47cb3bc7..aabe3044 100644 --- a/packages/oneclient_app/src/components/microsoft_login.rs +++ b/packages/oneclient_app/src/components/microsoft_login.rs @@ -1,9 +1,9 @@ -use crate::state::LoginProgress; use freya::prelude::*; use freya::query::{MutationCapability, MutationStateData, UseMutation}; use freya::text_edit::Clipboard; -use oneclient_auth::{AuthErrorGuidance, MicrosoftLoginSession}; use oneclient_core::LauncherError; +use oneclient_auth::{AuthErrorGuidance, MicrosoftLoginSession}; +use crate::state::LoginProgress; use crate::components::{Button, Icon, IconType, OverlayPopup}; use crate::hooks::{ @@ -185,7 +185,7 @@ pub(crate) fn login_dialog( on_close: impl FnMut() + Clone + 'static, ) -> impl IntoElement { let mut close_scrim = on_close.clone(); - let mut close_cancel = on_close.clone(); + let mut close_cancel = on_close; OverlayPopup::new() .on_close(move |()| close_scrim()) .child( @@ -225,9 +225,10 @@ pub(crate) fn login_dialog( .spacing(18.) .child(browser_dialog_body(auth_url)) .child(dialog_divider()) - .child(device_code_dialog_body(user_code, verification_uri)) - .child(dialog_divider()) - .child(offline_mode_dialog_body(on_close.clone())) + .child(device_code_dialog_body( + user_code, + verification_uri, + )) .child(status_row(status, error)) .maybe_child(guidance.map(guidance_block)), ), @@ -313,13 +314,17 @@ fn status_row(status: Option, error: Option) -> impl Into } fn guidance_block(guidance: AuthErrorGuidance) -> impl IntoElement { - let mut steps = rect().vertical().width(Size::fill()).spacing(6.).child( - label() - .text("What you can do:") - .font_size(12.) - .font_weight(FontWeight::SEMI_BOLD) - .color(colors::fg_primary()), - ); + let mut steps = rect() + .vertical() + .width(Size::fill()) + .spacing(6.) + .child( + label() + .text("What you can do:") + .font_size(12.) + .font_weight(FontWeight::SEMI_BOLD) + .color(colors::fg_primary()), + ); for (index, step) in guidance.steps_to_fix.into_iter().enumerate() { steps = steps.child( @@ -438,39 +443,3 @@ fn device_code_dialog_body(code: String, verification_uri: String) -> impl IntoE ) .into_element() } - -fn offline_mode_dialog_body(on_close: impl FnMut() + Clone + 'static) -> impl IntoElement { - let on_close_btn = on_close; - - rect() - .vertical() - .width(Size::fill()) - .cross_align(Alignment::Center) - .spacing(10.) - .child( - label() - .text("Or Play Offline (LAN / Offline Servers)") - .font_size(13.) - .font_weight(FontWeight::MEDIUM) - .color(colors::fg_secondary()), - ) - .child( - Button::new() - .primary() - .on_press(move |_| { - let mut close_cb = on_close_btn.clone(); - spawn(async move { - if let Ok(state) = crate::launcher::state() { - let res = state.auth.add_offline_account("Player".to_string()).await; - if let Ok(acc) = res { - let _ = state.auth.set_default_account(Some(acc.id)).await; - } - } - close_cb(); - }); - }) - .text("Play Offline as Player") - .into_element(), - ) - .into_element() -} diff --git a/packages/oneclient_app/src/components/mod.rs b/packages/oneclient_app/src/components/mod.rs index 75dd453b..de7cdae1 100644 --- a/packages/oneclient_app/src/components/mod.rs +++ b/packages/oneclient_app/src/components/mod.rs @@ -7,15 +7,16 @@ mod checkbox; mod cluster_landscape_art; mod cluster_update_popup; mod context_menu; +mod date_range_picker; mod optional_mods_popup; mod dropdown; mod dynamic_art; mod file_drop; -mod grid_columns; mod icons; mod java_install_manager; mod generic_prompt; mod java_prompt; +mod microsoft_java_prompt; mod link_confirm; mod local_image; mod log_viewer; @@ -40,6 +41,7 @@ mod tab_bar; mod text_input; mod toasts; mod toggle; +mod tooltip; mod update_prompt; mod version_card; pub mod upload_mclogs; @@ -53,15 +55,16 @@ pub use checkbox::checkbox_labeled; pub use cluster_landscape_art::ClusterLandscapeArt; pub use cluster_update_popup::ClusterUpdatePopup; pub use context_menu::ContextMenu; +pub use date_range_picker::{DateRange, DateRangePicker}; pub use optional_mods_popup::OptionalModsPopup; pub use dropdown::Dropdown; pub use dynamic_art::{ART_PREVIEW_EDGE, DynamicArt}; pub use file_drop::FileDropOverlay; -pub use grid_columns::{cell_width, grid_columns_picker, resolved_columns}; pub use icons::{Icon, IconTint, IconType}; pub use java_install_manager::JavaInstallManager; pub use generic_prompt::GenericPromptOverlay; pub use java_prompt::JavaPromptOverlay; +pub use microsoft_java_prompt::MicrosoftJavaPromptOverlay; pub use link_confirm::ConfirmLinkOverlay; pub use local_image::LocalImage; pub use markdown::{Markdown, MarkdownStyle}; @@ -90,5 +93,6 @@ pub use tab_bar::{TabBar, TabItem}; pub use text_input::{TextInput, validate_memory, validate_number}; pub use toasts::Toasts; pub use toggle::{toggle, toggle_controlled}; +pub use tooltip::{TooltipHost, TooltipPlacement, use_provide_tooltips}; pub use update_prompt::UpdatePromptOverlay; pub use version_card::VersionCard; diff --git a/packages/oneclient_app/src/components/navbar/app_navbar.rs b/packages/oneclient_app/src/components/navbar/app_navbar.rs index d5dfcd04..47f18230 100644 --- a/packages/oneclient_app/src/components/navbar/app_navbar.rs +++ b/packages/oneclient_app/src/components/navbar/app_navbar.rs @@ -8,20 +8,25 @@ use crate::{ Route, components::{Avatar, Icon, IconType}, hooks::{ - settled_or_loading, try_default_account, use_active_cluster_id, use_clusters, - use_current_account, use_dispatch, use_notifications_snapshot, + settled_or_loading, try_default_account, use_active_cluster_id, use_browser_type, + use_clusters, use_current_account, use_dispatch, use_notifications_snapshot, }, theme, utils::sort_clusters_for_home, }; const NAVBAR_INTRO_MS: u64 = 460; +const LOGO_HIDE_NAVBAR_W: f32 = 1100.; +const NAVBAR_SIDE_PADDING_PX: f32 = 40.; +const NAV_LINK_SPACING_PX: f32 = 36.; +const COMPACT_LOGO_PX: f32 = 28.; #[derive(PartialEq)] pub struct Navbar; impl Component for Navbar { fn render(&self) -> impl IntoElement { + let mut navbar_width = use_state(|| 0f32); let intro = use_animation(|conf| { conf.on_creation(OnCreation::Run); AnimNum::new(0., 1.) @@ -32,6 +37,9 @@ impl Component for Navbar { let eased = intro.get().value(); let slide = (1.0 - eased) * -theme::NAVBAR_HEIGHT_PX; + let measured = *navbar_width.read(); + let show_logo = measured <= 0. || measured > LOGO_HIDE_NAVBAR_W; + rect() .width(Size::fill()) .height(Size::px(theme::NAVBAR_HEIGHT_PX)) @@ -44,11 +52,17 @@ impl Component for Navbar { .horizontal() .content(Content::Flex) .cross_align(Alignment::Center) - .padding(Gaps::new_symmetric(0.0, 40.0)) + .padding(Gaps::new_symmetric(0.0, NAVBAR_SIDE_PADDING_PX)) .offset_y(slide) .opacity(eased) - .child(navbar_left()) - .child(navbar_center()) + .on_sized(move |event: Event| { + let next = event.data().area.width(); + if (*navbar_width.peek() - next).abs() > 0.5 { + navbar_width.set(next); + } + }) + .child(navbar_left(show_logo)) + .child(navbar_center(!show_logo)) .child(NavbarRight), ) .child( @@ -61,34 +75,60 @@ impl Component for Navbar { } } -fn navbar_left() -> impl IntoElement { +fn navbar_left(show_logo: bool) -> impl IntoElement { rect() .horizontal() - .width(Size::flex(1.0)) + .width(if show_logo { + Size::flex(1.0) + } else { + Size::auto() + }) .cross_align(Alignment::Center) - .child(navbar_logo()) + .maybe(!show_logo, |el| { + el.padding(Gaps::new(0., NAV_LINK_SPACING_PX, 0., 0.)) + }) + .child(if show_logo { + NavbarLogo.into_element() + } else { + Icon::new(IconType::IconLogo) + .size(COMPACT_LOGO_PX) + .into_element() + }) } -fn navbar_logo() -> impl IntoElement { - let bytes = use_memo(|| crate::AppAssets::get_bytes("logo.svg").unwrap_or_default()); +#[derive(PartialEq)] +struct NavbarLogo; - SvgViewer::new(("logo.svg", bytes.read().cloned())) - .show_loader(false) - .height(Size::px(44.)) - .width(Size::px(214.)) - .color(theme::colors::fg_primary()) +impl Component for NavbarLogo { + fn render(&self) -> impl IntoElement { + let bytes = use_memo(|| crate::AppAssets::get_bytes("logo.svg").unwrap_or_default()); + + SvgViewer::new(("logo.svg", bytes.read().cloned())) + .show_loader(false) + .height(Size::px(44.)) + .width(Size::px(214.)) + .color(theme::colors::fg_primary()) + } } -fn navbar_center() -> impl IntoElement { +fn navbar_center(is_small: bool) -> impl IntoElement { let route = use_route::(); let browse_target = browse_target(); rect() .horizontal() .width(Size::flex(1.0)) - .main_align(Alignment::Center) + .main_align(if is_small { + Alignment::Start + } else { + Alignment::Center + }) .cross_align(Alignment::Center) - .spacing(36.) + .spacing(if is_small { + NAV_LINK_SPACING_PX + } else { + NAV_LINK_SPACING_PX / 2. + }) .child(NavLink { active: route == Route::Home {}, target: NavTarget::Route(Route::Home {}), @@ -115,17 +155,13 @@ fn navbar_center() -> impl IntoElement { target: NavTarget::Route(browse_target), nav_label: "Browse", }) - .child(NavLink { - active: route == Route::Stats {}, - target: NavTarget::Route(Route::Stats {}), - nav_label: "Stats", - }) } /// Falls back active cluster then most recently played then Versions when none exist fn browse_target() -> Route { let clusters = settled_or_loading(&use_clusters()).unwrap_or_default(); let active = *use_active_cluster_id().read(); + let package_type = use_browser_type().read().clone(); let cluster_id = active .filter(|id| clusters.iter().any(|cluster| cluster.id == *id)) @@ -134,7 +170,7 @@ fn browse_target() -> Route { match cluster_id { Some(cluster_id) => Route::Browser { cluster_id, - package_type: "mod".to_string(), + package_type, pick_cluster: true, }, None => Route::Clusters {}, @@ -253,6 +289,10 @@ impl Component for NavbarRight { let _ = RouterContext::get().push(Route::SettingsLauncher {}); }; + let open_stats = |_| { + let _ = RouterContext::get().push(Route::Stats {}); + }; + rect() .horizontal() .width(Size::flex(1.0)) @@ -261,17 +301,26 @@ impl Component for NavbarRight { .spacing(8.) .child( super::navbar_button() + .tooltip("Stats") + .child(Icon::new(IconType::LineChartUp01).size(20.)) + .on_press(open_stats), + ) + .child( + super::navbar_button() + .tooltip("Notifications") .child(notification_bell(unread)) .on_press(open_notifications), ) .child( super::navbar_button() + .tooltip("Settings") .child(Icon::new(IconType::Settings02).size(20.)) .on_press(open_settings), ) .child( super::navbar_button() .padding(0.0) + .tooltip("Switch account") .on_press(open_account_switcher) .child( Avatar::new(account_uuid) diff --git a/packages/oneclient_app/src/components/navbar/mod.rs b/packages/oneclient_app/src/components/navbar/mod.rs index 55b87014..5fba5620 100644 --- a/packages/oneclient_app/src/components/navbar/mod.rs +++ b/packages/oneclient_app/src/components/navbar/mod.rs @@ -44,16 +44,19 @@ pub fn window_controls() -> impl IntoElement { .layer(Layer::OverlayLevel(15)) .child( navbar_button() + .tooltip("Minimize") .child(Icon::new(IconType::Minus)) .on_press(minimize), ) .child( navbar_button() + .tooltip("Maximize") .child(Icon::new(IconType::Maximize01).size(20.)) .on_press(maximize), ) .child( navbar_button() + .tooltip("Close") .child(Icon::new(IconType::X)) .on_press(close), ) diff --git a/packages/oneclient_app/src/components/notifications.rs b/packages/oneclient_app/src/components/notifications.rs index a960a129..b8d40b1d 100644 --- a/packages/oneclient_app/src/components/notifications.rs +++ b/packages/oneclient_app/src/components/notifications.rs @@ -11,7 +11,7 @@ use crate::{ notifications::{InboxEntry, NotificationActionKind}, theme::colors, transfer::TransferStats, - utils::{format_duration_hms, format_size}, + utils::{format_durations, format_size}, }; #[derive(PartialEq)] @@ -445,7 +445,7 @@ fn transfer_footer(stats: TransferStats) -> Element { let speed = format!("{}/s", format_size(stats.speed_bps as u64)); let eta = stats .eta_secs - .map(|secs| format!("{} left", format_duration_hms(secs as i64))); + .map(|secs| format!("{} left", format_durations(secs as i64))); rect() .horizontal() diff --git a/packages/oneclient_app/src/components/overlay_popup.rs b/packages/oneclient_app/src/components/overlay_popup.rs index 4f22759e..f4f5d5ba 100644 --- a/packages/oneclient_app/src/components/overlay_popup.rs +++ b/packages/oneclient_app/src/components/overlay_popup.rs @@ -8,6 +8,7 @@ use freya::{ use crate::hooks::use_overlay_claim; const SCRIM_ALPHA: f32 = 90.; +const SCRIM_MIN_ALPHA: u8 = 1; /// Overlay level of a top level popup. Its scrim sits two levels below it. pub const OVERLAY_BASE_LEVEL: u8 = 12; @@ -116,7 +117,7 @@ impl Component for OverlayPopup { }); let scrim_alpha = if self.backdrop { SCRIM_ALPHA } else { 0. }; - let alpha = (fade.read().value() * scrim_alpha) as u8; + let alpha = ((fade.read().value() * scrim_alpha) as u8).max(SCRIM_MIN_ALPHA); rect() .layer(Layer::Overlay) @@ -130,7 +131,7 @@ impl Component for OverlayPopup { .height(Size::window_percent(100.)) .layer(Layer::OverlayLevel(self.overlay_level.saturating_sub(2))) .background(Color::from_argb(alpha, 0, 0, 0)) - .on_press(move |_| { + .on_all_press(move |_| { if let Some(on_close) = scrim_close.as_ref() { on_close.call(()); } diff --git a/packages/oneclient_app/src/components/package_row.rs b/packages/oneclient_app/src/components/package_row.rs index 1a733baa..9f40bae7 100644 --- a/packages/oneclient_app/src/components/package_row.rs +++ b/packages/oneclient_app/src/components/package_row.rs @@ -7,7 +7,7 @@ use crate::components::{Icon, IconType, toggle_controlled}; use crate::hooks::{ClusterAction, loaded_image, use_cached_image, use_cluster_mutation}; use crate::routes::Route; use crate::theme::colors; -use crate::ui::border_all_color; +use crate::ui::{ImageFallbackExt, border_all_color}; use crate::utils::format_size; pub(crate) const CARD_BG: Color = Color::from_rgb(26, 34, 41); @@ -268,6 +268,7 @@ pub(crate) fn grid_card( .font_size(14.) .font_weight(FontWeight::MEDIUM) .max_lines(1) + .text_overflow(TextOverflow::Ellipsis) .width(Size::fill()) .color(CARD_NAME.with_a(content_alpha)), ) @@ -277,6 +278,7 @@ pub(crate) fn grid_card( .text(format!("by {}", item.author)) .font_size(10.) .max_lines(1) + .text_overflow(TextOverflow::Ellipsis) .width(Size::fill()) .color(colors::fg_secondary().with_a(content_alpha)), ) @@ -368,6 +370,7 @@ fn package_info( .child( rect() .horizontal() + .width(Size::fill()) .cross_align(Alignment::Center) .spacing(8.) .child( @@ -376,6 +379,8 @@ fn package_info( .font_size(15.) .font_weight(FontWeight::MEDIUM) .max_lines(1) + .text_overflow(TextOverflow::Ellipsis) + .max_width(Size::percent(60.)) .color(CARD_NAME), ) .child(if remote { @@ -391,6 +396,9 @@ fn package_info( label() .text(format!("by {}", item.author)) .font_size(10.) + .max_lines(1) + .text_overflow(TextOverflow::Ellipsis) + .width(Size::fill()) .color(colors::fg_secondary()), ) }) @@ -400,6 +408,7 @@ fn package_info( .text(item.description.clone()) .font_size(11.) .max_lines(2) + .text_overflow(TextOverflow::Ellipsis) .width(Size::fill()) .color(colors::fg_secondary()), ) @@ -422,6 +431,7 @@ pub(crate) fn package_icon( .height(Size::px(size)) .aspect_ratio(AspectRatio::Min) .corner_radius(CornerRadius::new_all(8.)) + .fallback(icon_box(IconType::DotsGrid, size)) .into_element(), None if icon_url.is_some() => icon_box(IconType::DotsGrid, size), diff --git a/packages/oneclient_app/src/components/package_update_popup.rs b/packages/oneclient_app/src/components/package_update_popup.rs index ce6b2e21..89907681 100644 --- a/packages/oneclient_app/src/components/package_update_popup.rs +++ b/packages/oneclient_app/src/components/package_update_popup.rs @@ -247,15 +247,14 @@ fn content( } let answers = choices.read(); + let mut chosen = Vec::new(); for update in &all { match answers .get(&row_key(update)) .copied() .unwrap_or_default() { - RowChoice::Update => { - proceed_dispatch.apply_package_update(update.clone()); - } + RowChoice::Update => chosen.push(update.clone()), RowChoice::Skip => {} RowChoice::SkipVersion => proceed_dispatch .skip_package_update( @@ -264,7 +263,7 @@ fn content( ), } } - proceed_dispatch.close_package_updates(); + proceed_dispatch.proceed_package_updates(chosen); }) .child(Icon::new(IconType::DownloadCloud02).size(15.)) .text("Proceed"), diff --git a/packages/oneclient_app/src/components/pagination.rs b/packages/oneclient_app/src/components/pagination.rs index ef5a7640..5e9f1211 100644 --- a/packages/oneclient_app/src/components/pagination.rs +++ b/packages/oneclient_app/src/components/pagination.rs @@ -1,6 +1,6 @@ use freya::prelude::*; -use crate::components::{Button, Icon, IconType}; +use crate::components::{Button, Icon, IconType, TooltipPlacement}; use crate::theme::colors; use crate::ui::border_all_color; @@ -52,13 +52,18 @@ impl Component for Pagination { let current = *page.read(); let last = total_pages.saturating_sub(1); - let icon_btn = move |target: usize, available: bool, icon: IconType| { + let icon_btn = move |target: usize, + available: bool, + icon: IconType, + tooltip: &'static str| { let mut page = page; let enabled = available && interactive; Button::new() .secondary() .icon() .enabled(enabled) + .tooltip(tooltip) + .tooltip_placement(TooltipPlacement::Top) .on_press(move |_| page.set(target)) .child(Icon::new(icon).size(14.).color(if enabled { colors::fg_primary() @@ -130,11 +135,17 @@ impl Component for Pagination { .main_align(Alignment::Center) .cross_align(Alignment::Center) .spacing(6.) - .child(icon_btn(0, current > 0, IconType::ChevronsLeft)) + .child(icon_btn( + 0, + current > 0, + IconType::ChevronsLeft, + "First page", + )) .child(icon_btn( current.saturating_sub(1), current > 0, IconType::ArrowLeft, + "Previous page", )); if start > 0 { @@ -155,7 +166,17 @@ impl Component for Pagination { row = row.child(num_btn(last)); } - row.child(icon_btn(current + 1, current < last, IconType::ArrowRight)) - .child(icon_btn(last, current < last, IconType::ChevronsRight)) + row.child(icon_btn( + current + 1, + current < last, + IconType::ArrowRight, + "Next page", + )) + .child(icon_btn( + last, + current < last, + IconType::ChevronsRight, + "Last page", + )) } } diff --git a/packages/oneclient_app/src/components/screenshot_viewer.rs b/packages/oneclient_app/src/components/screenshot_viewer.rs index 3a9ee50c..5caae228 100644 --- a/packages/oneclient_app/src/components/screenshot_viewer.rs +++ b/packages/oneclient_app/src/components/screenshot_viewer.rs @@ -324,6 +324,7 @@ fn header_row( Button::new() .ghost() .icon() + .tooltip("Close") .on_press(on_close) .child(Icon::new(IconType::XClose).size(18.)), ) diff --git a/packages/oneclient_app/src/components/scrollview.rs b/packages/oneclient_app/src/components/scrollview.rs index 46978be6..cadeed8e 100644 --- a/packages/oneclient_app/src/components/scrollview.rs +++ b/packages/oneclient_app/src/components/scrollview.rs @@ -4,6 +4,7 @@ use freya::prelude::*; use crate::theme::colors; const LAZY_OVERSCAN: i64 = 3; +const SCROLLBAR_GUTTER: f32 = 14.; pub(crate) fn normalize_wheel_delta(delta: f64, scale_factor: f64) -> f32 { if delta == 0.0 { @@ -86,6 +87,7 @@ pub struct ScrollArea { padding: Gaps, spacing: f32, show_scrollbar: bool, + scrollbar_gutter: bool, horizontal: bool, content_width: f32, stick_bottom: bool, @@ -111,6 +113,7 @@ impl ScrollArea { padding: Gaps::default(), spacing: 0., show_scrollbar: true, + scrollbar_gutter: false, horizontal: false, content_width: 0., stick_bottom: false, @@ -148,6 +151,11 @@ impl ScrollArea { self } + pub fn scrollbar_gutter(mut self, gutter: bool) -> Self { + self.scrollbar_gutter = gutter; + self + } + pub fn horizontal(mut self, content_width: f32) -> Self { self.horizontal = true; self.content_width = content_width; @@ -244,6 +252,7 @@ impl ScrollArea { let spacing = self.spacing; let padding = self.padding; let show_scrollbar = self.show_scrollbar; + let scrollbar_gutter = self.scrollbar_gutter; let stick_bottom = self.stick_bottom; let on_user_scroll = self.on_user_scroll.clone(); @@ -293,10 +302,26 @@ impl ScrollArea { let pressing_v = use_memo(move || drag_start.read().is_some()); let pressing_h = use_memo(move || drag_start_x.read().is_some()); + let gutter = if show_v && scrollbar_gutter { + SCROLLBAR_GUTTER + } else { + 0. + }; + let padding = if gutter > 0. { + Gaps::new( + padding.top(), + padding.right() + gutter, + padding.bottom(), + padding.left(), + ) + } else { + padding + }; + let ctx = ScrollAreaCtx { corrected_x, corrected_y, - viewport_w: vp_w, + viewport_w: (vp_w - gutter).max(0.), viewport_h: vp_h, viewport_top: vp_top, viewport_left: vp_left, @@ -536,6 +561,7 @@ impl PartialEq for ScrollArea { && self.padding == other.padding && self.spacing == other.spacing && self.show_scrollbar == other.show_scrollbar + && self.scrollbar_gutter == other.scrollbar_gutter && self.horizontal == other.horizontal && self.content_width == other.content_width && self.stick_bottom == other.stick_bottom diff --git a/packages/oneclient_app/src/components/splash_curtain.rs b/packages/oneclient_app/src/components/splash_curtain.rs index 7f2ee599..c244a6a4 100644 --- a/packages/oneclient_app/src/components/splash_curtain.rs +++ b/packages/oneclient_app/src/components/splash_curtain.rs @@ -65,15 +65,14 @@ impl Component for CurtainFade { }); }); - let opacity = fade.get().value(); + let alpha = fade.get().value().clamp(0., 1.); rect() .width(Size::fill()) .height(Size::fill()) .position(Position::new_global().top(0.).left(0.)) .layer(Layer::Overlay) - .opacity(opacity) - .background(colors::page()) + .background(colors::page().with_a((255. * alpha).round() as u8)) .window_drag() .into_element() } diff --git a/packages/oneclient_app/src/components/tab_bar.rs b/packages/oneclient_app/src/components/tab_bar.rs index c56cb394..39679069 100644 --- a/packages/oneclient_app/src/components/tab_bar.rs +++ b/packages/oneclient_app/src/components/tab_bar.rs @@ -93,6 +93,7 @@ fn tab_label(text: &str, font_size: f32, active: bool) -> impl IntoElement { .text(text.to_string()) .font_size(font_size) .font_weight(ACTIVE_WEIGHT) + .max_lines(1) .color(Color::TRANSPARENT) .text_align(TextAlign::Center), ) @@ -115,6 +116,7 @@ fn tab_label(text: &str, font_size: f32, active: bool) -> impl IntoElement { } else { FontWeight::NORMAL }) + .max_lines(1) .color(colors::fg_primary()) .text_align(TextAlign::Center), ), @@ -135,23 +137,36 @@ fn count_pill(count: &str, font_size: f32) -> impl IntoElement { ) } +fn min_tab_width(text: &str, font_size: f32) -> f32 { + text.chars().count() as f32 * font_size * 0.62 +} + impl IntoElement for TabBar { fn into_element(self) -> Element { let font_size = self.font_size; + let overflows = self.width == Size::auto(); + let content = if overflows { + Content::Normal + } else { + Content::Fit + }; + rect() .horizontal() .width(self.width) .height(self.height) .spacing(self.spacing) .cross_align(Alignment::Center) - .content(Content::Fit) + .content(content) .children(self.tabs.into_iter().map(|tab| { + let min_width = overflows.then(|| min_tab_width(&tab.label, font_size)); TabButton { label: tab.label, active: tab.active, count: tab.count, font_size, + min_width, on_press: tab.on_press, } .into_element() @@ -166,6 +181,7 @@ struct TabButton { active: bool, count: Option, font_size: f32, + min_width: Option, on_press: Option>>, } @@ -181,6 +197,8 @@ impl Component for TabButton { let mut el = rect() .vertical() .content(Content::Fit) + .cross_align(Alignment::Center) + .map(self.min_width, |el, w| el.min_width(Size::px(w))) .a11y_id(a11y_id) .a11y_focusable(true) .a11y_role(AccessibilityRole::Button) diff --git a/packages/oneclient_app/src/components/toasts.rs b/packages/oneclient_app/src/components/toasts.rs index a3cbaa91..8a63f263 100644 --- a/packages/oneclient_app/src/components/toasts.rs +++ b/packages/oneclient_app/src/components/toasts.rs @@ -11,7 +11,7 @@ use oneclient_events::Level; use crate::{ ui::{divider}, - components::{Button, ButtonSize, Icon, IconType, progress_track}, + components::{Button, ButtonSize, Icon, IconType, TooltipPlacement, progress_track}, hooks::{use_dispatch, use_notifications_snapshot}, motion::use_animations_enabled, notifications::{InboxEntry, NotificationActionKind}, @@ -328,6 +328,8 @@ fn close_button(dispatch: crate::Actions, id: u64) -> impl IntoElement { .size(ButtonSize::Icon) .width(Size::px(28.)) .height(Size::px(28.)) + .tooltip("Dismiss") + .tooltip_placement(TooltipPlacement::Left) .on_press(move |_| dispatch.dismiss_toast(id)) .child( Icon::new(IconType::XClose) diff --git a/packages/oneclient_app/src/components/tooltip.rs b/packages/oneclient_app/src/components/tooltip.rs new file mode 100644 index 00000000..029cd694 --- /dev/null +++ b/packages/oneclient_app/src/components/tooltip.rs @@ -0,0 +1,315 @@ +use std::cell::Cell; +use std::rc::Rc; +use std::time::Duration; + +use freya::animation::{AnimNum, Ease, OnCreation, use_animation}; +use freya::prelude::*; + +use crate::components::OVERLAY_MAX_LEVEL; +use crate::theme::colors; +use crate::ui::{border_all_color, clamp_to_window, window_logical_size}; + +const SHOW_DELAY: Duration = Duration::from_millis(400); +const ANCHOR_GAP: f32 = 6.; +const MAX_WIDTH: f32 = 260.; +const FADE_MS: u64 = 110; +const HOVER_SLACK: f32 = 1.; + +#[allow(dead_code)] +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum TooltipPlacement { + Top, + #[default] + Bottom, + Left, + Right, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq)] +struct AnchorRect { + x: f32, + y: f32, + width: f32, + height: f32, +} + +impl AnchorRect { + fn contains(&self, x: f32, y: f32) -> bool { + x >= self.x - HOVER_SLACK + && x <= self.x + self.width + HOVER_SLACK + && y >= self.y - HOVER_SLACK + && y <= self.y + self.height + HOVER_SLACK + } +} + +#[derive(Clone, PartialEq)] +struct ActiveTooltip { + owner: u64, + text: Box, + anchor: AnchorRect, + placement: TooltipPlacement, +} + +#[derive(Clone)] +struct Tooltips { + active: State>, + next_owner: Rc>, +} + +pub fn use_provide_tooltips() { + let active = use_state(|| None::); + let next_owner = use_hook(|| Rc::new(Cell::new(0u64))); + use_provide_root_context(move || Tooltips { active, next_owner }); +} + +#[derive(Clone)] +struct AnchorState { + tooltips: Tooltips, + owner: u64, + area: Rc>, + generation: Rc>, + text: Box, + placement: TooltipPlacement, +} + +pub struct TooltipAnchor(Option); + +pub fn use_tooltip_anchor(text: Option>, placement: TooltipPlacement) -> TooltipAnchor { + let tooltips = use_hook(try_consume_root_context::); + let owner = use_hook(|| { + tooltips.as_ref().map_or(0, |tooltips| { + let owner = tooltips.next_owner.get() + 1; + tooltips.next_owner.set(owner); + owner + }) + }); + let area = use_hook(|| Rc::new(Cell::new(AnchorRect::default()))); + let generation = use_hook(|| Rc::new(Cell::new(0u64))); + + let abandoned = generation.clone(); + use_drop(move || abandoned.set(abandoned.get() + 1)); + + TooltipAnchor(tooltips.zip(text).map(|(tooltips, text)| AnchorState { + tooltips, + owner, + area, + generation, + text, + placement, + })) +} + +impl TooltipAnchor { + pub fn attach(&self, rect: Rect) -> Rect { + let Some(state) = self.0.clone() else { + return rect; + }; + + let sized = state.clone(); + let enter = state.clone(); + let leave = state.clone(); + let pressed = state.clone(); + let scrolled = state; + + rect.on_sized(move |e: Event| { + let area = e.area; + sized.area.set(AnchorRect { + x: area.min_x(), + y: area.min_y(), + width: area.width(), + height: area.height(), + }); + }) + .on_pointer_enter(move |e: Event| { + let global = e.global_location(); + let element = e.element_location(); + + let mut anchor = enter.area.get(); + anchor.x = (global.x - element.x) as f32; + anchor.y = (global.y - element.y) as f32; + + let generation = enter.generation.get() + 1; + enter.generation.set(generation); + + let pending = enter.generation.clone(); + let mut active = enter.tooltips.active; + let tooltip = ActiveTooltip { + owner: enter.owner, + text: enter.text.clone(), + anchor, + placement: enter.placement, + }; + + spawn(async move { + tokio::time::sleep(SHOW_DELAY).await; + if pending.get() == generation { + active.set(Some(tooltip)); + } + }); + }) + .on_pointer_leave(move |_| hide(&leave)) + .on_mouse_down(move |_| hide(&pressed)) + .on_wheel(move |_| hide(&scrolled)) + } +} + +fn hide(state: &AnchorState) { + state.generation.set(state.generation.get() + 1); + clear_owner(state.tooltips.active, state.owner); +} + +fn clear_owner(mut active: State>, owner: u64) { + let shown = active.peek().as_ref().map(|tooltip| tooltip.owner); + if shown == Some(owner) { + active.set(None); + } +} + +#[derive(PartialEq)] +pub struct TooltipHost; + +impl Component for TooltipHost { + fn render(&self) -> impl IntoElement { + let Some(tooltips) = use_hook(try_consume_root_context::) else { + return rect(); + }; + + let state = tooltips.active; + let active = (*state.read()).clone(); + + let mut host = rect() + .layer(Layer::OverlayLevel(OVERLAY_MAX_LEVEL)) + .position(Position::new_global().top(0.).left(0.)) + .width(Size::window_percent(100.)) + .height(Size::window_percent(100.)) + .interactive(false); + + if let Some((owner, anchor)) = active.as_ref().map(|active| (active.owner, active.anchor)) { + host = host.on_global_pointer_move(move |e: Event| { + let cursor = e.global_location(); + if !anchor.contains(cursor.x as f32, cursor.y as f32) { + clear_owner(state, owner); + } + }); + } + + host.maybe_child(active.map(|active| { + let owner = active.owner; + TooltipBubble { + text: active.text, + anchor: active.anchor, + placement: active.placement, + key: DiffKey::None, + } + .key(owner) + .into_element() + })) + } +} + +#[derive(PartialEq)] +struct TooltipBubble { + text: Box, + anchor: AnchorRect, + placement: TooltipPlacement, + key: DiffKey, +} + +impl KeyExt for TooltipBubble { + fn write_key(&mut self) -> &mut DiffKey { + &mut self.key + } +} + +impl Component for TooltipBubble { + fn render(&self) -> impl IntoElement { + let mut size = use_state(Size2D::zero); + + let measured = *size.read(); + let placed = measured.width > 0.; + + let fade = use_animation(|conf| { + conf.on_creation(OnCreation::Run); + AnimNum::new(0., 1.).time(FADE_MS).ease(Ease::Out) + }); + + let (x, y) = place(self.anchor, self.placement, measured); + + rect() + .position(Position::new_global().top(y).left(x)) + .interactive(false) + .opacity(if placed { fade.read().value() } else { 0. }) + .max_width(Size::px(MAX_WIDTH)) + .padding(Gaps::new_symmetric(5., 9.)) + .corner_radius(CornerRadius::new_all(8.)) + .background(colors::page_elevated()) + .border(border_all_color(1., colors::component_border())) + .on_sized(move |e: Event| { + let bubble = e.area.size; + let previous = *size.peek(); + if (bubble.width - previous.width).abs() > 0.5 + || (bubble.height - previous.height).abs() > 0.5 + { + size.set(bubble); + } + }) + .child( + label() + .text(self.text.to_string()) + .font_size(12.) + .font_weight(FontWeight::MEDIUM) + .max_lines(2) + .color(colors::fg_primary()), + ) + } + + fn render_key(&self) -> DiffKey { + self.key.clone().or(self.default_key()) + } +} + +fn place(anchor: AnchorRect, placement: TooltipPlacement, size: Size2D) -> (f32, f32) { + let window = window_logical_size(); + + let centered_x = anchor.x + (anchor.width - size.width) / 2.; + let centered_y = anchor.y + (anchor.height - size.height) / 2.; + + let above = anchor.y - size.height - ANCHOR_GAP; + let below = anchor.y + anchor.height + ANCHOR_GAP; + let before = anchor.x - size.width - ANCHOR_GAP; + let after = anchor.x + anchor.width + ANCHOR_GAP; + + let fits_above = above >= 0.; + let fits_below = below + size.height <= window.height; + let fits_before = before >= 0.; + let fits_after = after + size.width <= window.width; + + let (x, y) = match placement { + TooltipPlacement::Top => ( + centered_x, + if fits_above || !fits_below { above } else { below }, + ), + TooltipPlacement::Bottom => ( + centered_x, + if fits_below || !fits_above { below } else { above }, + ), + TooltipPlacement::Left => ( + if fits_before || !fits_after { + before + } else { + after + }, + centered_y, + ), + TooltipPlacement::Right => ( + if fits_after || !fits_before { + after + } else { + before + }, + centered_y, + ), + }; + + clamp_to_window(x, y, size.width, size.height) +} diff --git a/packages/oneclient_app/src/events.rs b/packages/oneclient_app/src/events.rs index 43dbddde..56bd0328 100644 --- a/packages/oneclient_app/src/events.rs +++ b/packages/oneclient_app/src/events.rs @@ -11,7 +11,7 @@ use tokio::sync::mpsc; use crate::hooks::PumpSignal; use crate::notifications::{MESSAGE_TOAST_TTL, PendingPromptView}; -use crate::state::{AppChannel, AppState, LoginProgress}; +use crate::state::{AppChannel, AppState, LoginProgress, StorageScanProgress}; /// Quiet period before log lines are written without it every line wakes the log view const GAME_LOG_FLUSH: Duration = Duration::from_millis(120); @@ -127,6 +127,7 @@ impl EventPump { let mut logs: Vec<(i64, String)> = Vec::new(); let mut failed: Option<(i64, String)> = None; let mut login: Option> = None; + let mut storage_scan: Option> = None; let mut sync_complete = false; for event in batch { @@ -148,7 +149,6 @@ impl EventPump { cluster_id, message, }) => failed = Some((cluster_id, message)), - // Lifted out so it never reaches the engine the sign-in modal renders it inline Event::Progress(ProgressEvent::Update { id, ref label, @@ -161,6 +161,18 @@ impl EventPump { total, })); } + Event::Progress(ProgressEvent::Update { + id, + ref label, + current, + total, + }) if id == oneclient_core::storage::STORAGE_SCAN_PROGRESS => { + storage_scan = Some((current < total).then(|| StorageScanProgress { + label: label.clone(), + current, + total, + })); + } other => engine_events.push(other), } } @@ -197,6 +209,12 @@ impl EventPump { .microsoft_login = progress; } + if let Some(progress) = storage_scan { + self.station + .write_channel(AppChannel::StorageScan) + .storage_scan = progress; + } + if !engine_events.is_empty() { folded.touched_engine = true; let mut guard = self.station.write_channel(AppChannel::Notifications); diff --git a/packages/oneclient_app/src/hooks/actions.rs b/packages/oneclient_app/src/hooks/actions.rs index 73855c58..b83a9ce7 100644 --- a/packages/oneclient_app/src/hooks/actions.rs +++ b/packages/oneclient_app/src/hooks/actions.rs @@ -10,8 +10,9 @@ use std::time::{Duration, Instant}; use freya::prelude::spawn_forever; use freya::radio::RadioStation; +use oneclient_cluster::profiles::list_named_profiles; use oneclient_cluster::{ - ClusterStage, ClusterUpdate, GameSettingsProfile, PackageUpdateMode, ProfileUpdate, + Cluster, ClusterStage, ClusterUpdate, GameSettingsProfile, PackageUpdateMode, ProfileUpdate, }; use oneclient_common::domain::{ContentType, ProviderId}; use oneclient_content::packages::{LiveSync, LocalImportReport}; @@ -87,6 +88,26 @@ fn plan_launch_updates( } } +/// Names the clusters that pinned `java_path` to this runtime by hand +fn clusters_pinned_to_java( + profiles: &[GameSettingsProfile], + clusters: &[Cluster], + absolute_path: &str, +) -> Vec { + profiles + .iter() + .filter(|profile| profile.java_path.as_deref() == Some(absolute_path)) + .map(|profile| { + clusters + .iter() + .find(|cluster| { + cluster.setting_profile_name.as_deref() == Some(profile.name.as_str()) + }) + .map_or_else(|| profile.name.clone(), |cluster| cluster.name.clone()) + }) + .collect() +} + /// The pump alone owns the toast timers so adding or removing a toast has to /// tell it to re-arm hover-pause is a timer property not component state #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -275,6 +296,13 @@ impl Actions { } } + pub fn skip_microsoft_java(&self) { + if let Some(updated) = self.mutate_settings(|settings| settings.skip_microsoft_java = true) + { + self.persist(updated); + } + } + pub fn reset_onboarding(&self) { if let Some(updated) = self.mutate_settings(|settings| { settings.seen_onboarding = false; @@ -560,6 +588,29 @@ impl Actions { let path = path.into(); spawn_forever(async move { let Ok(state) = launcher::state() else { return }; + let events = state.services.events.clone(); + + // Checks if the jdk is pinned to the cluster + match ( + list_named_profiles(&state.services.db).await, + state.clusters.list().await, + ) { + (Ok(profiles), Ok(clusters)) => { + let pinned = clusters_pinned_to_java(&profiles, &clusters, &path); + if !pinned.is_empty() { + events + .notify("Cannot remove this JDK") + .body(format!("It is used by cluster: {}", pinned.join(", "))) + .error() + .send(); + return; + } + } + (Err(err), _) | (_, Err(err)) => { + tracing::error!("could not check whether the java runtime is in use: {err:#}"); + } + } + match state.java.remove_runtime(&path).await { Ok(()) => super::invalidate_java_queries().await, Err(err) => tracing::error!("failed to remove java runtime: {err:#}"), @@ -743,6 +794,7 @@ impl Actions { state.bundles.as_ref(), &content, Some(&session), + None, ) .await { @@ -795,6 +847,12 @@ impl Actions { }); } + pub fn proceed_package_updates(&self, chosen: Vec) { + self.with_engine(move |state| { + state.notifications.proceed_package_updates(chosen) + }); + } + pub fn close_package_updates(&self) { self.with_engine(|state| state.notifications.close_package_updates()); } @@ -1032,7 +1090,7 @@ impl Actions { if install.result.is_ok() { // Before the refresh below so the version list never renders // the moment where both copies read as active - if let Err(err) = oneclient_content::bundles::reconcile_duplicate_activity( + if let Err(err) = oneclient_content::packages::reconcile_duplicate_activity( cluster_id, &state.services.content(), ) @@ -1162,6 +1220,7 @@ impl Actions { cluster_id, state.bundles.as_ref(), &state.services.content(), + None, ) .await { @@ -1178,7 +1237,9 @@ impl Actions { } }; - actions.record_bundle_checks([cluster_id]); + if result.settled() { + actions.record_bundle_checks([cluster_id]); + } super::invalidate_cluster_queries().await; if let Some(spec) = @@ -1304,7 +1365,18 @@ impl Actions { if synced && let Ok(clusters) = state.clusters.list().await { - actions.record_bundle_checks(clusters.iter().map(|cluster| cluster.id)); + let unsettled: std::collections::HashSet = changed + .iter() + .filter(|(_, result)| !result.settled()) + .map(|(cluster_id, _)| *cluster_id) + .collect(); + + actions.record_bundle_checks( + clusters + .iter() + .map(|cluster| cluster.id) + .filter(|cluster_id| !unsettled.contains(cluster_id)), + ); } actions @@ -1384,23 +1456,14 @@ impl Actions { return; } - let applied = match tokio::time::timeout( - BUNDLE_APPLY_BUDGET, - oneclient_core::apply_bundle_updates(cluster_id, state.bundles.as_ref(), &content), + let result = match oneclient_core::apply_bundle_updates( + cluster_id, + state.bundles.as_ref(), + &content, + Some(Instant::now() + BUNDLE_APPLY_BUDGET), ) .await { - Ok(applied) => applied, - Err(_elapsed) => { - tracing::warn!( - cluster_id, - "bundle updates exceeded their launch budget, launching anyway" - ); - return; - } - }; - - let result = match applied { Ok(result) => result, Err(err) => { tracing::warn!( @@ -1412,7 +1475,14 @@ impl Actions { } }; - if synced { + if result.stopped_early { + tracing::warn!( + cluster_id, + "bundle updates ran out of their launch budget; the rest go at the next launch" + ); + } + + if synced && result.settled() { self.record_bundle_checks([cluster_id]); } @@ -1485,7 +1555,10 @@ impl Actions { else { return; }; - self.prompt_package_updates(group).await; + let chosen = self.prompt_package_updates(group).await; + if !chosen.is_empty() { + self.apply_updates_for_launch(state, &chosen).await; + } } } } @@ -1568,6 +1641,8 @@ impl Actions { .finish_grouped_as_actions(&mut app.inbox, session_id, spec); }); + super::invalidate_cluster_queries().await; + if applied > 0 { super::invalidate_cluster_queries().await; } @@ -1613,7 +1688,10 @@ impl Actions { let _ = wait.await; } - async fn prompt_package_updates(&self, group: PackageUpdateGroup) { + async fn prompt_package_updates( + &self, + group: PackageUpdateGroup, + ) -> Vec { let (done, wait) = tokio::sync::oneshot::channel(); self.with_engine(move |state| { @@ -1621,71 +1699,7 @@ impl Actions { state.center_open = false; }); - let _ = wait.await; - } - - pub fn apply_package_update(&self, update: oneclient_core::BrowserPackageUpdate) { - let actions = self.clone(); - spawn_forever(async move { - let Ok(state) = launcher::state() else { return }; - let events = state.services.events.clone(); - - let session = oneclient_events::GroupedProgressSession::start( - &events, - format!("Updating {}", update.display_name), - ); - let child = session.child( - update.display_name.clone(), - 1, - oneclient_events::TaskCategory::Packages, - ); - - let result = oneclient_core::apply_browser_package_update( - &update, - Some(&child), - &state.services.content(), - ) - .await; - - child.finish(); - let session_id = session.detach(); - - let spec = match &result { - Ok(_) => NotificationSpec { - title: "Updated".to_string(), - body: format!( - "{} is now on {}", - update.display_name, update.latest_version_name - ), - level: Level::Info, - icon: Some(IconType::DownloadCloud02), - progress: None, - actions: Vec::new(), - }, - Err(err) => NotificationSpec { - title: "Update failed".to_string(), - body: err.to_string(), - level: Level::Error, - icon: None, - progress: None, - actions: Vec::new(), - }, - }; - - actions.with_engine(|app| { - app.notifications - .finish_grouped_as_actions(&mut app.inbox, session_id, Some(spec)); - }); - - // A failed update stays in the list so the user can retry - if result.is_ok() { - actions.with_engine(|app| { - app.notifications - .resolve_package_update(update.cluster_id, &update.hash); - }); - super::invalidate_cluster_queries().await; - } - }); + wait.await.unwrap_or_default() } /// The package stays marked out of date only the modal stops asking and @@ -1748,6 +1762,8 @@ async fn launch(actions: &Actions, cluster_id: ClusterId) { return; }; + crate::microsoft_java::offer_for_pinned_cluster(actions, cluster_id).await; + // Before the game process never after Minecraft reads its mods once at // startup actions @@ -1990,4 +2006,88 @@ mod tests { ); } } + + const JDK_25: &str = r"C:\jdk-25\bin\javaw.exe"; + + fn profile(name: &str, java_path: Option<&str>) -> GameSettingsProfile { + GameSettingsProfile { + name: name.into(), + java_path: java_path.map(Into::into), + ..GameSettingsProfile::default_global_profile() + } + } + + fn cluster(id: i64, name: &str, profile_name: Option<&str>) -> Cluster { + Cluster { + id, + name: name.into(), + folder_name: name.into(), + setting_profile_name: profile_name.map(Into::into), + mc_version: "26.2".into(), + mc_loader: oneclient_common::domain::GameLoader::default(), + mc_loader_version: None, + stage: ClusterStage::default(), + created_at: None, + last_played: None, + overall_played: Duration::ZERO, + linked_modpack_hash: None, + } + } + + #[test] + fn a_runtime_nobody_pinned_is_free_to_go() { + let profiles = vec![profile("26.2 Fabric", None)]; + let clusters = vec![cluster(1, "26.2 Fabric", Some("26.2 Fabric"))]; + + assert!(clusters_pinned_to_java(&profiles, &clusters, JDK_25).is_empty()); + } + + #[test] + fn a_pinned_runtime_is_reported_under_its_cluster_name() { + let profiles = vec![profile("26.2 Fabric", Some(JDK_25))]; + let clusters = vec![cluster(1, "26.2 Fabric", Some("26.2 Fabric"))]; + + assert_eq!( + clusters_pinned_to_java(&profiles, &clusters, JDK_25), + vec!["26.2 Fabric".to_string()], + ); + } + + #[test] + fn only_the_runtime_being_removed_counts() { + let profiles = vec![ + profile("21 pinned", Some(r"C:\jdk-21\bin\javaw.exe")), + profile("25 pinned", Some(JDK_25)), + ]; + let clusters = vec![ + cluster(1, "Old pack", Some("21 pinned")), + cluster(2, "New pack", Some("25 pinned")), + ]; + + assert_eq!( + clusters_pinned_to_java(&profiles, &clusters, JDK_25), + vec!["New pack".to_string()], + ); + } + + #[test] + fn every_cluster_holding_the_runtime_is_named() { + let profiles = vec![profile("a", Some(JDK_25)), profile("b", Some(JDK_25))]; + let clusters = vec![cluster(1, "Alpha", Some("a")), cluster(2, "Beta", Some("b"))]; + + assert_eq!( + clusters_pinned_to_java(&profiles, &clusters, JDK_25), + vec!["Alpha".to_string(), "Beta".to_string()], + ); + } + + #[test] + fn a_profile_no_cluster_claims_falls_back_to_its_own_name() { + let profiles = vec![profile("orphaned", Some(JDK_25))]; + + assert_eq!( + clusters_pinned_to_java(&profiles, &[], JDK_25), + vec!["orphaned".to_string()], + ); + } } diff --git a/packages/oneclient_app/src/hooks/active_cluster.rs b/packages/oneclient_app/src/hooks/active_cluster.rs index 46b5cba7..6608f6ff 100644 --- a/packages/oneclient_app/src/hooks/active_cluster.rs +++ b/packages/oneclient_app/src/hooks/active_cluster.rs @@ -55,6 +55,17 @@ pub fn use_browser_compat() -> State { consume_root_context::().0 } +#[derive(Clone)] +pub struct BrowserTypeState(pub State); + +pub fn use_provide_browser_type(state: BrowserTypeState) { + use_hook(move || provide_root_context(state)); +} + +pub fn use_browser_type() -> State { + consume_root_context::().0 +} + #[derive(Clone)] pub struct LinkConfirmState(pub State>); diff --git a/packages/oneclient_app/src/hooks/mod.rs b/packages/oneclient_app/src/hooks/mod.rs index f0c5e986..2adf9020 100644 --- a/packages/oneclient_app/src/hooks/mod.rs +++ b/packages/oneclient_app/src/hooks/mod.rs @@ -14,10 +14,11 @@ pub use overlay_claims::{ pub use view_state::{PersistedView, use_view_state}; pub use active_cluster::{ - ActiveClusterState, BrowserCompatState, BrowserStateStore, BrowserUiState, LinkConfirmState, - OnboardingSelectionState, SplashState, StartMaximizedState, use_active_cluster_id, - use_browser_compat, use_browser_state_store, use_link_confirm, use_onboarding_selection, - use_provide_active_cluster, use_provide_browser_compat, use_provide_browser_state, + ActiveClusterState, BrowserCompatState, BrowserStateStore, BrowserTypeState, BrowserUiState, + LinkConfirmState, OnboardingSelectionState, SplashState, StartMaximizedState, + use_active_cluster_id, use_browser_compat, use_browser_state_store, use_browser_type, + use_link_confirm, use_onboarding_selection, use_provide_active_cluster, + use_provide_browser_compat, use_provide_browser_state, use_provide_browser_type, use_provide_link_confirm, use_provide_onboarding_selection, use_provide_splash, use_provide_start_maximized, use_splash, use_start_maximized, }; @@ -72,7 +73,7 @@ pub(crate) use queries::invalidate_auth_queries; use crate::notifications::NotificationSnapshot; use crate::state::{ AppChannel, GameState, InstallState, LauncherInit, LoginProgress, RelocationState, - SettingsState, + SettingsState, StorageScanProgress, }; use freya::prelude::*; use freya::radio::use_radio; @@ -126,6 +127,13 @@ pub fn use_installs_snapshot() -> InstallState { use_radio(AppChannel::Installs).read().installs.clone() } +pub fn use_storage_scan_progress() -> Option { + use_radio(AppChannel::StorageScan) + .read() + .storage_scan + .clone() +} + pub fn use_pending_launch() -> Option { use_radio(AppChannel::PendingLaunch) .read() diff --git a/packages/oneclient_app/src/hooks/queries/player_profile.rs b/packages/oneclient_app/src/hooks/queries/player_profile.rs index 58ef5892..39e3687b 100644 --- a/packages/oneclient_app/src/hooks/queries/player_profile.rs +++ b/packages/oneclient_app/src/hooks/queries/player_profile.rs @@ -38,13 +38,11 @@ impl QueryCapability for FetchPlayerProfileQuery { } let access_token = keys.access_token.clone(); + let state = crate::launcher::state()?; let client = &state.services.requester; - Ok( - minecraft::fetch_player_profile_view(client, &keys.uuid, access_token.as_deref()) - .await?, - ) + Ok(minecraft::fetch_player_profile_view(client, &keys.uuid, access_token.as_deref()).await?) } // TODO Cache diff --git a/packages/oneclient_app/src/hooks/view_state.rs b/packages/oneclient_app/src/hooks/view_state.rs index 7379d707..64696a34 100644 --- a/packages/oneclient_app/src/hooks/view_state.rs +++ b/packages/oneclient_app/src/hooks/view_state.rs @@ -7,7 +7,6 @@ use super::{use_dispatch, use_settings_snapshot}; pub struct PersistedView { pub layout: State, pub sort: State>, - pub columns: State, } pub fn use_view_state(key: &str) -> PersistedView { @@ -18,17 +17,14 @@ pub fn use_view_state(key: &str) -> PersistedView { let initial = settings.view_state(&key); let init_layout = initial.layout; let init_sort = initial.sort; - let init_columns = initial.columns; let layout = use_state(move || init_layout); let sort = use_state(move || init_sort.clone()); - let columns = use_state(move || init_columns); let mut first = use_state(|| true); use_side_effect(move || { let next = ViewState { layout: *layout.read(), sort: sort.read().clone(), - columns: *columns.read(), }; if *first.peek() { first.set(false); @@ -42,9 +38,5 @@ pub fn use_view_state(key: &str) -> PersistedView { dispatch.set_settings(updated); }); - PersistedView { - layout, - sort, - columns, - } + PersistedView { layout, sort } } diff --git a/packages/oneclient_app/src/layout/animated_outlet.rs b/packages/oneclient_app/src/layout/animated_outlet.rs index 859f97b4..5938c37a 100644 --- a/packages/oneclient_app/src/layout/animated_outlet.rs +++ b/packages/oneclient_app/src/layout/animated_outlet.rs @@ -218,7 +218,7 @@ impl Component for AnimatedAppOutlet { } }; - let overlay = show_overlay.then(|| appshell_overlay().opacity(chrome_opacity)); + let overlay = show_overlay.then(|| appshell_overlay(chrome_opacity)); let back = (!is_home).then(|| { rect() diff --git a/packages/oneclient_app/src/layout/app_shell.rs b/packages/oneclient_app/src/layout/app_shell.rs index f5df1122..f1ed300f 100644 --- a/packages/oneclient_app/src/layout/app_shell.rs +++ b/packages/oneclient_app/src/layout/app_shell.rs @@ -20,9 +20,10 @@ use oneclient_core::clusters::Cluster; use oneclient_db::models::ClusterId; use crate::hooks::{ - ActiveClusterState, BrowserCompatState, BrowserStateStore, use_active_cluster_id, use_clusters, - use_game_snapshot, use_launcher, use_provide_active_cluster, use_provide_browser_compat, - use_provide_browser_state, use_splash, + ActiveClusterState, BrowserCompatState, BrowserStateStore, BrowserTypeState, + use_active_cluster_id, use_clusters, use_game_snapshot, use_launcher, + use_provide_active_cluster, use_provide_browser_compat, use_provide_browser_state, + use_provide_browser_type, use_splash, }; use crate::theme::colors; use oneclient_events::LaunchStage; @@ -43,6 +44,9 @@ impl Component for AppShell { let browser_state = use_state(HashMap::new); use_provide_browser_state(BrowserStateStore(browser_state)); + let browser_type = use_state(|| "mod".to_string()); + use_provide_browser_type(BrowserTypeState(browser_type)); + // `FileDrop` bubbles so anything a drop zone doesn't `stop_propagation()` lands here let mut drop_hovering = use_state(|| false); let mut drop_pending = use_state(Vec::::new); @@ -229,7 +233,10 @@ fn copy_error_button(message: &str, dispatch: crate::Actions) -> impl IntoElemen .into_element() } -pub(crate) fn appshell_overlay() -> Rect { +pub(crate) fn appshell_overlay(alpha: f32) -> Rect { + let alpha = alpha.clamp(0., 1.); + let scale = |a: u8| (f32::from(a) * alpha).round() as u8; + rect() .width(Size::fill()) .height(Size::fill()) @@ -242,15 +249,15 @@ pub(crate) fn appshell_overlay() -> Rect { .width(Size::fill()) .background( LinearGradient::new() - .stop((Color::BLACK.with_a(100), 0.)) - .stop((colors::page().with_a(255), 95.0)), + .stop((Color::BLACK.with_a(scale(100)), 0.)) + .stop((colors::page().with_a(scale(255)), 95.0)), ), ) .child( rect() .height(Size::fill()) .width(Size::fill()) - .background(colors::page()), + .background(colors::page().with_a(scale(255))), ) } diff --git a/packages/oneclient_app/src/layout/cluster_shell.rs b/packages/oneclient_app/src/layout/cluster_shell.rs index a5458e7e..8f619cff 100644 --- a/packages/oneclient_app/src/layout/cluster_shell.rs +++ b/packages/oneclient_app/src/layout/cluster_shell.rs @@ -274,6 +274,7 @@ fn cluster_header( Button::new() .danger() .icon() + .tooltip("Stop game") .on_press(move |_| kill_dispatch.kill_cluster(cluster_id)) .child(Icon::new(IconType::Square).size(16.)), ) diff --git a/packages/oneclient_app/src/layout/root_layout.rs b/packages/oneclient_app/src/layout/root_layout.rs index b891bad3..7efbb9b7 100644 --- a/packages/oneclient_app/src/layout/root_layout.rs +++ b/packages/oneclient_app/src/layout/root_layout.rs @@ -2,9 +2,9 @@ use freya::prelude::*; use freya::router::*; use crate::components::{ - AccountSwitcher, ClusterUpdatePopup, GenericPromptOverlay, JavaPromptOverlay, OptionalModsPopup, - NotificationCenter, PackageUpdatePopup, SplashCurtain, StatusBar, Toasts, - UpdatePromptOverlay, + AccountSwitcher, ClusterUpdatePopup, GenericPromptOverlay, JavaPromptOverlay, OptionalModsPopup, MicrosoftJavaPromptOverlay, + NotificationCenter, PackageUpdatePopup, SplashCurtain, StatusBar, Toasts, TooltipHost, + UpdatePromptOverlay, use_provide_tooltips, }; use crate::hooks::{SplashState, use_provide_overlay_claims, use_provide_splash}; #[cfg(not(target_os = "macos"))] @@ -24,15 +24,16 @@ impl Component for RootLayout { let home_ready = use_state(|| false); use_provide_splash(SplashState { active, home_ready }); use_provide_overlay_claims(); + use_provide_tooltips(); // macOS rounds the window natively so Freya must not round on top of it #[cfg(target_os = "macos")] - let corner = 0.; + let _corner = 0.; // Elsewhere the window is borderless and squared when maximized // No reactive maximized signal exists so root_size changes stand in as the trigger #[cfg(not(target_os = "macos"))] - let corner = { + let _corner = { let root_size = Platform::get().root_size; // Seeded because the query below only answers after the window is visible // which would round the corners of the first frames of a maximized launch @@ -63,7 +64,7 @@ impl Component for RootLayout { .background(colors::page()) .color(colors::fg_primary()) .font_family(theme::DEFAULT_FONT) - .corner_radius(CornerRadius::new_all(corner)) + //.corner_radius(CornerRadius::new_all(corner)) .overflow(Overflow::Clip) .child( rect() @@ -76,12 +77,14 @@ impl Component for RootLayout { .child(Toasts) .child(UpdatePromptOverlay) .child(JavaPromptOverlay) + .child(MicrosoftJavaPromptOverlay) // Must stay last it renders whatever the overlays above did not claim .child(GenericPromptOverlay) .child(ClusterUpdatePopup) .child(OptionalModsPopup) .child(PackageUpdatePopup) .child(StatusBar) + .child(TooltipHost) .child(SplashCurtain) .child(AnimationClockDriver) .child(HomeArtPrefetch) diff --git a/packages/oneclient_app/src/layout/settings_shell.rs b/packages/oneclient_app/src/layout/settings_shell.rs index c1d44c5a..31b2ed33 100644 --- a/packages/oneclient_app/src/layout/settings_shell.rs +++ b/packages/oneclient_app/src/layout/settings_shell.rs @@ -20,6 +20,7 @@ use crate::use_dispatch; const SIDEBAR_WIDTH_PX: f32 = 225.; const ITEM_HEIGHT_PX: f32 = 33.; const SEARCH_WIDTH_PX: f32 = 256.; +const SEARCH_MIN_WIDTH_PX: f32 = 120.; const SEARCH_RESULTS_MAX: usize = 12; thread_local! { @@ -386,6 +387,7 @@ impl Component for SettingsShell { let changelog_unread = has_unread_changelog(); let search = use_state(String::new); + let header_width = use_state(|| 0f32); let query = SearchQuery::new(&search.read()); let scroll = use_scroll_controller(ScrollConfig::default); SETTINGS_SCROLL_CONTROLLER.with(|cell| *cell.borrow_mut() = Some(scroll)); @@ -433,12 +435,17 @@ impl Component for SettingsShell { .height(Size::fill()) .overflow(Overflow::Clip) .spacing(24.) - .child(content_header(active.label().to_string(), search)) + .child(content_header( + active.label().to_string(), + search, + header_width, + )) .child( ScrollArea::new() .width(Size::fill()) .height(Size::flex(1.0)) .show_scrollbar(true) + .scrollbar_gutter(true) .spacing(4.) .scroll_controller(scroll) .on_ctx(|ctx| { @@ -450,27 +457,49 @@ impl Component for SettingsShell { } } -fn content_header(title: String, search: State) -> impl IntoElement { +fn content_header( + title: String, + search: State, + mut width: State, +) -> impl IntoElement { + let search_width = search_width_for(*width.read()); + rect() .horizontal() .width(Size::fill()) + .content(Content::Flex) .cross_align(Alignment::Center) - .main_align(Alignment::SpaceBetween) + .spacing(12.) + .on_sized(move |event: Event| { + let next = event.data().area.width(); + if (*width.peek() - next).abs() > 0.5 { + width.set(next); + } + }) .child( label() .text(title) .font_size(30.) .font_weight(FontWeight::SEMI_BOLD) + .max_lines(1) + .width(Size::flex(1.0)) .color(colors::fg_primary()), ) - .child(search_box(search)) + .child(search_box(search, search_width)) } -fn search_box(mut search: State) -> impl IntoElement { +fn search_width_for(header_width: f32) -> f32 { + if header_width <= 0. { + return SEARCH_WIDTH_PX; + } + (header_width * 0.45).clamp(SEARCH_MIN_WIDTH_PX, SEARCH_WIDTH_PX) +} + +fn search_box(mut search: State, width: f32) -> impl IntoElement { let has_query = !search.read().trim().is_empty(); let base = TextInput::new(search) .placeholder("Search settings...") - .width(Size::px(SEARCH_WIDTH_PX)) + .width(Size::px(width)) .leading( Icon::new(IconType::SearchMd) .size(16.) @@ -631,7 +660,7 @@ fn sidebar(active: SettingsTab, changelog_unread: bool) -> impl IntoElement { .padding(Gaps::new(16., 0., 0., 0.)) .content(Content::Flex) .child( - rect() + ScrollArea::new() .width(Size::fill()) .height(Size::flex(1.0)) .spacing(16.) @@ -741,6 +770,9 @@ impl Component for SidebarInfo { .width(Size::fill()) .spacing(4.) .font_size(12.) + .padding(Gaps::new( + 15.,0.,0.,0. + )) .color(colors::fg_secondary()) .children( items diff --git a/packages/oneclient_app/src/lib.rs b/packages/oneclient_app/src/lib.rs index 67413770..7db65b8e 100644 --- a/packages/oneclient_app/src/lib.rs +++ b/packages/oneclient_app/src/lib.rs @@ -15,6 +15,7 @@ mod launcher; pub mod state; mod transfer; mod layout; +pub mod microsoft_java; mod motion; mod notifications; pub mod platform; diff --git a/packages/oneclient_app/src/main.rs b/packages/oneclient_app/src/main.rs index ceb56952..4fcdc615 100644 --- a/packages/oneclient_app/src/main.rs +++ b/packages/oneclient_app/src/main.rs @@ -11,7 +11,7 @@ use oneclient_app::state::{AppChannel, AppState, LauncherInit}; use oneclient_app::{ Actions, ConfirmLinkOverlay, EventPump, LinkConfirmState, StartMaximizedState, cli, constants, events, platform, router, theme, use_provide_actions, use_provide_link_confirm, - use_provide_start_maximized, + use_provide_start_maximized, microsoft_java, }; use std::cell::Cell; use tokio::runtime::Builder; @@ -60,7 +60,10 @@ impl App for OneClientApp { match events::start_launcher(station, events_bus).await { // Must follow startup `sync_bundles` needs the launcher handle and firing // it early leaves `syncing_bundles` stuck disabling every launch button - Ok(()) => startup.sync_bundles(), + Ok(()) => { + startup.sync_bundles(); + microsoft_java::spawn_auto_install(); + } Err(err) => { events::report_startup_failure(&station, &err); oneclient_app::updater::spawn_update_check(false, rescue_bus); @@ -182,7 +185,6 @@ fn main() { let _sentry_guard = oneclient_core::reporting::init(settings.crash_reporting); - #[cfg(target_os = "macos")] oneclient_app::platform::macos::loop_memory_collector(); @@ -201,7 +203,7 @@ fn main() { ))) .with_size(1200., 800.) .with_min_size(800., 600.) - .with_transparency(true) + .with_transparency(false) .with_background(Color::TRANSPARENT) // A half-copied data folder is unrecoverable, so the window refuses to // close while one is being moved; the move screen says as much @@ -239,7 +241,7 @@ fn main() { std::env::var("ONECLIENT_GPU_CACHE_BYTES") .ok() .and_then(|v| v.parse::().ok()) - .unwrap_or(512 * 1024 * 1024), + .unwrap_or(96 * 1024 * 1024), ) .with_default_font(theme::DEFAULT_FONT); diff --git a/packages/oneclient_app/src/microsoft_java.rs b/packages/oneclient_app/src/microsoft_java.rs new file mode 100644 index 00000000..18451889 --- /dev/null +++ b/packages/oneclient_app/src/microsoft_java.rs @@ -0,0 +1,358 @@ +use std::collections::BTreeSet; +use std::sync::{Arc, Mutex}; + +use freya::prelude::spawn_forever; +use tokio::sync::mpsc::UnboundedSender; +use oneclient_common::Patch; +use oneclient_core::settings::store::save_settings_and_apply; +use oneclient_core::{LauncherState, ProfileUpdate}; +use oneclient_db::models::ClusterId; +use oneclient_events::{Choice, Prompt, Signal}; +use oneclient_java::{JavaRuntime, JavaVendor}; + +use crate::hooks::Actions; +use crate::launcher; + +/// Front-ends match on these +pub const MICROSOFT_JAVA_CHOICE_INSTALL: &str = "java.microsoft.install"; +pub const MICROSOFT_JAVA_CHOICE_NEVER: &str = "java.microsoft.never"; + +/// Cancel works per session, "Don't ask again" is set in settings +static ASKED: Mutex> = Mutex::new(BTreeSet::new()); + +enum MicrosoftJavaAnswer { + Install, + Never, +} + +pub fn spawn_auto_install() { + tokio::spawn(auto_install(ui_refresh_channel())); +} + +fn ui_refresh_channel() -> UnboundedSender<()> { + let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<()>(); + spawn_forever(async move { + while rx.recv().await.is_some() { + crate::hooks::invalidate_profile_queries().await; + } + }); + tx +} + +async fn auto_install(refresh: UnboundedSender<()>) { + let Ok(state) = launcher::state() else { + return; + }; + + if opted_out(&state) || already_migrated(&state) { + return; + } + + let Some(cluster_id) = automatic_cluster(&state).await else { + return; + }; + + let major = match oneclient_core::required_java_major(&state, cluster_id).await { + Ok(Some(major)) => major, + Ok(None) => { + tracing::info!(cluster_id, "the cluster's manifest names no Java version"); + return; + } + Err(err) => { + tracing::warn!(cluster_id, "could not read the cluster's Java version: {err:#}"); + return; + } + }; + + match state + .java + .has_vendor_runtime(&JavaVendor::Microsoft, Some(major)) + .await + { + Ok(true) => return, + Ok(false) => {} + Err(err) => { + tracing::warn!("could not check for a Microsoft Java runtime: {err:#}"); + return; + } + } + + if !publishes(&state, major).await { + return; + } + + tracing::info!( + cluster_id, + major, + "fetching a Microsoft runtime for the cluster on Automatic" + ); + install_and_unpin(cluster_id, major, refresh); +} + +pub async fn offer_for_pinned_cluster(actions: &Actions, cluster_id: ClusterId) { + let Ok(state) = launcher::state() else { + return; + }; + + if opted_out(&state) { + return; + } + + let Some(pinned) = pinned_runtime(&state, cluster_id).await else { + return; + }; + + if pinned.vendor == JavaVendor::Microsoft { + return; + } + + let major = match oneclient_core::required_java_major(&state, cluster_id).await { + Ok(Some(major)) => major, + Ok(None) => { + tracing::info!(cluster_id, "the cluster's manifest names no Java version"); + return; + } + Err(err) => { + tracing::warn!(cluster_id, "could not read the cluster's Java version: {err:#}"); + return; + } + }; + + let installed = match state + .java + .has_vendor_runtime(&JavaVendor::Microsoft, Some(major)) + .await + { + Ok(installed) => installed, + Err(err) => { + tracing::warn!("could not check for a Microsoft Java runtime: {err:#}"); + false + } + }; + + if !installed && !publishes(&state, major).await { + return; + } + + if !claim_ask(cluster_id) { + return; + } + + match actions.events().ask(offer_prompt(installed)).await { + Ok(Some(chosen)) => match chosen.value { + MicrosoftJavaAnswer::Install => { + if installed { + unpin_to_automatic(&state, cluster_id, major, true, ui_refresh_channel()).await; + } else { + install_and_unpin(cluster_id, major, ui_refresh_channel()); + } + } + MicrosoftJavaAnswer::Never => actions.skip_microsoft_java(), + }, + Ok(None) => tracing::info!(cluster_id, "Microsoft Java offer dismissed"), + Err(err) => tracing::warn!(cluster_id, "Microsoft Java offer failed: {err:#}"), + } +} + +fn offer_prompt(installed: bool) -> Prompt { + let closing = if installed { + "You already have it installed, so this cluster will switch over immediately." + } else { + "Minecraft will launch with Microsoft OpenJDK next time." + }; + + Prompt::new( + "Microsoft Java runtime", + format!( + "Based on our research, we now recommend Microsoft's OpenJDK \ + as they have specific optimizations for Minecraft. \ + Would you like to use Microsoft OpenJDK as your default Java installation? \ + {closing}" + ), + ) + .option( + Choice::new(MICROSOFT_JAVA_CHOICE_NEVER, "Don't ask again"), + MicrosoftJavaAnswer::Never, + ) + .option( + Choice::primary(MICROSOFT_JAVA_CHOICE_INSTALL, "Proceed"), + MicrosoftJavaAnswer::Install, + ) + .dismiss("Cancel") +} + +fn install_and_unpin(cluster_id: ClusterId, major: u32, refresh: UnboundedSender<()>) { + tokio::spawn(async move { + let Ok(state) = launcher::state() else { return }; + let events = state.services.events.clone(); + + let runtime = match state + .java + .install_runtime_from(&JavaVendor::Microsoft, major) + .await + { + Ok(runtime) => runtime, + Err(err) => { + events + .notify("Java install failed") + .body(err.to_string()) + .error() + .send(); + return; + } + }; + + events.signal(Signal::JavaChanged); + tracing::info!(cluster_id, version = %runtime.version, "installed a Microsoft runtime"); + + unpin_to_automatic(&state, cluster_id, major, false, refresh).await; + }); +} + +async fn unpin_to_automatic( + state: &Arc, + cluster_id: ClusterId, + major: u32, + immediate: bool, + refresh: UnboundedSender<()>, +) { + let events = state.services.events.clone(); + + mark_migrated(state).await; + + // Cleared rather than pointed at the new runtime Automatic ranks the + // default vendor first so it lands on this one anyway and the cluster + // keeps following later Microsoft installs instead of freezing on one + let update = ProfileUpdate { + java_path: Patch::Clear, + ..Default::default() + }; + + match state.clusters.update_profile(cluster_id, update).await { + Ok(_) => { + let _ = refresh.send(()); + let body = if immediate { + format!( + "This cluster is back on Automatic and will run on the optimized Microsoft \ + {major} JDK from now on." + ) + } else { + format!( + "This cluster is back on Automatic and will pick the optimized Microsoft \ + {major} JDK from its next launch." + ) + }; + events.notify("Java switched").body(body).send(); + } + // Only when installation was successfull and the cluster is not pointing to the new version + Err(err) => events + .notify("Cluster not switched") + .body(format!( + "Optimized Microsoft {major} JDK is ready but the cluster still points at its \ + old runtime: {err}. Please report to discord.gg/polyfrost" + )) + .error() + .send(), + } +} + +fn already_migrated(state: &Arc) -> bool { + state.settings.read().microsoft_java_migrated +} + +async fn mark_migrated(state: &Arc) { + let snapshot = { + let mut settings = state.settings.write(); + if settings.microsoft_java_migrated { + return; + } + settings.microsoft_java_migrated = true; + settings.clone() + }; + + if let Err(err) = save_settings_and_apply(&state.services, &snapshot).await { + tracing::warn!("could not record the Microsoft Java migration: {err:#}"); + } +} + +fn opted_out(state: &Arc) -> bool { + let settings = state.settings.read(); + settings.skip_microsoft_java || !settings.seen_onboarding +} + +fn claim_ask(cluster_id: ClusterId) -> bool { + ASKED + .lock() + .map(|mut asked| asked.insert(cluster_id)) + .unwrap_or(false) +} + +/// If there is not Microsoft build for given host, the offer is abandoned +async fn publishes(state: &Arc, major: u32) -> bool { + match state + .java + .latest_package(&JavaVendor::Microsoft, major) + .await + { + Ok(Some(_)) => true, + Ok(None) => { + tracing::info!(major, "Microsoft publishes no build for this host"); + false + } + Err(err) => { + tracing::warn!("could not reach Microsoft's downloads: {err:#}"); + false + } + } +} + +/// Only the cluster the launcher opens on the one the home panel preselects last played first and newest version when nothing has been played yet +async fn automatic_cluster(state: &Arc) -> Option { + let clusters = match state.clusters.list().await { + Ok(clusters) => clusters, + Err(err) => { + tracing::warn!("could not read the cluster list: {err:#}"); + return None; + } + }; + + let cluster = crate::utils::sort_clusters_for_home(clusters) + .into_iter() + .next()?; + + let global = state.settings.read().global_game_settings.clone(); + + match state.clusters.resolve_settings(&global, &cluster).await { + Ok(profile) => { + let automatic = profile.java_path.is_none(); + tracing::info!( + cluster_id = cluster.id, + name = %cluster.name, + automatic, + "checked the Java of the cluster the launcher opens on" + ); + automatic.then_some(cluster.id) + } + Err(err) => { + tracing::warn!( + cluster_id = cluster.id, + "could not resolve cluster settings: {err:#}" + ); + None + } + } +} + +/// `None` when the cluster is on Automatic or when its pin points at a runtime that is no longer on disk +async fn pinned_runtime(state: &Arc, cluster_id: ClusterId) -> Option { + let cluster = state.clusters.get(cluster_id).await.ok()?; + let global = state.settings.read().global_game_settings.clone(); + let profile = state + .clusters + .resolve_settings(&global, &cluster) + .await + .ok()?; + + let path = profile.java_path?; + state.java.runtime_for_profile(Some(&path)).await.ok()? +} diff --git a/packages/oneclient_app/src/notifications.rs b/packages/oneclient_app/src/notifications.rs index 5bc1d3fe..bcd34267 100644 --- a/packages/oneclient_app/src/notifications.rs +++ b/packages/oneclient_app/src/notifications.rs @@ -225,7 +225,7 @@ pub struct NotificationState { /// Resumes the launch that opened the update modal /// Held here not by the launch task /// because every way the modal can end goes through this state - package_updates_done: Option>, + package_updates_done: Option>>, optional_mods_done: Option>, } @@ -388,7 +388,7 @@ impl NotificationState { pub fn open_package_updates( &mut self, groups: Vec, - done: Option>, + done: Option>>, ) { let groups: Vec = groups .into_iter() @@ -397,19 +397,25 @@ impl NotificationState { if groups.is_empty() { if let Some(done) = done { - let _ = done.send(()); + let _ = done.send(Vec::new()); } return; } // A second modal would strand the first launch so release the old continuation first - self.finish_package_updates(); + self.finish_package_updates(Vec::new()); self.package_updates = Some(groups); self.package_updates_done = done; } pub fn close_package_updates(&mut self) { - self.finish_package_updates(); + self.finish_package_updates(Vec::new()); + } + + /// The launch applies these itself so the game starts on the versions the + /// player just picked rather than racing them + pub fn proceed_package_updates(&mut self, chosen: Vec) { + self.finish_package_updates(chosen); } /// Applying and skipping are both answers either drops the row and can close the modal @@ -426,16 +432,16 @@ impl NotificationState { groups.retain(|group| !group.packages.is_empty()); if groups.is_empty() { - self.finish_package_updates(); + self.finish_package_updates(Vec::new()); } } /// Safe to call when nothing is open so every exit path can call it unconditionally - fn finish_package_updates(&mut self) { + fn finish_package_updates(&mut self, chosen: Vec) { self.package_updates = None; if let Some(done) = self.package_updates_done.take() { // A send failure means the launch task is gone nothing left to resume - let _ = done.send(()); + let _ = done.send(chosen); } } @@ -465,9 +471,11 @@ impl NotificationState { ); self.push_ephemeral_toast(entry_id, MESSAGE_TOAST_TTL); } - // The sign-in modal renders this progress itself it must not also become a toast + Event::Progress(ProgressEvent::Update { id, .. }) if id == oneclient_auth::MICROSOFT_LOGIN_PROGRESS => {} + Event::Progress(ProgressEvent::Update { id, .. }) + if id == oneclient_core::storage::STORAGE_SCAN_PROGRESS => {} Event::Progress(ProgressEvent::Update { id, label, diff --git a/packages/oneclient_app/src/state.rs b/packages/oneclient_app/src/state.rs index 82c3654e..51eeed8b 100644 --- a/packages/oneclient_app/src/state.rs +++ b/packages/oneclient_app/src/state.rs @@ -22,6 +22,7 @@ pub enum AppChannel { AccountSwitcher, MicrosoftLogin, Installs, + StorageScan, Relocation, PendingLaunch, } @@ -43,6 +44,7 @@ pub struct AppState { pub account_switcher_open: bool, pub microsoft_login: Option, pub installs: InstallState, + pub storage_scan: Option, pub relocation: RelocationState, pub pending_launch: Option, } @@ -121,6 +123,13 @@ pub struct LoginProgress { pub total: u64, } +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct StorageScanProgress { + pub label: String, + pub current: u64, + pub total: u64, +} + #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum LaunchBlock { Starting(i64), diff --git a/packages/oneclient_app/src/ui.rs b/packages/oneclient_app/src/ui.rs index 4b3a9a6b..84eb9ba5 100644 --- a/packages/oneclient_app/src/ui.rs +++ b/packages/oneclient_app/src/ui.rs @@ -92,6 +92,29 @@ pub fn relative_time(created_at: Instant) -> String { } } +/// Gap the clamped menu keeps from the window edges +pub const EDGE_MARGIN: f32 = 8.; + +/// `root_size` is physical while the press position is logical, so it has to be +/// scaled down before the two are compared +pub fn clamp_to_window(x: f32, y: f32, width: f32, height: f32) -> (f32, f32) { + let platform = Platform::get(); + let scale = *platform.scale_factor.peek() as f32; + if scale <= 0. { + return (x, y); + } + + let window = *platform.root_size.peek(); + let clamp = |pos: f32, len: f32, limit: f32| { + pos.clamp(EDGE_MARGIN, (limit - len - EDGE_MARGIN).max(EDGE_MARGIN)) + }; + + ( + clamp(x, width, window.width / scale), + clamp(y, height, window.height / scale), + ) +} + /// Returns the `Rect` not an `Element` so callers can inset or round it pub fn divider() -> Rect { rect() @@ -114,6 +137,23 @@ pub fn centered_note(text: &str) -> Element { .into_element() } +pub fn grid_columns_for_width(width: f32, max_col: f32, gap: f32) -> usize { + if width <= 0. { + return 1; + } + + (((width + gap) / (max_col + gap)).ceil() as usize).max(1) +} + +pub fn columns_for(width: f32, min_cell: f32, max: usize, gap: f32) -> usize { + let max = max.max(1); + if width <= 0. { + return max; + } + + (((width + gap) / (min_cell + gap)).floor() as usize).clamp(1, max) +} + /// Short final rows are padded with empty flex cells so tiles keep the column width pub fn flow_grid(items: Vec, cols: usize, mut width: State, gap: f32) -> Element { let cols = cols.max(1); @@ -172,3 +212,25 @@ pub fn entrance_motion_layer( ) .into_element() } + +pub trait ImageFallbackExt: Sized { + fn fallback(self, placeholder: impl IntoElement) -> Self; +} + +impl ImageFallbackExt for ImageViewer { + fn fallback(self, placeholder: impl IntoElement) -> Self { + let placeholder = placeholder.into_element(); + self.error_renderer(move |_: String| placeholder.clone()) + } +} + +pub fn window_logical_size() -> Size2D { + let platform = Platform::get(); + let scale = *platform.scale_factor.peek() as f32; + let size = *platform.root_size.peek(); + if scale <= 0. { + return size; + } + + Size2D::new(size.width / scale, size.height / scale) +} diff --git a/packages/oneclient_app/src/utils.rs b/packages/oneclient_app/src/utils.rs index ada19be8..15ac17cb 100644 --- a/packages/oneclient_app/src/utils.rs +++ b/packages/oneclient_app/src/utils.rs @@ -245,22 +245,28 @@ pub fn format_memory_gb(mb: u32) -> String { } } -/// `7384` -> `2h 3m` `540` -> `9m` `0` -> `0m` -pub fn format_duration_hm(secs: i64) -> String { +/// `45` -> `45s` `540` -> `9m` `7200` -> `2h` `7384` -> `2h 3m` `180000` -> `2d 2h` +pub fn format_duration(secs: i64) -> String { if secs <= 0 { return "0m".to_string(); } - let hours = secs / 3600; + + let days = secs / 86_400; + let hours = (secs % 86_400) / 3600; let minutes = (secs % 3600) / 60; - if hours > 0 { - format!("{hours}h {minutes}m") - } else { - format!("{minutes}m") + + match (days, hours, minutes) { + (0, 0, 0) => format!("{secs}s"), + (0, 0, m) => format!("{m}m"), + (0, h, 0) => format!("{h}h"), + (0, h, m) => format!("{h}h {m}m"), + (d, 0, _) => format!("{d}d"), + (d, h, _) => format!("{d}d {h}h"), } } /// `3723` -> `1h 2m` `83` -> `1m 23s` `45` -> `45s` -pub fn format_duration_hms(secs: i64) -> String { +pub fn format_durations(secs: i64) -> String { if secs <= 0 { return "0s".to_string(); } @@ -466,6 +472,17 @@ mod tests { assert_eq!(format_memory_gb(1536), "1.5 GB"); } + #[test] + fn durations_never_show_a_zero_unit() { + assert_eq!(format_duration(45), "45s"); + assert_eq!(format_duration(540), "9m"); + assert_eq!(format_duration(7200), "2h"); + assert_eq!(format_duration(7384), "2h 3m"); + assert_eq!(format_duration(180_000), "2d 2h"); + assert_eq!(format_duration(172_800), "2d"); + assert_eq!(format_duration(0), "0m"); + } + #[test] fn a_bare_legacy_version_keeps_its_line_name() { let clusters = [versioned(1, "1.21")]; diff --git a/packages/oneclient_app/src/view/app/analytics/charts.rs b/packages/oneclient_app/src/view/app/analytics/charts.rs index 7eb925a3..8acaae64 100644 --- a/packages/oneclient_app/src/view/app/analytics/charts.rs +++ b/packages/oneclient_app/src/view/app/analytics/charts.rs @@ -1,83 +1,131 @@ -use std::collections::HashMap; +use std::collections::{BTreeMap, HashMap}; -use chrono::{Local, NaiveDate}; +use chrono::{Datelike, Local, Months, NaiveDate}; use freya::prelude::*; use oneclient_core::game::{DayPlaytime, PlaytimeStats, WEEKDAY_LABELS}; -use crate::components::{BarChart, IconType, Segment, SegmentedControl, ValueUnit}; -use crate::utils::{format_day, format_duration_hm, format_hour, parse_day}; +use crate::components::{ + BarChart, DateRange, DateRangePicker, IconType, Segment, SegmentedControl, ValueUnit, +}; +use crate::utils::{format_day, format_duration, format_hour, parse_day}; use super::{chart_card, nav_button}; #[derive(Clone, Copy, PartialEq)] -enum WhenMode { - Weekday, +enum PlaytimeMode { Hour, + Day, + Month, } #[derive(PartialEq)] -pub(super) struct WhenChart { +pub(super) struct PlaytimeChart { per_weekday: Vec, per_hour: Vec, peak_weekday: Option, peak_hour: Option, + daily: Vec, } -impl WhenChart { +impl PlaytimeChart { pub(super) fn from_stats(stats: &PlaytimeStats) -> Self { Self { per_weekday: stats.per_weekday.to_vec(), per_hour: stats.per_hour.to_vec(), peak_weekday: stats.peak_weekday, peak_hour: stats.peak_hour, + daily: stats.daily.clone(), } } } -impl Component for WhenChart { +impl Component for PlaytimeChart { fn render(&self) -> impl IntoElement { - let mode = use_state(|| WhenMode::Weekday); + let mode = use_state(|| PlaytimeMode::Day); let m = *mode.read(); let control = SegmentedControl::new(mode) .height(30.) - .segment(Segment::new(WhenMode::Weekday).label("Day")) - .segment(Segment::new(WhenMode::Hour).label("Hour")) + .segment(Segment::new(PlaytimeMode::Hour).label("Hour")) + .segment(Segment::new(PlaytimeMode::Day).label("Day")) + .segment(Segment::new(PlaytimeMode::Month).label("Month")) .into_element(); - let (subtitle, chart) = match m { - WhenMode::Weekday => { - let day = self - .peak_weekday - .map(|i| WEEKDAY_LABELS[i].to_string()) - .unwrap_or_else(|| "—".to_string()); - ( - format!("Most active on {day}"), - BarChart::new(self.per_weekday.clone(), weekday_labels()) - .highlight(self.peak_weekday) - .unit(ValueUnit::Duration) - .gap(6.) - .into_element(), - ) - } - WhenMode::Hour => { + let (title, subtitle, chart) = match m { + PlaytimeMode::Hour => { let hour = self .peak_hour .map(format_hour) .unwrap_or_else(|| "—".to_string()); ( - format!("Peak around {hour}"), + "Playtime by hour", + format!("Every day's {hour} hour, added up"), BarChart::new(self.per_hour.clone(), (0..24).map(format_hour).collect()) + .readout_labels( + (0..24) + .map(|h| format!("All {} hours", format_hour(h))) + .collect(), + ) .highlight(self.peak_hour) .unit(ValueUnit::Duration) .gap(3.) .into_element(), ) } + PlaytimeMode::Day => { + let day = self + .peak_weekday + .map(|i| WEEKDAY_FULL[i].to_string()) + .unwrap_or_else(|| "—".to_string()); + ( + "Playtime by day", + format!("Every {day} you have played, added up"), + BarChart::new(self.per_weekday.clone(), weekday_labels()) + .readout_labels( + WEEKDAY_FULL.iter().map(|d| format!("All {d}s")).collect(), + ) + .highlight(self.peak_weekday) + .unit(ValueUnit::Duration) + .gap(6.) + .into_element(), + ) + } + PlaytimeMode::Month => { + let months = monthly_series(&self.daily); + let values: Vec = months.iter().map(|(_, s)| *s).collect(); + let labels: Vec = months + .iter() + .map(|(m, _)| MONTH_ABBR[m.month0() as usize].to_string()) + .collect(); + let readout: Vec = months + .iter() + .map(|(m, _)| format!("{} {}", MONTH_ABBR[m.month0() as usize], m.year())) + .collect(); + let best = values + .iter() + .enumerate() + .max_by_key(|(_, v)| **v) + .filter(|(_, v)| **v > 0) + .map(|(i, _)| i); + let subtitle = match best { + Some(i) => format!("Best month so far: {}", readout[i]), + None => "No sessions recorded yet".to_string(), + }; + ( + "Playtime by month", + subtitle, + BarChart::new(values, labels) + .readout_labels(readout) + .highlight(best) + .unit(ValueUnit::Duration) + .gap(4.) + .into_element(), + ) + } }; - chart_card("When you play", subtitle, Some(control), chart) + chart_card(title, subtitle, Some(control), chart) } } @@ -113,25 +161,36 @@ impl Component for DailyChart { fn render(&self) -> impl IntoElement { let range = use_state(|| Range::TwoWeeks); let mut offset = use_state(|| 0usize); + let custom = use_state(|| Option::::None); let series = continuous_series(&self.daily); - if series.is_empty() { + let (Some((first_day, _)), Some((last_day, _))) = (series.first(), series.last()) else { return chart_card( "Daily playtime", "No sessions recorded yet".to_string(), None, rect().height(Size::px(120.)).into_element(), ); - } + }; + let (first_day, last_day) = (*first_day, *last_day); - let window = range.read().days(); let total = series.len(); - let max_offset = total.saturating_sub(1) / window; - let off = (*offset.read()).min(max_offset); + let picked = *custom.read(); - let end = total.saturating_sub(off * window); - let start = end.saturating_sub(window); - let slice = &series[start..end]; + let (start, end) = match picked { + Some((from, to)) => ( + day_index(first_day, from).min(total), + (day_index(first_day, to) + 1).min(total), + ), + None => { + let window = range.read().days(); + let max_offset = total.saturating_sub(1) / window; + let off = (*offset.read()).min(max_offset); + let end = total.saturating_sub(off * window); + (end.saturating_sub(window), end) + } + }; + let slice = &series[start.min(end)..end]; let values: Vec = slice.iter().map(|(_, s)| *s).collect(); let labels: Vec = slice.iter().map(|(d, _)| format_day(*d)).collect(); @@ -141,16 +200,21 @@ impl Component for DailyChart { (Some((a, _)), Some((b, _))) => { format!( "{} · {} – {}", - format_duration_hm(win_total), + format_duration(win_total), format_day(*a), format_day(*b) ) } - _ => format_duration_hm(win_total), + _ => format_duration(win_total), }; - let can_older = off < max_offset; - let can_newer = off > 0; + let paging = picked.is_none(); + let window = range.read().days(); + let max_offset = total.saturating_sub(1) / window; + let off = (*offset.read()).min(max_offset); + let can_older = paging && off < max_offset; + let can_newer = paging && off > 0; + let nav = rect() .horizontal() .cross_align(Alignment::Center) @@ -168,25 +232,31 @@ impl Component for DailyChart { .child( SegmentedControl::new(range) .height(30.) + .disabled(!paging) .segment(Segment::new(Range::TwoWeeks).label("2W")) .segment(Segment::new(Range::Month).label("1M")) .segment(Segment::new(Range::Quarter).label("3M")) .into_element(), ) + .child(DateRangePicker::new(custom, (first_day, last_day)).height(30.)) .into_element(); - chart_card( - "Daily playtime", - subtitle, - Some(nav), - BarChart::new(values, labels) - .unit(ValueUnit::Duration) - .gap(3.) - .into_element(), - ) + chart_card( + "Daily playtime", + subtitle, + Some(nav), + BarChart::new(values, labels) + .unit(ValueUnit::Duration) + .gap(3.) + .into_element(), + ) } } +fn day_index(first: NaiveDate, day: NaiveDate) -> usize { + (day - first).num_days().max(0) as usize +} + fn continuous_series(daily: &[DayPlaytime]) -> Vec<(NaiveDate, i64)> { let map: HashMap = daily .iter() @@ -211,6 +281,41 @@ fn continuous_series(daily: &[DayPlaytime]) -> Vec<(NaiveDate, i64)> { out } +fn monthly_series(daily: &[DayPlaytime]) -> Vec<(NaiveDate, i64)> { + let mut totals: BTreeMap = BTreeMap::new(); + for day in daily { + let Some(date) = parse_day(&day.date) else { + continue; + }; + let Some(month) = date.with_day(1) else { + continue; + }; + *totals.entry(month).or_insert(0) += day.secs; + } + + let (Some(first), Some(last)) = ( + totals.keys().min().copied(), + totals.keys().max().copied(), + ) else { + return Vec::new(); + }; + + let mut out = Vec::new(); + let mut cur = first; + while cur <= last { + out.push((cur, totals.get(&cur).copied().unwrap_or(0))); + cur = match cur.checked_add_months(Months::new(1)) { + Some(next) => next, + None => break, + }; + } + out +} + +const MONTH_ABBR: [&str; 12] = [ + "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec", +]; + pub(super) fn distribution_card(session_secs: &[i64], force: bool) -> Option { if session_secs.len() < 2 && !force { return None; @@ -253,6 +358,16 @@ pub(super) fn distribution_card(session_secs: &[i64], force: bool) -> Option Vec { WEEKDAY_LABELS.iter().map(|s| (*s).to_string()).collect() } diff --git a/packages/oneclient_app/src/view/app/analytics/mod.rs b/packages/oneclient_app/src/view/app/analytics/mod.rs index 52ee6097..3ffb5255 100644 --- a/packages/oneclient_app/src/view/app/analytics/mod.rs +++ b/packages/oneclient_app/src/view/app/analytics/mod.rs @@ -5,13 +5,13 @@ use oneclient_core::game::{Analytics, DayPlaytime, PlaytimeStats}; use crate::components::{Icon, IconType}; use crate::theme::colors; -use crate::ui::border_all_color; +use crate::ui::{border_all_color, columns_for}; mod charts; mod servers; mod tiles; -use charts::{DailyChart, WhenChart, distribution_card}; +use charts::{DailyChart, PlaytimeChart, distribution_card}; use servers::servers_section; use tiles::{personas_row, tiles_row}; @@ -27,14 +27,13 @@ fn analytics_body_inner(analytics: &Analytics, force_all: bool) -> Element { root = root.child(tiles_row(analytics, force_all)); - if !stats.personas.is_empty() { - root = root.child(personas_row(&stats.personas)); + if !analytics.personas.is_empty() { + root = root.child(personas_row(&analytics.personas)); } - let mut charts: Vec = vec![ - WhenChart::from_stats(stats).into_element(), - DailyChart::new(stats.daily.clone()).into_element(), - ]; + root = root.child(DailyChart::new(stats.daily.clone())); + + let mut charts: Vec = vec![PlaytimeChart::from_stats(stats).into_element()]; if let Some(dist) = distribution_card(&stats.session_secs, force_all) { charts.push(dist); } @@ -127,40 +126,64 @@ fn empty_analytics() -> Analytics { per_hour: [0; 24], daily, session_secs: Vec::new(), + longest_session_secs: 0, active_days: 0, + current_streak: 0, + longest_streak: 0, avg_secs_per_active_day: 0.0, peak_hour: None, peak_weekday: None, night_share: 0.0, - personas: Vec::new(), }; Analytics { playtime, servers: Vec::new(), + personas: Vec::new(), } } +const CHART_GAP: f32 = 16.; +const CHART_MIN_W: f32 = 420.; + fn charts_grid(cards: Vec) -> Element { - let mut grid = rect().vertical().width(Size::fill()).spacing(24.); - for pair in cards.chunks(2) { - if pair.len() == 1 { - grid = grid.child(rect().width(Size::fill()).child(pair[0].clone())); - continue; + ChartsGrid { cards }.into_element() +} + +#[derive(PartialEq)] +struct ChartsGrid { + cards: Vec, +} + +impl Component for ChartsGrid { + fn render(&self) -> impl IntoElement { + let mut width = use_state(|| 0f32); + let cols = columns_for(*width.read(), CHART_MIN_W, 2, CHART_GAP); + + let mut grid = rect().vertical().width(Size::fill()).spacing(24.); + for chunk in self.cards.chunks(cols) { + let mut row = rect() + .horizontal() + .content(Content::Flex) + .width(Size::fill()) + .cross_align(Alignment::Start) + .spacing(CHART_GAP); + for card in chunk { + row = row.child(rect().width(Size::flex(1.0)).child(card.clone())); + } + for _ in chunk.len()..cols { + row = row.child(rect().width(Size::flex(1.0))); + } + grid = grid.child(row); } - let mut row = rect() - .horizontal() - .content(Content::Flex) - .width(Size::fill()) - .cross_align(Alignment::Start) - .spacing(16.); - for card in pair { - row = row.child(rect().width(Size::flex(1.0)).child(card.clone())); - } - grid = grid.child(row); + grid.on_sized(move |e: Event| { + let w = e.area.width(); + if (w - *width.peek()).abs() > 0.5 { + width.set(w); + } + }) } - grid.into_element() } pub(super) fn chart_card( diff --git a/packages/oneclient_app/src/view/app/analytics/servers.rs b/packages/oneclient_app/src/view/app/analytics/servers.rs index 263343dc..71f5ceb6 100644 --- a/packages/oneclient_app/src/view/app/analytics/servers.rs +++ b/packages/oneclient_app/src/view/app/analytics/servers.rs @@ -3,17 +3,39 @@ use freya::prelude::*; use oneclient_core::game::ServerStat; use crate::components::{ - Button, Icon, IconType, OverlayPopup, PieChart, ScrollArea, ValueUnit, slice_color, + Button, Icon, IconType, OverlayPopup, PieChart, ScrollArea, Segment, SegmentedControl, + ValueUnit, slice_color, }; use crate::hooks::{loaded_image, use_cached_image}; use crate::theme::colors; -use crate::ui::border_all_color; -use crate::utils::{format_duration_hm, plural}; +use crate::ui::{ImageFallbackExt, border_all_color}; +use crate::utils::{format_duration, plural}; use super::{card, card_header}; -/// Number of servers charted the rest are grouped into an "Other" slice -const TOP_SERVERS: usize = 10; +const MAX_SLICES: usize = 10; + +#[derive(Clone, Copy, PartialEq)] +enum ServerMetric { + Playtime, + Joins, +} + +impl ServerMetric { + fn of(self, s: &ServerStat) -> i64 { + match self { + ServerMetric::Playtime => s.total_secs, + ServerMetric::Joins => s.joins, + } + } + + fn unit(self) -> ValueUnit { + match self { + ServerMetric::Playtime => ValueUnit::Duration, + ServerMetric::Joins => ValueUnit::Count, + } + } +} pub(super) fn servers_section(servers: &[ServerStat]) -> Element { ServersSection { @@ -31,27 +53,46 @@ impl Component for ServersSection { fn render(&self) -> impl IntoElement { let hovered = use_state(|| Option::::None); let mut show_all = use_state(|| false); + let metric = use_state(|| ServerMetric::Playtime); + let m = *metric.read(); let total_joins: i64 = self.servers.iter().map(|s| s.joins).sum(); let mut sorted = self.servers.clone(); - sorted.sort_by_key(|s| std::cmp::Reverse(s.total_secs)); + sorted.sort_by_key(|s| std::cmp::Reverse(m.of(s))); - let (top, rest) = sorted.split_at(sorted.len().min(TOP_SERVERS)); - let mut values: Vec = top.iter().map(|s| s.total_secs).collect(); + let keep = if sorted.len() > MAX_SLICES { + MAX_SLICES - 1 + } else { + sorted.len() + }; + let (top, rest) = sorted.split_at(keep); + let mut values: Vec = top.iter().map(|s| m.of(s)).collect(); let mut labels: Vec = top.iter().map(server_label).collect(); if !rest.is_empty() { - values.push(rest.iter().map(|s| s.total_secs).sum()); - labels.push(format!("{} more", rest.len())); + values.push(rest.iter().map(|s| m.of(s)).sum()); + labels.push("Other".to_string()); } let details = (!self.servers.is_empty()).then(|| { - Button::new() - .ghost() - .small() - .on_press(move |_| show_all.set(true)) - .child(Icon::new(IconType::Eye).size(12.)) - .text("Details") + rect() + .horizontal() + .cross_align(Alignment::Center) + .spacing(8.) + .child( + SegmentedControl::new(metric) + .height(30.) + .segment(Segment::new(ServerMetric::Playtime).label("Playtime")) + .segment(Segment::new(ServerMetric::Joins).label("Joins")), + ) + .child( + Button::new() + .ghost() + .small() + .on_press(move |_| show_all.set(true)) + .child(Icon::new(IconType::Eye).size(12.)) + .text("Details"), + ) .into_element() }); @@ -78,13 +119,14 @@ impl Component for ServersSection { .spacing(20.) .child( PieChart::new(values.clone(), labels.clone()) - .unit(ValueUnit::Duration) + .unit(m.unit()) .hovered(hovered), ) .child(rect().width(Size::flex(1.0)).child(legend( &values, &labels, *hovered.read(), + m.unit(), ))), ) .maybe_child(show_all.read().then(|| { @@ -105,7 +147,7 @@ impl Component for ServersSection { } } -fn legend(values: &[i64], labels: &[String], active: Option) -> Element { +fn legend(values: &[i64], labels: &[String], active: Option, unit: ValueUnit) -> Element { let total: i64 = values.iter().sum(); let mut list = rect().vertical().width(Size::fill()).spacing(4.); @@ -155,7 +197,7 @@ fn legend(values: &[i64], labels: &[String], active: Option) -> Element { ) .child( label() - .text(format_duration_hm(*value)) + .text(unit.format(*value)) .font_size(11.) .font_weight(FontWeight::SEMI_BOLD) .color(colors::fg_primary()), @@ -223,7 +265,7 @@ impl Component for ServerDetailsPopup { .child( label() .text(format!( - "{} server{} by playtime", + "All {} server{} by playtime", servers.len(), plural(servers.len() as i64) )) @@ -332,7 +374,7 @@ impl Component for ServerRow { ) .child( label() - .text(format_duration_hm(s.total_secs)) + .text(format_duration(s.total_secs)) .font_size(12.) .font_weight(FontWeight::SEMI_BOLD) .color(colors::fg_primary()), @@ -371,6 +413,7 @@ impl Component for ServerIcon { .height(Size::px(size)) .aspect_ratio(AspectRatio::Min) .corner_radius(CornerRadius::new_all(6.)) + .fallback(server_icon_placeholder(self.is_ip, size)) .into_element(), None => server_icon_placeholder(self.is_ip, size), } diff --git a/packages/oneclient_app/src/view/app/analytics/tiles.rs b/packages/oneclient_app/src/view/app/analytics/tiles.rs index bdfa60a5..97541f48 100644 --- a/packages/oneclient_app/src/view/app/analytics/tiles.rs +++ b/packages/oneclient_app/src/view/app/analytics/tiles.rs @@ -4,134 +4,278 @@ use oneclient_core::game::{Analytics, Persona}; use crate::components::{Icon, IconType}; use crate::theme::colors; -use crate::ui::border_all_color; -use crate::utils::format_duration_hm; +use crate::ui::{border_all_color, columns_for, flow_grid}; +use crate::utils::{format_duration, plural}; use super::card; -pub(super) fn tiles_row(analytics: &Analytics, force_all: bool) -> Element { - let stats = &analytics.playtime; - let avg_session = if stats.session_count > 0 { - stats.total_secs / stats.session_count as i64 - } else { - 0 - }; - let total_joins: i64 = analytics.servers.iter().map(|s| s.joins).sum(); +pub(super) fn tiles_row(analytics: &Analytics, _force_all: bool) -> Element { + TilesRow { + analytics: analytics.clone(), + } + .into_element() +} - let mut tiles = vec![ - stat_tile( +const TILE_GAP: f32 = 14.; +const TILE_MIN_W: f32 = 190.; +const TILE_H: f32 = 104.; + +#[derive(PartialEq)] +struct TilesRow { + analytics: Analytics, +} + +impl Component for TilesRow { + fn render(&self) -> impl IntoElement { + let width = use_state(|| 0f32); + let cols = columns_for(*width.read(), TILE_MIN_W, 4, TILE_GAP); + + let stats = &self.analytics.playtime; + let avg_session = if stats.session_count > 0 { + stats.total_secs / stats.session_count as i64 + } else { + 0 + }; + + let hero = hero_tile( IconType::ClockRewind, "Total playtime", - format_duration_hm(stats.total_secs), - ), - stat_tile(IconType::Play, "Sessions", stats.session_count.to_string()), - stat_tile( - IconType::Calendar, - "Avg / session", - format_duration_hm(avg_session), - ), - stat_tile( - IconType::Rocket02, - "Active days", - stats.active_days.to_string(), - ), - ]; - if force_all || !analytics.servers.is_empty() { - tiles.push(stat_tile( - IconType::Globe01, - "Server joins", - format!("{total_joins}"), - )); - } + format_duration(stats.total_secs), + format!( + "over {} session{}", + stats.session_count, + plural(stats.session_count as i64) + ), + ); + let mut rest = vec![ + stat_tile(IconType::Play, "Sessions", stats.session_count.to_string()), + stat_tile( + IconType::Sliders04, + "Avg / session", + format_duration(avg_session), + ), + stat_tile( + IconType::Maximize01, + "Longest session", + format_duration(stats.longest_session_secs), + ), + stat_tile( + IconType::Rocket02, + "Day streak", + streak_value(stats.current_streak), + ), + stat_tile( + IconType::CheckCircle, + "Best streak", + streak_value(stats.longest_streak), + ), + stat_tile( + IconType::Calendar, + "Days played", + stats.active_days.to_string(), + ), + ]; + + let mut root = rect().vertical().width(Size::fill()).spacing(TILE_GAP); + + if cols >= 3 { + let alongside: Vec = rest.drain(..(cols - 2).min(rest.len())).collect(); + let mut lead = rect() + .horizontal() + .content(Content::Flex) + .width(Size::fill()) + .height(Size::px(TILE_H)) + .spacing(TILE_GAP) + .child( + rect() + .width(Size::flex(2.0)) + .height(Size::fill()) + .child(hero), + ); + for tile in alongside { + lead = lead.child( + rect() + .width(Size::flex(1.0)) + .height(Size::fill()) + .child(tile), + ); + } + root = root.child(lead); + } else { + root = root.child( + rect() + .width(Size::fill()) + .height(Size::px(TILE_H)) + .child(hero), + ); + } + + let rows: Vec = rest + .into_iter() + .map(|tile| { + rect() + .height(Size::px(TILE_H)) + .width(Size::flex(1.0)) + .child(tile) + .into_element() + }) + .collect(); + + root.child(flow_grid(rows, cols, width, TILE_GAP)) + } +} + +fn streak_value(days: usize) -> String { + format!("{days}d") +} + +fn icon_chip(icon: IconType, size: f32, tint: Color) -> Element { rect() - .horizontal() - .content(Content::Flex) + .width(Size::px(size)) + .height(Size::px(size)) + .corner_radius(CornerRadius::new_all(size * 0.5)) + .background(tint.with_a(38)) + .center() + .child(Icon::new(icon).size(size * 0.52).color(tint)) + .into_element() +} + +fn hero_tile(icon: IconType, caption: &str, value: String, note: String) -> Element { + card() .width(Size::fill()) - .spacing(16.) - .children(tiles) + .height(Size::fill()) + .padding(Gaps::new_symmetric(16., 20.)) + .spacing(10.) + .background(colors::brand().with_a(18)) + .border(border_all_color(1., colors::brand().with_a(90))) + .child( + rect() + .horizontal() + .cross_align(Alignment::Center) + .spacing(8.) + .child(icon_chip(icon, 24., colors::brand())) + .child( + label() + .text(caption.to_string()) + .font_size(12.) + .font_weight(FontWeight::MEDIUM) + .color(colors::fg_secondary()), + ), + ) + .child( + rect() + .horizontal() + .cross_align(Alignment::End) + .spacing(8.) + .child( + label() + .text(value) + .font_size(30.) + .font_weight(FontWeight::BOLD) + .max_lines(1) + .color(colors::fg_primary()), + ) + .child( + rect().margin(Gaps::new(0., 0., 5., 0.)).child( + label() + .text(note) + .font_size(12.) + .max_lines(1) + .color(colors::fg_secondary()), + ), + ), + ) .into_element() } fn stat_tile(icon: IconType, caption: &str, value: String) -> Element { card() - .width(Size::flex(1.0)) + .width(Size::fill()) + .height(Size::fill()) .spacing(10.) .child( rect() .horizontal() .cross_align(Alignment::Center) .spacing(8.) - .child(Icon::new(icon).size(16.).color(colors::fg_secondary())) + .child(icon_chip(icon, 24., colors::fg_secondary())) .child( label() .text(caption.to_string()) .font_size(12.) + .max_lines(1) + .width(Size::fill()) .color(colors::fg_secondary()), ), ) .child( label() .text(value) - .font_size(26.) + .font_size(24.) .font_weight(FontWeight::BOLD) + .max_lines(1) .color(colors::fg_primary()), ) .into_element() } pub(super) fn personas_row(personas: &[Persona]) -> Element { - let mut row = rect().horizontal().width(Size::fill()).spacing(16.); - for persona in personas { - row = row.child(persona_card(*persona)); - } - row.into_element() + rect() + .horizontal() + .content(Content::Wrap { + wrap_spacing: Some(10.), + }) + .width(Size::fill()) + .spacing(10.) + .children(personas.iter().map(|p| persona_badge(*p))) + .into_element() +} + +fn persona_badge(persona: Persona) -> Element { + rect() + .horizontal() + .cross_align(Alignment::Center) + .spacing(7.) + .padding(Gaps::new_symmetric(6., 11.)) + .corner_radius(CornerRadius::new_all(8.)) + .background(colors::component_bg()) + .border(border_all_color(1., colors::component_border())) + .child( + Icon::new(persona_icon(persona)) + .size(13.) + .color(colors::brand()), + ) + .child( + label() + .text(persona.title().to_string()) + .font_size(13.) + .font_weight(FontWeight::SEMI_BOLD) + .max_lines(1) + .color(colors::fg_primary()), + ) + .child( + label() + .text(persona.description().to_string()) + .font_size(11.) + .max_lines(1) + .color(colors::fg_secondary()), + ) + .into_element() } fn persona_icon(persona: Persona) -> IconType { match persona { - Persona::NightOwl => IconType::ClockRewind, + Persona::Veteran => IconType::ClockRewind, + Persona::Marathoner => IconType::Maximize01, + Persona::Regular => IconType::CheckCircle, + Persona::Loyalist => IconType::Key01, + Persona::Explorer => IconType::Globe01, + Persona::NightOwl => IconType::Eye, + Persona::EarlyBird => IconType::Bell01, + Persona::WeekendWarrior => IconType::Calendar, Persona::Gamer => IconType::Rocket02, + Persona::Sprinter => IconType::Play, } } -fn persona_card(persona: Persona) -> Element { - card() - .width(Size::flex(1.0)) - .horizontal() - .cross_align(Alignment::Center) - .spacing(14.) - .border(border_all_color(1., colors::brand())) - .child( - rect() - .width(Size::px(44.)) - .height(Size::px(44.)) - .corner_radius(CornerRadius::new_all(22.)) - .background(colors::brand().with_a(40)) - .center() - .child( - Icon::new(persona_icon(persona)) - .size(22.) - .color(colors::brand()), - ), - ) - .child( - rect() - .vertical() - .spacing(2.) - .child( - label() - .text(persona.title().to_string()) - .font_size(18.) - .font_weight(FontWeight::SEMI_BOLD) - .color(colors::fg_primary()), - ) - .child( - label() - .text(persona.description().to_string()) - .font_size(12.) - .color(colors::fg_secondary()), - ), - ) - .into_element() -} diff --git a/packages/oneclient_app/src/view/app/browser/index/cards.rs b/packages/oneclient_app/src/view/app/browser/index/cards.rs index f75869c6..47641eb0 100644 --- a/packages/oneclient_app/src/view/app/browser/index/cards.rs +++ b/packages/oneclient_app/src/view/app/browser/index/cards.rs @@ -31,10 +31,10 @@ pub(super) fn grid_row( cluster_id: i64, package_type: &str, installed: &InstalledMap, - metrics: GridMetrics, + cols: usize, ) -> impl IntoElement { let package_type = package_type.to_string(); - let fill = metrics.cols.saturating_sub(row.len()); + let fill = cols.saturating_sub(row.len()); let installed: Vec> = row .iter() .map(|item| installed_for(installed, item)) @@ -43,14 +43,14 @@ pub(super) fn grid_row( rect() .horizontal() .width(Size::fill()) - .height(Size::px(metrics.card_h)) + .height(Size::px(CARD_H)) .spacing(GRID_SPACING) .content(Content::Flex) .children( row.into_iter() .zip(installed) .map(move |(item, installed)| { - PackageCard::new(item, cluster_id, package_type.clone(), installed, metrics) + PackageCard::new(item, cluster_id, package_type.clone(), installed) .into_element() }), ) @@ -77,7 +77,6 @@ struct PackageCard { cluster_id: i64, package_type: String, installed: Option, - metrics: GridMetrics, } impl PackageCard { @@ -86,14 +85,12 @@ impl PackageCard { cluster_id: i64, package_type: String, installed: Option, - metrics: GridMetrics, ) -> Self { Self { item, cluster_id, package_type, installed, - metrics, } } } @@ -113,7 +110,7 @@ impl Component for PackageCard { rect() .vertical() .width(Size::flex(1.0)) - .height(Size::px(self.metrics.card_h)) + .height(Size::px(CARD_H)) .corner_radius(CornerRadius::new_all(10.)) .background(CARD_BG) .border(border_all_color( @@ -135,7 +132,7 @@ impl Component for PackageCard { .margin(Gaps::new_all(1.)) .corner_radius(CornerRadius::new(10., 10., 0., 0.)) .overflow(Overflow::Clip) - .child(PackageBanner::new(icon_url, self.metrics.banner_h)) + .child(PackageBanner::new(icon_url, BANNER_H)) // Top-left of the banner the first thing worth knowing about a result and the opposite corner is the install button's .maybe_child(self.installed.map(|installed| { rect() diff --git a/packages/oneclient_app/src/view/app/browser/index/mod.rs b/packages/oneclient_app/src/view/app/browser/index/mod.rs index 53b30f17..07ecb13f 100644 --- a/packages/oneclient_app/src/view/app/browser/index/mod.rs +++ b/packages/oneclient_app/src/view/app/browser/index/mod.rs @@ -16,18 +16,18 @@ use oneclient_core::settings::ViewLayout; use crate::components::{ Dropdown, Icon, IconType, Pagination, ScrollArea, Segment, SegmentedControl, TextInput, - cell_width, grid_columns_picker, resolved_columns, }; use crate::hooks::use_cluster; use crate::hooks::{ BROWSE_PAGE_SIZE, BrowserUiState, bundles_with_status_items, category_list, cluster_content_items, content_type_for_slug, pick_version_metadata, search_items, search_pending, search_total, settled_or_loading, use_browser_compat, use_browser_state_store, - use_bundles_with_status, use_cluster_content, use_clusters, use_debounced, + use_browser_type, use_bundles_with_status, use_cluster_content, use_clusters, use_debounced, use_package_categories, use_package_search, use_versions, use_view_state, versions_metadata, }; use crate::routes::Route; use crate::theme::colors; +use crate::ui::grid_columns_for_width; use super::{ InstallSource, Installed, PackageBanner, Thumbnail, installed_badge, installed_badge_overlay, @@ -45,29 +45,30 @@ use skeletons::{SkeletonListRow, skeleton_grid_row}; const CARD_BG: Color = Color::from_rgb(26, 34, 41); const CARD_NAME: Color = Color::from_rgb(213, 219, 255); const SCROLLBAR_GUTTER: f32 = 18.; +const CATEGORY_SIDEBAR_W: f32 = 190.; +const PROVIDER_LABELS_W: f32 = 900.; +const WIDE_SEARCH_W: f32 = 780.; +const SEARCH_W: f32 = 260.; +const SEARCH_COMPACT_W: f32 = 170.; const CARD_H: f32 = 240.; const BANNER_H: f32 = 100.; -const CARD_TEXT_H: f32 = CARD_H - BANNER_H; -const BANNER_RATIO: f32 = 0.35; -const BANNER_MAX_H: f32 = 200.; +const MAX_CARD_W: f32 = 400.; const LIST_ROW_H: f32 = 78.; const GRID_SPACING: f32 = 12.; const LIST_SPACING: f32 = 8.; const SEARCH_DEBOUNCE_MS: u64 = 250; -#[derive(Clone, Copy, PartialEq)] -pub(super) struct GridMetrics { - pub cols: usize, - pub card_h: f32, - pub banner_h: f32, -} +const BROWSE_TYPES: [(&str, &str); 3] = [ + ("mod", "Mods"), + ("texture", "Textures"), + ("shader", "Shaders"), +]; fn type_title(package_type: &str) -> &'static str { - match package_type { - "shader" => "Shaders", - "texture" => "Textures", - _ => "Mods", - } + BROWSE_TYPES + .iter() + .find(|(slug, _)| *slug == package_type) + .map_or("Mods", |(_, title)| *title) } fn encode_package_id(provider: ProviderId, id: &str) -> String { @@ -83,6 +84,37 @@ pub struct Browser { } impl Component for Browser { + fn render(&self) -> impl IntoElement { + let mut last_type = use_browser_type(); + use_side_effect_with_deps(&self.package_type, move |package_type| { + last_type.set_if_modified(package_type.clone()); + }); + + BrowserBody { + cluster_id: self.cluster_id, + package_type: self.package_type.clone(), + pick_cluster: self.pick_cluster, + key: DiffKey::None, + } + .key((self.cluster_id, self.package_type.as_str())) + } +} + +#[derive(PartialEq)] +struct BrowserBody { + cluster_id: i64, + package_type: String, + pick_cluster: bool, + key: DiffKey, +} + +impl KeyExt for BrowserBody { + fn write_key(&mut self) -> &mut DiffKey { + &mut self.key + } +} + +impl Component for BrowserBody { fn render(&self) -> impl IntoElement { let cluster_id = self.cluster_id; let package_type = self.package_type.clone(); @@ -94,9 +126,7 @@ impl Component for Browser { let query = use_state(|| saved.query.clone()); let provider = use_state(|| saved.provider); - let view = use_view_state(&format!("browser.{package_type}")); - let view_mode = view.layout; - let grid_columns = view.columns; + let view_mode = use_view_state(&format!("browser.{package_type}")).layout; let compatible_only = use_browser_compat(); let selected_categories = use_state(|| saved.categories.clone()); let page = use_state(|| saved.page); @@ -191,14 +221,12 @@ impl Component for Browser { let mode = *view_mode.read(); let mut grid_width = use_state(|| 0f32); - let cols = resolved_columns(*grid_columns.read()); - let card_w = cell_width( + let controls_width = use_state(|| 0f32); + let cols = grid_columns_for_width( (*grid_width.read() - SCROLLBAR_GUTTER).max(0.), - cols, + MAX_CARD_W, GRID_SPACING, ); - let banner_h = (card_w * BANNER_RATIO).clamp(BANNER_H, BANNER_MAX_H); - let card_h = banner_h + CARD_TEXT_H; let fade_dep = (current_page, packages.is_empty(), pending, mode); let fade = use_animation_with_dependencies(&fade_dep, |conf, _| { @@ -222,19 +250,8 @@ impl Component for Browser { let rows: Vec> = packages.chunks(cols).map(|c| c.to_vec()).collect(); - sa.lazy(rows.len(), card_h, GRID_SPACING, move |i| { - grid_row( - rows[i].clone(), - cluster_id, - &pkg, - &installed, - GridMetrics { - cols, - card_h, - banner_h, - }, - ) - .into_element() + sa.lazy(rows.len(), CARD_H, GRID_SPACING, move |i| { + grid_row(rows[i].clone(), cluster_id, &pkg, &installed, cols).into_element() }) } ViewLayout::List => sa.lazy(packages.len(), LIST_ROW_H, LIST_SPACING, move |i| { @@ -245,13 +262,8 @@ impl Component for Browser { match mode { ViewLayout::Grid => { let rows = BROWSE_PAGE_SIZE.div_ceil(cols); - sa.lazy(rows, card_h, GRID_SPACING, move |_| { - skeleton_grid_row(GridMetrics { - cols, - card_h, - banner_h, - }) - .into_element() + sa.lazy(rows, CARD_H, GRID_SPACING, move |_| { + skeleton_grid_row(cols).into_element() }) } ViewLayout::List => sa.lazy(BROWSE_PAGE_SIZE, LIST_ROW_H, LIST_SPACING, |_| { @@ -278,7 +290,17 @@ impl Component for Browser { package_type: package_type.clone(), }), )) - .child(controls(provider, query, view_mode, grid_columns)) + .child(controls( + TypePicker { + cluster_id, + package_type: package_type.clone(), + pick_cluster: self.pick_cluster, + }, + provider, + query, + view_mode, + controls_width, + )) .child( rect() .horizontal() @@ -319,6 +341,10 @@ impl Component for Browser { ), ) } + + fn render_key(&self) -> DiffKey { + self.key.clone().or(self.default_key()) + } } fn page_title( @@ -341,7 +367,7 @@ fn page_title( // Without the picker the cluster is fixed so it's just a subtitle None => cluster_name.map(|name| { label() - .text(format!("for {name}")) + .text(name) .font_size(14.) .color(colors::fg_secondary()) .into_element() @@ -364,6 +390,41 @@ fn version_name(metadata: &[VersionMetadata], cluster: &Cluster) -> String { .unwrap_or_else(|| cluster.mc_version.clone()) } +#[derive(PartialEq)] +struct TypePicker { + cluster_id: i64, + package_type: String, + pick_cluster: bool, +} + +impl Component for TypePicker { + fn render(&self) -> impl IntoElement { + let cluster_id = self.cluster_id; + let pick_cluster = self.pick_cluster; + let current = self.package_type.clone(); + + let labels: Vec = BROWSE_TYPES + .iter() + .map(|(_, title)| (*title).to_string()) + .collect(); + let selected = type_title(&self.package_type).to_string(); + + Dropdown::new(selected, labels) + .width(Size::px(CATEGORY_SIDEBAR_W)) + .height(Size::px(30.)) + .on_select(move |idx: usize| { + let picked = BROWSE_TYPES.get(idx).filter(|(slug, _)| *slug != current); + if let Some((slug, _)) = picked { + let _ = RouterContext::get().push(Route::Browser { + cluster_id, + package_type: (*slug).to_string(), + pick_cluster, + }); + } + }) + } +} + /// The choice lives in the route so back and forward keep the cluster being browsed #[derive(PartialEq)] struct ClusterPicker { @@ -396,12 +457,6 @@ impl Component for ClusterPicker { .cross_align(Alignment::Center) .spacing(8.) .margin(Gaps::new(4., 0., 0., 0.)) - .child( - label() - .text("for") - .font_size(14.) - .color(colors::fg_secondary()), - ) .child( Dropdown::new(selected, labels) .width(Size::px(240.)) @@ -420,22 +475,34 @@ impl Component for ClusterPicker { } fn controls( + type_picker: TypePicker, provider: State, query: State, view_mode: State, - grid_columns: State, + mut controls_width: State, ) -> impl IntoElement { + let measured = *controls_width.read(); + let show_provider_labels = measured == 0. || measured >= PROVIDER_LABELS_W; + let search_width = if measured == 0. || measured >= WIDE_SEARCH_W { + SEARCH_W + } else { + SEARCH_COMPACT_W + }; + rect() .horizontal() .width(Size::fill()) .cross_align(Alignment::Center) .spacing(12.) .content(Content::Flex) + .on_sized(move |event: Event| { + let w = event.data().area.width(); + if (w - *controls_width.peek()).abs() > 0.5 { + *controls_width.write() = w; + } + }) + .child(type_picker) .child(rect().width(Size::flex(1.0))) - .maybe_child( - (*view_mode.read() == ViewLayout::Grid) - .then(|| grid_columns_picker(grid_columns, 30.).into_element()), - ) .child( SegmentedControl::new(view_mode) .equal_width(30.) @@ -446,16 +513,19 @@ fn controls( .child( SegmentedControl::new(provider) .no_tint() - .segments(ProviderId::REMOTE_PROVIDERS.iter().map(|provider| { - Segment::new(*provider) - .icon(IconType::from(*provider)) - .label(provider.to_string()) + .segments(ProviderId::REMOTE_PROVIDERS.iter().map(move |provider| { + let segment = Segment::new(*provider).icon(IconType::from(*provider)); + if show_provider_labels { + segment.label(provider.to_string()) + } else { + segment + } })) .into_element(), ) .child( TextInput::new(query) - .width(Size::px(260.)) + .width(Size::px(search_width)) .placeholder("Search for content") .leading( Icon::new(IconType::SearchMd) diff --git a/packages/oneclient_app/src/view/app/browser/index/sidebar.rs b/packages/oneclient_app/src/view/app/browser/index/sidebar.rs index ad814631..f7e25dbb 100644 --- a/packages/oneclient_app/src/view/app/browser/index/sidebar.rs +++ b/packages/oneclient_app/src/view/app/browser/index/sidebar.rs @@ -41,7 +41,7 @@ impl Component for CategorySidebar { rect() .vertical() - .width(Size::px(190.)) + .width(Size::px(CATEGORY_SIDEBAR_W)) .height(Size::fill()) .spacing(8.) .child( diff --git a/packages/oneclient_app/src/view/app/browser/index/skeletons.rs b/packages/oneclient_app/src/view/app/browser/index/skeletons.rs index ee6dca57..77f8ae4f 100644 --- a/packages/oneclient_app/src/view/app/browser/index/skeletons.rs +++ b/packages/oneclient_app/src/view/app/browser/index/skeletons.rs @@ -27,9 +27,7 @@ fn use_skeleton_pulse() -> f32 { } #[derive(PartialEq)] -struct SkeletonCard { - metrics: GridMetrics, -} +struct SkeletonCard; impl Component for SkeletonCard { fn render(&self) -> impl IntoElement { @@ -37,13 +35,13 @@ impl Component for SkeletonCard { rect() .vertical() .width(Size::flex(1.0)) - .height(Size::px(self.metrics.card_h)) + .height(Size::px(CARD_H)) .corner_radius(CornerRadius::new_all(10.)) .background(CARD_BG) .border(border_all_color(1., colors::component_border())) .overflow(Overflow::Clip) .opacity(pulse) - .child(skeleton_block(Size::fill(), self.metrics.banner_h)) + .child(skeleton_block(Size::fill(), BANNER_H)) .child( rect() .vertical() @@ -90,13 +88,13 @@ impl Component for SkeletonListRow { } } -pub(super) fn skeleton_grid_row(metrics: GridMetrics) -> impl IntoElement { +pub(super) fn skeleton_grid_row(cols: usize) -> impl IntoElement { rect() .horizontal() .width(Size::fill()) - .height(Size::px(metrics.card_h)) + .height(Size::px(CARD_H)) .spacing(GRID_SPACING) .content(Content::Flex) - .children((0..metrics.cols).map(move |_| SkeletonCard { metrics }.into_element())) + .children((0..cols).map(|_| SkeletonCard.into_element())) .into_element() } diff --git a/packages/oneclient_app/src/view/app/browser/mod.rs b/packages/oneclient_app/src/view/app/browser/mod.rs index fde529ae..5d6f4510 100644 --- a/packages/oneclient_app/src/view/app/browser/mod.rs +++ b/packages/oneclient_app/src/view/app/browser/mod.rs @@ -13,7 +13,7 @@ use oneclient_core::{BundleFileKind, BundleWithUpdateStatus, LinkedArtifactInfo} use crate::components::{Icon, IconType}; use crate::hooks::{loaded_image, use_cached_image}; use crate::theme::colors; -use crate::ui::border_all_color; +use crate::ui::{ImageFallbackExt, border_all_color}; const BANNER_BG: Color = Color::from_rgb(21, 28, 34); @@ -251,29 +251,36 @@ impl Component for Thumbnail { let query = use_cached_image(self.icon_url.clone(), 256); let loaded = loaded_image(self.icon_url.as_deref(), &query); + let placeholder = thumbnail_placeholder(size, radius, 0.4); + match loaded { Some((url, bytes)) => ImageViewer::new((url, bytes)) .width(Size::px(size)) .height(Size::px(size)) .aspect_ratio(AspectRatio::Min) .corner_radius(CornerRadius::new_all(radius)) + .fallback(placeholder) .into_element(), - None => rect() - .center() - .width(Size::px(size)) - .height(Size::px(size)) - .corner_radius(CornerRadius::new_all(radius)) - .background(colors::component_bg()) - .child( - Icon::new(IconType::DotsGrid) - .size(size * 0.4) - .color(colors::fg_secondary()), - ) - .into_element(), + None => placeholder, } } } +fn thumbnail_placeholder(size: f32, radius: f32, icon_ratio: f32) -> Element { + rect() + .center() + .width(Size::px(size)) + .height(Size::px(size)) + .corner_radius(CornerRadius::new_all(radius)) + .background(colors::component_bg()) + .child( + Icon::new(IconType::DotsGrid) + .size(size * icon_ratio) + .color(colors::fg_secondary()), + ) + .into_element() +} + #[derive(PartialEq)] pub(crate) struct PackageBanner { icon_url: Option, @@ -311,6 +318,8 @@ impl Component for PackageBanner { .overflow(Overflow::Clip) .background(BANNER_BG); + let icon_placeholder = thumbnail_placeholder(icon, 10., 0.45); + match loaded { Some((url, bytes)) => banner .child( @@ -324,7 +333,8 @@ impl Component for PackageBanner { .width(Size::fill()) .height(Size::fill()) .aspect_ratio(AspectRatio::Max) - .image_cover(ImageCover::Center), + .image_cover(ImageCover::Center) + .fallback(rect().width(Size::fill()).height(Size::fill())), ) .layer(Layer::Relative(1)), ) @@ -347,23 +357,12 @@ impl Component for PackageBanner { .width(Size::px(icon)) .height(Size::px(icon)) .aspect_ratio(AspectRatio::Min) - .corner_radius(CornerRadius::new_all(10.)), + .corner_radius(CornerRadius::new_all(10.)) + .fallback(icon_placeholder), ) .layer(Layer::Relative(5)), ), - None => banner.child( - rect() - .center() - .width(Size::px(icon)) - .height(Size::px(icon)) - .corner_radius(CornerRadius::new_all(10.)) - .background(colors::component_bg()) - .child( - Icon::new(IconType::DotsGrid) - .size(icon * 0.45) - .color(colors::fg_secondary()), - ), - ), + None => banner.child(icon_placeholder), } } } diff --git a/packages/oneclient_app/src/view/app/browser/package/gallery.rs b/packages/oneclient_app/src/view/app/browser/package/gallery.rs index 8eae22da..898fdfc1 100644 --- a/packages/oneclient_app/src/view/app/browser/package/gallery.rs +++ b/packages/oneclient_app/src/view/app/browser/package/gallery.rs @@ -4,18 +4,14 @@ use super::*; use oneclient_content::packages::types::GalleryImage; -use crate::components::{ - Button, Icon, IconType, OverlayPopup, cell_width, grid_columns_picker, resolved_columns, -}; -use crate::hooks::{loaded_image, query_is_busy, use_cached_image, use_view_state}; +use crate::components::{Button, Icon, IconType, OverlayPopup}; +use crate::hooks::{loaded_image, query_is_busy, use_cached_image}; use crate::theme::colors; -use crate::ui::{border_all_color, flow_grid}; +use crate::ui::{ImageFallbackExt, border_all_color, flow_grid, grid_columns_for_width}; const MAX_COL_W: f32 = 400.; const GRID_GAP: f32 = 16.; const TILE_PREVIEW_H: f32 = 168.; -const TILE_PREVIEW_RATIO: f32 = TILE_PREVIEW_H / MAX_COL_W; -const TILE_PREVIEW_MAX_H: f32 = 420.; /// Downscale ceilings for the image cache full-size tiles made the grid slow to fill const TILE_EDGE: u32 = 640; @@ -35,11 +31,6 @@ impl Component for Gallery { let grid_width = use_state(|| 0f32); let mut viewing = use_state(|| None::); - let grid_columns = use_view_state("browser.gallery").columns; - let cols = resolved_columns(*grid_columns.read()); - let tile_w = cell_width(*grid_width.read(), cols, GRID_GAP); - let preview_h = (tile_w * TILE_PREVIEW_RATIO).clamp(TILE_PREVIEW_H, TILE_PREVIEW_MAX_H); - let tiles: Vec = self .images .iter() @@ -48,7 +39,6 @@ impl Component for Gallery { let key = image.url.clone(); GalleryTile { image: image.clone(), - preview_h, on_open: (move |()| viewing.set(Some(idx))).into(), key: DiffKey::None, } @@ -57,21 +47,11 @@ impl Component for Gallery { }) .collect(); + let cols = grid_columns_for_width(*grid_width.read(), MAX_COL_W, GRID_GAP); let images = self.images.clone(); rect() - .vertical() .width(Size::fill()) - .spacing(10.) - .child( - rect() - .horizontal() - .width(Size::fill()) - .cross_align(Alignment::Center) - .content(Content::Flex) - .child(rect().width(Size::flex(1.0))) - .child(grid_columns_picker(grid_columns, 30.)), - ) .child(flow_grid(tiles, cols, grid_width, GRID_GAP)) .maybe_child((*viewing.read()).map(|start| { GalleryViewer { @@ -87,7 +67,6 @@ impl Component for Gallery { #[derive(PartialEq)] struct GalleryTile { image: GalleryImage, - preview_h: f32, on_open: EventHandler<()>, key: DiffKey, } @@ -108,7 +87,7 @@ impl Component for GalleryTile { let preview = rect() .width(Size::fill()) - .height(Size::px(self.preview_h)) + .height(Size::px(TILE_PREVIEW_H)) .center() .overflow(Overflow::Clip) .background(colors::component_bg()) @@ -118,12 +97,14 @@ impl Component for GalleryTile { .height(Size::fill()) .aspect_ratio(AspectRatio::Max) .image_cover(ImageCover::Center) + .fallback(broken_image(32.)) .into_element() })); rect() .vertical() .width(Size::flex(1.0)) + .max_width(Size::px(MAX_COL_W)) .corner_radius(CornerRadius::new_all(10.)) .overflow(Overflow::Clip) .background(PANEL_BG) @@ -202,6 +183,7 @@ impl Component for GalleryViewer { .width(Size::fill()) .height(Size::fill()) .aspect_ratio(AspectRatio::Min) + .fallback(broken_image(48.)) .into_element() })) // A full-size fetch separate from the tile's cached copy a failed fetch is not loading @@ -265,6 +247,19 @@ impl Component for GalleryViewer { } } +fn broken_image(size: f32) -> Element { + rect() + .center() + .width(Size::fill()) + .height(Size::fill()) + .child( + Icon::new(IconType::FileX02) + .size(size) + .color(colors::fg_secondary()), + ) + .into_element() +} + fn chevron_btn( icon: IconType, enabled: bool, @@ -337,6 +332,7 @@ fn header_row( Button::new() .ghost() .icon() + .tooltip("Close") .on_press(on_close) .child(Icon::new(IconType::XClose).size(18.)), ) diff --git a/packages/oneclient_app/src/view/app/cluster/cluster_settings.rs b/packages/oneclient_app/src/view/app/cluster/cluster_settings.rs index 0d8d72ef..78fe3fe3 100644 --- a/packages/oneclient_app/src/view/app/cluster/cluster_settings.rs +++ b/packages/oneclient_app/src/view/app/cluster/cluster_settings.rs @@ -69,6 +69,7 @@ impl Component for ClusterSettings { ScrollArea::new() .width(Size::fill()) .height(Size::fill()) + .scrollbar_gutter(true) .spacing(4.) .child(section_header("GAME")) .child( @@ -184,6 +185,11 @@ fn reset_button(overridden: bool, on_reset: EventHandler<()>) -> impl IntoElemen .small() .ghost() .icon() + .tooltip(if overridden { + "Reset to the global setting" + } else { + "Already using the global setting" + }) .corner_radius(CornerRadius::new_all(7.)) .maybe(overridden, |el| el.on_press(move |_| on_reset.call(()))) .enabled(overridden) @@ -357,8 +363,8 @@ impl Component for MemoryRow { let overridden = self.value.is_some(); let dispatch = use_dispatch(); - let global = self.global.to_string(); - let initial = self.value.unwrap_or(self.global).to_string(); + let global = self.global; + let initial = self.value.map(|v| v.to_string()).unwrap_or_default(); let mut memory = use_state({ let v = initial.clone(); move || v @@ -387,13 +393,13 @@ impl Component for MemoryRow { } let on_reset: EventHandler<()> = (move |()| { - last.set(global.clone()); - memory.set(global.clone()); + last.set(String::new()); + memory.set(String::new()); dispatch.update_cluster_profile(cluster_id, clear_update(Field::MemMax)); }) .into(); - let control = memory_field(memory); + let control = memory_field(memory, "Global", global); settings_row( IconType::Database01, diff --git a/packages/oneclient_app/src/view/app/cluster/overview.rs b/packages/oneclient_app/src/view/app/cluster/overview.rs index 73fd5153..160616e9 100644 --- a/packages/oneclient_app/src/view/app/cluster/overview.rs +++ b/packages/oneclient_app/src/view/app/cluster/overview.rs @@ -39,6 +39,7 @@ impl Component for ClusterOverview { ScrollArea::new() .width(Size::fill()) .height(Size::fill()) + .scrollbar_gutter(true) .child( rect() .vertical() diff --git a/packages/oneclient_app/src/view/app/cluster/package_manager/mod.rs b/packages/oneclient_app/src/view/app/cluster/package_manager/mod.rs index 29153440..6119a953 100644 --- a/packages/oneclient_app/src/view/app/cluster/package_manager/mod.rs +++ b/packages/oneclient_app/src/view/app/cluster/package_manager/mod.rs @@ -419,10 +419,10 @@ impl Component for PackageManager { let search = use_state(String::new); let enabled_filter = use_state(|| EnabledFilter::All); let hidden_filter = use_state(|| HiddenFilter::Hide); + let toolbar_width = use_state(|| 0f32); let view = use_view_state("cluster.packages"); let sort = view.sort; let layout = view.layout; - let grid_columns = view.columns; let query = SearchQuery::new(&search.read()); let sort_mode = sort .read() @@ -475,10 +475,15 @@ impl Component for PackageManager { enabled_filter, hidden_filter, layout, - grid_columns, cluster_id, package_type, + toolbar_width, )) + .maybe_child( + content_type + .is_global() + .then(|| views::global_notice(noun_plural)), + ) .maybe_child(session_live.then(|| views::running_notice(noun_plural, content_type))) .child(ContentBox::new( filtered, @@ -488,7 +493,6 @@ impl Component for PackageManager { cluster_id, content_kind, card_layout, - *grid_columns.read(), )) } } diff --git a/packages/oneclient_app/src/view/app/cluster/package_manager/views.rs b/packages/oneclient_app/src/view/app/cluster/package_manager/views.rs index b9ff7074..f64c2c1f 100644 --- a/packages/oneclient_app/src/view/app/cluster/package_manager/views.rs +++ b/packages/oneclient_app/src/view/app/cluster/package_manager/views.rs @@ -7,7 +7,7 @@ use oneclient_core::settings::ViewLayout; use crate::components::{ Button, CardLayout, Icon, IconType, PackageEntry, PackageRow, ScrollArea, ScrollAreaCtx, - Segment, SegmentedControl, TabBar, TabItem, TextInput, grid_columns_picker, resolved_columns, + Segment, SegmentedControl, TabBar, TabItem, TextInput, }; use crate::hooks::{use_dispatch, use_overlay_claim}; use crate::routes::Route; @@ -122,6 +122,17 @@ impl HiddenFilter { const FILTER_PANEL_W: f32 = 172.; const FILTER_BTN_W: f32 = 34.; +const TOOLBAR_STACK_W: f32 = 640.; +const TABS_ROW_H: f32 = 34.; + +fn tabs_scrollbar_theme() -> ScrollBarThemePartial { + ScrollBarThemePartial { + thumb_background: Some(colors::fg_secondary().with_a(120).into()), + hover_thumb_background: Some(colors::fg_secondary().with_a(190).into()), + active_thumb_background: Some(colors::fg_primary().into()), + ..Default::default() + } +} #[allow(clippy::too_many_arguments)] pub(super) fn toolbar_bar( @@ -134,9 +145,9 @@ pub(super) fn toolbar_bar( enabled_filter: State, hidden_filter: State, layout: State, - grid_columns: State, cluster_id: i64, package_type: &'static str, + mut toolbar_width: State, ) -> impl IntoElement { let tab_items = tabs.iter().enumerate().map(|(i, tab)| { let mut active = active; @@ -146,69 +157,127 @@ pub(super) fn toolbar_bar( let mut top_corners = CornerRadius::new_all(0.); top_corners.fill_top(12.); - rect() - .horizontal() - .width(Size::fill()) - .cross_align(Alignment::Center) - .spacing(8.) - .content(Content::Flex) - .overflow(Overflow::Clip) - .padding(Gaps::new_symmetric(8., 12.)) - .corner_radius(top_corners) - .background(colors::page_elevated()) + let measured = *toolbar_width.read(); + let stacked = measured > 0. && measured < TOOLBAR_STACK_W; + + let filter_tabs = ScrollView::new() + .direction(Direction::Horizontal) + .invert_scroll_wheel(true) + .show_scrollbar(stacked) + .scrollbar_theme(tabs_scrollbar_theme()) + .width(if stacked { + Size::fill() + } else { + Size::flex(1.0) + }) + .height(if stacked { + Size::fill() + } else { + Size::auto() + }) .child( - ScrollView::new() - .direction(Direction::Horizontal) - .show_scrollbar(false) - .width(Size::flex(1.0)) - .height(Size::auto()) - .child( - TabBar::new() - .width(Size::auto()) - .height(Size::auto()) - .spacing(20.) - .font_size(12.) - .tabs(tab_items), - ), + TabBar::new() + .width(Size::auto()) + .height(if stacked { + Size::fill() + } else { + Size::auto() + }) + .spacing(20.) + .font_size(12.) + .tabs(tab_items), ) - .child( - TextInput::new(search) - .placeholder("Search...") - .width(Size::px(180.)) - .leading( - Icon::new(IconType::SearchMd) - .size(14.) - .color(colors::fg_secondary()) - .into_element(), - ), - ) - .child(FilterButton { + .into_element(); + + let mut controls: Vec = vec![ + TextInput::new(search) + .placeholder("Search...") + .width(Size::px(180.)) + .leading( + Icon::new(IconType::SearchMd) + .size(14.) + .color(colors::fg_secondary()) + .into_element(), + ) + .into_element(), + FilterButton { sort, current_sort, enabled_filter, hidden_filter, + } + .into_element(), + ]; + + controls.push( + SegmentedControl::new(layout) + .height(34.) + .icon_size(15.) + .equal_width(34.) + .segment(Segment::new(ViewLayout::List).icon(IconType::ParagraphWrap)) + .segment(Segment::new(ViewLayout::Grid).icon(IconType::DotsGrid)) + .into_element(), + ); + controls.push( + Button::new() + .primary() + .height(Size::px(34.)) + .font_size(12.) + .on_press(move |_| open_browser(cluster_id, package_type)) + .child(Icon::new(IconType::Plus).size(15.)) + .text("Add Content") + .into_element(), + ); + + let inner = if stacked { + rect() + .vertical() + .width(Size::fill()) + .spacing(8.) + .child( + rect() + .horizontal() + .width(Size::fill()) + .height(Size::px(TABS_ROW_H)) + .cross_align(Alignment::Center) + .child(filter_tabs), + ) + .child( + rect() + .horizontal() + .width(Size::fill()) + .cross_align(Alignment::Center) + .spacing(8.) + .content(Content::Flex) + .children(controls), + ) + .into_element() + } else { + rect() + .horizontal() + .width(Size::fill()) + .cross_align(Alignment::Center) + .spacing(8.) + .content(Content::Flex) + .child(filter_tabs) + .children(controls) + .into_element() + }; + + rect() + .vertical() + .width(Size::fill()) + .overflow(Overflow::Clip) + .padding(Gaps::new_symmetric(8., 12.)) + .corner_radius(top_corners) + .background(colors::page_elevated()) + .on_sized(move |event: Event| { + let next = event.data().area.width(); + if (*toolbar_width.peek() - next).abs() > 0.5 { + toolbar_width.set(next); + } }) - .maybe_child( - (*layout.read() == ViewLayout::Grid) - .then(|| grid_columns_picker(grid_columns, 34.).into_element()), - ) - .child( - SegmentedControl::new(layout) - .height(34.) - .icon_size(15.) - .equal_width(34.) - .segment(Segment::new(ViewLayout::List).icon(IconType::ParagraphWrap)) - .segment(Segment::new(ViewLayout::Grid).icon(IconType::DotsGrid)), - ) - .child( - Button::new() - .primary() - .height(Size::px(34.)) - .font_size(12.) - .on_press(move |_| open_browser(cluster_id, package_type)) - .child(Icon::new(IconType::Plus).size(15.)) - .text("Add Content"), - ) + .child(inner) .into_element() } @@ -225,6 +294,16 @@ pub(super) fn running_notice(noun_plural: &'static str, content_type: ContentTyp ), }; + notice_bar(text) +} + +pub(super) fn global_notice(noun_plural: &'static str) -> Element { + notice_bar(format!( + "These {noun_plural} are shared across all your clusters. Adding one here makes it available everywhere, and turning one off removes it everywhere." + )) +} + +fn notice_bar(text: String) -> Element { rect() .horizontal() .width(Size::fill()) @@ -286,6 +365,7 @@ impl Component for FilterButton { Button::new() .secondary() .icon() + .tooltip("Sort and filter") .width(Size::px(FILTER_BTN_W)) .height(Size::px(34.)) .on_press(move |e: Event| { @@ -583,7 +663,6 @@ pub(super) struct ContentBox { cluster_id: i64, kind: ContentKind, layout: CardLayout, - grid_columns: u8, } impl ContentBox { @@ -596,7 +675,6 @@ impl ContentBox { cluster_id: i64, kind: ContentKind, layout: CardLayout, - grid_columns: u8, ) -> Self { Self { items, @@ -606,7 +684,6 @@ impl ContentBox { cluster_id, kind, layout, - grid_columns, } } } @@ -620,7 +697,6 @@ impl Component for ContentBox { let noun_plural = self.noun_plural; let kind = &self.kind; let layout = self.layout; - let grid_columns = self.grid_columns; let dispatch = use_dispatch(); @@ -631,6 +707,7 @@ impl Component for ContentBox { ScrollArea::new() .width(Size::fill()) .height(Size::fill()) + .scrollbar_gutter(true) .lazy(count, CARD_H, CARD_SPACING, move |i| { let item = items[i].clone(); let key = item.package_id.clone(); @@ -644,8 +721,9 @@ impl Component for ContentBox { CardLayout::Grid => ScrollArea::new() .width(Size::fill()) .height(Size::fill()) + .scrollbar_gutter(true) .content(move |ctx: ScrollAreaCtx| { - grid_content(&items, package_type, cluster_id, grid_columns, ctx).into_element() + grid_content(&items, package_type, cluster_id, ctx).into_element() }) .into_element(), }); @@ -710,12 +788,11 @@ fn grid_content( items: &[PackageEntry], package_type: &'static str, cluster_id: i64, - grid_columns: u8, ctx: ScrollAreaCtx, ) -> impl IntoElement { let count = items.len(); - let fits = ((ctx.viewport_w + GRID_GAP) / (GRID_MIN_W + GRID_GAP)).floor() as usize; - let cols = resolved_columns(grid_columns).min(fits.max(1)); + let cols = + (((ctx.viewport_w + GRID_GAP) / (GRID_MIN_W + GRID_GAP)).floor() as usize).clamp(1, 5); let rows_total = count.div_ceil(cols); let slot = CARD_GRID_H + GRID_GAP; diff --git a/packages/oneclient_app/src/view/app/cluster/screenshots.rs b/packages/oneclient_app/src/view/app/cluster/screenshots.rs index cb7e8ce2..a7a8905a 100644 --- a/packages/oneclient_app/src/view/app/cluster/screenshots.rs +++ b/packages/oneclient_app/src/view/app/cluster/screenshots.rs @@ -7,13 +7,12 @@ use oneclient_core::settings::ViewLayout; use crate::components::{ Button, Icon, IconType, LocalImage, OverlayPopup, ScreenshotViewer, ScrollArea, Segment, - SegmentedControl, cell_width, grid_columns_picker, open_folder_button, resolved_columns, - screenshot_context_menu, + SegmentedControl, open_folder_button, screenshot_context_menu, }; use crate::hooks::{ScreenshotAction, query_is_loading, try_cluster_screenshots, use_cluster_screenshots, use_dispatch, use_screenshot_action, use_screenshot_folder_watch, use_view_state}; use crate::layout::cluster_content; use crate::theme::colors; -use crate::ui::{border_all_color, flow_grid, fmt_date}; +use crate::ui::{border_all_color, flow_grid, fmt_date, grid_columns_for_width}; use crate::utils::{format_res, format_size}; use super::cluster_not_found; @@ -23,8 +22,6 @@ const THUMB_EDGE: u32 = 480; const MAX_COL_W: f32 = 400.; const GRID_GAP: f32 = 16.; const TILE_PREVIEW_H: f32 = 168.; -const TILE_PREVIEW_RATIO: f32 = TILE_PREVIEW_H / MAX_COL_W; -const TILE_PREVIEW_MAX_H: f32 = 420.; const CARD_BG: Color = Color::from_rgb(26, 34, 41); const CARD_NAME: Color = Color::from_rgb(213, 219, 255); @@ -48,9 +45,7 @@ impl Component for ClusterScreenshots { let shots = try_cluster_screenshots(&query).unwrap_or_default(); - let view = use_view_state("cluster.screenshots"); - let view_mode = view.layout; - let grid_columns = view.columns; + let view_mode = use_view_state("cluster.screenshots").layout; let menu_dispatch = use_dispatch(); let mut edit_mode = use_state(|| false); @@ -64,16 +59,11 @@ impl Component for ClusterScreenshots { &shots, folder, view_mode, - grid_columns, edit_mode, selected, confirm_delete, ); - let cols = resolved_columns(*grid_columns.read()); - let tile_w = cell_width(*grid_width.read(), cols, GRID_GAP); - let preview_h = (tile_w * TILE_PREVIEW_RATIO).clamp(TILE_PREVIEW_H, TILE_PREVIEW_MAX_H); - let content: Element = if shots.is_empty() { empty_state(query_is_loading(&query)) .into_element() @@ -108,7 +98,6 @@ impl Component for ClusterScreenshots { info, selected: is_selected, edit_mode: editing, - preview_h, on_activate: on_activate.into(), on_context, } @@ -126,7 +115,10 @@ impl Component for ClusterScreenshots { } match *view_mode.read() { - ViewLayout::Grid => flow_grid(items, cols, grid_width, GRID_GAP), + ViewLayout::Grid => { + let cols = grid_columns_for_width(*grid_width.read(), MAX_COL_W, GRID_GAP); + flow_grid(items, cols, grid_width, GRID_GAP) + } ViewLayout::List => rect() .vertical() .width(Size::fill()) @@ -139,6 +131,7 @@ impl Component for ClusterScreenshots { let body = ScrollArea::new() .width(Size::fill()) .height(Size::flex(1.0)) + .scrollbar_gutter(true) .children(vec![content]); let confirm_overlay = confirm_delete.read().then(|| { @@ -184,7 +177,6 @@ fn toolbar_row( shots: &[ScreenshotInfo], folder: Option, view_mode: State, - grid_columns: State, mut edit_mode: State, mut selected: State>, mut confirm_delete: State, @@ -252,10 +244,6 @@ fn toolbar_row( ); } else { right = right - .maybe_child( - (*view_mode.read() == ViewLayout::Grid) - .then(|| grid_columns_picker(grid_columns, 36.).into_element()), - ) .child( SegmentedControl::new(view_mode) .equal_width(40.) @@ -405,7 +393,6 @@ struct ScreenshotTile { info: ScreenshotInfo, selected: bool, edit_mode: bool, - preview_h: f32, on_activate: EventHandler<()>, on_context: EventHandler<(f32, f32)>, } @@ -430,6 +417,7 @@ impl Component for ScreenshotTile { rect() .vertical() .width(Size::flex(1.0)) + .max_width(Size::px(MAX_COL_W)) .corner_radius(CornerRadius::new_all(10.)) .background(CARD_BG) .overflow(Overflow::Clip) @@ -445,7 +433,7 @@ impl Component for ScreenshotTile { }) .child(preview_box( info.path.clone(), - self.preview_h, + TILE_PREVIEW_H, info.resolution, )) .child( diff --git a/packages/oneclient_app/src/view/app/clusters/page.rs b/packages/oneclient_app/src/view/app/clusters/page.rs index 14b74fa0..f1306920 100644 --- a/packages/oneclient_app/src/view/app/clusters/page.rs +++ b/packages/oneclient_app/src/view/app/clusters/page.rs @@ -22,6 +22,8 @@ const MIN_CARD_WIDTH_PX: f32 = 280.; const SIDEBAR_WIDTH_PX: f32 = 300.; const CARD_HEIGHT_PX: f32 = 146.; const PLACEHOLDER_VERSION_INFO: &str = "Placeholder version info"; +const ART_MAX_HEIGHT_PX: f32 = 140.; +const ART_MIN_SIDEBAR_PX: f32 = 260.; #[derive(PartialEq)] pub struct Clusters; @@ -196,6 +198,7 @@ struct DetailSidebar { impl Component for DetailSidebar { fn render(&self) -> impl IntoElement { + let mut sidebar_height = use_state(|| 0f32); let active_id = use_active_cluster_id(); let dispatch = use_dispatch(); let game = use_game_snapshot(); @@ -224,6 +227,8 @@ impl Component for DetailSidebar { .map(|m| m.tags.clone()) .unwrap_or_default(); + let art_height = art_height_for(*sidebar_height.read()); + rect() .width(Size::px(SIDEBAR_WIDTH_PX)) .min_width(Size::px(SIDEBAR_WIDTH_PX)) @@ -231,13 +236,29 @@ impl Component for DetailSidebar { .vertical() .spacing(8.) .padding(8.) + .content(Content::Flex) .corner_radius(CornerRadius::new_all(12.)) .background(colors::page_elevated()) .border(border_all_color(1., colors::component_border())) .overflow(Overflow::Clip) - .child(rect().width(Size::fill()).max_height(Size::px(140.)).child( - ClusterLandscapeArt::for_version(self.line.major, version_value, loader_value, false), - )) + .on_sized(move |event: Event| { + let height = event.data().area.height(); + if (*sidebar_height.peek() - height).abs() > 0.5 { + *sidebar_height.write() = height; + } + }) + .maybe_child(art_height.map(|max| { + rect() + .width(Size::fill()) + .max_height(Size::px(max)) + .child(ClusterLandscapeArt::for_version( + self.line.major, + version_value, + loader_value, + false, + )) + .into_element() + })) .child( rect() .vertical() @@ -251,20 +272,27 @@ impl Component for DetailSidebar { .vertical() .width(Size::fill()) .height(Size::flex(1.0)) + .content(Content::Flex) .spacing(4.) .child( - label() - .text(heading) - .font_size(24.) - .font_weight(FontWeight::SEMI_BOLD) - .color(colors::fg_primary()), - ) - .maybe_child(tags_row(&tags)) - .child( - label() - .text(description) - .font_size(12.) - .color(colors::fg_secondary()), + ScrollArea::new() + .width(Size::fill()) + .height(Size::flex(1.0)) + .spacing(4.) + .child( + label() + .text(heading) + .font_size(24.) + .font_weight(FontWeight::SEMI_BOLD) + .color(colors::fg_primary()), + ) + .children(tags_row(&tags)) + .child( + label() + .text(description) + .font_size(12.) + .color(colors::fg_secondary()), + ), ) .children(version_rows(self.line, &keys, version_value, self.selected_version)) .children(loader_rows(&loaders, loader_value, self.selected_loader)), @@ -288,6 +316,16 @@ impl Component for DetailSidebar { } } +fn art_height_for(sidebar_height: f32) -> Option { + if sidebar_height <= 0. { + return Some(ART_MAX_HEIGHT_PX); + } + if sidebar_height < ART_MIN_SIDEBAR_PX { + return None; + } + Some((sidebar_height * 0.32).min(ART_MAX_HEIGHT_PX)) +} + fn tags_row(tags: &[String]) -> Option { if tags.is_empty() { return None; diff --git a/packages/oneclient_app/src/view/app/debug.rs b/packages/oneclient_app/src/view/app/debug.rs index 552b462a..0c1c13e4 100644 --- a/packages/oneclient_app/src/view/app/debug.rs +++ b/packages/oneclient_app/src/view/app/debug.rs @@ -9,7 +9,7 @@ use oneclient_net::status::{self, ServiceStatus}; use crate::Actions; use crate::components::{Button, Dropdown, Icon, IconType, TextInput, login_dialog, toggle}; -use crate::hooks::use_dispatch; +use crate::hooks::{settled_or_loading, use_active_cluster_id, use_clusters, use_dispatch}; use crate::notifications::{ ClusterUpdateItem, ClusterUpdateSummary, NotificationAction, NotificationActionKind, OptionalModsGroup, @@ -97,6 +97,8 @@ impl Component for Debug { vec![CorruptionSimulator.into_element()], )) .child(divider()) + .child(section("Clusters", vec![ClusterList.into_element()])) + .child(divider()) .child(section("SQL Console", vec![SqlConsole.into_element()])) .child(divider()) .child(section( @@ -112,6 +114,92 @@ impl Component for Debug { } } +#[derive(PartialEq)] +struct ClusterList; + +impl Component for ClusterList { + fn render(&self) -> impl IntoElement { + let dispatch = use_dispatch(); + let active_id = use_active_cluster_id(); + let clusters = settled_or_loading(&use_clusters()).unwrap_or_default(); + + let mut list = rect().vertical().width(Size::fill()).spacing(8.); + + if clusters.is_empty() { + return list.child( + label() + .text("No clusters in the database.") + .font_size(13.) + .color(colors::fg_secondary()), + ); + } + + for cluster in clusters { + let cluster_id = cluster.id; + let launch = dispatch.clone(); + let mut active_id = active_id; + let loader_version = cluster + .mc_loader_version + .as_deref() + .map_or_else(String::new, |version| format!(" {version}")); + + list = list.child( + rect() + .horizontal() + .width(Size::fill()) + .cross_align(Alignment::Center) + .main_align(Alignment::SpaceBetween) + .spacing(12.) + .padding(Gaps::new_symmetric(10., 14.)) + .corner_radius(CornerRadius::new_all(10.)) + .background(colors::page_elevated()) + .child( + rect() + .vertical() + .child( + label() + .text(cluster.name.clone()) + .font_size(14.) + .color(colors::fg_primary()), + ) + .child( + label() + .text(format!( + "#{cluster_id} · {} · {}{loader_version} · {:?}", + cluster.mc_version, cluster.mc_loader, cluster.stage + )) + .font_size(12.) + .color(colors::fg_secondary()), + ), + ) + .child( + rect() + .horizontal() + .spacing(8.) + .child( + Button::new() + .primary() + .on_press(move |_| launch.launch_cluster(cluster_id)) + .text("Launch"), + ) + .child( + Button::new() + .secondary() + .on_press(move |_| { + *active_id.write() = Some(cluster_id); + let _ = RouterContext::get() + .push(Route::ClusterOverview { cluster_id }); + }) + .text("View"), + ), + ), + ); + } + + list + } +} + #[derive(PartialEq)] struct ToastController; diff --git a/packages/oneclient_app/src/view/app/settings/accounts.rs b/packages/oneclient_app/src/view/app/settings/accounts.rs index 70587634..9065601d 100644 --- a/packages/oneclient_app/src/view/app/settings/accounts.rs +++ b/packages/oneclient_app/src/view/app/settings/accounts.rs @@ -38,11 +38,29 @@ impl Component for SettingsAccounts { let remove = use_remove_account(); let refresh = use_refresh_account(); - let username = use_state(String::new); + let mut username = use_state(String::new); let mut show_offline = use_state(|| false); + let mut closing_offline = use_state(|| false); let edit_account = use_state(|| None::<(Uuid, String)>); let edit_name = use_state(String::new); + use_side_effect(move || { + if !*closing_offline.read() { + return; + } + match &*add_offline.read().state() { + MutationStateData::Settled { res: Ok(_), .. } => { + closing_offline.set(false); + show_offline.set(false); + username.set(String::new()); + } + MutationStateData::Settled { res: Err(_), .. } => { + closing_offline.set(false); + } + _ => {} + } + }); + let accounts = try_accounts(&accounts_query).unwrap_or_default(); let default_account = try_default_account(&default_query); let default_id = default_account.as_ref().map(|a| a.id); @@ -59,20 +77,8 @@ impl Component for SettingsAccounts { if name.is_empty() { return; } - let mut show_offline = show_offline; - let mut username = username; - spawn(async move { - let reader = add_offline - .mutate_async(AddOfflineAccountKeys { username: name }) - .await; - if matches!( - &*reader.state(), - MutationStateData::Settled { res: Ok(_), .. } - ) { - show_offline.set(false); - username.set(String::new()); - } - }); + add_offline.mutate(AddOfflineAccountKeys { username: name }); + closing_offline.set(true); }; let mut rows: Vec = accounts @@ -410,7 +416,7 @@ fn offline_dialog( .child(field_label("UUID")) .child( label() - .text(uuid_preview.unwrap_or_else(|| "—".to_string())) + .text(uuid_preview.unwrap_or_else(|| "-".to_string())) .font_size(12.) .color(colors::fg_secondary()), ), @@ -606,6 +612,7 @@ impl Component for AccountRow { Button::new() .ghost() .icon() + .tooltip("Refresh this account") .on_press(move |e: Event| { e.stop_propagation(); if *refreshing.peek() { @@ -634,6 +641,7 @@ impl Component for AccountRow { Button::new() .ghost() .icon() + .tooltip("Change Skin") .on_press(move |e: Event| { e.stop_propagation(); let _ = freya::router::RouterContext::get().push(crate::Route::AccountSkins {}); @@ -654,6 +662,7 @@ impl Component for AccountRow { .ghost() .icon() .enabled(is_offline) + .tooltip("Edit username") .on_press(move |e: Event| { e.stop_propagation(); edit_name_state.set(username_val.clone()); @@ -673,6 +682,7 @@ impl Component for AccountRow { Button::new() .ghost() .icon() + .tooltip("Remove account") .on_press(move |e: Event| { e.stop_propagation(); remove.mutate(RemoveAccountKeys { id }); diff --git a/packages/oneclient_app/src/view/app/settings/java.rs b/packages/oneclient_app/src/view/app/settings/java.rs index 3bf11880..6a229796 100644 --- a/packages/oneclient_app/src/view/app/settings/java.rs +++ b/packages/oneclient_app/src/view/app/settings/java.rs @@ -23,7 +23,7 @@ impl Component for SettingsJava { let mut show_manager = use_state(|| false); let pending_remove = use_state(|| None::); - #[allow(dead_code)] + #[allow(dead_code, unused_variables)] fn invalidate_runtimes(dispatch: Actions) { spawn(async move { invalidate_java_queries().await; diff --git a/packages/oneclient_app/src/view/app/settings/minecraft.rs b/packages/oneclient_app/src/view/app/settings/minecraft.rs index 19d7de5b..a1f0e559 100644 --- a/packages/oneclient_app/src/view/app/settings/minecraft.rs +++ b/packages/oneclient_app/src/view/app/settings/minecraft.rs @@ -1,24 +1,19 @@ use freya::prelude::*; use oneclient_common::Patch; use oneclient_core::settings::{PackageUpdateMode, ProfileUpdate, Resolution}; -#[cfg(windows)] -use oneclient_core::settings::LauncherSettings; +#[cfg(any(target_os = "linux", windows))] +use oneclient_core::settings::SettingsOsExtra; use super::settings_page; use crate::components::{ Dropdown, Icon, IconType, TextInput, memory_field, toggle, validate_number, }; -#[cfg(windows)] +#[cfg(any(target_os = "linux", windows))] use crate::components::toggle_controlled; use crate::hooks::{use_dispatch, use_settings_snapshot}; use crate::theme::colors; use crate::view::app::settings::{section_header, settings_row}; -#[cfg(target_os = "linux")] -use crate::components::toggle_controlled; -#[cfg(target_os = "linux")] -use oneclient_core::settings::SettingsOsExtra; - #[derive(PartialEq)] pub struct SettingsMinecraft; @@ -105,7 +100,7 @@ impl Component for SettingsMinecraft { IconType::Database01, "Memory", "The amount of memory in megabytes allocated for the game. Presets leave 2 GB for the system.", - memory_field(memory), + memory_field(memory, "Default", oneclient_common::default_mem_max()), )) .child(settings_row( IconType::Terminal, @@ -151,15 +146,7 @@ impl Component for SettingsMinecraft { .width(Size::px(220.)), )); - #[cfg(windows)] - let page = page.child(settings_row( - IconType::Rocket02, - "Prefer Dedicated GPU", - "Ask Windows to run Java on the high-performance GPU.", - discrete_gpu_field(settings, dispatch.clone()), - )); - - #[cfg(target_os = "linux")] + #[cfg(any(target_os = "linux", windows))] let page = page .child(section_header("GRAPHICS")) .child(discrete_gpu_row(profile.os_extra.clone(), dispatch)); @@ -168,7 +155,7 @@ impl Component for SettingsMinecraft { } } -#[cfg(target_os = "linux")] +#[cfg(any(target_os = "linux", windows))] fn discrete_gpu_row( os_extra: Option, dispatch: crate::Actions, @@ -231,20 +218,6 @@ fn build_update( } } -/// A launcher setting rather than a profile field, so it bypasses [`build_update`] -#[cfg(windows)] -fn discrete_gpu_field(settings: LauncherSettings, dispatch: crate::Actions) -> impl IntoElement { - let on = settings.use_discrete_gpu; - let on_toggle: EventHandler<()> = (move |()| { - let mut next = settings.clone(); - next.use_discrete_gpu = !on; - dispatch.set_settings(next); - }) - .into(); - - toggle_controlled(on, on_toggle) -} - /// Dispatched separately from [`build_update`] which debounces keystrokes a dropdown has no intermediate states fn update_mode_field( selected: PackageUpdateMode, diff --git a/packages/oneclient_app/src/view/app/settings/storage.rs b/packages/oneclient_app/src/view/app/settings/storage.rs index 85d6015f..28b9f99f 100644 --- a/packages/oneclient_app/src/view/app/settings/storage.rs +++ b/packages/oneclient_app/src/view/app/settings/storage.rs @@ -5,7 +5,9 @@ use super::{section_header, settings_page}; use crate::components::{Button, Icon, IconType, open_folder_button}; use crate::hooks::{ StorageAction, mutation_is_running, try_storage_report, use_storage_action, use_storage_report, + use_storage_scan_progress, }; +use crate::state::StorageScanProgress; use crate::theme::colors; use crate::utils::plural; @@ -19,9 +21,12 @@ impl Component for SettingsStorage { fn render(&self) -> impl IntoElement { // Every hook before any early return the report is absent on the first render and a later-only hook would change the hook order let report_query = use_storage_report(); + let scan = use_storage_scan_progress(); let Some(report) = try_storage_report(&report_query) else { - return settings_page().child(hero_placeholder()).into_element(); + return settings_page() + .child(scan_card(scan.as_ref())) + .into_element(); }; let refresh = Button::new() @@ -32,9 +37,13 @@ impl Component for SettingsStorage { }) .child(label().text("Refresh")); - settings_page() - .child(hero(&report, refresh.into_element())) - .child(section_header("FREE UP SPACE")) + let mut page = settings_page().child(hero(&report, refresh.into_element())); + + if scan.is_some() { + page = page.child(scan_card(scan.as_ref())); + } + + page.child(section_header("FREE UP SPACE")) .child( ReclaimRow { icon: IconType::FileX02, @@ -123,18 +132,47 @@ fn hero(report: &StorageReport, refresh: Element) -> impl IntoElement { .into_element() } -fn hero_placeholder() -> impl IntoElement { - rect() +// the first-load placeholder and the strip shown while a refresh rescans +fn scan_card(scan: Option<&StorageScanProgress>) -> impl IntoElement { + let counting = scan.filter(|scan| scan.total > 0); + let fraction = counting.map_or(0.0, |scan| scan.current as f32 / scan.total as f32); + + let mut header = rect() + .horizontal() .width(Size::fill()) - .padding(Gaps::new_symmetric(20., 16.)) + .content(Content::Flex) + .cross_align(Alignment::Center) + .spacing(12.) + .child( + rect().width(Size::flex(1.0)).child( + label() + .text(scan.map_or_else( + || "Measuring disk usage…".to_string(), + |scan| format!("{}…", scan.label), + )) + .font_size(14.) + .color(colors::fg_secondary()), + ), + ); + + if let Some(scan) = counting { + header = header.child( + label() + .text(format!("{} / {}", scan.current, scan.total)) + .font_size(12.) + .color(colors::fg_secondary()), + ); + } + + rect() + .vertical() + .width(Size::fill()) + .spacing(10.) + .padding(Gaps::new_symmetric(16., 16.)) .corner_radius(CornerRadius::new_all(12.)) .background(colors::page_elevated()) - .child( - label() - .text("Measuring disk usage…") - .font_size(16.) - .color(colors::fg_secondary()), - ) + .child(header) + .child(proportion_bar(fraction, colors::brand())) .into_element() } diff --git a/packages/oneclient_app/src/view/onboarding/bundles/row.rs b/packages/oneclient_app/src/view/onboarding/bundles/row.rs index 479bf341..705696ae 100644 --- a/packages/oneclient_app/src/view/onboarding/bundles/row.rs +++ b/packages/oneclient_app/src/view/onboarding/bundles/row.rs @@ -4,7 +4,7 @@ use oneclient_content::packages::ProviderId; use crate::components::{Icon, provider_badge}; use crate::hooks::{loaded_image, use_cached_image}; use crate::theme::colors; -use crate::ui::border_all_color; +use crate::ui::{ImageFallbackExt, border_all_color}; use crate::utils::format_size; const CARD_BG: Color = Color::from_rgb(26, 34, 41); @@ -36,6 +36,7 @@ impl Component for OnboardingModCard { .height(Size::px(44.)) .aspect_ratio(AspectRatio::Min) .corner_radius(CornerRadius::new_all(8.)) + .fallback(icon_box(self.provider)) .into_element(), None => icon_box(self.provider).into_element(), diff --git a/packages/oneclient_app/src/view/onboarding/downloading/mod.rs b/packages/oneclient_app/src/view/onboarding/downloading/mod.rs index 4232460e..349bf007 100644 --- a/packages/oneclient_app/src/view/onboarding/downloading/mod.rs +++ b/packages/oneclient_app/src/view/onboarding/downloading/mod.rs @@ -44,7 +44,7 @@ struct ClusterPlan { mc_version: String, /// `(bundle_name, package_id, override)` for every file whose fate differs from the manifest default overrides: Vec<(String, String, OverrideType)>, - /// Manifest `predownload` flag versions without it fetch on first launch + /// Manifest `predownload` flag versions without it fetch their content on first launch predownload: bool, } @@ -614,9 +614,6 @@ fn archive_overrides( overrides } -const GAME_SIZE_GUESS: u64 = 180_000_000; -const JRE_SIZE_GUESS: u64 = 45_000_000; - fn rough_download_estimate( items: &[ClusterBundles], selected: &std::collections::HashSet, @@ -638,7 +635,6 @@ fn rough_download_estimate( } } } - total += GAME_SIZE_GUESS + JRE_SIZE_GUESS; } total } @@ -736,10 +732,10 @@ fn run_install_batch(plans: Vec, predownload: bool, handles: Instal if let Ok(state) = crate::launcher::state() { let mut sum = 0u64; for plan in plans.iter().filter(|p| p.predownload) { - match oneclient_core::estimate_cluster_download( - &state, + match oneclient_content::bundles::enabled_bundle_bytes( plan.cluster_id, state.bundles.as_ref(), + &state.services.content(), ) .await { @@ -814,39 +810,20 @@ async fn install_one( GroupedProgressSession::start(events, format!("Downloading {}", plan.mc_version)); let _ = ui_tx.send(InstallUiEvent::Activity(format!( - "Downloading Minecraft {}...", + "Installing mods & content for {}...", plan.mc_version ))); - let prepared = oneclient_core::clusters::prepare_cluster_locked( - &state, + let bundles_result = oneclient_core::install_cluster_bundles( plan.cluster_id, - false, - true, - true, + state.bundles.as_ref(), Some(&session), + &state.services.content(), ) .await; - let bundles_result = if prepared.is_ok() { - let _ = ui_tx.send(InstallUiEvent::Activity(format!( - "Installing mods & content for {}...", - plan.mc_version - ))); - oneclient_core::install_cluster_bundles( - plan.cluster_id, - state.bundles.as_ref(), - Some(&session), - &state.services.content(), - ) - .await - } else { - Ok(()) - }; - session.finish(); - prepared?; bundles_result?; Ok(()) } @@ -1097,20 +1074,18 @@ mod tests { fn estimate_skips_declined_bundles_and_counts_opted_in_extras() { let sb = skyblock(); let all = items(vec![sb.clone()]); - let baseline = GAME_SIZE_GUESS + JRE_SIZE_GUESS; - let declined = rough_download_estimate(&all, &HashSet::new(), &versions()); - assert_eq!(declined, baseline, "declined bundle should cost nothing"); + assert_eq!(declined, 0, "declined bundle should cost nothing"); let accepted = rough_download_estimate(&all, &keys(&sb, &["skyblock-main"]), &versions()); - assert_eq!(accepted, baseline + 2); + assert_eq!(accepted, 2); let with_extra = rough_download_estimate( &all, &keys(&sb, &["skyblock-main", "skycubed"]), &versions(), ); - assert_eq!(with_extra, baseline + 3); + assert_eq!(with_extra, 3); } #[test] diff --git a/packages/oneclient_app/src/view/onboarding/downloading/progress.rs b/packages/oneclient_app/src/view/onboarding/downloading/progress.rs index d7b5920a..f9246e4f 100644 --- a/packages/oneclient_app/src/view/onboarding/downloading/progress.rs +++ b/packages/oneclient_app/src/view/onboarding/downloading/progress.rs @@ -3,7 +3,7 @@ use super::*; use crate::components::{Button, progress_track}; use crate::theme::colors; use crate::ui::border_all_color; -use crate::utils::{format_duration_hms, format_size}; +use crate::utils::{format_durations, format_size}; pub(super) fn failure_panel( failures: &[InstallFailure], @@ -244,7 +244,7 @@ fn build_stats(view: StatsView) -> Option { let elapsed = elapsed_secs?; let finished = !running && done >= total && total > 0; - let mut parts: Vec = vec![format!("Elapsed {}", format_duration_hms(elapsed as i64))]; + let mut parts: Vec = vec![format!("Elapsed {}", format_durations(elapsed as i64))]; if finished { parts.push("Complete".to_string()); @@ -253,7 +253,7 @@ fn build_stats(view: StatsView) -> Option { match transfer.and_then(|t| t.eta_secs) { Some(remaining) => parts.push(format!( "~{} left", - format_duration_hms(remaining as i64) + format_durations(remaining as i64) )), None => parts.push("~- left".to_string()), } diff --git a/packages/oneclient_app/src/view/onboarding/mod.rs b/packages/oneclient_app/src/view/onboarding/mod.rs index 72d1e390..8cb1ec22 100644 --- a/packages/oneclient_app/src/view/onboarding/mod.rs +++ b/packages/oneclient_app/src/view/onboarding/mod.rs @@ -225,7 +225,7 @@ pub(crate) fn predownload_toggle_row(predownload: State) -> impl IntoEleme .spacing(3.) .child( label() - .text("Download content now") + .text("Download mods now") .font_size(14.) .font_weight(FontWeight::MEDIUM) .color(colors::fg_primary()), @@ -233,9 +233,9 @@ pub(crate) fn predownload_toggle_row(predownload: State) -> impl IntoEleme .child( label() .text( - "Downloads Minecraft and your chosen mods now, so you can play as \ - soon as setup finishes. Turn it off to download each version the \ - first time you play it.", + "Downloads your chosen mods now. Minecraft itself is always \ + downloaded the first time you play a version. Turn this off to \ + leave the mods until then too.", ) .font_size(11.) .color(colors::fg_secondary()), diff --git a/packages/oneclient_cluster/src/cluster.rs b/packages/oneclient_cluster/src/cluster.rs index 4c6111bc..60a89fa7 100644 --- a/packages/oneclient_cluster/src/cluster.rs +++ b/packages/oneclient_cluster/src/cluster.rs @@ -15,6 +15,23 @@ use crate::stage::ClusterStage; pub use oneclient_common::paths::DEDICATED_MARKER; +// takes a cluster out of the shared `mods` folder +pub async fn remove_mods_link(folder_name: &str) { + let Ok(link) = paths::shared_mods_link(folder_name) else { + return; + }; + + match polyio::symlink_metadata(&link).await { + Ok(meta) if meta.file_type().is_symlink() => { + if let Err(err) = polyio::remove_symlink_dir(&link).await { + tracing::warn!(folder = folder_name, error = %err, "failed to clear cluster mods link"); + } + } + + _ => {} + } +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Cluster { pub id: ClusterId, diff --git a/packages/oneclient_cluster/src/error.rs b/packages/oneclient_cluster/src/error.rs index a4bb87de..1f9cf0f1 100644 --- a/packages/oneclient_cluster/src/error.rs +++ b/packages/oneclient_cluster/src/error.rs @@ -28,6 +28,9 @@ pub enum ClusterError { #[error("invalid settings profile: {reason}")] InvalidProfile { reason: String }, + #[error("invalid minecraft version: {0}")] + InvalidVersion(String), + #[error(transparent)] Logs(#[from] crate::logs::LogsError), @@ -46,9 +49,6 @@ pub enum ClusterError { #[error("cluster is in {0} stage")] Busy(ClusterStage), - #[error("invalid Minecraft version '{0}'")] - InvalidVersion(String), - #[error("cluster is missing a required Java version")] MissingJavaVersion, diff --git a/packages/oneclient_cluster/src/lib.rs b/packages/oneclient_cluster/src/lib.rs index 825353fb..904113bd 100644 --- a/packages/oneclient_cluster/src/lib.rs +++ b/packages/oneclient_cluster/src/lib.rs @@ -13,7 +13,7 @@ pub mod logs; pub mod profiles; pub mod screenshots; -pub use cluster::{Cluster, ClusterLinkTarget}; +pub use cluster::{Cluster, ClusterLinkTarget, remove_mods_link}; pub use error::{ClusterError, ClusterResult}; pub use manager::ClusterManager; pub use options::{ClusterUpdate, CreateClusterOptions}; diff --git a/packages/oneclient_cluster/src/manager.rs b/packages/oneclient_cluster/src/manager.rs index 489d625d..bc3b719a 100644 --- a/packages/oneclient_cluster/src/manager.rs +++ b/packages/oneclient_cluster/src/manager.rs @@ -13,7 +13,7 @@ use crate::error::ClusterResult; use oneclient_db::DbPool; use tokio::sync::Mutex; -use crate::cluster::Cluster; +use crate::cluster::{Cluster, remove_mods_link}; use crate::error::ClusterError; use crate::options::{ClusterUpdate, CreateClusterOptions}; use crate::stage::ClusterStage; @@ -168,6 +168,8 @@ impl ClusterManager { return Err(ClusterError::NotFound(cluster_id)); } + remove_mods_link(&cluster.folder_name).await; + if remove_files { let path = cluster.dir()?; if path.exists() { diff --git a/packages/oneclient_cluster/src/profile.rs b/packages/oneclient_cluster/src/profile.rs index 07d7c86c..14c3b8c6 100644 --- a/packages/oneclient_cluster/src/profile.rs +++ b/packages/oneclient_cluster/src/profile.rs @@ -26,37 +26,25 @@ pub struct GameSettingsProfile { pub use oneclient_common::Resolution; pub use oneclient_common::domain::PackageUpdateMode; -cfg_select! { - target_os = "linux" => { - #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] - #[serde(default)] - pub struct SettingsOsExtra { - pub enable_gamemode: Option, - pub use_discrete_gpu: Option, - /// Keys this build has no field for, kept verbatim so a profile edited - /// on one OS does not clear another OS's settings - #[serde(flatten)] - pub unknown: serde_json::Map, - } +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct SettingsOsExtra { + #[cfg(target_os = "linux")] + pub enable_gamemode: Option, + #[cfg(any(target_os = "linux", windows))] + pub use_discrete_gpu: Option, + #[serde(flatten)] + pub unknown: serde_json::Map, +} - impl Default for SettingsOsExtra { - fn default() -> Self { - Self { - enable_gamemode: Some(true), - use_discrete_gpu: Some(false), - unknown: serde_json::Map::new(), - } - } - } - } - _ => { - #[derive(Default, Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] - #[serde(default)] - pub struct SettingsOsExtra { - /// Keys this build has no field for, kept verbatim so a profile edited - /// on one OS does not clear another OS's settings - #[serde(flatten)] - pub unknown: serde_json::Map, +impl Default for SettingsOsExtra { + fn default() -> Self { + Self { + #[cfg(target_os = "linux")] + enable_gamemode: Some(true), + #[cfg(any(target_os = "linux", windows))] + use_discrete_gpu: Some(true), + unknown: serde_json::Map::new(), } } } @@ -79,6 +67,17 @@ impl GameSettingsProfile { } } + pub fn use_discrete_gpu(&self) -> bool { + cfg_select! { + any(target_os = "linux", target_os = "windows") => self + .os_extra + .as_ref() + .and_then(|extra| extra.use_discrete_gpu) + .unwrap_or(false), + _ => false + } + } + pub fn is_global(&self) -> bool { self.name == GLOBAL_PROFILE_NAME } diff --git a/packages/oneclient_cluster/src/profiles.rs b/packages/oneclient_cluster/src/profiles.rs index 2a684fbf..ce442b66 100644 --- a/packages/oneclient_cluster/src/profiles.rs +++ b/packages/oneclient_cluster/src/profiles.rs @@ -76,9 +76,7 @@ pub async fn create_profile_from_global( let mut profile = global.clone(); profile.name = name.to_string(); - if let Some(mem) = mem_max { - profile.mem_max = Some(mem); - } + profile.mem_max = mem_max; if let Some(fullscreen) = force_fullscreen { profile.force_fullscreen = Some(fullscreen); diff --git a/packages/oneclient_common/src/domain.rs b/packages/oneclient_common/src/domain.rs index bf25db06..652e906f 100644 --- a/packages/oneclient_common/src/domain.rs +++ b/packages/oneclient_common/src/domain.rs @@ -56,6 +56,12 @@ impl ContentType { } } + // installed once for the whole launcher rather than per cluster + #[must_use] + pub const fn is_global(self) -> bool { + matches!(self, Self::ResourcePack | Self::Shader) + } + #[must_use] pub const fn reloads_in_game(self) -> bool { matches!(self, Self::ResourcePack | Self::Shader) @@ -197,7 +203,7 @@ pub enum GameLoader { NeoForge = 2, Quilt = 3, Fabric = 4, - LegacyFabric = 5, + Ornithe = 5, } impl GameLoader { @@ -212,7 +218,7 @@ impl GameLoader { Self::NeoForge => interfrost::api::modded::CURRENT_NEOFORGE_FORMAT_VERSION, Self::Quilt => interfrost::api::modded::CURRENT_QUILT_FORMAT_VERSION, Self::Fabric => interfrost::api::modded::CURRENT_FABRIC_FORMAT_VERSION, - Self::LegacyFabric => interfrost::api::modded::CURRENT_LEGACY_FABRIC_FORMAT_VERSION, + Self::Ornithe => interfrost::api::modded::CURRENT_ORNITHE_FORMAT_VERSION, } } @@ -220,12 +226,19 @@ impl GameLoader { match self { Self::Vanilla => String::from("minecraft"), Self::NeoForge => String::from("neo"), + Self::Ornithe => String::from("ornithe"), _ => self.to_string().to_lowercase().replace(['_', '.', ' '], ""), } } pub const fn modded_loaders() -> &'static [Self] { - &[Self::Forge, Self::NeoForge, Self::Quilt, Self::Fabric] + &[ + Self::Forge, + Self::NeoForge, + Self::Quilt, + Self::Fabric, + Self::Ornithe, + ] } pub fn compatible_with(self, other: Self) -> bool { @@ -235,7 +248,7 @@ impl GameLoader { Self::NeoForge => other == Self::NeoForge, Self::Quilt => matches!(other, Self::Quilt | Self::Fabric), Self::Fabric => other == Self::Fabric, - Self::LegacyFabric => other == Self::LegacyFabric, + Self::Ornithe => other == Self::Ornithe, } } @@ -246,7 +259,7 @@ impl GameLoader { Self::NeoForge => "neoforge", Self::Quilt => "quilt", Self::Fabric => "fabric", - Self::LegacyFabric => "legacy-fabric", + Self::Ornithe => "ornithe", } } } @@ -262,7 +275,7 @@ impl FromStr for GameLoader { "neoforge" | "neo" => Self::NeoForge, "quilt" | "quiltloader" => Self::Quilt, "fabric" | "fabricloader" => Self::Fabric, - "legacyfabric" => Self::LegacyFabric, + "ornithe" => Self::Ornithe, _ => return Err(format!("'{s}' is not a valid game loader")), }, ) @@ -277,7 +290,7 @@ impl Display for GameLoader { Self::NeoForge => "NeoForge", Self::Quilt => "Quilt", Self::Fabric => "Fabric", - Self::LegacyFabric => "Legacy Fabric", + Self::Ornithe => "Fabric (Ornithe)", }) } } @@ -302,3 +315,19 @@ impl Resolution { Self { width, height } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_display_name_is_not_a_metadata_path() { + assert_eq!(GameLoader::Ornithe.to_string(), "Fabric (Ornithe)"); + assert_eq!(GameLoader::Ornithe.get_format_name(), "ornithe"); + assert_eq!(GameLoader::Vanilla.get_format_name(), "minecraft"); + assert_eq!(GameLoader::NeoForge.get_format_name(), "neo"); + assert_eq!(GameLoader::Fabric.get_format_name(), "fabric"); + assert_eq!(GameLoader::Quilt.get_format_name(), "quilt"); + assert_eq!(GameLoader::Forge.get_format_name(), "forge"); + } +} diff --git a/packages/oneclient_common/src/paths.rs b/packages/oneclient_common/src/paths.rs index bdf0ecf2..d537982f 100644 --- a/packages/oneclient_common/src/paths.rs +++ b/packages/oneclient_common/src/paths.rs @@ -151,6 +151,22 @@ pub fn cluster_game_dir(folder_name: &str) -> PathsResult { } } +pub fn cluster_mods_dir(folder_name: &str) -> PathsResult { + Ok(cluster_dir(folder_name)?.join(ContentType::Mod.folder_name())) +} + +pub fn shared_mods_dir() -> PathsResult { + Ok(shared_minecraft_dir()?.join(ContentType::Mod.folder_name())) +} + +pub fn global_content_dir(content_type: ContentType) -> PathsResult { + Ok(shared_minecraft_dir()?.join(content_type.folder_name())) +} + +pub fn shared_mods_link(folder_name: &str) -> PathsResult { + Ok(shared_mods_dir()?.join(folder_name)) +} + pub fn packages_cache_dir() -> PathsResult { Ok(data_dir()?.join("metadata").join("packages")) } diff --git a/packages/oneclient_content/src/bundles/install.rs b/packages/oneclient_content/src/bundles/install.rs index df8f1693..496fe527 100644 --- a/packages/oneclient_content/src/bundles/install.rs +++ b/packages/oneclient_content/src/bundles/install.rs @@ -1,5 +1,7 @@ use futures_util::StreamExt; +use oneclient_db::dao::applied_migration as migration_dao; use oneclient_db::dao::artifact as artifact_dao; +use oneclient_db::dao::cluster as cluster_dao; use oneclient_db::dao::cluster_bundle as bundle_dao; use oneclient_db::models::ClusterRow; use oneclient_db::models::OverrideType; @@ -201,12 +203,54 @@ pub(crate) fn disable_was_deliberate(suppression: Option) -> bool } } +const DUPLICATE_DISABLE_REPAIR: &str = "repair-hidden-duplicate-disables"; + +async fn clear_reconciler_disables( + cluster_id: i64, + archives: &[BundleArchive], + ctx: &ContentCtx, +) -> ContentResult<()> { + let id = format!("{DUPLICATE_DISABLE_REPAIR}:{cluster_id}"); + if migration_dao::is_applied(&ctx.db, &id).await? { + return Ok(()); + } + + let hidden: std::collections::HashSet = archives + .iter() + .flat_map(|archive| &archive.manifest.files) + .filter(|file| file.hidden) + .map(|file| file.kind.package_id()) + .collect(); + + if hidden.is_empty() { + return Ok(()); + } + + let mut cleared = 0u64; + for package_id in &hidden { + cleared += bundle_dao::clear_disabled_overrides(&ctx.db, cluster_id, package_id).await?; + } + + if cleared > 0 { + tracing::info!( + cluster_id, + cleared, + "cleared disabled overrides on hidden bundle files that no user was ever shown" + ); + } + + migration_dao::mark_applied(&ctx.db, &id).await?; + Ok(()) +} + #[tracing::instrument(level = "debug", skip(archives, ctx))] pub async fn heal_bundle_activity( cluster_id: i64, archives: &[BundleArchive], ctx: &ContentCtx, ) -> ContentResult<()> { + clear_reconciler_disables(cluster_id, archives, ctx).await?; + let tracked = bundle_dao::list_bundle_tracked(&ctx.db, cluster_id).await?; if tracked.iter().all(|row| row.enabled != 0) { return Ok(()); @@ -225,10 +269,20 @@ pub async fn heal_bundle_activity( let overrides = bundle_dao::list_overrides(&ctx.db, cluster_id).await?; + let mut live_packages: std::collections::HashSet = tracked + .iter() + .filter(|row| row.enabled != 0) + .filter_map(|row| row.package_id.clone()) + .collect(); + for row in tracked.iter().filter(|row| row.enabled == 0) { let (Some(bundle_name), Some(package_id)) = (&row.bundle_name, &row.package_id) else { continue; }; + + if live_packages.contains(package_id) { + continue; + } let Some(is_hidden) = hidden .get(&(bundle_name.as_str(), package_id.clone())) .copied() @@ -260,6 +314,8 @@ pub async fn heal_bundle_activity( tracing::warn!(hash = %row.hash, error = %err, "failed to re-enable bundle content"); continue; } + + live_packages.insert(package_id.clone()); } Ok(()) @@ -583,27 +639,45 @@ pub async fn set_artifact_enabled_to( Ok(live) } -/// Writes the override alongside the flag -/// without it the losing bundle copy looks disabled-by-nobody and -/// heal_bundle_activity re-enables it every launch -#[tracing::instrument(level = "debug", skip(ctx))] -pub async fn reconcile_duplicate_activity( +// which clusters have to record what the user just did +async fn override_scope( cluster_id: i64, + hash: &str, ctx: &ContentCtx, -) -> ContentResult<()> { - for hash in crate::packages::reconcile_duplicate_activity(cluster_id, ctx).await? { - on_user_disable_artifact(cluster_id, &hash, ctx).await?; +) -> ContentResult> { + let global = artifact_dao::get_artifact_by_hash(&ctx.db, hash) + .await? + .and_then(|artifact| ContentType::from_repr(artifact.content_type as u8)) + .is_some_and(ContentType::is_global); + + if !global { + return Ok(vec![cluster_id]); } - Ok(()) + let mut ids: Vec = cluster_dao::list_all(&ctx.db) + .await? + .into_iter() + .map(|row| row.id) + .collect(); + + if !ids.contains(&cluster_id) { + ids.push(cluster_id); + } + + Ok(ids) } +#[tracing::instrument(level = "debug", skip(ctx))] pub async fn on_user_disable_artifact( cluster_id: i64, hash: &str, ctx: &ContentCtx, ) -> ContentResult<()> { - handle_user_artifact_action(cluster_id, hash, ctx, OverrideType::Disabled).await + for id in override_scope(cluster_id, hash, ctx).await? { + handle_user_artifact_action(id, hash, ctx, OverrideType::Disabled).await?; + } + + Ok(()) } #[tracing::instrument(level = "debug", skip(ctx))] @@ -612,10 +686,13 @@ pub async fn on_user_enable_artifact( hash: &str, ctx: &ContentCtx, ) -> ContentResult<()> { - if let Some(tracked) = bundle_dao::get_bundle_tracked(&ctx.db, cluster_id, hash).await? - && let Some(package_id) = tracked.package_id { - clear_suppressing_overrides(cluster_id, &package_id, ctx).await?; - } + for id in override_scope(cluster_id, hash, ctx).await? { + if let Some(tracked) = bundle_dao::get_bundle_tracked(&ctx.db, id, hash).await? + && let Some(package_id) = tracked.package_id { + clear_suppressing_overrides(id, &package_id, ctx).await?; + } + } + Ok(()) } @@ -672,13 +749,19 @@ pub async fn remove_artifact_from_cluster( artifact_dao::unlink_cluster_artifact(&ctx.db, cluster_id, hash).await?; // Best-effort folder cleanup failure here is not an error - if let (Some(content_type), Some(link)) = (target, link) { - try_unlink_materialized(&cluster, content_type, &link.cluster_file_name).await; - } + let deferred = match (target, link) { + (Some(content_type), Some(link)) => { + try_unlink_materialized(&cluster, content_type, &link.cluster_file_name).await + == LiveSync::Deferred + } + _ => false, + }; // The package actually lives in the cache // `evict_if_unused` drops it only once no other cluster still needs it - if let Err(err) = evict_if_unused(hash, ctx).await { + if !deferred + && let Err(err) = evict_if_unused(hash, ctx).await + { tracing::warn!(hash, error = %err, "failed to evict unused artifact from the cache"); } diff --git a/packages/oneclient_content/src/bundles/mod.rs b/packages/oneclient_content/src/bundles/mod.rs index 5270378c..965411f1 100644 --- a/packages/oneclient_content/src/bundles/mod.rs +++ b/packages/oneclient_content/src/bundles/mod.rs @@ -16,7 +16,6 @@ pub use install::{ install_cluster_bundles, install_enabled_bundle_files, install_package_from_bundle, list_cluster_bundle_overrides, on_user_disable_artifact, on_user_enable_artifact, on_user_remove_artifact, - reconcile_duplicate_activity, remove_artifact_from_cluster, set_artifact_enabled_to, set_bundle_package_enabled, set_bundle_package_opt_in, set_bundle_package_override, set_bundle_package_overrides, diff --git a/packages/oneclient_content/src/bundles/types.rs b/packages/oneclient_content/src/bundles/types.rs index d535924c..41c207e2 100644 --- a/packages/oneclient_content/src/bundles/types.rs +++ b/packages/oneclient_content/src/bundles/types.rs @@ -150,6 +150,17 @@ pub struct ApplyBundleUpdatesResult { pub updates_failed: Vec, pub removals_failed: Vec, pub additions_failed: Vec, + pub stopped_early: bool, +} + +impl ApplyBundleUpdatesResult { + #[must_use] + pub fn settled(&self) -> bool { + !self.stopped_early + && self.updates_failed.is_empty() + && self.removals_failed.is_empty() + && self.additions_failed.is_empty() + } } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] diff --git a/packages/oneclient_content/src/bundles/updates.rs b/packages/oneclient_content/src/bundles/updates.rs index 598753fd..6806b610 100644 --- a/packages/oneclient_content/src/bundles/updates.rs +++ b/packages/oneclient_content/src/bundles/updates.rs @@ -1,6 +1,7 @@ use std::cmp::Ordering; use std::collections::{HashMap, HashSet}; use std::sync::{Arc, Mutex, OnceLock}; +use std::time::Instant; use oneclient_db::dao::artifact as artifact_dao; use oneclient_db::dao::cluster as cluster_dao; @@ -418,16 +419,21 @@ pub async fn apply_bundle_updates( cluster_id: i64, bundles: &BundlesManager, ctx: &ContentCtx, + deadline: Option, ) -> ContentResult { let session = oneclient_events::GroupedProgressSession::start( &ctx.events, "Updating bundle content", ); - let result = apply_bundle_updates_with(cluster_id, bundles, ctx, Some(&session)).await; + let result = apply_bundle_updates_with(cluster_id, bundles, ctx, Some(&session), deadline).await; session.finish(); result } +fn past(deadline: Option) -> bool { + deadline.is_some_and(|deadline| Instant::now() >= deadline) +} + /// The caller owns `session`'s lifetime /// sharing one across clusters makes a batch surface as a single notification #[tracing::instrument(skip(bundles, ctx, session))] @@ -436,6 +442,7 @@ pub async fn apply_bundle_updates_with( bundles: &BundlesManager, ctx: &ContentCtx, session: Option<&oneclient_events::GroupedProgressSession>, + deadline: Option, ) -> ContentResult { let lock = cluster_lock(cluster_id); let _guard = lock.lock().await; @@ -465,6 +472,11 @@ pub async fn apply_bundle_updates_with( let mut result = ApplyBundleUpdatesResult::default(); for removal in check.removals_available { + if past(deadline) { + result.stopped_early = true; + break; + } + match remove_artifact_from_cluster(cluster_id, &removal.hash, false, ctx).await { Ok(()) => result.removals_applied.push(removal), Err(err) => result @@ -486,11 +498,13 @@ pub async fn apply_bundle_updates_with( s.expect(oneclient_events::TaskCategory::Packages, count, bytes); } - // Fetch concurrently reconcile in order - // splitting the two keeps the fan-out safe since no artifact is unlinked - // while another download still needs it + let overrides_ref = &overrides; let fetched_updates = futures_util::stream::iter(check.updates_available.into_iter().map( |update| async move { + if past(deadline) { + return (update, None); + } + let child = session.map(|s| { let c = s.child( update.new_file.display_name(), @@ -509,29 +523,37 @@ pub async fn apply_bundle_updates_with( ctx, ) .await; - if let Some(child) = child { + if let Some(child) = &child { child.set_phase(oneclient_events::TaskPhase::Installing); + } + let applied = match installed { + Ok(hash) => reconcile_update(&update, &hash, overrides_ref, ctx).await, + Err(err) => Err(err), + }; + if let Some(child) = child { child.finish(); } - (update, installed) + (update, Some(applied)) }, )) .buffer_unordered(BUNDLE_INSTALL_CONCURRENCY) .collect::>() .await; - for (update, installed) in fetched_updates { - match installed { - Ok(hash) => match reconcile_update(&update, &hash, &overrides, ctx).await { - Ok(()) => result.updates_applied.push(update), - Err(err) => result.updates_failed.push(err.to_string()), - }, - Err(err) => result.updates_failed.push(err.to_string()), + for (update, applied) in fetched_updates { + match applied { + Some(Ok(())) => result.updates_applied.push(update), + Some(Err(err)) => result.updates_failed.push(err.to_string()), + None => result.stopped_early = true, } } let fetched_additions = futures_util::stream::iter(check.additions_available.into_iter().map( |addition| async move { + if past(deadline) { + return (addition, None); + } + let child = session.map(|s| { let c = s.child( addition.new_file.display_name(), @@ -550,27 +572,31 @@ pub async fn apply_bundle_updates_with( ctx, ) .await; - if let Some(child) = child { + if let Some(child) = &child { child.set_phase(oneclient_events::TaskPhase::Installing); + } + let applied = match installed { + Ok(hash) => reconcile_addition(&addition, &hash, overrides_ref, ctx).await, + Err(err) => Err(err), + }; + if let Some(child) = child { child.finish(); } - (addition, installed) + (addition, Some(applied)) }, )) .buffer_unordered(BUNDLE_INSTALL_CONCURRENCY) .collect::>() .await; - for (addition, installed) in fetched_additions { - let file_id = addition.new_file.kind.package_id(); - match installed { - Ok(hash) => { - match reconcile_addition(&addition, &hash, &overrides, ctx).await { - Ok(()) => result.additions_applied.push(addition), - Err(err) => result.additions_failed.push(format!("{file_id}: {err:#}")), - } + for (addition, applied) in fetched_additions { + match applied { + Some(Ok(())) => result.additions_applied.push(addition), + Some(Err(err)) => { + let file_id = addition.new_file.kind.package_id(); + result.additions_failed.push(format!("{file_id}: {err:#}")); } - Err(err) => result.additions_failed.push(format!("{file_id}: {err:#}")), + None => result.stopped_early = true, } } @@ -884,7 +910,7 @@ pub async fn apply_bundle_updates_for_all_clusters( ) -> ContentResult> { let mut changed = Vec::new(); for cluster in cluster_dao::list_all(&ctx.db).await? { - match apply_bundle_updates_with(cluster.id, bundles, ctx, session).await { + match apply_bundle_updates_with(cluster.id, bundles, ctx, session, None).await { Ok(result) => { if !result.updates_applied.is_empty() || !result.additions_applied.is_empty() diff --git a/packages/oneclient_content/src/packages/activity.rs b/packages/oneclient_content/src/packages/activity.rs index 3ed1e2f0..8bc344c4 100644 --- a/packages/oneclient_content/src/packages/activity.rs +++ b/packages/oneclient_content/src/packages/activity.rs @@ -4,7 +4,9 @@ use std::collections::HashMap; -use oneclient_common::domain::ProviderId; +use oneclient_common::domain::{ContentType, ProviderId}; +use oneclient_db::dao::applied_migration as migration_dao; +use oneclient_db::dao::artifact as artifact_dao; use crate::ctx::ContentCtx; use crate::error::ContentResult; @@ -89,6 +91,91 @@ fn group_duplicates( by_project.into_iter().collect() } +#[tracing::instrument(level = "debug", skip(ctx))] +pub async fn disable_foreign_game_versions( + cluster_id: i64, + mc_version: &str, + ctx: &ContentCtx, +) -> ContentResult> { + if mc_version.is_empty() { + return Ok(Vec::new()); + } + + let linked = PackageStore::list_linked_artifacts(cluster_id, ctx).await?; + let mut switched_off = Vec::new(); + + for info in linked + .iter() + .filter(|info| info.enabled && info.content_type == ContentType::Mod) + { + match switch_off_if_foreign(cluster_id, info, mc_version, ctx).await { + Ok(true) => switched_off.push( + info.display_name + .clone() + .unwrap_or_else(|| info.file_name.clone()), + ), + Ok(false) => {} + Err(err) => tracing::warn!( + cluster_id, + hash = %info.hash, + %err, + "could not switch off a mod built for another game version" + ), + } + } + + Ok(switched_off) +} + +const FOREIGN_VERSION_REPAIR: &str = "disable-foreign-game-version"; + +async fn switch_off_if_foreign( + cluster_id: i64, + info: &LinkedArtifactInfo, + mc_version: &str, + ctx: &ContentCtx, +) -> ContentResult { + let repair_id = format!("{FOREIGN_VERSION_REPAIR}:{cluster_id}:{mc_version}:{}", info.hash); + if migration_dao::is_applied(&ctx.db, &repair_id).await? { + return Ok(false); + } + + let stated = artifact_dao::list_release_game_versions(&ctx.db, &info.hash).await?; + if !built_for_another_game_version(&stated, mc_version) { + return Ok(false); + } + + tracing::info!( + cluster_id, + hash = %info.hash, + built_for = ?stated, + "switching off a mod built for another game version" + ); + + PackageStore::set_artifact_enabled_to(cluster_id, &info.hash, false, ctx).await?; + migration_dao::mark_applied(&ctx.db, &repair_id).await?; + + Ok(true) +} + +fn built_for_another_game_version(stated: &[String], mc_version: &str) -> bool { + !stated.is_empty() + && !stated.iter().any(|versions| { + let built_for: Vec = serde_json::from_str(versions).unwrap_or_default(); + covers_game_version(&built_for, mc_version) + }) +} + +fn covers_game_version(stated: &[String], mc_version: &str) -> bool { + stated.is_empty() + || stated.iter().any(|version| { + version == mc_version + || mc_version + .strip_prefix(version.as_str()) + .is_some_and(|rest| rest.starts_with('.')) + }) +} + /// `published_at` is RFC 3339 so string order matches time order /// undated copies lose to dated ones and an all-undated group still picks one fn newest<'a>(copies: impl IntoIterator) -> Option { @@ -165,6 +252,41 @@ mod tests { assert!(picked.is_some(), "a group with no dates must not go unresolved"); } + #[test] + fn only_a_stated_mismatch_switches_a_mod_off() { + let foreign = |rows: &[&str]| { + built_for_another_game_version( + &rows.iter().map(|r| (*r).to_string()).collect::>(), + "26.1.2", + ) + }; + + assert!(!foreign(&[]), "a jar no provider ever described is left alone"); + assert!(!foreign(&["[]"]), "an empty list is the provider saying nothing"); + assert!(!foreign(&["[\"1.21.11"]), "a list that will not parse says nothing"); + assert!(!foreign(&["[\"26.1.2\"]"])); + assert!( + !foreign(&["[\"1.21.11\",\"26.1.2\"]"]), + "a multi-version build stays" + ); + assert!( + !foreign(&["[\"1.21.11\"]", "[\"26.1.2\"]"]), + "one release row out of two is enough to keep it" + ); + assert!( + foreign(&["[\"1.21.11\"]"]), + "the jars the old stash dragged in have to go" + ); + assert!( + !foreign(&["[\"26.1\"]"]), + "a build for the release this cluster was migrated from stays" + ); + assert!( + foreign(&["[\"1.21.1\"]"]), + "a shorter version that is not a component prefix is still foreign" + ); + } + #[test] fn only_projects_with_several_copies_are_reconciled() { let groups = group_duplicates(&[ diff --git a/packages/oneclient_content/src/packages/dependencies.rs b/packages/oneclient_content/src/packages/dependencies.rs index 3d359c6a..44bd8c3d 100644 --- a/packages/oneclient_content/src/packages/dependencies.rs +++ b/packages/oneclient_content/src/packages/dependencies.rs @@ -193,15 +193,13 @@ fn choose_version( .cloned() } -/// Mirrors the store's install-time compatibility check -/// must stay in sync with it +#[must_use] +pub(crate) fn supports_game_version(game_versions: &[String], mc_version: &str) -> bool { + game_versions.is_empty() || game_versions.iter().any(|v| v == mc_version) +} + fn fits_cluster(version: &VersionSummary, cluster: &ClusterRow, loader: GameLoader) -> bool { - if !version.game_versions.is_empty() - && !version - .game_versions - .iter() - .any(|v| cluster.mc_version.contains(v)) - { + if !supports_game_version(&version.game_versions, &cluster.mc_version) { return false; } diff --git a/packages/oneclient_content/src/packages/mod.rs b/packages/oneclient_content/src/packages/mod.rs index 841dffde..b996a0a5 100644 --- a/packages/oneclient_content/src/packages/mod.rs +++ b/packages/oneclient_content/src/packages/mod.rs @@ -18,7 +18,7 @@ pub use metadata_cache::{ CachedPackageMeta, cached_project_detail, fetch_package_meta, get_version_cached, read_cached_package_meta, }; -pub use activity::reconcile_duplicate_activity; +pub use activity::{disable_foreign_game_versions, reconcile_duplicate_activity}; pub use dependencies::{ DependencyResolution, ResolvedDependency, resolve_required, resolves_dependencies, }; diff --git a/packages/oneclient_content/src/packages/store/link.rs b/packages/oneclient_content/src/packages/store/link.rs index c223c3e3..d1f1e432 100644 --- a/packages/oneclient_content/src/packages/store/link.rs +++ b/packages/oneclient_content/src/packages/store/link.rs @@ -87,44 +87,87 @@ pub async fn remove_entry(path: &Path) -> ContentResult<()> { Ok(()) } +async fn materialized_root( + cluster: &ClusterRow, + content_type: ContentType, +) -> Option<(PathBuf, &'static str)> { + if content_type.is_global() { + return paths::shared_minecraft_dir() + .ok() + .map(|dir| (dir, manifest::GLOBAL_MANIFEST_NAME)); + } + + if content_type == ContentType::Mod + && let Ok(dir) = paths::cluster_dir(&cluster.folder_name) + && manifest::mods_live_in_cluster(&dir).await + { + return Some((dir, manifest::MODS_MANIFEST_NAME)); + } + + paths::cluster_game_dir(&cluster.folder_name) + .ok() + .map(|dir| (dir, manifest::MANIFEST_NAME)) +} + +async fn session_owns(cluster: &ClusterRow) -> bool { + let Ok(game_dir) = paths::cluster_game_dir(&cluster.folder_name) else { + return false; + }; + + manifest::load(&game_dir, manifest::MANIFEST_NAME) + .await + .is_some_and(|session| session.cluster_id == cluster.id) +} + #[tracing::instrument(level = "debug", skip(cluster), fields(cluster_id = cluster.id))] pub async fn try_unlink_materialized( cluster: &ClusterRow, content_type: ContentType, file_name: &str, -) -> bool { - let Ok(game_dir) = paths::cluster_game_dir(&cluster.folder_name) else { - return false; +) -> LiveSync { + if !content_type.reloads_in_game() && session_owns(cluster).await { + tracing::debug!( + file = file_name, + "file will be moved at next launch due to an active session" + ); + return LiveSync::Deferred; + } + + let Some((root, manifest_name)) = materialized_root(cluster, content_type).await else { + return LiveSync::Deferred; }; let _guard = manifest::lock().await; - let Some(mut loaded) = manifest::load(&game_dir).await else { - return false; + let Some(mut loaded) = manifest::load(&root, manifest_name).await else { + return LiveSync::Skipped; }; - // The shared game dir belongs to whichever cluster played last - // touching a file we did not put there would delete another cluster's or - // the user's content let relative = manifest::entry_path(content_type.folder_name(), file_name); - if !loaded.owns(cluster.id, &relative) { - return false; + let ours = if content_type.is_global() { + loaded.contains(&relative) + } else { + loaded.owns(cluster.id, &relative) + }; + + if !ours { + return LiveSync::Skipped; } - let path = game_dir.join(content_type.folder_name()).join(file_name); + let path = root.join(&relative); if let Err(err) = remove_entry(&path).await { tracing::debug!( file = file_name, error = %err, "could not drop package from the game folder now; it goes at the next launch" ); - return false; + return LiveSync::Deferred; } loaded.entries.retain(|entry| entry.path != relative); - manifest::save(&game_dir, &loaded).await; + manifest::save(&root, manifest_name, &loaded).await; - true + LiveSync::Applied } #[tracing::instrument(level = "debug", skip(cluster, artifact), fields(cluster_id = cluster.id, hash = %artifact.hash))] @@ -141,7 +184,7 @@ pub async fn try_link_materialized( return LiveSync::Skipped; } - let Ok(game_dir) = paths::cluster_game_dir(&cluster.folder_name) else { + let Some((root, manifest_name)) = materialized_root(cluster, content_type).await else { return LiveSync::Deferred; }; @@ -157,19 +200,25 @@ pub async fn try_link_materialized( let _guard = manifest::lock().await; // No manifest means nothing is playing out of this folder right now - let Some(mut loaded) = manifest::load(&game_dir).await else { + let Some(mut loaded) = manifest::load(&root, manifest_name).await else { return LiveSync::Skipped; }; - if loaded.cluster_id != cluster.id { + if !content_type.is_global() && loaded.cluster_id != cluster.id { return LiveSync::Deferred; } let relative = manifest::entry_path(content_type.folder_name(), file_name); - let dest = game_dir.join(content_type.folder_name()).join(file_name); + let dest = root.join(&relative); + + let ours = if content_type.is_global() { + loaded.contains(&relative) + } else { + loaded.owns(cluster.id, &relative) + }; // Same rule as the unlink path: never write over a file we did not place - if !loaded.owns(cluster.id, &relative) && polyio::symlink_metadata(&dest).await.is_ok() { + if !ours && polyio::symlink_metadata(&dest).await.is_ok() { tracing::debug!( file = file_name, "a file we do not own already sits in the game folder; leaving it to the next launch" @@ -191,7 +240,7 @@ pub async fn try_link_materialized( path: relative, hash: artifact.hash.clone(), }); - manifest::save(&game_dir, &loaded).await; + manifest::save(&root, manifest_name, &loaded).await; LiveSync::Applied } @@ -259,6 +308,7 @@ mod tests { std::fs::remove_dir_all(root.path()).ok(); } + #[cfg(not(windows))] #[tokio::test] async fn remove_entry_clears_a_dangling_link() { let root = polyio::testing::ScratchDir::new("dangling_link"); @@ -283,6 +333,27 @@ mod tests { std::fs::remove_dir_all(root.path()).ok(); } + #[cfg(windows)] + #[tokio::test] + async fn remove_entry_clears_a_hard_link() { + let root = polyio::testing::ScratchDir::new("hard_link"); + let dir = root.path(); + polyio::create_dir_all(dir).await.unwrap(); + + let target = dir.join("target.jar"); + let link = dir.join("link.jar"); + polyio::write(&target, b"jar".as_slice()).await.unwrap(); + polyio::symlink_file(&target, &link).await.unwrap(); + polyio::remove_file(&target).await.unwrap(); + + assert!(link.exists(), "the link is still holding the file"); + + remove_entry(&link).await.unwrap(); + assert!(polyio::symlink_metadata(&link).await.is_err()); + + std::fs::remove_dir_all(root.path()).ok(); + } + #[tokio::test] async fn replacing_a_pack_leaves_only_the_pack() { let root = polyio::testing::ScratchDir::new("atomic_replace"); diff --git a/packages/oneclient_content/src/packages/store/manifest.rs b/packages/oneclient_content/src/packages/store/manifest.rs index 99b12a64..ec7bbe5e 100644 --- a/packages/oneclient_content/src/packages/store/manifest.rs +++ b/packages/oneclient_content/src/packages/store/manifest.rs @@ -4,6 +4,8 @@ use std::path::{Path, PathBuf}; use serde::{Deserialize, Serialize}; pub const MANIFEST_NAME: &str = ".oneclient-materialized.json"; +pub const MODS_MANIFEST_NAME: &str = ".oneclient-mods.json"; +pub const GLOBAL_MANIFEST_NAME: &str = ".oneclient-global.json"; const MANIFEST_VERSION: u32 = 1; @@ -48,14 +50,14 @@ impl MaterializedManifest { } #[must_use] -pub fn manifest_path(game_dir: &Path) -> PathBuf { - game_dir.join(MANIFEST_NAME) +pub fn manifest_path(dir: &Path, name: &str) -> PathBuf { + dir.join(name) } /// An unparseable manifest reads as `None` not an error /// stashing links as user content is recoverable refusing to launch is not -pub async fn load(game_dir: &Path) -> Option { - let raw = polyio::read_to_string(manifest_path(game_dir)).await.ok()?; +pub async fn load(dir: &Path, name: &str) -> Option { + let raw = polyio::read_to_string(manifest_path(dir, name)).await.ok()?; match serde_json::from_str::(&raw) { Ok(manifest) if manifest.version == MANIFEST_VERSION => Some(manifest), @@ -73,7 +75,7 @@ pub async fn load(game_dir: &Path) -> Option { } } -pub async fn save(game_dir: &Path, manifest: &MaterializedManifest) { +pub async fn save(dir: &Path, name: &str, manifest: &MaterializedManifest) { let body = match serde_json::to_vec_pretty(manifest) { Ok(body) => body, Err(err) => { @@ -82,13 +84,13 @@ pub async fn save(game_dir: &Path, manifest: &MaterializedManifest) { } }; - if let Err(err) = polyio::write(manifest_path(game_dir), body).await { + if let Err(err) = polyio::write(manifest_path(dir, name), body).await { tracing::warn!(error = %err, "failed to write materialized manifest"); } } -pub async fn clear(game_dir: &Path) { - polyio::remove_file(manifest_path(game_dir)).await.ok(); +pub async fn clear(dir: &Path, name: &str) { + polyio::remove_file(manifest_path(dir, name)).await.ok(); } static MANIFEST_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(()); @@ -102,6 +104,11 @@ pub fn entry_path(content_folder: &str, file_name: &str) -> String { format!("{content_folder}/{file_name}") } +// whether this cluster keeps its mods in its own folder rather than in the game directory +pub async fn mods_live_in_cluster(cluster_dir: &Path) -> bool { + load(cluster_dir, MODS_MANIFEST_NAME).await.is_some() +} + #[cfg(test)] mod tests { use super::*; @@ -131,15 +138,40 @@ mod tests { let dir = root.path(); polyio::create_dir_all(dir).await.unwrap(); - assert!(load(dir).await.is_none(), "no manifest yet"); + assert!(load(dir, MANIFEST_NAME).await.is_none(), "no manifest yet"); - save(dir, &manifest()).await; - let loaded = load(dir).await.expect("manifest should load"); + save(dir, MANIFEST_NAME, &manifest()).await; + let loaded = load(dir, MANIFEST_NAME).await.expect("manifest should load"); assert_eq!(loaded.cluster_id, 7); assert!(loaded.contains("mods/sodium.jar")); - clear(dir).await; - assert!(load(dir).await.is_none(), "cleared manifest should be gone"); + clear(dir, MANIFEST_NAME).await; + assert!( + load(dir, MANIFEST_NAME).await.is_none(), + "cleared manifest should be gone" + ); + + std::fs::remove_dir_all(root.path()).ok(); + } + + #[tokio::test] + async fn the_two_manifests_do_not_collide() { + let root = polyio::testing::ScratchDir::new("manifest_two_names"); + let dir = root.path(); + polyio::create_dir_all(dir).await.unwrap(); + + save(dir, MODS_MANIFEST_NAME, &manifest()).await; + + assert!( + load(dir, MODS_MANIFEST_NAME).await.is_some(), + "the mods manifest is there" + ); + assert!( + load(dir, MANIFEST_NAME).await.is_none(), + "and it is not mistaken for the game-dir one" + ); + + clear(dir, MODS_MANIFEST_NAME).await; std::fs::remove_dir_all(root.path()).ok(); } @@ -149,11 +181,11 @@ mod tests { let root = polyio::testing::ScratchDir::new("manifest_garbage"); let dir = root.path(); polyio::create_dir_all(dir).await.unwrap(); - polyio::write(manifest_path(dir), b"not json".as_slice()) + polyio::write(manifest_path(dir, MANIFEST_NAME), b"not json".as_slice()) .await .unwrap(); - assert!(load(dir).await.is_none()); + assert!(load(dir, MANIFEST_NAME).await.is_none()); std::fs::remove_dir_all(root.path()).ok(); } diff --git a/packages/oneclient_content/src/packages/store/mod.rs b/packages/oneclient_content/src/packages/store/mod.rs index 5b0c3276..dc377edf 100644 --- a/packages/oneclient_content/src/packages/store/mod.rs +++ b/packages/oneclient_content/src/packages/store/mod.rs @@ -16,7 +16,8 @@ pub use link::{ pub use paths::{artifact_absolute_path, cache_file_path, relative_cache_path}; use oneclient_db::dao::{ - artifact as artifact_dao, cluster as cluster_dao, package_metadata as meta_dao, + artifact as artifact_dao, cluster as cluster_dao, cluster_bundle as bundle_dao, + package_metadata as meta_dao, }; use oneclient_db::models::{ArtifactRow, ClusterRow, SeenStatus}; @@ -121,6 +122,9 @@ impl PackageStore { let enabled = Self::link_artifact(&artifact, &cluster, None, ctx).await?; + Self::replace_other_versions(cluster_id, provider_id, &project.id, &artifact.hash, ctx) + .await?; + let live = if enabled { link::try_link_materialized(&cluster, &artifact, &artifact.file_name).await } else { @@ -130,6 +134,80 @@ impl PackageStore { Ok((artifact, live)) } + /// Bundle-owned copies are stepped over + /// unlinking one here would have the next bundle sync put it straight back + #[tracing::instrument(level = "debug", skip(ctx))] + pub async fn replace_other_versions( + cluster_id: i64, + provider: ProviderId, + project_id: &str, + keep_hash: &str, + ctx: &ContentCtx, + ) -> ContentResult<()> { + let others = artifact_dao::list_cluster_artifacts_for_project( + &ctx.db, + cluster_id, + provider as i64, + project_id, + keep_hash, + ) + .await?; + + for link in others { + if bundle_dao::get_bundle_tracked(&ctx.db, cluster_id, &link.hash) + .await? + .is_some() + { + tracing::debug!( + hash = %link.hash, + "leaving a bundle-owned version of the project in place" + ); + continue; + } + + tracing::info!( + cluster_id, + project_id, + hash = %link.hash, + "dropping a superseded version of the project" + ); + Self::unlink_superseded(cluster_id, &link.hash, ctx).await?; + } + + Ok(()) + } + + /// Not `bundles::remove_artifact_from_cluster` + /// `packages` must not depend on `bundles` and its override reconciliation + /// does not apply here + #[tracing::instrument(level = "debug", skip(ctx))] + async fn unlink_superseded( + cluster_id: i64, + hash: &str, + ctx: &ContentCtx, + ) -> ContentResult<()> { + let cluster = Self::get_cluster(cluster_id, ctx).await?; + let content_type = artifact_dao::get_artifact_by_hash(&ctx.db, hash) + .await? + .and_then(|artifact| ContentType::from_repr(artifact.content_type as u8)); + let link = artifact_dao::get_cluster_artifact(&ctx.db, cluster_id, hash).await?; + + artifact_dao::unlink_cluster_artifact(&ctx.db, cluster_id, hash).await?; + + if let (Some(content_type), Some(link)) = (content_type, link) + && link::try_unlink_materialized(&cluster, content_type, &link.cluster_file_name).await + == LiveSync::Deferred + { + return Ok(()); + } + + if let Err(err) = gc::evict_if_unused(hash, ctx).await { + tracing::warn!(hash, error = %err, "failed to evict the superseded artifact"); + } + + Ok(()) + } + #[tracing::instrument(level = "debug", skip(artifact, cluster, ctx))] pub async fn link_artifact( artifact: &ArtifactRow, @@ -282,6 +360,10 @@ impl PackageStore { ) .await?; + if content_type.is_global() { + artifact_dao::set_enabled_for_hash(&ctx.db, hash, i64::from(enabled)).await?; + } + // Only the enable side has an outcome to report; a pack that is not in // the running folder needs no removing from it let live = if enabled { @@ -625,12 +707,10 @@ fn ensure_compatible( } } - if !version.game_versions.is_empty() - && !version - .game_versions - .iter() - .any(|v| cluster.mc_version.contains(v)) - { + if !crate::packages::dependencies::supports_game_version( + &version.game_versions, + &cluster.mc_version, + ) { return Err(PackageError::IncompatibleMcVersion.into()); } diff --git a/packages/oneclient_content/src/packages/updates.rs b/packages/oneclient_content/src/packages/updates.rs index 201312ae..771913f1 100644 --- a/packages/oneclient_content/src/packages/updates.rs +++ b/packages/oneclient_content/src/packages/updates.rs @@ -7,6 +7,7 @@ use std::collections::HashSet; +use chrono::{DateTime, Utc}; use futures_util::StreamExt; use oneclient_common::domain::ProviderId; @@ -18,8 +19,8 @@ use oneclient_events::GroupedProgressChild; use crate::ctx::ContentCtx; use crate::error::{ContentError, ContentResult}; -use crate::packages::dependencies::pick_version; -use crate::packages::store::{PackageStore, evict_if_unused, try_unlink_materialized}; +use crate::packages::dependencies::{pick_version, resolve_required, resolves_dependencies}; +use crate::packages::store::PackageStore; use crate::packages::types::LinkedArtifactInfo; /// Matches the bundle installer's fan-out @@ -83,16 +84,24 @@ struct Candidate { version_id: String, display_name: String, display_version: String, + published_at: Option>, } /// A missing bundle tracking row is the marker for "the user added this themselves" fn browser_installed( linked: &[LinkedArtifactInfo], bundle_hashes: &HashSet, + bundle_projects: &HashSet, ) -> Vec { linked .iter() + .filter(|info| info.enabled) .filter(|info| !bundle_hashes.contains(&info.hash)) + .filter(|info| { + info.project_id + .as_ref() + .is_none_or(|id| !bundle_projects.contains(id)) + }) .filter_map(|info| { let provider = info.provider?; if provider == ProviderId::Local { @@ -108,6 +117,7 @@ fn browser_installed( .clone() .unwrap_or_else(|| info.file_name.clone()), display_version: info.display_version.clone().unwrap_or_default(), + published_at: info.published_at.as_deref().and_then(parse_published), }) }) .collect() @@ -123,13 +133,12 @@ pub async fn check_browser_package_updates( let cluster = PackageStore::get_cluster(cluster_id, ctx).await?; let linked = PackageStore::list_linked_artifacts(cluster_id, ctx).await?; - let bundle_hashes: HashSet = bundle_dao::list_bundle_tracked(&ctx.db, cluster_id) - .await? - .into_iter() - .map(|row| row.hash) - .collect(); + let tracked = bundle_dao::list_bundle_tracked(&ctx.db, cluster_id).await?; + let bundle_projects: HashSet = + tracked.iter().filter_map(|row| row.package_id.clone()).collect(); + let bundle_hashes: HashSet = tracked.into_iter().map(|row| row.hash).collect(); - let candidates = browser_installed(&linked, &bundle_hashes); + let candidates = browser_installed(&linked, &bundle_hashes, &bundle_projects); if candidates.is_empty() { return Ok(BrowserUpdateCheck { cluster_id, @@ -201,7 +210,16 @@ pub async fn check_browser_package_updates( Ok(check) } -/// `None` when the installed version is already the newest compatible one +fn parse_published(raw: &str) -> Option> { + DateTime::parse_from_rfc3339(raw) + .ok() + .map(|at| at.with_timezone(&Utc)) +} + +fn is_upgrade(installed_published: Option>, latest_published: DateTime) -> bool { + installed_published.is_none_or(|installed| latest_published > installed) +} + async fn latest_for( candidate: &Candidate, cluster: &ClusterRow, @@ -216,6 +234,16 @@ async fn latest_for( return Ok(None); } + if !is_upgrade(candidate.published_at, latest.published) { + tracing::debug!( + project_id = %candidate.project_id, + installed = %candidate.version_id, + offered = %latest.version_id, + "update not offered, the installed version is not older than the pick" + ); + return Ok(None); + } + let name = if latest.version_number.is_empty() { latest.name } else { @@ -317,6 +345,12 @@ pub async fn apply_browser_package_update( .get_version(&update.project_id, &update.latest_version_id, ctx) .await?; + let enabled_before = artifact_dao::get_cluster_artifact(&ctx.db, update.cluster_id, &update.hash) + .await? + .map(|link| link.enabled != 0); + + install_new_dependencies(update, &project, &version, ctx).await; + // Compatibility is not re-checked // a provider disagreeing at install time would strand the user on a build // they cannot move off @@ -332,21 +366,6 @@ pub async fn apply_browser_package_update( ) .await?; - let enabled_before = artifact_dao::get_cluster_artifact(&ctx.db, update.cluster_id, &update.hash) - .await? - .map(|link| link.enabled != 0); - - // Everything else of this project goes not just the recorded hash which can - // be stale if the user installed a version by hand since the check ran - unlink_other_versions( - update.cluster_id, - update.provider, - &update.project_id, - &installed.hash, - ctx, - ) - .await?; - // Provider handed back the already-linked file // leave the link untouched if installed.hash == update.hash { @@ -373,73 +392,65 @@ pub async fn apply_browser_package_update( Ok(installed.hash) } -/// Bundle-owned copies are stepped over -/// unlinking one here would have the next bundle sync put it straight back -#[tracing::instrument(level = "debug", skip(ctx))] -async fn unlink_other_versions( - cluster_id: i64, - provider: ProviderId, - project_id: &str, - keep_hash: &str, +#[tracing::instrument(level = "debug", skip(update, project, version, ctx), fields(cluster_id = update.cluster_id))] +async fn install_new_dependencies( + update: &BrowserPackageUpdate, + project: &crate::packages::types::ProjectDetail, + version: &crate::packages::types::VersionDetail, ctx: &ContentCtx, -) -> ContentResult<()> { - let others = artifact_dao::list_cluster_artifacts_for_project( - &ctx.db, - cluster_id, - provider as i64, - project_id, - keep_hash, - ) - .await?; +) { + if !resolves_dependencies(project.content_type) { + return; + } - for link in others { - if bundle_dao::get_bundle_tracked(&ctx.db, cluster_id, &link.hash) - .await? - .is_some() - { - tracing::debug!( - hash = %link.hash, - "leaving a bundle-owned version of the project in place" - ); - continue; + let resolution = match resolve_required(update.provider, version, update.cluster_id, ctx).await { + Ok(resolution) => resolution, + Err(err) => { + tracing::warn!(%err, "dependency resolution failed, updating the package alone"); + return; } + }; - tracing::info!( - cluster_id, - project_id, - hash = %link.hash, - "dropping a superseded version of the project" - ); - unlink_superseded(cluster_id, &link.hash, ctx).await?; + for missing in &resolution.unresolved { + tracing::warn!(dependency = %missing, "no compatible version for a new dependency"); } - Ok(()) -} - -/// Not `bundles::remove_artifact_from_cluster` -/// `packages` must not depend on `bundles` and its override reconciliation -/// does not apply here -#[tracing::instrument(level = "debug", skip(ctx))] -async fn unlink_superseded(cluster_id: i64, hash: &str, ctx: &ContentCtx) -> ContentResult<()> { - let cluster = PackageStore::get_cluster(cluster_id, ctx).await?; - let content_type = artifact_dao::get_artifact_by_hash(&ctx.db, hash) - .await? - .and_then(|artifact| { - oneclient_common::domain::ContentType::from_repr(artifact.content_type as u8) - }); - let link = artifact_dao::get_cluster_artifact(&ctx.db, cluster_id, hash).await?; - - artifact_dao::unlink_cluster_artifact(&ctx.db, cluster_id, hash).await?; - - if let (Some(content_type), Some(link)) = (content_type, link) { - try_unlink_materialized(&cluster, content_type, &link.cluster_file_name).await; + for dependency in &resolution.install { + match PackageStore::install_to_cluster( + update.provider, + &dependency.project, + &dependency.version, + update.cluster_id, + true, + false, + None, + ctx, + ) + .await + { + Ok((artifact, _)) => { + tracing::info!( + dependency = %dependency.project.name, + "installed a dependency the newer version needs" + ); + if let Err(err) = artifact_dao::set_seen_status( + &ctx.db, + update.cluster_id, + &artifact.hash, + SeenStatus::New, + ) + .await + { + tracing::debug!(%err, "could not mark a new dependency as new"); + } + } + Err(err) => tracing::warn!( + dependency = %dependency.project.name, + %err, + "failed to install a dependency the newer version needs" + ), + } } - - if let Err(err) = evict_if_unused(hash, ctx).await { - tracing::warn!(hash, error = %err, "failed to evict the superseded artifact"); - } - - Ok(()) } #[cfg(test)] @@ -447,6 +458,10 @@ mod tests { use super::*; use oneclient_common::domain::ContentType; + fn at(raw: &str) -> DateTime { + parse_published(raw).expect("test dates are rfc 3339") + } + fn linked( hash: &str, provider: Option, @@ -480,7 +495,7 @@ mod tests { let bundles: HashSet = ["a".to_string()].into_iter().collect(); assert!( - browser_installed(&linked, &bundles).is_empty(), + browser_installed(&linked, &bundles, &HashSet::new()).is_empty(), "a bundle-tracked artifact must stay out of the browser update flow" ); } @@ -494,7 +509,7 @@ mod tests { Some("v1"), )]; - let found = browser_installed(&linked, &HashSet::new()); + let found = browser_installed(&linked, &HashSet::new(), &HashSet::new()); assert_eq!(found.len(), 1); assert_eq!(found[0].project_id, "sodium"); assert_eq!(found[0].version_id, "v1"); @@ -508,7 +523,7 @@ mod tests { ]; assert!( - browser_installed(&linked, &HashSet::new()).is_empty(), + browser_installed(&linked, &HashSet::new(), &HashSet::new()).is_empty(), "a file with no provider release has no version to compare" ); } @@ -518,7 +533,7 @@ mod tests { let linked = vec![linked("a", Some(ProviderId::Modrinth), Some("sodium"), None)]; assert!( - browser_installed(&linked, &HashSet::new()).is_empty(), + browser_installed(&linked, &HashSet::new(), &HashSet::new()).is_empty(), "without an installed version id there is nothing to compare against" ); } @@ -548,4 +563,53 @@ mod tests { assert_eq!(pending.len(), 1); assert_eq!(pending[0].hash, "b"); } + + #[test] + fn a_newer_pick_is_an_upgrade() { + assert!(is_upgrade( + Some(at("2026-01-01T00:00:00Z")), + at("2026-02-01T00:00:00Z") + )); + } + + #[test] + fn a_pick_older_than_the_installed_build_is_not_an_upgrade() { + assert!( + !is_upgrade( + Some(at("2026-02-01T00:00:00Z")), + at("2026-01-01T00:00:00Z") + ), + "a hand-installed build newer than the provider pick must not be offered a downgrade" + ); + } + + #[test] + fn a_pick_published_at_the_same_moment_is_not_an_upgrade() { + assert!(!is_upgrade( + Some(at("2026-02-01T00:00:00Z")), + at("2026-02-01T00:00:00Z") + )); + } + + #[test] + fn an_unknown_installed_date_still_offers_the_update() { + assert!(is_upgrade(None, at("2026-02-01T00:00:00Z"))); + } + + #[test] + fn candidates_carry_the_installed_publish_date() { + let mut info = linked("a", Some(ProviderId::Modrinth), Some("sodium"), Some("v1")); + info.published_at = Some("2026-02-01T00:00:00Z".into()); + + let found = browser_installed(&[info], &HashSet::new(), &HashSet::new()); + assert_eq!(found[0].published_at, Some(at("2026-02-01T00:00:00Z"))); + } + #[test] + fn an_unparseable_publish_date_leaves_the_candidate_undated() { + let mut info = linked("a", Some(ProviderId::Modrinth), Some("sodium"), Some("v1")); + info.published_at = Some("not a date".into()); + + let found = browser_installed(&[info], &HashSet::new(), &HashSet::new()); + assert_eq!(found[0].published_at, None); + } } diff --git a/packages/oneclient_core/Cargo.toml b/packages/oneclient_core/Cargo.toml index 51262cd5..5763f432 100644 --- a/packages/oneclient_core/Cargo.toml +++ b/packages/oneclient_core/Cargo.toml @@ -92,3 +92,6 @@ sysinfo.workspace = true trash.workspace = true url.workspace = true uuid.workspace = true + +[target.'cfg(target_os = "linux")'.dependencies] +libc.workspace = true diff --git a/packages/oneclient_core/src/auth/mod.rs b/packages/oneclient_core/src/auth/mod.rs deleted file mode 100644 index e30678a6..00000000 --- a/packages/oneclient_core/src/auth/mod.rs +++ /dev/null @@ -1,13 +0,0 @@ -pub mod microsoft; -pub mod offline; - -pub use offline::{OfflineAccount, generate_offline_uuid}; - -use serde::{Deserialize, Serialize}; - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(tag = "type", rename_all = "snake_case")] -pub enum AccountType { - Microsoft { refresh_token_key: String }, - Offline, -} diff --git a/packages/oneclient_core/src/auth/offline.rs b/packages/oneclient_core/src/auth/offline.rs deleted file mode 100644 index 5c3c507f..00000000 --- a/packages/oneclient_core/src/auth/offline.rs +++ /dev/null @@ -1,52 +0,0 @@ -use serde::{Deserialize, Serialize}; -use uuid::{Builder, Uuid, Variant, Version}; -use md5::{Md5, Digest}; -use thiserror::Error; - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct OfflineAccount { - pub username: String, - pub uuid: Uuid, - pub access_token: String, - pub user_type: String, -} - -impl OfflineAccount { - pub fn new(username: &str) -> Result { - let trimmed = username.trim(); - if trimmed.is_empty() { return Err(OfflineAuthError::EmptyUsername); } - if trimmed.len() > 16 { return Err(OfflineAuthError::UsernameTooLong); } - if !trimmed.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') { - return Err(OfflineAuthError::InvalidCharacters); - } - - let uuid = generate_offline_uuid(trimmed); - Ok(Self { - username: trimmed.to_string(), - uuid, - access_token: "offline".to_string(), - user_type: "offline".to_string(), - }) - } -} - -pub fn generate_offline_uuid(username: &str) -> Uuid { - let mut hasher = Md5::new(); - hasher.update(format!("OfflinePlayer:{}", username).as_bytes()); - let result = hasher.finalize(); - - let mut builder = Builder::from_slice(&result).expect("MD5 is 16 bytes"); - builder.set_version(Version::Md5); - builder.set_variant(Variant::RFC4122); - builder.into_uuid() -} - -#[derive(Debug, Error)] -pub enum OfflineAuthError { - #[error("Username cannot be empty")] - EmptyUsername, - #[error("Username cannot exceed 16 characters")] - UsernameTooLong, - #[error("Invalid characters in username")] - InvalidCharacters, -} diff --git a/packages/oneclient_core/src/clusters/mod.rs b/packages/oneclient_core/src/clusters/mod.rs index 8c484704..37be52b9 100644 --- a/packages/oneclient_core/src/clusters/mod.rs +++ b/packages/oneclient_core/src/clusters/mod.rs @@ -4,7 +4,9 @@ mod provision; mod unlink_legacy; pub use migrate::apply_remote_migrations; -pub use prepare::{estimate_cluster_download, prepare_cluster, prepare_cluster_locked}; +pub use prepare::{ + estimate_cluster_download, prepare_cluster, prepare_cluster_locked, required_java_major, +}; pub use provision::{ensure_from_bundles, ensure_from_versions}; pub use unlink_legacy::{SweepReport, unlink_legacy_cluster_content}; diff --git a/packages/oneclient_core/src/clusters/prepare.rs b/packages/oneclient_core/src/clusters/prepare.rs index 5f4e73f0..d4e9ed51 100644 --- a/packages/oneclient_core/src/clusters/prepare.rs +++ b/packages/oneclient_core/src/clusters/prepare.rs @@ -151,6 +151,36 @@ async fn cached_assets_index( polyio::read_json(&path).await.ok() } +#[tracing::instrument(level = "debug", skip(state))] +pub async fn required_java_major( + state: &Arc, + cluster_id: i64, +) -> LauncherResult> { + let cluster = state.clusters.get(cluster_id).await?; + let mc_version = oneclient_common::version::normalize_mc_version_input(&cluster.mc_version); + + let info = { + let mut metadata = state.metadata.lock().await; + let (version, _index, _updated) = + resolve_minecraft_version(&mut metadata, &state.services.mc(), &mc_version) + .await + .map_err(|_| ClusterError::InvalidVersion(cluster.mc_version.clone()))?; + + let loader_version = get_loader_version( + &mut metadata, + &state.services.mc(), + &mc_version, + cluster.mc_loader, + cluster.mc_loader_version.as_deref(), + ) + .await?; + download_version_info(&state.services.mc(), None, &version, loader_version.as_ref(), false) + .await? + }; + + Ok(info.java_version.map(|java| java.major_version)) +} + #[tracing::instrument(level = "debug", skip(state, bundles))] pub async fn estimate_cluster_download( state: &Arc, @@ -163,9 +193,7 @@ pub async fn estimate_cluster_download( let info = { let mut metadata = state.metadata.lock().await; let (version, _index, _updated) = - resolve_minecraft_version(&mut metadata, &state.services.mc(), &mc_version) - .await - .map_err(|_| ClusterError::InvalidVersion(cluster.mc_version.clone()))?; + resolve_minecraft_version(&mut metadata, &state.services.mc(), &mc_version).await?; let loader_version = get_loader_version( &mut metadata, &state.services.mc(), @@ -211,9 +239,7 @@ async fn install_cluster( let mc_version = oneclient_common::version::normalize_mc_version_input(&cluster.mc_version); let (version, _version_index, minecraft_updated) = - resolve_minecraft_version(metadata, &state.services.mc(), &mc_version) - .await - .map_err(|_| ClusterError::InvalidVersion(cluster.mc_version.clone()))?; + resolve_minecraft_version(metadata, &state.services.mc(), &mc_version).await?; let loader_version = get_loader_version( metadata, diff --git a/packages/oneclient_core/src/clusters/unlink_legacy.rs b/packages/oneclient_core/src/clusters/unlink_legacy.rs index 1387f98e..49d94c71 100644 --- a/packages/oneclient_core/src/clusters/unlink_legacy.rs +++ b/packages/oneclient_core/src/clusters/unlink_legacy.rs @@ -10,12 +10,16 @@ use oneclient_content::packages::store::manifest::{ }; use oneclient_content::packages::store::artifact_absolute_path; -const SWEPT_TYPES: [ContentType; 4] = [ - ContentType::Mod, - ContentType::ResourcePack, - ContentType::Shader, - ContentType::DataPack, -]; +const SWEPT_TYPES: [ContentType; 2] = [ContentType::Mod, ContentType::DataPack]; +const SWEPT_TYPES_REDIRECTED: [ContentType; 1] = [ContentType::DataPack]; + +async fn swept_types(cluster_root: &Path) -> &'static [ContentType] { + if manifest::mods_live_in_cluster(cluster_root).await { + &SWEPT_TYPES_REDIRECTED + } else { + &SWEPT_TYPES + } +} #[derive(Debug, Default, Clone, Copy)] pub struct SweepReport { @@ -24,8 +28,6 @@ pub struct SweepReport { pub skipped: usize, } -/// User-triggered only the "hash matches a cached artifact so it is ours" rule -/// is true during the transition and false once cluster folders hold user content #[tracing::instrument(skip(state))] pub async fn unlink_legacy_cluster_content(state: &LauncherState) -> LauncherResult { let mut report = SweepReport::default(); @@ -36,10 +38,12 @@ pub async fn unlink_legacy_cluster_content(state: &LauncherState) -> LauncherRes continue; }; + let swept = swept_types(&cluster_root).await; + if dedicated { - adopt_dedicated(state, &cluster, &cluster_root, &mut report).await; + adopt_dedicated(state, &cluster, &cluster_root, swept, &mut report).await; } else { - sweep_shared(state, &cluster_root, &mut report).await; + sweep_shared(state, &cluster_root, swept, &mut report).await; } } @@ -55,8 +59,13 @@ pub async fn unlink_legacy_cluster_content(state: &LauncherState) -> LauncherRes Ok(report) } -async fn sweep_shared(state: &LauncherState, cluster_root: &Path, report: &mut SweepReport) { - for content_type in SWEPT_TYPES { +async fn sweep_shared( + state: &LauncherState, + cluster_root: &Path, + swept: &[ContentType], + report: &mut SweepReport, +) { + for content_type in swept { let dir = cluster_root.join(content_type.folder_name()); let Ok(mut entries) = polyio::read_dir(&dir).await else { continue; @@ -105,11 +114,15 @@ async fn adopt_dedicated( state: &LauncherState, cluster: &crate::clusters::Cluster, cluster_root: &Path, + swept: &[ContentType], report: &mut SweepReport, ) { - let _manifest = manifest::lock().await; - - if manifest::load(cluster_root).await.is_some() { + let _manifest = manifest::lock().await; + + if manifest::load(cluster_root, manifest::MANIFEST_NAME) + .await + .is_some() + { return; } @@ -128,7 +141,7 @@ async fn adopt_dedicated( let mut entries = Vec::new(); for link in linked { - if !link.enabled || !SWEPT_TYPES.contains(&link.content_type) { + if !link.enabled || !swept.contains(&link.content_type) { continue; } @@ -148,7 +161,12 @@ async fn adopt_dedicated( } report.adopted += entries.len(); - manifest::save(cluster_root, &MaterializedManifest::new(cluster.id, entries)).await; + manifest::save( + cluster_root, + manifest::MANIFEST_NAME, + &MaterializedManifest::new(cluster.id, entries), + ) + .await; } enum CacheMatch { diff --git a/packages/oneclient_core/src/game/analytics.rs b/packages/oneclient_core/src/game/analytics.rs index 653d091e..fd4fdf2a 100644 --- a/packages/oneclient_core/src/game/analytics.rs +++ b/packages/oneclient_core/src/game/analytics.rs @@ -1,15 +1,30 @@ -use std::collections::HashMap; +use std::collections::{BTreeMap, HashMap}; use std::net::IpAddr; -use chrono::{DateTime, Datelike, Local, Timelike}; +use chrono::{DateTime, Datelike, Duration, Local, NaiveDate, TimeZone, Timelike, Utc}; use oneclient_db::dao::game_session as session_dao; use oneclient_db::models::{GameSessionServerRow, SessionSpan}; use crate::error::LauncherResult; const NIGHT_HOURS: [usize; 8] = [22, 23, 0, 1, 2, 3, 4, 5]; +const MORNING_HOURS: [usize; 4] = [5, 6, 7, 8]; +const WEEKEND_DAYS: [usize; 2] = [5, 6]; + const NIGHT_OWL_SHARE: f64 = 0.35; +const EARLY_BIRD_SHARE: f64 = 0.30; +const WEEKEND_SHARE: f64 = 0.55; +const LOYALIST_SHARE: f64 = 0.70; + const GAMER_SECS_PER_DAY: f64 = 5.0 * 3600.0; +const MARATHON_SECS: i64 = 8 * 3600; +const VETERAN_SECS: i64 = 500 * 3600; +const REGULAR_STREAK: usize = 7; +const EXPLORER_SERVERS: usize = 10; +const SPRINTER_SECS: i64 = 30 * 60; +const SPRINTER_SESSIONS: usize = 10; + +const MAX_PERSONAS: usize = 6; #[derive(Debug, Clone, PartialEq)] pub struct DayPlaytime { @@ -19,26 +34,112 @@ pub struct DayPlaytime { #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Persona { + Veteran, + Marathoner, + Regular, + Loyalist, + Explorer, NightOwl, + EarlyBird, + WeekendWarrior, Gamer, + Sprinter, } impl Persona { pub fn title(self) -> &'static str { match self { + Persona::Veteran => "Veteran", + Persona::Marathoner => "Marathoner", + Persona::Regular => "Regular", + Persona::Loyalist => "Loyalist", + Persona::Explorer => "Explorer", Persona::NightOwl => "Night Owl", + Persona::EarlyBird => "Early Bird", + Persona::WeekendWarrior => "Weekend Warrior", Persona::Gamer => "Gamer", + Persona::Sprinter => "Sprinter", } } pub fn description(self) -> &'static str { match self { - Persona::NightOwl => "Most of your playtime happens after dark.", - Persona::Gamer => "You average over 5 hours on the days you play.", + Persona::Veteran => "500h+ played", + Persona::Marathoner => "An 8h+ session", + Persona::Regular => "A week straight", + Persona::Loyalist => "Mostly one server", + Persona::Explorer => "10+ servers joined", + Persona::NightOwl => "Mostly after dark", + Persona::EarlyBird => "Mostly before 9am", + Persona::WeekendWarrior => "Mostly weekends", + Persona::Gamer => "5h+ per day played", + Persona::Sprinter => "Short and frequent", } } } +fn personas_for(stats: &PlaytimeStats, servers: &[ServerStat]) -> Vec { + if stats.total_secs <= 0 { + return Vec::new(); + } + + let total = stats.total_secs as f64; + let share = |hours: &[usize]| -> f64 { + hours.iter().map(|&h| stats.per_hour[h]).sum::() as f64 / total + }; + let weekend = WEEKEND_DAYS + .iter() + .map(|&d| stats.per_weekday[d]) + .sum::() as f64 + / total; + + let server_secs: i64 = servers.iter().map(|s| s.total_secs).sum(); + let top_server_share = servers + .iter() + .map(|s| s.total_secs) + .max() + .filter(|_| server_secs > 0) + .map_or(0.0, |top| top as f64 / server_secs as f64); + + let mut lengths: Vec = stats.session_secs.clone(); + lengths.sort_unstable(); + let median = lengths.get(lengths.len() / 2).copied().unwrap_or(0); + + let earned = [ + (Persona::Veteran, stats.total_secs >= VETERAN_SECS), + ( + Persona::Marathoner, + stats.longest_session_secs >= MARATHON_SECS, + ), + (Persona::Regular, stats.longest_streak >= REGULAR_STREAK), + ( + Persona::Loyalist, + servers.len() > 1 && top_server_share >= LOYALIST_SHARE, + ), + (Persona::Explorer, servers.len() >= EXPLORER_SERVERS), + (Persona::NightOwl, share(&NIGHT_HOURS) >= NIGHT_OWL_SHARE), + ( + Persona::EarlyBird, + share(&MORNING_HOURS) >= EARLY_BIRD_SHARE, + ), + (Persona::WeekendWarrior, weekend >= WEEKEND_SHARE), + ( + Persona::Gamer, + stats.avg_secs_per_active_day > GAMER_SECS_PER_DAY, + ), + ( + Persona::Sprinter, + stats.session_count >= SPRINTER_SESSIONS && median < SPRINTER_SECS, + ), + ]; + + earned + .into_iter() + .filter_map(|(persona, earned)| earned.then_some(persona)) + .take(MAX_PERSONAS) + .collect() +} + pub const WEEKDAY_LABELS: [&str; 7] = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]; #[derive(Debug, Clone, PartialEq)] @@ -49,22 +150,28 @@ pub struct PlaytimeStats { pub per_hour: [i64; 24], pub daily: Vec, pub session_secs: Vec, + pub longest_session_secs: i64, pub active_days: usize, + pub current_streak: usize, + pub longest_streak: usize, pub avg_secs_per_active_day: f64, pub peak_hour: Option, pub peak_weekday: Option, pub night_share: f64, - pub personas: Vec, } impl PlaytimeStats { pub fn from_spans(spans: &[SessionSpan]) -> Self { + Self::from_spans_on(spans, Local::now().date_naive()) + } + + fn from_spans_on(spans: &[SessionSpan], today: NaiveDate) -> Self { let mut per_weekday = [0i64; 7]; let mut per_hour = [0i64; 24]; let mut total_secs = 0i64; let mut night_secs = 0i64; let mut session_count = 0usize; - let mut daily: Vec = Vec::new(); + let mut by_day: BTreeMap = BTreeMap::new(); let mut session_secs: Vec = Vec::new(); for span in spans { @@ -83,26 +190,36 @@ impl PlaytimeStats { continue; } - let local = start.with_timezone(&Local); - let hour = local.hour() as usize; - let weekday = local.weekday().num_days_from_monday() as usize; - total_secs += secs; session_count += 1; session_secs.push(secs); - per_hour[hour] += secs; - per_weekday[weekday] += secs; - if NIGHT_HOURS.contains(&hour) { - night_secs += secs; - } - let date = local.format("%Y-%m-%d").to_string(); - match daily.last_mut() { - Some(last) if last.date == date => last.secs += secs, - _ => daily.push(DayPlaytime { date, secs }), - } + for_each_local_hour( + start.with_timezone(&Utc), + end.with_timezone(&Utc), + |at, slice| { + let hour = at.hour() as usize; + per_hour[hour] += slice; + per_weekday[at.weekday().num_days_from_monday() as usize] += slice; + if NIGHT_HOURS.contains(&hour) { + night_secs += slice; + } + *by_day.entry(at.date_naive()).or_insert(0) += slice; + }, + ); } + let longest_session_secs = session_secs.iter().copied().max().unwrap_or(0); + let dates: Vec = by_day.keys().copied().collect(); + let (current_streak, longest_streak) = streaks(&dates, today); + let daily: Vec = by_day + .iter() + .map(|(date, secs)| DayPlaytime { + date: date.format("%Y-%m-%d").to_string(), + secs: *secs, + }) + .collect(); + let active_days = daily.len(); let avg_secs_per_active_day = if active_days > 0 { total_secs as f64 / active_days as f64 @@ -118,14 +235,6 @@ impl PlaytimeStats { let peak_hour = argmax(&per_hour); let peak_weekday = argmax(&per_weekday); - let mut personas = Vec::new(); - if total_secs > 0 && night_share >= NIGHT_OWL_SHARE { - personas.push(Persona::NightOwl); - } - if avg_secs_per_active_day > GAMER_SECS_PER_DAY { - personas.push(Persona::Gamer); - } - Self { total_secs, session_count, @@ -133,16 +242,74 @@ impl PlaytimeStats { per_hour, daily, session_secs, + longest_session_secs, active_days, + current_streak, + longest_streak, avg_secs_per_active_day, peak_hour, peak_weekday, night_share, - personas, } } } +fn for_each_local_hour( + start: DateTime, + end: DateTime, + mut f: impl FnMut(DateTime, i64), +) { + let mut cur = start; + while cur < end { + let local = cur.with_timezone(&Local); + let boundary = next_local_hour(local); + let next = if boundary > cur { + boundary.min(end) + } else { + (cur + Duration::hours(1)).min(end) + }; + f(local, (next - cur).num_seconds()); + cur = next; + } +} + +fn next_local_hour(local: DateTime) -> DateTime { + let naive = local.naive_local(); + let top = match naive.date().and_hms_opt(naive.hour(), 0, 0) { + Some(top) => top + Duration::hours(1), + None => return (local + Duration::hours(1)).with_timezone(&Utc), + }; + + Local + .from_local_datetime(&top) + .earliest() + .map(|at| at.with_timezone(&Utc)) + .unwrap_or_else(|| (local + Duration::hours(1)).with_timezone(&Utc)) +} + +fn streaks(days: &[NaiveDate], today: NaiveDate) -> (usize, usize) { + let mut longest = 0usize; + let mut run = 0usize; + let mut prev: Option = None; + + for &day in days { + run = match prev { + Some(p) if p == day => run, + Some(p) if p.succ_opt() == Some(day) => run + 1, + _ => 1, + }; + longest = longest.max(run); + prev = Some(day); + } + + let current = match days.last() { + Some(last) if *last == today || Some(*last) == today.pred_opt() => run, + _ => 0, + }; + + (current, longest) +} + fn argmax(buckets: &[i64]) -> Option { buckets .iter() @@ -230,26 +397,38 @@ pub fn aggregate_servers(rows: &[GameSessionServerRow]) -> Vec { pub struct Analytics { pub playtime: PlaytimeStats, pub servers: Vec, + pub personas: Vec, +} + +impl Analytics { + pub fn new(playtime: PlaytimeStats, servers: Vec) -> Self { + let personas = personas_for(&playtime, &servers); + Self { + playtime, + servers, + personas, + } + } } #[tracing::instrument(level = "debug", skip(db))] pub async fn global_analytics(db: &oneclient_db::DbPool) -> LauncherResult { let spans = session_dao::all_session_spans(db).await?; let servers = session_dao::all_session_servers(db).await?; - Ok(Analytics { - playtime: PlaytimeStats::from_spans(&spans), - servers: aggregate_servers(&servers), - }) + Ok(Analytics::new( + PlaytimeStats::from_spans(&spans), + aggregate_servers(&servers), + )) } #[tracing::instrument(level = "debug", skip(db))] pub async fn cluster_analytics(db: &oneclient_db::DbPool, cluster_id: i64) -> LauncherResult { let spans = session_dao::session_spans_for_cluster(db, cluster_id).await?; let servers = session_dao::session_servers_for_cluster(db, cluster_id).await?; - Ok(Analytics { - playtime: PlaytimeStats::from_spans(&spans), - servers: aggregate_servers(&servers), - }) + Ok(Analytics::new( + PlaytimeStats::from_spans(&spans), + aggregate_servers(&servers), + )) } #[cfg(test)] @@ -267,7 +446,7 @@ mod tests { fn empty_has_no_personas() { let stats = PlaytimeStats::from_spans(&[]); assert_eq!(stats.total_secs, 0); - assert!(stats.personas.is_empty()); + assert!(personas_for(&stats, &[]).is_empty()); assert_eq!(stats.peak_hour, None); } @@ -331,11 +510,146 @@ mod tests { assert!(servers[1].is_ip); } + fn day(s: &str) -> NaiveDate { + NaiveDate::parse_from_str(s, "%Y-%m-%d").expect("a date") + } + + fn local_span(start: &str, end: &str) -> SessionSpan { + let at = |s: &str| { + Local + .from_local_datetime( + &chrono::NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S").expect("a time"), + ) + .earliest() + .expect("an unambiguous local time") + .to_rfc3339() + }; + SessionSpan { + started_at: at(start), + ended_at: Some(at(end)), + } + } + + #[test] + fn an_overnight_session_is_split_across_both_days() { + let spans = vec![local_span("2026-07-16 22:00:00", "2026-07-17 04:00:00")]; + let stats = PlaytimeStats::from_spans_on(&spans, day("2026-07-17")); + + assert_eq!(stats.total_secs, 6 * 3600); + assert_eq!(stats.session_count, 1, "still one session"); + assert_eq!(stats.daily.len(), 2); + assert_eq!(stats.daily[0].date, "2026-07-16"); + assert_eq!(stats.daily[0].secs, 2 * 3600); + assert_eq!(stats.daily[1].date, "2026-07-17"); + assert_eq!(stats.daily[1].secs, 4 * 3600); + } + + #[test] + fn a_multi_day_session_books_a_full_day_per_day() { + let spans = vec![local_span("2026-07-16 12:00:00", "2026-07-19 12:00:00")]; + let stats = PlaytimeStats::from_spans_on(&spans, day("2026-07-19")); + + assert_eq!(stats.daily.len(), 4, "three nights means four days touched"); + assert_eq!(stats.daily[1].secs, 24 * 3600); + assert_eq!(stats.daily[2].secs, 24 * 3600); + assert_eq!( + stats.daily.iter().map(|d| d.secs).sum::(), + stats.total_secs + ); + assert!(stats.per_hour.iter().all(|&h| h > 0)); + } + + #[test] + fn weekday_totals_follow_the_hours_played_not_the_start() { + let spans = vec![local_span("2026-07-17 23:00:00", "2026-07-18 09:00:00")]; + let stats = PlaytimeStats::from_spans_on(&spans, day("2026-07-18")); + + assert_eq!(stats.per_weekday[4], 3600, "Friday keeps only its one hour"); + assert_eq!(stats.per_weekday[5], 9 * 3600); + assert_eq!(stats.peak_weekday, Some(5)); + } + + #[test] + fn a_streak_survives_an_empty_today_but_not_an_empty_yesterday() { + let days = [day("2026-07-15"), day("2026-07-16"), day("2026-07-17")]; + + assert_eq!(streaks(&days, day("2026-07-17")), (3, 3)); + assert_eq!(streaks(&days, day("2026-07-18")), (3, 3), "today is not over"); + assert_eq!(streaks(&days, day("2026-07-19")), (0, 3), "yesterday was missed"); + } + + #[test] + fn a_gap_ends_the_run_but_keeps_the_record() { + let days = [ + day("2026-07-01"), + day("2026-07-02"), + day("2026-07-03"), + day("2026-07-10"), + ]; + assert_eq!(streaks(&days, day("2026-07-10")), (1, 3)); + assert_eq!(streaks(&[], day("2026-07-10")), (0, 0)); + } + #[test] fn gamer_needs_five_hour_average() { - let spans = vec![span("2026-01-01T12:00:00+00:00", "2026-01-01T18:00:00+00:00")]; - let stats = PlaytimeStats::from_spans(&spans); + let spans = vec![local_span("2026-01-01 12:00:00", "2026-01-01 18:00:00")]; + let stats = PlaytimeStats::from_spans_on(&spans, day("2026-01-01")); assert_eq!(stats.active_days, 1); - assert!(stats.personas.contains(&Persona::Gamer)); + assert!(personas_for(&stats, &[]).contains(&Persona::Gamer)); + } + + fn stat_server(address: &str, total_secs: i64) -> ServerStat { + ServerStat { + address: address.into(), + port: None, + joins: 1, + total_secs, + last_played: None, + is_ip: false, + } + } + + #[test] + fn loyalty_needs_somewhere_else_to_have_been() { + let spans = vec![local_span("2026-01-01 12:00:00", "2026-01-01 18:00:00")]; + let stats = PlaytimeStats::from_spans_on(&spans, day("2026-01-01")); + + let only_one = [stat_server("a.example.com", 3600)]; + assert!( + !personas_for(&stats, &only_one).contains(&Persona::Loyalist), + "a single server is loyalty to nothing" + ); + + let favourite = [ + stat_server("a.example.com", 9000), + stat_server("b.example.com", 1000), + ]; + assert!(personas_for(&stats, &favourite).contains(&Persona::Loyalist)); + } + + #[test] + fn an_overnight_session_still_reads_as_a_night_owl() { + let spans = vec![local_span("2026-01-01 22:00:00", "2026-01-02 06:00:00")]; + let stats = PlaytimeStats::from_spans_on(&spans, day("2026-01-02")); + + assert!(personas_for(&stats, &[]).contains(&Persona::NightOwl)); + assert!(personas_for(&stats, &[]).contains(&Persona::Marathoner)); + } + + #[test] + fn no_more_personas_than_fit() { + let mut spans = Vec::new(); + for day_of in 1..=20 { + spans.push(local_span( + &format!("2026-01-{day_of:02} 22:00:00"), + &format!("2026-01-{:02} 08:00:00", day_of + 1), + )); + } + let stats = PlaytimeStats::from_spans_on(&spans, day("2026-01-21")); + let servers: Vec = (0..12) + .map(|i| stat_server(&format!("s{i}.example.com"), 3600)) + .collect(); + + assert_eq!(personas_for(&stats, &servers).len(), MAX_PERSONAS); } } diff --git a/packages/oneclient_core/src/game/error.rs b/packages/oneclient_core/src/game/error.rs index 2a2234ad..95d5d3f5 100644 --- a/packages/oneclient_core/src/game/error.rs +++ b/packages/oneclient_core/src/game/error.rs @@ -24,9 +24,6 @@ pub enum GameError { #[error("processor main class not found for {0}")] ProcessorMainClass(String), - #[error("invalid game version {0}")] - InvalidVersion(String), - #[error("cluster {0} is already running")] AlreadyRunning(i64), diff --git a/packages/oneclient_core/src/game/fabric.rs b/packages/oneclient_core/src/game/fabric.rs new file mode 100644 index 00000000..611de7ca --- /dev/null +++ b/packages/oneclient_core/src/game/fabric.rs @@ -0,0 +1,113 @@ +use std::path::Path; + +use oneclient_common::domain::GameLoader; + +const MODS_FOLDER_PROPERTY: &str = "fabric.modsFolder"; + +// version 0.15.0 is required for fabric.modsFolder to work +const MIN_LOADER_VERSION: Version = Version(0, 15, 0); + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +struct Version(u32, u32, u32); + +fn parse_version(raw: &str) -> Option { + let core = raw.trim().split(['+', '-']).next()?; + let mut parts = core.split('.'); + + let major = parts.next()?.parse().ok()?; + let minor = parts.next().and_then(|p| p.parse().ok()).unwrap_or(0); + let patch = parts.next().and_then(|p| p.parse().ok()).unwrap_or(0); + + Some(Version(major, minor, patch)) +} + +#[must_use] +pub fn uses_cluster_mods_folder( + loader: GameLoader, + loader_version: Option<&str>, + custom_args: &str, +) -> bool { + if !matches!(loader, GameLoader::Fabric | GameLoader::Ornithe) { + return false; + } + + if custom_args.contains(MODS_FOLDER_PROPERTY) { + tracing::info!("launch args already set {MODS_FOLDER_PROPERTY}; leaving the layout alone"); + return false; + } + + parse_version(loader_version.unwrap_or_default()).is_some_and(|v| v >= MIN_LOADER_VERSION) +} + +#[must_use] +pub fn mods_folder_argument( + loader: GameLoader, + loader_version: Option<&str>, + custom_args: &str, + mods_dir: &Path, +) -> Option { + if !uses_cluster_mods_folder(loader, loader_version, custom_args) { + return None; + } + + Some(format!("-D{MODS_FOLDER_PROPERTY}={}", mods_dir.display())) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn versions_compare_by_component_not_by_string() { + assert!(parse_version("0.9.0") < parse_version("0.15.0")); + assert!(parse_version("0.16.5") > parse_version("0.15.0")); + assert_eq!(parse_version("0.15"), Some(Version(0, 15, 0))); + } + + #[test] + fn build_metadata_is_ignored() { + assert_eq!(parse_version("0.16.0+build.1"), Some(Version(0, 16, 0))); + assert_eq!(parse_version("0.16.0-rc.1"), Some(Version(0, 16, 0))); + } + + #[test] + fn only_fabric_and_only_new_enough() { + assert!(uses_cluster_mods_folder( + GameLoader::Fabric, + Some("0.16.5"), + "" + )); + assert!(uses_cluster_mods_folder( + GameLoader::Ornithe, + Some("0.16.5"), + "" + )); + assert!(!uses_cluster_mods_folder( + GameLoader::Fabric, + Some("0.11.0"), + "" + )); + + assert!(!uses_cluster_mods_folder(GameLoader::Fabric, None, "")); + + for loader in [ + GameLoader::Vanilla, + GameLoader::Forge, + GameLoader::NeoForge, + GameLoader::Quilt, + ] { + assert!(!uses_cluster_mods_folder(loader, Some("0.16.5"), "")); + } + } + + #[test] + fn a_user_set_property_takes_the_whole_layout_with_it() { + let dir = Path::new("/clusters/one/mods"); + let mine = "-Dfabric.modsFolder=/elsewhere"; + + assert!(mods_folder_argument(GameLoader::Fabric, Some("0.16.5"), "-Xmx4G", dir).is_some()); + + assert!(!uses_cluster_mods_folder(GameLoader::Fabric, Some("0.16.5"), mine)); + assert!(mods_folder_argument(GameLoader::Fabric, Some("0.16.5"), mine, dir).is_none()); + } +} diff --git a/packages/oneclient_core/src/game/gpu.rs b/packages/oneclient_core/src/game/gpu.rs deleted file mode 100644 index 52e1bf00..00000000 --- a/packages/oneclient_core/src/game/gpu.rs +++ /dev/null @@ -1,362 +0,0 @@ -use std::path::Path; - -const VENDOR_NVIDIA: u16 = 0x10de; - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct Gpu { - pub index: u32, - pub vendor_id: u16, - /// The card the firmware booted with - /// `None` on the non-PCI platforms that never publish the attribute - pub boot_vga: Option, - pub pci_address: Option, - /// `device/class`; display controllers are `0x03xxxx` - pub class: Option, - pub has_render_node: bool, -} - -impl Gpu { - /// A server's BMC display adapter is a display controller with no render node, and naming it in `DRI_PRIME` drops the game to software rendering - fn can_render(&self) -> bool { - self.has_render_node && self.class.is_none_or(|class| class >> 16 == 0x03) - } -} - -/// Empty whenever offload does not apply -pub fn offload_env(gpus: &[Gpu]) -> Vec<(&'static str, String)> { - if gpus.len() < 2 { - return Vec::new(); - } - - let Some(boot) = gpus.iter().find(|gpu| gpu.boot_vga == Some(true)) else { - return Vec::new(); - }; - - if boot.vendor_id == VENDOR_NVIDIA { - return Vec::new(); - } - - let Some(target) = gpus - .iter() - .find(|gpu| gpu.boot_vga != Some(true) && gpu.can_render()) - else { - return Vec::new(); - }; - - // `prime-run` sets only the NVIDIA variables; `DRI_PRIME` alongside them names a - // card the proprietary driver owns and steers any Mesa path still in play onto nouveau - if target.vendor_id == VENDOR_NVIDIA { - return vec![ - ("__NV_PRIME_RENDER_OFFLOAD", "1".to_string()), - ("__VK_LAYER_NV_optimus", "NVIDIA_only".to_string()), - ("__GLX_VENDOR_LIBRARY_NAME", "nvidia".to_string()), - ]; - } - - let prime = target - .pci_address - .as_deref() - .and_then(pci_tag) - .unwrap_or_else(|| "1".to_string()); - - vec![("DRI_PRIME", prime)] -} - -#[cfg(target_os = "linux")] -pub fn detect() -> Vec { - read_drm_devices(Path::new("/sys/class/drm")) -} - -fn read_drm_devices(root: &Path) -> Vec { - let Ok(entries) = std::fs::read_dir(root) else { - return Vec::new(); - }; - - let mut gpus = Vec::new(); - - for entry in entries.flatten() { - let name = entry.file_name(); - let Some(name) = name.to_str() else { - continue; - }; - - // `card0` is a device; `card0-HDMI-A-1` is a connector hanging off it - let Some(index) = name.strip_prefix("card").and_then(|n| n.parse::().ok()) else { - continue; - }; - - let device = entry.path().join("device"); - let Some(vendor_id) = read_hex(&device.join("vendor")) else { - continue; - }; - - gpus.push(Gpu { - index, - vendor_id, - boot_vga: read_flag(&device.join("boot_vga")), - pci_address: std::fs::read_link(&device) - .ok() - .and_then(|link| link.file_name()?.to_str().map(str::to_string)), - class: read_class(&device.join("class")), - has_render_node: has_render_node(&device.join("drm")), - }); - } - - gpus.sort_by_key(|gpu| gpu.index); - gpus -} - -fn read_hex(path: &Path) -> Option { - let raw = std::fs::read_to_string(path).ok()?; - let raw = raw.trim(); - u16::from_str_radix(raw.strip_prefix("0x").unwrap_or(raw), 16).ok() -} - -fn read_flag(path: &Path) -> Option { - Some(std::fs::read_to_string(path).ok()?.trim() == "1") -} - -/// sysfs spells the PCI class as `0x030000` -fn read_class(path: &Path) -> Option { - let raw = std::fs::read_to_string(path).ok()?; - let raw = raw.trim(); - u32::from_str_radix(raw.strip_prefix("0x").unwrap_or(raw), 16).ok() -} - -/// The `drm` directory on the device holds its own `cardN` plus a `renderD*` whenever the card can be rendered on -fn has_render_node(drm: &Path) -> bool { - let Ok(entries) = std::fs::read_dir(drm) else { - return false; - }; - - entries.flatten().any(|entry| { - entry - .file_name() - .to_str() - .is_some_and(|name| name.starts_with("renderD")) - }) -} - -/// Mesa builds its tag as `pci-%04x_%02x_%02x_%1u`, which is the sysfs `0000:01:00.0` spelling with the separators swapped -fn pci_tag(address: &str) -> Option { - let (domain, rest) = address.split_once(':')?; - let (bus, rest) = rest.split_once(':')?; - let (device, function) = rest.split_once('.')?; - - let widths = [(domain, 4), (bus, 2), (device, 2), (function, 1)]; - if widths - .iter() - .any(|(part, width)| part.len() != *width || !part.bytes().all(|b| b.is_ascii_hexdigit())) - { - return None; - } - - Some(format!("pci-{domain}_{bus}_{device}_{function}")) -} - -#[cfg(test)] -mod tests { - use super::*; - - const NVIDIA: u16 = 0x10de; - const AMD: u16 = 0x1002; - const INTEL: u16 = 0x8086; - const ASPEED: u16 = 0x1a03; - - fn gpu(index: u32, vendor_id: u16, boot_vga: Option) -> Gpu { - Gpu { - index, - vendor_id, - boot_vga, - pci_address: None, - class: Some(0x03_0000), - has_render_node: true, - } - } - - #[test] - fn a_lone_card_is_never_offloaded_from() { - for vendor in [AMD, NVIDIA, INTEL] { - assert!(offload_env(&[gpu(0, vendor, Some(true))]).is_empty()); - } - assert!(offload_env(&[]).is_empty()); - } - - #[test] - fn an_all_amd_hybrid_never_names_nvidia() { - let env = offload_env(&[gpu(0, AMD, Some(true)), gpu(1, AMD, Some(false))]); - - assert_eq!(env, vec![("DRI_PRIME", "1".to_string())]); - } - - #[test] - fn an_intel_amd_hybrid_never_names_nvidia() { - let env = offload_env(&[gpu(0, INTEL, Some(true)), gpu(1, AMD, Some(false))]); - - assert_eq!(env, vec![("DRI_PRIME", "1".to_string())]); - } - - #[test] - fn an_optimus_laptop_gets_the_nvidia_variables() { - let env = offload_env(&[gpu(0, INTEL, Some(true)), gpu(1, NVIDIA, Some(false))]); - - assert_eq!( - env, - vec![ - ("__NV_PRIME_RENDER_OFFLOAD", "1".to_string()), - ("__VK_LAYER_NV_optimus", "NVIDIA_only".to_string()), - ("__GLX_VENDOR_LIBRARY_NAME", "nvidia".to_string()), - ] - ); - assert!( - !env.iter().any(|(key, _)| *key == "DRI_PRIME"), - "the proprietary driver owns the card DRI_PRIME would name" - ); - } - - #[test] - fn a_bmc_display_adapter_is_never_the_offload_target() { - // Workstation: iGPU on the display, the BMC's ASPEED at a lower card index - // than the real dGPU, and no render node on the BMC - let mut bmc = gpu(1, ASPEED, Some(false)); - bmc.has_render_node = false; - - let mut dgpu = gpu(2, AMD, Some(false)); - dgpu.pci_address = Some("0000:c1:00.0".to_string()); - - let env = offload_env(&[gpu(0, INTEL, Some(true)), bmc, dgpu]); - - assert_eq!(env, vec![("DRI_PRIME", "pci-0000_c1_00_0".to_string())]); - } - - #[test] - fn a_card_that_is_not_a_display_controller_is_never_the_offload_target() { - let mut compute = gpu(1, AMD, Some(false)); - compute.class = Some(0x12_0000); - - assert!(offload_env(&[gpu(0, INTEL, Some(true)), compute]).is_empty()); - } - - #[test] - fn nothing_happens_when_the_discrete_card_already_drives_the_display() { - // Desktop with the monitor on the NVIDIA card and the iGPU still on. - // `DRI_PRIME` would move Mesa onto the integrated one - let env = offload_env(&[gpu(0, NVIDIA, Some(true)), gpu(1, INTEL, Some(false))]); - - assert!(env.is_empty()); - } - - #[test] - fn nothing_happens_when_no_card_claims_to_be_the_boot_gpu() { - let env = offload_env(&[gpu(0, INTEL, None), gpu(1, NVIDIA, None)]); - - assert!(env.is_empty()); - } - - #[test] - fn the_target_card_is_named_by_pci_address_when_sysfs_offers_one() { - let mut target = gpu(1, AMD, Some(false)); - target.pci_address = Some("0000:01:00.0".to_string()); - - let env = offload_env(&[gpu(0, INTEL, Some(true)), target]); - - assert_eq!(env[0], ("DRI_PRIME", "pci-0000_01_00_0".to_string())); - } - - #[test] - fn a_malformed_pci_address_falls_back_to_the_ordinal() { - let mut target = gpu(1, AMD, Some(false)); - target.pci_address = Some("not-an-address".to_string()); - - let env = offload_env(&[gpu(0, AMD, Some(true)), target]); - - assert_eq!(env[0], ("DRI_PRIME", "1".to_string())); - } - - #[test] - fn pci_tags_match_the_shape_mesa_builds() { - assert_eq!(pci_tag("0000:01:00.0").as_deref(), Some("pci-0000_01_00_0")); - assert_eq!(pci_tag("10000:af:1f.7").as_deref(), None, "domain is 4 wide"); - assert_eq!(pci_tag("0000:1:00.0"), None, "bus is 2 wide"); - assert_eq!(pci_tag("0000:01:00"), None, "no function"); - assert_eq!(pci_tag("0000:0g:00.0"), None, "not hex"); - } - - fn write(path: &Path, contents: &str) { - std::fs::create_dir_all(path.parent().unwrap()).unwrap(); - std::fs::write(path, contents).unwrap(); - } - - #[test] - fn reads_vendor_boot_flag_and_address_out_of_a_sysfs_tree() { - let scratch = polyio::testing::ScratchDir::new("gpu-sysfs"); - let drm = scratch.join("drm"); - - // sysfs spells these with a trailing newline - write(&drm.join("card0").join("device").join("vendor"), "0x8086\n"); - write(&drm.join("card0").join("device").join("boot_vga"), "1\n"); - write(&drm.join("card0").join("device").join("class"), "0x030000\n"); - write( - &drm.join("card0") - .join("device") - .join("drm") - .join("renderD128") - .join("dev"), - "226:128\n", - ); - write(&drm.join("card1").join("device").join("vendor"), "0x10de\n"); - write(&drm.join("card1").join("device").join("boot_vga"), "0\n"); - write(&drm.join("card1").join("device").join("class"), "0x030000\n"); - write( - &drm.join("card1") - .join("device") - .join("drm") - .join("renderD129") - .join("dev"), - "226:129\n", - ); - - let gpus = read_drm_devices(&drm); - - assert_eq!(gpus.len(), 2); - assert_eq!(gpus[0].index, 0); - assert_eq!(gpus[0].vendor_id, INTEL); - assert_eq!(gpus[0].boot_vga, Some(true)); - assert_eq!(gpus[1].vendor_id, NVIDIA); - assert_eq!(gpus[1].boot_vga, Some(false)); - assert_eq!(gpus[1].class, Some(0x03_0000)); - assert!(gpus[1].has_render_node); - - // and the whole point: this pair earns the NVIDIA variables - assert_eq!(offload_env(&gpus).len(), 3); - } - - #[test] - fn skips_connectors_and_anything_without_a_vendor() { - let scratch = polyio::testing::ScratchDir::new("gpu-connectors"); - let drm = scratch.join("drm"); - - write(&drm.join("card0").join("device").join("vendor"), "0x1002\n"); - // a connector, not a device - write( - &drm.join("card0-HDMI-A-1").join("device").join("vendor"), - "0x1002\n", - ); - // renderD128 sits alongside the cards and is the same device again - write(&drm.join("renderD128").join("device").join("vendor"), "0x1002\n"); - // present but unreadable as a device - std::fs::create_dir_all(drm.join("card9")).unwrap(); - - let gpus = read_drm_devices(&drm); - - assert_eq!(gpus.len(), 1, "one card, counted once"); - assert_eq!(gpus[0].vendor_id, AMD); - } - - #[test] - fn a_missing_drm_tree_is_not_an_error() { - let scratch = polyio::testing::ScratchDir::new("gpu-missing"); - - assert!(read_drm_devices(&scratch.join("nope")).is_empty()); - } -} diff --git a/packages/oneclient_core/src/game/gpu/linux.rs b/packages/oneclient_core/src/game/gpu/linux.rs new file mode 100644 index 00000000..7734a33c --- /dev/null +++ b/packages/oneclient_core/src/game/gpu/linux.rs @@ -0,0 +1,601 @@ +use std::path::{Path, PathBuf}; + +const INTEL_INTEGRATED: &str = "0000:00:02.0"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Driver { + AmdGpu, + Radeon, + Nouveau, + Nvidia, + I915, + Xe, + Other, +} + +impl Driver { + fn from_name(name: Option<&str>) -> Self { + match name { + Some("amdgpu") => Self::AmdGpu, + Some("radeon") => Self::Radeon, + Some("nouveau") => Self::Nouveau, + Some("nvidia") => Self::Nvidia, + Some("i915") => Self::I915, + Some("xe") => Self::Xe, + Some(other) => { + tracing::warn!(driver = other, "unknown graphics driver"); + Self::Other + } + None => Self::Other, + } + } + + fn vulkan_icd(self) -> Option<&'static str> { + match self { + Self::AmdGpu | Self::Radeon => Some("*radeon*,*amd*"), + Self::Nvidia => Some("*nvidia*"), + Self::I915 | Self::Xe => Some("*intel*"), + Self::Nouveau | Self::Other => None, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Gpu { + pub pci_address: String, + pub boot_vga: Option, + pub driver: Driver, + pub render_node: Option, + pub discrete: bool, +} + +impl Gpu { + fn nvidia_proprietary(&self) -> bool { + self.driver == Driver::Nvidia + } + + fn can_render(&self) -> bool { + self.render_node.is_some() || self.nvidia_proprietary() + } + + fn priority(&self) -> u32 { + u32::from(self.discrete) * 2 + u32::from(self.boot_vga == Some(true)) + } +} + +pub fn offload_env(gpus: &[Gpu]) -> Vec<(&'static str, String)> { + let Some(best) = gpus + .iter() + .filter(|gpu| gpu.can_render()) + .max_by_key(|gpu| gpu.priority()) + else { + return Vec::new(); + }; + + if !best.discrete || best.boot_vga == Some(true) { + return Vec::new(); + } + + let mut env = if best.nvidia_proprietary() { + vec![ + ("__NV_PRIME_RENDER_OFFLOAD", "1".to_string()), + ("__VK_LAYER_NV_optimus", "NVIDIA_only".to_string()), + ("__GLX_VENDOR_LIBRARY_NAME", "nvidia".to_string()), + ] + } else { + let prime = pci_tag(&best.pci_address).unwrap_or_else(|| "1".to_string()); + vec![("DRI_PRIME", prime)] + }; + + if let Some(icd) = best.driver.vulkan_icd() { + env.push(("VK_LOADER_DRIVERS_SELECT", icd.to_string())); + } + + env +} + +#[cfg(target_os = "linux")] +pub fn detect() -> Vec { + let mut gpus = read_pci_devices(Path::new("/sys/bus/pci/devices")); + + for gpu in &mut gpus { + gpu.discrete = probe_discrete(gpu); + } + + gpus +} + +fn read_pci_devices(root: &Path) -> Vec { + let Ok(entries) = std::fs::read_dir(root) else { + return Vec::new(); + }; + + let mut gpus = Vec::new(); + + for entry in entries.flatten() { + let name = entry.file_name(); + let Some(address) = name.to_str() else { + continue; + }; + + let device = entry.path(); + + if read_class(&device.join("class")).is_none_or(|class| class >> 16 != 0x03) { + continue; + } + + gpus.push(Gpu { + pci_address: address.to_string(), + boot_vga: read_flag(&device.join("boot_vga")), + driver: Driver::from_name(read_link_name(&device.join("driver")).as_deref()), + render_node: render_node(&device.join("drm")), + discrete: false, + }); + } + + gpus.sort_by(|a, b| a.pci_address.cmp(&b.pci_address)); + gpus +} + +fn read_flag(path: &Path) -> Option { + Some(std::fs::read_to_string(path).ok()?.trim() == "1") +} + +fn read_class(path: &Path) -> Option { + let raw = std::fs::read_to_string(path).ok()?; + let raw = raw.trim(); + u32::from_str_radix(raw.strip_prefix("0x").unwrap_or(raw), 16).ok() +} + +fn read_link_name(path: &Path) -> Option { + let link = std::fs::read_link(path).ok()?; + Some(link.file_name()?.to_str()?.to_string()) +} + +fn render_node(drm: &Path) -> Option { + let entries = std::fs::read_dir(drm).ok()?; + + entries.flatten().find_map(|entry| { + let name = entry.file_name(); + name.to_str()? + .starts_with("renderD") + .then(|| Path::new("/dev/dri").join(&name)) + }) +} + +fn pci_tag(address: &str) -> Option { + let (domain, rest) = address.split_once(':')?; + let (bus, rest) = rest.split_once(':')?; + let (device, function) = rest.split_once('.')?; + + let widths = [(domain, 4), (bus, 2), (device, 2), (function, 1)]; + if widths + .iter() + .any(|(part, width)| part.len() != *width || !part.bytes().all(|b| b.is_ascii_hexdigit())) + { + return None; + } + + Some(format!("pci-{domain}_{bus}_{device}_{function}")) +} + +fn probe_discrete(gpu: &Gpu) -> bool { + match gpu.driver { + Driver::Nvidia => true, + Driver::AmdGpu => answered(gpu, drm::amdgpu_is_discrete(gpu)), + Driver::Xe => answered(gpu, drm::xe_is_discrete(gpu)), + Driver::I915 => gpu.pci_address != INTEL_INTEGRATED, + Driver::Nouveau | Driver::Radeon | Driver::Other => gpu.boot_vga == Some(false), + } +} + +fn answered(gpu: &Gpu, probe: std::io::Result) -> bool { + probe.unwrap_or_else(|error| { + tracing::warn!( + card = gpu.pci_address, + ?error, + "no discreteness answer; assuming integrated" + ); + false + }) +} + +#[cfg(not(target_os = "linux"))] +mod drm { + use super::Gpu; + + fn unavailable() -> std::io::Result { + Err(std::io::Error::from(std::io::ErrorKind::Unsupported)) + } + + pub fn amdgpu_is_discrete(_gpu: &Gpu) -> std::io::Result { + unavailable() + } + + pub fn xe_is_discrete(_gpu: &Gpu) -> std::io::Result { + unavailable() + } +} + +#[cfg(target_os = "linux")] +mod drm { + use std::fs::File; + use std::os::fd::AsRawFd; + + use super::Gpu; + + const DRM_IOCTL_BASE: u32 = b'd' as u32; + const DRM_COMMAND_BASE: u32 = 0x40; + + fn open(gpu: &Gpu) -> std::io::Result> { + let Some(node) = &gpu.render_node else { + return Ok(None); + }; + + std::fs::OpenOptions::new() + .read(true) + .write(true) + .open(node) + .map(Some) + } + + fn check(result: i32) -> std::io::Result<()> { + if result == 0 { + Ok(()) + } else { + Err(std::io::Error::last_os_error()) + } + } + + fn command_write(file: &File, index: u32, data: &T) -> std::io::Result<()> { + let request = libc::_IOW::(DRM_IOCTL_BASE, DRM_COMMAND_BASE + index); + check(unsafe { libc::ioctl(file.as_raw_fd(), request, std::ptr::from_ref(data)) }) + } + + fn command_read_write(file: &File, index: u32, data: &mut T) -> std::io::Result<()> { + let request = libc::_IOWR::(DRM_IOCTL_BASE, DRM_COMMAND_BASE + index); + check(unsafe { libc::ioctl(file.as_raw_fd(), request, std::ptr::from_mut(data)) }) + } + + pub fn amdgpu_is_discrete(gpu: &Gpu) -> std::io::Result { + let Some(file) = open(gpu)? else { + return Ok(false); + }; + + #[repr(C)] + struct InfoDevice { + _before_ids_flags: [u32; 34], + ids_flags: u64, + } + + #[repr(C)] + struct Info { + return_pointer: u64, + return_size: u32, + query: u32, + _query_args: [u32; 4], + } + + let mut result = InfoDevice { + _before_ids_flags: [0; 34], + ids_flags: 0, + }; + + const AMDGPU_INFO_DEV_INFO: u32 = 0x16; + let query = Info { + return_pointer: std::ptr::from_mut(&mut result) as u64, + return_size: size_of_val(&result) as u32, + query: AMDGPU_INFO_DEV_INFO, + _query_args: [0; 4], + }; + + const DRM_AMDGPU_INFO: u32 = 0x05; + command_write(&file, DRM_AMDGPU_INFO, &query)?; + + const AMDGPU_IDS_FLAGS_FUSION: u64 = 0x1; + Ok(result.ids_flags & AMDGPU_IDS_FLAGS_FUSION == 0) + } + + pub fn xe_is_discrete(gpu: &Gpu) -> std::io::Result { + let Some(file) = open(gpu)? else { + return Ok(false); + }; + + #[repr(C)] + struct DeviceQuery { + extensions: u64, + query: u32, + size: u32, + data: u64, + _reserved: [u64; 2], + } + + const DRM_XE_DEVICE_QUERY: u32 = 0x00; + const DRM_XE_DEVICE_QUERY_CONFIG: u32 = 0x2; + + let mut query = DeviceQuery { + extensions: 0, + query: DRM_XE_DEVICE_QUERY_CONFIG, + size: 0, + data: 0, + _reserved: [0; 2], + }; + + command_read_write(&file, DRM_XE_DEVICE_QUERY, &mut query)?; + + const INFO_OFFSET: usize = 1; + const CONFIG_FLAGS: usize = 1; + let words = (query.size as usize).div_ceil(size_of::()); + + if words <= INFO_OFFSET + CONFIG_FLAGS { + tracing::warn!(size = query.size, "xe config too short to hold its flags"); + return Ok(false); + } + + let mut data = vec![0_u64; words]; + query.data = data.as_mut_ptr() as u64; + + command_read_write(&file, DRM_XE_DEVICE_QUERY, &mut query)?; + + const DRM_XE_QUERY_CONFIG_FLAG_HAS_VRAM: u64 = 1 << 0; + Ok(data[INFO_OFFSET + CONFIG_FLAGS] & DRM_XE_QUERY_CONFIG_FLAG_HAS_VRAM != 0) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const AMD_INTEGRATED: &str = "0000:0c:00.0"; + + fn gpu(address: &str, driver: Driver, boot_vga: Option) -> Gpu { + let discrete = match driver { + Driver::Nvidia => true, + Driver::I915 => address != INTEL_INTEGRATED, + _ => boot_vga == Some(false), + }; + + Gpu { + pci_address: address.to_string(), + boot_vga, + driver, + render_node: Some(PathBuf::from("/dev/dri/renderD128")), + discrete, + } + } + + fn nvidia_offload() -> Vec<(&'static str, String)> { + vec![ + ("__NV_PRIME_RENDER_OFFLOAD", "1".to_string()), + ("__VK_LAYER_NV_optimus", "NVIDIA_only".to_string()), + ("__GLX_VENDOR_LIBRARY_NAME", "nvidia".to_string()), + ("VK_LOADER_DRIVERS_SELECT", "*nvidia*".to_string()), + ] + } + + fn dri_prime(card: &str, icd: &str) -> Vec<(&'static str, String)> { + vec![ + ("DRI_PRIME", card.to_string()), + ("VK_LOADER_DRIVERS_SELECT", icd.to_string()), + ] + } + + #[test] + fn offload_env_names_the_right_card_for_every_pairing() { + let igpu = || gpu(INTEL_INTEGRATED, Driver::I915, Some(true)); + let dgpu = || gpu("0000:01:00.0", Driver::Nvidia, Some(false)); + + type Case = (&'static str, Vec, Vec<(&'static str, String)>); + let amd = "*radeon*,*amd*"; + + let cases: Vec = vec![ + ("no cards at all", vec![], vec![]), + ( + "a lone amd card", + vec![gpu("0000:01:00.0", Driver::AmdGpu, Some(true))], + vec![], + ), + ( + "a lone nvidia card", + vec![gpu("0000:01:00.0", Driver::Nvidia, Some(true))], + vec![], + ), + ("a lone intel card", vec![igpu()], vec![]), + ( + "an all-amd hybrid never names nvidia", + vec![ + gpu(AMD_INTEGRATED, Driver::AmdGpu, Some(true)), + gpu("0000:01:00.0", Driver::AmdGpu, Some(false)), + ], + dri_prime("pci-0000_01_00_0", amd), + ), + ( + "an intel + amd hybrid never names nvidia", + vec![igpu(), gpu("0000:01:00.0", Driver::AmdGpu, Some(false))], + dri_prime("pci-0000_01_00_0", amd), + ), + ( + "an optimus laptop gets the nvidia variables and no DRI_PRIME", + vec![igpu(), dgpu()], + nvidia_offload(), + ), + ( + "the same laptop on nouveau gets DRI_PRIME and no ICD filter", + vec![igpu(), Gpu { driver: Driver::Nouveau, ..dgpu() }], + vec![("DRI_PRIME", "pci-0000_01_00_0".to_string())], + ), + ( + "an nvidia card without a render node is still a target", + vec![igpu(), Gpu { render_node: None, ..dgpu() }], + nvidia_offload(), + ), + ( + "an unbound card is never the target", + vec![igpu(), Gpu { driver: Driver::Other, render_node: None, ..dgpu() }], + vec![], + ), + ( + "a bmc display adapter is skipped for the real card behind it", + vec![ + igpu(), + Gpu { render_node: None, ..gpu("0000:03:00.0", Driver::Other, Some(false)) }, + gpu("0000:c1:00.0", Driver::AmdGpu, Some(false)), + ], + dri_prime("pci-0000_c1_00_0", amd), + ), + ( + "the discrete card wins over a renderable card at a lower address", + vec![ + igpu(), + Gpu { discrete: false, ..gpu("0000:03:00.0", Driver::AmdGpu, Some(false)) }, + gpu("0000:c1:00.0", Driver::AmdGpu, Some(false)), + ], + dri_prime("pci-0000_c1_00_0", amd), + ), + ( + "nothing happens when the discrete card already drives the display", + vec![ + gpu("0000:01:00.0", Driver::Nvidia, Some(true)), + gpu(INTEL_INTEGRATED, Driver::I915, Some(false)), + ], + vec![], + ), + ( + "the driver still finds the discrete card when nothing publishes boot_vga", + vec![ + gpu(INTEL_INTEGRATED, Driver::I915, None), + gpu("0000:01:00.0", Driver::Nvidia, None), + ], + nvidia_offload(), + ), + ( + "a malformed pci address falls back to the ordinal", + vec![ + gpu(AMD_INTEGRATED, Driver::AmdGpu, Some(true)), + gpu("not-an-address", Driver::AmdGpu, Some(false)), + ], + dri_prime("1", amd), + ), + ]; + + for (name, gpus, expected) in cases { + assert_eq!(offload_env(&gpus), expected, "{name}"); + } + } + + #[test] + fn pci_tags_match_the_shape_mesa_builds() { + assert_eq!(pci_tag("0000:01:00.0").as_deref(), Some("pci-0000_01_00_0")); + assert_eq!(pci_tag("10000:af:1f.7").as_deref(), None, "domain is 4 wide"); + assert_eq!(pci_tag("0000:1:00.0"), None, "bus is 2 wide"); + assert_eq!(pci_tag("0000:01:00"), None, "no function"); + assert_eq!(pci_tag("0000:0g:00.0"), None, "not hex"); + } + + #[test] + fn a_driver_that_will_not_answer_is_never_called_discrete() { + let mut apu = gpu(AMD_INTEGRATED, Driver::AmdGpu, Some(false)); + apu.discrete = probe_discrete(&apu); + assert!(!apu.discrete); + + let mut dgpu = gpu("0000:03:00.0", Driver::AmdGpu, Some(true)); + dgpu.discrete = probe_discrete(&dgpu); + assert!(!dgpu.discrete); + + assert!( + offload_env(&[apu, dgpu]).is_empty(), + "knowing nothing, leave the renderer alone" + ); + } + + #[test] + fn the_cards_we_can_place_without_asking_the_driver() { + assert!(probe_discrete(&gpu("0000:01:00.0", Driver::Nvidia, Some(false)))); + assert!(!probe_discrete(&gpu(INTEL_INTEGRATED, Driver::I915, Some(true)))); + assert!( + probe_discrete(&gpu("0000:03:00.0", Driver::I915, Some(false))), + "an arc board does not sit at the integrated address" + ); + assert!( + probe_discrete(&gpu("0000:01:00.0", Driver::Nouveau, Some(false))), + "no query, so boot_vga is all we have" + ); + } + + #[test] + fn an_unknown_module_name_is_not_fatal() { + assert_eq!(Driver::from_name(Some("asahi")), Driver::Other); + assert_eq!(Driver::from_name(None), Driver::Other); + } + + fn write(path: &Path, contents: &str) { + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(path, contents).unwrap(); + } + + fn write_card(bus: &Path, address: &str, boot_vga: &str, driver: &str) { + let device = bus.join(address); + write(&device.join("boot_vga"), &format!("{boot_vga}\n")); + write(&device.join("class"), "0x030000\n"); + + let target = bus.join("drivers").join(driver); + std::fs::create_dir_all(&target).unwrap(); + std::os::unix::fs::symlink(&target, device.join("driver")).unwrap(); + } + + #[test] + fn reads_boot_flag_driver_and_render_node_out_of_a_sysfs_tree() { + let scratch = polyio::testing::ScratchDir::new("gpu-sysfs"); + let bus = scratch.join("devices"); + + write_card(&bus, INTEL_INTEGRATED, "1", "i915"); + write( + &bus.join(INTEL_INTEGRATED) + .join("drm") + .join("renderD128") + .join("dev"), + "226:128\n", + ); + write_card(&bus, "0000:01:00.0", "0", "nvidia"); + + let gpus = read_pci_devices(&bus); + + assert_eq!(gpus.len(), 2); + assert_eq!(gpus[0].pci_address, INTEL_INTEGRATED); + assert_eq!(gpus[0].boot_vga, Some(true)); + assert_eq!(gpus[0].driver, Driver::I915); + assert_eq!( + gpus[0].render_node, + Some(PathBuf::from("/dev/dri/renderD128")), + "the node the ioctl probes open" + ); + assert_eq!(gpus[1].boot_vga, Some(false)); + assert_eq!(gpus[1].driver, Driver::Nvidia); + assert_eq!(gpus[1].render_node, None, "no nvidia-drm in this tree"); + + assert!(gpus.iter().all(|gpu| !gpu.discrete)); + } + + #[test] + fn skips_everything_on_the_bus_that_is_not_a_display_controller() { + let scratch = polyio::testing::ScratchDir::new("gpu-bus"); + let bus = scratch.join("devices"); + + write_card(&bus, "0000:01:00.0", "1", "amdgpu"); + write(&bus.join("0000:01:00.1").join("class"), "0x040300\n"); + write(&bus.join("0000:02:00.0").join("class"), "0x020000\n"); + std::fs::create_dir_all(bus.join("0000:03:00.0")).unwrap(); + + let gpus = read_pci_devices(&bus); + + assert_eq!(gpus.len(), 1, "one card, counted once"); + assert_eq!(gpus[0].driver, Driver::AmdGpu); + } + + #[test] + fn a_missing_pci_tree_is_not_an_error() { + let scratch = polyio::testing::ScratchDir::new("gpu-missing"); + + assert!(read_pci_devices(&scratch.join("nope")).is_empty()); + } +} diff --git a/packages/oneclient_core/src/game/gpu/mod.rs b/packages/oneclient_core/src/game/gpu/mod.rs new file mode 100644 index 00000000..e6d37f06 --- /dev/null +++ b/packages/oneclient_core/src/game/gpu/mod.rs @@ -0,0 +1,30 @@ +#[cfg(any(target_os = "linux", test))] +mod linux; + +use tokio::process::Command; + +#[allow(unused_variables)] +pub async fn prefer_discrete(command: &mut Command, java_path: &str) { + #[cfg(target_os = "linux")] + { + let gpus = linux::detect(); + let env = linux::offload_env(&gpus); + + if env.is_empty() { + tracing::info!( + gpus = gpus.len(), + "discrete GPU was requested but nothing here is a valid offload target; \ + leaving the renderer alone" + ); + return; + } + + for (key, value) in env { + tracing::debug!(key, value, "offloading the game to the discrete GPU"); + command.env(key, value); + } + } + + #[cfg(windows)] + oneclient_java::prefer_dedicated_gpu(std::path::Path::new(java_path)).await; +} diff --git a/packages/oneclient_core/src/game/heal.rs b/packages/oneclient_core/src/game/heal.rs index 7104ef1e..71571762 100644 --- a/packages/oneclient_core/src/game/heal.rs +++ b/packages/oneclient_core/src/game/heal.rs @@ -22,6 +22,27 @@ const SWEPT_CONTENT: [(ContentType, bool); 3] = [ (ContentType::Shader, false), ]; +#[tracing::instrument(level = "debug")] +pub async fn clear_zeroed_mods(cluster_dir: &Path) -> usize { + let mods_dir = cluster_dir.join(ContentType::Mod.folder_name()); + + tokio::task::spawn_blocking(move || { + let cleared = sweep_dir(&mods_dir, true); + + if cleared > 0 { + tracing::info!( + cleared, + mods_dir = %mods_dir.display(), + "cleared zero-filled files; affected mods will regenerate defaults" + ); + } + + cleared + }) + .await + .unwrap_or(0) +} + #[tracing::instrument(level = "debug")] pub async fn clear_zeroed_files(game_dir: &Path) -> usize { let game_dir = game_dir.to_path_buf(); diff --git a/packages/oneclient_core/src/game/launch.rs b/packages/oneclient_core/src/game/launch.rs index 71ac617b..ed7cc13d 100644 --- a/packages/oneclient_core/src/game/launch.rs +++ b/packages/oneclient_core/src/game/launch.rs @@ -148,9 +148,7 @@ async fn start( let mut metadata = state.metadata.lock().await; let (version, _index, updated) = - resolve_minecraft_version(&mut metadata, &state.services.mc(), &mc_version) - .await - .map_err(|_| GameError::InvalidVersion(cluster.mc_version.clone()))?; + resolve_minecraft_version(&mut metadata, &state.services.mc(), &mc_version).await?; let loader_version = get_loader_version( &mut metadata, @@ -253,15 +251,22 @@ async fn start( tracing::warn!(cluster_id, error = %err, "failed to write allowed_symlinks.txt"); } - // The one moment nothing holds the content open so this is where a package - // removed or disabled mid-session actually leaves the folder - if let Err(err) = crate::game::materialize_content(&state.services, &cluster, &cwd).await { + let custom_args = profile.launch_args.clone().unwrap_or_default(); + let loader_version_id = loader_version.as_ref().map(|lv| lv.id.as_str()); + + let mods_in_cluster = crate::game::uses_cluster_mods_folder( + cluster.mc_loader, + loader_version_id, + &custom_args, + ); + + if let Err(err) = + crate::game::materialize_content(&state.services, &cluster, &cwd, mods_in_cluster).await + { tracing::warn!(cluster_id, error = %err, "failed to materialize cluster content"); } if !dedicated { - // Redirects the shared dir's `logs`/`crash-reports` into this cluster's - // folder so output is attributable unlinked on exit crate::game::link_cluster_logs(&cluster, &cwd).await; } @@ -283,7 +288,7 @@ async fn start( updated, )?; - let jvm_args = arguments::java_arguments( + let mut jvm_args = arguments::java_arguments( updated, arg_map.get(&ArgumentType::Jvm).map(Vec::as_slice), &natives, @@ -296,6 +301,17 @@ async fn start( java.major, )?; + let mods_dir = paths::cluster_mods_dir(&cluster.folder_name)?; + if let Some(arg) = crate::game::mods_folder_argument( + cluster.mc_loader, + loader_version_id, + &custom_args, + &mods_dir, + ) { + tracing::debug!(cluster_id, mods_dir = %mods_dir.display(), "redirecting fabric mods folder"); + jvm_args.push(arg); + } + // Replace default Minecraft skin textures inside client jar, write resource packs, and sync mod folders inject_skin(&cwd, &client_jar, &account.id.to_string(), &account.username).await; @@ -328,14 +344,15 @@ async fn start( ); tracing::debug!(cluster_id, ?jvm_args, main_class = %version_info.main_class, "jvm arguments"); - let use_discrete_gpu = state.settings.read().use_discrete_gpu; - if use_discrete_gpu { - oneclient_java::prefer_dedicated_gpu(std::path::Path::new(&java.absolute_path)).await; + let mut command = base_command(&profile, &java.absolute_path); + + if profile.use_discrete_gpu() { + crate::game::gpu::prefer_discrete(&mut command, &java.absolute_path).await; } - let mut command = base_command(&profile, &java.absolute_path); - apply_env(&mut command, &profile); - command + apply_env(&mut command, &profile); + + command .args(jvm_args) .arg(&version_info.main_class) .args(mc_args) @@ -670,9 +687,6 @@ fn base_command(profile: &GameSettingsProfile, java_path: &str) -> Command { fn apply_env(command: &mut Command, profile: &GameSettingsProfile) { command.env_remove("_JAVA_OPTIONS"); - #[cfg(target_os = "linux")] - apply_discrete_gpu(command, profile); - if let Some(env) = &profile.launch_env { for pair in env.split_whitespace() { if let Some((key, value)) = pair.split_once('=') { @@ -682,36 +696,6 @@ fn apply_env(command: &mut Command, profile: &GameSettingsProfile) { } } -#[cfg(target_os = "linux")] -fn apply_discrete_gpu(command: &mut Command, profile: &GameSettingsProfile) { - let requested = profile - .os_extra - .as_ref() - .and_then(|extra| extra.use_discrete_gpu) - .unwrap_or(false); - - if !requested { - return; - } - - let gpus = crate::game::gpu::detect(); - let env = crate::game::gpu::offload_env(&gpus); - - if env.is_empty() { - tracing::info!( - gpus = gpus.len(), - "discrete GPU was requested but nothing here is a valid offload target; \ - leaving the renderer alone" - ); - return; - } - - for (key, value) in env { - tracing::debug!(key, value, "offloading the game to the discrete GPU"); - command.env(key, value); - } -} - #[tracing::instrument(skip(cwd), fields(hook), level = "debug")] async fn run_hook(hook: Option<&str>, cwd: &Path) { let Some(hook) = hook.map(str::trim).filter(|h| !h.is_empty()) else { diff --git a/packages/oneclient_core/src/game/mod.rs b/packages/oneclient_core/src/game/mod.rs index 8c6754a4..d9c59cdb 100644 --- a/packages/oneclient_core/src/game/mod.rs +++ b/packages/oneclient_core/src/game/mod.rs @@ -1,6 +1,6 @@ mod analytics; mod error; -#[cfg(any(target_os = "linux", test))] +pub mod fabric; mod gpu; mod heal; mod launch; @@ -38,6 +38,7 @@ pub use oneclient_mc::{ validate_rules, verify_game_files, }; +pub use fabric::{mods_folder_argument, uses_cluster_mods_folder}; pub use shared_dir::{ dematerialize_content, import_manual_content, link_cluster_logs, materialize_content, unlink_cluster_logs, write_allowed_symlinks, diff --git a/packages/oneclient_core/src/game/reattach.rs b/packages/oneclient_core/src/game/reattach.rs index ef1757da..54843134 100644 --- a/packages/oneclient_core/src/game/reattach.rs +++ b/packages/oneclient_core/src/game/reattach.rs @@ -92,10 +92,16 @@ async fn classify( return None; }; - let alive = session - .pid - .and_then(|pid| u32::try_from(pid).ok()) - .filter(|pid| is_process_alive(*pid, session.pid_started_at.map(|t| t as u64))); + let alive = match (session.pid, session.pid_started_at) { + (Some(pid), Some(pid_started_at)) => u32::try_from(pid) + .ok() + .filter(|pid| is_process_alive(*pid, Some(pid_started_at as u64))), + (Some(pid), None) => { + tracing::debug!(cluster_id, pid, "session has no process start time; not re-adopting"); + None + } + (None, _) => None, + }; Some((cluster, session, started_at, alive)) } @@ -227,14 +233,16 @@ async fn reconcile( let now = Utc::now().max(started_at); + let plausible = |at: DateTime| (started_at..=now).contains(&at).then_some(at); + // Prefer the log's own timestamps over mtime mtime only helps when nothing // in the log carries a time let ended_at = replay .stopped_at - .or(replay.last_activity) - .or_else(|| log.as_ref().and_then(|log| log.modified)) - .unwrap_or(started_at) - .clamp(started_at, now); + .and_then(plausible) + .or_else(|| replay.last_activity.and_then(plausible)) + .or_else(|| log.as_ref().and_then(|log| log.modified).and_then(plausible)) + .unwrap_or(started_at); tracing::info!( cluster_id, diff --git a/packages/oneclient_core/src/game/shared_dir.rs b/packages/oneclient_core/src/game/shared_dir.rs index d4155bdd..2d496e26 100644 --- a/packages/oneclient_core/src/game/shared_dir.rs +++ b/packages/oneclient_core/src/game/shared_dir.rs @@ -2,11 +2,15 @@ use std::collections::HashSet; use std::fs::FileType; use std::path::{Path, PathBuf}; +use oneclient_db::dao::applied_migration as migration_dao; use oneclient_db::dao::artifact as artifact_dao; +use oneclient_db::dao::cluster as cluster_dao; use crate::LauncherResult; use crate::clusters::Cluster; -use oneclient_common::domain::ContentType; +use oneclient_cluster::remove_mods_link; +use oneclient_common::domain::{ContentType, ProviderId}; +use oneclient_common::paths; use oneclient_content::packages::store::manifest::{ self, ManifestEntry, MaterializedManifest, }; @@ -18,11 +22,13 @@ use crate::state::LauncherServices; const REDIRECTED_DIRS: [&str; 2] = ["logs", "crash-reports"]; -const SWAP_TYPES: [ContentType; 3] = [ - ContentType::Mod, - ContentType::ResourcePack, - ContentType::Shader, -]; +const GLOBAL_TYPES: [ContentType; 2] = [ContentType::ResourcePack, ContentType::Shader]; + +const SWAP_TYPES: [ContentType; 1] = [ContentType::Mod]; + +fn swap_types(mods_in_cluster: bool) -> &'static [ContentType] { + if mods_in_cluster { &[] } else { &SWAP_TYPES } +} const FABRIC_DEP_OVERRIDES: &str = "config/fabric_loader_dependencies.json"; @@ -40,24 +46,87 @@ impl Desired { } } -/// Safe to run over a directory left by a crashed session another cluster or -/// a launcher version predating the manifest #[tracing::instrument(skip(services, cluster), fields(cluster_id = cluster.id, game_dir = %game_dir.display()), level = "debug")] pub async fn materialize_content( services: &LauncherServices, cluster: &Cluster, game_dir: &Path, + mods_in_cluster: bool, ) -> LauncherResult<()> { let dedicated = cluster.uses_dedicated_dir(); + let cluster_dir = cluster.dir()?; + let global_root = paths::shared_minecraft_dir()?; + polyio::create_dir_all(game_dir).await.ok(); + polyio::create_dir_all(&global_root).await.ok(); + if mods_in_cluster { + polyio::create_dir_all(paths::cluster_mods_dir(&cluster.folder_name)?) + .await + .ok(); + ensure_mods_link(cluster).await; + prune_mods_links(services).await; + } else { + unwind_cluster_mods(cluster, &cluster_dir).await; + } + + adopt_into_global(&cluster_dir, &global_root).await; + adopt_into_global(game_dir, &global_root).await; + ensure_global_links(game_dir, &global_root).await; + + let mods_swapped = !dedicated && !mods_in_cluster; + drop_stale_notes(&[game_dir, &cluster_dir, &global_root], mods_swapped).await; + + // In the shared directory this often belongs to another cluster so every + // use of it checks the id + let previous = manifest::load(game_dir, manifest::MANIFEST_NAME) + .await + .map(without_global_entries); + let previous_mods = manifest::load(&cluster_dir, manifest::MODS_MANIFEST_NAME).await; + let previous_global = manifest::load(&global_root, manifest::GLOBAL_MANIFEST_NAME).await; crate::game::heal::clear_zeroed_files(game_dir).await; + if mods_in_cluster { + crate::game::heal::clear_zeroed_mods(&cluster_dir).await; + } - import_manual_content(services, cluster, game_dir).await; + let linked = PackageStore::list_linked_artifacts(cluster.id, &services.content()) + .await + .unwrap_or_default(); - // Before the folder is built not after handing the game several enabled - // versions of one mod is a classloader conflict - if let Err(err) = oneclient_content::bundles::reconcile_duplicate_activity( + if mods_in_cluster && previous_mods.is_none() && !dedicated { + let from = game_dir.join(ContentType::Mod.folder_name()); + let into = cluster_dir.join(ContentType::Mod.folder_name()); + let ours = ours_in_folder(ContentType::Mod, &linked, previous.as_ref()); + + tracing::info!(cluster_id = cluster.id, "moving mods out of the shared game directory"); + stash_content_files(&from, &into, ContentType::Mod, &ours).await; + } + + for content_type in GLOBAL_TYPES { + let dir = global_root.join(content_type.folder_name()); + let disabled = disable_hand_removed( + services, + cluster, + &dir, + content_type, + previous_global.as_ref(), + ) + .await; + + if !disabled.is_empty() { + let (title, body) = removal_notice(&disabled); + services.events.notify(title).body(body).send(); + } + } + + if mods_in_cluster { + clear_unlinked_mods_once(services, cluster, &cluster_dir.join(ContentType::Mod.folder_name())) + .await; + } + + import_manual_content_with(services, cluster, game_dir, mods_in_cluster, true).await; + + if let Err(err) = oneclient_content::packages::reconcile_duplicate_activity( cluster.id, &services.content(), ) @@ -67,55 +136,446 @@ pub async fn materialize_content( tracing::warn!(cluster_id = cluster.id, %err, "failed to resolve duplicate package versions"); } + match oneclient_content::packages::disable_foreign_game_versions( + cluster.id, + &cluster.mc_version, + &services.content(), + ) + .await + { + Ok(disabled) if !disabled.is_empty() => { + let names = removal_summary(&disabled); + let body = if disabled.len() == 1 { + format!("{names} is built for a different Minecraft version, so it has been switched off in {}.", cluster.name) + } else { + format!("{names} are built for a different Minecraft version, so they have been switched off in {}.", cluster.name) + }; + services.events.notify("Incompatible mods switched off").body(body).send(); + } + Ok(_) => {} + Err(err) => { + tracing::warn!(cluster_id = cluster.id, %err, "failed to switch off mods built for another game version"); + } + } + + let active_mods_dir = if mods_in_cluster { + cluster_dir.as_path() + } else { + game_dir + } + .join(ContentType::Mod.folder_name()); + clear_disabled_mod_files(services, cluster, &active_mods_dir).await; + + let mut unrestored = Vec::new(); + let (mods, rest): (Vec, Vec) = + desired_mods(services, cluster, &mut unrestored) + .await? + .into_iter() + .partition(|_| mods_in_cluster); + + // read across every cluster rather than this one so a pack installed anywhere is present here too + let packs = desired_global(services, &mut unrestored).await?; + + if !unrestored.is_empty() { + let (title, body) = unrestored_notice(&unrestored); + services.events.notify(title).body(body).send(); + } + // Held from the database snapshot through the save so a package removed // mid-launch is not resurrected by our own write; the two calls above take // it themselves so it cannot be taken any earlier let _manifest = manifest::lock().await; - // In the shared directory this often belongs to another cluster so every - // use of it checks the id - let previous = manifest::load(game_dir).await; - - let desired = desired_content(services, cluster).await?; - let desired_paths: HashSet = desired.iter().map(Desired::relative_path).collect(); - + let mods_root = if mods_in_cluster { &cluster_dir } else { game_dir }; for content_type in SWAP_TYPES { - sweep_staging_files(&game_dir.join(content_type.folder_name())).await; + sweep_staging_files(&mods_root.join(content_type.folder_name())).await; + } + for content_type in GLOBAL_TYPES { + sweep_staging_files(&global_root.join(content_type.folder_name())).await; } // While the game is still closed this is what lands a package removed // mid-session and clears another cluster's content from the shared dir - prune_previous(game_dir, previous.as_ref(), &desired_paths).await; + let mod_paths: HashSet = mods.iter().map(Desired::relative_path).collect(); + let rest_paths: HashSet = rest.iter().map(Desired::relative_path).collect(); + let pack_paths: HashSet = packs.iter().map(Desired::relative_path).collect(); + + prune_previous(&cluster_dir, previous_mods.as_ref(), &mod_paths).await; + prune_previous(game_dir, previous.as_ref(), &rest_paths).await; + prune_previous(&global_root, previous_global.as_ref(), &pack_paths).await; if !dedicated { - let linked = PackageStore::list_linked_artifacts(cluster.id, &services.content()) - .await - .unwrap_or_default(); - - for content_type in SWAP_TYPES { + for content_type in swap_types(mods_in_cluster) { let dir = game_dir.join(content_type.folder_name()); - let stash = cluster.dir()?.join(content_type.folder_name()); + let stash = cluster_dir.join(content_type.folder_name()); polyio::create_dir_all(&dir).await.ok(); - // Whatever is still here belongs to whoever played (or crashed) - // last so take it into this cluster rather than deleting it - let ours = ours_in_folder(content_type, &linked, previous.as_ref()); - stash_content_files(&dir, &stash, &ours).await; - ensure_note(&dir, content_type).await; - restore_stashed(&stash, &dir, content_type, &ours).await; + let ours = ours_in_folder(*content_type, &linked, previous.as_ref()); + stash_content_files(&dir, &stash, *content_type, &ours).await; + ensure_note(&dir, *content_type).await; + restore_stashed(&stash, &dir, *content_type, &ours).await; } } - let entries = link_desired(game_dir, &desired).await; - manifest::save(game_dir, &MaterializedManifest::new(cluster.id, entries)).await; + if mods_in_cluster { + let mod_entries = link_desired(&cluster_dir, &mods).await; + drop_unmaterialized( + &cluster_dir.join(ContentType::Mod.folder_name()), + ContentType::Mod, + &mod_entries, + ) + .await; + manifest::save( + &cluster_dir, + manifest::MODS_MANIFEST_NAME, + &MaterializedManifest::new(cluster.id, mod_entries), + ) + .await; + } + + let pack_entries = link_desired(&global_root, &packs).await; + manifest::save( + &global_root, + manifest::GLOBAL_MANIFEST_NAME, + &MaterializedManifest::new(cluster.id, pack_entries), + ) + .await; + + let entries = link_desired(game_dir, &rest).await; + manifest::save( + game_dir, + manifest::MANIFEST_NAME, + &MaterializedManifest::new(cluster.id, entries), + ) + .await; sync_fabric_dep_overrides(cluster, game_dir).await?; Ok(()) } -/// Only for shared directories a dedicated cluster's content stays put between -/// sessions and [`materialize_content`] reconciles it at the next launch +// every enabled pack across every cluster +async fn desired_global( + services: &LauncherServices, + unrestored: &mut Vec, +) -> LauncherResult> { + let mut desired = Vec::new(); + + for content_type in GLOBAL_TYPES { + for row in artifact_dao::list_global_artifacts(&services.db, content_type as i64).await? { + if row.enabled == 0 { + continue; + } + + let Some(artifact) = artifact_dao::get_artifact_by_hash(&services.db, &row.hash).await? + else { + continue; + }; + + let Some(src) = cached_file(services, &row.hash, &artifact.path).await else { + unrestored.push(row.file_name); + continue; + }; + + desired.push(Desired { + content_type, + file_name: row.file_name, + hash: row.hash, + src, + }); + } + } + + Ok(desired) +} + +fn without_global_entries(mut manifest: MaterializedManifest) -> MaterializedManifest { + let prefixes: Vec = GLOBAL_TYPES + .iter() + .map(|content_type| format!("{}/", content_type.folder_name())) + .collect(); + + manifest + .entries + .retain(|entry| !prefixes.iter().any(|prefix| entry.path.starts_with(prefix))); + + manifest +} + +// moves a cluster's own pack folders into the shared one before [`ensure_global_links`] replaces them with links +async fn adopt_into_global(own_root: &Path, global_root: &Path) { + if own_root == global_root { + return; + } + + for content_type in GLOBAL_TYPES { + let own = own_root.join(content_type.folder_name()); + let shared = global_root.join(content_type.folder_name()); + + match polyio::symlink_metadata(&own).await { + Ok(meta) if meta.is_dir() && !meta.file_type().is_symlink() => {} + _ => continue, + } + + polyio::create_dir_all(&shared).await.ok(); + + let Ok(mut entries) = polyio::read_dir(&own).await else { + continue; + }; + + while let Ok(Some(entry)) = entries.next_entry().await { + let name = entry.file_name(); + let dest = shared.join(&name); + + if polyio::symlink_metadata(&dest).await.is_ok() { + continue; + } + + if let Err(err) = move_entry(&entry.path(), &dest).await { + tracing::warn!( + file = %name.to_string_lossy(), + error = %err, + "failed to move content into the shared folder; leaving it in place" + ); + } + } + } +} + +// points a cluster's pack folders at the shared ones +async fn ensure_global_links(game_dir: &Path, global_root: &Path) { + if game_dir == global_root { + return; + } + + for content_type in GLOBAL_TYPES { + let link = game_dir.join(content_type.folder_name()); + let target = global_root.join(content_type.folder_name()); + polyio::create_dir_all(&target).await.ok(); + + match polyio::symlink_metadata(&link).await { + Ok(meta) if meta.file_type().is_symlink() => { + let aimed_right = matches!( + (polyio::canonicalize(&link), polyio::canonicalize(&target)), + (Ok(from), Ok(to)) if from == to + ); + if aimed_right { + continue; + } + + polyio::remove_symlink_dir(&link).await.ok(); + } + + Ok(meta) if meta.is_dir() => { + if polyio::remove_dir_all(&link).await.is_err() { + tracing::warn!( + dir = %link.display(), + "cannot clear the cluster's own pack folder; leaving it unlinked" + ); + continue; + } + } + + Ok(_) => continue, + Err(_) => {} + } + + if let Err(err) = polyio::symlink_dir(&target, &link).await { + tracing::warn!( + dir = %link.display(), + error = %err, + "failed to link the shared pack folder into the game directory" + ); + } + } +} + +const MASS_REMOVAL_FLOOR: usize = 3; + +// names in a manifest whose file is no longer on disk paired with the hash that identifies the row to disable +async fn hand_removed_content( + dir: &Path, + content_type: ContentType, + previous: Option<&MaterializedManifest>, +) -> Vec<(String, String)> { + let Some(previous) = previous else { + return Vec::new(); + }; + + if polyio::read_dir(dir).await.is_err() { + tracing::warn!( + dir = %dir.display(), + "cannot read the content folder; leaving activity alone" + ); + return Vec::new(); + } + + let prefix = format!("{}/", content_type.folder_name()); + let mut considered = 0usize; + let mut removed = Vec::new(); + + for entry in &previous.entries { + let Some(name) = entry.path.strip_prefix(&prefix) else { + continue; + }; + considered += 1; + + if polyio::symlink_metadata(dir.join(name)).await.is_ok() { + continue; + } + + removed.push((name.to_owned(), entry.hash.clone())); + } + + if removed.len() == considered && considered >= MASS_REMOVAL_FLOOR { + tracing::warn!( + count = considered, + dir = %dir.display(), + "everything materialized here is missing; reading that as a folder problem, not as deletions" + ); + return Vec::new(); + } + + removed +} + +// turns hand-removed content off so the next launch stops putting it back +async fn disable_hand_removed( + services: &LauncherServices, + cluster: &Cluster, + dir: &Path, + content_type: ContentType, + previous: Option<&MaterializedManifest>, +) -> Vec { + let removed = hand_removed_content(dir, content_type, previous).await; + if removed.is_empty() { + return Vec::new(); + } + + let ctx = services.content(); + let mut disabled = Vec::new(); + + for (name, hash) in removed { + let outcome = if content_type.is_global() { + disable_globally(cluster, &hash, &ctx).await + } else { + oneclient_content::bundles::set_artifact_enabled_to(cluster.id, &hash, false, &ctx) + .await + .map(|_| ()) + .map_err(Into::into) + }; + + match outcome { + Ok(()) => { + tracing::info!( + cluster_id = cluster.id, + file = %name, + ?content_type, + "removed by hand; disabling it instead of restoring it" + ); + disabled.push(name); + } + Err(err) => tracing::warn!( + cluster_id = cluster.id, + file = %name, + error = %err, + "failed to disable hand-removed content; it will be restored" + ), + } + } + + disabled +} + +// switches a globally installed artifact off for every cluster that has it +async fn disable_globally( + cluster: &Cluster, + hash: &str, + ctx: &oneclient_content::ContentCtx, +) -> LauncherResult<()> { + artifact_dao::set_enabled_for_hash(&ctx.db, hash, 0).await?; + oneclient_content::bundles::on_user_disable_artifact(cluster.id, hash, ctx).await?; + Ok(()) +} + +// at most three names +fn removal_summary(disabled: &[String]) -> String { + const SHOWN: usize = 3; + + let names = disabled + .iter() + .take(SHOWN) + .map(String::as_str) + .collect::>() + .join(", "); + + match disabled.len().saturating_sub(SHOWN) { + 0 => names, + rest => format!("{names} and {rest} more"), + } +} + +fn removal_notice(disabled: &[String]) -> (&'static str, String) { + let names = removal_summary(disabled); + + if disabled.len() == 1 { + return ( + "Content disabled", + format!( + "{names} is gone from your shared folder, so it has been switched off on every \ + cluster. Turn it back on in OneClient to restore it." + ), + ); + } + + ( + "Content disabled", + format!( + "{names} are gone from your shared folder, so they have been switched off on every \ + cluster. Turn them back on in OneClient to restore them." + ), + ) +} + +fn unrestored_notice(unrestored: &[String]) -> (&'static str, String) { + let names = removal_summary(unrestored); + + if unrestored.len() == 1 { + return ( + "Missing from the game", + format!( + "{names} could not be downloaded again, so the game starts without it. \ + Reinstall it in OneClient once you are back online." + ), + ); + } + + ( + "Missing from the game", + format!( + "{names} could not be downloaded again, so the game starts without them. \ + Reinstall them in OneClient once you are back online." + ), + ) +} + +// puts a cluster back on the old layout after its loader stopped supporting `fabric.modsFolder` (a downgrade or a switch away from Fabric 0.15.0) +async fn unwind_cluster_mods(cluster: &Cluster, cluster_dir: &Path) { + remove_mods_link(&cluster.folder_name).await; + + let Some(previous) = manifest::load(cluster_dir, manifest::MODS_MANIFEST_NAME).await else { + return; + }; + + tracing::info!( + cluster_id = cluster.id, + "loader cannot be redirected; returning mods to the game directory" + ); + + prune_previous(cluster_dir, Some(&previous), &HashSet::new()).await; + manifest::clear(cluster_dir, manifest::MODS_MANIFEST_NAME).await; +} + #[tracing::instrument(skip(services, cluster), fields(cluster_id = cluster.id), level = "debug")] pub async fn dematerialize_content( services: &LauncherServices, @@ -128,35 +588,39 @@ pub async fn dematerialize_content( import_manual_content(services, cluster, game_dir).await; let _manifest = manifest::lock().await; - let current = manifest::load(game_dir).await; + let cluster_dir = cluster.dir()?; + let current = manifest::load(game_dir, manifest::MANIFEST_NAME).await; let linked = PackageStore::list_linked_artifacts(cluster.id, &services.content()) .await .unwrap_or_default(); - for content_type in SWAP_TYPES { + let mods_in_cluster = manifest::mods_live_in_cluster(&cluster_dir).await; + + for content_type in swap_types(mods_in_cluster) { let dir = game_dir.join(content_type.folder_name()); - let stash = cluster.dir()?.join(content_type.folder_name()); + let stash = cluster_dir.join(content_type.folder_name()); polyio::create_dir_all(&dir).await.ok(); - let ours = ours_in_folder(content_type, &linked, current.as_ref()); - stash_content_files(&dir, &stash, &ours).await; + let ours = ours_in_folder(*content_type, &linked, current.as_ref()); + stash_content_files(&dir, &stash, *content_type, &ours).await; sweep_staging_files(&dir).await; - ensure_note(&dir, content_type).await; + ensure_note(&dir, *content_type).await; } - manifest::clear(game_dir).await; + manifest::clear(game_dir, manifest::MANIFEST_NAME).await; Ok(()) } -async fn desired_content( +async fn desired_mods( services: &LauncherServices, cluster: &Cluster, + unrestored: &mut Vec, ) -> LauncherResult> { let linked = PackageStore::list_linked_artifacts(cluster.id, &services.content()).await?; let mut desired = Vec::with_capacity(linked.len()); for link in linked { - if !link.enabled || !SWAP_TYPES.contains(&link.content_type) { + if !link.enabled || link.content_type != ContentType::Mod { continue; } @@ -165,11 +629,10 @@ async fn desired_content( continue; }; - let src = artifact_absolute_path(&artifact.path)?; - if !polyio::try_exists(&src).await.unwrap_or(false) { - tracing::warn!(hash = %link.hash, "cached artifact missing; skipping"); + let Some(src) = cached_file(services, &link.hash, &artifact.path).await else { + unrestored.push(link.cluster_file_name); continue; - } + }; desired.push(Desired { content_type: link.content_type, @@ -182,13 +645,44 @@ async fn desired_content( Ok(desired) } -/// Only files that made it are recorded so a failed link is never later -/// mistaken for ours and deleted out from under the user -async fn link_desired(game_dir: &Path, desired: &[Desired]) -> Vec { +async fn cached_file( + services: &LauncherServices, + hash: &str, + stored_path: &str, +) -> Option { + let src = artifact_absolute_path(stored_path).ok()?; + if polyio::try_exists(&src).await.unwrap_or(false) { + return Some(src); + } + + let release = artifact_dao::get_release_by_hash(&services.db, hash) + .await + .ok() + .flatten()?; + let provider = ProviderId::from_repr(release.provider as u8)?; + + tracing::info!(hash, "cached package file is gone; fetching it again"); + + let restored = PackageStore::resolve_or_download( + provider, + &release.project_id, + &release.version_id, + &services.content(), + ) + .await + .inspect_err(|err| tracing::warn!(hash, error = %err, "could not restore a cached package")) + .ok() + .filter(|artifact| artifact.hash == hash)?; + + let path = artifact_absolute_path(&restored.path).ok()?; + polyio::try_exists(&path).await.unwrap_or(false).then_some(path) +} + +async fn link_desired(root: &Path, desired: &[Desired]) -> Vec { let mut entries = Vec::with_capacity(desired.len()); for item in desired { - let dest = game_dir + let dest = root .join(item.content_type.folder_name()) .join(&item.file_name); @@ -208,10 +702,99 @@ async fn link_desired(game_dir: &Path, desired: &[Desired]) -> Vec return, + Ok(false) => {} + Err(err) => { + tracing::warn!(error = %err, "cannot read the repair log; leaving the mods folder alone"); + return; + } + } + + let linked = match PackageStore::list_linked_artifacts(cluster.id, &services.content()).await { + Ok(linked) => linked, + Err(err) => { + tracing::warn!(error = %err, "cannot list links; leaving the mods folder alone"); + return; + } + }; + + let ours = names_linked_here(&linked, ContentType::Mod); + let Ok(mut entries) = polyio::read_dir(mods_dir).await else { + mark_repair_applied(services, &id).await; + return; + }; + + while let Ok(Some(entry)) = entries.next_entry().await { + let path = entry.path(); + let Some(name) = path.file_name().and_then(|name| name.to_str()) else { + continue; + }; + if name.starts_with('.') + || is_note(name) + || !has_content_extension(ContentType::Mod, name) + || ours.contains(name) + { + continue; + } + + tracing::info!(file = name, "clearing a mod this cluster never linked"); + if let Err(err) = remove_entry(&path).await { + tracing::warn!(file = name, error = %err, "failed to clear an unlinked mod"); + } + } + + mark_repair_applied(services, &id).await; +} + +async fn mark_repair_applied(services: &LauncherServices, id: &str) { + if let Err(err) = migration_dao::mark_applied(&services.db, id).await { + tracing::warn!(repair = id, error = %err, "failed to record a one-time repair"); + } +} + +async fn drop_unmaterialized(dir: &Path, content_type: ContentType, entries: &[ManifestEntry]) { + let kept: HashSet<&str> = entries + .iter() + .filter_map(|entry| entry.path.rsplit('/').next()) + .collect(); + + let Ok(mut read) = polyio::read_dir(dir).await else { + return; + }; + + while let Ok(Some(entry)) = read.next_entry().await { + let path = entry.path(); + let Some(name) = path.file_name().and_then(|name| name.to_str()) else { + continue; + }; + if name.starts_with('.') || is_note(name) || !has_content_extension(content_type, name) { + continue; + } + if kept.contains(name) { + continue; + } + + tracing::info!( + file = name, + dir = %dir.display(), + "clearing content the launcher no longer lists for this cluster" + ); + if let Err(err) = remove_entry(&path).await { + tracing::warn!(file = name, error = %err, "failed to clear stale content"); + } + } +} + /// Entries are keyed by path so a package whose file name is unchanged is left /// in place rather than being deleted and relinked on every launch async fn prune_previous( - game_dir: &Path, + root: &Path, previous: Option<&MaterializedManifest>, keep: &HashSet, ) { @@ -224,7 +807,7 @@ async fn prune_previous( continue; } - let path = game_dir.join(&entry.path); + let path = root.join(&entry.path); if let Err(err) = remove_entry(&path).await { tracing::warn!( file = %entry.path, @@ -235,6 +818,53 @@ async fn prune_previous( } } +#[tracing::instrument(skip(services, cluster), fields(cluster_id = cluster.id), level = "debug")] +async fn clear_disabled_mod_files(services: &LauncherServices, cluster: &Cluster, mods_dir: &Path) { + let linked = match PackageStore::list_linked_artifacts(cluster.id, &services.content()).await { + Ok(linked) => linked, + Err(err) => { + tracing::warn!(error = %err, "cannot list links; leaving the mods folder alone"); + return; + } + }; + + for name in disabled_mod_names(&linked) { + let path = mods_dir.join(&name); + if polyio::symlink_metadata(&path).await.is_err() { + continue; + } + + match remove_entry(&path).await { + Ok(()) => tracing::info!(file = %name, "cleared a switched-off mod from the folder"), + Err(err) => { + tracing::warn!(file = %name, error = %err, "failed to clear a switched-off mod") + } + } + } +} + +fn disabled_mod_names( + linked: &[oneclient_content::packages::LinkedArtifactInfo], +) -> HashSet { + let mods = || { + linked + .iter() + .filter(|link| link.content_type == ContentType::Mod) + }; + + let live: HashSet<&str> = mods() + .filter(|link| link.enabled) + .map(|link| link.cluster_file_name.as_str()) + .collect(); + + mods() + .filter(|link| !link.enabled) + .map(|link| link.cluster_file_name.as_str()) + .filter(|name| !live.contains(name)) + .map(str::to_owned) + .collect() +} + /// Names in one folder that are the launcher's not the user's what we /// materialized last plus what the database tracks (covering files just /// adopted by [`import_manual_content`]) @@ -268,6 +898,21 @@ pub async fn import_manual_content( services: &LauncherServices, cluster: &Cluster, game_dir: &Path, +) { + let mods_in_cluster = match cluster.dir() { + Ok(dir) => manifest::mods_live_in_cluster(&dir).await, + Err(_) => false, + }; + + import_manual_content_with(services, cluster, game_dir, mods_in_cluster, false).await; +} + +async fn import_manual_content_with( + services: &LauncherServices, + cluster: &Cluster, + game_dir: &Path, + mods_in_cluster: bool, + discard_originals: bool, ) { let linked = match PackageStore::list_linked_artifacts(cluster.id, &services.content()).await { Ok(linked) => linked, @@ -280,73 +925,165 @@ pub async fn import_manual_content( // Not held across the import loop below, which is long and does not need it let manifest = { let _guard = manifest::lock().await; - manifest::load(game_dir).await + manifest::load(game_dir, manifest::MANIFEST_NAME).await }; - for content_type in SWAP_TYPES { - let dir = game_dir.join(content_type.folder_name()); - let Ok(mut entries) = polyio::read_dir(&dir).await else { - continue; + // under the old layout mods sit in the game directory and are matched + // against its manifest exactly like resource packs and shaders + let cluster_dir = cluster.dir().ok(); + let mods_manifest = match cluster_dir.as_deref() { + Some(dir) if mods_in_cluster => manifest::load(dir, manifest::MODS_MANIFEST_NAME).await, + _ => None, + }; + + let (mods_dir, mods_manifest) = match cluster_dir.as_deref() { + Some(dir) if mods_in_cluster => ( + dir.join(ContentType::Mod.folder_name()), + mods_manifest.as_ref(), + ), + _ => ( + game_dir.join(ContentType::Mod.folder_name()), + manifest.as_ref(), + ), + }; + + import_from_dir( + services, + cluster, + &mods_dir, + ContentType::Mod, + &names_linked_here(&linked, ContentType::Mod), + mods_manifest, + discard_originals, + ) + .await; + + let Ok(global_root) = paths::shared_minecraft_dir() else { + return; + }; + let global_manifest = manifest::load(&global_root, manifest::GLOBAL_MANIFEST_NAME).await; + + for content_type in GLOBAL_TYPES { + let dir = global_root.join(content_type.folder_name()); + let known = match artifact_dao::list_global_artifacts(&services.db, content_type as i64) + .await + { + Ok(rows) => rows.into_iter().map(|row| row.file_name).collect(), + Err(err) => { + tracing::warn!(error = %err, "cannot list global content; skipping its import"); + continue; + } }; - let known: HashSet<&str> = linked - .iter() - .filter(|link| link.content_type == content_type) - .map(|link| link.cluster_file_name.as_str()) - .collect(); + import_from_dir( + services, + cluster, + &dir, + content_type, + &known, + global_manifest.as_ref(), + discard_originals, + ) + .await; + } +} - while let Ok(Some(entry)) = entries.next_entry().await { - let Ok(file_type) = entry.file_type().await else { - continue; - }; - if !file_type.is_file() { - continue; - } +fn names_linked_here( + linked: &[oneclient_content::packages::LinkedArtifactInfo], + content_type: ContentType, +) -> HashSet { + linked + .iter() + .filter(|link| link.content_type == content_type) + .map(|link| link.cluster_file_name.clone()) + .collect() +} - let path = entry.path(); - let Some(name) = path.file_name().and_then(|n| n.to_str()) else { - continue; - }; - if name.starts_with('.') || !has_content_extension(content_type, name) { - continue; - } - if known.contains(name) { - continue; - } +async fn import_from_dir( + services: &LauncherServices, + cluster: &Cluster, + dir: &Path, + content_type: ContentType, + known: &HashSet, + manifest: Option<&MaterializedManifest>, + discard_originals: bool, +) { + let Ok(mut entries) = polyio::read_dir(dir).await else { + return; + }; - // Ours from a previous launch `prune_previous` handles it - // Taking it as a user drop-in would reinstall a just-deleted package - let relative = manifest::entry_path(content_type.folder_name(), name); - if manifest.as_ref().is_some_and(|m| m.contains(&relative)) { - continue; - } + while let Ok(Some(entry)) = entries.next_entry().await { + let Ok(file_type) = entry.file_type().await else { + continue; + }; + if !file_type.is_file() { + continue; + } - // No manifest a directory from a launcher version predating it - // If the cache holds this exact file the launcher put it here - if manifest.is_none() && is_cached_artifact(services, &path).await { - tracing::debug!(file = name, "discarding stale launcher content in game dir"); - if let Err(err) = polyio::remove_file(&path).await { - tracing::warn!(file = name, error = %err, "failed to discard stale content"); + let path = entry.path(); + let Some(name) = path.file_name().and_then(|n| n.to_str()) else { + continue; + }; + if name.starts_with('.') || !has_content_extension(content_type, name) { + continue; + } + if known.contains(name) { + continue; + } + + let relative = manifest::entry_path(content_type.folder_name(), name); + if manifest.is_some_and(|m| m.contains(&relative)) { + continue; + } + + if is_cached_artifact(services, &path).await { + tracing::debug!( + file = name, + dir = %dir.display(), + "discarding stale launcher content; the cache still holds it" + ); + if let Err(err) = polyio::remove_file(&path).await { + tracing::warn!(file = name, error = %err, "failed to discard stale content"); + } + continue; + } + + match PackageStore::import_local_file(&path, content_type, cluster.id, &services.content()) + .await + { + Ok(row) => { + tracing::debug!(file = name, "registered manually-added content"); + if discard_originals { + discard_adopted_original(&row, &path, name).await; } - continue; - } - - match PackageStore::import_local_file(&path, content_type, cluster.id, &services.content()).await { - Ok(_) => { - tracing::debug!(file = name, "registered manually-added content") - } - Err(err) => tracing::warn!( - file = name, - error = %err, - "failed to register manually-added content" - ), } + Err(err) => tracing::warn!( + file = name, + error = %err, + "failed to register manually-added content" + ), } } } -/// Already in the artifact cache i.e. the launcher put it in the game dir -/// rather than the user dropping it there +async fn discard_adopted_original( + row: &oneclient_db::models::ArtifactRow, + path: &Path, + name: &str, +) { + match artifact_absolute_path(&row.path) { + Ok(cached) if polyio::try_exists(&cached).await.unwrap_or(false) => {} + _ => { + tracing::warn!(file = name, "adopted content is not in the cache; leaving the original"); + return; + } + } + + if let Err(err) = polyio::remove_file(path).await { + tracing::warn!(file = name, error = %err, "failed to clear the adopted original"); + } +} + async fn is_cached_artifact(services: &LauncherServices, path: &Path) -> bool { let Ok(hash) = polyio::sha1_file(path).await else { return false; @@ -368,6 +1105,108 @@ fn has_content_extension(content_type: ContentType, name: &str) -> bool { } } +// puts this cluster's mods folder into the shared `mods` directory +#[tracing::instrument(skip(cluster), fields(cluster_id = cluster.id), level = "debug")] +async fn ensure_mods_link(cluster: &Cluster) { + let (Ok(link), Ok(target)) = ( + paths::shared_mods_link(&cluster.folder_name), + paths::cluster_mods_dir(&cluster.folder_name), + ) else { + return; + }; + + match polyio::symlink_metadata(&link).await { + Ok(meta) if meta.file_type().is_symlink() => { + let aimed_right = matches!( + (polyio::canonicalize(&link), polyio::canonicalize(&target)), + (Ok(from), Ok(to)) if from == to + ); + if aimed_right { + return; + } + + polyio::remove_symlink_dir(&link).await.ok(); + } + + Ok(_) => { + tracing::warn!( + folder = %cluster.folder_name, + "shared mods folder holds a real entry under this name; not linking" + ); + return; + } + + Err(_) => {} + } + + if let Some(parent) = link.parent() { + polyio::create_dir_all(parent).await.ok(); + ensure_links_note(parent).await; + } + polyio::create_dir_all(&target).await.ok(); + + if let Err(err) = polyio::symlink_dir(&target, &link).await { + tracing::warn!( + folder = %cluster.folder_name, + error = %err, + "failed to link cluster mods into the shared minecraft folder" + ); + } +} + +async fn points_into_clusters_dir(path: &Path) -> bool { + let (Ok(target), Ok(root)) = (polyio::read_link(path).await, paths::clusters_dir()) else { + return false; + }; + + target.starts_with(root) +} + +// drops links a deleted cluster left behind +#[tracing::instrument(skip(services), level = "debug")] +async fn prune_mods_links(services: &LauncherServices) { + let Ok(root) = paths::shared_mods_dir() else { + return; + }; + + let Ok(mut entries) = polyio::read_dir(&root).await else { + return; + }; + + let known: HashSet = match cluster_dao::list_all(&services.db).await { + Ok(rows) => rows.into_iter().map(|row| row.folder_name).collect(), + Err(err) => { + tracing::warn!(error = %err, "cannot list clusters; leaving shared mods links alone"); + return; + } + }; + + while let Ok(Some(entry)) = entries.next_entry().await { + let Ok(file_type) = entry.file_type().await else { + continue; + }; + if !file_type.is_symlink() { + continue; + } + + let name = entry.file_name().to_string_lossy().into_owned(); + if known.contains(&name) { + continue; + } + + if !points_into_clusters_dir(&entry.path()).await { + continue; + } + + match polyio::remove_symlink_dir(entry.path()).await { + Ok(()) => tracing::debug!(link = %name, "cleared mods link for a deleted cluster"), + Err(err) => { + tracing::warn!(link = %name, error = %err, "failed to clear stale cluster mods link") + } + } + } +} + const ALLOWED_SYMLINKS_NAME: &str = "allowed_symlinks.txt"; #[tracing::instrument(level = "debug")] @@ -404,11 +1243,44 @@ fn allowed_symlinks_body(roots: &[PathBuf]) -> String { } const EMPTY_NOTE_NAME: &str = "WHY_NOTHING_HERE.txt"; +const LINKS_NOTE_NAME: &str = "EACH_FOLDER_IS_A_CLUSTER.txt"; -/// Launcher-owned content is dropped (the cache has it) everything else -/// (sidecars unzipped packs stray configs) is *moved* into the cluster folder -/// so it stays attached to that cluster and [`restore_stashed`] links it back -async fn stash_content_files(dir: &Path, stash: &Path, ours: &HashSet) { +fn is_note(name: &str) -> bool { + name == EMPTY_NOTE_NAME || name == LINKS_NOTE_NAME +} + +async fn ensure_links_note(dir: &Path) { + let note = dir.join(LINKS_NOTE_NAME); + + if polyio::try_exists(¬e).await.unwrap_or(false) { + return; + } + + let noun = ContentType::Mod.folder_name(); + + polyio::write( + ¬e, + format!( + "PLEASE READ CAREFULLY!!!!\n\ + \n\ + OneClient SPLITS your {noun} folder per version/cluster, so that you can have \ + separate {noun} in each.\n\ + \n\ + Add / remove {noun} IN THESE FOLDERS!!!\n\ + \n\ + Also... you can drag your {noun} and stuff straight into the launcher as well...\n" + ), + ) + .await + .ok(); +} + +async fn stash_content_files( + dir: &Path, + stash: &Path, + content_type: ContentType, + ours: &HashSet, +) { let Ok(mut entries) = polyio::read_dir(dir).await else { return; }; @@ -418,7 +1290,7 @@ async fn stash_content_files(dir: &Path, stash: &Path, ours: &HashSet) { let Some(name) = path.file_name().and_then(|n| n.to_str()).map(str::to_owned) else { continue; }; - if name == EMPTY_NOTE_NAME || name.starts_with('.') { + if is_note(&name) || name.starts_with('.') { continue; } @@ -426,13 +1298,29 @@ async fn stash_content_files(dir: &Path, stash: &Path, ours: &HashSet) { continue; }; - // Ours and the cache has it covered - // On Windows `symlink_file` hard-links so ours is not always a symlink - if file_type.is_symlink() || ours.contains(&name) { + if file_type.is_symlink() { + if points_into_clusters_dir(&path).await { + continue; + } + remove_dir_or_file(&path, file_type).await; continue; } + if ours.contains(&name) { + remove_dir_or_file(&path, file_type).await; + continue; + } + + if file_type.is_file() && has_content_extension(content_type, &name) { + tracing::debug!( + file = %name, + dir = %dir.display(), + "leaving unrecognised content where it is rather than stashing it" + ); + continue; + } + let dest = stash.join(&name); if let Err(err) = move_entry(&path, &dest).await { tracing::warn!( @@ -444,8 +1332,6 @@ async fn stash_content_files(dir: &Path, stash: &Path, ours: &HashSet) { } } -/// Links stashed leftovers back so the game writes straight through into the -/// cluster folder even if we never get to run on exit async fn restore_stashed( stash: &Path, dir: &Path, @@ -461,7 +1347,7 @@ async fn restore_stashed( let Some(name) = path.file_name().and_then(|n| n.to_str()).map(str::to_owned) else { continue; }; - if name == EMPTY_NOTE_NAME || name.starts_with('.') || ours.contains(&name) { + if is_note(&name) || name.starts_with('.') || ours.contains(&name) { continue; } @@ -533,6 +1419,45 @@ async fn move_entry(src: &Path, dest: &Path) -> LauncherResult<()> { Ok(()) } +async fn drop_stale_notes(roots: &[&Path], mods_swapped: bool) { + let mut swept: Vec<&Path> = Vec::new(); + + for root in roots { + // The shared game directory *is* the global root for a cluster without + // a dedicated directory, and both are the cluster folder for one with + // it visiting a root twice would only walk the same folders again + if swept.contains(root) { + continue; + } + swept.push(*root); + + for content_type in GLOBAL_TYPES { + drop_note(&root.join(content_type.folder_name())).await; + } + + if !mods_swapped { + drop_note(&root.join(ContentType::Mod.folder_name())).await; + } + } +} + +async fn drop_note(dir: &Path) { + let note = dir.join(EMPTY_NOTE_NAME); + if polyio::symlink_metadata(¬e).await.is_err() { + return; + } + + match polyio::remove_file(¬e).await { + Ok(()) => tracing::debug!(dir = %dir.display(), "removed a stale empty-folder note"), + // Nothing downstream reads it the next launch tries again + Err(err) => tracing::debug!( + dir = %dir.display(), + error = %err, + "could not remove the stale empty-folder note" + ), + } +} + async fn ensure_note(dir: &Path, content_type: ContentType) { let note = dir.join(EMPTY_NOTE_NAME); @@ -694,9 +1619,56 @@ mod tests { assert_eq!(body.lines().count(), 1); } - use super::*; + fn link(file_name: &str, enabled: bool) -> oneclient_content::packages::LinkedArtifactInfo { + oneclient_content::packages::LinkedArtifactInfo { + hash: format!("{file_name}-hash"), + cluster_file_name: file_name.into(), + enabled, + content_type: ContentType::Mod, + file_name: file_name.into(), + project_id: None, + version_id: None, + display_name: None, + display_version: None, + provider: None, + published_at: None, + seen_status: oneclient_db::models::SeenStatus::Seen, + } + } + + #[test] + fn a_switched_off_jar_goes_unless_a_live_copy_shares_its_name() { + let names = disabled_mod_names(&[ + link("NBTac-FABRIC-26.2-2.0.1.jar", true), + link("NBTac-FABRIC-26.1-1.3.15.jar", false), + link("shared.jar", true), + link("shared.jar", false), + ]); + + assert!( + names.contains("NBTac-FABRIC-26.1-1.3.15.jar"), + "the jar the old stash stranded has to go" + ); + assert!( + !names.contains("NBTac-FABRIC-26.2-2.0.1.jar"), + "a mod that is on is never cleared" + ); + assert!( + !names.contains("shared.jar"), + "a name another copy still has switched on must survive" + ); + } + + #[test] + fn other_content_types_are_left_to_their_own_folders() { + let mut pack = link("pack.zip", false); + pack.content_type = ContentType::ResourcePack; + + assert!(disabled_mod_names(&[pack]).is_empty()); + } + fn names(names: &[&str]) -> HashSet { names.iter().map(|n| (*n).to_string()).collect() } @@ -732,7 +1704,7 @@ mod tests { .await .unwrap(); - stash_content_files(&shared, &stash, &ours).await; + stash_content_files(&shared, &stash, ContentType::Shader, &ours).await; assert!(!shared.join("bsl.zip").exists(), "managed pack left behind"); assert!(!shared.join("bsl.zip.txt").exists(), "sidecar left behind"); assert_eq!( @@ -767,7 +1739,7 @@ mod tests { .await .unwrap(); - stash_content_files(&shared, &stash, &HashSet::new()).await; + stash_content_files(&shared, &stash, ContentType::Shader, &HashSet::new()).await; assert!(!shared.join("Loose").exists()); assert!(stash.join("Loose/shaders/final.fsh").exists()); @@ -775,7 +1747,7 @@ mod tests { assert!(shared.join("Loose/shaders/final.fsh").exists()); // Only the link goes never the stashed original - stash_content_files(&shared, &stash, &HashSet::new()).await; + stash_content_files(&shared, &stash, ContentType::Shader, &HashSet::new()).await; assert!(!shared.join("Loose").exists()); assert!(stash.join("Loose/shaders/final.fsh").exists()); @@ -792,7 +1764,7 @@ mod tests { .await .unwrap(); - stash_content_files(&shared, &stash, &HashSet::new()).await; + stash_content_files(&shared, &stash, ContentType::Mod, &HashSet::new()).await; assert!(shared.join(EMPTY_NOTE_NAME).exists()); assert!(!stash.join(EMPTY_NOTE_NAME).exists()); @@ -894,7 +1866,6 @@ mod tests { std::fs::remove_dir_all(root.path()).ok(); } - /// Pinned because the halves are easy to swap `stash` reads the game dir /// and writes the cluster `restore` the reverse and both take two /// same-typed `&Path`s a swap would quietly delete a user's files @@ -925,9 +1896,9 @@ mod tests { let before = dir_entries(&stash).await; - stash_content_files(&shared, &stash, &ours).await; + stash_content_files(&shared, &stash, ContentType::Mod, &ours).await; restore_stashed(&stash, &shared, ContentType::Mod, &ours).await; - stash_content_files(&shared, &stash, &ours).await; + stash_content_files(&shared, &stash, ContentType::Mod, &ours).await; restore_stashed(&stash, &shared, ContentType::Mod, &ours).await; let after = dir_entries(&stash).await; @@ -956,6 +1927,209 @@ mod tests { names } + #[test] + fn mods_are_swapped_only_while_they_still_live_in_the_game_dir() { + assert!( + !swap_types(true).contains(&ContentType::Mod), + "redirected mods are not the game dir's to swap" + ); + assert!( + swap_types(false).contains(&ContentType::Mod), + "un-redirected mods still have to leave the shared dir on exit" + ); + } + + #[test] + fn global_content_is_never_swapped() { + for types in [swap_types(true), swap_types(false)] { + for content_type in GLOBAL_TYPES { + assert!( + !types.contains(&content_type), + "{content_type:?} is shared and must survive a session" + ); + } + } + + for content_type in GLOBAL_TYPES { + assert!(content_type.is_global()); + } + assert!(!ContentType::Mod.is_global()); + } + + #[test] + fn the_game_dir_manifest_stops_claiming_packs() { + let manifest = manifest_of( + 1, + &[ + "mods/sodium.jar", + "resourcepacks/faithful.zip", + "shaderpacks/bsl.zip", + ], + ); + + let stripped = without_global_entries(manifest); + let paths = stripped.paths(); + + assert!(paths.contains("mods/sodium.jar")); + assert!(!paths.contains("resourcepacks/faithful.zip")); + assert!(!paths.contains("shaderpacks/bsl.zip")); + } + + #[test] + fn the_global_notice_owns_up_to_its_reach() { + let (_, body) = removal_notice(&["bsl.zip".into()]); + + assert!(body.contains("every cluster"), "{body}"); + assert!(!body.contains("'s folder"), "{body}"); + } + + async fn mods_scratch(name: &str, present: &[&str]) -> polyio::testing::ScratchDir { + let root = polyio::testing::ScratchDir::new(name); + polyio::create_dir_all(root.path()).await.unwrap(); + + for file in present { + polyio::write(root.join(file), b"jar".as_slice()).await.unwrap(); + } + + root + } + + fn mods_manifest(files: &[&str]) -> MaterializedManifest { + MaterializedManifest::new( + 1, + files + .iter() + .map(|name| ManifestEntry { + path: manifest::entry_path(ContentType::Mod.folder_name(), name), + hash: format!("hash-{name}"), + }) + .collect(), + ) + } + + #[tokio::test] + async fn a_jar_the_user_deleted_is_reported_with_its_hash() { + let dir = mods_scratch("hand_removed", &["kept.jar", "also_kept.jar"]).await; + let manifest = mods_manifest(&["kept.jar", "gone.jar", "also_kept.jar"]); + + let removed = hand_removed_content(dir.path(), ContentType::Mod, Some(&manifest)).await; + + assert_eq!(removed, vec![("gone.jar".into(), "hash-gone.jar".into())]); + + std::fs::remove_dir_all(dir.path()).ok(); + } + + #[tokio::test] + async fn renaming_a_jar_out_of_the_way_counts_as_removing_it() { + let dir = mods_scratch("renamed_away", &["sodium.jar.disabled", "other.jar"]).await; + let manifest = mods_manifest(&["sodium.jar", "other.jar"]); + + let removed = hand_removed_content(dir.path(), ContentType::Mod, Some(&manifest)).await; + + assert_eq!(removed.len(), 1); + assert_eq!(removed[0].0, "sodium.jar"); + + std::fs::remove_dir_all(dir.path()).ok(); + } + + #[tokio::test] + async fn an_unreadable_folder_disables_nothing() { + let manifest = mods_manifest(&["a.jar", "b.jar"]); + let missing = Path::new("definitely-not-a-directory-ÑŽ"); + + assert!(hand_removed_content(missing, ContentType::Mod, Some(&manifest)).await.is_empty()); + } + + #[tokio::test] + async fn a_wholesale_disappearance_reads_as_a_folder_problem() { + let dir = mods_scratch("all_gone", &[]).await; + let manifest = mods_manifest(&["a.jar", "b.jar", "c.jar", "d.jar"]); + + assert!( + hand_removed_content(dir.path(), ContentType::Mod, Some(&manifest)).await.is_empty(), + "an empty folder where everything was is not four deliberate deletions" + ); + + std::fs::remove_dir_all(dir.path()).ok(); + } + + #[tokio::test] + async fn clearing_a_short_list_is_still_taken_at_face_value() { + let dir = mods_scratch("small_clear", &[]).await; + let manifest = mods_manifest(&["a.jar", "b.jar"]); + + assert_eq!(hand_removed_content(dir.path(), ContentType::Mod, Some(&manifest)).await.len(), 2); + + std::fs::remove_dir_all(dir.path()).ok(); + } + + #[tokio::test] + async fn a_first_launch_concludes_nothing() { + let dir = mods_scratch("no_manifest", &[]).await; + + assert!(hand_removed_content(dir.path(), ContentType::Mod, None).await.is_empty()); + + std::fs::remove_dir_all(dir.path()).ok(); + } + + #[test] + fn notes_are_never_user_content() { + assert!(is_note(EMPTY_NOTE_NAME)); + assert!(is_note(LINKS_NOTE_NAME)); + assert!(!is_note("sodium.jar")); + } + + #[tokio::test] + async fn stale_notes_go_but_the_folder_is_left_alone() { + let root = polyio::testing::ScratchDir::new("stale_notes"); + let dir = root.path(); + + for folder in ["mods", "resourcepacks", "shaderpacks"] { + let sub = dir.join(folder); + polyio::create_dir_all(&sub).await.unwrap(); + polyio::write(sub.join(EMPTY_NOTE_NAME), b"stale".as_slice()) + .await + .unwrap(); + polyio::write(sub.join("keep.jar"), b"jar".as_slice()) + .await + .unwrap(); + } + + drop_stale_notes(&[dir], false).await; + + for folder in ["mods", "resourcepacks", "shaderpacks"] { + let sub = dir.join(folder); + assert!(!sub.join(EMPTY_NOTE_NAME).exists(), "{folder}"); + assert!(sub.join("keep.jar").exists(), "{folder}"); + } + + std::fs::remove_dir_all(dir).ok(); + } + + #[tokio::test] + async fn a_swapped_mods_folder_keeps_its_note() { + let root = polyio::testing::ScratchDir::new("swapped_note"); + let dir = root.path(); + + let mods = dir.join("mods"); + let packs = dir.join("resourcepacks"); + polyio::create_dir_all(&mods).await.unwrap(); + polyio::create_dir_all(&packs).await.unwrap(); + polyio::write(mods.join(EMPTY_NOTE_NAME), b"stale".as_slice()) + .await + .unwrap(); + polyio::write(packs.join(EMPTY_NOTE_NAME), b"stale".as_slice()) + .await + .unwrap(); + + drop_stale_notes(&[dir], true).await; + + assert!(mods.join(EMPTY_NOTE_NAME).exists()); + assert!(!packs.join(EMPTY_NOTE_NAME).exists()); + + std::fs::remove_dir_all(dir).ok(); + } + #[test] fn ownership_spans_the_manifest_and_the_database() { let manifest = manifest_of(1, &["mods/from_manifest.jar", "shaderpacks/bsl.zip"]); @@ -964,8 +2138,6 @@ mod tests { let ours = ours_in_folder(ContentType::Mod, &linked, Some(&manifest)); assert!(ours.contains("from_manifest.jar")); - // Scoped to the folder a shaderpack entry must not make a mod of the - // same name look managed assert!(!ours.contains("bsl.zip")); } } diff --git a/packages/oneclient_core/src/images/mod.rs b/packages/oneclient_core/src/images/mod.rs index 679e8a55..71d5370b 100644 --- a/packages/oneclient_core/src/images/mod.rs +++ b/packages/oneclient_core/src/images/mod.rs @@ -1,4 +1,4 @@ -use std::collections::HashMap; +use std::collections::{HashMap, VecDeque}; use std::path::PathBuf; use bytes::Bytes; @@ -11,14 +11,63 @@ use oneclient_common::paths; use oneclient_net::RequestClient; use crate::{LauncherError, LauncherResult}; -pub const DEFAULT_IMAGE_EDGE: u32 = 1600; +pub const DEFAULT_IMAGE_EDGE: u32 = 1200; /// Image urls come from untrusted remote descriptions, so cap what is fetched and decoded. const MAX_IMAGE_BYTES: usize = 8 * 1024 * 1024; +/// Every decoded image also stays on disk +/// one entry per (url, max_edge) pair for every image during the lifetme of the program +const MEMORY_CACHE_BUDGET: usize = 32 * 1024 * 1024; + +#[derive(Debug, Default)] +struct MemoryCache { + entries: HashMap, + order: VecDeque, + bytes: usize, +} + +impl MemoryCache { + fn get(&mut self, key: &str) -> Option { + let hit = self.entries.get(key)?.clone(); + if let Some(at) = self.order.iter().position(|k| k == key) { + self.order.remove(at); + } + self.order.push_back(key.to_string()); + Some(hit) + } + + fn insert(&mut self, key: String, value: Bytes) { + // A single oversized image must not evict the entire cache to store itself. + if value.len() > MEMORY_CACHE_BUDGET { + return; + } + + if let Some(old) = self.entries.remove(&key) { + self.bytes -= old.len(); + if let Some(at) = self.order.iter().position(|k| *k == key) { + self.order.remove(at); + } + } + + self.bytes += value.len(); + self.entries.insert(key.clone(), value); + self.order.push_back(key); + + while self.bytes > MEMORY_CACHE_BUDGET { + let Some(oldest) = self.order.pop_front() else { + break; + }; + if let Some(dropped) = self.entries.remove(&oldest) { + self.bytes -= dropped.len(); + } + } + } +} + #[derive(Debug, Default)] pub struct ImageCacheStore { - memory: Mutex>, + memory: Mutex, } impl ImageCacheStore { @@ -35,7 +84,7 @@ impl ImageCacheStore { max_edge: u32, ) -> LauncherResult { let mem_key = Self::mem_key(url, max_edge); - if let Some(hit) = self.memory.lock().await.get(&mem_key).cloned() { + if let Some(hit) = self.memory.lock().await.get(&mem_key) { return Ok(hit); } diff --git a/packages/oneclient_core/src/lib.rs b/packages/oneclient_core/src/lib.rs index 63d15d2d..55cb511c 100644 --- a/packages/oneclient_core/src/lib.rs +++ b/packages/oneclient_core/src/lib.rs @@ -41,7 +41,7 @@ pub use tos::{fetch_terms, TermsDocument}; pub use oneclient_discord::{DiscordRpc, Presence}; pub use clusters::{ Cluster, ClusterError, ClusterManager, ClusterStage, ClusterUpdate, CreateClusterOptions, - ensure_from_bundles, ensure_from_versions, estimate_cluster_download, + ensure_from_bundles, ensure_from_versions, estimate_cluster_download, required_java_major, }; pub use error::{LauncherError, LauncherResult, SentryExclusion}; pub use game::{GameError, LaunchedGame, get_loader_versions, launch_cluster}; diff --git a/packages/oneclient_core/src/recovery.rs b/packages/oneclient_core/src/recovery.rs index 699552a3..9c712a26 100644 --- a/packages/oneclient_core/src/recovery.rs +++ b/packages/oneclient_core/src/recovery.rs @@ -249,6 +249,14 @@ async fn relink_cluster_files( for content_type in FILE_CONTENT_TYPES { let dir = cluster_root.join(content_type.folder_name()); + + if polyio::symlink_metadata(&dir) + .await + .is_ok_and(|meta| meta.file_type().is_symlink()) + { + continue; + } + let files = match list_files(&dir).await { Ok(files) => files, Err(_) => continue, diff --git a/packages/oneclient_core/src/relocate.rs b/packages/oneclient_core/src/relocate.rs index b7d21654..ccf8bfdf 100644 --- a/packages/oneclient_core/src/relocate.rs +++ b/packages/oneclient_core/src/relocate.rs @@ -352,21 +352,29 @@ async fn drop_materialized_content(state: &LauncherState) { let mut dirs = Vec::new(); if let Ok(shared) = paths::shared_minecraft_dir() { - dirs.push(shared); + dirs.push((shared.clone(), manifest::MANIFEST_NAME)); + dirs.push((shared, manifest::GLOBAL_MANIFEST_NAME)); } match state.clusters.list().await { - Ok(clusters) => dirs.extend( - clusters - .iter() - .filter(|cluster| cluster.uses_dedicated_dir()) - .filter_map(|cluster| cluster.game_dir().ok()), - ), + Ok(clusters) => { + for cluster in &clusters { + if let Ok(dir) = cluster.dir() { + dirs.push((dir, manifest::MODS_MANIFEST_NAME)); + } + + if cluster.uses_dedicated_dir() + && let Ok(dir) = cluster.game_dir() + { + dirs.push((dir, manifest::MANIFEST_NAME)); + } + } + } Err(err) => tracing::warn!(%err, "could not list clusters; only clearing the shared folder"), } - for dir in dirs { - let Some(loaded) = manifest::load(&dir).await else { + for (dir, manifest_name) in dirs { + let Some(loaded) = manifest::load(&dir, manifest_name).await else { continue; }; @@ -379,7 +387,7 @@ async fn drop_materialized_content(state: &LauncherState) { } } - manifest::clear(&dir).await; + manifest::clear(&dir, manifest_name).await; } if let Ok(shared) = paths::shared_minecraft_dir() { diff --git a/packages/oneclient_core/src/settings/launcher.rs b/packages/oneclient_core/src/settings/launcher.rs index 92f16c6c..81db7eac 100644 --- a/packages/oneclient_core/src/settings/launcher.rs +++ b/packages/oneclient_core/src/settings/launcher.rs @@ -13,25 +13,12 @@ pub enum ViewLayout { List, } -pub const DEFAULT_GRID_COLUMNS: u8 = 3; - -#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, Default)] #[serde(default)] pub struct ViewState { pub layout: ViewLayout, #[serde(skip_serializing_if = "Option::is_none")] pub sort: Option, - pub columns: u8, -} - -impl Default for ViewState { - fn default() -> Self { - Self { - layout: ViewLayout::default(), - sort: None, - columns: DEFAULT_GRID_COLUMNS, - } - } } #[derive(Debug, Serialize, Deserialize, Clone)] @@ -42,7 +29,6 @@ pub struct LauncherSettings { pub auto_update: bool, pub crash_reporting: bool, pub enable_gamemode: bool, - pub use_discrete_gpu: bool, pub discord_enabled: bool, pub max_concurrent_requests: usize, pub global_game_settings: GameSettingsProfile, @@ -52,6 +38,8 @@ pub struct LauncherSettings { pub animations_enabled: bool, pub view_states: BTreeMap, pub seen_onboarding: bool, + pub skip_microsoft_java: bool, + pub microsoft_java_migrated: bool, pub accepted_tos_version: u32, pub accepted_privacy_version: u32, pub declined_tos: bool, @@ -84,7 +72,6 @@ impl Default for LauncherSettings { crash_reporting: true, discord_enabled: true, enable_gamemode: false, - use_discrete_gpu: false, max_concurrent_requests: 25, global_game_settings: GameSettingsProfile::default_global_profile(), allow_parallel_running_clusters: false, @@ -93,6 +80,8 @@ impl Default for LauncherSettings { animations_enabled: true, view_states: BTreeMap::new(), seen_onboarding: false, + skip_microsoft_java: false, + microsoft_java_migrated: false, accepted_tos_version: 0, accepted_privacy_version: 0, declined_tos: false, diff --git a/packages/oneclient_core/src/settings/mod.rs b/packages/oneclient_core/src/settings/mod.rs index 56bdd5a2..2c54d3af 100644 --- a/packages/oneclient_core/src/settings/mod.rs +++ b/packages/oneclient_core/src/settings/mod.rs @@ -2,7 +2,7 @@ mod launcher; pub mod data_dir; -pub use launcher::{DEFAULT_GRID_COLUMNS, LauncherSettings, ViewLayout, ViewState}; +pub use launcher::{LauncherSettings, ViewLayout, ViewState}; pub use oneclient_cluster::{ GameSettingsProfile, PackageUpdateMode, ProfileUpdate, SettingsOsExtra, }; diff --git a/packages/oneclient_core/src/storage.rs b/packages/oneclient_core/src/storage.rs index 5da23d98..e149dbde 100644 --- a/packages/oneclient_core/src/storage.rs +++ b/packages/oneclient_core/src/storage.rs @@ -2,21 +2,24 @@ use std::collections::HashSet; use std::path::{Path, PathBuf}; use serde::{Deserialize, Serialize}; +use uuid::Uuid; use crate::LauncherResult; +use crate::clusters::Cluster; use crate::state::LauncherState; use oneclient_common::domain::ContentType; use oneclient_common::paths; +use oneclient_content::packages::store::manifest; use oneclient_content::packages::store::{ find_unreferenced_files, remove_unreferenced_files, }; +use oneclient_events::EventBus; -const LEGACY_TYPES: [ContentType; 4] = [ - ContentType::Mod, - ContentType::ResourcePack, - ContentType::Shader, - ContentType::DataPack, -]; +const LEGACY_TYPES: [ContentType; 2] = [ContentType::Mod, ContentType::DataPack]; + +// stable id for the storage scan's progress +pub const STORAGE_SCAN_PROGRESS: Uuid = + Uuid::from_u128(0x5354_4F52_4147_4500_0000_0000_0000_0001); #[derive(Debug, Clone, Serialize, Deserialize)] pub struct StorageEntry { @@ -59,35 +62,57 @@ pub async fn storage_report(state: &LauncherState) -> LauncherResult LauncherResult { + events: &'a EventBus, + done: u64, + total: u64, +} + +impl ScanSteps<'_> { + fn begin(&mut self, label: &str) { + self.events.progress( + STORAGE_SCAN_PROGRESS, + format!("Measuring {label}"), + self.done, + self.total, + ); + self.done += 1; + } +} + +impl Drop for ScanSteps<'_> { + fn drop(&mut self) { + self.events + .progress(STORAGE_SCAN_PROGRESS, "Done", self.total, self.total); + } +} + async fn natives_in_use(state: &LauncherState) -> Option> { let clusters = state.clusters.list().await.ok()?; let metadata = state.metadata.lock().await; @@ -191,11 +241,10 @@ async fn unused_natives(state: &LauncherState) -> ReclaimableEntry { /// Content is materialized from the cache now so anything in a cluster's own /// folder is an inert leftover from an older launcher space not correctness -async fn legacy_cluster_content(state: &LauncherState) -> LauncherResult { +async fn legacy_cluster_content(clusters: &[Cluster]) -> ReclaimableEntry { let mut found = ReclaimableEntry::default(); - for cluster in state.clusters.list().await? { - // A dedicated cluster's folder *is* its game directory so content there belongs + for cluster in clusters { if cluster.uses_dedicated_dir() { continue; } @@ -203,7 +252,13 @@ async fn legacy_cluster_content(state: &LauncherState) -> LauncherResult LauncherResult bool { @@ -247,18 +302,20 @@ fn is_content_file(content_type: ContentType, name: &str) -> bool { } } -async fn entry(label: &str, path: PathBuf) -> StorageEntry { +async fn entry(label: &str, path: PathBuf, seen: &mut SeenFiles) -> StorageEntry { StorageEntry { label: label.to_string(), - bytes: dir_size(&path).await, + bytes: dir_size_seen(&path, seen).await, path, files: None, } } -/// Links are not followed so a materialized game directory does not appear to -/// double the size of the package cache pub async fn dir_size(root: impl AsRef) -> u64 { + dir_size_seen(root, &mut SeenFiles::default()).await +} + +async fn dir_size_seen(root: impl AsRef, seen: &mut SeenFiles) -> u64 { let mut total = 0; let mut stack = vec![root.as_ref().to_path_buf()]; @@ -278,7 +335,9 @@ pub async fn dir_size(root: impl AsRef) -> u64 { if file_type.is_dir() { stack.push(entry.path()); - } else if let Ok(meta) = entry.metadata().await { + } else if let Ok(meta) = entry.metadata().await + && seen.first_sighting(&entry.path()).await + { total += meta.len(); } } @@ -287,8 +346,37 @@ pub async fn dir_size(root: impl AsRef) -> u64 { total } -/// Cleanup refuses to run while fixture numbers are shown the report bears no -/// relation to disk so the only thing it could delete is the user's real data +#[derive(Default)] +struct SeenFiles(HashSet<(u64, u64)>); + +impl SeenFiles { + async fn first_sighting(&mut self, path: &Path) -> bool { + match file_identity(path).await { + Some(id) => self.0.insert(id), + None => true, + } + } +} + +async fn file_identity(path: &Path) -> Option<(u64, u64)> { + if !can_be_materialized(path) { + return None; + } + + polyio::file_id(path).await.ok() +} + +fn can_be_materialized(path: &Path) -> bool { + let Some(name) = path.file_name().and_then(|name| name.to_str()) else { + return false; + }; + + let lower = name.to_lowercase(); + let lower = lower.trim_end_matches(".disabled"); + + lower.ends_with(".jar") || lower.ends_with(".zip") +} + fn showing_fixture() -> bool { #[cfg(debug_assertions)] { @@ -487,14 +575,14 @@ mod tests { let dir = root.path(); polyio::create_dir_all(dir.join("nested")).await.unwrap(); - polyio::write(dir.join("a.bin"), vec![0u8; 1000]).await.unwrap(); - polyio::write(dir.join("nested").join("b.bin"), vec![0u8; 500]) + polyio::write(dir.join("a.jar"), vec![0u8; 1000]).await.unwrap(); + polyio::write(dir.join("nested").join("b.jar"), vec![0u8; 500]) .await .unwrap(); assert_eq!(dir_size(dir).await, 1500); - polyio::symlink_file(dir.join("a.bin"), dir.join("link.bin")) + polyio::symlink_file(dir.join("a.jar"), dir.join("link.jar")) .await .unwrap(); assert_eq!( @@ -505,4 +593,29 @@ mod tests { std::fs::remove_dir_all(root.path()).ok(); } + + #[tokio::test] + async fn a_file_in_two_folders_is_paid_for_once() { + let root = polyio::testing::ScratchDir::new("shared_size"); + let dir = root.path(); + let store = dir.join("store"); + let cluster = dir.join("cluster"); + polyio::create_dir_all(&store).await.unwrap(); + polyio::create_dir_all(&cluster).await.unwrap(); + + polyio::write(store.join("mod.jar"), vec![0u8; 1000]).await.unwrap(); + polyio::symlink_file(store.join("mod.jar"), cluster.join("mod.jar")) + .await + .unwrap(); + + let mut seen = SeenFiles::default(); + assert_eq!(dir_size_seen(&store, &mut seen).await, 1000); + assert_eq!( + dir_size_seen(&cluster, &mut seen).await, + 0, + "the cache already paid for it" + ); + + std::fs::remove_dir_all(root.path()).ok(); + } } diff --git a/packages/oneclient_core/src/verify.rs b/packages/oneclient_core/src/verify.rs index 69ffe303..97774788 100644 --- a/packages/oneclient_core/src/verify.rs +++ b/packages/oneclient_core/src/verify.rs @@ -1,6 +1,5 @@ use std::sync::Arc; -use oneclient_cluster::ClusterError; use oneclient_content::packages::PackageStore; use oneclient_content::packages::store::artifact_absolute_path; use oneclient_db::dao::artifact as artifact_dao; @@ -109,9 +108,7 @@ async fn run_verify( let (version_info, minecraft_updated) = { let mut metadata = state.metadata.lock().await; let (version, _index, minecraft_updated) = - resolve_minecraft_version(&mut metadata, &state.services.mc(), &mc_version) - .await - .map_err(|_| ClusterError::InvalidVersion(cluster.mc_version.clone()))?; + resolve_minecraft_version(&mut metadata, &state.services.mc(), &mc_version).await?; let loader_version = get_loader_version( &mut metadata, diff --git a/packages/oneclient_db/migrations/20260914120000_repair_duplicate_disable_overrides.sql b/packages/oneclient_db/migrations/20260914120000_repair_duplicate_disable_overrides.sql new file mode 100644 index 00000000..50606a02 --- /dev/null +++ b/packages/oneclient_db/migrations/20260914120000_repair_duplicate_disable_overrides.sql @@ -0,0 +1,20 @@ +-- Repairs the overrides duplicate resolution filed against the user. +-- +-- `reconcile_duplicate_activity` switched the older copy of a package off and +-- recorded it as a user disable, but `cluster_bundle_overrides` is keyed by +-- package id and the surviving copy shares it, so the row spoke for a copy +-- nobody turned off. Every lookup that resolves a user's choice then answers +-- "off" for the package: the next bundle update installs the new copy and +-- immediately disables it, and the update check stops shipping the file at all. +-- +-- A row alongside a copy that is still on cannot be a considered choice about +-- the package -- had the user turned the package off, nothing of it would be +-- left enabled. Only those go. A package whose copies are all off is +-- indistinguishable from a genuine disable here and is left for the runtime +-- pass, which has the manifest and can tell whether the file was ever shown. +DELETE FROM cluster_bundle_overrides +WHERE override_type = 'disabled' + AND EXISTS (SELECT 1 FROM cluster_artifacts a + WHERE a.cluster_id = cluster_bundle_overrides.cluster_id + AND a.package_id = cluster_bundle_overrides.package_id + AND a.enabled = 1); diff --git a/packages/oneclient_db/src/dao/artifact.rs b/packages/oneclient_db/src/dao/artifact.rs index 1c2ed553..86b8c802 100644 --- a/packages/oneclient_db/src/dao/artifact.rs +++ b/packages/oneclient_db/src/dao/artifact.rs @@ -72,6 +72,59 @@ pub async fn delete_artifact_if_unused(pool: &SqlitePool, hash: &str) -> Result< Ok(true) } +use crate::models::GlobalArtifactRow; + +/// One row per hash across every cluster for content that is installed globally +/// +/// `enabled` is the OR over the clusters: one cluster still having a pack on is +/// enough to keep it in the folder, because there is one folder and it can only +/// have one answer +/// +/// Switching a pack off still reaches every cluster it goes through +/// [`set_enabled_for_hash`], which leaves no row for this to read as on +/// +/// Written with the runtime-checked builder rather than `query!` so it needs no +/// entry in the offline cache +pub async fn list_global_artifacts( + pool: &SqlitePool, + content_type: i64, +) -> Result, sqlx::Error> { + sqlx::query_as::<_, GlobalArtifactRow>( + r#" + SELECT + ca.hash AS hash, + MIN(ca.cluster_file_name) AS file_name, + MAX(ca.enabled) AS enabled + FROM cluster_artifacts ca + JOIN artifacts a ON a.hash = ca.hash + WHERE a.content_type = ? + GROUP BY ca.hash + "#, + ) + .bind(content_type) + .fetch_all(pool) + .await +} + +/// Sets the flag on every cluster that has this artifact +/// +/// Globally installed content has one folder so writing only the row of +/// whichever cluster the user happened to be looking at would leave the rest +/// disagreeing with what is on disk +pub async fn set_enabled_for_hash( + pool: &SqlitePool, + hash: &str, + enabled: i64, +) -> Result { + let result = sqlx::query("UPDATE cluster_artifacts SET enabled = ? WHERE hash = ?") + .bind(enabled) + .bind(hash) + .execute(pool) + .await?; + + Ok(result.rows_affected()) +} + pub async fn list_unused_artifacts(pool: &SqlitePool) -> Result, sqlx::Error> { sqlx::query_as::<_, ArtifactRow>( r#" @@ -181,6 +234,19 @@ pub async fn get_release_by_hash( .await } +pub async fn list_release_game_versions( + pool: &SqlitePool, + hash: &str, +) -> Result, sqlx::Error> { + let rows: Vec<(String,)> = + sqlx::query_as("SELECT mc_versions FROM provider_releases WHERE hash = ?") + .bind(hash) + .fetch_all(pool) + .await?; + + Ok(rows.into_iter().map(|(versions,)| versions).collect()) +} + pub async fn link_cluster_artifact( pool: &SqlitePool, cluster_id: i64, diff --git a/packages/oneclient_db/src/dao/cluster_bundle.rs b/packages/oneclient_db/src/dao/cluster_bundle.rs index 96b44f45..615d3aab 100644 --- a/packages/oneclient_db/src/dao/cluster_bundle.rs +++ b/packages/oneclient_db/src/dao/cluster_bundle.rs @@ -189,6 +189,25 @@ pub async fn remove_override( Ok(()) } +pub async fn clear_disabled_overrides( + pool: &SqlitePool, + cluster_id: i64, + package_id: &str, +) -> Result { + let result = sqlx::query!( + r#" + DELETE FROM cluster_bundle_overrides + WHERE cluster_id = ? AND package_id = ? AND override_type = 'disabled' + "#, + cluster_id, + package_id + ) + .execute(pool) + .await?; + + Ok(result.rows_affected()) +} + /// Opt-in (`enabled`) rows are left alone they say the opposite thing pub async fn clear_suppressing_overrides( pool: &SqlitePool, diff --git a/packages/oneclient_db/src/models/artifact.rs b/packages/oneclient_db/src/models/artifact.rs index c82b67a8..ebcfdd25 100644 --- a/packages/oneclient_db/src/models/artifact.rs +++ b/packages/oneclient_db/src/models/artifact.rs @@ -77,3 +77,11 @@ pub struct LinkedArtifactRow { pub display_version: Option, pub published_at: Option, } + +// used for resourcepacks and shaders +#[derive(Debug, Clone, FromRow)] +pub struct GlobalArtifactRow { + pub hash: String, + pub file_name: String, + pub enabled: i64, +} diff --git a/packages/oneclient_db/src/models/mod.rs b/packages/oneclient_db/src/models/mod.rs index 3ba3cb26..9e83ac64 100644 --- a/packages/oneclient_db/src/models/mod.rs +++ b/packages/oneclient_db/src/models/mod.rs @@ -10,7 +10,7 @@ mod package_metadata; mod setting_profile; pub use artifact::{ - ArtifactRow, ClusterArtifactRow, LinkedArtifactRow, ProviderReleaseRow, SeenStatus, + ArtifactRow, ClusterArtifactRow, LinkedArtifactRow, ProviderReleaseRow, SeenStatus, GlobalArtifactRow }; pub use browser_package_update::BrowserPackageUpdateRow; pub use package_metadata::PackageMetadataRow; diff --git a/packages/oneclient_java/Cargo.toml b/packages/oneclient_java/Cargo.toml index b677d596..667ec4db 100644 --- a/packages/oneclient_java/Cargo.toml +++ b/packages/oneclient_java/Cargo.toml @@ -24,6 +24,7 @@ async-tempfile.workspace = true async_zip.workspace = true astral-tokio-tar.workspace = true futures-lite.workspace = true +futures-util.workspace = true tokio.workspace = true reqwest.workspace = true diff --git a/packages/oneclient_java/examples/install_provider.rs b/packages/oneclient_java/examples/install_provider.rs index b9ef8b9c..6b39611b 100644 --- a/packages/oneclient_java/examples/install_provider.rs +++ b/packages/oneclient_java/examples/install_provider.rs @@ -6,7 +6,7 @@ use oneclient_common::paths::java_dir; use oneclient_events::EventBus; use oneclient_java::vendors::{ AdoptiumRuntimeProvider, CorrettoRuntimeProvider, JavaRuntimeProvider, LibericaRuntimeProvider, - ZuluRuntimeProvider, + MicrosoftRuntimeProvider, ZuluRuntimeProvider, }; use oneclient_java::{JavaResult, JavaService, MemoryJavaStore, check_java_runtime}; use oneclient_net::{NetConfig, RequestClient}; @@ -15,7 +15,7 @@ use oneclient_net::{NetConfig, RequestClient}; async fn main() -> JavaResult<()> { let mut args = env::args().skip(1); let vendor = args.next().unwrap_or_else(|| { - eprintln!("usage: install_provider [major]"); + eprintln!("usage: install_provider [major]"); std::process::exit(1); }); @@ -34,8 +34,11 @@ async fn main() -> JavaResult<()> { "adoptium" => Box::new(AdoptiumRuntimeProvider), "corretto" => Box::new(CorrettoRuntimeProvider), "liberica" => Box::new(LibericaRuntimeProvider), + "microsoft" => Box::new(MicrosoftRuntimeProvider), other => { - eprintln!("unknown vendor '{other}', use zulu, adoptium, corretto, or liberica"); + eprintln!( + "unknown vendor '{other}', use zulu, adoptium, corretto, liberica, or microsoft" + ); std::process::exit(1); } }; diff --git a/packages/oneclient_java/src/service.rs b/packages/oneclient_java/src/service.rs index 50e94a48..b96ee29f 100644 --- a/packages/oneclient_java/src/service.rs +++ b/packages/oneclient_java/src/service.rs @@ -64,6 +64,20 @@ impl JavaService { Ok(self.store.list().await?) } + /// Checks if file wasn't deleted manually + #[tracing::instrument(level = "debug", skip(self))] + pub async fn has_vendor_runtime( + &self, + vendor: &JavaVendor, + major: Option, + ) -> JavaResult { + Ok(self.list_runtimes().await?.iter().any(|runtime| { + &runtime.vendor == vendor + && major.is_none_or(|major| runtime.major == major) + && Path::new(&runtime.absolute_path).is_file() + })) + } + #[tracing::instrument(level = "debug", skip(self))] pub async fn runtime_for_profile( &self, @@ -140,6 +154,20 @@ impl JavaService { Ok(available) } + /// `None` when the vendor publishes nothing for this major on this host + #[tracing::instrument(level = "debug", skip(self))] + pub async fn latest_package( + &self, + vendor: &JavaVendor, + major: u32, + ) -> JavaResult> { + let Some(provider) = provider_for_vendor(vendor) else { + return Ok(None); + }; + + provider.latest_package_by_major(major, &self.net).await + } + #[tracing::instrument(level = "debug", skip(self))] pub async fn rescan(&self) -> JavaResult<()> { self.register_located(&crate::locate::locate_java().await?) @@ -181,14 +209,7 @@ impl JavaService { auto_install: bool, progress: Option<&GroupedProgressSession>, ) -> JavaResult { - let recorded = loop { - let Some(runtime) = self.store.latest_by_major(major).await? else { - break None; - }; - if let Some(valid) = self.revalidate(runtime).await? { - break Some(valid); - } - }; + let recorded = self.best_recorded_for_major(major).await?; if let Some(runtime) = &recorded && runtime.is_jdk @@ -219,6 +240,28 @@ impl JavaService { self.prompt_and_install(major, progress).await } + #[tracing::instrument(level = "debug", skip(self))] + async fn best_recorded_for_major(&self, major: u32) -> JavaResult> { + let preferred = vendors::default_vendor(); + + let mut candidates: Vec = self + .list_runtimes() + .await? + .into_iter() + .filter(|runtime| runtime.major == major) + .collect(); + + candidates.sort_by_key(|b| std::cmp::Reverse(pick_order(b, &preferred))); + + for candidate in candidates { + if let Some(valid) = self.revalidate(candidate).await? { + return Ok(Some(valid)); + } + } + + Ok(None) + } + #[tracing::instrument(skip(self, progress))] pub async fn install_runtime( &self, @@ -400,6 +443,25 @@ impl JavaService { } } +/// Ranked highest-first the default vendor beats every other, a kit beats a +/// runtime of the same vendor, and only then does the newest build win +fn pick_order(runtime: &JavaRuntime, preferred: &JavaVendor) -> (bool, bool, Vec) { + ( + &runtime.vendor == preferred, + runtime.is_jdk, + version_key(&runtime.version), + ) +} + +/// `21.0.9` sorts above `21.0.12` as text which is backwards so the components +/// are compared as numbers Legacy `1.8.0_412` keeps its build as the last one +fn version_key(version: &str) -> Vec { + version + .split(|c: char| !c.is_ascii_digit()) + .filter_map(|part| part.parse().ok()) + .collect() +} + fn provider_for_vendor(vendor: &JavaVendor) -> Option> { vendors::runtime_providers() .into_iter() @@ -445,4 +507,69 @@ mod tests { assert!(parse_major_version("not-a-version").is_err()); assert!(parse_major_version("1.x").is_err()); } + + fn runtime(vendor: JavaVendor, version: &str, is_jdk: bool) -> JavaRuntime { + JavaRuntime { + absolute_path: format!("/java/{vendor}-{version}/bin/java"), + major: parse_major_version(version).unwrap(), + version: version.to_string(), + vendor, + os_arch: "x64".to_string(), + is_jdk, + probe_version: PROBE_VERSION, + } + } + + /// Highest first the same way `best_recorded_for_major` sorts + fn best(mut candidates: Vec, preferred: &JavaVendor) -> JavaRuntime { + candidates.sort_by(|a, b| pick_order(b, preferred).cmp(&pick_order(a, preferred))); + candidates.remove(0) + } + + #[test] + fn patch_numbers_are_compared_as_numbers_not_as_text() { + assert!(version_key("21.0.12") > version_key("21.0.9")); + assert_eq!(version_key("1.8.0_412"), vec![1, 8, 0, 412]); + } + + #[test] + fn the_default_vendor_wins_even_against_a_newer_build() { + let picked = best( + vec![ + runtime(JavaVendor::Zulu, "21.0.99", true), + runtime(JavaVendor::Microsoft, "21.0.1", true), + ], + &JavaVendor::Microsoft, + ); + + assert_eq!(picked.vendor, JavaVendor::Microsoft); + } + + #[test] + fn a_kit_beats_a_runtime_of_the_same_vendor() { + let picked = best( + vec![ + runtime(JavaVendor::Microsoft, "21.0.5", false), + runtime(JavaVendor::Microsoft, "21.0.2", true), + ], + &JavaVendor::Microsoft, + ); + + assert!(picked.is_jdk); + } + + /// Nothing from the preferred vendor means the ranking falls through to the + /// newest build rather than to whatever the store happened to return + #[test] + fn without_the_default_vendor_the_newest_build_wins() { + let picked = best( + vec![ + runtime(JavaVendor::Zulu, "21.0.9", true), + runtime(JavaVendor::Adoptium, "21.0.12", true), + ], + &JavaVendor::Microsoft, + ); + + assert_eq!(picked.version, "21.0.12"); + } } diff --git a/packages/oneclient_java/src/vendors/microsoft.rs b/packages/oneclient_java/src/vendors/microsoft.rs new file mode 100644 index 00000000..a66a5ba0 --- /dev/null +++ b/packages/oneclient_java/src/vendors/microsoft.rs @@ -0,0 +1,286 @@ +use futures_util::StreamExt; +use reqwest::{Method, Request}; +use url::Url; + +use oneclient_net::RequestClient; +use polyio::Checksum; + +use crate::data::{JavaPackage, PackageArchive}; +use crate::error::JavaResult; +use crate::platform::{HostArch, HostOs, HostTarget}; +use crate::vendors::{JavaRuntimeProvider, JavaVendor}; + +pub struct MicrosoftRuntimeProvider; + +/// Microsoft publishes no metadata API at all only static `aka.ms` vanity +/// links so the majors it still builds have to be listed here +const MICROSOFT_MAJORS: &[u32] = &[25, 21, 17, 11]; + +/// Alpine is the one platform Microsoft never filled out x64 only and nothing +/// newer than 17 +const MICROSOFT_ALPINE_MAJORS: &[u32] = &[17, 11]; + +/// Four small redirects when no major is pinned nowhere near enough to be +/// worth throttling harder +const MAJOR_LOOKUP_CONCURRENCY: usize = 4; + +#[async_trait::async_trait] +impl JavaRuntimeProvider for MicrosoftRuntimeProvider { + fn vendor(&self) -> JavaVendor { + JavaVendor::Microsoft + } + + #[tracing::instrument(level = "debug", skip(self, net))] + async fn list_packages( + &self, + major: Option, + net: &RequestClient, + ) -> JavaResult> { + let Some(arch) = MICROSOFT_ARCH else { + tracing::debug!( + "Microsoft publishes no build for this architecture; listing nothing" + ); + return Ok(Vec::new()); + }; + + let wanted: Vec = MICROSOFT_MAJORS + .iter() + .copied() + .filter(|candidate| major.is_none_or(|filter| filter == *candidate)) + .filter(|candidate| publishes_host_build(*candidate, arch)) + .collect(); + + let mut packages: Vec = futures_util::stream::iter( + wanted + .into_iter() + .map(|major| async move { resolve_package(major, arch, net).await }), + ) + .buffer_unordered(MAJOR_LOOKUP_CONCURRENCY) + .collect::>() + .await + .into_iter() + .collect::>>>()? + .into_iter() + .flatten() + .collect(); + + packages.sort_by_key(|p| std::cmp::Reverse(p.java_version.first().copied().unwrap_or(0))); + tracing::debug!(count = packages.len(), "listed Microsoft packages"); + Ok(packages) + } +} + +/// The checksum file names the build it belongs to so one request resolves the +/// exact version, the filename and the hash the major-only link would download +#[tracing::instrument(level = "debug", skip(net))] +async fn resolve_package( + major: u32, + arch: &str, + net: &RequestClient, +) -> JavaResult> { + let url = checksum_url(major, arch)?; + let response = net.send(Request::new(Method::GET, url)).await?; + let status = response.status(); + let body = response + .text() + .await + .map_err(oneclient_net::RequestError::from)?; + + if !status.is_success() { + tracing::debug!( + major, + status = status.as_u16(), + "Microsoft checksum file is unavailable; skipping the major" + ); + return Ok(None); + } + + let Some(build) = parse_checksum_file(&body, major) else { + tracing::debug!( + major, + "Microsoft published no build under this link; skipping the major" + ); + return Ok(None); + }; + + Ok(Some( + JavaPackage { + archive: MICROSOFT_EXT.1, + download_url: download_url(&build.filename), + java_version: build.java_version, + name: build.filename, + vendor: JavaVendor::Microsoft, + checksum: None, + size: None, + } + .with_checksum(Some(build.checksum)), + )) +} + +struct MicrosoftBuild { + filename: String, + java_version: Vec, + checksum: Checksum, +} + +/// `aka.ms` answers a link it does not know with a 200 and a search page +/// rather than a 404 so only the body can say whether a build exists +fn parse_checksum_file(body: &str, major: u32) -> Option { + let line = body.lines().next()?.trim(); + let (hex, filename) = line.split_once(char::is_whitespace)?; + let filename = filename.trim(); + + if hex.len() != 64 || !hex.bytes().all(|b| b.is_ascii_hexdigit()) { + return None; + } + + let java_version = version_from_filename(filename)?; + + // A link that quietly moved to another major would otherwise install the + // wrong runtime under the requested one's name + if java_version.first() != Some(&major) { + tracing::warn!( + major, + filename, + "Microsoft's major link resolved to a different major; skipping it" + ); + return None; + } + + Some(MicrosoftBuild { + filename: filename.to_string(), + java_version, + checksum: Checksum::sha256(hex), + }) +} + +/// Splitting the whole filename the way the other vendors split their version +/// strings would fold the `64` of `x64` into the version +fn version_from_filename(filename: &str) -> Option> { + let (version, _) = filename + .strip_prefix("microsoft-jdk-")? + .split_once('-')?; + + let parts: Vec = version + .split('.') + .map(str::parse) + .collect::>() + .ok()?; + + (!parts.is_empty()).then_some(parts) +} + +/// Microsoft never published a JRE so every link is a kit by construction +fn checksum_url(major: u32, arch: &str) -> JavaResult { + Ok(Url::parse(&format!( + "https://aka.ms/download-jdk/microsoft-jdk-{major}-{MICROSOFT_OS}-{arch}.{}.sha256sum.txt", + MICROSOFT_EXT.0 + ))?) +} + +/// Prefer the versioned link the checksum names over the major-only one it was +/// read through so the download cannot drift onto a newer build mid-release +fn download_url(filename: &str) -> String { + format!("https://aka.ms/download-jdk/{filename}") +} + +fn publishes_host_build(major: u32, arch: &str) -> bool { + if HostTarget::CURRENT.is_musl() { + return arch == "x64" && MICROSOFT_ALPINE_MAJORS.contains(&major); + } + true +} + +/// Microsoft doesn't supply 32-bit +const MICROSOFT_ARCH: Option<&str> = match HostTarget::CURRENT.arch { + HostArch::X86_64 => Some("x64"), + HostArch::Aarch64 => Some("aarch64"), + HostArch::X86 | HostArch::Arm => None, +}; + +/// Microsoft files musl builds under `alpine` where the other vendors say +/// `linux-musl` +const MICROSOFT_OS: &str = match HostTarget::CURRENT.os { + HostOs::Windows => "windows", + HostOs::MacOs => "macos", + HostOs::Linux { musl: true } => "alpine", + HostOs::Linux { musl: false } => "linux", +}; + +const MICROSOFT_EXT: (&str, PackageArchive) = ( + HostTarget::CURRENT.archive_ext(), + HostTarget::CURRENT.archive(), +); + +#[cfg(test)] +mod tests { + use super::*; + + const LINE: &str = + "bf27a5d6298c736af8daf5b8c883098e83291446e5766118d8a5ea6a2617195d microsoft-jdk-21.0.12-windows-x64.zip"; + + #[test] + fn only_kits_are_ever_requested() { + let url = checksum_url(21, "x64").expect("a valid url"); + + assert!(url.as_str().contains("microsoft-jdk-21-")); + assert!(!url.as_str().contains("jre")); + } + + #[test] + fn a_checksum_file_carries_the_build_it_belongs_to() { + let build = parse_checksum_file(LINE, 21).expect("a build"); + + assert_eq!(build.filename, "microsoft-jdk-21.0.12-windows-x64.zip"); + assert_eq!(build.java_version, vec![21, 0, 12]); + assert_eq!(build.checksum.algorithm, polyio::ChecksumAlgorithm::Sha256); + assert!(build.checksum.is_well_formed()); + } + + #[test] + fn the_search_page_a_dead_link_redirects_to_is_not_a_build() { + assert!(parse_checksum_file("", 21).is_none()); + assert!(parse_checksum_file("", 21).is_none()); + } + + #[test] + fn a_link_resolving_to_another_major_is_refused() { + assert!(parse_checksum_file(LINE, 17).is_none()); + } + + #[test] + fn the_architecture_never_leaks_into_the_version() { + assert_eq!( + version_from_filename("microsoft-jdk-21.0.12-windows-x64.zip"), + Some(vec![21, 0, 12]) + ); + assert_eq!( + version_from_filename("microsoft-jdk-25.0.4-linux-aarch64.tar.gz"), + Some(vec![25, 0, 4]) + ); + } + + #[test] + fn debug_symbol_builds_are_never_mistaken_for_runtimes() { + assert_eq!( + version_from_filename("microsoft-jdk-debugsymbols-21.0.12-windows-x64.zip"), + None + ); + } + + #[test] + fn alpine_only_gets_the_majors_microsoft_actually_built_for_it() { + for major in MICROSOFT_MAJORS { + let published = publishes_host_build(*major, "x64"); + if HostTarget::CURRENT.is_musl() { + assert_eq!(published, MICROSOFT_ALPINE_MAJORS.contains(major)); + } else { + assert!(published); + } + } + + if HostTarget::CURRENT.is_musl() { + assert!(!publishes_host_build(17, "aarch64")); + } + } +} diff --git a/packages/oneclient_java/src/vendors/mod.rs b/packages/oneclient_java/src/vendors/mod.rs index b1c5e872..8a83cfff 100644 --- a/packages/oneclient_java/src/vendors/mod.rs +++ b/packages/oneclient_java/src/vendors/mod.rs @@ -10,16 +10,19 @@ use crate::error::JavaResult; mod adoptium; mod corretto; mod liberica; +mod microsoft; mod zulu; pub use adoptium::AdoptiumRuntimeProvider; pub use corretto::CorrettoRuntimeProvider; pub use liberica::LibericaRuntimeProvider; +pub use microsoft::MicrosoftRuntimeProvider; use serde::{Deserialize, Deserializer, Serialize}; pub use zulu::ZuluRuntimeProvider; pub fn runtime_providers() -> Vec> { vec![ + Box::new(MicrosoftRuntimeProvider), Box::new(ZuluRuntimeProvider), Box::new(AdoptiumRuntimeProvider), Box::new(CorrettoRuntimeProvider), @@ -27,6 +30,15 @@ pub fn runtime_providers() -> Vec> { ] } +// For making sure microsoft jdk provider is first +#[must_use] +pub fn default_vendor() -> JavaVendor { + runtime_providers() + .first() + .expect("the provider list is never empty") + .vendor() +} + #[async_trait::async_trait] pub trait JavaRuntimeProvider: Send + Sync { fn vendor(&self) -> JavaVendor; diff --git a/packages/oneclient_mc/src/arguments.rs b/packages/oneclient_mc/src/arguments.rs index d957d70e..4b22f5bb 100644 --- a/packages/oneclient_mc/src/arguments.rs +++ b/packages/oneclient_mc/src/arguments.rs @@ -6,6 +6,7 @@ use interfrost::api::modded::SidedDataEntry; use interfrost::utils::get_path_from_artifact; use oneclient_common::constants::{self, DUMMY_REPLACE_NEWLINE}; +use oneclient_common::paths; use crate::rules::validate_rules; use crate::error::McError; use oneclient_common::Resolution; @@ -41,13 +42,20 @@ pub fn java_arguments( }, java_arch, )?; - } else { + } + + if !parsed + .iter() + .any(|arg| arg.starts_with("-Djava.library.path=")) + { parsed.push(format!( "-Djava.library.path={}", polyio::canonicalize(natives_path) .map_err(|_| McError::LibraryPath(natives_path.display().to_string()))? .display() )); + } + if !parsed.iter().any(|arg| arg == "-cp" || arg == "-classpath") { parsed.push("-cp".to_string()); parsed.push(classpaths.to_string()); } @@ -174,8 +182,28 @@ pub fn minecraft_arguments( resolution: Resolution, java_arch: &str, ) -> McResult> { + let mut parsed = Vec::new(); + + // a legacy version states its arguments as a string and a loader merged onto + // one adds a list on top the game needs both, in that order + if let Some(legacy_args) = legacy_args { + for arg in legacy_args.split(' ') { + parsed.push(parse_minecraft_argument( + &arg.replace(' ', DUMMY_REPLACE_NEWLINE), + access_token, + username, + uuid, + version, + asset_index, + game_directory, + assets_directory, + version_type, + resolution, + )?); + } + } + if let Some(args) = args { - let mut parsed = Vec::new(); parse_arguments( version_updated, args, @@ -196,29 +224,9 @@ pub fn minecraft_arguments( }, java_arch, )?; - - Ok(parsed) - } else if let Some(legacy_args) = legacy_args { - let mut parsed = Vec::new(); - for arg in legacy_args.split(' ') { - parsed.push(parse_minecraft_argument( - &arg.replace(' ', DUMMY_REPLACE_NEWLINE), - access_token, - username, - uuid, - version, - asset_index, - game_directory, - assets_directory, - version_type, - resolution, - )?); - } - - Ok(parsed) - } else { - Ok(Vec::new()) } + + Ok(parsed) } pub fn append_profile_game_arguments( @@ -343,15 +351,21 @@ pub fn parse_minecraft_argument( "${assets_root}", &polyio::canonicalize(assets_directory)?.display().to_string(), ) - .replace( - "${game_assets}", - &polyio::canonicalize(assets_directory)?.display().to_string(), - ) + .replace("${game_assets}", &legacy_assets_path()?) .replace("${version_type}", version_type.as_str()) .replace("${resolution_width}", &resolution.width.to_string()) .replace("${resolution_height}", &resolution.height.to_string())) } +fn legacy_assets_path() -> McResult { + let dir = paths::legacy_assets_dir()?; + + Ok(polyio::canonicalize(&dir) + .unwrap_or(dir) + .display() + .to_string()) +} + fn parse_java_argument( mut argument: String, natives_path: &Path, @@ -393,6 +407,10 @@ pub fn classpaths( if !lib.include_in_classpath { continue; } + // natives-only libraries (classifiers, no artifact) have no jar on disk + if !crate::install::has_main_artifact(lib) { + continue; + } let (artifact, version) = split_artifact_version(&lib.name); let ver_key = version_key(version); @@ -516,7 +534,11 @@ where #[cfg(test)] mod tests { - use super::{ZGC_MIN_HEAP_MB, is_collector_flag, performance_flags, split_custom_args}; + use super::{ + Library, ZGC_MIN_HEAP_MB, classpaths, is_collector_flag, java_arguments, + minecraft_arguments, performance_flags, split_custom_args, + }; + use oneclient_common::Resolution; #[test] fn blank_input_contributes_nothing() { @@ -786,5 +808,92 @@ mod tests { fn a_java_7_runtime_is_left_untouched() { assert_eq!(flags(7, "amd64", 4096), vec!["-Xms512M"]); } -} + #[test] + fn a_natives_only_library_is_not_a_classpath_entry() { + let libraries: Vec = serde_json::from_str( + r#"[ + { "name": "net.fabricmc:fabric-loader:0.19.5" }, + { "name": "org.lwjgl.lwjgl:lwjgl:2.9.4+legacyfabric.15", + "downloads": { "artifact": { "sha1": "a", "size": 1, "url": "https://maven.legacyfabric.net/lwjgl.jar" } } }, + { "name": "org.lwjgl.lwjgl:lwjgl-platform:2.9.4+legacyfabric.15", + "downloads": { "classifiers": { "natives-osx": { "sha1": "b", "size": 2, "url": "https://maven.legacyfabric.net/natives-osx.jar" } } }, + "natives": { "osx": "natives-osx" } } + ]"#, + ) + .unwrap(); + + let dir = std::env::temp_dir().join("oneclient-classpath-test"); + let client = dir.join("client.jar"); + for lib in ["net/fabricmc/fabric-loader/0.19.5/fabric-loader-0.19.5.jar", + "org/lwjgl/lwjgl/lwjgl/2.9.4+legacyfabric.15/lwjgl-2.9.4+legacyfabric.15.jar"] { + let path = dir.join(lib); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(&path, b"").unwrap(); + } + std::fs::write(&client, b"").unwrap(); + + let cp = classpaths(&dir, &libraries, &client, "x86", false) + .expect("a jar that was never downloaded must not fail the launch"); + assert!(!cp.contains("lwjgl-platform")); + assert!(cp.contains("fabric-loader") && cp.contains("lwjgl-2.9.4")); + } + + #[test] + fn a_loader_on_a_legacy_version_still_gets_a_classpath() { + // ornithe's manifest declares two -D flags and nothing else + // 1.8.9 has no `arguments` block to supply -cp or the natives path + let loader_jvm: Vec = serde_json::from_str( + r#"["-Dfabric.fixPackageAccess=true", "-Dfabric.gameVersion=1.8.9"]"#, + ) + .unwrap(); + let natives = std::env::temp_dir(); + + let args = java_arguments( + false, + Some(&loader_jvm), + &natives, + &natives, + "/libs/fabric-loader.jar", + "1.8.9", + 2048, + String::new(), + "arm64", + 21, + ) + .unwrap(); + + assert!(args.contains(&"-Dfabric.fixPackageAccess=true".to_string())); + let cp = args.iter().position(|a| a == "-cp").expect("no classpath"); + assert_eq!(args[cp + 1], "/libs/fabric-loader.jar"); + assert!(args.iter().any(|a| a.starts_with("-Djava.library.path="))); + } + + #[test] + fn a_loader_game_argument_does_not_replace_the_legacy_string() { + let loader_game: Vec = + serde_json::from_str(r#"["--fabric"]"#).unwrap(); + let dir = std::env::temp_dir(); + + let args = minecraft_arguments( + false, + Some(&loader_game), + Some("--username ${auth_player_name} --gameDir ${game_directory}"), + "token", + "player", + uuid::Uuid::nil(), + "1.8.9", + "1.8", + &dir, + &dir, + interfrost::api::minecraft::VersionType::Release, + Resolution::default(), + "arm64", + ) + .unwrap(); + + assert_eq!(args.first().unwrap(), "--username"); + assert_eq!(args[1], "player"); + assert_eq!(args.last().unwrap(), "--fabric"); + } +} diff --git a/packages/oneclient_mc/src/install.rs b/packages/oneclient_mc/src/install.rs index 77924ce5..6f3bf560 100644 --- a/packages/oneclient_mc/src/install.rs +++ b/packages/oneclient_mc/src/install.rs @@ -50,6 +50,11 @@ impl DownloadPlan { } } +#[must_use] +pub fn uses_legacy_assets(assets: &str) -> bool { + matches!(assets, "legacy" | "pre-1.6") +} + fn asset_object_path(dir: &Path, name: &str, hash: &str, legacy: bool) -> PathBuf { if legacy { dir.join(name.replace('/', std::path::MAIN_SEPARATOR_STR)) @@ -77,6 +82,12 @@ fn library_artifact_size(lib: &Library) -> u64 { .map_or(0, |artifact| u64::from(artifact.size)) } +pub(crate) fn has_main_artifact(lib: &Library) -> bool { + lib.downloads + .as_ref() + .is_none_or(|downloads| downloads.artifact.is_some()) +} + fn native_download<'a>(lib: &'a Library, java_arch: &str) -> Option<&'a LibraryDownload> { let (os_key, classifiers) = lib.natives.as_ref().and_then(|natives| { Some(( @@ -155,7 +166,7 @@ pub async fn plan_downloads( minecraft_updated: bool, force: bool, ) -> McResult { - let legacy = version.assets == "legacy"; + let legacy = uses_legacy_assets(&version.assets); let asset_dir = if legacy { paths::legacy_assets_dir()? } else { @@ -208,11 +219,12 @@ pub async fn plan_downloads( continue; }; - let artifact = force - || !matches_expected_size( - &lib_dir.join(&artifact_path), - library_artifact_size(&lib), - ); + let artifact = has_main_artifact(&lib) + && (force + || !matches_expected_size( + &lib_dir.join(&artifact_path), + library_artifact_size(&lib), + )); let natives = force || !natives_extracted(&natives_dir, &lib, &java_arch); if !artifact && !natives { @@ -426,7 +438,7 @@ pub async fn verify_game_files( java_arch: &str, minecraft_updated: bool, ) -> McResult { - let legacy = version.assets == "legacy"; + let legacy = uses_legacy_assets(&version.assets); let asset_dir = if legacy { paths::legacy_assets_dir()? } else { @@ -599,7 +611,7 @@ pub async fn download_minecraft( download_assets( ctx, progress, - version.assets == "legacy", + uses_legacy_assets(&version.assets), assets, ), download_client(ctx, progress, version, force), @@ -712,22 +724,60 @@ pub async fn download_version_info( .join(&version_id) .join(format!("{version_id}.json")); - let result = if path.exists() && !force { - let data = polyio::read(&path).await?; - serde_json::from_slice(&data)? - } else { - tracing::debug!( - version_id = %version_id, - "downloading Minecraft version metadata" - ); + if path.exists() && !force { + match polyio::read_json::(&path).await { + Ok(cached) => return Ok(cached), + Err(err) => { + tracing::warn!( + path = %path.display(), + "cached version metadata is unusable, redownloading: {err}" + ); + } + } + } - let version_url = version.url.parse().map_err(McError::Url)?; + tracing::debug!( + version_id = %version_id, + "downloading Minecraft version metadata" + ); + + let version_url = version.url.parse().map_err(McError::Url)?; + let requester = ctx.net.clone(); + let mut info: VersionInfo = match progress { + Some(progress) => { + progress + .run_child( + format!("Version metadata ({version_id})"), + 1, + TaskCategory::Metadata, + |child| { + let requester = requester.clone(); + async move { + child.set_progress(0, Some(1)); + let result = requester + .send_json(Method::GET, version_url, None, &[]) + .await + .map_err(McError::from)?; + child.set_progress(1, Some(1)); + Ok::(result) + } + }) + .await? + } + None => requester + .send_json(Method::GET, version_url, None, &[]) + .await + .map_err(McError::from)?, + }; + + if let Some(loader) = loader { + let loader_url = loader.url.parse().map_err(McError::Url)?; let requester = ctx.net.clone(); - let mut info: VersionInfo = match progress { + let partial: interfrost::api::modded::PartialVersionInfo = match progress { Some(progress) => { progress .run_child( - format!("Version metadata ({version_id})"), + format!("Loader metadata ({version_id})"), 1, TaskCategory::Metadata, |child| { @@ -735,72 +785,39 @@ pub async fn download_version_info( async move { child.set_progress(0, Some(1)); let result = requester - .send_json(Method::GET, version_url, None, &[]) + .send_json(Method::GET, loader_url, None, &[]) .await .map_err(McError::from)?; child.set_progress(1, Some(1)); - Ok::(result) + Ok::( + result, + ) } }) .await? } None => requester - .send_json(Method::GET, version_url, None, &[]) + .send_json(Method::GET, loader_url, None, &[]) .await .map_err(McError::from)?, }; - if let Some(loader) = loader { - let loader_url = loader.url.parse().map_err(McError::Url)?; - let requester = ctx.net.clone(); - let partial: interfrost::api::modded::PartialVersionInfo = match progress { - Some(progress) => { - progress - .run_child( - format!("Loader metadata ({version_id})"), - 1, - TaskCategory::Metadata, - |child| { - let requester = requester.clone(); - async move { - child.set_progress(0, Some(1)); - let result = requester - .send_json(Method::GET, loader_url, None, &[]) - .await - .map_err(McError::from)?; - child.set_progress(1, Some(1)); - Ok::( - result, - ) - } - }) - .await? - } - None => requester - .send_json(Method::GET, loader_url, None, &[]) - .await - .map_err(McError::from)?, - }; - - info = interfrost::api::modded::merge_partial_version(partial, info); - - for lib in &mut info.libraries { - lib.name = lib.name.replace("${interpulse.gameVersion}", &version.id); - } + let legacy_args = info.minecraft_arguments.clone(); + info = interfrost::api::modded::merge_partial_version(partial, info); + if info.minecraft_arguments.is_none() { + info.minecraft_arguments = legacy_args; } - info.id.clone_from(&version_id); - - if let Some(parent) = path.parent() { - polyio::create_dir_all(parent).await?; + for lib in &mut info.libraries { + lib.name = lib.name.replace("${interpulse.gameVersion}", &version.id); } + } - polyio::write(&path, &serde_json::to_vec(&info)?).await?; + info.id.clone_from(&version_id); - info - }; + polyio::write_json_atomic(&path, &info).await?; - Ok(result) + Ok(info) } #[tracing::instrument(skip_all, level = "debug")] @@ -1032,6 +1049,9 @@ pub fn natives_missing( ); } + if !has_main_artifact(lib) { + continue; + } let Ok(rel) = interfrost::utils::get_path_from_artifact(&lib.name) else { continue; }; @@ -1060,6 +1080,9 @@ pub fn libraries_missing( if !lib.include_in_classpath { continue; } + if !has_main_artifact(lib) { + continue; + } let Ok(rel) = interfrost::utils::get_path_from_artifact(&lib.name) else { continue; }; @@ -1098,7 +1121,7 @@ pub fn game_files_missing( minecraft_updated: bool, ) -> McResult { let index = paths::assets_index_dir()?.join(format!("{}.json", version_info.asset_index.id)); - let objects = if version_info.assets == "legacy" { + let objects = if uses_legacy_assets(&version_info.assets) { paths::legacy_assets_dir()? } else { paths::assets_object_dir()? @@ -1424,7 +1447,8 @@ pub async fn resolve_minecraft_version( version_index = manifest.versions.iter().position(|it| it.id == mc_version); } - let version_index = version_index.ok_or(McError::NoMatchingVersion)?; + let version_index = + version_index.ok_or_else(|| McError::InvalidVersion(mc_version.to_string()))?; let versions = &manifest.versions; Ok(( @@ -1482,6 +1506,67 @@ mod tests { (index, objects) } + #[test] + fn a_natives_only_library_has_no_main_jar_to_fetch() { + let natives_only = serde_json::from_str::( + r#"{ + "name": "org.lwjgl.lwjgl:lwjgl-platform:2.9.4+legacyfabric.15", + "downloads": { "classifiers": { "natives-osx": { "sha1": "a", "size": 1, "url": "https://maven.legacyfabric.net/natives-osx.jar" } } }, + "natives": { "osx-arm64": "natives-osx" } + }"#, + ) + .unwrap(); + let with_artifact = serde_json::from_str::( + r#"{ + "name": "org.ow2.asm:asm:9.10.1", + "downloads": { "artifact": { "sha1": "b", "size": 2, "url": "https://maven.fabricmc.net/asm.jar" } } + }"#, + ) + .unwrap(); + let coordinates_only = + serde_json::from_str::(r#"{ "name": "net.fabricmc:fabric-loader:0.19.5" }"#) + .unwrap(); + + assert!(!has_main_artifact(&natives_only)); + assert!(has_main_artifact(&with_artifact)); + assert!(has_main_artifact(&coordinates_only)); + } + + #[test] + fn old_versions_keep_their_assets_in_a_flat_tree() { + assert!(uses_legacy_assets("pre-1.6")); + assert!(uses_legacy_assets("legacy")); + assert!(!uses_legacy_assets("1.8")); + assert!(!uses_legacy_assets("1.14")); + } + + #[test] + fn a_manifest_without_a_wildcard_resolves_per_game_version() { + let manifest = serde_json::from_str::( + r#"{"gameVersions": [ + { "id": "b1.7.3", "stable": true, "loaders": [ + { "id": "0.19.5", "url": "https://meta.example/b1.7.3/0.19.5.json", "stable": true }, + { "id": "0.19.4", "url": "https://meta.example/b1.7.3/0.19.4.json", "stable": false } + ]}, + { "id": "1.8.9", "stable": true, "loaders": [ + { "id": "0.19.5", "url": "https://meta.example/1.8.9/0.19.5.json", "stable": true } + ]} + ]}"#, + ) + .unwrap(); + + let (saw, resolved) = resolve_loader_from_manifest(&manifest, "b1.7.3", None); + assert!(saw); + assert_eq!(resolved.unwrap().id, "0.19.5"); + + let (saw, resolved) = resolve_loader_from_manifest(&manifest, "b1.7.3", Some("0.19.4")); + assert!(saw); + assert_eq!(resolved.unwrap().id, "0.19.4"); + + let (saw, resolved) = resolve_loader_from_manifest(&manifest, "1.21", None); + assert!(!saw && resolved.is_none()); + } + #[test] fn a_complete_assets_tree_needs_no_repair() { let dir = scratch("assets-ok"); diff --git a/packages/oneclient_mc/src/manifest.rs b/packages/oneclient_mc/src/manifest.rs index dd3b8f1c..5a8a1009 100644 --- a/packages/oneclient_mc/src/manifest.rs +++ b/packages/oneclient_mc/src/manifest.rs @@ -27,7 +27,7 @@ struct MetadataInner { neo: Option, fabric: Option, quilt: Option, - legacyfabric: Option, + ornithe: Option, } impl MetadataStore { @@ -50,6 +50,10 @@ impl MetadataStore { self.initialize(ctx).await?; } + if self.inner.minecraft.is_none() { + self.refetch_errored(ctx).await; + } + self.get_vanilla() } @@ -87,7 +91,7 @@ impl MetadataStore { GameLoader::NeoForge => self.inner.neo.as_ref(), GameLoader::Fabric => self.inner.fabric.as_ref(), GameLoader::Quilt => self.inner.quilt.as_ref(), - GameLoader::LegacyFabric => self.inner.legacyfabric.as_ref(), + GameLoader::Ornithe => self.inner.ornithe.as_ref(), GameLoader::Vanilla => None, } .ok_or_else(|| McError::FetchError) @@ -99,27 +103,29 @@ impl MetadataStore { let mut save_file = false; let mut metadata = Self::default(); - if let Ok(bytes) = polyio::read(&path).await { - if let Ok(inner) = serde_json::from_slice::(&bytes) { + match polyio::read_json::(&path).await { + Ok(inner) => { metadata.inner = inner; if metadata.refetch_errored(ctx).await > 0 { save_file = true; } - } else { + } + Err(err) => { + if path.exists() { + tracing::warn!( + path = %path.display(), + "cached metadata manifest is unusable, refetching: {err}" + ); + } + metadata.fetch_all(ctx).await; save_file = true; } - } else { - metadata.fetch_all(ctx).await; - save_file = true; } if save_file { - if let Some(parent) = path.parent() { - polyio::create_dir_all(parent).await?; - } - polyio::write(&path, &serde_json::to_vec(&metadata.inner)?).await?; + polyio::write_json_atomic(&path, &metadata.inner).await?; } *self = metadata; @@ -164,25 +170,28 @@ impl MetadataStore { check_modded!(neo); check_modded!(fabric); check_modded!(quilt); + check_modded!(ornithe); changed } #[tracing::instrument(level = "debug", skip_all)] pub async fn fetch_all(&mut self, ctx: &McCtx) { - let (minecraft, forge, neo, fabric, quilt) = tokio::join!( + let (minecraft, forge, neo, fabric, quilt, ornithe) = tokio::join!( fetch_vanilla_manifest(ctx), fetch_modded_manifest(ctx, GameLoader::Forge), fetch_modded_manifest(ctx, GameLoader::NeoForge), fetch_modded_manifest(ctx, GameLoader::Fabric), fetch_modded_manifest(ctx, GameLoader::Quilt), + fetch_modded_manifest(ctx, GameLoader::Ornithe), ); - self.inner.minecraft = minecraft.ok(); - self.inner.forge = forge.ok(); - self.inner.neo = neo.ok(); - self.inner.fabric = fabric.ok(); - self.inner.quilt = quilt.ok(); + keep_fetched(&mut self.inner.minecraft, minecraft); + keep_fetched(&mut self.inner.forge, forge); + keep_fetched(&mut self.inner.neo, neo); + keep_fetched(&mut self.inner.fabric, fabric); + keep_fetched(&mut self.inner.quilt, quilt); + keep_fetched(&mut self.inner.ornithe, ornithe); } #[tracing::instrument(level = "debug", skip(self, ctx))] @@ -201,12 +210,8 @@ impl MetadataStore { let mut loaders = Vec::new(); for loader in GameLoader::modded_loaders() { - let manifest = match self.get_modded(*loader) { - Ok(manifest) => manifest, - Err(McError::NotModdedManifest(_)) => { - continue - } - Err(e) => return Err(e), + let Ok(manifest) = self.get_modded(*loader) else { + continue; }; let found = manifest.game_versions.iter().any(|entry| { @@ -229,6 +234,12 @@ impl MetadataStore { } } +fn keep_fetched(slot: &mut Option, fetched: McResult) { + if let Ok(data) = fetched { + *slot = Some(data); + } +} + #[tracing::instrument(level = "debug", skip_all)] async fn fetch_vanilla_manifest(ctx: &McCtx) -> McResult { match fetch_manifest::(ctx, GameLoader::Vanilla).await { diff --git a/packages/polyio/Cargo.toml b/packages/polyio/Cargo.toml index 3c8961c0..d13badf3 100644 --- a/packages/polyio/Cargo.toml +++ b/packages/polyio/Cargo.toml @@ -46,4 +46,5 @@ sha2.workspace = true [target.'cfg(windows)'.dependencies] junction.workspace = true +windows-sys.workspace = true diff --git a/packages/polyio/src/file.rs b/packages/polyio/src/file.rs index 09aed9b6..ec5834b8 100644 --- a/packages/polyio/src/file.rs +++ b/packages/polyio/src/file.rs @@ -620,6 +620,10 @@ pub async fn symlink_metadata(path: impl AsRef) -> PolyIOResult }) } +fn resolved_target(original: &std::path::Path) -> PathBuf { + crate::canonicalize(original).unwrap_or_else(|_| original.to_path_buf()) +} + /// Symlink on Unix hard link on Windows so both paths must be on one volume #[tracing::instrument( level = "debug", @@ -633,7 +637,8 @@ pub async fn symlink_file( original: impl AsRef, link: impl AsRef, ) -> PolyIOResult<()> { - let original = original.as_ref(); + let original = resolved_target(original.as_ref()); + let original = original.as_path(); let link = link.as_ref(); #[cfg(windows)] @@ -647,9 +652,70 @@ pub async fn symlink_file( }) } -/// Windows gets a junction which needs no elevated privilege unlike a real -/// directory symlink -/// Remove with [`remove_symlink_dir`] +// what a file is on disk rather than what it is called +#[cfg(windows)] +#[tracing::instrument( + level = "debug", + skip(path), + fields(path = %path.as_ref().display()) +)] +pub async fn file_id(path: impl AsRef) -> PolyIOResult<(u64, u64)> { + use std::os::windows::io::AsRawHandle; + + use windows_sys::Win32::Storage::FileSystem::{ + BY_HANDLE_FILE_INFORMATION, GetFileInformationByHandle, + }; + + let path = path.as_ref().to_path_buf(); + let display = path.to_string_lossy().to_string(); + + tokio::task::spawn_blocking(move || { + let file = std::fs::File::open(&path)?; + let mut info = BY_HANDLE_FILE_INFORMATION { + dwFileAttributes: 0, + ftCreationTime: unsafe { std::mem::zeroed() }, + ftLastAccessTime: unsafe { std::mem::zeroed() }, + ftLastWriteTime: unsafe { std::mem::zeroed() }, + dwVolumeSerialNumber: 0, + nFileSizeHigh: 0, + nFileSizeLow: 0, + nNumberOfLinks: 0, + nFileIndexHigh: 0, + nFileIndexLow: 0, + }; + + let filled = unsafe { + GetFileInformationByHandle(file.as_raw_handle().cast(), std::ptr::from_mut(&mut info)) + }; + + if filled == 0 { + return Err(std::io::Error::last_os_error()); + } + + let index = (u64::from(info.nFileIndexHigh) << 32) | u64::from(info.nFileIndexLow); + Ok((u64::from(info.dwVolumeSerialNumber), index)) + }) + .await + .map_err(std::io::Error::other)? + .map_err(|e| IOError::PathIOError { + source: e, + path: display, + }) +} + +#[cfg(not(windows))] +#[tracing::instrument( + level = "debug", + skip(path), + fields(path = %path.as_ref().display()) +)] +pub async fn file_id(path: impl AsRef) -> PolyIOResult<(u64, u64)> { + use std::os::unix::fs::MetadataExt; + + let meta = stat(path).await?; + Ok((meta.dev(), meta.ino())) +} + #[tracing::instrument( level = "debug", skip(original, link), @@ -662,7 +728,8 @@ pub async fn symlink_dir( original: impl AsRef, link: impl AsRef, ) -> PolyIOResult<()> { - let original = original.as_ref(); + let original = resolved_target(original.as_ref()); + let original = original.as_path(); let link = link.as_ref(); #[cfg(windows)] @@ -685,7 +752,34 @@ pub async fn symlink_dir( }) } -/// A Windows junction must be removed with `remove_dir` not `remove_file` +#[tracing::instrument( + level = "debug", + skip(path), + fields(path = %path.as_ref().display()) +)] +pub async fn read_link(path: impl AsRef) -> PolyIOResult { + let path = path.as_ref(); + + let target = tokio::fs::read_link(path) + .await + .map_err(|e| IOError::PathIOError { + source: e, + path: path.to_string_lossy().to_string(), + })?; + + #[cfg(windows)] + { + let text = target.to_string_lossy().into_owned(); + for prefix in [r"\??\", r"\\?\"] { + if let Some(rest) = text.strip_prefix(prefix) { + return Ok(std::path::PathBuf::from(rest)); + } + } + } + + Ok(target) +} + #[tracing::instrument( level = "debug", skip(path), @@ -758,6 +852,45 @@ mod tests { assert_eq!(write_buffer_size(Some(0)), DEFAULT_WRITE_BUFFER); } + #[cfg(unix)] + #[tokio::test] + async fn a_link_target_is_stored_resolved() { + let root = scratch("resolved-target"); + let real = root.join("var").join("home"); + std::fs::create_dir_all(&real).unwrap(); + std::os::unix::fs::symlink(&real, root.join("home")).unwrap(); + + let pack = real.join("pack.zip"); + std::fs::write(&pack, b"pack").unwrap(); + + let through_link = root.join("home").join("pack.zip"); + let link = root.join("resourcepacks.zip"); + + symlink_file(&through_link, &link).await.unwrap(); + + assert_eq!( + read_link(&link).await.unwrap(), + crate::canonicalize(&pack).unwrap(), + "the stored target must be the resolved path, not the spelling we were given" + ); + + std::fs::remove_dir_all(&root).ok(); + } + + #[cfg(unix)] + #[tokio::test] + async fn a_missing_target_is_stored_as_given() { + let root = scratch("unresolved-target"); + let missing = root.join("not-here.zip"); + let link = root.join("link.zip"); + + symlink_file(&missing, &link).await.unwrap(); + + assert_eq!(read_link(&link).await.unwrap(), missing); + + std::fs::remove_dir_all(&root).ok(); + } + fn scratch(tag: &str) -> PathBuf { static N: AtomicU64 = AtomicU64::new(0); let dir = std::env::temp_dir().join(format!(