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 @@
-
+
-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 | | |