From b9f341e5798827b43e236237d356fd864681cc39 Mon Sep 17 00:00:00 2001 From: LynithDev <61880709+LynithDev@users.noreply.github.com> Date: Tue, 28 Jul 2026 21:26:17 +0200 Subject: [PATCH 01/16] chore: update to freya 0.4 (fixes browser freeze issue) --- Cargo.lock | 118 ++++++++++-------- Cargo.toml | 6 - .../oneclient_app/src/components/icons.rs | 3 +- .../src/components/navbar/app_navbar.rs | 5 +- .../src/components/navbar/mod.rs | 2 +- .../components/navbar/onboarding_navbar.rs | 3 +- .../src/components/overlay_popup.rs | 4 +- .../src/components/splash_curtain.rs | 3 +- .../src/components/status_bar.rs | 2 +- .../oneclient_app/src/components/toasts.rs | 2 +- .../src/view/app/cluster/logs.rs | 4 +- .../view/app/cluster/package_manager/views.rs | 4 +- packages/oneclient_app/src/view/startup.rs | 3 +- 13 files changed, 88 insertions(+), 71 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index a11570b0..f6bd9d24 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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]] @@ -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" @@ -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", diff --git a/Cargo.toml b/Cargo.toml index b46eb3cb..b4dbb9c7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/packages/oneclient_app/src/components/icons.rs b/packages/oneclient_app/src/components/icons.rs index 1b79ac59..dd004183 100644 --- a/packages/oneclient_app/src/components/icons.rs +++ b/packages/oneclient_app/src/components/icons.rs @@ -42,7 +42,8 @@ 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) diff --git a/packages/oneclient_app/src/components/navbar/app_navbar.rs b/packages/oneclient_app/src/components/navbar/app_navbar.rs index 258ddabc..4cafc524 100644 --- a/packages/oneclient_app/src/components/navbar/app_navbar.rs +++ b/packages/oneclient_app/src/components/navbar/app_navbar.rs @@ -32,7 +32,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 +68,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()) diff --git a/packages/oneclient_app/src/components/navbar/mod.rs b/packages/oneclient_app/src/components/navbar/mod.rs index 3138fa63..47e8eb07 100644 --- a/packages/oneclient_app/src/components/navbar/mod.rs +++ b/packages/oneclient_app/src/components/navbar/mod.rs @@ -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(u8::MAX)) .child( navbar_button() .child(Icon::new(IconType::Minus)) diff --git a/packages/oneclient_app/src/components/navbar/onboarding_navbar.rs b/packages/oneclient_app/src/components/navbar/onboarding_navbar.rs index 7118d727..d9c66235 100644 --- a/packages/oneclient_app/src/components/navbar/onboarding_navbar.rs +++ b/packages/oneclient_app/src/components/navbar/onboarding_navbar.rs @@ -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()) diff --git a/packages/oneclient_app/src/components/overlay_popup.rs b/packages/oneclient_app/src/components/overlay_popup.rs index bbac7c6b..956b27a4 100644 --- a/packages/oneclient_app/src/components/overlay_popup.rs +++ b/packages/oneclient_app/src/components/overlay_popup.rs @@ -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| { if e.key == Key::Named(NamedKey::Escape) && let Some(on_close) = key_close.as_ref() diff --git a/packages/oneclient_app/src/components/splash_curtain.rs b/packages/oneclient_app/src/components/splash_curtain.rs index 8435781b..bf04733c 100644 --- a/packages/oneclient_app/src/components/splash_curtain.rs +++ b/packages/oneclient_app/src/components/splash_curtain.rs @@ -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()), diff --git a/packages/oneclient_app/src/components/status_bar.rs b/packages/oneclient_app/src/components/status_bar.rs index a7786e50..89d33bd2 100644 --- a/packages/oneclient_app/src/components/status_bar.rs +++ b/packages/oneclient_app/src/components/status_bar.rs @@ -153,7 +153,7 @@ impl Component for StatusBanner { .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(u8::MAX - 20)) .background(issue.background()) .opacity(p) .child( diff --git a/packages/oneclient_app/src/components/toasts.rs b/packages/oneclient_app/src/components/toasts.rs index 5ee98859..6454411d 100644 --- a/packages/oneclient_app/src/components/toasts.rs +++ b/packages/oneclient_app/src/components/toasts.rs @@ -74,7 +74,7 @@ impl Component for Toasts { .vertical() .spacing(12.) .position(Position::new_global().top(64.).right(24.)) - .layer(Layer::RelativeOverlay(3)); + .layer(Layer::OverlayLevel(3)); for entry in entries { let id = entry.id; diff --git a/packages/oneclient_app/src/view/app/cluster/logs.rs b/packages/oneclient_app/src/view/app/cluster/logs.rs index 3cdb9492..887fb50c 100644 --- a/packages/oneclient_app/src/view/app/cluster/logs.rs +++ b/packages/oneclient_app/src/view/app/cluster/logs.rs @@ -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.) diff --git a/packages/oneclient_app/src/view/app/cluster/package_manager/views.rs b/packages/oneclient_app/src/view/app/cluster/package_manager/views.rs index 89037eee..483db3b4 100644 --- a/packages/oneclient_app/src/view/app/cluster/package_manager/views.rs +++ b/packages/oneclient_app/src/view/app/cluster/package_manager/views.rs @@ -313,7 +313,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 +322,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), ) diff --git a/packages/oneclient_app/src/view/startup.rs b/packages/oneclient_app/src/view/startup.rs index ac3f44b9..68b30dd6 100644 --- a/packages/oneclient_app/src/view/startup.rs +++ b/packages/oneclient_app/src/view/startup.rs @@ -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()), From 1fd587a840029cd3ecc0fa26681156e15c2b80a7 Mon Sep 17 00:00:00 2001 From: LynithDev <61880709+LynithDev@users.noreply.github.com> Date: Tue, 28 Jul 2026 13:11:48 +0200 Subject: [PATCH 02/16] fix(ui): keep progress toasts from covering the navbar --- packages/oneclient_app/src/components/toasts.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/oneclient_app/src/components/toasts.rs b/packages/oneclient_app/src/components/toasts.rs index 6454411d..66391f66 100644 --- a/packages/oneclient_app/src/components/toasts.rs +++ b/packages/oneclient_app/src/components/toasts.rs @@ -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,7 +77,7 @@ impl Component for Toasts { let mut stack = rect() .vertical() .spacing(12.) - .position(Position::new_global().top(64.).right(24.)) + .position(Position::new_global().top(TOP_PX).right(24.)) .layer(Layer::OverlayLevel(3)); for entry in entries { From 875c6673136c0ff17389e8e16e776a3e390ff266 Mon Sep 17 00:00:00 2001 From: LynithDev <61880709+LynithDev@users.noreply.github.com> Date: Tue, 28 Jul 2026 13:13:32 +0200 Subject: [PATCH 03/16] fix(ui): render the connectivity status bar message properly --- .../src/components/status_bar.rs | 37 +++--- packages/oneclient_app/src/view/app/debug.rs | 114 ++++++++++++++++++ packages/oneclient_net/src/status.rs | 34 +++++- 3 files changed, 163 insertions(+), 22 deletions(-) diff --git a/packages/oneclient_app/src/components/status_bar.rs b/packages/oneclient_app/src/components/status_bar.rs index 89d33bd2..ecf4c9be 100644 --- a/packages/oneclient_app/src/components/status_bar.rs +++ b/packages/oneclient_app/src/components/status_bar.rs @@ -121,19 +121,6 @@ impl Component for StatusBanner { }); let p = intro.get().value(); - let content = rect() - .horizontal() - .cross_align(Alignment::Center) - .spacing(8.) - .child(Icon::new(issue.icon()).size(15.).color(Color::WHITE)) - .child( - label() - .text(issue.message()) - .font_size(12.) - .font_weight(FontWeight::MEDIUM) - .color(Color::WHITE), - ); - let close = issue.closeable().then(|| { rect() .position(Position::new_absolute().right(10.)) @@ -147,8 +134,12 @@ impl Component for StatusBanner { dismissed.write().insert(issue); }) .child(Icon::new(IconType::XClose).size(14.).color(Color::WHITE)) + .into_element() }); + // The icon+message row is laid out directly in the bar rather than in a + // nested auto-sized wrapper: a transparent rect with no explicit size + // measures to nothing here, which is what was swallowing the message. rect() .width(Size::window_percent(100.)) .height(Size::px(BAR_HEIGHT)) @@ -156,14 +147,20 @@ impl Component for StatusBanner { .layer(Layer::OverlayLevel(u8::MAX - 20)) .background(issue.background()) .opacity(p) + .horizontal() + .main_align(Alignment::Center) + .cross_align(Alignment::Center) + .spacing(8.) + .padding(Gaps::new_symmetric(0., 40.)) + .child(Icon::new(issue.icon()).size(15.).color(Color::WHITE)) .child( - rect() - .width(Size::fill()) - .height(Size::fill()) - .center() - .padding(Gaps::new_symmetric(0., 16.)) - .child(content) - .maybe_child(close), + label() + .text(issue.message()) + .font_size(12.) + .font_weight(FontWeight::MEDIUM) + .max_lines(1) + .color(Color::WHITE), ) + .maybe_child(close) } } diff --git a/packages/oneclient_app/src/view/app/debug.rs b/packages/oneclient_app/src/view/app/debug.rs index 7d757132..a358bafd 100644 --- a/packages/oneclient_app/src/view/app/debug.rs +++ b/packages/oneclient_app/src/view/app/debug.rs @@ -3,6 +3,7 @@ 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::hooks::use_dispatch; @@ -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,6 +474,114 @@ 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; diff --git a/packages/oneclient_net/src/status.rs b/packages/oneclient_net/src/status.rs index 15b2399d..535e6f4f 100644 --- a/packages/oneclient_net/src/status.rs +++ b/packages/oneclient_net/src/status.rs @@ -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, 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>, } 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) { + 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 { + *connectivity() + .forced + .read() + .unwrap_or_else(|e| e.into_inner()) +} + pub fn subscribe() -> watch::Receiver { 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; } }); From d9e4f99adde708c0ac1f304330bd743bb603270f Mon Sep 17 00:00:00 2001 From: LynithDev <61880709+LynithDev@users.noreply.github.com> Date: Tue, 28 Jul 2026 13:14:21 +0200 Subject: [PATCH 04/16] refactor(ui): compact the auth error preview in the debug page --- packages/oneclient_app/src/view/app/debug.rs | 82 +++++++++++--------- 1 file changed, 45 insertions(+), 37 deletions(-) diff --git a/packages/oneclient_app/src/view/app/debug.rs b/packages/oneclient_app/src/view/app/debug.rs index a358bafd..edf92c40 100644 --- a/packages/oneclient_app/src/view/app/debug.rs +++ b/packages/oneclient_app/src/view/app/debug.rs @@ -5,7 +5,7 @@ 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, @@ -587,50 +587,27 @@ struct AuthGuidancePreview; impl Component for AuthGuidancePreview { fn render(&self) -> impl IntoElement { - let selected = use_state(|| None::>); 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 = vec!["No error (in progress)".to_string()]; + options.extend(samples.iter().map(|s| s.label.to_string())); - let mut labels: Vec<(&'static str, Option)> = 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(), @@ -639,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() } } From 56b421b18737fe4b898b04462807f246c84a758b Mon Sep 17 00:00:00 2001 From: LynithDev <61880709+LynithDev@users.noreply.github.com> Date: Tue, 28 Jul 2026 13:16:15 +0200 Subject: [PATCH 05/16] fix(content): stop removed bundle mods coming back --- .../src/packages/store/link.rs | 27 +++++++++++++---- .../oneclient_core/src/game/shared_dir.rs | 30 +++++++++++++++++++ 2 files changed, 51 insertions(+), 6 deletions(-) diff --git a/packages/oneclient_content/src/packages/store/link.rs b/packages/oneclient_content/src/packages/store/link.rs index 6f3a84b9..08919b3a 100644 --- a/packages/oneclient_content/src/packages/store/link.rs +++ b/packages/oneclient_content/src/packages/store/link.rs @@ -68,13 +68,28 @@ pub async fn unlink_cluster_file( 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 `.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(()) } diff --git a/packages/oneclient_core/src/game/shared_dir.rs b/packages/oneclient_core/src/game/shared_dir.rs index 9af2cbf7..f1068302 100644 --- a/packages/oneclient_core/src/game/shared_dir.rs +++ b/packages/oneclient_core/src/game/shared_dir.rs @@ -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 { From 18b95ae60a20987ea1e1eede1c95545e7104303b Mon Sep 17 00:00:00 2001 From: LynithDev <61880709+LynithDev@users.noreply.github.com> Date: Tue, 28 Jul 2026 13:17:38 +0200 Subject: [PATCH 06/16] fix(ui): rotate the changelog chevron with the accordion --- .../oneclient_app/src/components/icons.rs | 9 +++++ .../src/view/app/settings/changelog.rs | 34 ++++++++++++++----- 2 files changed, 35 insertions(+), 8 deletions(-) diff --git a/packages/oneclient_app/src/components/icons.rs b/packages/oneclient_app/src/components/icons.rs index dd004183..182393e9 100644 --- a/packages/oneclient_app/src/components/icons.rs +++ b/packages/oneclient_app/src/components/icons.rs @@ -8,6 +8,7 @@ pub struct Icon { pub icon: IconType, pub size_px: f32, pub color: Option, + pub rotation: Option, } 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 { @@ -48,6 +56,7 @@ impl Component for Icon { .height(Size::px(self.size_px)) // .fill(color) .map(self.color, |svg, color| svg.color(color)) + .map(self.rotation, |svg, degrees| svg.rotate(degrees)) } } diff --git a/packages/oneclient_app/src/view/app/settings/changelog.rs b/packages/oneclient_app/src/view/app/settings/changelog.rs index adc96f94..302c4af2 100644 --- a/packages/oneclient_app/src/view/app/settings/changelog.rs +++ b/packages/oneclient_app/src/view/app/settings/changelog.rs @@ -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() From e0c8994dd8b329ebd4b66d2f3f77ef72be21fc06 Mon Sep 17 00:00:00 2001 From: LynithDev <61880709+LynithDev@users.noreply.github.com> Date: Tue, 28 Jul 2026 13:20:49 +0200 Subject: [PATCH 07/16] fix(ui): keep home recent cards sized correctly through a resize --- .../src/components/recents_row.rs | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/packages/oneclient_app/src/components/recents_row.rs b/packages/oneclient_app/src/components/recents_row.rs index 0a3121c0..925273e6 100644 --- a/packages/oneclient_app/src/components/recents_row.rs +++ b/packages/oneclient_app/src/components/recents_row.rs @@ -51,8 +51,7 @@ impl Component for RecentsRow { .height(Size::px(ROW_HEIGHT_PX)) .content(Content::Flex) .on_sized(move |event: Event| { - 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; } @@ -122,7 +121,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) @@ -284,12 +282,20 @@ 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 } From 5f5fa4141ae0f4ac17c1c978adc7eae798231574 Mon Sep 17 00:00:00 2001 From: LynithDev <61880709+LynithDev@users.noreply.github.com> Date: Tue, 28 Jul 2026 13:25:30 +0200 Subject: [PATCH 08/16] feat(content): make mod toggles work while the game is running --- .../src/hooks/queries/mutations.rs | 13 +- .../view/app/cluster/package_manager/mod.rs | 7 +- .../view/app/cluster/package_manager/views.rs | 32 +++++ .../oneclient_content/src/bundles/install.rs | 6 +- .../oneclient_content/src/bundles/updates.rs | 4 +- .../src/packages/store/link.rs | 19 +++ .../src/packages/store/mod.rs | 113 ++++++++++++++++-- packages/oneclient_core/src/game/launch.rs | 13 ++ 8 files changed, 191 insertions(+), 16 deletions(-) diff --git a/packages/oneclient_app/src/hooks/queries/mutations.rs b/packages/oneclient_app/src/hooks/queries/mutations.rs index ba0ce185..e9a297ab 100644 --- a/packages/oneclient_app/src/hooks/queries/mutations.rs +++ b/packages/oneclient_app/src/hooks/queries/mutations.rs @@ -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 diff --git a/packages/oneclient_app/src/view/app/cluster/package_manager/mod.rs b/packages/oneclient_app/src/view/app/cluster/package_manager/mod.rs index 450257c1..9f582915 100644 --- a/packages/oneclient_app/src/view/app/cluster/package_manager/mod.rs +++ b/packages/oneclient_app/src/view/app/cluster/package_manager/mod.rs @@ -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, diff --git a/packages/oneclient_app/src/view/app/cluster/package_manager/views.rs b/packages/oneclient_app/src/view/app/cluster/package_manager/views.rs index 483db3b4..592f2468 100644 --- a/packages/oneclient_app/src/view/app/cluster/package_manager/views.rs +++ b/packages/oneclient_app/src/view/app/cluster/package_manager/views.rs @@ -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>, diff --git a/packages/oneclient_content/src/bundles/install.rs b/packages/oneclient_content/src/bundles/install.rs index fb29da51..b9be5fdb 100644 --- a/packages/oneclient_content/src/bundles/install.rs +++ b/packages/oneclient_content/src/bundles/install.rs @@ -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 { - 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?; diff --git a/packages/oneclient_content/src/bundles/updates.rs b/packages/oneclient_content/src/bundles/updates.rs index a8ee9154..632722b1 100644 --- a/packages/oneclient_content/src/bundles/updates.rs +++ b/packages/oneclient_content/src/bundles/updates.rs @@ -547,7 +547,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 +566,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(()) diff --git a/packages/oneclient_content/src/packages/store/link.rs b/packages/oneclient_content/src/packages/store/link.rs index 08919b3a..975e3f31 100644 --- a/packages/oneclient_content/src/packages/store/link.rs +++ b/packages/oneclient_content/src/packages/store/link.rs @@ -62,6 +62,25 @@ 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, diff --git a/packages/oneclient_content/src/packages/store/mod.rs b/packages/oneclient_content/src/packages/store/mod.rs index 2b74c5ef..3fcd6543 100644 --- a/packages/oneclient_content/src/packages/store/mod.rs +++ b/packages/oneclient_content/src/packages/store/mod.rs @@ -172,6 +172,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 +185,7 @@ impl PackageStore { pub async fn set_artifact_enabled( cluster_id: i64, hash: &str, + live: bool, ctx: &ContentCtx, ) -> ContentResult { let cluster = Self::get_cluster(cluster_id, ctx).await?; @@ -201,15 +208,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 +219,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 +391,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, diff --git a/packages/oneclient_core/src/game/launch.rs b/packages/oneclient_core/src/game/launch.rs index ef697494..1b4adba7 100644 --- a/packages/oneclient_core/src/game/launch.rs +++ b/packages/oneclient_core/src/game/launch.rs @@ -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"); From 6344d2e985be811a34f966665c47dd1973388d9c Mon Sep 17 00:00:00 2001 From: LynithDev <61880709+LynithDev@users.noreply.github.com> Date: Tue, 28 Jul 2026 13:28:16 +0200 Subject: [PATCH 09/16] feat(ui): JVM arguments setting --- .../src/view/app/cluster/cluster_settings.rs | 10 +++ .../src/view/app/settings/minecraft.rs | 16 ++++ packages/oneclient_mc/src/arguments.rs | 75 ++++++++++++++++++- 3 files changed, 97 insertions(+), 4 deletions(-) diff --git a/packages/oneclient_app/src/view/app/cluster/cluster_settings.rs b/packages/oneclient_app/src/view/app/cluster/cluster_settings.rs index 3936d29e..7190ac43 100644 --- a/packages/oneclient_app/src/view/app/cluster/cluster_settings.rs +++ b/packages/oneclient_app/src/view/app/cluster/cluster_settings.rs @@ -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 { 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) -> 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, diff --git a/packages/oneclient_app/src/view/app/settings/minecraft.rs b/packages/oneclient_app/src/view/app/settings/minecraft.rs index 914361a6..e4797bdb 100644 --- a/packages/oneclient_app/src/view/app/settings/minecraft.rs +++ b/packages/oneclient_app/src/view/app/settings/minecraft.rs @@ -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), diff --git a/packages/oneclient_mc/src/arguments.rs b/packages/oneclient_mc/src/arguments.rs index 498e9cd3..f8efd7e6 100644 --- a/packages/oneclient_mc/src/arguments.rs +++ b/packages/oneclient_mc/src/arguments.rs @@ -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 { + 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="]); + } +} From 21dc169578229afa3d4bf16fb82b689ff805b68f Mon Sep 17 00:00:00 2001 From: LynithDev <61880709+LynithDev@users.noreply.github.com> Date: Tue, 28 Jul 2026 13:59:22 +0200 Subject: [PATCH 10/16] fix(ui): status bar close button, and the layer saturation behind it --- .../src/components/navbar/mod.rs | 2 +- .../src/components/status_bar.rs | 83 ++++++++++++++----- 2 files changed, 65 insertions(+), 20 deletions(-) diff --git a/packages/oneclient_app/src/components/navbar/mod.rs b/packages/oneclient_app/src/components/navbar/mod.rs index 47e8eb07..150c3832 100644 --- a/packages/oneclient_app/src/components/navbar/mod.rs +++ b/packages/oneclient_app/src/components/navbar/mod.rs @@ -41,7 +41,7 @@ pub fn window_controls() -> impl IntoElement { .main_align(Alignment::End) .cross_align(Alignment::Center) .spacing(8.) - .layer(Layer::OverlayLevel(u8::MAX)) + .layer(Layer::OverlayLevel(15)) .child( navbar_button() .child(Icon::new(IconType::Minus)) diff --git a/packages/oneclient_app/src/components/status_bar.rs b/packages/oneclient_app/src/components/status_bar.rs index ecf4c9be..6db20068 100644 --- a/packages/oneclient_app/src/components/status_bar.rs +++ b/packages/oneclient_app/src/components/status_bar.rs @@ -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,15 +129,31 @@ impl Component for StatusBanner { }); let p = intro.get().value(); + 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() - .position(Position::new_absolute().right(10.)) .center() - .width(Size::px(20.)) - .height(Size::px(20.)) + .width(Size::px(22.)) + .height(Size::px(22.)) .corner_radius(CornerRadius::new_all(6.)) - .on_pointer_enter(|_| Cursor::set(CursorIcon::Pointer)) - .on_pointer_leave(|_| Cursor::set(CursorIcon::default())) + .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); }) @@ -137,21 +161,18 @@ impl Component for StatusBanner { .into_element() }); - // The icon+message row is laid out directly in the bar rather than in a - // nested auto-sized wrapper: a transparent rect with no explicit size - // measures to nothing here, which is what was swallowing the message. - rect() - .width(Size::window_percent(100.)) - .height(Size::px(BAR_HEIGHT)) - .position(Position::new_global().bottom(0.).left(0.)) - .layer(Layer::OverlayLevel(u8::MAX - 20)) - .background(issue.background()) - .opacity(p) + // 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() - .main_align(Alignment::Center) + .height(Size::fill()) .cross_align(Alignment::Center) .spacing(8.) - .padding(Gaps::new_symmetric(0., 40.)) + .layer(Layer::OverlayLevel(u8::MAX - 20)) .child(Icon::new(issue.icon()).size(15.).color(Color::WHITE)) .child( label() @@ -160,7 +181,31 @@ impl Component for StatusBanner { .font_weight(FontWeight::MEDIUM) .max_lines(1) .color(Color::WHITE), - ) - .maybe_child(close) + ); + + 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(BAR_LAYER)) + .background(issue.background()) + .opacity(p) + .horizontal() + .content(Content::Flex) + .cross_align(Alignment::Center) + .spacing(8.) + .padding(Gaps::new_symmetric(0., 10.)) + .child(leading) + .child(message) + .child(trailing) } } From 57d50a95c4e98b4cce0273f1fbfd330c29221821 Mon Sep 17 00:00:00 2001 From: LynithDev <61880709+LynithDev@users.noreply.github.com> Date: Tue, 28 Jul 2026 21:50:43 +0200 Subject: [PATCH 11/16] fix: recent cards sizing issue --- .../src/components/recents_row.rs | 90 +++++++++++++++++-- 1 file changed, 81 insertions(+), 9 deletions(-) diff --git a/packages/oneclient_app/src/components/recents_row.rs b/packages/oneclient_app/src/components/recents_row.rs index 925273e6..9c263e8a 100644 --- a/packages/oneclient_app/src/components/recents_row.rs +++ b/packages/oneclient_app/src/components/recents_row.rs @@ -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 = sort_clusters_for_home(clusters); let slots = *visible_slots.read(); - let display = sorted.into_iter().take(slots).collect::>(); - 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::>(); 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(); @@ -68,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) } @@ -299,3 +317,57 @@ fn recent_card_slots_for_width(row_width_px: f32) -> usize { let slot = MIN_CARD_WIDTH_PX + CARD_GAP_PX; (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); + } +} From ae76595913ad8bec8336352c509227c867ec9e48 Mon Sep 17 00:00:00 2001 From: LynithDev <61880709+LynithDev@users.noreply.github.com> Date: Tue, 28 Jul 2026 22:42:22 +0200 Subject: [PATCH 12/16] fix(ui): forgot to change these compile errors --- packages/oneclient_app/src/components/icons.rs | 2 +- packages/oneclient_app/src/components/status_bar.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/oneclient_app/src/components/icons.rs b/packages/oneclient_app/src/components/icons.rs index 182393e9..f7e209b3 100644 --- a/packages/oneclient_app/src/components/icons.rs +++ b/packages/oneclient_app/src/components/icons.rs @@ -56,7 +56,7 @@ impl Component for Icon { .height(Size::px(self.size_px)) // .fill(color) .map(self.color, |svg, color| svg.color(color)) - .map(self.rotation, |svg, degrees| svg.rotate(degrees)) + .map(self.rotation, |svg, degrees| svg.rotation(degrees)) } } diff --git a/packages/oneclient_app/src/components/status_bar.rs b/packages/oneclient_app/src/components/status_bar.rs index 6db20068..8b9054c5 100644 --- a/packages/oneclient_app/src/components/status_bar.rs +++ b/packages/oneclient_app/src/components/status_bar.rs @@ -196,7 +196,7 @@ impl Component for StatusBanner { .width(Size::window_percent(100.)) .height(Size::px(BAR_HEIGHT)) .position(Position::new_global().bottom(0.).left(0.)) - .layer(Layer::RelativeOverlay(BAR_LAYER)) + .layer(Layer::OverlayLevel(BAR_LAYER)) .background(issue.background()) .opacity(p) .horizontal() From 34408056c2c532294f01ee8e6a16c4823d5ea102 Mon Sep 17 00:00:00 2001 From: LynithDev <61880709+LynithDev@users.noreply.github.com> Date: Tue, 28 Jul 2026 23:04:44 +0200 Subject: [PATCH 13/16] fix(core): disabled packages don't enable themselves after updating --- .../oneclient_content/src/bundles/updates.rs | 60 ++++++- .../oneclient_core/tests/bundle_updates.rs | 164 ++++++++++++++++++ 2 files changed, 221 insertions(+), 3 deletions(-) diff --git a/packages/oneclient_content/src/bundles/updates.rs b/packages/oneclient_content/src/bundles/updates.rs index 632722b1..cf25db9b 100644 --- a/packages/oneclient_content/src/bundles/updates.rs +++ b/packages/oneclient_content/src/bundles/updates.rs @@ -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; } @@ -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>, + overrides: &[ClusterBundleOverrideRow], +) -> ContentResult> { + let (live_managed_keys, _) = + installed_bundle_keys(ctx, all_linked.iter().filter(|item| item.enabled)).await?; + + let mut eligible: HashSet = 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, ) -> ContentResult<(HashSet, HashSet)> { let mut managed = HashSet::new(); let mut external = HashSet::new(); diff --git a/packages/oneclient_core/tests/bundle_updates.rs b/packages/oneclient_core/tests/bundle_updates.rs index 41de3b2d..b4dc3d49 100644 --- a/packages/oneclient_core/tests/bundle_updates.rs +++ b/packages/oneclient_core/tests/bundle_updates.rs @@ -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) -> 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::>() + ); +} + +#[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::>() + ); +} + +#[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" + ); +} From 4aaff39abc2bde2b46a03dfb5ba1f96c51db448a Mon Sep 17 00:00:00 2001 From: LynithDev <61880709+LynithDev@users.noreply.github.com> Date: Wed, 29 Jul 2026 00:06:14 +0200 Subject: [PATCH 14/16] feat(ui): browser navbar tab + better installation feedback --- .../src/components/navbar/app_navbar.rs | 41 ++++- .../src/components/pagination.rs | 14 +- packages/oneclient_app/src/hooks/actions.rs | 73 +++++++-- packages/oneclient_app/src/hooks/mod.rs | 8 +- packages/oneclient_app/src/install.rs | 49 ++++-- packages/oneclient_app/src/routes.rs | 4 +- packages/oneclient_app/src/state.rs | 30 +++- .../src/view/app/browser/index/cards.rs | 59 ++++++- .../src/view/app/browser/index/mod.rs | 151 ++++++++++++++++-- .../oneclient_app/src/view/app/browser/mod.rs | 116 ++++++++++++++ .../src/view/app/browser/package/mod.rs | 20 ++- .../src/view/app/browser/package/panels.rs | 14 ++ .../src/view/app/browser/package/sidebar.rs | 130 ++++++++++++--- .../view/app/cluster/package_manager/views.rs | 1 + .../src/packages/store/mod.rs | 1 + .../oneclient_content/src/packages/types.rs | 1 + 16 files changed, 633 insertions(+), 79 deletions(-) diff --git a/packages/oneclient_app/src/components/navbar/app_navbar.rs b/packages/oneclient_app/src/components/navbar/app_navbar.rs index 4cafc524..3a1145eb 100644 --- a/packages/oneclient_app/src/components/navbar/app_navbar.rs +++ b/packages/oneclient_app/src/components/navbar/app_navbar.rs @@ -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; @@ -77,13 +81,14 @@ fn navbar_logo() -> impl IntoElement { fn navbar_center() -> impl IntoElement { let route = use_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 {}, @@ -94,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 {}, @@ -101,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, diff --git a/packages/oneclient_app/src/components/pagination.rs b/packages/oneclient_app/src/components/pagination.rs index bb3913b4..29dd8634 100644 --- a/packages/oneclient_app/src/components/pagination.rs +++ b/packages/oneclient_app/src/components/pagination.rs @@ -18,6 +18,7 @@ pub struct Pagination { page: State, 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() diff --git a/packages/oneclient_app/src/hooks/actions.rs b/packages/oneclient_app/src/hooks/actions.rs index f80af977..5f1ada78 100644 --- a/packages/oneclient_app/src/hooks/actions.rs +++ b/packages/oneclient_app/src/hooks/actions.rs @@ -661,28 +661,73 @@ impl Actions { version_id: impl Into, ) { 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); }); } diff --git a/packages/oneclient_app/src/hooks/mod.rs b/packages/oneclient_app/src/hooks/mod.rs index 277bd08e..f43120ec 100644 --- a/packages/oneclient_app/src/hooks/mod.rs +++ b/packages/oneclient_app/src/hooks/mod.rs @@ -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 { use_radio(AppChannel::MicrosoftLogin) .read() diff --git a/packages/oneclient_app/src/install.rs b/packages/oneclient_app/src/install.rs index 7360ba16..d66b0acb 100644 --- a/packages/oneclient_app/src/install.rs +++ b/packages/oneclient_app/src/install.rs @@ -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, + pub result: anyhow::Result, +} + pub async fn install_package( state: &Arc, provider: oneclient_content::packages::ProviderId, project_id: &str, version_id: &str, cluster_id: i64, -) -> anyhow::Result { - 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), + } } diff --git a/packages/oneclient_app/src/routes.rs b/packages/oneclient_app/src/routes.rs index ce354ce7..9dfbceb3 100644 --- a/packages/oneclient_app/src/routes.rs +++ b/packages/oneclient_app/src/routes.rs @@ -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")] diff --git a/packages/oneclient_app/src/state.rs b/packages/oneclient_app/src/state.rs index 11c865cc..2d511f0b 100644 --- a/packages/oneclient_app/src/state.rs +++ b/packages/oneclient_app/src/state.rs @@ -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 for AppChannel {} @@ -57,6 +60,31 @@ pub struct AppState { pub game: GameState, pub account_switcher_open: bool, pub microsoft_login: Option, + 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)] diff --git a/packages/oneclient_app/src/view/app/browser/index/cards.rs b/packages/oneclient_app/src/view/app/browser/index/cards.rs index 097dc238..99f45d13 100644 --- a/packages/oneclient_app/src/view/app/browser/index/cards.rs +++ b/packages/oneclient_app/src/view/app/browser/index/cards.rs @@ -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 { + installed + .get(&(item.provider, item.id.clone())) + .map(|installed| installed.source) +} + pub(super) fn grid_row( row: Vec, 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> = 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, } 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, + ) -> 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, } 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)) } } diff --git a/packages/oneclient_app/src/view/app/browser/index/mod.rs b/packages/oneclient_app/src/view/app/browser/index/mod.rs index 83e5295a..d2553aa0 100644 --- a/packages/oneclient_app/src/view/app/browser/index/mod.rs +++ b/packages/oneclient_app/src/view/app/browser/index/mod.rs @@ -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) -> impl IntoElement { +fn page_title( + package_type: &str, + cluster_name: Option, + picker: Option, +) -> impl IntoElement { rect() .vertical() .spacing(2.) @@ -250,12 +290,89 @@ fn page_title(package_type: &str, cluster_name: Option) -> 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 = clusters + .iter() + .map(|c| format!("{} · {}", c.name, version_name(&metadata, c))) + .collect(); + let ids: Vec = 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( diff --git a/packages/oneclient_app/src/view/app/browser/mod.rs b/packages/oneclient_app/src/view/app/browser/mod.rs index 5fadb63e..0fe5b8a9 100644 --- a/packages/oneclient_app/src/view/app/browser/mod.rs +++ b/packages/oneclient_app/src/view/app/browser/mod.rs @@ -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, +} + +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, + 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, diff --git a/packages/oneclient_app/src/view/app/browser/package/mod.rs b/packages/oneclient_app/src/view/app/browser/package/mod.rs index 95f91dfc..e7d1866f 100644 --- a/packages/oneclient_app/src/view/app/browser/package/mod.rs +++ b/packages/oneclient_app/src/view/app/browser/package/mod.rs @@ -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() diff --git a/packages/oneclient_app/src/view/app/browser/package/panels.rs b/packages/oneclient_app/src/view/app/browser/package/panels.rs index 6296ef89..6273d13b 100644 --- a/packages/oneclient_app/src/view/app/browser/package/panels.rs +++ b/packages/oneclient_app/src/view/app/browser/package/panels.rs @@ -157,6 +157,7 @@ impl Component for GalleryTile { } } +#[allow(clippy::too_many_arguments)] pub(super) fn versions_panel( versions: Vec, total_versions: usize, @@ -165,6 +166,8 @@ pub(super) fn versions_panel( project_id: String, cluster_id: i64, dispatch: Actions, + installed: Option, + 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) -> 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, + installing: bool, ) -> impl IntoElement { let version_id = v.version_id.clone(); let mut chips: Vec = 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, diff --git a/packages/oneclient_app/src/view/app/browser/package/sidebar.rs b/packages/oneclient_app/src/view/app/browser/package/sidebar.rs index 9b0c7eca..f73ea5bf 100644 --- a/packages/oneclient_app/src/view/app/browser/package/sidebar.rs +++ b/packages/oneclient_app/src/view/app/browser/package/sidebar.rs @@ -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, latest_version: Option, @@ -15,6 +16,8 @@ pub(super) fn sidebar( cluster_id: i64, dispatch: Actions, confirm: State>, + installed: Option, + 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 { + 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, + confirm: State>, +} + +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) -> 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, 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>) -> 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>) -> 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())) }) diff --git a/packages/oneclient_app/src/view/app/cluster/package_manager/views.rs b/packages/oneclient_app/src/view/app/cluster/package_manager/views.rs index 592f2468..96810b93 100644 --- a/packages/oneclient_app/src/view/app/cluster/package_manager/views.rs +++ b/packages/oneclient_app/src/view/app/cluster/package_manager/views.rs @@ -469,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.)) diff --git a/packages/oneclient_content/src/packages/store/mod.rs b/packages/oneclient_content/src/packages/store/mod.rs index 3fcd6543..30b45cff 100644 --- a/packages/oneclient_content/src/packages/store/mod.rs +++ b/packages/oneclient_content/src/packages/store/mod.rs @@ -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 diff --git a/packages/oneclient_content/src/packages/types.rs b/packages/oneclient_content/src/packages/types.rs index 941a0df2..88be6bff 100644 --- a/packages/oneclient_content/src/packages/types.rs +++ b/packages/oneclient_content/src/packages/types.rs @@ -200,6 +200,7 @@ pub struct LinkedArtifactInfo { pub content_type: ContentType, pub file_name: String, pub project_id: Option, + pub version_id: Option, pub display_name: Option, pub display_version: Option, pub provider: Option, From e31d799e86b3b5b1171a66264791e4ccd2c38b0c Mon Sep 17 00:00:00 2001 From: LynithDev <61880709+LynithDev@users.noreply.github.com> Date: Wed, 29 Jul 2026 00:34:40 +0200 Subject: [PATCH 15/16] fix(ui): version art gets properly prefetched --- .../src/components/cluster_landscape_art.rs | 4 +- .../src/components/dynamic_art.rs | 52 ++++++++++++-- packages/oneclient_app/src/components/mod.rs | 2 +- .../src/components/recents_row.rs | 4 +- .../oneclient_app/src/layout/app_shell.rs | 71 +++++++++++++++++-- packages/oneclient_app/src/layout/mod.rs | 2 +- .../oneclient_app/src/layout/root_layout.rs | 2 + .../view/onboarding/downloading/backdrop.rs | 13 ++-- .../view/onboarding/downloading/summary.rs | 4 +- packages/oneclient_core/src/state.rs | 2 +- .../oneclient_core/src/versions/manager.rs | 30 ++++++++ 11 files changed, 161 insertions(+), 25 deletions(-) diff --git a/packages/oneclient_app/src/components/cluster_landscape_art.rs b/packages/oneclient_app/src/components/cluster_landscape_art.rs index 9f5b7fdc..cde8c815 100644 --- a/packages/oneclient_app/src/components/cluster_landscape_art.rs +++ b/packages/oneclient_app/src/components/cluster_landscape_art.rs @@ -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, } } diff --git a/packages/oneclient_app/src/components/dynamic_art.rs b/packages/oneclient_app/src/components/dynamic_art.rs index 3cd2a0dc..8a6176a7 100644 --- a/packages/oneclient_app/src/components/dynamic_art.rs +++ b/packages/oneclient_app/src/components/dynamic_art.rs @@ -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, key: Option, loader: Option, max_edge: u32, + preview_edge: Option, } 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, loader: Option, max_edge: u32, + preview_edge: Option, ) -> (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 { diff --git a/packages/oneclient_app/src/components/mod.rs b/packages/oneclient_app/src/components/mod.rs index 63f0a138..76e17129 100644 --- a/packages/oneclient_app/src/components/mod.rs +++ b/packages/oneclient_app/src/components/mod.rs @@ -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; diff --git a/packages/oneclient_app/src/components/recents_row.rs b/packages/oneclient_app/src/components/recents_row.rs index 9c263e8a..f26ec59a 100644 --- a/packages/oneclient_app/src/components/recents_row.rs +++ b/packages/oneclient_app/src/components/recents_row.rs @@ -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; @@ -190,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() diff --git a/packages/oneclient_app/src/layout/app_shell.rs b/packages/oneclient_app/src/layout/app_shell.rs index 13dee94e..fa58d5d8 100644 --- a/packages/oneclient_app/src/layout/app_shell.rs +++ b/packages/oneclient_app/src/layout/app_shell.rs @@ -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) -> 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(); diff --git a/packages/oneclient_app/src/layout/mod.rs b/packages/oneclient_app/src/layout/mod.rs index 9342e22d..ac882c8b 100644 --- a/packages/oneclient_app/src/layout/mod.rs +++ b/packages/oneclient_app/src/layout/mod.rs @@ -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; diff --git a/packages/oneclient_app/src/layout/root_layout.rs b/packages/oneclient_app/src/layout/root_layout.rs index 0aeb2b59..4211a04f 100644 --- a/packages/oneclient_app/src/layout/root_layout.rs +++ b/packages/oneclient_app/src/layout/root_layout.rs @@ -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) } } diff --git a/packages/oneclient_app/src/view/onboarding/downloading/backdrop.rs b/packages/oneclient_app/src/view/onboarding/downloading/backdrop.rs index efce9c88..06625b10 100644 --- a/packages/oneclient_app/src/view/onboarding/downloading/backdrop.rs +++ b/packages/oneclient_app/src/view/onboarding/downloading/backdrop.rs @@ -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(¤t, |conf, _| { conf.on_creation(OnCreation::Run); diff --git a/packages/oneclient_app/src/view/onboarding/downloading/summary.rs b/packages/oneclient_app/src/view/onboarding/downloading/summary.rs index 9796a390..d0b44c1a 100644 --- a/packages/oneclient_app/src/view/onboarding/downloading/summary.rs +++ b/packages/oneclient_app/src/view/onboarding/downloading/summary.rs @@ -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() diff --git a/packages/oneclient_core/src/state.rs b/packages/oneclient_core/src/state.rs index 82d80b6d..4d36dab6 100644 --- a/packages/oneclient_core/src/state.rs +++ b/packages/oneclient_core/src/state.rs @@ -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, diff --git a/packages/oneclient_core/src/versions/manager.rs b/packages/oneclient_core/src/versions/manager.rs index aae8c20f..06738f00 100644 --- a/packages/oneclient_core/src/versions/manager.rs +++ b/packages/oneclient_core/src/versions/manager.rs @@ -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 { + 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 { let Some((manifest, changed)) = Self::fetch_manifest(services).await? else { From b6044d9e15b8163ff18450bd713583a0ad2c93d1 Mon Sep 17 00:00:00 2001 From: LynithDev <61880709+LynithDev@users.noreply.github.com> Date: Wed, 29 Jul 2026 00:35:05 +0200 Subject: [PATCH 16/16] chore: bump version to 2.1.1 --- Cargo.lock | 30 +++++++++++++++--------------- Cargo.toml | 2 +- 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f6bd9d24..f01e6939 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4517,7 +4517,7 @@ dependencies = [ [[package]] name = "oneclient_app" -version = "2.1.0" +version = "2.1.1" dependencies = [ "anyhow", "arboard", @@ -4555,7 +4555,7 @@ dependencies = [ [[package]] name = "oneclient_auth" -version = "2.1.0" +version = "2.1.1" dependencies = [ "base64", "chrono", @@ -4578,7 +4578,7 @@ dependencies = [ [[package]] name = "oneclient_cluster" -version = "2.1.0" +version = "2.1.1" dependencies = [ "bytes 1.11.1", "chrono", @@ -4601,7 +4601,7 @@ dependencies = [ [[package]] name = "oneclient_common" -version = "2.1.0" +version = "2.1.1" dependencies = [ "directories", "interfrost", @@ -4612,7 +4612,7 @@ dependencies = [ [[package]] name = "oneclient_content" -version = "2.1.0" +version = "2.1.1" dependencies = [ "async-trait", "async_zip", @@ -4638,7 +4638,7 @@ dependencies = [ [[package]] name = "oneclient_core" -version = "2.1.0" +version = "2.1.1" dependencies = [ "async-compression", "async-stream", @@ -4702,7 +4702,7 @@ dependencies = [ [[package]] name = "oneclient_db" -version = "2.1.0" +version = "2.1.1" dependencies = [ "chrono", "dunce", @@ -4716,7 +4716,7 @@ dependencies = [ [[package]] name = "oneclient_discord" -version = "2.1.0" +version = "2.1.1" dependencies = [ "chrono", "discord-rich-presence", @@ -4728,7 +4728,7 @@ dependencies = [ [[package]] name = "oneclient_events" -version = "2.1.0" +version = "2.1.1" dependencies = [ "thiserror 2.0.18", "tokio", @@ -4738,7 +4738,7 @@ dependencies = [ [[package]] name = "oneclient_java" -version = "2.1.0" +version = "2.1.1" dependencies = [ "astral-tokio-tar", "async-tempfile", @@ -4760,7 +4760,7 @@ dependencies = [ [[package]] name = "oneclient_macro" -version = "2.1.0" +version = "2.1.1" dependencies = [ "proc-macro2", "quote", @@ -4769,7 +4769,7 @@ dependencies = [ [[package]] name = "oneclient_mc" -version = "2.1.0" +version = "2.1.1" dependencies = [ "base64", "chrono", @@ -4791,7 +4791,7 @@ dependencies = [ [[package]] name = "oneclient_net" -version = "2.1.0" +version = "2.1.1" dependencies = [ "arc-swap", "async-trait", @@ -4813,7 +4813,7 @@ dependencies = [ [[package]] name = "oneclient_polyplus" -version = "2.1.0" +version = "2.1.1" dependencies = [ "async-tungstenite", "bytes 1.11.1", @@ -5090,7 +5090,7 @@ checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3" [[package]] name = "polyio" -version = "2.1.0" +version = "2.1.1" dependencies = [ "astral-tokio-tar", "async-compression", diff --git a/Cargo.toml b/Cargo.toml index b4dbb9c7..1db93224 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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]