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:
parent
b3b436bc3e
commit
695406145f
16 changed files with 580 additions and 2016 deletions
|
|
@ -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>,
|
||||
|
|
|
|||
|
|
@ -77,7 +77,6 @@ mod tests {
|
|||
art_url: None,
|
||||
long_description: None,
|
||||
tags: Vec::new(),
|
||||
predownload: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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 {}),
|
||||
|
|
|
|||
|
|
@ -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(¶llax_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(¤t, |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
|
|
@ -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()),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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()),
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -77,7 +77,7 @@ impl Component for OnboardingPreferences {
|
|||
content,
|
||||
onboarding_nav(
|
||||
Some(Route::OnboardingBundles {}),
|
||||
Route::OnboardingDownloading {},
|
||||
Route::OnboardingSummary {},
|
||||
true,
|
||||
),
|
||||
)
|
||||
|
|
|
|||
479
packages/oneclient_app/src/view/onboarding/summary/mod.rs
Normal file
479
packages/oneclient_app/src/view/onboarding/summary/mod.rs
Normal 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"]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue