From 3d0c8800968a5c5745f730d19b92fab38f06efd4 Mon Sep 17 00:00:00 2001 From: LynithDev <61880709+LynithDev@users.noreply.github.com> Date: Sun, 6 Sep 2026 01:38:32 +0200 Subject: [PATCH] feat: let new installs choose the data folder --- .../oneclient_app/src/hooks/active_cluster.rs | 1 + .../src/layout/onboarding_shell.rs | 19 ++- packages/oneclient_app/src/routes.rs | 11 +- .../src/view/app/settings/launcher.rs | 2 - packages/oneclient_app/src/view/mod.rs | 2 - .../location.rs} | 155 +++++++++++------- .../oneclient_app/src/view/onboarding/mod.rs | 111 +++++++++++-- .../src/view/onboarding/terms.rs | 10 +- .../src/view/onboarding/welcome.rs | 9 +- packages/oneclient_app/src/view/startup.rs | 2 +- packages/oneclient_core/src/relocate.rs | 1 - .../oneclient_core/src/settings/data_dir.rs | 6 - packages/oneclient_java/src/checker.rs | 8 +- 13 files changed, 234 insertions(+), 103 deletions(-) rename packages/oneclient_app/src/view/{setup_location.rs => onboarding/location.rs} (51%) diff --git a/packages/oneclient_app/src/hooks/active_cluster.rs b/packages/oneclient_app/src/hooks/active_cluster.rs index 0b7fe84a..46b5cba7 100644 --- a/packages/oneclient_app/src/hooks/active_cluster.rs +++ b/packages/oneclient_app/src/hooks/active_cluster.rs @@ -111,6 +111,7 @@ pub struct OnboardingSelectionState { /// `None` = don't import pub import_folder: State>, pub import_dedicated: State, + pub picks_location: State, } pub fn use_provide_onboarding_selection(state: OnboardingSelectionState) { diff --git a/packages/oneclient_app/src/layout/onboarding_shell.rs b/packages/oneclient_app/src/layout/onboarding_shell.rs index 695183a0..ed701bd2 100644 --- a/packages/oneclient_app/src/layout/onboarding_shell.rs +++ b/packages/oneclient_app/src/layout/onboarding_shell.rs @@ -6,8 +6,8 @@ use freya::router::{Outlet, use_route}; use crate::Route; use crate::components::OnboardingNavbar; use crate::hooks::{ - OnboardingSelectionState, has_migration_data, onboarding_bundles_items, use_migration, - use_onboarding_bundles, use_provide_onboarding_selection, use_settings_snapshot, + OnboardingSelectionState, has_migration_data, onboarding_bundles_items, use_launcher, + use_migration, use_onboarding_bundles, use_provide_onboarding_selection, use_settings_snapshot, }; use crate::theme::colors; use crate::view::onboarding::{ @@ -28,6 +28,10 @@ impl Component for OnboardingShell { let setup_started = use_state(|| false); let import_folder = use_state(|| None::); let import_dedicated = use_state(|| false); + let picks_location = use_state({ + let fresh = use_launcher().needs_location; + move || fresh + }); use_provide_onboarding_selection(OnboardingSelectionState { selected, user_touched, @@ -38,13 +42,15 @@ impl Component for OnboardingShell { setup_started, import_folder, import_dedicated, + picks_location, }); let migration_query = use_migration(); let detected_migration = has_migration_data(&migration_query); let route = use_route::(); - let step_index = onboarding_step_index(&route, detected_migration); + let choosing_location = *picks_location.read(); + let step_index = onboarding_step_index(&route, detected_migration, choosing_location); let onboarded = use_settings_snapshot().settings.seen_onboarding; let reconsent = onboarded && matches!(&route, Route::OnboardingTerms {}); @@ -89,8 +95,11 @@ impl Component for OnboardingShell { .content(Content::Flex) .layer(Layer::Relative(30)) .maybe_child((!reconsent).then(|| { - progress_bar(step_index, onboarding_total(detected_migration)) - .into_element() + progress_bar( + step_index, + onboarding_total(detected_migration, choosing_location), + ) + .into_element() })) .child(OnboardingNavbar) .child( diff --git a/packages/oneclient_app/src/routes.rs b/packages/oneclient_app/src/routes.rs index 70862fe7..e27f4011 100644 --- a/packages/oneclient_app/src/routes.rs +++ b/packages/oneclient_app/src/routes.rs @@ -8,7 +8,7 @@ use crate::layout::RootLayout; use crate::layout::SettingsShell; use crate::view::{ - NotFound, Relocating, SetupLocation, Startup, + NotFound, Relocating, Startup, app::{ AccountSkins, Clusters, Debug, Home, Stats, browser::{Browser, BrowserPackage}, @@ -24,7 +24,8 @@ use crate::view::{ }, onboarding::{ OnboardingAccount, OnboardingBundles, OnboardingDownloading, OnboardingLanguage, - OnboardingMigration, OnboardingPreferences, OnboardingTerms, OnboardingWelcome, + OnboardingLocation, OnboardingMigration, OnboardingPreferences, OnboardingTerms, + OnboardingWelcome, }, }; @@ -34,14 +35,14 @@ pub enum Route { #[layout(RootLayout)] #[route("/")] Startup {}, - #[route("/setup/location")] - SetupLocation {}, #[route("/moving")] Relocating {}, #[layout(OnboardingShell)] #[route("/onboarding")] OnboardingWelcome {}, + #[route("/onboarding/location")] + OnboardingLocation {}, #[route("/onboarding/terms")] OnboardingTerms {}, #[route("/onboarding/migration")] @@ -128,9 +129,9 @@ impl Route { pub fn title(&self) -> String { match self { Route::Startup { .. } => "Startup".to_string(), - Route::SetupLocation { .. } => "Setup".to_string(), Route::Relocating { .. } => "Moving".to_string(), Route::OnboardingWelcome { .. } => "Welcome".to_string(), + Route::OnboardingLocation { .. } => "Folder".to_string(), Route::OnboardingTerms { .. } => "Terms & Privacy".to_string(), Route::OnboardingMigration { .. } => "Migration".to_string(), Route::OnboardingLanguage { .. } => "Language".to_string(), diff --git a/packages/oneclient_app/src/view/app/settings/launcher.rs b/packages/oneclient_app/src/view/app/settings/launcher.rs index c85b3e97..55580965 100644 --- a/packages/oneclient_app/src/view/app/settings/launcher.rs +++ b/packages/oneclient_app/src/view/app/settings/launcher.rs @@ -195,9 +195,7 @@ impl Component for DataFolder { #[derive(Clone, Copy, PartialEq, Eq)] enum Source { - /// Whatever folder the picker comes back with Picked, - /// The folder OneClient uses when nothing is set Default, } diff --git a/packages/oneclient_app/src/view/mod.rs b/packages/oneclient_app/src/view/mod.rs index 3eb93696..f3075ef1 100644 --- a/packages/oneclient_app/src/view/mod.rs +++ b/packages/oneclient_app/src/view/mod.rs @@ -1,6 +1,5 @@ mod not_found; mod relocating; -mod setup_location; mod startup; pub mod app; @@ -9,5 +8,4 @@ pub mod onboarding; pub use not_found::NotFound; pub use relocating::Relocating; -pub use setup_location::SetupLocation; pub use startup::Startup; diff --git a/packages/oneclient_app/src/view/setup_location.rs b/packages/oneclient_app/src/view/onboarding/location.rs similarity index 51% rename from packages/oneclient_app/src/view/setup_location.rs rename to packages/oneclient_app/src/view/onboarding/location.rs index 5a902c32..c9d3fa18 100644 --- a/packages/oneclient_app/src/view/setup_location.rs +++ b/packages/oneclient_app/src/view/onboarding/location.rs @@ -3,29 +3,40 @@ use std::path::PathBuf; use freya::prelude::*; use freya::router::RouterContext; -use crate::components::Button; -use crate::hooks::{use_dispatch, use_launcher}; +use crate::components::IconType; +use crate::hooks::{use_dispatch, use_launcher, use_onboarding_selection}; use crate::routes::Route; use crate::state::AppChannel; use crate::theme::colors; use crate::ui::note; -use crate::view::onboarding::choice_row; +use crate::view::onboarding::{ + choice_row, onboarding_illustration, onboarding_nav_action, onboarding_page, step_heading, +}; #[derive(PartialEq)] -pub struct SetupLocation; +pub struct OnboardingLocation; -impl Component for SetupLocation { +impl Component for OnboardingLocation { fn render(&self) -> impl IntoElement { let actions = use_dispatch(); let launcher = use_launcher(); + let selection = use_onboarding_selection(); let mut picked = use_state(|| None::); let mut warning = use_state(|| None::); let mut error = use_state(|| None::); - let mut busy = use_state(|| false); + let mut waiting = use_state(|| false); - if !launcher.needs_location { - let _ = RouterContext::get().replace(Route::Startup {}); + let answered = !launcher.needs_location; + let settled = answered && launcher.ready; + + if !*selection.picks_location.read() && settled { + let _ = RouterContext::get().replace(Route::OnboardingTerms {}); + return rect().into_element(); + } + + if *waiting.read() && launcher.ready { + let _ = RouterContext::get().replace(Route::OnboardingTerms {}); return rect().into_element(); } @@ -33,8 +44,13 @@ impl Component for SetupLocation { .map(|dir| dir.display().to_string()) .unwrap_or_default(); - let confirm = move |_| { - if *busy.peek() { + let confirm = move |()| { + if *waiting.peek() { + return; + } + + if settled { + let _ = RouterContext::get().replace(Route::OnboardingTerms {}); return; } @@ -43,19 +59,17 @@ impl Component for SetupLocation { let station = actions.station(); let events = actions.events(); - busy.set(true); + waiting.set(true); error.set(None); spawn_forever(async move { - if let Err(message) = - oneclient_core::settings::data_dir::apply(chosen).await - { - error.set(Some(message)); - busy.set(false); - return; - } + if !answered { + if let Err(message) = oneclient_core::settings::data_dir::apply(chosen).await { + error.set(Some(message)); + waiting.set(false); + return; + } - { let mut station = station; let mut guard = station.write_channel(AppChannel::Launcher); guard.launcher.needs_location = false; @@ -63,40 +77,50 @@ impl Component for SetupLocation { match crate::events::start_launcher(station, events).await { Ok(()) => actions.sync_bundles(), - Err(err) => crate::events::report_startup_failure(&station, &err), + Err(err) => { + crate::events::report_startup_failure(&station, &err); + waiting.set(false); + } } }); }; let chosen = picked.read().clone(); - let running = *busy.read(); + let running = *waiting.read(); let mut content = rect() .vertical() - .width(Size::px(520.)) - .spacing(20.) - .child( - rect() - .vertical() - .width(Size::fill()) - .spacing(8.) - .child( - label() - .text("Choose a folder location for OneClient") - .font_size(26.) - .font_weight(FontWeight::BOLD) - .color(colors::fg_primary()), - ) - .child( - label() - .text( - "Any content downloaded via OneClient (such as Minecraft versions, mods, your worlds) will go in this folder. Make sure you have sufficient storage in your chosen disk.", - ) - .font_size(14.) - .color(colors::fg_secondary()), - ), - ) - .child( + .width(Size::fill()) + .spacing(16.) + .child(step_heading( + "Where should OneClient live?", + "All your data including mods, worlds and game settings gets stored here. This can be changed later from Settings.", + )); + + content = if answered { + let folder = if launcher.data_dir.is_empty() { + chosen + .as_ref() + .map(|path| path.display().to_string()) + .unwrap_or_else(|| default_dir.clone()) + } else { + launcher.data_dir.clone() + }; + + content + .child(choice_row("Your folder", &folder, true, |()| {})) + .child( + label() + .text( + "This is where OneClient is set up. Settings can move it once you are \ + through setup.", + ) + .font_size(13.) + .max_lines(2) + .color(colors::fg_secondary()), + ) + } else { + content.child( rect() .vertical() .width(Size::fill()) @@ -120,33 +144,38 @@ impl Component for SetupLocation { chosen.is_some(), move |()| browse(picked, warning, error), )), - ); + ) + }; if let Some(message) = warning.read().clone() { content = content.child(note(message, colors::code_warn())); } - if let Some(message) = error.read().clone() { + let failure = error.read().clone().or_else(|| launcher.error.clone()); + + if let Some(message) = failure { content = content.child(note(message, colors::danger())); } - content = content.child( - Button::new() - .primary() - .width(Size::px(160.)) - .enabled(!running) - .on_press(confirm) - .text(if running { "Setting up…" } else { "Continue" }), - ); + let next_label = if running { + "Setting up…" + } else if answered && !settled { + "Try again" + } else { + "Next" + }; - rect() - .width(Size::fill()) - .height(Size::fill()) - .center() - .background(colors::page()) - .window_drag() - .child(content) - .into_element() + onboarding_page( + onboarding_illustration(IconType::Folder), + content.into_element(), + onboarding_nav_action( + Some(Route::OnboardingWelcome {}), + next_label, + !running, + confirm, + ), + ) + .into_element() } } diff --git a/packages/oneclient_app/src/view/onboarding/mod.rs b/packages/oneclient_app/src/view/onboarding/mod.rs index 9abb4f19..72d1e390 100644 --- a/packages/oneclient_app/src/view/onboarding/mod.rs +++ b/packages/oneclient_app/src/view/onboarding/mod.rs @@ -2,6 +2,7 @@ mod account; mod bundles; mod downloading; mod language; +mod location; mod migration; mod preferences; mod selection; @@ -19,6 +20,7 @@ pub use account::OnboardingAccount; pub use bundles::OnboardingBundles; pub use downloading::{LoadingBackdrop, OnboardingDownloading}; pub use language::OnboardingLanguage; +pub use location::OnboardingLocation; pub use migration::OnboardingMigration; pub(crate) use migration::matching_new_cluster_id; pub use preferences::OnboardingPreferences; @@ -37,18 +39,19 @@ use crate::hooks::{has_migration_data, use_migration, use_onboarding_selection}; use crate::theme::colors; use crate::ui::{border_all_color, entrance_motion_layer}; -/// The v1-migration step only exists and is only counted when old launcher data was detected -pub fn onboarding_total(has_migration: bool) -> usize { - if has_migration { 8 } else { 7 } +pub fn onboarding_total(has_migration: bool, picks_location: bool) -> usize { + 7 + usize::from(has_migration) + usize::from(picks_location) } -pub fn onboarding_step_index(route: &Route, has_migration: bool) -> usize { - let shift = if has_migration { 1 } else { 0 }; +pub fn onboarding_step_index(route: &Route, has_migration: bool, picks_location: bool) -> usize { + let location = usize::from(picks_location); + let shift = location + usize::from(has_migration); match route { Route::OnboardingWelcome {} => 0, - Route::OnboardingTerms {} => 1, - Route::OnboardingMigration {} => 2, + Route::OnboardingLocation {} => 1, + Route::OnboardingTerms {} => 1 + location, + Route::OnboardingMigration {} => 2 + location, Route::OnboardingLanguage {} => 2 + shift, Route::OnboardingAccount {} => 3 + shift, Route::OnboardingBundles {} => 4 + shift, @@ -65,8 +68,13 @@ const SLIDE_DISTANCE: f32 = 44.; pub(crate) fn onboarding_slide(content: impl IntoElement) -> impl IntoElement { let route = use_route::(); let migration_query = use_migration(); - let step = onboarding_step_index(&route, has_migration_data(&migration_query)); - let reduce_motion = use_onboarding_selection().reduce_motion; + let selection = use_onboarding_selection(); + let step = onboarding_step_index( + &route, + has_migration_data(&migration_query), + *selection.picks_location.read(), + ); + let reduce_motion = selection.reduce_motion; let direction = use_hook(|| { let prev = LAST_STEP.swap(step, Ordering::Relaxed); @@ -157,6 +165,19 @@ pub(crate) fn onboarding_nav( next: Route, next_enabled: bool, ) -> impl IntoElement { + onboarding_nav_action(back, "Next", next_enabled, move |()| { + let _ = RouterContext::get().replace(next.clone()); + }) +} + +pub(crate) fn onboarding_nav_action( + back: Option, + next_label: &str, + next_enabled: bool, + on_next: impl FnMut(()) + 'static, +) -> impl IntoElement { + let mut on_next = on_next; + rect() .horizontal() .width(Size::fill()) @@ -179,10 +200,8 @@ pub(crate) fn onboarding_nav( .primary() .width(Size::px(140.)) .enabled(next_enabled) - .on_press(move |_| { - let _ = RouterContext::get().replace(next.clone()); - }) - .text("Next") + .on_press(move |_| on_next(())) + .text(next_label.to_string()) .child(Icon::new(IconType::ArrowRight).size(16.)), ) .into_element() @@ -347,3 +366,69 @@ pub(crate) fn step_heading(title: &str, subtitle: &str) -> impl IntoElement { ) .into_element() } + +#[cfg(test)] +mod tests { + use super::*; + + fn flow(has_migration: bool, picks_location: bool) -> Vec { + let mut routes = vec![Route::OnboardingWelcome {}]; + + if picks_location { + routes.push(Route::OnboardingLocation {}); + } + + routes.push(Route::OnboardingTerms {}); + + if has_migration { + routes.push(Route::OnboardingMigration {}); + } + + routes.extend([ + Route::OnboardingLanguage {}, + Route::OnboardingAccount {}, + Route::OnboardingBundles {}, + Route::OnboardingPreferences {}, + Route::OnboardingDownloading {}, + ]); + + routes + } + + #[test] + fn every_run_numbers_its_steps_one_after_another() { + for has_migration in [false, true] { + for picks_location in [false, true] { + let routes = flow(has_migration, picks_location); + let total = onboarding_total(has_migration, picks_location); + + assert_eq!( + routes.len(), + total, + "the bar is drawn out of {total} but the run has {} steps \ + (migration: {has_migration}, folder: {picks_location})", + routes.len() + ); + + for (position, route) in routes.iter().enumerate() { + assert_eq!( + onboarding_step_index(route, has_migration, picks_location), + position, + "{route:?} is numbered off its place in the run \ + (migration: {has_migration}, folder: {picks_location})" + ); + } + } + } + } + + #[test] + fn a_run_without_the_folder_step_is_numbered_as_it_always_was() { + assert_eq!(onboarding_total(false, false), 7); + assert_eq!(onboarding_total(true, false), 8); + assert_eq!( + onboarding_step_index(&Route::OnboardingTerms {}, false, false), + 1 + ); + } +} diff --git a/packages/oneclient_app/src/view/onboarding/terms.rs b/packages/oneclient_app/src/view/onboarding/terms.rs index f3cfa433..cb225d74 100644 --- a/packages/oneclient_app/src/view/onboarding/terms.rs +++ b/packages/oneclient_app/src/view/onboarding/terms.rs @@ -8,7 +8,7 @@ use crate::components::{ }; use crate::hooks::{ TermsQuery, has_migration_data, terms_document, terms_error, terms_is_loading, use_dispatch, - use_migration, use_settings_snapshot, use_terms, + use_migration, use_onboarding_selection, use_settings_snapshot, use_terms, }; use crate::platform::open_url; use crate::routes::Route; @@ -49,7 +49,13 @@ impl Component for OnboardingTerms { } else { Route::OnboardingLanguage {} }; - let back = (!returning).then_some(Route::OnboardingWelcome {}); + let back = (!returning).then(|| { + if *use_onboarding_selection().picks_location.read() { + Route::OnboardingLocation {} + } else { + Route::OnboardingWelcome {} + } + }); let terms_version = document.as_ref().map(|doc| doc.version).unwrap_or(1); let privacy_version = document diff --git a/packages/oneclient_app/src/view/onboarding/welcome.rs b/packages/oneclient_app/src/view/onboarding/welcome.rs index 37ad66e6..b995c360 100644 --- a/packages/oneclient_app/src/view/onboarding/welcome.rs +++ b/packages/oneclient_app/src/view/onboarding/welcome.rs @@ -1,6 +1,7 @@ use freya::prelude::*; use crate::components::IconType; +use crate::hooks::use_onboarding_selection; use crate::routes::Route; use crate::theme::colors; use crate::view::onboarding::{ @@ -12,6 +13,12 @@ pub struct OnboardingWelcome; impl Component for OnboardingWelcome { fn render(&self) -> impl IntoElement { + let next = if *use_onboarding_selection().picks_location.read() { + Route::OnboardingLocation {} + } else { + Route::OnboardingTerms {} + }; + let content = rect() .vertical() .width(Size::fill()) @@ -31,7 +38,7 @@ impl Component for OnboardingWelcome { onboarding_page( onboarding_illustration(IconType::OnboardingWelcome), content, - onboarding_nav(None, Route::OnboardingTerms {}, true), + onboarding_nav(None, next, true), ) } } diff --git a/packages/oneclient_app/src/view/startup.rs b/packages/oneclient_app/src/view/startup.rs index c377c405..79077dbd 100644 --- a/packages/oneclient_app/src/view/startup.rs +++ b/packages/oneclient_app/src/view/startup.rs @@ -90,7 +90,7 @@ impl Component for Startup { let logo = use_memo(|| AppAssets::get_bytes("logo.svg").unwrap_or_default()); if launcher.needs_location { - let _ = RouterContext::get().replace(Route::SetupLocation {}); + let _ = RouterContext::get().replace(Route::OnboardingWelcome {}); return rect().into_element(); } diff --git a/packages/oneclient_core/src/relocate.rs b/packages/oneclient_core/src/relocate.rs index 2fc4ffe5..b7d21654 100644 --- a/packages/oneclient_core/src/relocate.rs +++ b/packages/oneclient_core/src/relocate.rs @@ -98,7 +98,6 @@ fn ready_to_move(state: &LauncherState) -> Result { Ok(from) } -/// `settled` names what the destination is allowed to be holding already async fn weigh( state: &LauncherState, from: PathBuf, diff --git a/packages/oneclient_core/src/settings/data_dir.rs b/packages/oneclient_core/src/settings/data_dir.rs index 40af8c6d..6e4842ed 100644 --- a/packages/oneclient_core/src/settings/data_dir.rs +++ b/packages/oneclient_core/src/settings/data_dir.rs @@ -11,8 +11,6 @@ const LOW_SPACE_BYTES: u64 = 5 * 1000 * 1000 * 1000; const PROBE_NAME: &str = ".oneclient_write_test"; -/// What the operating system leaves lying around on its own. A folder holding -/// nothing else is one the person who picked it would call empty. const OS_CLUTTER: &[&str] = &[ ".DS_Store", ".localized", @@ -31,8 +29,6 @@ pub struct DataDirCheck { pub warning: Option, } -/// Whether `dir` holds nothing worth worrying about: a folder that is missing, -/// unreadable, or carrying only [`OS_CLUTTER`] and `ignored` names counts as empty pub async fn looks_empty(dir: &Path, ignored: &[OsString]) -> bool { let Ok(mut entries) = polyio::read_dir(dir).await else { return true; @@ -67,7 +63,6 @@ pub async fn resolve(picked: &Path) -> PathBuf { } } -/// Where the game data sits when nothing is set: alongside the settings pub fn default_path() -> Result { paths::config_dir() .map(Path::to_path_buf) @@ -83,7 +78,6 @@ pub async fn check(picked: &Path) -> Result { check_exact(&resolve(picked).await).await } -/// Checks the folder handed over rather than one derived from it pub async fn check_exact(path: &Path) -> Result { let path = path.to_path_buf(); diff --git a/packages/oneclient_java/src/checker.rs b/packages/oneclient_java/src/checker.rs index 835e9c98..56b4773c 100644 --- a/packages/oneclient_java/src/checker.rs +++ b/packages/oneclient_java/src/checker.rs @@ -167,8 +167,12 @@ mod tests { } #[test] - fn natives_that_refuse_to_link_are_not_usable() { - assert!(!has_usable_awt(&awt("true", "true", "false"))); + fn a_link_probe_that_fails_does_not_rule_the_image_out() { + assert!(has_usable_awt(&awt("true", "true", "false"))); + assert!(has_usable_awt(&info(&[ + ("java.awt", "true"), + ("java.awt.natives", "true"), + ]))); } #[test]