feat: let new installs choose the data folder

This commit is contained in:
LynithDev 2026-09-06 01:38:32 +02:00
parent 776b5a8562
commit 3d0c880096
13 changed files with 234 additions and 103 deletions

View file

@ -111,6 +111,7 @@ pub struct OnboardingSelectionState {
/// `None` = don't import
pub import_folder: State<Option<String>>,
pub import_dedicated: State<bool>,
pub picks_location: State<bool>,
}
pub fn use_provide_onboarding_selection(state: OnboardingSelectionState) {

View file

@ -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::<String>);
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::<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(

View file

@ -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(),

View file

@ -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,
}

View file

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

View file

@ -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::<PathBuf>);
let mut warning = use_state(|| None::<String>);
let mut error = use_state(|| None::<String>);
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()
}
}

View file

@ -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::<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<Route>,
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<Route> {
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
);
}
}

View file

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

View file

@ -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),
)
}
}

View file

@ -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();
}

View file

@ -98,7 +98,6 @@ fn ready_to_move(state: &LauncherState) -> Result<PathBuf, String> {
Ok(from)
}
/// `settled` names what the destination is allowed to be holding already
async fn weigh(
state: &LauncherState,
from: PathBuf,

View file

@ -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<String>,
}
/// 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<PathBuf, String> {
paths::config_dir()
.map(Path::to_path_buf)
@ -83,7 +78,6 @@ pub async fn check(picked: &Path) -> Result<DataDirCheck, String> {
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<DataDirCheck, String> {
let path = path.to_path_buf();

View file

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