This commit is contained in:
Julian Chang 2026-07-29 23:04:28 +09:00
commit 087365e877
No known key found for this signature in database
52 changed files with 1848 additions and 307 deletions

148
Cargo.lock generated
View file

@ -517,7 +517,7 @@ version = "0.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532"
dependencies = [
"event-listener",
"event-listener 5.4.1",
"event-listener-strategy",
"futures-core",
"pin-project-lite",
@ -586,7 +586,7 @@ version = "3.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311"
dependencies = [
"event-listener",
"event-listener 5.4.1",
"event-listener-strategy",
"pin-project-lite",
]
@ -604,7 +604,7 @@ dependencies = [
"async-task",
"blocking",
"cfg-if",
"event-listener",
"event-listener 5.4.1",
"futures-lite",
"rustix 1.1.4",
]
@ -704,6 +704,15 @@ dependencies = [
"tungstenite",
]
[[package]]
name = "async-watch"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a078faf4e27c0c6cc0efb20e5da59dcccc04968ebf2801d8e0b2195124cdcdb2"
dependencies = [
"event-listener 2.5.3",
]
[[package]]
name = "async_zip"
version = "0.0.18"
@ -1949,6 +1958,12 @@ dependencies = [
"num-traits",
]
[[package]]
name = "event-listener"
version = "2.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0206175f82b8d6bf6652ff7d71a1e27fd2e4efde587fd368662814d6ec1d9ce0"
[[package]]
name = "event-listener"
version = "5.4.1"
@ -1966,7 +1981,7 @@ version = "0.5.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93"
dependencies = [
"event-listener",
"event-listener 5.4.1",
"pin-project-lite",
]
@ -2113,8 +2128,8 @@ dependencies = [
[[package]]
name = "freya"
version = "0.4.0-rc.23"
source = "git+https://github.com/marc2332/freya.git?branch=fix%2Fblur#be57ee3184e047ac932057cd063f7858741d41d9"
version = "0.4.0"
source = "git+https://github.com/marc2332/freya.git?branch=fix%2Fblur#f3140a08462d907d607b91a8aba3b8f9fe3420e4"
dependencies = [
"cfg-if",
"freya-android",
@ -2136,8 +2151,8 @@ dependencies = [
[[package]]
name = "freya-android"
version = "0.4.0-rc.23"
source = "git+https://github.com/marc2332/freya.git?branch=fix%2Fblur#be57ee3184e047ac932057cd063f7858741d41d9"
version = "0.4.0"
source = "git+https://github.com/marc2332/freya.git?branch=fix%2Fblur#f3140a08462d907d607b91a8aba3b8f9fe3420e4"
dependencies = [
"freya-components",
"freya-core",
@ -2149,8 +2164,8 @@ dependencies = [
[[package]]
name = "freya-animation"
version = "0.4.0-rc.23"
source = "git+https://github.com/marc2332/freya.git?branch=fix%2Fblur#be57ee3184e047ac932057cd063f7858741d41d9"
version = "0.4.0"
source = "git+https://github.com/marc2332/freya.git?branch=fix%2Fblur#f3140a08462d907d607b91a8aba3b8f9fe3420e4"
dependencies = [
"async-io",
"easer",
@ -2159,8 +2174,8 @@ dependencies = [
[[package]]
name = "freya-clipboard"
version = "0.4.0-rc.23"
source = "git+https://github.com/marc2332/freya.git?branch=fix%2Fblur#be57ee3184e047ac932057cd063f7858741d41d9"
version = "0.4.1"
source = "git+https://github.com/marc2332/freya.git?branch=fix%2Fblur#f3140a08462d907d607b91a8aba3b8f9fe3420e4"
dependencies = [
"copypasta",
"freya-core",
@ -2168,8 +2183,8 @@ dependencies = [
[[package]]
name = "freya-components"
version = "0.4.0-rc.23"
source = "git+https://github.com/marc2332/freya.git?branch=fix%2Fblur#be57ee3184e047ac932057cd063f7858741d41d9"
version = "0.4.0"
source = "git+https://github.com/marc2332/freya.git?branch=fix%2Fblur#f3140a08462d907d607b91a8aba3b8f9fe3420e4"
dependencies = [
"accesskit",
"anyhow",
@ -2188,19 +2203,20 @@ dependencies = [
"freya-sdk",
"open",
"paste",
"reqwest 0.12.28",
"rustc-hash",
"thiserror 2.0.18",
"torin",
"tracing",
"ureq",
]
[[package]]
name = "freya-core"
version = "0.4.0-rc.23"
source = "git+https://github.com/marc2332/freya.git?branch=fix%2Fblur#be57ee3184e047ac932057cd063f7858741d41d9"
version = "0.4.0"
source = "git+https://github.com/marc2332/freya.git?branch=fix%2Fblur#f3140a08462d907d607b91a8aba3b8f9fe3420e4"
dependencies = [
"accesskit",
"async-broadcast",
"async-watch",
"bitflags 2.12.1",
"bytes 1.11.1",
"cursor-icon",
@ -2224,8 +2240,8 @@ dependencies = [
[[package]]
name = "freya-edit"
version = "0.4.0-rc.23"
source = "git+https://github.com/marc2332/freya.git?branch=fix%2Fblur#be57ee3184e047ac932057cd063f7858741d41d9"
version = "0.4.0"
source = "git+https://github.com/marc2332/freya.git?branch=fix%2Fblur#f3140a08462d907d607b91a8aba3b8f9fe3420e4"
dependencies = [
"bitflags 2.12.1",
"freya-clipboard",
@ -2238,24 +2254,24 @@ dependencies = [
[[package]]
name = "freya-engine"
version = "0.4.0-rc.23"
source = "git+https://github.com/marc2332/freya.git?branch=fix%2Fblur#be57ee3184e047ac932057cd063f7858741d41d9"
version = "0.4.0"
source = "git+https://github.com/marc2332/freya.git?branch=fix%2Fblur#f3140a08462d907d607b91a8aba3b8f9fe3420e4"
dependencies = [
"freya-skia-safe",
]
[[package]]
name = "freya-icons"
version = "0.4.0-rc.23"
source = "git+https://github.com/marc2332/freya.git?branch=fix%2Fblur#be57ee3184e047ac932057cd063f7858741d41d9"
version = "0.4.0"
source = "git+https://github.com/marc2332/freya.git?branch=fix%2Fblur#f3140a08462d907d607b91a8aba3b8f9fe3420e4"
dependencies = [
"bytes 1.11.1",
]
[[package]]
name = "freya-markdown"
version = "0.4.0-rc.23"
source = "git+https://github.com/marc2332/freya.git?branch=fix%2Fblur#be57ee3184e047ac932057cd063f7858741d41d9"
version = "0.4.0"
source = "git+https://github.com/marc2332/freya.git?branch=fix%2Fblur#f3140a08462d907d607b91a8aba3b8f9fe3420e4"
dependencies = [
"freya-components",
"freya-core",
@ -2265,8 +2281,8 @@ dependencies = [
[[package]]
name = "freya-performance-plugin"
version = "0.4.0-rc.23"
source = "git+https://github.com/marc2332/freya.git?branch=fix%2Fblur#be57ee3184e047ac932057cd063f7858741d41d9"
version = "0.4.0"
source = "git+https://github.com/marc2332/freya.git?branch=fix%2Fblur#f3140a08462d907d607b91a8aba3b8f9fe3420e4"
dependencies = [
"freya-core",
"freya-engine",
@ -2275,8 +2291,8 @@ dependencies = [
[[package]]
name = "freya-query"
version = "0.4.0-rc.23"
source = "git+https://github.com/marc2332/freya.git?branch=fix%2Fblur#be57ee3184e047ac932057cd063f7858741d41d9"
version = "0.4.0"
source = "git+https://github.com/marc2332/freya.git?branch=fix%2Fblur#f3140a08462d907d607b91a8aba3b8f9fe3420e4"
dependencies = [
"async-io",
"freya-core",
@ -2285,16 +2301,16 @@ dependencies = [
[[package]]
name = "freya-radio"
version = "0.4.0-rc.23"
source = "git+https://github.com/marc2332/freya.git?branch=fix%2Fblur#be57ee3184e047ac932057cd063f7858741d41d9"
version = "0.4.0"
source = "git+https://github.com/marc2332/freya.git?branch=fix%2Fblur#f3140a08462d907d607b91a8aba3b8f9fe3420e4"
dependencies = [
"freya-core",
]
[[package]]
name = "freya-router"
version = "0.4.0-rc.23"
source = "git+https://github.com/marc2332/freya.git?branch=fix%2Fblur#be57ee3184e047ac932057cd063f7858741d41d9"
version = "0.4.0"
source = "git+https://github.com/marc2332/freya.git?branch=fix%2Fblur#f3140a08462d907d607b91a8aba3b8f9fe3420e4"
dependencies = [
"freya-core",
"freya-router-macro",
@ -2305,8 +2321,8 @@ dependencies = [
[[package]]
name = "freya-router-macro"
version = "0.4.0-rc.23"
source = "git+https://github.com/marc2332/freya.git?branch=fix%2Fblur#be57ee3184e047ac932057cd063f7858741d41d9"
version = "0.4.0"
source = "git+https://github.com/marc2332/freya.git?branch=fix%2Fblur#f3140a08462d907d607b91a8aba3b8f9fe3420e4"
dependencies = [
"proc-macro2",
"quote",
@ -2316,8 +2332,8 @@ dependencies = [
[[package]]
name = "freya-sdk"
version = "0.4.0-rc.23"
source = "git+https://github.com/marc2332/freya.git?branch=fix%2Fblur#be57ee3184e047ac932057cd063f7858741d41d9"
version = "0.4.0"
source = "git+https://github.com/marc2332/freya.git?branch=fix%2Fblur#f3140a08462d907d607b91a8aba3b8f9fe3420e4"
dependencies = [
"async-io",
"freya-core",
@ -2367,8 +2383,8 @@ dependencies = [
[[package]]
name = "freya-winit"
version = "0.4.0-rc.23"
source = "git+https://github.com/marc2332/freya.git?branch=fix%2Fblur#be57ee3184e047ac932057cd063f7858741d41d9"
version = "0.4.0"
source = "git+https://github.com/marc2332/freya.git?branch=fix%2Fblur#f3140a08462d907d607b91a8aba3b8f9fe3420e4"
dependencies = [
"accesskit",
"accesskit_winit",
@ -3008,6 +3024,7 @@ dependencies = [
"tokio",
"tokio-rustls",
"tower-service",
"webpki-roots",
]
[[package]]
@ -3045,7 +3062,7 @@ dependencies = [
"js-sys",
"log",
"wasm-bindgen",
"windows-core 0.62.2",
"windows-core 0.56.0",
]
[[package]]
@ -4500,7 +4517,7 @@ dependencies = [
[[package]]
name = "oneclient_app"
version = "2.1.0"
version = "2.1.1"
dependencies = [
"anyhow",
"arboard",
@ -4538,7 +4555,7 @@ dependencies = [
[[package]]
name = "oneclient_auth"
version = "2.1.0"
version = "2.1.1"
dependencies = [
"base64",
"chrono",
@ -4561,7 +4578,7 @@ dependencies = [
[[package]]
name = "oneclient_cluster"
version = "2.1.0"
version = "2.1.1"
dependencies = [
"bytes 1.11.1",
"chrono",
@ -4584,7 +4601,7 @@ dependencies = [
[[package]]
name = "oneclient_common"
version = "2.1.0"
version = "2.1.1"
dependencies = [
"directories",
"interfrost",
@ -4595,7 +4612,7 @@ dependencies = [
[[package]]
name = "oneclient_content"
version = "2.1.0"
version = "2.1.1"
dependencies = [
"async-trait",
"async_zip",
@ -4621,7 +4638,7 @@ dependencies = [
[[package]]
name = "oneclient_core"
version = "2.1.0"
version = "2.1.1"
dependencies = [
"async-compression",
"async-stream",
@ -4685,7 +4702,7 @@ dependencies = [
[[package]]
name = "oneclient_db"
version = "2.1.0"
version = "2.1.1"
dependencies = [
"chrono",
"dunce",
@ -4699,7 +4716,7 @@ dependencies = [
[[package]]
name = "oneclient_discord"
version = "2.1.0"
version = "2.1.1"
dependencies = [
"chrono",
"discord-rich-presence",
@ -4711,7 +4728,7 @@ dependencies = [
[[package]]
name = "oneclient_events"
version = "2.1.0"
version = "2.1.1"
dependencies = [
"thiserror 2.0.18",
"tokio",
@ -4721,7 +4738,7 @@ dependencies = [
[[package]]
name = "oneclient_java"
version = "2.1.0"
version = "2.1.1"
dependencies = [
"astral-tokio-tar",
"async-tempfile",
@ -4743,7 +4760,7 @@ dependencies = [
[[package]]
name = "oneclient_macro"
version = "2.1.0"
version = "2.1.1"
dependencies = [
"proc-macro2",
"quote",
@ -4752,7 +4769,7 @@ dependencies = [
[[package]]
name = "oneclient_mc"
version = "2.1.0"
version = "2.1.1"
dependencies = [
"base64",
"chrono",
@ -4774,7 +4791,7 @@ dependencies = [
[[package]]
name = "oneclient_net"
version = "2.1.0"
version = "2.1.1"
dependencies = [
"arc-swap",
"async-trait",
@ -4796,7 +4813,7 @@ dependencies = [
[[package]]
name = "oneclient_polyplus"
version = "2.1.0"
version = "2.1.1"
dependencies = [
"async-tungstenite",
"bytes 1.11.1",
@ -4927,8 +4944,8 @@ checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec"
[[package]]
name = "pathgraph"
version = "0.4.0-rc.23"
source = "git+https://github.com/marc2332/freya.git?branch=fix%2Fblur#be57ee3184e047ac932057cd063f7858741d41d9"
version = "0.4.0"
source = "git+https://github.com/marc2332/freya.git?branch=fix%2Fblur#f3140a08462d907d607b91a8aba3b8f9fe3420e4"
[[package]]
name = "percent-encoding"
@ -5073,7 +5090,7 @@ checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3"
[[package]]
name = "polyio"
version = "2.1.0"
version = "2.1.1"
dependencies = [
"astral-tokio-tar",
"async-compression",
@ -5352,8 +5369,8 @@ checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]]
name = "ragnarok"
version = "0.4.0-rc.23"
source = "git+https://github.com/marc2332/freya.git?branch=fix%2Fblur#be57ee3184e047ac932057cd063f7858741d41d9"
version = "0.4.0"
source = "git+https://github.com/marc2332/freya.git?branch=fix%2Fblur#f3140a08462d907d607b91a8aba3b8f9fe3420e4"
dependencies = [
"euclid",
"hotpath",
@ -5606,6 +5623,7 @@ dependencies = [
"futures-channel",
"futures-core",
"futures-util",
"h2",
"http 1.4.1",
"http-body",
"http-body-util",
@ -5635,6 +5653,7 @@ dependencies = [
"wasm-bindgen-futures",
"wasm-streams 0.4.2",
"web-sys",
"webpki-roots",
]
[[package]]
@ -6508,7 +6527,7 @@ dependencies = [
"crc",
"crossbeam-queue",
"either",
"event-listener",
"event-listener 5.4.1",
"futures-core",
"futures-intrusive",
"futures-io",
@ -7122,8 +7141,8 @@ checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db"
[[package]]
name = "torin"
version = "0.4.0-rc.23"
source = "git+https://github.com/marc2332/freya.git?branch=fix%2Fblur#be57ee3184e047ac932057cd063f7858741d41d9"
version = "0.4.0"
source = "git+https://github.com/marc2332/freya.git?branch=fix%2Fblur#f3140a08462d907d607b91a8aba3b8f9fe3420e4"
dependencies = [
"euclid",
"itertools 0.14.0",
@ -7410,7 +7429,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dea7109cdcd5864d4eeb1b58a1648dc9bf520360d7af16ec26d0a9354bafcfc0"
dependencies = [
"base64",
"flate2",
"log",
"percent-encoding",
"rustls",
@ -8616,7 +8634,7 @@ dependencies = [
"async-trait",
"blocking",
"enumflags2",
"event-listener",
"event-listener 5.4.1",
"futures-core",
"futures-lite",
"hex",

View file

@ -15,7 +15,7 @@ homepage = "https://polyfrost.org/projects/oneclient"
license = "GPL-3.0-only"
readme = "README.md"
repository = "https://github.com/Polyfrost/OneLauncher"
version = "2.1.0"
version = "2.1.1"
[workspace.dependencies]
@ -147,12 +147,6 @@ freya = { git = "https://github.com/marc2332/freya.git", branch = "fix/blur" }
opt-level = 3
lto = "fat"
codegen-units = 1
# Keep the *shipped* binary stripped and small, but still emit debug info into
# separate files (macOS: .dSYM, Linux: .dwp, Windows: .pdb) so Sentry can
# symbolicate crash reports. CI uploads those files with `sentry-cli debug-files
# upload`; they are never shipped to users. Without this, release stack traces
# are just raw addresses. Use "full" instead of "line-tables-only" if you also
# want inlined-frame / variable info (larger debug files, slower builds).
strip = true
panic = "abort"
debug = "line-tables-only"

View file

@ -2,7 +2,7 @@ use freya::prelude::*;
use oneclient_common::VersionKey;
use oneclient_common::domain::GameLoader;
use crate::components::DynamicArt;
use crate::components::{ART_PREVIEW_EDGE, DynamicArt};
use crate::theme::colors;
use crate::ui::border_all_color;
@ -20,7 +20,7 @@ impl ClusterLandscapeArt {
selected: bool,
) -> Self {
Self {
art: DynamicArt::for_version(major, key, loader).max_edge(512),
art: DynamicArt::for_version(major, key, loader).max_edge(ART_PREVIEW_EDGE),
selected,
}
}

View file

@ -9,12 +9,18 @@ use crate::AppAssets;
use crate::hooks::{loaded_image, use_cached_image, use_version_metadata};
use crate::layout::HOME_BACKGROUND_ASSET;
/// The size the cluster cards ask for, and so the one variant of a version's
/// art that is usually already cached by the time anything else wants it.
/// Full-screen art stands in with it while its own download is in flight.
pub const ART_PREVIEW_EDGE: u32 = 512;
#[derive(PartialEq, Clone)]
pub struct DynamicArt {
major: Option<u32>,
key: Option<VersionKey>,
loader: Option<GameLoader>,
max_edge: u32,
preview_edge: Option<u32>,
}
impl DynamicArt {
@ -24,6 +30,7 @@ impl DynamicArt {
key,
loader,
max_edge: DEFAULT_IMAGE_EDGE,
preview_edge: None,
}
}
@ -34,6 +41,7 @@ impl DynamicArt {
key: parsed.and_then(|p| p.key()),
loader: Some(cluster.mc_loader),
max_edge: DEFAULT_IMAGE_EDGE,
preview_edge: None,
}
}
@ -43,6 +51,7 @@ impl DynamicArt {
key: None,
loader: None,
max_edge: DEFAULT_IMAGE_EDGE,
preview_edge: None,
}
}
@ -52,8 +61,27 @@ impl DynamicArt {
self
}
/// Stand in with a smaller variant of the same art until the full-size one
/// has downloaded.
///
/// A full-size background is a few hundred KB and the cluster cards have
/// usually already cached the small one, so this puts the right picture on
/// screen right away and sharpens it later, instead of sitting on the
/// built-in fallback for the length of a download.
#[must_use]
pub fn preview_edge(mut self, preview_edge: u32) -> Self {
self.preview_edge = Some(preview_edge);
self
}
pub fn use_bytes(&self) -> (String, Bytes) {
use_art_bytes(self.major, self.key, self.loader, self.max_edge)
use_art_bytes(
self.major,
self.key,
self.loader,
self.max_edge,
self.preview_edge,
)
}
}
@ -62,6 +90,7 @@ pub fn use_art_bytes(
key: Option<VersionKey>,
loader: Option<GameLoader>,
max_edge: u32,
preview_edge: Option<u32>,
) -> (String, Bytes) {
let fallback = use_memo(|| AppAssets::get_bytes(HOME_BACKGROUND_ASSET).unwrap_or_default());
@ -69,13 +98,24 @@ pub fn use_art_bytes(
let image_query = use_cached_image(art_url.clone(), max_edge);
match loaded_image(art_url.as_deref(), &image_query) {
Some((url, bytes)) => (format!("{max_edge}|{url}"), bytes),
None => (
// Subscribed unconditionally to keep the hook order stable. Without a
// preview size it is the same query as the one above, so it costs nothing.
let preview_edge = preview_edge.unwrap_or(max_edge);
let preview_query = use_cached_image(art_url.clone(), preview_edge);
let full = loaded_image(art_url.as_deref(), &image_query)
.map(|(url, bytes)| (format!("{max_edge}|{url}"), bytes));
let preview = || {
loaded_image(art_url.as_deref(), &preview_query)
.map(|(url, bytes)| (format!("{preview_edge}|{url}"), bytes))
};
full.or_else(preview).unwrap_or_else(|| {
(
format!("{max_edge}|{HOME_BACKGROUND_ASSET}"),
fallback.read().clone(),
),
}
)
})
}
impl Component for DynamicArt {

View file

@ -8,6 +8,7 @@ pub struct Icon {
pub icon: IconType,
pub size_px: f32,
pub color: Option<Color>,
pub rotation: Option<f32>,
}
impl Icon {
@ -23,6 +24,7 @@ impl Icon {
icon,
size_px: 24.,
color,
rotation: None,
}
}
@ -35,6 +37,12 @@ impl Icon {
self.color = Some(color);
self
}
/// Rotation in degrees, clockwise.
pub fn rotate(mut self, degrees: f32) -> Self {
self.rotation = Some(degrees);
self
}
}
impl Component for Icon {
@ -42,11 +50,13 @@ impl Component for Icon {
let path = self.icon.path();
let bytes = use_memo(move || AppAssets::get_bytes(path).unwrap_or_default());
svg(bytes.read().cloned())
SvgViewer::new((path, bytes.read().cloned()))
.show_loader(false)
.width(Size::px(self.size_px))
.height(Size::px(self.size_px))
// .fill(color)
.map(self.color, |svg, color| svg.color(color))
.map(self.rotation, |svg, degrees| svg.rotation(degrees))
}
}

View file

@ -44,7 +44,7 @@ pub use cluster_landscape_art::ClusterLandscapeArt;
pub use cluster_update_popup::ClusterUpdatePopup;
pub use context_menu::ContextMenu;
pub use dropdown::Dropdown;
pub use dynamic_art::DynamicArt;
pub use dynamic_art::{ART_PREVIEW_EDGE, DynamicArt};
pub use file_drop::FileDropOverlay;
pub use icons::{Icon, IconType};
pub use java_install_manager::JavaInstallManager;

View file

@ -7,8 +7,12 @@ use freya::{
use crate::{
Route,
components::{Avatar, Icon, IconType},
hooks::{try_default_account, use_current_account, use_dispatch, use_notifications_snapshot},
hooks::{
settled_or_loading, try_default_account, use_active_cluster_id, use_clusters,
use_current_account, use_dispatch, use_notifications_snapshot,
},
theme,
utils::sort_clusters_for_home,
};
const NAVBAR_INTRO_MS: u64 = 460;
@ -32,7 +36,7 @@ impl Component for Navbar {
.width(Size::fill())
.height(Size::px(theme::NAVBAR_HEIGHT_PX))
.position(Position::new_absolute().top(0.).left(0.))
.layer(Layer::RelativeOverlay(2))
.layer(Layer::OverlayLevel(2))
.child(
rect()
.width(Size::fill())
@ -68,7 +72,8 @@ fn navbar_left() -> impl IntoElement {
fn navbar_logo() -> impl IntoElement {
let bytes = use_memo(|| crate::AppAssets::get_bytes("logo.svg").unwrap_or_default());
svg(bytes.read().cloned())
SvgViewer::new(("logo.svg", bytes.read().cloned()))
.show_loader(false)
.height(Size::px(44.))
.width(Size::px(214.))
.color(theme::colors::fg_primary())
@ -76,13 +81,14 @@ fn navbar_logo() -> impl IntoElement {
fn navbar_center() -> impl IntoElement {
let route = use_route::<Route>();
let browse_target = browse_target();
rect()
.horizontal()
.width(Size::flex(1.0))
.main_align(Alignment::Center)
.cross_align(Alignment::Center)
.spacing(64.)
.spacing(48.)
.child(NavLink {
active: route == Route::Home {},
target: Route::Home {},
@ -93,6 +99,17 @@ fn navbar_center() -> impl IntoElement {
target: Route::Clusters {},
nav_label: "Versions",
})
.child(NavLink {
active: matches!(
route,
Route::Browser {
pick_cluster: true,
..
}
),
target: browse_target,
nav_label: "Browse",
})
.child(NavLink {
active: route == Route::Stats {},
target: Route::Stats {},
@ -100,6 +117,27 @@ fn navbar_center() -> impl IntoElement {
})
}
/// Browsing always happens for some cluster, so the navbar picks the one the
/// shell has active and falls back to the most recently played one. With no
/// clusters at all there is nothing to browse for, so it points at Versions.
fn browse_target() -> Route {
let clusters = settled_or_loading(&use_clusters()).unwrap_or_default();
let active = *use_active_cluster_id().read();
let cluster_id = active
.filter(|id| clusters.iter().any(|cluster| cluster.id == *id))
.or_else(|| sort_clusters_for_home(clusters).first().map(|c| c.id));
match cluster_id {
Some(cluster_id) => Route::Browser {
cluster_id,
package_type: "mod".to_string(),
pick_cluster: true,
},
None => Route::Clusters {},
}
}
#[derive(PartialEq)]
struct NavLink {
active: bool,

View file

@ -41,7 +41,7 @@ pub fn window_controls() -> impl IntoElement {
.main_align(Alignment::End)
.cross_align(Alignment::Center)
.spacing(8.)
.layer(Layer::RelativeOverlay(u8::MAX))
.layer(Layer::OverlayLevel(15))
.child(
navbar_button()
.child(Icon::new(IconType::Minus))

View file

@ -36,7 +36,8 @@ impl Component for OnboardingNavbar {
fn logo() -> impl IntoElement {
let bytes = use_memo(|| crate::AppAssets::get_bytes("logo.svg").unwrap_or_default());
svg(bytes.read().cloned())
SvgViewer::new(("logo.svg", bytes.read().cloned()))
.show_loader(false)
.height(Size::px(36.))
.width(Size::px(170.))
.color(colors::fg_primary())

View file

@ -84,7 +84,7 @@ impl Component for OverlayPopup {
.position(Position::new_global().top(0.).left(0.))
.width(Size::window_percent(100.))
.height(Size::window_percent(100.))
.layer(Layer::RelativeOverlay(10))
.layer(Layer::OverlayLevel(10))
.background(Color::from_argb(alpha, 0, 0, 0))
.on_press(move |_| {
if let Some(on_close) = scrim_close.as_ref() {
@ -99,7 +99,7 @@ impl Component for OverlayPopup {
.a11y_focusable(true)
.a11y_auto_focus(true)
.a11y_role(AccessibilityRole::Dialog)
.layer(Layer::RelativeOverlay(12))
.layer(Layer::OverlayLevel(12))
.on_global_key_down(move |e: Event<KeyboardEventData>| {
if e.key == Key::Named(NamedKey::Escape)
&& let Some(on_close) = key_close.as_ref()

View file

@ -18,6 +18,7 @@ pub struct Pagination {
page: State<usize>,
total_pages: usize,
window: usize,
enabled: bool,
}
impl Pagination {
@ -26,6 +27,7 @@ impl Pagination {
page,
total_pages,
window: 5,
enabled: true,
}
}
@ -34,17 +36,26 @@ impl Pagination {
self.window = window.max(1);
self
}
/// Greys out every control without unmounting the pager, for callers that
/// are still loading the page the user just asked for.
pub fn enabled(mut self, enabled: bool) -> Self {
self.enabled = enabled;
self
}
}
impl Component for Pagination {
fn render(&self) -> impl IntoElement {
let page = self.page;
let total_pages = self.total_pages;
let interactive = self.enabled;
let current = *page.read();
let last = total_pages.saturating_sub(1);
let icon_btn = move |target: usize, enabled: bool, icon: IconType| {
let icon_btn = move |target: usize, available: bool, icon: IconType| {
let mut page = page;
let enabled = available && interactive;
Button::new()
.secondary()
.icon()
@ -85,6 +96,7 @@ impl Component for Pagination {
.width(Size::px(32.))
.height(Size::px(32.))
.padding(Gaps::new_symmetric(6., 0.))
.enabled(interactive)
.on_press(move |_| page.set(target))
.child(
label()

View file

@ -3,7 +3,7 @@ use freya::prelude::*;
use freya::router::RouterContext;
use oneclient_core::clusters::Cluster;
use crate::components::{DynamicArt, Icon, IconType};
use crate::components::{ART_PREVIEW_EDGE, DynamicArt, Icon, IconType};
use crate::hooks::{settled_or_loading, use_active_cluster_id, use_clusters};
use crate::routes::Route;
use crate::theme::colors;
@ -18,6 +18,8 @@ const MAX_CARD_WIDTH_PX: f32 = 480.0;
const CARD_MS: u64 = 460;
const STAGGER_MS: u64 = 85;
const CARD_RISE_PX: f32 = 48.0;
/// Items the intro staggers individually; past this they rise together.
const INTRO_ITEMS: usize = 6;
#[derive(PartialEq)]
pub struct RecentsRow;
@ -31,17 +33,22 @@ impl Component for RecentsRow {
let sorted: Vec<Cluster> = sort_clusters_for_home(clusters);
let slots = *visible_slots.read();
let display = sorted.into_iter().take(slots).collect::<Vec<_>>();
let count = display.len();
let items = count + 1;
// Budgeted off the clusters we have, never off the slots we can show. An
// offset is the one thing a card must not change on a frame where the row
// is re-measured: the layout engine treats an offset-only change as "inner
// layout" and hands back the card's *cached* area instead of measuring it,
// so a rerun triggered by the slot count would freeze the surviving cards
// at their pre-resize widths while the new ones come in at the new width.
let items = intro_items(sorted.len());
let display = sorted.into_iter().take(slots).collect::<Vec<_>>();
let intro = use_animation_with_dependencies(&items, |conf, items| {
conf.on_creation(OnCreation::Run);
conf.on_change(OnChange::Rerun);
let total = CARD_MS + (items.saturating_sub(1) as u64) * STAGGER_MS;
AnimNum::new(0., 1.)
.time(total.max(1))
.time(intro_ms(*items).max(1))
.function(Function::Linear)
});
let progress = intro.get().value();
@ -51,8 +58,7 @@ impl Component for RecentsRow {
.height(Size::px(ROW_HEIGHT_PX))
.content(Content::Flex)
.on_sized(move |event: Event<SizedEventData>| {
let width = event.data().area.width();
let next = recent_card_slots_for_width(width).max(1);
let next = recent_card_slots_for_width(event.data().area.width());
if next != *visible_slots.peek() {
*visible_slots.write() = next;
}
@ -69,17 +75,28 @@ impl Component for RecentsRow {
.into_element()
}))
.child(OtherVersionsTile {
index: count,
index: items.saturating_sub(1),
items,
progress,
})
}
}
/// Items to stagger for `clusters` recents, counting the "other versions" tile.
fn intro_items(clusters: usize) -> usize {
(clusters + 1).min(INTRO_ITEMS)
}
fn intro_ms(items: usize) -> u64 {
CARD_MS + (items.saturating_sub(1) as u64) * STAGGER_MS
}
fn stagger_eased(progress: f32, index: usize, items: usize) -> f32 {
let total = CARD_MS as f32 + (items.saturating_sub(1) as f32) * STAGGER_MS as f32;
let elapsed = progress * total;
let local = ((elapsed - index as f32 * STAGGER_MS as f32) / CARD_MS as f32).clamp(0., 1.);
let elapsed = progress * intro_ms(items) as f32;
// Anything past the budget shares the last slot, so every item still lands on
// a finished 1.0 rather than freezing part-way with a leftover offset.
let start = index.min(items.saturating_sub(1)) as f32 * STAGGER_MS as f32;
let local = ((elapsed - start) / CARD_MS as f32).clamp(0., 1.);
1.0 - (1.0 - local).powi(3)
}
@ -122,7 +139,6 @@ impl Component for ClusterCard {
rect()
.key(self.cluster.id)
.width(Size::flex(1.0))
.min_width(Size::px(MIN_CARD_WIDTH_PX))
.max_width(Size::px(MAX_CARD_WIDTH_PX))
.height(Size::fill())
.offset_y(rise)
@ -174,7 +190,7 @@ impl Component for ClusterCard {
.height(Size::fill())
.position(Position::new_absolute())
.layer(Layer::Relative(1))
.child(DynamicArt::for_cluster(&self.cluster).max_edge(512)),
.child(DynamicArt::for_cluster(&self.cluster).max_edge(ART_PREVIEW_EDGE)),
)
.child(
rect()
@ -284,12 +300,74 @@ impl Component for OtherVersionsTile {
}
}
/// How many cards fit beside the "other versions" tile at `row_width_px`.
///
/// `n` cards sit in `n` gaps' worth of spacing — `n - 1` between the cards and
/// one before the tile — so `n * (MIN + GAP) + MORE` has to fit. One card is
/// always shown: below that width it just renders narrower than the minimum,
/// which is why the card carries no `min_width`. With one, a stale count during
/// a live resize used to push the row past its bounds and leave a card cut off
/// or stretched until the next measurement landed.
fn recent_card_slots_for_width(row_width_px: f32) -> usize {
if row_width_px <= MORE_TILE_WIDTH_PX + CARD_GAP_PX {
return 0;
if !row_width_px.is_finite() {
return 1;
}
let available = row_width_px - MORE_TILE_WIDTH_PX;
let slot = MIN_CARD_WIDTH_PX + CARD_GAP_PX;
(available / slot).floor() as usize
(available / slot).floor().max(1.0) as usize
}
#[cfg(test)]
mod tests {
use super::*;
fn row_fits(slots: usize, row_width_px: f32) -> bool {
let cards = slots as f32 * MIN_CARD_WIDTH_PX;
let gaps = slots as f32 * CARD_GAP_PX;
cards + gaps + MORE_TILE_WIDTH_PX <= row_width_px
}
#[test]
fn slots_fill_the_row_without_overflowing_it() {
for width in (200..3200).step_by(4) {
let width = width as f32;
let slots = recent_card_slots_for_width(width);
assert!(slots >= 1, "{width}px left no room for a card");
if slots > 1 {
assert!(row_fits(slots, width), "{slots} cards overflow {width}px");
}
assert!(
!row_fits(slots + 1, width),
"{width}px had room for {} cards",
slots + 1
);
}
}
#[test]
fn every_item_settles_when_the_intro_ends() {
// A card left with a leftover offset keeps changing it, and an offset-only
// change is what makes the layout engine reuse a card's cached width.
for clusters in 0..24 {
let items = intro_items(clusters);
for index in 0..=clusters {
assert_eq!(
stagger_eased(1.0, index, items),
1.0,
"item {index} of {clusters} never finished rising"
);
}
}
}
#[test]
fn the_intro_runs_the_same_length_whatever_fits_on_screen() {
// The window width decides how many cards show, so anything the intro is
// measured against has to be independent of it.
assert_eq!(intro_items(3), intro_items(3));
assert_eq!(intro_ms(intro_items(30)), intro_ms(INTRO_ITEMS));
assert_eq!(intro_ms(intro_items(0)), CARD_MS);
}
}

View file

@ -89,7 +89,8 @@ impl Component for CurtainFade {
.vertical()
.spacing(14.)
.child(
svg(logo.read().cloned())
SvgViewer::new(("logo.svg", logo.read().cloned()))
.show_loader(false)
.width(Size::px(288.))
.height(Size::px(60.))
.color(colors::fg_primary()),

View file

@ -10,6 +10,14 @@ use crate::theme::colors;
const BAR_HEIGHT: f32 = 34.;
const AMBER: Color = Color::from_rgb(191, 122, 26);
/// `Layer::RelativeOverlay(n)` resolves to `n * (i16::MAX / 16)`, so any `n`
/// past 16 pins to `i16::MAX`. Children resolve to `parent + 1`, which saturates
/// to the *same* layer — and a layer is an unordered set, so the bar's own
/// background painted over its contents about as often as not. That is what
/// made the message and the close button come and go. 14 keeps the bar above
/// every popup while leaving its children room to sit in front of it.
const BAR_LAYER: u8 = 14;
#[derive(Clone, Copy, PartialEq, Eq, Hash)]
enum Issue {
NoInternet,
@ -121,49 +129,83 @@ impl Component for StatusBanner {
});
let p = intro.get().value();
let content = rect()
let mut close_hover = use_state(|| false);
// Pressing close unmounts the banner from under the pointer, so there is
// no `leave` to put the cursor back.
use_drop(|| Cursor::set(CursorIcon::default()));
let close = issue.closeable().then(|| {
rect()
.center()
.width(Size::px(22.))
.height(Size::px(22.))
.corner_radius(CornerRadius::new_all(6.))
.background(if *close_hover.read() {
Color::WHITE.with_a(46)
} else {
Color::TRANSPARENT
})
.on_pointer_enter(move |_| {
close_hover.set(true);
Cursor::set(CursorIcon::Pointer);
})
.on_pointer_leave(move |_| {
close_hover.set(false);
Cursor::set(CursorIcon::default());
})
.on_press(move |_| {
dismissed.write().insert(issue);
})
.child(Icon::new(IconType::XClose).size(14.).color(Color::WHITE))
.into_element()
});
// The message is centred by a flex spacer on each side, and the close
// button rides the end of the trailing one. It used to be absolutely
// positioned, which pinned it to the top of the content box — inside the
// bar's horizontal padding, so it sat high and well short of the edge.
let leading = rect().width(Size::flex(1.0)).height(Size::fill());
let message = rect()
.horizontal()
.height(Size::fill())
.cross_align(Alignment::Center)
.spacing(8.)
.layer(Layer::OverlayLevel(u8::MAX - 20))
.child(Icon::new(issue.icon()).size(15.).color(Color::WHITE))
.child(
label()
.text(issue.message())
.font_size(12.)
.font_weight(FontWeight::MEDIUM)
.max_lines(1)
.color(Color::WHITE),
);
let close = issue.closeable().then(|| {
rect()
.position(Position::new_absolute().right(10.))
.center()
.width(Size::px(20.))
.height(Size::px(20.))
.corner_radius(CornerRadius::new_all(6.))
.on_pointer_enter(|_| Cursor::set(CursorIcon::Pointer))
.on_pointer_leave(|_| Cursor::set(CursorIcon::default()))
.on_press(move |_| {
dismissed.write().insert(issue);
})
.child(Icon::new(IconType::XClose).size(14.).color(Color::WHITE))
});
let trailing = rect()
.width(Size::flex(1.0))
.height(Size::fill())
.horizontal()
.main_align(Alignment::End)
.cross_align(Alignment::Center)
.layer(Layer::Relative(1))
.maybe_child(close);
rect()
.width(Size::window_percent(100.))
.height(Size::px(BAR_HEIGHT))
.position(Position::new_global().bottom(0.).left(0.))
.layer(Layer::RelativeOverlay(u8::MAX - 20))
.layer(Layer::OverlayLevel(BAR_LAYER))
.background(issue.background())
.opacity(p)
.child(
rect()
.width(Size::fill())
.height(Size::fill())
.center()
.padding(Gaps::new_symmetric(0., 16.))
.child(content)
.maybe_child(close),
)
.horizontal()
.content(Content::Flex)
.cross_align(Alignment::Center)
.spacing(8.)
.padding(Gaps::new_symmetric(0., 10.))
.child(leading)
.child(message)
.child(trailing)
}
}

View file

@ -20,6 +20,10 @@ use crate::{
const TOAST_W: f32 = 300.;
const TRAVEL: f32 = 320.;
const TTL_MS: f32 = 5000.;
/// Toasts sit below the navbar rather than on top of it: the startup/bundle-sync
/// progress toast is on screen exactly when Home first appears, and at any
/// smaller offset its card covered the nav links and account controls.
const TOP_PX: f32 = crate::theme::NAVBAR_HEIGHT_PX + 12.;
#[derive(Clone, Copy, PartialEq)]
enum Variant {
@ -73,8 +77,8 @@ impl Component for Toasts {
let mut stack = rect()
.vertical()
.spacing(12.)
.position(Position::new_global().top(64.).right(24.))
.layer(Layer::RelativeOverlay(3));
.position(Position::new_global().top(TOP_PX).right(24.))
.layer(Layer::OverlayLevel(3));
for entry in entries {
let id = entry.id;

View file

@ -661,28 +661,73 @@ impl Actions {
version_id: impl Into<String>,
) {
let (project_id, version_id) = (project_id.into(), version_id.into());
let actions = self.clone();
self.station
.clone()
.write_channel(AppChannel::Installs)
.installs
.begin(cluster_id, provider, project_id.clone());
spawn_forever(async move {
let Ok(state) = launcher::state() else { return };
let events = state.services.events.clone();
match crate::install::install_package(
let finish = |actions: &Actions| {
actions
.station
.clone()
.write_channel(AppChannel::Installs)
.installs
.finish(cluster_id, provider, &project_id);
};
let Ok(state) = launcher::state() else {
finish(&actions);
return;
};
let install = crate::install::install_package(
&state,
provider,
&project_id,
&version_id,
cluster_id,
)
.await
{
Ok(name) => {
events.notify("Installed").body(format!("Added {name}")).send();
events.signal(oneclient_events::Signal::ClustersChanged);
}
Err(err) => events
.notify("Install failed")
.body(err.to_string())
.error()
.send(),
.await;
// The download already raised a progress notification; the outcome
// replaces it in place rather than arriving as a second one.
let spec = match &install.result {
Ok(name) => NotificationSpec {
title: "Installed".to_string(),
body: format!("Added {name}"),
level: Level::Info,
icon: Some(IconType::Download01),
progress: None,
actions: Vec::new(),
},
Err(err) => NotificationSpec {
title: "Install failed".to_string(),
body: err.to_string(),
level: Level::Error,
icon: None,
progress: None,
actions: Vec::new(),
},
};
let session_id = install.session_id.unwrap_or_else(uuid::Uuid::nil);
actions.with_engine(|app| {
app.notifications
.finish_grouped_as_actions(&mut app.inbox, session_id, Some(spec));
});
if install.result.is_ok() {
state
.services
.events
.signal(oneclient_events::Signal::ClustersChanged);
}
finish(&actions);
});
}

View file

@ -50,7 +50,9 @@ pub use queries::{
};
use crate::notifications::NotificationSnapshot;
use crate::state::{AppChannel, GameState, LauncherInit, LoginProgress, SettingsState};
use crate::state::{
AppChannel, GameState, InstallState, LauncherInit, LoginProgress, SettingsState,
};
use freya::prelude::*;
use freya::radio::use_radio;
@ -102,6 +104,10 @@ pub fn use_game_snapshot() -> GameState {
use_radio(AppChannel::Game).read().game.clone()
}
pub fn use_installs_snapshot() -> InstallState {
use_radio(AppChannel::Installs).read().installs.clone()
}
pub fn use_microsoft_login_status() -> Option<LoginProgress> {
use_radio(AppChannel::MicrosoftLogin)
.read()

View file

@ -79,9 +79,16 @@ impl MutationCapability for ClusterMutation {
let content = &state.services.content();
let result = match keys {
ClusterAction::ToggleArtifact { cluster_id, hash } => {
oneclient_core::toggle_artifact_enabled(*cluster_id, hash, content)
.await
.map(|_| ())
// A live session holds the cluster's files open, so the toggle
// is recorded now and lands in the folder at the next launch.
oneclient_core::toggle_artifact_enabled(
*cluster_id,
hash,
state.games.is_active(*cluster_id),
content,
)
.await
.map(|_| ())
}
ClusterAction::RemoveArtifact { cluster_id, hash } => {
oneclient_core::remove_artifact_from_cluster(*cluster_id, hash, true, content).await

View file

@ -152,20 +152,45 @@ pub async fn combined_cluster_update_spec(
})
}
/// An install's outcome together with the progress session it ran under.
///
/// The session is detached rather than finished so the caller can convert that
/// same notification into the "Installed" / "Install failed" result, instead of
/// leaving a finished progress card and raising a second notification beside it.
/// `session_id` is `None` when the install never got as far as downloading.
pub struct PackageInstall {
pub session_id: Option<uuid::Uuid>,
pub result: anyhow::Result<String>,
}
pub async fn install_package(
state: &Arc<LauncherState>,
provider: oneclient_content::packages::ProviderId,
project_id: &str,
version_id: &str,
cluster_id: i64,
) -> anyhow::Result<String> {
let provider_impl = state.services.packages.get(provider)?;
let project = provider_impl
.get_project(project_id, &state.services.content())
.await?;
let version = provider_impl
.get_version(project_id, version_id, &state.services.content())
.await?;
) -> PackageInstall {
let lookup = async {
let provider_impl = state.services.packages.get(provider)?;
let project = provider_impl
.get_project(project_id, &state.services.content())
.await?;
let version = provider_impl
.get_version(project_id, version_id, &state.services.content())
.await?;
anyhow::Ok((project, version))
}
.await;
let (project, version) = match lookup {
Ok(found) => found,
Err(err) => {
return PackageInstall {
session_id: None,
result: Err(err),
};
}
};
let session = oneclient_events::GroupedProgressSession::start(
&state.services.events,
@ -191,7 +216,9 @@ pub async fn install_package(
.await;
child.finish();
session.finish();
result?;
Ok(project.name)
PackageInstall {
session_id: Some(session.detach()),
result: result.map(|_| project.name).map_err(anyhow::Error::from),
}
}

View file

@ -10,11 +10,13 @@ use skia_safe::sampling_options::CubicResampler;
use crate::Route;
use crate::components::Button;
use crate::components::{
AppNavbar, DynamicArt, FileDropOverlay, Icon, IconType, OverlayPopup, ScrollArea,
ART_PREVIEW_EDGE, AppNavbar, DynamicArt, FileDropOverlay, Icon, IconType, OverlayPopup,
ScrollArea,
};
use crate::layout::AnimatedAppOutlet;
use crate::theme;
use crate::use_settings_snapshot;
use oneclient_core::clusters::Cluster;
use oneclient_db::models::ClusterId;
use crate::hooks::{
@ -259,6 +261,66 @@ pub(crate) fn appshell_overlay() -> Rect {
pub const HOME_BACKGROUND_ASSET: &str = "backgrounds/CavesAndCliffs.jpg";
/// The cluster the home background is drawn from: the active one, or the first
/// in the list before anything is active.
fn home_cluster(clusters: &[Cluster], active: Option<ClusterId>) -> Option<&Cluster> {
active
.and_then(|id| clusters.iter().find(|c| c.id == id))
.or_else(|| clusters.first())
}
fn home_art(cluster: Option<&Cluster>) -> DynamicArt {
cluster
.map_or_else(DynamicArt::fallback, DynamicArt::for_cluster)
.preview_edge(ART_PREVIEW_EDGE)
}
/// Loads the home background art as soon as the launcher is up, rather than
/// when Home mounts.
///
/// The art is three async hops deep — the cluster list, then the version
/// manifest holding its url, then the image itself — and none of them start
/// until something subscribes. Mounted at the root, they overlap the rest of
/// startup while the splash curtain is still up; left to Home, they run after
/// it has lifted and show as the built-in fallback lingering on screen.
///
/// The queries are global and outlive this component's subscription, so Home
/// finds them already settled and renders the real art on its first frame.
#[derive(PartialEq, Clone, Copy)]
pub struct HomeArtPrefetch;
impl Component for HomeArtPrefetch {
fn render(&self) -> impl IntoElement {
// Before the launcher is ready every query errors out, and a settled
// error is not retried until a new subscriber mounts. Waiting means the
// inner component's hooks run once, when they can actually succeed.
if !use_launcher().ready {
return rect().into_element();
}
HomeArtWarm.into_element()
}
}
#[derive(PartialEq, Clone, Copy)]
struct HomeArtWarm;
impl Component for HomeArtWarm {
fn render(&self) -> impl IntoElement {
let clusters_query = use_clusters();
let art = {
let reader = clusters_query.read();
let state = reader.state();
// No active cluster this early, so this is the first in the list —
// the same one `AppHomeBackground` picks.
let clusters = state.ok().map_or(&[][..], Vec::as_slice);
home_art(home_cluster(clusters, None))
};
let _ = art.use_bytes();
rect().into_element()
}
}
#[derive(PartialEq, Clone, Copy)]
pub struct AppHomeBackground;
@ -290,12 +352,9 @@ impl Component for AppHomeBackground {
let reader = clusters_query.read();
let state = reader.state();
let clusters = state.ok().map_or(&[][..], Vec::as_slice);
let chosen = (*active_id.read())
.and_then(|id| clusters.iter().find(|c| c.id == id))
.or_else(|| clusters.first());
let chosen = home_cluster(clusters, *active_id.read());
let dep = chosen.map(|c| c.id);
let art = chosen.map_or_else(DynamicArt::fallback, DynamicArt::for_cluster);
(art, dep)
(home_art(chosen), dep)
};
let (_, art_bytes) = art.use_bytes();

View file

@ -7,7 +7,7 @@ mod settings_shell;
pub use animated_outlet::AnimatedAppOutlet;
pub(crate) use app_shell::gradient_overlay_radial;
pub use app_shell::{AppShell, HOME_BACKGROUND_ASSET};
pub use app_shell::{AppShell, HOME_BACKGROUND_ASSET, HomeArtPrefetch};
pub use cluster_shell::{ClusterShell, cluster_content};
pub use onboarding_shell::OnboardingShell;
pub use root_layout::RootLayout;

View file

@ -6,6 +6,7 @@ use crate::components::{
StatusBar, Toasts, UpdatePromptOverlay,
};
use crate::hooks::{SplashState, use_provide_splash};
use crate::layout::HomeArtPrefetch;
use crate::routes::Route;
use crate::theme;
use crate::theme::colors;
@ -73,5 +74,6 @@ impl Component for RootLayout {
.child(ClusterUpdatePopup)
.child(StatusBar)
.child(SplashCurtain)
.child(HomeArtPrefetch)
}
}

View file

@ -78,8 +78,8 @@ pub enum Route {
ClusterSettings { cluster_id: i64 },
#[end_layout]
#[route("/app/browser/:cluster_id/:package_type")]
Browser { cluster_id: i64, package_type: String },
#[route("/app/browser/:cluster_id/:package_type?:pick_cluster")]
Browser { cluster_id: i64, package_type: String, pick_cluster: bool },
#[route("/app/browser/:cluster_id/:package_type/package/:package_id")]
BrowserPackage { cluster_id: i64, package_type: String, package_id: String },
#[route("/app/accounts")]

View file

@ -9,10 +9,11 @@
//! station only requires `'static`, this owns [`NotificationState`] outright
//! rather than cloning a snapshot out of it on every event.
use std::collections::HashMap;
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use freya::radio::RadioChannel;
use oneclient_common::domain::ProviderId;
use oneclient_core::settings::LauncherSettings;
use oneclient_events::LaunchStage;
@ -34,6 +35,8 @@ pub enum AppChannel {
AccountSwitcher,
/// Live progress of an in-flight Microsoft sign-in.
MicrosoftLogin,
/// Packages currently being installed into a cluster.
Installs,
}
impl RadioChannel<AppState> for AppChannel {}
@ -57,6 +60,31 @@ pub struct AppState {
pub game: GameState,
pub account_switcher_open: bool,
pub microsoft_login: Option<LoginProgress>,
pub installs: InstallState,
}
/// Package installs that are in flight, so the button that started one can stay
/// disabled until it lands — and come back if it failed.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct InstallState {
pending: HashSet<(i64, ProviderId, String)>,
}
impl InstallState {
pub fn begin(&mut self, cluster_id: i64, provider: ProviderId, project_id: String) {
self.pending.insert((cluster_id, provider, project_id));
}
pub fn finish(&mut self, cluster_id: i64, provider: ProviderId, project_id: &str) {
self.pending
.remove(&(cluster_id, provider, project_id.to_string()));
}
#[must_use]
pub fn is_installing(&self, cluster_id: i64, provider: ProviderId, project_id: &str) -> bool {
self.pending
.contains(&(cluster_id, provider, project_id.to_string()))
}
}
#[derive(Clone, Debug, Default)]

View file

@ -1,5 +1,7 @@
use super::*;
use std::collections::HashMap;
use freya::router::RouterContext;
use oneclient_content::packages::ProviderId;
use oneclient_content::packages::types::ProjectSummary;
@ -9,13 +11,26 @@ use crate::routes::Route;
use crate::theme::colors;
use crate::ui::border_all_color;
type InstalledMap = HashMap<(ProviderId, String), Installed>;
fn installed_for(installed: &InstalledMap, item: &ProjectSummary) -> Option<InstallSource> {
installed
.get(&(item.provider, item.id.clone()))
.map(|installed| installed.source)
}
pub(super) fn grid_row(
row: Vec<ProjectSummary>,
cluster_id: i64,
package_type: &str,
installed: &InstalledMap,
) -> impl IntoElement {
let package_type = package_type.to_string();
let fill = GRID_COLUMNS - row.len();
let installed: Vec<Option<InstallSource>> = row
.iter()
.map(|item| installed_for(installed, item))
.collect();
rect()
.horizontal()
@ -23,9 +38,14 @@ pub(super) fn grid_row(
.height(Size::px(CARD_H))
.spacing(GRID_SPACING)
.content(Content::Flex)
.children(row.into_iter().map(move |item| {
PackageCard::new(item, cluster_id, package_type.clone()).into_element()
}))
.children(
row.into_iter()
.zip(installed)
.map(move |(item, installed)| {
PackageCard::new(item, cluster_id, package_type.clone(), installed)
.into_element()
}),
)
.maybe(fill > 0, move |mut el| {
for _ in 0..fill {
el = el.child(rect().width(Size::flex(1.0)));
@ -48,14 +68,21 @@ struct PackageCard {
item: ProjectSummary,
cluster_id: i64,
package_type: String,
installed: Option<InstallSource>,
}
impl PackageCard {
fn new(item: ProjectSummary, cluster_id: i64, package_type: String) -> Self {
fn new(
item: ProjectSummary,
cluster_id: i64,
package_type: String,
installed: Option<InstallSource>,
) -> Self {
Self {
item,
cluster_id,
package_type,
installed,
}
}
}
@ -151,7 +178,22 @@ impl Component for PackageCard {
.color(colors::fg_secondary()),
),
)
.child(downloads_row(self.item.downloads)),
.child(
rect()
.horizontal()
.width(Size::fill())
.cross_align(Alignment::Center)
.content(Content::Flex)
.child(downloads_row(self.item.downloads))
.maybe_child(self.installed.map(|installed| {
rect()
.horizontal()
.width(Size::flex(1.0))
.main_align(Alignment::End)
.child(installed_badge(installed, 10.))
.into_element()
})),
),
)
}
}
@ -160,8 +202,10 @@ pub(super) fn list_row(
item: ProjectSummary,
cluster_id: i64,
package_type: &str,
installed: &InstalledMap,
) -> impl IntoElement {
ListRow {
installed: installed_for(installed, &item),
item,
cluster_id,
package_type: package_type.to_string(),
@ -173,6 +217,7 @@ struct ListRow {
item: ProjectSummary,
cluster_id: i64,
package_type: String,
installed: Option<InstallSource>,
}
impl Component for ListRow {
@ -253,6 +298,10 @@ impl Component for ListRow {
.color(colors::fg_secondary()),
),
)
.maybe_child(
self.installed
.map(|installed| installed_badge(installed, 11.).into_element()),
)
.child(downloads_row(item.downloads))
}
}

View file

@ -4,24 +4,31 @@ use freya::animation::{
AnimNum, Ease, Function, OnChange, OnCreation, use_animation_with_dependencies,
};
use freya::prelude::*;
use freya::router::RouterContext;
use oneclient_common::parse_mc_version;
use oneclient_content::packages::types::ProjectSummary;
use oneclient_content::packages::types::SearchSort;
use oneclient_content::packages::{ContentType, ProviderId};
use oneclient_core::VersionMetadata;
use oneclient_core::clusters::Cluster;
use oneclient_core::settings::ViewLayout;
use crate::components::{
Icon, IconType, Pagination, ScrollArea, Segment, SegmentedControl, TextInput,
Dropdown, Icon, IconType, Pagination, ScrollArea, Segment, SegmentedControl, TextInput,
};
use crate::hooks::{
BROWSE_PAGE_SIZE, BrowserUiState, category_list, content_type_for_slug, search_items,
search_pending, search_total, use_browser_compat, use_browser_state_store, use_debounced,
use_package_categories, use_package_search, use_view_state,
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_package_categories, use_package_search, use_versions, use_view_state, versions_metadata,
};
use crate::routes::Route;
use crate::theme::colors;
use crate::hooks::use_cluster;
use super::{PackageBanner, Thumbnail};
use crate::utils::abbreviate_number;
use super::{InstallSource, Installed, PackageBanner, Thumbnail, installed_badge, installed_map};
use crate::utils::{abbreviate_number, sort_clusters_for_home};
mod cards;
mod sidebar;
@ -57,6 +64,9 @@ fn encode_package_id(provider: ProviderId, id: &str) -> String {
pub struct Browser {
pub cluster_id: i64,
pub package_type: String,
/// Entered from the navbar rather than from a cluster's content page, so
/// the cluster it browses for is picked here instead of being decided.
pub pick_cluster: bool,
}
impl Component for Browser {
@ -140,11 +150,29 @@ impl Component for Browser {
let categories_query = use_package_categories(provider_id, content_type);
let all_categories = category_list(&categories_query);
let installed = installed_map(
cluster_content_items(&use_cluster_content(cluster_id, content_type)),
&bundles_with_status_items(&use_bundles_with_status(cluster_id)),
);
let packages = search_items(&search);
let total = search_total(&search);
let pending = search_pending(&search);
let current_page = *page.read();
let total_pages = total.div_ceil(BROWSE_PAGE_SIZE).max(1);
// A page switch re-runs the search, which reports no total until it
// settles. Holding the last count keeps the pager in place — disabled
// while the page loads — instead of dropping out and back in.
let live_pages = (total > 0).then(|| total.div_ceil(BROWSE_PAGE_SIZE).max(1));
let mut settled_pages = use_state(|| live_pages);
if !pending {
settled_pages.set_if_modified(live_pages);
}
let pages = if pending {
*settled_pages.read()
} else {
live_pages
};
let mode = *view_mode.read();
@ -171,11 +199,11 @@ impl Component for Browser {
packages.chunks(GRID_COLUMNS).map(|c| c.to_vec()).collect();
sa.lazy(rows.len(), CARD_H, GRID_SPACING, move |i| {
grid_row(rows[i].clone(), cluster_id, &pkg).into_element()
grid_row(rows[i].clone(), cluster_id, &pkg, &installed).into_element()
})
}
ViewLayout::List => sa.lazy(packages.len(), LIST_ROW_H, LIST_SPACING, move |i| {
list_row(packages[i].clone(), cluster_id, &pkg).into_element()
list_row(packages[i].clone(), cluster_id, &pkg, &installed).into_element()
}),
}
} else if pending {
@ -205,6 +233,10 @@ impl Component for Browser {
.child(page_title(
&package_type,
cluster.as_ref().map(|c| c.name.clone()),
self.pick_cluster.then(|| ClusterPicker {
cluster_id,
package_type: package_type.clone(),
}),
))
.child(controls(provider, query, view_mode))
.child(
@ -233,13 +265,21 @@ impl Component for Browser {
.child(results)
.opacity(fade_opacity),
)
.maybe(total > 0, |el| el.child(Pagination::new(page, total_pages))),
.maybe_child(pages.map(|pages| {
Pagination::new(page, pages)
.enabled(!pending)
.into_element()
})),
),
)
}
}
fn page_title(package_type: &str, cluster_name: Option<String>) -> impl IntoElement {
fn page_title(
package_type: &str,
cluster_name: Option<String>,
picker: Option<ClusterPicker>,
) -> impl IntoElement {
rect()
.vertical()
.spacing(2.)
@ -250,12 +290,89 @@ fn page_title(package_type: &str, cluster_name: Option<String>) -> impl IntoElem
.font_weight(FontWeight::BOLD)
.color(colors::fg_primary()),
)
.maybe_child(cluster_name.map(|name| {
label()
.text(format!("for {name}"))
.font_size(14.)
.color(colors::fg_secondary())
}))
.maybe_child(match picker {
Some(picker) => Some(picker.into_element()),
// Without the picker the cluster is fixed, so it's just a subtitle.
None => cluster_name.map(|name| {
label()
.text(format!("for {name}"))
.font_size(14.)
.color(colors::fg_secondary())
.into_element()
}),
})
}
/// The version's display name from the manifest ("Tricky Trials"), falling back
/// to the bare version while the manifest hasn't arrived or doesn't cover it.
fn version_name(metadata: &[VersionMetadata], cluster: &Cluster) -> String {
parse_mc_version(&cluster.mc_version)
.and_then(|parsed| {
pick_version_metadata(
metadata,
parsed.major,
parsed.key(),
Some(cluster.mc_loader),
)
})
.map(|m| m.name)
.unwrap_or_else(|| cluster.mc_version.clone())
}
/// Switches which cluster the browser installs into. The choice lives in the
/// route, so going back and forward keeps the cluster that was being browsed.
#[derive(PartialEq)]
struct ClusterPicker {
cluster_id: i64,
package_type: String,
}
impl Component for ClusterPicker {
fn render(&self) -> impl IntoElement {
let clusters =
sort_clusters_for_home(settled_or_loading(&use_clusters()).unwrap_or_default());
let metadata = versions_metadata(&use_versions()).unwrap_or_default();
let labels: Vec<String> = clusters
.iter()
.map(|c| format!("{} · {}", c.name, version_name(&metadata, c)))
.collect();
let ids: Vec<i64> = clusters.iter().map(|c| c.id).collect();
let selected = ids
.iter()
.position(|id| *id == self.cluster_id)
.and_then(|idx| labels.get(idx).cloned())
.unwrap_or_else(|| "Select a version".to_string());
let package_type = self.package_type.clone();
rect()
.horizontal()
.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.))
.height(Size::px(28.))
.on_select(move |idx: usize| {
if let Some(cluster_id) = ids.get(idx).copied() {
let _ = RouterContext::get().replace(Route::Browser {
cluster_id,
package_type: package_type.clone(),
pick_cluster: true,
});
}
}),
)
}
}
fn controls(

View file

@ -4,7 +4,11 @@ mod package;
pub use index::Browser;
pub use package::BrowserPackage;
use std::collections::HashMap;
use freya::prelude::*;
use oneclient_content::packages::ProviderId;
use oneclient_core::{BundleFileKind, BundleWithUpdateStatus, LinkedArtifactInfo};
use crate::components::{Icon, IconType};
use crate::hooks::{loaded_image, use_cached_image};
@ -12,6 +16,118 @@ use crate::theme::colors;
const BANNER_BG: Color = Color::from_rgb(21, 28, 34);
/// How a browsed package is already present in the cluster.
#[derive(Clone, Copy, PartialEq)]
pub(crate) enum InstallSource {
/// Installed on its own.
Manual,
/// Comes from one of the cluster's bundles.
Bundled,
}
impl InstallSource {
fn label(self) -> &'static str {
match self {
Self::Manual => "Installed",
Self::Bundled => "Bundled",
}
}
/// Bundled reads blue rather than green: the package is there, but the
/// cluster's bundle put it there, not the user.
fn color(self) -> Color {
match self {
Self::Manual => colors::success(),
Self::Bundled => colors::code_info(),
}
}
}
/// A browsed package the cluster already has.
#[derive(Clone, PartialEq)]
pub(crate) struct Installed {
pub source: InstallSource,
/// Which version is in there, when the provider recorded one. Used to mark
/// the matching row in a package's version list, never shown as text.
pub version_id: Option<String>,
}
impl Installed {
pub fn is_version(&self, version_id: &str) -> bool {
self.version_id.as_deref() == Some(version_id)
}
}
/// Indexes what the cluster already has by the project it came from, so a
/// search result can tell at a glance whether it's in there and how it got
/// there. Local files have no project to match a search result against and
/// are left out, as are a bundle's external (non-provider) files.
pub(crate) fn installed_map(
content: Vec<LinkedArtifactInfo>,
bundles: &[BundleWithUpdateStatus],
) -> HashMap<(ProviderId, String), Installed> {
let mut map: HashMap<(ProviderId, String), Installed> = content
.into_iter()
.filter_map(|item| {
Some((
(item.provider?, item.project_id?),
Installed {
source: InstallSource::Manual,
version_id: item.version_id,
},
))
})
.collect();
// Bundle membership wins: a bundle's files land in the cluster as ordinary
// content, so they'd otherwise read as hand-installed. The version on disk
// is the truth when there is one; the manifest's pin is the fallback.
for bundle in bundles {
for (file, _status) in &bundle.files {
if let BundleFileKind::Managed {
provider,
project_id,
version_id,
..
} = &file.kind
{
map.entry((*provider, project_id.clone()))
.and_modify(|installed| installed.source = InstallSource::Bundled)
.or_insert_with(|| Installed {
source: InstallSource::Bundled,
version_id: Some(version_id.clone()),
});
}
}
}
map
}
pub(crate) fn installed_badge(installed: InstallSource, font_size: f32) -> impl IntoElement {
let color = installed.color();
rect()
.horizontal()
.cross_align(Alignment::Center)
.spacing(4.)
.padding(Gaps::new_symmetric(2., 8.))
.corner_radius(CornerRadius::new_all(999.))
.background(color.with_a(38))
.child(
Icon::new(IconType::CheckCircle)
.size(font_size)
.color(color),
)
.child(
label()
.text(installed.label())
.font_size(font_size)
.max_lines(1)
.color(color),
)
}
#[derive(PartialEq)]
pub(crate) struct Thumbnail {
icon_url: Option<String>,

View file

@ -3,14 +3,16 @@ use oneclient_content::packages::{ContentType, ProviderId};
use crate::components::ScrollArea;
use crate::hooks::{
content_type_for_slug, project_detail, use_browser_compat, use_dispatch, use_link_confirm,
use_package_project, use_package_versions, version_list, versions_total,
bundles_with_status_items, cluster_content_items, content_type_for_slug, project_detail,
use_browser_compat, use_bundles_with_status, use_cluster_content, use_dispatch,
use_installs_snapshot, use_link_confirm, use_package_project, use_package_versions,
version_list, versions_total,
};
use crate::theme::colors;
use crate::ui::border_all_color;
use crate::hooks::use_cluster;
use super::{PackageBanner, Thumbnail};
use super::{InstallSource, Installed, PackageBanner, Thumbnail, installed_badge, installed_map};
use crate::utils::abbreviate_number;
mod panels;
@ -136,6 +138,14 @@ impl Component for BrowserPackage {
*versions_page.read(),
);
let installing = use_installs_snapshot().is_installing(cluster_id, provider, &project_id);
let installed = installed_map(
cluster_content_items(&use_cluster_content(cluster_id, content_type)),
&bundles_with_status_items(&use_bundles_with_status(cluster_id)),
)
.remove(&(provider, project_id.clone()));
let project = project_detail(&project_query);
let versions = version_list(&versions_query);
let total_versions = versions_total(&versions_query);
@ -160,6 +170,8 @@ impl Component for BrowserPackage {
project.id.clone(),
cluster_id,
dispatch.clone(),
installed.clone(),
installing,
)
.into_element(),
(Some(_), _) => gallery_panel(gallery).into_element(),
@ -178,6 +190,8 @@ impl Component for BrowserPackage {
cluster_id,
dispatch,
confirm,
installed,
installing,
))
.child(
rect()

View file

@ -157,6 +157,7 @@ impl Component for GalleryTile {
}
}
#[allow(clippy::too_many_arguments)]
pub(super) fn versions_panel(
versions: Vec<VersionSummary>,
total_versions: usize,
@ -165,6 +166,8 @@ pub(super) fn versions_panel(
project_id: String,
cluster_id: i64,
dispatch: Actions,
installed: Option<Installed>,
installing: bool,
) -> impl IntoElement {
let current = *versions_page.read();
let total_pages = total_versions.div_ceil(VERSIONS_PAGE_SIZE).max(1);
@ -187,12 +190,18 @@ pub(super) fn versions_panel(
.width(Size::fill())
.spacing(8.)
.children(versions.into_iter().map(move |v| {
let tag = installed
.as_ref()
.filter(|installed| installed.is_version(&v.version_id))
.map(|installed| installed.source);
version_row(
v,
provider,
project_id.clone(),
cluster_id,
dispatch.clone(),
tag,
installing,
)
.into_element()
}))
@ -258,12 +267,15 @@ fn version_pager(current: usize, total_pages: usize, page: State<usize>) -> impl
.into_element()
}
#[allow(clippy::too_many_arguments)]
fn version_row(
v: VersionSummary,
provider: ProviderId,
project_id: String,
cluster_id: i64,
dispatch: Actions,
installed: Option<InstallSource>,
installing: bool,
) -> impl IntoElement {
let version_id = v.version_id.clone();
let mut chips: Vec<String> = v.loaders.iter().map(|l| l.to_string()).collect();
@ -311,10 +323,12 @@ fn version_row(
.color(colors::fg_secondary()),
),
)
.maybe_child(installed.map(|installed| installed_badge(installed, 11.).into_element()))
.child(
Button::new()
.secondary()
.small()
.enabled(installed.is_none() && !installing)
.on_press(move |_| {
dispatch.install_package(
cluster_id,

View file

@ -8,6 +8,7 @@ use crate::components::{Button, Icon, IconType};
use crate::theme::colors;
use crate::ui::border_all_color;
#[allow(clippy::too_many_arguments)]
pub(super) fn sidebar(
project: Option<ProjectDetail>,
latest_version: Option<String>,
@ -15,6 +16,8 @@ pub(super) fn sidebar(
cluster_id: i64,
dispatch: Actions,
confirm: State<Option<String>>,
installed: Option<Installed>,
installing: bool,
) -> impl IntoElement {
let Some(project) = project else {
return rect()
@ -24,7 +27,13 @@ pub(super) fn sidebar(
};
let project_id = project.id.clone();
let can_install = latest_version.is_some();
// Nothing to do when the cluster already has exactly this version, and
// nothing to do twice while the install that was just started is running.
let have_latest = match (&installed, &latest_version) {
(Some(installed), Some(latest)) => installed.is_version(latest),
_ => false,
};
let can_install = latest_version.is_some() && !have_latest && !installing;
rect()
.vertical()
@ -54,22 +63,12 @@ pub(super) fn sidebar(
.max_lines(2)
.color(colors::fg_primary()),
)
.child(
rect()
.horizontal()
.cross_align(Alignment::Center)
.spacing(4.)
.child(Icon::new(project.provider).size(12.))
.child(
label()
.text(format!(
"{} on {}",
project.content_type, project.provider
))
.font_size(11.)
.color(colors::fg_secondary()),
),
)
.child(ProviderTag {
text: format!("{} on {}", project.content_type, project.provider),
provider: project.provider,
url: provider_project_url(&project),
confirm,
})
.maybe(!project.summary.is_empty(), |el| {
el.child(
label()
@ -101,6 +100,11 @@ pub(super) fn sidebar(
),
),
)
.maybe_child(
installed
.as_ref()
.map(|installed| installed_badge(installed.source, 12.).into_element()),
)
.child(
Button::new()
.primary()
@ -117,7 +121,13 @@ pub(super) fn sidebar(
}
})
.child(Icon::new(IconType::Download01).size(14.))
.text("Install latest"),
.text(if installing {
"Installing..."
} else if have_latest {
"Latest installed"
} else {
"Install latest"
}),
)
.maybe(
!project.members.is_empty() || !project.author.is_empty(),
@ -130,11 +140,87 @@ pub(super) fn sidebar(
.into_element()
}
/// The package's own page on the provider's site. Modrinth doesn't hand one
/// out, so it's rebuilt from the slug; CurseForge already lists it as "Website".
fn provider_project_url(project: &ProjectDetail) -> Option<String> {
match project.provider {
ProviderId::Modrinth => Some(format!(
"{}{}/{}",
ProviderId::Modrinth.website(),
project.content_type.modrinth_type(),
project.slug
)),
ProviderId::CurseForge => project
.links
.iter()
.find(|(label, _)| label == "Website")
.map(|(_, url)| url.clone()),
ProviderId::Local => None,
}
}
/// "Mod on Modrinth", linking out to the package's page there when the provider
/// has one. Plain text otherwise, so a local package doesn't look clickable.
#[derive(PartialEq)]
struct ProviderTag {
text: String,
provider: ProviderId,
url: Option<String>,
confirm: State<Option<String>>,
}
impl Component for ProviderTag {
fn render(&self) -> impl IntoElement {
let a11y_id = use_a11y();
let focus = use_focus(a11y_id);
let focused = focus().is_focused();
let url = self.url.clone();
let interactive = url.is_some();
let mut confirm = self.confirm;
rect()
.horizontal()
.cross_align(Alignment::Center)
.spacing(4.)
.child(Icon::new(self.provider).size(12.))
.child(
label()
.text(self.text.clone())
.font_size(11.)
.color(if interactive {
colors::code_info()
} else {
colors::fg_secondary()
}),
)
.maybe(interactive, |el| {
let url = url.clone().unwrap_or_default();
el.a11y_id(a11y_id)
.a11y_focusable(true)
.a11y_role(AccessibilityRole::Link)
.corner_radius(CornerRadius::new_all(6.))
.maybe(focused, |el| {
el.border(border_all_color(1., colors::brand()))
})
.on_pointer_enter(|_| Cursor::set(CursorIcon::Pointer))
.on_pointer_leave(|_| Cursor::set(CursorIcon::default()))
.on_all_press(move |_| confirm.set(Some(url.clone())))
})
}
}
fn card(title: &str, rows: Vec<Element>) -> impl IntoElement {
card_spaced(title, rows, 10.)
}
/// `card` with the gap between rows chosen by the caller: a list of one-line
/// links reads better tight than the multi-line author and detail rows do.
fn card_spaced(title: &str, rows: Vec<Element>, spacing: f32) -> impl IntoElement {
rect()
.vertical()
.width(Size::fill())
.spacing(10.)
.spacing(spacing)
.padding(Gaps::new_all(16.))
.corner_radius(CornerRadius::new_all(12.))
.background(PANEL_BG)
@ -310,7 +396,7 @@ fn links_card(links: &[(String, String)], confirm: State<Option<String>>) -> imp
.iter()
.map(|(lbl, url)| link_row(lbl.clone(), url.clone(), confirm))
.collect();
card("Links", rows)
card_spaced("Links", rows, 2.)
}
fn link_row(label_text: String, url: String, confirm: State<Option<String>>) -> Element {
@ -344,10 +430,10 @@ impl Component for LinkRow {
.cross_align(Alignment::Center)
.spacing(8.)
.corner_radius(CornerRadius::new_all(6.))
.padding(Gaps::new_all(4.))
.padding(Gaps::new_symmetric(2., 4.))
.a11y_id(a11y_id)
.a11y_focusable(true)
.a11y_role(AccessibilityRole::Button)
.a11y_role(AccessibilityRole::Link)
.maybe(focused, |el| {
el.border(border_all_color(1., colors::brand()))
})

View file

@ -79,6 +79,7 @@ impl Component for ClusterSettings {
}
.into_element(),
)
.child(text_row(cluster_id, TextField::JvmArgs, &profile, &global))
.child(section_header("GAME"))
.child(
ToggleRow {
@ -420,6 +421,7 @@ impl Component for ResolutionRow {
#[derive(Clone, Copy, PartialEq)]
enum TextField {
JvmArgs,
Pre,
Wrapper,
Post,
@ -428,6 +430,12 @@ enum TextField {
impl TextField {
fn meta(self) -> (IconType, &'static str, &'static str, &'static str) {
match self {
Self::JvmArgs => (
IconType::Terminal,
"JVM Arguments",
"Extra arguments passed to Java. Separate them with spaces; quote values containing spaces.",
"-XX:+UseG1GC",
),
Self::Pre => (
IconType::FilePlus02,
"Pre-Launch Command",
@ -451,6 +459,7 @@ impl TextField {
fn value(self, profile: &GameSettingsProfile) -> Option<String> {
match self {
Self::JvmArgs => profile.launch_args.clone(),
Self::Pre => profile.hook_pre.clone(),
Self::Wrapper => profile.hook_wrapper.clone(),
Self::Post => profile.hook_post.clone(),
@ -460,6 +469,7 @@ impl TextField {
fn patch(self, value: Patch<String>) -> ProfileUpdate {
let mut u = ProfileUpdate::default();
match self {
Self::JvmArgs => u.launch_args = value,
Self::Pre => u.hook_pre = value,
Self::Wrapper => u.hook_wrapper = value,
Self::Post => u.hook_post = value,

View file

@ -456,7 +456,7 @@ impl Component for LogPicker {
.position(Position::new_global().top(0.).left(0.))
.width(Size::window_percent(100.))
.height(Size::window_percent(100.))
.layer(Layer::RelativeOverlay(10))
.layer(Layer::OverlayLevel(10))
.on_press(move |_| open.set(false)),
)
.child(
@ -464,7 +464,7 @@ impl Component for LogPicker {
.margin(Gaps::new(4., 0., 0., 0.))
.width(Size::px(320.))
.max_height(Size::px(360.))
.layer(Layer::RelativeOverlay(12))
.layer(Layer::OverlayLevel(12))
.vertical()
.spacing(4.)
.padding(6.)

View file

@ -6,7 +6,7 @@ use oneclient_core::{BundleFileKind, BundleWithUpdateStatus, LinkedArtifactInfo}
use oneclient_db::models::OverrideType;
use crate::components::{CardLayout, PackageEntry};
use crate::hooks::{package_meta_batch, use_package_meta_batch, use_view_state};
use crate::hooks::{package_meta_batch, use_game_snapshot, use_package_meta_batch, use_view_state};
mod views;
use views::{ContentBox, ContentKind, EnabledFilter, SortMode, toolbar_bar};
@ -324,6 +324,10 @@ impl Component for PackageManager {
let content_type = self.content_type;
let tabs = build_tabs(&self.categories, &items);
// Minecraft reads its content once at startup, so a toggle made now is
// recorded but cannot reach the session already playing. Say so rather
// than letting the switch look like it did nothing.
let session_live = use_game_snapshot().is_active(cluster_id);
let active = use_state(|| 0usize);
let active_idx = (*active.read()).min(tabs.len().saturating_sub(1));
@ -377,6 +381,7 @@ impl Component for PackageManager {
enabled_filter,
layout,
))
.maybe_child(session_live.then(|| views::running_notice(noun_plural)))
.child(ContentBox::new(
filtered,
noun_plural,

View file

@ -175,6 +175,38 @@ pub(super) fn toolbar_bar(
.into_element()
}
/// Shown while this cluster is being played. Enabling or disabling still takes
/// effect — it is stored, and the folder is brought in line at the next launch
/// — but the session already running cannot pick it up.
pub(super) fn running_notice(noun_plural: &'static str) -> Element {
rect()
.horizontal()
.width(Size::fill())
.cross_align(Alignment::Center)
.content(Content::Flex)
.spacing(10.)
.margin(Gaps::new(8., 0., 0., 0.))
.padding(Gaps::new_symmetric(9., 12.))
.corner_radius(CornerRadius::new_all(10.))
.background(colors::brand().with_a(30))
.border(crate::ui::border_all_color(1., colors::brand().with_a(90)))
.child(
Icon::new(IconType::InfoCircle)
.size(16.)
.color(colors::brand()),
)
.child(
label()
.text(format!(
"Minecraft is running. Changes to your {noun_plural} are saved, and take effect the next time you launch this version."
))
.font_size(12.)
.width(Size::flex(1.0))
.color(colors::fg_secondary()),
)
.into_element()
}
#[derive(PartialEq)]
struct FilterButton {
sort: State<Option<String>>,
@ -313,7 +345,7 @@ impl Component for FilterPopover {
.layer(Layer::Overlay)
.child(
rect()
.layer(Layer::RelativeOverlay(10))
.layer(Layer::OverlayLevel(10))
.position(Position::new_global().top(0.).left(0.))
.width(Size::window_percent(100.))
.height(Size::window_percent(100.))
@ -322,7 +354,7 @@ impl Component for FilterPopover {
.child(
rect()
.width(Size::fill())
.layer(Layer::RelativeOverlay(12))
.layer(Layer::OverlayLevel(12))
.margin(Gaps::new(6., 0., 0., -(FILTER_PANEL_W - FILTER_BTN_W)))
.child(panel),
)
@ -437,6 +469,7 @@ fn browse_button(cluster_id: i64, package_type: &'static str) -> impl IntoElemen
let _ = RouterContext::get().push(Route::Browser {
cluster_id,
package_type: package_type.to_string(),
pick_cluster: false,
});
})
.child(Icon::new(IconType::SearchMd).size(14.))

View file

@ -3,8 +3,9 @@ use freya::router::RouterContext;
use oneclient_db::console::{ConsoleQueryResult, run_console_query};
use oneclient_auth::preview_samples;
use oneclient_net::status::{self, ServiceStatus};
use crate::components::{Button, Icon, IconType, TextInput, login_dialog, toggle};
use crate::components::{Button, Dropdown, Icon, IconType, TextInput, login_dialog, toggle};
use crate::hooks::use_dispatch;
use crate::notifications::{
ClusterUpdateItem, ClusterUpdateSummary, NotificationAction, NotificationActionKind,
@ -76,6 +77,11 @@ impl Component for Debug {
vec![LauncherUpdateSimulator.into_element()],
))
.child(divider())
.child(section(
"Connectivity Status Bar",
vec![StatusBarSimulator.into_element()],
))
.child(divider())
.child(section(
"Auth Error Guidance",
vec![AuthGuidancePreview.into_element()],
@ -468,55 +474,140 @@ impl Component for LauncherUpdateSimulator {
}
}
/// Pins `oneclient_net::status` so the connectivity banner can be looked at
/// without unplugging anything. Each preset is one banner the bar knows how to
/// draw; the bar itself picks the first active issue.
#[derive(PartialEq)]
struct StatusBarSimulator;
const STATUS_PRESETS: [(&str, IconType, ServiceStatus); 4] = [
(
"No internet",
IconType::Globe01,
ServiceStatus {
online: false,
mc_auth_up: false,
polyfrost_up: false,
},
),
(
"Minecraft auth down",
IconType::AlertTriangle,
ServiceStatus {
online: true,
mc_auth_up: false,
polyfrost_up: true,
},
),
(
"Polyfrost down",
IconType::AlertCircle,
ServiceStatus {
online: true,
mc_auth_up: true,
polyfrost_up: false,
},
),
(
"Both services down",
IconType::AlertTriangle,
ServiceStatus {
online: true,
mc_auth_up: false,
polyfrost_up: false,
},
),
];
impl Component for StatusBarSimulator {
fn render(&self) -> impl IntoElement {
let mut pinned = use_state(status::forced);
let mut row = rect().horizontal().width(Size::fill()).spacing(12.);
for (text, icon, forced) in STATUS_PRESETS {
row = row.child(
Button::new()
.secondary()
.child(Icon::new(icon).size(16.))
.text(text)
.on_press(move |_| {
status::force(Some(forced));
pinned.set(Some(forced));
}),
);
}
let current = *pinned.read();
let state_text = match current {
Some(_) => "Pinned. Probing still runs, but its result is not published.".to_string(),
None => "Not pinned; showing the real probe result.".to_string(),
};
rect()
.vertical()
.width(Size::fill())
.spacing(10.)
.child(
label()
.text("Forces what the connectivity prober reports, so the real status bar renders its banner. Dismissing a banner is remembered until the issue clears.")
.font_size(13.)
.color(colors::fg_secondary()),
)
.child(row)
.child(
rect()
.horizontal()
.width(Size::fill())
.cross_align(Alignment::Center)
.spacing(12.)
.child(
Button::new()
.primary()
.child(Icon::new(IconType::RefreshCw01).size(16.))
.text("Clear + recheck")
.enabled(current.is_some())
.on_press(move |_| {
status::force(None);
pinned.set(None);
}),
)
.child(
label()
.text(state_text)
.font_size(13.)
.color(colors::fg_secondary()),
),
)
.into_element()
}
}
#[derive(PartialEq)]
struct AuthGuidancePreview;
impl Component for AuthGuidancePreview {
fn render(&self) -> impl IntoElement {
let selected = use_state(|| None::<Option<usize>>);
let samples = preview_samples();
// Index 0 is the no-error state; the rest line up with `samples`, so a
// dropdown holds all ~16 cases in one row instead of six rows of
// full-width buttons.
let mut choice = use_state(|| 0usize);
let mut open = use_state(|| false);
let mut controls = rect().vertical().width(Size::fill()).spacing(10.).child(
label()
.text("Each button opens the real Microsoft sign-in popup exactly as users see it, in the chosen error state.")
.font_size(13.)
.color(colors::fg_secondary()),
);
let mut options: Vec<String> = vec!["No error (in progress)".to_string()];
options.extend(samples.iter().map(|s| s.label.to_string()));
let mut labels: Vec<(&'static str, Option<usize>)> = vec![("No error (in progress)", None)];
labels.extend(samples.iter().enumerate().map(|(i, s)| (s.label, Some(i))));
let index = (*choice.read()).min(options.len() - 1);
let selected_label = options[index].clone();
for chunk in labels.chunks(3) {
let mut row = rect().horizontal().width(Size::fill()).spacing(12.);
for (text, target) in chunk.iter().copied() {
let mut sel = selected;
let mut button = Button::new()
.secondary()
.text(text)
.on_press(move |_| sel.set(Some(target)));
if text.contains("No error") {
button = button.child(Icon::new(IconType::CheckCircle).size(16.));
} else {
button = button.child(Icon::new(IconType::AlertTriangle).size(16.));
}
row = row.child(rect().width(Size::flex(1.0)).child(button));
}
for _ in chunk.len()..3 {
row = row.child(rect().width(Size::flex(1.0)));
}
controls = controls.child(row);
}
let current = *selected.read();
let popup = current.map(|choice| {
let (error, guidance) = match choice {
let popup = open().then(|| {
let (error, guidance) = match index.checked_sub(1) {
None => (None, None),
Some(i) => {
let s = &samples[i];
(Some(s.message.clone()), s.guidance.clone())
}
};
let mut sel = selected;
login_dialog(
"https://login.live.com/oauth20_authorize (preview)".to_string(),
"ABCD-EFGH".to_string(),
@ -525,11 +616,42 @@ impl Component for AuthGuidancePreview {
None,
error,
guidance,
move || sel.set(None),
move || open.set(false),
)
});
controls.maybe_child(popup).into_element()
rect()
.vertical()
.width(Size::fill())
.spacing(10.)
.child(
label()
.text("Opens the real Microsoft sign-in popup exactly as users see it, in the chosen error state.")
.font_size(13.)
.color(colors::fg_secondary()),
)
.child(
rect()
.horizontal()
.width(Size::fill())
.cross_align(Alignment::Center)
.spacing(12.)
.child(
Dropdown::new(selected_label, options)
.width(Size::px(300.))
.height(Size::px(34.))
.on_select(move |idx: usize| choice.set(idx)),
)
.child(
Button::new()
.primary()
.child(Icon::new(IconType::Eye).size(16.))
.text("Open preview")
.on_press(move |_| open.set(true)),
),
)
.maybe_child(popup)
.into_element()
}
}

View file

@ -1,3 +1,6 @@
use freya::animation::{
AnimNum, Ease, Function, OnChange, OnCreation, use_animation_with_dependencies,
};
use freya::prelude::*;
use super::settings_page;
@ -71,6 +74,10 @@ impl Component for SettingsChangelog {
}
}
/// Chevron angle for a collapsed card: `ChevronDown` swung a quarter turn
/// anticlockwise points right.
const CHEVRON_CLOSED_DEG: f32 = -90.;
#[derive(PartialEq)]
struct ReleaseCard {
version: String,
@ -84,6 +91,24 @@ impl Component for ReleaseCard {
let mut open = use_state(|| self.initially_open);
let is_open = *open.read();
// One chevron that swings between pointing right (collapsed) and down
// (expanded), so the arrow tracks the section instead of cutting to a
// different glyph that did not follow the state.
let swing = use_animation_with_dependencies(&is_open, |conf, open| {
conf.on_creation(OnCreation::Run);
conf.on_change(OnChange::Rerun);
let (from, to) = if *open {
(CHEVRON_CLOSED_DEG, 0.)
} else {
(0., CHEVRON_CLOSED_DEG)
};
AnimNum::new(from, to)
.time(180)
.ease(Ease::Out)
.function(Function::Cubic)
});
let chevron_deg = swing.get().value();
let title = if self.current {
format!("{} (Currently Installed)", self.version)
} else {
@ -122,14 +147,7 @@ impl Component for ReleaseCard {
.font_weight(FontWeight::SEMI_BOLD)
.color(colors::fg_primary()),
)
.child(
Icon::new(if is_open {
IconType::ChevronDown
} else {
IconType::ChevronRight
})
.size(18.),
),
.child(Icon::new(IconType::ChevronDown).size(18.).rotate(chevron_deg)),
)
.maybe_child(is_open.then(|| {
rect()

View file

@ -38,6 +38,10 @@ impl Component for SettingsMinecraft {
let v = profile.mem_max.map(|m| m.to_string()).unwrap_or_default();
move || v
});
let jvm_args = use_state({
let v = profile.launch_args.clone().unwrap_or_default();
move || v
});
let pre_launch_command = use_state({
let v = profile.hook_pre.clone().unwrap_or_default();
move || v
@ -58,6 +62,7 @@ impl Component for SettingsMinecraft {
&width.read(),
&height.read(),
&memory.read(),
&jvm_args.read(),
&pre_launch_command.read(),
&wrapper_command.read(),
&post_exit_command.read(),
@ -89,6 +94,14 @@ impl Component for SettingsMinecraft {
"The amount of memory in megabytes allocated for the game.",
memory_field(memory),
))
.child(settings_row(
IconType::Terminal,
"JVM Arguments",
"Extra arguments passed to Java. Separate them with spaces; quote values containing spaces.",
TextInput::new(jvm_args)
.placeholder("-XX:+UseG1GC")
.width(Size::px(220.)),
))
.child(section_header("PROCESS"))
.child(settings_row(
IconType::FilePlus02,
@ -118,11 +131,13 @@ impl Component for SettingsMinecraft {
}
}
#[allow(clippy::too_many_arguments)]
fn build_update(
fullscreen: bool,
width: &str,
height: &str,
memory: &str,
jvm_args: &str,
pre: &str,
wrapper: &str,
post: &str,
@ -144,6 +159,7 @@ fn build_update(
force_fullscreen: Patch::Set(fullscreen),
resolution,
mem_max,
launch_args: command_patch(jvm_args),
hook_pre: command_patch(pre),
hook_wrapper: command_patch(wrapper),
hook_post: command_patch(post),

View file

@ -6,7 +6,7 @@ use std::time::Duration;
use oneclient_core::clusters::Cluster;
use crate::components::DynamicArt;
use crate::components::{ART_PREVIEW_EDGE, DynamicArt};
use crate::hooks::use_settings_snapshot;
use crate::layout::gradient_overlay_radial;
use crate::theme::colors;
@ -102,10 +102,15 @@ impl Component for LoadingBackdrop {
let pan_x = cx * sw * PARALLAX_STRENGTH;
let pan_y = cy * sh * PARALLAX_STRENGTH;
// Left at the default edge on purpose: this is the same full-screen art
// the home background shows next, so asking for the same variant means
// the onboarding download is the one home renders instead of it having
// to fetch the same picture again at a different size.
let art = match clusters.get(current) {
Some(cluster) => DynamicArt::for_cluster(cluster).max_edge(1280),
None => DynamicArt::fallback().max_edge(1280),
};
Some(cluster) => DynamicArt::for_cluster(cluster),
None => DynamicArt::fallback(),
}
.preview_edge(ART_PREVIEW_EDGE);
let fade = use_animation_with_dependencies(&current, |conf, _| {
conf.on_creation(OnCreation::Run);

View file

@ -2,7 +2,7 @@ use super::*;
use freya::router::RouterContext;
use crate::components::{Button, DynamicArt, Icon, IconType, ScrollArea};
use crate::components::{ART_PREVIEW_EDGE, Button, DynamicArt, Icon, IconType, ScrollArea};
use crate::hooks::ClusterBundles;
use crate::routes::Route;
use crate::theme::colors;
@ -189,7 +189,7 @@ fn prefetch_art(items: &[ClusterBundles]) -> impl IntoElement {
rect()
.width(Size::px(1.))
.height(Size::px(1.))
.child(DynamicArt::for_cluster(&cb.cluster).max_edge(512))
.child(DynamicArt::for_cluster(&cb.cluster).max_edge(ART_PREVIEW_EDGE))
.into_element()
}))
.into_element()

View file

@ -137,7 +137,8 @@ impl Component for Startup {
.vertical()
.spacing(14.)
.child(
svg(logo.read().cloned())
SvgViewer::new(("logo.svg", logo.read().cloned()))
.show_loader(false)
.width(Size::px(288.))
.height(Size::px(60.))
.color(colors::fg_primary()),

View file

@ -465,12 +465,16 @@ pub async fn on_user_remove_artifact(
/// storage half and knows nothing about bundles; this adds the bookkeeping that
/// only makes sense once bundles exist.
#[tracing::instrument(level = "debug", skip(ctx))]
///
/// `live` says the cluster is currently being played; see
/// [`PackageStore::set_artifact_enabled`].
pub async fn toggle_artifact_enabled(
cluster_id: i64,
hash: &str,
live: bool,
ctx: &ContentCtx,
) -> ContentResult<bool> {
let enabled = PackageStore::set_artifact_enabled(cluster_id, hash, ctx).await?;
let enabled = PackageStore::set_artifact_enabled(cluster_id, hash, live, ctx).await?;
if enabled {
on_user_enable_artifact(cluster_id, hash, ctx).await?;

View file

@ -295,6 +295,15 @@ async fn check_bundle_updates_inner(
)
});
let addition_eligible_bundles = addition_eligible_bundles(
ctx,
&bundle_packages,
&all_linked,
&candidate_keys_by_bundle,
overrides,
)
.await?;
let mut additions_available = Vec::new();
let mut planned_addition_keys = all_installed_managed_keys.clone();
for hash in all_installed_external_hashes {
@ -302,7 +311,7 @@ async fn check_bundle_updates_inner(
}
for archive in &archives {
if !subscribed_bundles.contains(&archive.manifest.name) {
if !addition_eligible_bundles.contains(&archive.manifest.name) {
continue;
}
@ -547,7 +556,7 @@ async fn reconcile_update(
) -> ContentResult<()> {
let file_id = update.new_file.kind.package_id();
if should_be_disabled(&update.bundle_name, &file_id, overrides) {
toggle_artifact_enabled(update.cluster_id, hash, ctx).await?;
toggle_artifact_enabled(update.cluster_id, hash, false, ctx).await?;
}
if hash == update.installed_hash {
@ -566,7 +575,7 @@ async fn reconcile_addition(
) -> ContentResult<()> {
let file_id = addition.new_file.kind.package_id();
if should_be_disabled(&addition.bundle_name, &file_id, overrides) {
toggle_artifact_enabled(addition.cluster_id, hash, ctx).await?;
toggle_artifact_enabled(addition.cluster_id, hash, false, ctx).await?;
}
Ok(())
@ -771,10 +780,55 @@ fn bundle_package_key(
}
}
/// Bundles that may still take on files the catalog has newly added.
///
/// Subscription is deliberately looser than this: a bundle whose mods are all
/// installed-but-disabled still wants version updates and still owns its files.
/// New content is different. Removing or disabling everything a bundle ships is
/// how a user opts out of that bundle, and an absent per-file override is not
/// consent for a file that did not exist when they opted out — so gating
/// additions on subscription alone let one catalog addition quietly reinstate a
/// bundle the user had emptied.
///
/// A bundle qualifies while any of it is still live here: a tracked artifact
/// that is enabled, an enabled artifact that can only have come from this bundle
/// (the untracked/migrated case), or an explicit opt-in override.
#[tracing::instrument(level = "debug", skip_all)]
async fn installed_bundle_keys(
async fn addition_eligible_bundles(
ctx: &ContentCtx,
linked: &[LinkedArtifactInfo],
bundle_packages: &[BundleTrackedArtifactRow],
all_linked: &[LinkedArtifactInfo],
candidate_keys_by_bundle: &HashMap<String, HashSet<String>>,
overrides: &[ClusterBundleOverrideRow],
) -> ContentResult<HashSet<String>> {
let (live_managed_keys, _) =
installed_bundle_keys(ctx, all_linked.iter().filter(|item| item.enabled)).await?;
let mut eligible: HashSet<String> = bundle_packages
.iter()
.filter(|bp| bp.enabled != 0)
.filter_map(|bp| bp.bundle_name.clone())
.collect();
eligible.extend(infer_bundle_names_from_unique_installed_keys(
candidate_keys_by_bundle,
&live_managed_keys,
));
eligible.extend(
overrides
.iter()
.filter(|o| OverrideType::parse(&o.override_type) == Some(OverrideType::Enabled))
.map(|o| o.bundle_name.clone()),
);
Ok(eligible)
}
#[tracing::instrument(level = "debug", skip_all)]
async fn installed_bundle_keys<'a>(
ctx: &ContentCtx,
linked: impl IntoIterator<Item = &'a LinkedArtifactInfo>,
) -> ContentResult<(HashSet<String>, HashSet<String>)> {
let mut managed = HashSet::new();
let mut external = HashSet::new();

View file

@ -62,19 +62,53 @@ pub async fn link_or_copy(src: &Path, dest: &Path) -> ContentResult<()> {
Ok(())
}
/// Whether the cluster folder is currently holding `file_name`, following the
/// same symlink-tolerant rule as [`unlink_cluster_file`].
pub async fn cluster_file_present(
cluster: &ClusterRow,
content_type: ContentType,
file_name: &str,
) -> bool {
let Ok(clusters) = paths::clusters_dir() else {
return false;
};
let path = clusters
.join(&cluster.folder_name)
.join(content_type.folder_name())
.join(file_name);
polyio::symlink_metadata(&path).await.is_ok()
}
#[tracing::instrument(level = "debug", skip(cluster))]
pub async fn unlink_cluster_file(
cluster: &ClusterRow,
content_type: ContentType,
file_name: &str,
) -> ContentResult<()> {
let path = paths::clusters_dir()?
let dir = paths::clusters_dir()?
.join(&cluster.folder_name)
.join(content_type.folder_name())
.join(file_name);
.join(content_type.folder_name());
if path.exists() {
polyio::remove_file(&path).await?;
}
// Both spellings: the database always stores the bare name, but the folder
// can still be holding a `<name>.disabled` left by an older launcher or by
// the user renaming it by hand. Removing a package has to take both.
remove_entry(&dir.join(file_name)).await?;
remove_entry(&dir.join(format!("{file_name}.disabled"))).await?;
Ok(())
}
/// Deletes `path` if anything is there, symlink or not.
///
/// `Path::exists` resolves symlinks, so a link whose artifact was evicted from
/// the cache reads as absent and the dead link survives every unlink; the
/// package then keeps showing up in the folder it was removed from.
async fn remove_entry(path: &Path) -> ContentResult<()> {
if polyio::symlink_metadata(path).await.is_err() {
return Ok(());
}
polyio::remove_file(path).await?;
Ok(())
}

View file

@ -157,6 +157,7 @@ impl PackageStore {
content_type,
file_name: artifact.file_name,
project_id: release.as_ref().map(|r| r.project_id.clone()),
version_id: release.as_ref().map(|r| r.version_id.clone()),
display_name: release.as_ref().map(|r| r.display_name.clone()),
display_version: release.as_ref().map(|r| r.display_version.clone()),
provider: release
@ -172,6 +173,12 @@ impl PackageStore {
/// Flips an artifact's enabled flag and relinks it on disk, returning the
/// new state.
///
/// `live` says a session is currently playing this cluster. Minecraft reads
/// its mods once at startup and holds the jars open, so the on-disk half
/// cannot take effect and, on Windows, cannot even be performed. The flag is
/// recorded either way and [`Self::reconcile_cluster_links`] settles the
/// folder at the next launch, so the choice survives instead of failing.
///
/// Storage only: bundle override bookkeeping is composed on top by
/// [`crate::bundles::toggle_artifact_enabled`], because that is the layer that
/// knows about bundles. Calling up into `bundles` from here would make the two
@ -179,6 +186,7 @@ impl PackageStore {
pub async fn set_artifact_enabled(
cluster_id: i64,
hash: &str,
live: bool,
ctx: &ContentCtx,
) -> ContentResult<bool> {
let cluster = Self::get_cluster(cluster_id, ctx).await?;
@ -201,15 +209,8 @@ impl PackageStore {
.trim_end_matches(".disabled")
.to_string();
if enabled {
link::link_artifact_to_cluster(&artifact, &cluster, Some(&file_name)).await?;
} else {
link::unlink_cluster_file(&cluster, content_type, &link.cluster_file_name).await?;
if link.cluster_file_name != file_name {
link::unlink_cluster_file(&cluster, content_type, &file_name).await?;
}
}
// The database is the source of truth, so it goes first: a folder that
// cannot be touched right now must not cost the user their choice.
artifact_dao::update_cluster_artifact(
&ctx.db,
cluster_id,
@ -219,9 +220,80 @@ impl PackageStore {
)
.await?;
let applied = apply_enabled_to_disk(
&artifact,
&cluster,
content_type,
&link.cluster_file_name,
&file_name,
enabled,
)
.await;
match applied {
Ok(()) => {}
Err(err) if live => tracing::info!(
cluster_id,
hash,
error = %err,
"cluster folder is in use by a running game; the change applies at the next launch"
),
Err(err) => return Err(err),
}
Ok(enabled)
}
/// Makes the cluster folder match the database: every enabled artifact
/// linked in, every disabled one gone.
///
/// Toggles made while the cluster was being played only reached the
/// database, so this is what lands them. Files already in the right state
/// are left alone, and failures are logged rather than returned: one
/// unwritable file must not stop a launch.
#[tracing::instrument(level = "debug", skip(ctx))]
pub async fn reconcile_cluster_links(cluster_id: i64, ctx: &ContentCtx) -> ContentResult<()> {
let cluster = Self::get_cluster(cluster_id, ctx).await?;
for link in artifact_dao::list_cluster_artifacts(&ctx.db, cluster_id).await? {
let Some(artifact) = artifact_dao::get_artifact_by_hash(&ctx.db, &link.hash).await?
else {
continue;
};
let Some(content_type) = ContentType::from_repr(artifact.content_type as u8) else {
continue;
};
let enabled = link.enabled != 0;
let present =
link::cluster_file_present(&cluster, content_type, &link.cluster_file_name).await;
if enabled == present {
continue;
}
let result = apply_enabled_to_disk(
&artifact,
&cluster,
content_type,
&link.cluster_file_name,
&link.cluster_file_name,
enabled,
)
.await;
if let Err(err) = result {
tracing::warn!(
cluster_id,
file = %link.cluster_file_name,
error = %err,
"failed to reconcile cluster link"
);
}
}
Ok(())
}
#[tracing::instrument(level = "debug", skip(ctx))]
pub async fn import_local_file(
path: &Path,
@ -320,6 +392,30 @@ async fn row_to_cached(
})
}
/// Brings one artifact's presence in the cluster folder in line with `enabled`.
///
/// `current_file_name` is what the folder may be holding, `file_name` the name
/// the link should carry; they differ only while a legacy `.disabled` spelling
/// is being cleaned up.
async fn apply_enabled_to_disk(
artifact: &ArtifactRow,
cluster: &ClusterRow,
content_type: ContentType,
current_file_name: &str,
file_name: &str,
enabled: bool,
) -> ContentResult<()> {
if enabled {
return link::link_artifact_to_cluster(artifact, cluster, Some(file_name)).await;
}
link::unlink_cluster_file(cluster, content_type, current_file_name).await?;
if current_file_name != file_name {
link::unlink_cluster_file(cluster, content_type, file_name).await?;
}
Ok(())
}
fn ensure_compatible(
project: &ProjectDetail,
version: &VersionDetail,

View file

@ -200,6 +200,7 @@ pub struct LinkedArtifactInfo {
pub content_type: ContentType,
pub file_name: String,
pub project_id: Option<String>,
pub version_id: Option<String>,
pub display_name: Option<String>,
pub display_version: Option<String>,
pub provider: Option<ProviderId>,

View file

@ -213,6 +213,19 @@ pub async fn launch_cluster(
tracing::warn!(cluster_id, error = %err, "failed to write allowed_symlinks.txt");
}
// Settle any enable/disable made while a previous session held these files
// open. Shared clusters get their game dir rebuilt from the database below
// anyway; this is what makes the cluster folder itself — and so a dedicated
// cluster's game dir — agree with what the UI has been showing.
if let Err(err) = oneclient_content::packages::PackageStore::reconcile_cluster_links(
cluster_id,
&state.services.content(),
)
.await
{
tracing::warn!(cluster_id, error = %err, "failed to reconcile cluster content links");
}
if !dedicated {
if let Err(err) = crate::game::sync_shared_content(&state.services, &cluster, &cwd).await {
tracing::warn!(cluster_id, error = %err, "failed to sync shared content");

View file

@ -126,6 +126,21 @@ pub async fn import_manual_content(
continue;
}
// The shared dir is rebuilt from the database on every launch, so
// anything here that the launcher already has in its artifact cache
// is a leftover from a session whose cleanup never ran — a crash, or
// a bundle update that dropped the package in between. Importing it
// would re-link the package the update just removed, and the next
// launch would put it back in the game. Drop it instead; the cache
// still holds the file if it is ever wanted again.
if is_cached_artifact(services, &path).await {
tracing::debug!(file = name, "discarding stale launcher content in shared dir");
if let Err(err) = polyio::remove_file(&path).await {
tracing::warn!(file = name, error = %err, "failed to discard stale shared content");
}
continue;
}
match PackageStore::import_local_file(&path, content_type, cluster.id, &services.content()).await {
Ok(_) => {
tracing::debug!(file = name, "registered manually-added shared content")
@ -140,6 +155,21 @@ pub async fn import_manual_content(
}
}
/// Whether `path` is a copy of something already in the launcher's artifact
/// cache, i.e. content the launcher itself put in the shared dir rather than
/// something the user dropped there.
async fn is_cached_artifact(services: &LauncherServices, path: &Path) -> bool {
let Ok(hash) = polyio::sha1_file(path).await else {
return false;
};
artifact_dao::get_artifact_by_hash(&services.db, &polyio::normalize_hash(&hash))
.await
.ok()
.flatten()
.is_some()
}
fn has_content_extension(content_type: ContentType, name: &str) -> bool {
let lower = name.to_lowercase();
match content_type {

View file

@ -108,7 +108,7 @@ impl LauncherState {
clusters,
metadata: Mutex::new(MetadataStore::new()),
bundles: Arc::new(BundlesManager::new()),
versions: Arc::new(VersionsManager::new()),
versions: Arc::new(VersionsManager::from_cache().await),
images: ImageCacheStore::new(),
games: crate::game::GameProcessManager::new(),
discord,

View file

@ -19,6 +19,36 @@ impl VersionsManager {
}
}
/// Seeds the manifest from the last synced copy on disk.
///
/// Without this the manifest is empty until the network answers, and
/// everything read off it — a version's art, name, description — waits on a
/// request that, for a returning user, ends in a 304 and the very same file.
/// The UI would rather render slightly stale metadata now and pick up the
/// sync when it lands.
#[must_use]
pub async fn from_cache() -> Self {
let manifest = Self::cached_manifest()
.await
.unwrap_or_else(VersionsManifest::default);
Self {
manifest: RwLock::new(manifest),
}
}
async fn cached_manifest() -> Option<VersionsManifest> {
let path = paths::caches_dir().ok()?.join("versions.json");
let bytes = polyio::read(&path).await.ok()?;
match serde_json::from_slice(&bytes) {
Ok(manifest) => Some(manifest),
Err(err) => {
tracing::warn!("cached versions manifest is unreadable: {err}");
None
}
}
}
#[tracing::instrument(level = "debug", skip(self, services))]
pub async fn sync(&self, services: &LauncherServices) -> LauncherResult<bool> {
let Some((manifest, changed)) = Self::fetch_manifest(services).await? else {

View file

@ -26,6 +26,23 @@ fn managed_file(enabled: bool) -> BundleFile {
}
}
/// A file the catalog added after the cluster was last synced: nothing on the
/// cluster tracks it, so it has no override either.
fn newly_shipped_file() -> BundleFile {
BundleFile {
enabled: true,
hidden: false,
path: "mods/newcomer.jar".to_string(),
size: 1,
kind: BundleFileKind::Managed {
provider: ProviderId::Modrinth,
project_id: "newcomer".to_string(),
version_id: "v1".to_string(),
sha1: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb".to_string(),
},
}
}
fn manifest(files: Vec<BundleFile>) -> BundleManifest {
BundleManifest {
name: BUNDLE.to_string(),
@ -196,3 +213,150 @@ async fn user_disabled_mod_is_not_treated_as_a_removal() {
check.removals_available
);
}
#[tokio::test]
async fn live_bundle_takes_on_new_catalog_files() {
let state = oneclient_core::dev::ephemeral_state().await.unwrap();
oneclient_core::dev::seed_bundle_archive(
&state,
manifest(vec![managed_file(true), newly_shipped_file()]),
)
.await
.unwrap();
let cluster_id = cluster_with_tracked_mod(&state).await;
let check = check_bundle_updates(cluster_id, state.bundles.as_ref(), &state.services.content())
.await
.unwrap();
assert_eq!(
check.additions_available.len(),
1,
"a bundle the user still has enabled should pick up newly shipped files"
);
assert_eq!(
check.additions_available[0].new_file.kind.package_id(),
"newcomer"
);
}
#[tokio::test]
async fn emptied_bundle_does_not_take_on_new_catalog_files() {
let state = oneclient_core::dev::ephemeral_state().await.unwrap();
oneclient_core::dev::seed_bundle_archive(
&state,
manifest(vec![managed_file(true), newly_shipped_file()]),
)
.await
.unwrap();
let cluster_id = cluster_with_tracked_mod(&state).await;
// The user disabled everything this bundle shipped, which is how you opt out
// of it. The newcomer has no override of its own precisely because it did not
// exist yet, and that absence must not read as consent.
artifact_dao::update_cluster_artifact(&state.services.db, cluster_id, HASH, "sodium.jar", 0)
.await
.unwrap();
bundle_dao::save_override(
&state.services.db,
cluster_id,
BUNDLE,
PROJECT_ID,
OverrideType::Disabled,
)
.await
.unwrap();
let check = check_bundle_updates(cluster_id, state.bundles.as_ref(), &state.services.content())
.await
.unwrap();
assert!(
check.additions_available.is_empty(),
"a bundle whose content the user disabled must not reinstate itself: {:?}",
check
.additions_available
.iter()
.map(|a| a.new_file.kind.package_id())
.collect::<Vec<_>>()
);
}
#[tokio::test]
async fn removed_bundle_content_does_not_take_on_new_catalog_files() {
let state = oneclient_core::dev::ephemeral_state().await.unwrap();
oneclient_core::dev::seed_bundle_archive(
&state,
manifest(vec![managed_file(true), newly_shipped_file()]),
)
.await
.unwrap();
let cluster_id = cluster_with_tracked_mod(&state).await;
oneclient_content::bundles::remove_artifact_from_cluster(
cluster_id,
HASH,
true,
&state.services.content(),
)
.await
.unwrap();
let check = check_bundle_updates(cluster_id, state.bundles.as_ref(), &state.services.content())
.await
.unwrap();
assert!(
check.additions_available.is_empty(),
"a bundle the user emptied by removal must not reinstate itself: {:?}",
check
.additions_available
.iter()
.map(|a| a.new_file.kind.package_id())
.collect::<Vec<_>>()
);
}
#[tokio::test]
async fn opting_a_single_file_in_keeps_the_bundle_live() {
let state = oneclient_core::dev::ephemeral_state().await.unwrap();
oneclient_core::dev::seed_bundle_archive(
&state,
manifest(vec![managed_file(true), newly_shipped_file()]),
)
.await
.unwrap();
let cluster_id = cluster_with_tracked_mod(&state).await;
artifact_dao::update_cluster_artifact(&state.services.db, cluster_id, HASH, "sodium.jar", 0)
.await
.unwrap();
bundle_dao::save_override(
&state.services.db,
cluster_id,
BUNDLE,
PROJECT_ID,
OverrideType::Disabled,
)
.await
.unwrap();
bundle_dao::save_override(
&state.services.db,
cluster_id,
BUNDLE,
"newcomer",
OverrideType::Enabled,
)
.await
.unwrap();
let check = check_bundle_updates(cluster_id, state.bundles.as_ref(), &state.services.content())
.await
.unwrap();
assert_eq!(
check.additions_available.len(),
1,
"an explicit opt-in is consent, even with the rest of the bundle disabled"
);
}

View file

@ -52,14 +52,49 @@ pub fn java_arguments(
}
parsed.push(format!("-Xmx{mem_max}M"));
if !custom_args.is_empty() {
parsed.push(custom_args);
}
parsed.extend(split_custom_args(&custom_args));
Ok(parsed)
}
/// Splits a user-entered JVM argument string into individual arguments.
///
/// Whitespace-separated, with double quotes for values that contain spaces
/// (`-Dfoo="a b"`), which is what people expect from every other launcher.
/// Pushing the whole string as one argv entry, as this used to, means the JVM
/// sees a single unrecognised option as soon as there is more than one.
fn split_custom_args(raw: &str) -> Vec<String> {
let mut args = Vec::new();
let mut current = String::new();
let mut quoted = false;
let mut started = false;
for ch in raw.chars() {
match ch {
'"' => {
quoted = !quoted;
started = true;
}
c if c.is_whitespace() && !quoted => {
if started {
args.push(std::mem::take(&mut current));
started = false;
}
}
c => {
current.push(c);
started = true;
}
}
}
if started {
args.push(current);
}
args
}
#[allow(clippy::too_many_arguments)]
pub fn minecraft_arguments(
version_updated: bool,
@ -415,3 +450,35 @@ where
Ok(())
}
#[cfg(test)]
mod tests {
use super::split_custom_args;
#[test]
fn blank_input_contributes_nothing() {
assert!(split_custom_args("").is_empty());
assert!(split_custom_args(" \t ").is_empty());
}
#[test]
fn each_flag_becomes_its_own_argument() {
assert_eq!(
split_custom_args("-XX:+UseG1GC -Xms2G"),
vec!["-XX:+UseG1GC", "-Xms2G"]
);
}
#[test]
fn quotes_hold_a_value_with_spaces_together() {
assert_eq!(
split_custom_args(r#"-Dname="My Server" -Xss1M"#),
vec!["-Dname=My Server", "-Xss1M"]
);
}
#[test]
fn an_empty_quoted_value_survives() {
assert_eq!(split_custom_args(r#"-Dempty="""#), vec!["-Dempty="]);
}
}

View file

@ -1,4 +1,4 @@
use std::sync::OnceLock;
use std::sync::{OnceLock, RwLock};
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration;
@ -34,6 +34,9 @@ struct Connectivity {
rx: watch::Receiver<ServiceStatus>,
notify: Notify,
started: AtomicBool,
/// Debug-only pin. While set, probe results are recorded but never
/// published, so the forced status survives a recheck.
forced: RwLock<Option<ServiceStatus>>,
}
fn connectivity() -> &'static Connectivity {
@ -45,10 +48,34 @@ fn connectivity() -> &'static Connectivity {
rx,
notify: Notify::new(),
started: AtomicBool::new(false),
forced: RwLock::new(None),
}
})
}
/// Pins the reported status to `status`, or releases the pin with `None` and
/// re-probes. Exposed for the debug page: the connectivity banner is otherwise
/// only reachable by actually taking a service down.
pub fn force(status: Option<ServiceStatus>) {
let conn = connectivity();
*conn.forced.write().unwrap_or_else(|e| e.into_inner()) = status;
match status {
Some(status) => {
let _ = conn.tx.send(status);
}
None => request_recheck(),
}
}
#[must_use]
pub fn forced() -> Option<ServiceStatus> {
*connectivity()
.forced
.read()
.unwrap_or_else(|e| e.into_inner())
}
pub fn subscribe() -> watch::Receiver<ServiceStatus> {
connectivity().rx.clone()
}
@ -78,7 +105,10 @@ pub fn start(client: RequestClient) {
tokio::spawn(async move {
loop {
let _ = conn.tx.send(check_service_status(&client).await);
let probed = check_service_status(&client).await;
if forced().is_none() {
let _ = conn.tx.send(probed);
}
conn.notify.notified().await;
}
});