chore: deleted the download process from onboarding (#865)

* chore: deleted the download process from onboarding

* fix: gate onboarding Finish on loaded bundles and correct failure copy

* chore: drop unused install_cluster_bundles re-export from oneclient_core

* chore: drop the unread predownload flag from the versions manifest

---------

Co-authored-by: LynithDev <61880709+LynithDev@users.noreply.github.com>
This commit is contained in:
Maciek 2026-09-28 17:33:01 +02:00 • committed by GitHub
parent b3b436bc3e
commit 695406145f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 580 additions and 2016 deletions

View file

@ -122,8 +122,6 @@ pub struct OnboardingSelectionState {
pub migrated_categories: State<Option<Vec<String>>>,
pub language: State<String>,
pub reduce_motion: State<bool>,
pub predownload: State<bool>,
pub setup_started: State<bool>,
/// `None` = don't import
pub import_folder: State<Option<String>>,
pub import_dedicated: State<bool>,

View file

@ -77,7 +77,6 @@ mod tests {
art_url: None,
long_description: None,
tags: Vec::new(),
predownload: false,
}
}

View file

@ -577,7 +577,7 @@ half4 main(float2 fragCoord) {
}
"#;
pub(crate) fn gradient_overlay_radial() -> impl IntoElement {
fn gradient_overlay_radial() -> impl IntoElement {
let effect = use_hook(|| {
freya::engine::prelude::RuntimeEffect::make_for_shader(VIGNETTE_SPOTLIGHT_SHADER, None)
.expect("Failed to compile vignette shader")

View file

@ -8,7 +8,6 @@ mod root_layout;
mod settings_shell;
pub use animated_outlet::AnimatedAppOutlet;
pub(crate) use app_shell::gradient_overlay_radial;
pub use app_shell::{AppShell, HOME_BACKGROUND_ASSET, HomeArtPrefetch};
pub use cluster_shell::{ClusterShell, cluster_content};
pub use ipc_commands::use_ipc_commands;

View file

@ -10,9 +10,7 @@ use crate::hooks::{
use_migration, use_onboarding_bundles, use_provide_onboarding_selection, use_settings_snapshot,
};
use crate::theme::colors;
use crate::view::onboarding::{
LoadingBackdrop, default_selection, onboarding_step_index, onboarding_total,
};
use crate::view::onboarding::{default_selection, onboarding_step_index, onboarding_total};
#[derive(PartialEq)]
pub struct OnboardingShell;
@ -24,8 +22,6 @@ impl Component for OnboardingShell {
let migrated_categories = use_state(|| None::<Vec<String>>);
let language = use_state(|| "English".to_string());
let reduce_motion = use_state(|| false);
let predownload = use_state(|| true);
let setup_started = use_state(|| false);
let import_folder = use_state(|| None::<String>);
let import_dedicated = use_state(|| false);
let picks_location = use_state({
@ -38,8 +34,6 @@ impl Component for OnboardingShell {
migrated_categories,
language,
reduce_motion,
predownload,
setup_started,
import_folder,
import_dedicated,
picks_location,
@ -70,14 +64,6 @@ impl Component for OnboardingShell {
selected.set(defaults);
}
});
let show_backdrop =
matches!(&route, Route::OnboardingDownloading {}) && *setup_started.read();
let backdrop = show_backdrop.then(|| {
let clusters = onboarding_bundles_items(&bundles)
.map(|items| items.into_iter().map(|cb| cb.cluster).collect())
.unwrap_or_default();
LoadingBackdrop { clusters }.into_element()
});
rect()
.vertical()
@ -86,7 +72,6 @@ impl Component for OnboardingShell {
.background(colors::page())
.color(colors::fg_primary())
.overflow(Overflow::Clip)
.maybe_child(backdrop)
.child(
rect()
.vertical()

View file

@ -23,8 +23,8 @@ use crate::view::{
},
},
onboarding::{
OnboardingAccount, OnboardingBundles, OnboardingDownloading, OnboardingLanguage,
OnboardingLocation, OnboardingMigration, OnboardingPreferences, OnboardingTerms,
OnboardingAccount, OnboardingBundles, OnboardingLanguage, OnboardingLocation,
OnboardingMigration, OnboardingPreferences, OnboardingSummary, OnboardingTerms,
OnboardingWelcome,
},
};
@ -55,8 +55,8 @@ pub enum Route {
OnboardingPreferences {},
#[route("/onboarding/bundles")]
OnboardingBundles {},
#[route("/onboarding/downloading")]
OnboardingDownloading {},
#[route("/onboarding/summary")]
OnboardingSummary {},
#[end_layout]
#[layout(AppShell)]
@ -138,7 +138,7 @@ impl Route {
Route::OnboardingAccount { .. } => "Account".to_string(),
Route::OnboardingPreferences { .. } => "Accessibility".to_string(),
Route::OnboardingBundles { .. } => "Mods".to_string(),
Route::OnboardingDownloading { .. } => "Finishing".to_string(),
Route::OnboardingSummary { .. } => "Summary".to_string(),
Route::Home { .. } => "Home".to_string(),
Route::Clusters { .. } => "Versions".to_string(),
Route::ClusterOverview { .. } => "Cluster".to_string(),

View file

@ -17,8 +17,7 @@ use crate::theme::colors;
use crate::ui::border_all_color;
use crate::view::onboarding::{
archive_selected, choice_row_sized, is_default_bundle, is_optional_file, onboarding_nav,
onboarding_slide, pkg_key, predownload_toggle_row, set_archive_selected, step_heading,
version_chip,
onboarding_slide, pkg_key, set_archive_selected, step_heading, version_chip,
};
mod row;
@ -241,7 +240,6 @@ impl Component for OnboardingBundles {
let selection = use_onboarding_selection();
let selected = selection.selected;
let user_touched = selection.user_touched;
let predownload = selection.predownload;
let clusters = onboarding_bundles_items(&bundles_query).unwrap_or_default();
let bundles_loaded = onboarding_bundles_items(&bundles_query).is_some();
@ -317,8 +315,7 @@ impl Component for OnboardingBundles {
.children(sections),
)
.maybe_child(nothing_to_ask.then(|| empty_hint(&catalog_msg))),
)
.child(predownload_toggle_row(predownload)),
),
))
.child(onboarding_nav(
Some(Route::OnboardingAccount {}),

View file

@ -1,186 +0,0 @@
use super::*;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use oneclient_core::clusters::Cluster;
use crate::components::{ART_PREVIEW_EDGE, DynamicArt};
use crate::hooks::use_settings_snapshot;
use crate::layout::gradient_overlay_radial;
use crate::theme::colors;
pub(super) const BACKDROP_INTERVAL_SECS: u64 = 12;
const ROTATION_PERIOD: Duration = Duration::from_secs(BACKDROP_INTERVAL_SECS);
fn since_epoch() -> Duration {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or(Duration::ZERO)
}
pub(super) fn rotation_tick() -> usize {
(since_epoch().as_secs() / BACKDROP_INTERVAL_SECS) as usize
}
pub(super) fn next_rotation_delay() -> Duration {
let into_period = since_epoch().as_nanos() % ROTATION_PERIOD.as_nanos();
ROTATION_PERIOD - Duration::from_nanos(into_period as u64)
}
const PARALLAX_SCALE: f32 = 1.10;
const PARALLAX_STRENGTH: f32 = 0.008;
const PARALLAX_STEP: f32 = 0.12;
const PARALLAX_SETTLE: f32 = 0.002;
const PARALLAX_FRAME: Duration = Duration::from_millis(33);
#[derive(PartialEq)]
pub struct LoadingBackdrop {
pub clusters: Vec<Cluster>,
}
impl Component for LoadingBackdrop {
fn render(&self) -> impl IntoElement {
let clusters = &self.clusters;
let count = clusters.len().max(1);
// Offset against the shared tick so the first art shown is still the
// first cluster
let start = use_hook(rotation_tick);
let mut tick = use_state(rotation_tick);
let stop = use_hook(|| Arc::new(AtomicBool::new(false)));
use_drop({
let stop = stop.clone();
move || stop.store(true, Ordering::Relaxed)
});
use_hook({
let stop = stop.clone();
move || {
if count <= 1 {
return;
}
spawn(async move {
loop {
tokio::time::sleep(next_rotation_delay()).await;
if stop.load(Ordering::Relaxed) {
break;
}
tick.set(rotation_tick());
}
});
}
});
let current = (*tick.read()).wrapping_sub(start) % count;
let parallax_enabled = use_settings_snapshot().settings.dynamic_background_enabled;
let mut size = use_state(|| (0f32, 0f32));
let mut target = use_state(|| (0f32, 0f32));
let mut pan = use_state(|| (0f32, 0f32));
let mut anim_task = use_state(|| None::<OwnedTaskHandle>);
use_side_effect_with_deps(&parallax_enabled, move |enabled| {
if *enabled {
let handle = spawn(async move {
loop {
let (tx, ty) = *target.peek();
let (cx, cy) = *pan.peek();
if (tx - cx).abs() > PARALLAX_SETTLE || (ty - cy).abs() > PARALLAX_SETTLE {
pan.set((
cx + (tx - cx) * PARALLAX_STEP,
cy + (ty - cy) * PARALLAX_STEP,
));
}
tokio::time::sleep(PARALLAX_FRAME).await;
}
});
anim_task.set(Some(handle.owned()));
} else {
anim_task.set(None);
target.set((0.0, 0.0));
pan.set((0.0, 0.0));
}
});
let (cx, cy) = if parallax_enabled {
*pan.read()
} else {
(0.0, 0.0)
};
let (sw, sh) = *size.read();
let pan_x = cx * sw * PARALLAX_STRENGTH;
let pan_y = cy * sh * PARALLAX_STRENGTH;
let art = match clusters.get(current) {
Some(cluster) => DynamicArt::for_cluster(cluster),
None => DynamicArt::fallback(),
}
.preview_edge(ART_PREVIEW_EDGE);
let fade = use_animation_with_dependencies(&current, |conf, _| {
conf.on_creation(OnCreation::Run);
conf.on_change(OnChange::Rerun);
AnimNum::new(0.0, 1.0)
.time(900)
.ease(Ease::Out)
.function(Function::Cubic)
});
rect()
.position(Position::new_absolute().top(0.).left(0.))
.width(Size::fill())
.height(Size::fill())
.overflow(Overflow::Clip)
.interactive(false)
.background(colors::page())
.on_sized(move |e: Event<SizedEventData>| {
let (nw, nh) = (e.area.width(), e.area.height());
let (pw, ph) = *size.peek();
if (pw - nw).abs() > 0.5 || (ph - nh).abs() > 0.5 {
size.set((nw, nh));
}
})
.maybe(parallax_enabled, |el| {
el.on_capture_global_pointer_move(move |e: Event<PointerEventData>| {
let (sw, sh) = *size.peek();
if sw > 0.0 && sh > 0.0 {
let loc = e.global_location();
target.set((
(loc.x as f32 / sw) * 2.0 - 1.0,
(loc.y as f32 / sh) * 2.0 - 1.0,
));
}
})
})
.child(
rect()
.position(Position::new_absolute().top(0.).left(0.))
.width(Size::fill())
.height(Size::fill())
.layer(Layer::Relative(0))
.scale(PARALLAX_SCALE)
.offset_x(pan_x)
.offset_y(pan_y)
.opacity(fade.get().value())
.child(art),
)
.child(
rect()
.position(Position::new_absolute().top(0.).left(0.))
.width(Size::fill())
.height(Size::fill())
.layer(Layer::Relative(1))
.child(gradient_overlay_radial()),
)
}
}

File diff suppressed because it is too large Load diff

View file

@ -1,388 +0,0 @@
use super::*;
use crate::components::{Button, progress_track};
use crate::theme::colors;
use crate::ui::border_all_color;
use crate::utils::{format_durations, format_size};
pub(super) fn failure_panel(
failures: &[InstallFailure],
done: usize,
total: usize,
running: bool,
on_retry: impl FnMut(Event<PressEventData>) + 'static,
on_continue: impl FnMut(Event<PressEventData>) + 'static,
) -> impl IntoElement {
let lines: Vec<Element> = failures
.iter()
.take(8)
.map(|failure| {
label()
.text(format!(
"{} (cluster {}): {}",
failure.plan.mc_version, failure.plan.cluster_id, failure.reason
))
.font_size(12.)
.color(colors::fg_primary())
.into_element()
})
.collect();
let extra = failures.len().saturating_sub(8);
rect()
.vertical()
.width(Size::px(640.))
.margin(Gaps::new(24., 0., 0., 0.))
.padding(Gaps::new_all(20.))
.spacing(10.)
.corner_radius(CornerRadius::new_all(12.))
.background(colors::danger().with_a(26))
.border(border_all_color(1., colors::danger().with_a(128)))
.child(
label()
.text("Some versions could not be prepared.")
.font_size(16.)
.font_weight(FontWeight::SEMI_BOLD)
.color(colors::fg_primary()),
)
.child(
label()
.text(format!("Processed: {done} / {total}"))
.font_size(12.)
.color(colors::fg_secondary()),
)
.child(
rect()
.vertical()
.width(Size::fill())
.spacing(4.)
.padding(Gaps::new_all(12.))
.corner_radius(CornerRadius::new_all(8.))
.background(Color::BLACK.with_a(51))
.children(lines)
.maybe_child((extra > 0).then(|| {
label()
.text(format!("+{extra} more failures"))
.font_size(12.)
.color(colors::fg_secondary())
.into_element()
})),
)
.child(
rect()
.horizontal()
.spacing(10.)
.child(
Button::new()
.primary()
.enabled(!running)
.on_press(on_retry)
.text("Retry Failed Versions"),
)
.child(
Button::new()
.secondary()
.on_press(on_continue)
.text("Continue Anyway"),
),
)
.into_element()
}
pub(super) struct ProgressView<'a> {
pub(super) global: f32,
pub(super) stage: &'a DownloadStage,
pub(super) agg: &'a GroupedAgg,
pub(super) activity: &'a str,
/// `None` until the meter has two samples to work from
pub(super) transfer: Option<TransferStats>,
pub(super) total_estimate: u64,
pub(super) elapsed_secs: Option<u64>,
pub(super) done: usize,
pub(super) total: usize,
pub(super) predownload: bool,
pub(super) running: bool,
}
pub(super) fn progress_panel(view: ProgressView) -> impl IntoElement {
let ProgressView {
global,
stage,
agg,
activity,
transfer,
total_estimate,
elapsed_secs,
done,
total,
predownload,
running,
} = view;
let percentage = global.round() as i32;
let position = if !running && done >= total && total > 0 {
"Done".to_string()
} else if predownload && stage.total > 0 {
format!(
"Version {} of {}: {}",
(stage.index + 1).min(stage.total),
stage.total,
stage.label
)
} else if total > 0 {
format!("Step {done} of {total}")
} else {
"Preparing...".to_string()
};
let tasks = agg.task_list();
let live_count = tasks.len();
let shown = live_count.min(MAX_TASK_ROWS);
let overflow = live_count.saturating_sub(shown);
let task_rows: Vec<Element> = tasks.into_iter().take(shown).map(task_row).collect();
let activity_text = agg.summary().unwrap_or_else(|| {
if activity.is_empty() {
position.clone()
} else {
activity.to_string()
}
});
let stats = build_stats(StatsView {
global,
transfer,
total_estimate,
elapsed_secs,
running,
done,
total,
});
rect()
.vertical()
.width(Size::px(460.))
.spacing(10.)
.cross_align(Alignment::Start)
.main_align(Alignment::End)
.maybe_child((!task_rows.is_empty()).then(|| {
rect()
.vertical()
.width(Size::fill())
.spacing(8.)
.children(task_rows)
.maybe_child((overflow > 0).then(|| {
label()
.text(format!("+{overflow} more"))
.font_size(11.)
.color(colors::fg_secondary())
.into_element()
}))
.into_element()
}))
.child(
ActivityIndicator {
running,
text: activity_text,
}
.into_element(),
)
.maybe_child(stats)
.child(
rect()
.horizontal()
.width(Size::fill())
.cross_align(Alignment::Center)
.content(Content::Flex)
.child(
label()
.text(format!("{percentage}%"))
.width(Size::flex(1.0))
.font_size(24.)
.font_weight(FontWeight::SEMI_BOLD)
.color(colors::fg_primary()),
)
.child(
label()
.text(position)
.font_size(11.)
.max_lines(1)
.color(colors::fg_secondary()),
),
)
.child(progress_track(
global,
4.,
Color::WHITE,
Color::WHITE.with_a(51),
))
.into_element()
}
struct StatsView {
global: f32,
transfer: Option<TransferStats>,
total_estimate: u64,
elapsed_secs: Option<u64>,
running: bool,
done: usize,
total: usize,
}
fn build_stats(view: StatsView) -> Option<Element> {
let StatsView {
global,
transfer,
total_estimate,
elapsed_secs,
running,
done,
total,
} = view;
let elapsed = elapsed_secs?;
let finished = !running && done >= total && total > 0;
let mut parts: Vec<String> = vec![format!("Elapsed {}", format_durations(elapsed as i64))];
if finished {
parts.push("Complete".to_string());
} else {
// Both readings come off the same smoothed rate so they always agree
match transfer.and_then(|t| t.eta_secs) {
Some(remaining) => parts.push(format!("~{} left", format_durations(remaining as i64))),
None => parts.push("~- left".to_string()),
}
if let Some(speed) = transfer.map(|t| t.speed_bps).filter(|s| *s >= 1.0) {
parts.push(format!("{}/s", format_size(speed as u64)));
}
}
if total_estimate > 0 {
let downloaded = ((global as f64 / 100.0) * total_estimate as f64) as u64;
parts.push(format!(
"{} / ~{}",
format_size(downloaded),
format_size(total_estimate)
));
}
Some(
label()
.text(parts.join(" · "))
.font_size(11.)
.max_lines(1)
.color(colors::fg_secondary())
.into_element(),
)
}
fn task_row(task: TaskLine) -> Element {
let is_bytes = task.total > 1;
let pct = if task.total > 0 {
(task.current as f32 / task.total as f32 * 100.0).clamp(0.0, 100.0)
} else {
0.0
};
let detail = if is_bytes {
format!(
"{} / {}",
format_size(task.current),
format_size(task.total)
)
} else {
task.phase.to_string()
};
let track_pct = if is_bytes { pct } else { 100.0 };
let fill = if is_bytes {
colors::brand()
} else {
colors::brand().with_a(120)
};
rect()
.vertical()
.width(Size::fill())
.spacing(4.)
.child(
rect()
.horizontal()
.width(Size::fill())
.content(Content::Flex)
.cross_align(Alignment::Center)
.spacing(10.)
.child(
label()
.text(format!("{} {}", task.phase, task.label))
.width(Size::flex(1.0))
.font_size(12.)
.max_lines(1)
.color(colors::fg_primary()),
)
.child(
label()
.text(detail)
.font_size(11.)
.max_lines(1)
.color(colors::fg_secondary()),
),
)
.child(progress_track(track_pct, 3., fill, Color::WHITE.with_a(28)))
.into_element()
}
#[derive(PartialEq)]
struct ActivityIndicator {
running: bool,
text: String,
}
impl Component for ActivityIndicator {
fn render(&self) -> impl IntoElement {
let pulse = use_animation(|conf| {
conf.on_creation(OnCreation::Run);
conf.on_finish(OnFinish::reverse());
AnimNum::new(0.3, 1.0)
.time(650)
.ease(Ease::InOut)
.function(Function::Cubic)
});
let dot_opacity = if self.running {
pulse.read().value()
} else {
0.9
};
let color = if self.running {
colors::brand()
} else {
colors::fg_secondary()
};
rect()
.horizontal()
.width(Size::fill())
.cross_align(Alignment::Center)
.spacing(8.)
.content(Content::Flex)
.child(
rect()
.width(Size::px(8.))
.height(Size::px(8.))
.corner_radius(CornerRadius::new_all(4.))
.background(color)
.opacity(dot_opacity),
)
.child(
label()
.text(self.text.clone())
.width(Size::flex(1.0))
.font_size(12.)
.max_lines(1)
.color(colors::fg_primary()),
)
}
}

View file

@ -1,150 +0,0 @@
use super::*;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use super::backdrop::{next_rotation_delay, rotation_tick};
use crate::theme::colors;
const ONBOARDING_TIPS_BACKUP: &[&str] = &[
"418 I'm a teapot",
"Floors 8 through 10 of the Catacombs were originally planned for release, but portions of it were later merged into Floor 7 and its Master Mode counterpart.",
"At one point, Feather Client was willing to pay $300 USD to add Skytils, a popular SkyBlock mod.",
"Grass is a plant with narrow leaves growing from the base. [...]\nGrasses are monocotyledon, herbaceous plants.",
"In Canada, Santa's postal code is H0H 0H0.",
"Guvf vf abg na vzcbegnag fragrapr.",
"Vs lbh qrpvcure guvf, lbh fubhyq yvfgra gb Mrgihr'f zhfvp.",
"Try to do something creative! It could be music, art, coding...\nYou won't know how much you like it until you try it, so give it a shot :)",
"At one point in Minecraft, there was a rare chance of Snow Golems naturally spawning.",
"W[h]yvest",
"RUMOUR. Open your ears; 9r\"5j5&?OWTY Z0d",
"Make sure to drink water! You never know if you might be dehydrated.",
"People asking you to \"test their mod\" are likely trying to hack you.\nThe only ones you can trust are ones widely trusted by the community.",
"Have a great day!",
"You can sleep through a thunderstorm, even during the day",
];
const FUNFACTS_URL: &str = "https://data-v2.polyfrost.org/oneclient/funfacts.txt";
fn normalize_line_breaks(value: &str) -> String {
value.replace("/n", "\n").replace("\\n", "\n")
}
fn parse_funfacts(text: &str) -> Vec<String> {
text.lines()
.map(str::trim)
.filter(|line| !line.is_empty())
.map(normalize_line_breaks)
.collect()
}
/// A random tip that rotates in step with the backdrop art
pub(super) fn use_onboarding_tip() -> String {
let mut tips = use_state(|| {
ONBOARDING_TIPS_BACKUP
.iter()
.map(|tip| normalize_line_breaks(tip))
.collect::<Vec<String>>()
});
use_hook(move || {
spawn(async move {
let Ok(state) = crate::launcher::state() else {
return;
};
match state
.services
.requester
.http()
.get(FUNFACTS_URL)
.send()
.await
{
Ok(res) if res.status().is_success() => match res.text().await {
Ok(text) => {
let parsed = parse_funfacts(&text);
if !parsed.is_empty() {
tips.set(parsed);
}
}
Err(err) => {
tracing::warn!("onboarding tips: reading body failed: {err}");
}
},
Ok(res) => {
tracing::warn!("onboarding tips: unexpected status {}", res.status());
}
Err(err) => {
tracing::warn!("onboarding tips: request failed, using backup facts: {err}");
}
}
});
});
// Random offset against the shared tick so the run does not always open on
// the same fact
let seed = use_hook(|| {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.subsec_nanos() as usize)
.unwrap_or(0)
});
let mut tick = use_state(rotation_tick);
let stop = use_hook(|| Arc::new(AtomicBool::new(false)));
use_drop({
let stop = stop.clone();
move || stop.store(true, Ordering::Relaxed)
});
use_hook({
let stop = stop.clone();
move || {
spawn(async move {
loop {
tokio::time::sleep(next_rotation_delay()).await;
if stop.load(Ordering::Relaxed) {
break;
}
tick.set(rotation_tick());
}
});
}
});
// Wrapped at read time the list is swapped out once the remote facts land
let list = tips.read();
if list.is_empty() {
String::new()
} else {
list[seed.wrapping_add(*tick.read()) % list.len()].clone()
}
}
pub(super) fn tip_panel(tip: String) -> impl IntoElement {
rect()
.vertical()
.width(Size::px(320.))
.spacing(4.)
.cross_align(Alignment::End)
.child(
label()
.text("TIP")
.width(Size::fill())
.font_size(11.)
.font_weight(FontWeight::SEMI_BOLD)
.text_align(TextAlign::Right)
.color(colors::fg_secondary()),
)
.child(
label()
.text(tip)
.width(Size::fill())
.font_size(13.)
.text_align(TextAlign::Right)
.color(colors::fg_secondary()),
)
}

View file

@ -1,11 +1,11 @@
mod account;
mod bundles;
mod downloading;
mod language;
mod location;
mod migration;
mod preferences;
mod selection;
mod summary;
mod terms;
#[cfg(test)]
pub(crate) mod test_support;
@ -18,12 +18,12 @@ pub(crate) use selection::{
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;
pub use summary::OnboardingSummary;
pub use terms::OnboardingTerms;
pub use welcome::OnboardingWelcome;
@ -34,7 +34,7 @@ use freya::prelude::*;
use freya::router::{RouterContext, use_route};
use crate::Route;
use crate::components::{Button, Icon, IconType, toggle};
use crate::components::{Button, Icon, IconType};
use crate::hooks::{has_migration_data, use_migration, use_onboarding_selection};
use crate::theme::colors;
use crate::ui::{border_all_color, entrance_motion_layer};
@ -56,7 +56,7 @@ pub fn onboarding_step_index(route: &Route, has_migration: bool, picks_location:
Route::OnboardingAccount {} => 3 + shift,
Route::OnboardingBundles {} => 4 + shift,
Route::OnboardingPreferences {} => 5 + shift,
Route::OnboardingDownloading {} => 6 + shift,
Route::OnboardingSummary {} => 6 + shift,
_ => 0,
}
}
@ -207,44 +207,6 @@ pub(crate) fn onboarding_nav_action(
.into_element()
}
pub(crate) fn predownload_toggle_row(predownload: State<bool>) -> impl IntoElement {
rect()
.horizontal()
.width(Size::fill())
.cross_align(Alignment::Center)
.spacing(16.)
.content(Content::Flex)
.padding(Gaps::new_symmetric(12., 16.))
.corner_radius(CornerRadius::new_all(12.))
.background(colors::page_elevated())
.border(border_all_color(1., colors::component_border()))
.child(
rect()
.vertical()
.width(Size::flex(1.0))
.spacing(3.)
.child(
label()
.text("Download mods now")
.font_size(14.)
.font_weight(FontWeight::MEDIUM)
.color(colors::fg_primary()),
)
.child(
label()
.text(
"Downloads your chosen mods now. Minecraft itself is always \
downloaded the first time you play a version. Turn this off to \
leave the mods until then too.",
)
.font_size(11.)
.color(colors::fg_secondary()),
),
)
.child(toggle(predownload))
.into_element()
}
pub(crate) fn choice_row(
title: &str,
subtitle: &str,
@ -389,7 +351,7 @@ mod tests {
Route::OnboardingAccount {},
Route::OnboardingBundles {},
Route::OnboardingPreferences {},
Route::OnboardingDownloading {},
Route::OnboardingSummary {},
]);
routes

View file

@ -77,7 +77,7 @@ impl Component for OnboardingPreferences {
content,
onboarding_nav(
Some(Route::OnboardingBundles {}),
Route::OnboardingDownloading {},
Route::OnboardingSummary {},
true,
),
)

View file

@ -0,0 +1,479 @@
use freya::prelude::*;
use freya::router::RouterContext;
use oneclient_core::{BundleArchive, BundleFile, ImportTarget, MigrationSource, SentryExclusion};
use oneclient_db::models::OverrideType;
use crate::hooks::{
Actions, ClusterBundles, invalidate_cluster_queries, migration_detection,
onboarding_bundles_items, try_default_account, use_current_account, use_dispatch,
use_migration, use_onboarding_bundles, use_onboarding_selection, use_settings_snapshot,
};
use crate::routes::Route;
use crate::view::onboarding::{matching_new_cluster_id, pkg_key};
mod view;
use view::{SummaryView, summary_view};
#[derive(Clone, PartialEq)]
struct ClusterPlan {
cluster_id: i64,
/// `(bundle_name, package_id, override)` for every file whose fate differs from the manifest default
overrides: Vec<(String, String, OverrideType)>,
}
#[derive(PartialEq)]
pub struct OnboardingSummary;
impl Component for OnboardingSummary {
fn render(&self) -> impl IntoElement {
let dispatch = use_dispatch();
let bundles_query = use_onboarding_bundles();
let migration_query = use_migration();
let selection = use_onboarding_selection();
let settings = use_settings_snapshot().settings;
let account_query = use_current_account();
let selected_state = selection.selected;
let mut attempted = use_state(|| false);
let mut finishing = use_state(|| false);
let mut failure = use_state(|| None::<String>);
let loaded = onboarding_bundles_items(&bundles_query);
let ready = loaded.is_some();
let items = loaded.unwrap_or_default();
let selected = selected_state.read().clone();
let language = selection.language.read().clone();
let reduce_motion = *selection.reduce_motion.read();
let account_name = try_default_account(&account_query)
.map(|account| account.username.clone())
.unwrap_or_else(|| "Not signed in".to_string());
let import_dispatch = dispatch.clone();
let import_folder = selection.import_folder;
let import_dedicated = selection.import_dedicated;
let import_detection = migration_detection(&migration_query);
let migration_summary = import_detection.as_ref().map(|detection| {
let source_name = detection.source.display_name().to_string();
let is_vanilla = detection.source == MigrationSource::Vanilla;
match import_folder.read().clone() {
Some(folder) => {
let dedicated = !is_vanilla
&& *import_dedicated.read()
&& detection
.instances
.iter()
.find(|c| c.folder_name == folder)
.and_then(|inst| matching_new_cluster_id(inst, &items))
.is_some();
let target = if dedicated {
"This version only"
} else {
"Shared game directory"
};
let files = if is_vanilla {
"Your Minecraft folder".to_string()
} else {
folder
};
(source_name, files, target.to_string())
}
None => (source_name, "No files imported".to_string(), String::new()),
}
});
let finish_items = items.clone();
let finish_versions = seen_versions(&items);
let finish_dispatch = dispatch.clone();
let continue_versions = finish_versions.clone();
let continue_dispatch = dispatch;
summary_view(
SummaryView {
items: &items,
selected: &selected,
language: &language,
reduce_motion,
parallax: settings.dynamic_background_enabled,
account_name,
migration: migration_summary,
ready,
finishing: *finishing.read(),
failure: failure.read().clone(),
},
move |_| {
if *finishing.peek() {
return;
}
if !*attempted.peek() {
if let (Some(detection), Some(folder)) =
(import_detection.as_ref(), import_folder.peek().clone())
{
let target = if detection.source != MigrationSource::Vanilla
&& *import_dedicated.peek()
{
detection
.instances
.iter()
.find(|c| c.folder_name == folder)
.and_then(|inst| matching_new_cluster_id(inst, &finish_items))
.map(|new_cluster_id| ImportTarget::Dedicated { new_cluster_id })
.unwrap_or(ImportTarget::Shared)
} else {
ImportTarget::Shared
};
import_dispatch.import_launcher(detection.source, folder, target);
}
attempted.set(true);
}
failure.set(None);
finishing.set(true);
finish_setup(
build_plans(&finish_items, &selected_state.peek()),
finish_versions.clone(),
finish_dispatch.clone(),
finishing,
failure,
);
},
move |_| {
if *finishing.peek() {
return;
}
finishing.set(true);
// Leaves the manifest defaults in place the picks are already lost
finish_setup(
Vec::new(),
continue_versions.clone(),
continue_dispatch.clone(),
finishing,
failure,
);
},
)
.into_element()
}
}
fn build_plans(
items: &[ClusterBundles],
selected: &std::collections::HashSet<String>,
) -> Vec<ClusterPlan> {
items
.iter()
.map(|cb| {
let kept = kept_package_ids(cb.cluster.id, &cb.archives, selected);
let mut overrides = Vec::new();
for archive in &cb.archives {
overrides.extend(archive_overrides(cb.cluster.id, archive, selected, &kept));
}
ClusterPlan {
cluster_id: cb.cluster.id,
overrides,
}
})
.collect()
}
fn seen_versions(items: &[ClusterBundles]) -> Vec<String> {
let mut versions: Vec<String> = items
.iter()
.map(|cb| cb.cluster.mc_version.clone())
.collect();
versions.sort();
versions.dedup();
versions
}
/// True once anything visible in the bundle was taken including one opted-in extra that would otherwise lose its dependencies
fn bundle_taken(
cluster_id: i64,
archive: &BundleArchive,
selected: &std::collections::HashSet<String>,
) -> bool {
archive.manifest.files.iter().any(|file| {
!file.hidden
&& selected.contains(&pkg_key(
cluster_id,
&archive.manifest.name,
&file.kind.package_id(),
))
})
}
/// Bundles overlap deciding each archive alone recorded a removal for a package an accepted bundle was about to install
fn kept_package_ids(
cluster_id: i64,
archives: &[BundleArchive],
selected: &std::collections::HashSet<String>,
) -> std::collections::HashSet<String> {
let mut kept = std::collections::HashSet::new();
for archive in archives {
let takes_hidden = bundle_taken(cluster_id, archive, selected);
for file in &archive.manifest.files {
let package_id = file.kind.package_id();
let wanted = if file.hidden {
takes_hidden
} else {
selected.contains(&pkg_key(cluster_id, &archive.manifest.name, &package_id))
};
if wanted {
kept.insert(package_id);
}
}
}
kept
}
fn archive_overrides(
cluster_id: i64,
archive: &BundleArchive,
selected: &std::collections::HashSet<String>,
kept_elsewhere: &std::collections::HashSet<String>,
) -> Vec<(String, String, OverrideType)> {
let bundle_name = &archive.manifest.name;
let wants = |file: &BundleFile| {
selected.contains(&pkg_key(cluster_id, bundle_name, &file.kind.package_id()))
};
// Hidden files follow the bundle kept if anything from it was taken dropped if not
let takes_hidden = bundle_taken(cluster_id, archive, selected);
let mut overrides = Vec::new();
for file in &archive.manifest.files {
let wanted = if file.hidden {
takes_hidden
} else {
wants(file)
};
let override_type = match (wanted, file.enabled) {
// Matches the manifest default nothing to record
(true, true) | (false, false) => continue,
// Opting into a mod the bundle ships turned off
(true, false) => OverrideType::Enabled,
// Only really declined if no bundle the user took ships the same package
(false, true) => {
if kept_elsewhere.contains(&file.kind.package_id()) {
continue;
}
OverrideType::Removed
}
};
overrides.push((bundle_name.clone(), file.kind.package_id(), override_type));
}
overrides
}
/// The bundle picks are written nowhere else so a failure has to stop the run rather than drop them silently
fn finish_setup(
plans: Vec<ClusterPlan>,
versions: Vec<String>,
dispatch: Actions,
mut finishing: State<bool>,
mut failure: State<Option<String>>,
) {
spawn(async move {
for plan in plans.iter().filter(|p| !p.overrides.is_empty()) {
if let Err(err) = apply_overrides(plan).await {
// Expected failures (a busy or unwritable database) are not crashes warn! is a breadcrumb error! reports to Sentry
if err.is_sentry_excluded() {
tracing::warn!(
cluster_id = plan.cluster_id,
"saving package choices failed (expected): {err}"
);
} else {
tracing::error!(
cluster_id = plan.cluster_id,
"saving package choices failed: {err}"
);
}
failure.set(Some(err.to_string()));
finishing.set(false);
return;
}
}
invalidate_cluster_queries().await;
for version in versions {
dispatch.record_seen_version(version);
}
dispatch.mark_onboarding_seen();
let _ = RouterContext::get().replace(Route::Home {});
});
}
async fn apply_overrides(plan: &ClusterPlan) -> oneclient_core::LauncherResult<()> {
let state = crate::launcher::state()?;
oneclient_core::set_bundle_package_overrides(
plan.cluster_id,
&plan.overrides,
&state.services.content(),
)
.await?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::view::onboarding::test_support::{archive, cluster, file};
use std::collections::HashSet;
fn skyblock() -> BundleArchive {
archive(
"SkyBlock",
false,
vec![
file("skyblock-main", true, false),
file("skycubed", false, false),
file("sb-dep", true, true),
],
)
}
fn items(archives: Vec<BundleArchive>) -> Vec<ClusterBundles> {
vec![ClusterBundles {
cluster: cluster(1),
archives,
}]
}
fn keys(archive: &BundleArchive, pids: &[&str]) -> HashSet<String> {
pids.iter()
.map(|p| pkg_key(1, &archive.manifest.name, p))
.collect()
}
#[test]
fn declining_a_bundle_removes_its_visible_and_hidden_files() {
let sb = skyblock();
let plans = build_plans(&items(vec![sb.clone()]), &HashSet::new());
let ov = &plans[0].overrides;
// The hidden dependency must be dropped too or it installs anyway
assert!(ov.contains(&(
sb.manifest.name.clone(),
"sb-dep".to_string(),
OverrideType::Removed
)));
assert!(ov.contains(&(
sb.manifest.name.clone(),
"skyblock-main".to_string(),
OverrideType::Removed
)));
assert!(!ov.iter().any(|(_, pid, _)| pid == "skycubed"));
}
#[test]
fn accepting_a_bundle_records_nothing_for_its_defaults() {
let sb = skyblock();
let selected = keys(&sb, &["skyblock-main"]);
let plans = build_plans(&items(vec![sb.clone()]), &selected);
assert!(plans[0].overrides.is_empty());
}
#[test]
fn opting_into_an_extra_writes_enabled() {
let sb = skyblock();
let selected = keys(&sb, &["skyblock-main", "skycubed"]);
let plans = build_plans(&items(vec![sb.clone()]), &selected);
assert_eq!(
plans[0].overrides,
vec![(
sb.manifest.name.clone(),
"skycubed".to_string(),
OverrideType::Enabled
)]
);
}
#[test]
fn an_extra_alone_still_keeps_hidden_dependencies() {
let sb = skyblock();
let selected = keys(&sb, &["skycubed"]);
let plans = build_plans(&items(vec![sb.clone()]), &selected);
let ov = &plans[0].overrides;
assert!(ov.contains(&(
sb.manifest.name.clone(),
"skycubed".to_string(),
OverrideType::Enabled
)));
assert!(!ov.iter().any(|(_, pid, _)| pid == "sb-dep"));
assert!(ov.contains(&(
sb.manifest.name.clone(),
"skyblock-main".to_string(),
OverrideType::Removed
)));
}
#[test]
fn rejections_are_removed_never_disabled() {
let plans = build_plans(&items(vec![skyblock()]), &HashSet::new());
assert!(
!plans[0]
.overrides
.iter()
.any(|(_, _, ty)| *ty == OverrideType::Disabled)
);
}
#[test]
fn per_cluster_choices_are_independent() {
let sb = skyblock();
let both = vec![
ClusterBundles {
cluster: cluster(1),
archives: vec![sb.clone()],
},
ClusterBundles {
cluster: cluster(2),
archives: vec![sb.clone()],
},
];
let selected: HashSet<String> = [pkg_key(1, &sb.manifest.name, "skyblock-main")].into();
let plans = build_plans(&both, &selected);
assert!(plans[0].overrides.is_empty());
assert!(
plans[1]
.overrides
.iter()
.any(|(_, pid, ty)| pid == "skyblock-main" && *ty == OverrideType::Removed)
);
}
#[test]
fn every_version_is_recorded_once() {
let mut second = cluster(2);
second.mc_version = "1.20.1".to_string();
let items = vec![
ClusterBundles {
cluster: cluster(1),
archives: Vec::new(),
},
ClusterBundles {
cluster: second,
archives: Vec::new(),
},
ClusterBundles {
cluster: cluster(3),
archives: Vec::new(),
},
];
assert_eq!(seen_versions(&items), vec!["1.20.1", "1.21.11"]);
}
}

View file

@ -1,5 +1,4 @@
use super::*;
use freya::prelude::*;
use freya::router::RouterContext;
use crate::components::{ART_PREVIEW_EDGE, Button, DynamicArt, Icon, IconType, ScrollArea};
@ -7,22 +6,40 @@ use crate::hooks::ClusterBundles;
use crate::routes::Route;
use crate::theme::colors;
use crate::ui::border_all_color;
use crate::utils::format_size;
use crate::view::onboarding::{onboarding_illustration, predownload_toggle_row, step_heading};
use crate::view::onboarding::{onboarding_illustration, onboarding_slide, step_heading};
pub(super) struct SummaryView<'a> {
pub items: &'a [ClusterBundles],
pub selected: &'a std::collections::HashSet<String>,
pub language: &'a str,
pub reduce_motion: bool,
pub parallax: bool,
pub account_name: String,
pub migration: Option<(String, String, String)>,
pub ready: bool,
/// A run is in flight both buttons stay down until it settles
pub finishing: bool,
pub failure: Option<String>,
}
#[allow(clippy::too_many_arguments)]
pub(super) fn summary_view(
items: &[ClusterBundles],
selected: &std::collections::HashSet<String>,
versions: &[VersionMetadata],
language: &str,
reduce_motion: bool,
parallax: bool,
account_name: String,
migration: Option<(String, String, String)>,
predownload: State<bool>,
on_setup: impl FnMut(Event<PressEventData>) + 'static,
view: SummaryView<'_>,
on_finish: impl FnMut(Event<PressEventData>) + 'static,
on_continue: impl FnMut(Event<PressEventData>) + 'static,
) -> impl IntoElement {
let SummaryView {
items,
selected,
language,
reduce_motion,
parallax,
account_name,
migration,
ready,
finishing,
failure,
} = view;
let version_rows: Vec<Element> = items
.iter()
.map(|cb| {
@ -35,32 +52,6 @@ pub(super) fn summary_view(
})
.collect();
let ready_now = items
.iter()
.filter(|cb| cluster_predownloads(versions, &cb.cluster))
.count();
let packages_note = if *predownload.read() && ready_now > 0 {
let est = rough_download_estimate(items, selected, versions);
let (scope, rest) = if ready_now == items.len() {
("Your selected content is".to_string(), "")
} else {
(
format!(
"Content for {ready_now} version{} is",
if ready_now == 1 { "" } else { "s" }
),
" Everything else is downloaded the first time you launch it.",
)
};
format!(
"{scope} downloaded now, during setup. About {} total.{rest}",
format_size(est)
)
} else {
"Content is downloaded the first time you launch each version.".to_string()
};
let migration_section: Element = match migration {
Some((source, folder, target)) => {
let mut rows = vec![
@ -75,6 +66,8 @@ pub(super) fn summary_view(
None => rect().into_element(),
};
let failed = failure.is_some();
let content = rect()
.vertical()
.width(Size::fill())
@ -109,26 +102,26 @@ pub(super) fn summary_view(
))
.child(summary_section(
"Packages per version",
Some(packages_note.as_str()),
Some("Content is downloaded the first time you launch each version."),
if version_rows.is_empty() {
vec![summary_line("No versions", "")]
} else {
version_rows
},
))
.child(predownload_toggle_row(predownload)),
);
)),
)
.maybe_child(failure.map(|reason| failure_notice(&reason)));
rect()
.vertical()
.width(Size::fill())
.height(Size::fill())
.content(Content::Flex)
.child(
.child(onboarding_slide(
rect()
.horizontal()
.width(Size::fill())
.height(Size::flex(1.0))
.height(Size::fill())
.content(Content::Flex)
.child(
rect()
@ -147,7 +140,7 @@ pub(super) fn summary_view(
.padding(Gaps::new(48., 80., 24., 24.))
.child(content),
),
)
))
.child(
rect()
.horizontal()
@ -156,27 +149,66 @@ pub(super) fn summary_view(
.cross_align(Alignment::Center)
.spacing(12.)
.padding(Gaps::new(0., 40., 32., 40.))
.child(
.child(if failed {
Button::new()
.secondary()
.width(Size::px(180.))
.enabled(ready && !finishing)
.on_press(on_continue)
.text("Continue anyway")
.into_element()
} else {
Button::new()
.secondary()
.width(Size::px(128.))
.enabled(!finishing)
.on_press(move |_| {
let _ = RouterContext::get().replace(Route::OnboardingPreferences {});
})
.text("Back"),
)
.text("Back")
.into_element()
})
.child(
Button::new()
.primary()
.width(Size::px(160.))
.on_press(on_setup)
.text("Setup")
.child(Icon::new(IconType::Download01).size(16.)),
.enabled(ready && !finishing)
.on_press(on_finish)
.text(if failed { "Try again" } else { "Finish" })
.child(Icon::new(IconType::Check).size(16.)),
),
)
.child(prefetch_art(items))
}
fn failure_notice(reason: &str) -> Element {
rect()
.vertical()
.width(Size::fill())
.spacing(4.)
.padding(Gaps::new_all(14.))
.corner_radius(CornerRadius::new_all(12.))
.background(colors::danger().with_a(26))
.border(border_all_color(1., colors::danger().with_a(128)))
.child(
label()
.text("Your package choices could not be saved.")
.font_size(14.)
.font_weight(FontWeight::SEMI_BOLD)
.color(colors::fg_primary()),
)
.child(
label()
.text(format!(
"{}. Continuing anyway keeps the bundle defaults for any version whose choices were not saved.",
reason.trim_end_matches('.')
))
.font_size(11.)
.color(colors::fg_secondary()),
)
.into_element()
}
fn prefetch_art(items: &[ClusterBundles]) -> impl IntoElement {
rect()
.horizontal()

View file

@ -24,9 +24,6 @@ pub struct RemoteCluster {
pub long_description: Option<String>,
#[serde(default)]
pub tags: Vec<String>,
/// Default for every entry in this cluster an entry's own key wins
#[serde(default)]
pub predownload: Option<bool>,
#[serde(default)]
pub entries: Vec<RemoteEntry>,
}
@ -46,8 +43,6 @@ pub struct RemoteEntry {
pub long_description: Option<String>,
#[serde(default)]
pub tags: Option<Vec<String>>,
#[serde(default)]
pub predownload: Option<bool>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
@ -107,8 +102,6 @@ pub struct VersionMetadata {
pub art_url: Option<String>,
pub long_description: Option<String>,
pub tags: Vec<String>,
/// Fetched up front during onboarding rather than on first launch
pub predownload: bool,
}
impl VersionMetadata {
@ -151,7 +144,6 @@ impl VersionsManifest {
art_url: art_url(&cluster.art, meta_url_base),
long_description: cluster.long_description.clone(),
tags: cluster.tags.clone(),
predownload: cluster.predownload.unwrap_or(false),
});
for entry in &cluster.entries {
@ -168,7 +160,6 @@ impl VersionsManifest {
.clone()
.or_else(|| cluster.long_description.clone()),
tags: entry.tags.clone().unwrap_or_else(|| cluster.tags.clone()),
predownload: entry.predownload.or(cluster.predownload).unwrap_or(false),
});
}
}
@ -215,39 +206,6 @@ mod tests {
assert_eq!(entry.key(), Some((1, Some(2))));
}
#[test]
fn predownload_inherits_from_cluster_and_entry_overrides() {
let manifest: VersionsManifest = serde_json::from_str(
r#"{"clusters":[
{"major_version":26,"predownload":true,"entries":[
{"minor_version":1},
{"minor_version":2,"predownload":false}
]},
{"major_version":21,"entries":[
{"minor_version":1},
{"minor_version":11,"predownload":true}
]}
]}"#,
)
.expect("should parse");
let metadata = manifest.metadata("https://example.test");
let flag = |major: u32, minor: Option<u32>| {
metadata
.iter()
.find(|m| m.major_version == major && m.minor_version == minor)
.expect("row present")
.predownload
};
assert!(flag(26, None));
assert!(flag(26, Some(1)));
assert!(!flag(26, Some(2)));
assert!(flag(21, Some(11)));
assert!(!flag(21, None));
assert!(!flag(21, Some(1)));
}
#[test]
fn mc_version_skips_the_cluster_row() {
let manifest: VersionsManifest = serde_json::from_str(