adds tips and parallax background to onboarding

update client id for MSA + fix account already logged in issues
This commit is contained in:
Julian Chang 2026-07-06 02:05:15 -04:00
parent 2bbecb6e86
commit c27688c8d4
No known key found for this signature in database
11 changed files with 299 additions and 103 deletions

View file

@ -19,6 +19,7 @@ pub use queries::{
ClusterAction, invalidate_cluster_queries, invalidate_profile_queries, use_cluster_mutation,
AddOfflineAccountKeys, RefreshAccountKeys, RemoveAccountKeys, SetDefaultAccountKeys,
UseRefreshAccount, UseRemoveAccount, UseSetDefaultAccount, accounts_have_microsoft,
login_code_already_handled,
mutation_error, mutation_is_pending, pick_version_metadata, try_account, try_accounts,
try_default_account, try_game_profile, use_account, use_accounts, use_add_microsoft_account,
use_add_offline_account, use_begin_microsoft_login, use_bundle_updates,

View file

@ -1,4 +1,6 @@
use std::sync::Mutex;
use freya::query::{
Mutation, MutationCapability, MutationStateData, QueriesStorage, Query, QueryCapability,
QueryStateData, UseMutation, UseQuery, use_mutation, use_query,
@ -7,6 +9,17 @@ use oneclient_core::LauncherError;
use oneclient_core::auth::{self, AccountKind, MinecraftAccount, MinecraftLogin};
use uuid::Uuid;
static HANDLED_LOGIN_CODE: Mutex<Option<String>> = Mutex::new(None);
pub fn login_code_already_handled(user_code: &str) -> bool {
let mut handled = HANDLED_LOGIN_CODE.lock().unwrap();
if handled.as_deref() == Some(user_code) {
return true;
}
*handled = Some(user_code.to_string());
false
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub struct ListAccountsKeys;

View file

@ -57,6 +57,7 @@ pub use versions::{use_versions, use_loader_versions, loader_versions};
pub use auth::{
AddOfflineAccountKeys, RefreshAccountKeys, RemoveAccountKeys, SetDefaultAccountKeys,
UseRefreshAccount, UseRemoveAccount, UseSetDefaultAccount, accounts_have_microsoft,
login_code_already_handled,
mutation_error, mutation_is_pending, try_account, try_accounts, try_default_account,
use_account, use_accounts, use_add_microsoft_account, use_add_offline_account,
use_begin_microsoft_login, use_current_account, use_default_account,

View file

@ -492,7 +492,7 @@ half4 main(float2 fragCoord) {
}
"#;
fn gradient_overlay_radial() -> impl IntoElement {
pub(crate) 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

@ -7,6 +7,7 @@ mod settings_shell;
pub use animated_outlet::AnimatedAppOutlet;
pub use app_shell::{AppShell, HOME_BACKGROUND_ASSET};
pub(crate) use app_shell::gradient_overlay_radial;
pub use cluster_shell::{ClusterShell, cluster_content};
pub use onboarding_shell::OnboardingShell;
pub use root_layout::RootLayout;

View file

@ -1,5 +1,3 @@
use std::sync::Mutex;
use chrono::Utc;
use freya::prelude::*;
use freya::query::{MutationCapability, MutationStateData, UseMutation};
@ -11,15 +9,14 @@ use crate::components::{
};
use crate::hooks::{
AddOfflineAccountKeys, RefreshAccountKeys, RemoveAccountKeys, SetDefaultAccountKeys,
accounts_have_microsoft, try_accounts, try_default_account, use_accounts,
use_add_offline_account, use_begin_microsoft_login, use_current_account,
accounts_have_microsoft, login_code_already_handled, try_accounts, try_default_account,
use_accounts, use_add_offline_account, use_begin_microsoft_login, use_current_account,
use_finish_microsoft_login, use_refresh_account, use_remove_account, use_set_default_account,
};
use crate::platform;
use crate::theme::colors;
use crate::ui::border_all_color;
static HANDLED_LOGIN_CODE: Mutex<Option<String>> = Mutex::new(None);
#[derive(PartialEq)]
pub struct Accounts;
@ -48,12 +45,8 @@ impl Component for Accounts {
_ => None,
};
let Some(login) = login else { return };
{
let mut handled = HANDLED_LOGIN_CODE.lock().unwrap();
if handled.as_deref() == Some(login.user_code.as_str()) {
return;
}
*handled = Some(login.user_code.clone());
if login_code_already_handled(&login.user_code) {
return;
}
handled_code.set(Some(login.user_code.clone()));
finish.mutate(login.clone());

View file

@ -1,5 +1,3 @@
use std::sync::Mutex;
use freya::prelude::*;
use freya::query::{MutationCapability, MutationStateData, UseMutation};
use freya::text_edit::Clipboard;
@ -7,8 +5,8 @@ use oneclient_core::auth::{MinecraftAccount, MinecraftLogin};
use crate::components::{Avatar, Button, Icon, IconType, OverlayPopup};
use crate::hooks::{
try_default_account, use_begin_microsoft_login, use_current_account,
use_finish_microsoft_login,
login_code_already_handled, try_default_account, use_begin_microsoft_login,
use_current_account, use_finish_microsoft_login,
};
use crate::platform;
use crate::routes::Route;
@ -18,8 +16,6 @@ use crate::view::onboarding::{
onboarding_illustration, onboarding_nav, onboarding_page, step_heading,
};
static HANDLED_LOGIN_CODE: Mutex<Option<String>> = Mutex::new(None);
#[derive(PartialEq)]
pub struct OnboardingAccount;
@ -38,12 +34,8 @@ impl Component for OnboardingAccount {
_ => None,
};
let Some(login) = login else { return };
{
let mut handled = HANDLED_LOGIN_CODE.lock().unwrap();
if handled.as_deref() == Some(login.user_code.as_str()) {
return;
}
*handled = Some(login.user_code.clone());
if login_code_already_handled(&login.user_code) {
return;
}
finish.mutate(login.clone());
pending_login.set(Some(login));

View file

@ -7,10 +7,18 @@ use std::time::Duration;
use oneclient_core::clusters::Cluster;
use crate::components::DynamicArt;
use crate::hooks::use_settings_snapshot;
use crate::layout::gradient_overlay_radial;
use crate::theme::colors;
const BACKDROP_INTERVAL_SECS: u64 = 12;
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>,
@ -23,7 +31,7 @@ impl Component for LoadingBackdrop {
let mut index = use_state(|| 0usize);
let stop = use_hook(|| Arc::new(AtomicBool::new(false)));
use_drop({
let stop = stop.clone();
move || stop.store(true, Ordering::Relaxed)
@ -53,6 +61,52 @@ impl Component for LoadingBackdrop {
let current = *index.read() % 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).max_edge(1280),
None => DynamicArt::fallback().max_edge(1280),
};
let fade = use_animation_with_dependencies(&current, |conf, _| {
conf.on_creation(OnCreation::Run);
conf.on_change(OnChange::Rerun);
@ -62,26 +116,6 @@ impl Component for LoadingBackdrop {
.function(Function::Cubic)
});
let art: Element = match clusters.get(current) {
Some(cluster) => rect()
.width(Size::fill())
.height(Size::fill())
.opacity(fade.get().value())
.child(
rect()
.width(Size::fill())
.height(Size::fill())
.child(DynamicArt::for_cluster(cluster).max_edge(1280)),
)
.into_element(),
None => rect()
.width(Size::fill())
.height(Size::fill())
.background(colors::page())
.into_element(),
};
rect()
.position(Position::new_absolute().top(0.).left(0.))
.width(Size::fill())
@ -89,41 +123,44 @@ impl Component for LoadingBackdrop {
.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())
.position(Position::new_absolute().top(0.).left(0.))
.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())
.position(Position::new_absolute().top(0.).left(0.))
.layer(Layer::Relative(8))
.background(
LinearGradient::new()
.angle(270.)
.stop((Color::BLACK.with_a(235), 0.))
.stop((Color::BLACK.with_a(235), 32.))
.stop((Color::BLACK.with_a(120), 48.))
.stop((Color::BLACK.with_a(10), 100.)),
),
)
.child(
rect()
.width(Size::fill())
.height(Size::fill())
.position(Position::new_absolute().top(0.).left(0.))
.layer(Layer::Relative(8))
.background(
LinearGradient::new()
.angle(0.)
.stop((Color::BLACK.with_a(0), 55.))
.stop((Color::BLACK.with_a(160), 100.)),
),
.layer(Layer::Relative(1))
.child(gradient_overlay_radial()),
)
}
}

View file

@ -21,9 +21,11 @@ use crate::theme::colors;
mod backdrop;
mod progress;
mod summary;
mod tips;
pub use backdrop::LoadingBackdrop;
use progress::{ProgressView, failure_panel, progress_panel};
use summary::summary_view;
use tips::{tip_panel, use_onboarding_tip};
const FADE_DURATION_MS: u64 = 700;
const MAX_TASK_ROWS: usize = 6;
@ -154,6 +156,14 @@ struct DownloadStage {
label: String,
}
enum InstallUiEvent {
Progress(usize, usize),
Stage(DownloadStage),
Activity(String),
TotalEstimate(u64),
Finished(Vec<InstallFailure>),
}
#[derive(Clone, Copy)]
struct InstallHandles {
progress: State<(usize, usize)>,
@ -257,7 +267,7 @@ impl Component for OnboardingDownloading {
let heading = if has_failures && is_complete {
"Download issues found"
} else if predownload {
"Downloading your content"
"Downloading..."
} else {
"Finishing up..."
};
@ -277,6 +287,8 @@ impl Component for OnboardingDownloading {
1.0
};
let tip = use_onboarding_tip();
if !*confirmed.read() {
let items = onboarding_bundles_items(&bundles_query).unwrap_or_default();
let selected = selected_state.read().clone();
@ -365,7 +377,8 @@ impl Component for OnboardingDownloading {
predownload,
running: is_running,
}))
.child(rect().width(Size::flex(1.0))),
.child(rect().width(Size::flex(1.0)))
.child(tip_panel(tip)),
)
.into_element()
}
@ -455,6 +468,11 @@ fn run_install_batch(plans: Vec<ClusterPlan>, predownload: bool, handles: Instal
let total = plans.len();
progress.set((0, total));
let (notif_tx, mut notif_rx) = mpsc::unbounded_channel::<Notification>();
let notifier = NotificationService::new(notif_tx);
let (ui_tx, mut ui_rx) = mpsc::unbounded_channel::<InstallUiEvent>();
spawn(async move {
let mut prev = 0u64;
let mut speed = 0f64;
@ -482,23 +500,52 @@ fn run_install_batch(plans: Vec<ClusterPlan>, predownload: bool, handles: Instal
});
spawn(async move {
let mut failed = Vec::new();
let (tx, mut rx) = mpsc::unbounded_channel::<Notification>();
let notifier = NotificationService::new(tx);
spawn(async move {
while let Some(notification) = rx.recv().await {
let mut local = GroupedAgg::default();
loop {
let Some(first) = notif_rx.recv().await else {
break;
};
if let Notification::GroupedProgress(event) = first {
apply_grouped(&mut local, event);
}
// Drain everything already queued in one pass.
while let Ok(notification) = notif_rx.try_recv() {
if let Notification::GroupedProgress(event) = notification {
let mut next = agg.peek().clone();
apply_grouped(&mut next, event);
agg.set(next);
apply_grouped(&mut local, event);
}
}
});
agg.set(local.clone());
tokio::time::sleep(Duration::from_millis(50)).await;
}
});
spawn(async move {
while let Some(event) = ui_rx.recv().await {
match event {
InstallUiEvent::Progress(done, total) => progress.set((done, total)),
InstallUiEvent::Stage(next) => stage.set(next),
InstallUiEvent::Activity(text) => activity.set(text),
InstallUiEvent::TotalEstimate(bytes) => total_estimate.set(bytes),
InstallUiEvent::Finished(failed) => {
agg.set(GroupedAgg::default());
activity.set("Wrapping up...".to_string());
invalidate_cluster_queries().await;
failures.set(failed);
running.set(false);
complete.set(true);
break;
}
}
}
});
tokio::spawn(async move {
let mut failed = Vec::new();
if predownload {
activity.set("Calculating download size...".to_string());
let _ = ui_tx.send(InstallUiEvent::Activity(
"Calculating download size...".to_string(),
));
if let Ok(state) = oneclient_core::LauncherState::get() {
let mut sum = 0u64;
for plan in &plans {
@ -515,19 +562,19 @@ fn run_install_batch(plans: Vec<ClusterPlan>, predownload: bool, handles: Instal
"download size estimate failed: {err}"
),
}
total_estimate.set(sum);
let _ = ui_tx.send(InstallUiEvent::TotalEstimate(sum));
}
}
}
for (index, plan) in plans.into_iter().enumerate() {
stage.set(DownloadStage {
let _ = ui_tx.send(InstallUiEvent::Stage(DownloadStage {
index,
total,
label: plan.mc_version.clone(),
});
}));
if let Err(reason) = install_one(&plan, predownload, &notifier, activity).await {
if let Err(reason) = install_one(&plan, predownload, &notifier, &ui_tx).await {
tracing::error!(
cluster_id = plan.cluster_id,
mc_version = %plan.mc_version,
@ -535,17 +582,11 @@ fn run_install_batch(plans: Vec<ClusterPlan>, predownload: bool, handles: Instal
);
failed.push(InstallFailure { plan, reason });
}
progress.set((index + 1, total));
let _ = ui_tx.send(InstallUiEvent::Progress(index + 1, total));
}
drop(notifier);
agg.set(GroupedAgg::default());
activity.set("Wrapping up...".to_string());
invalidate_cluster_queries().await;
failures.set(failed);
running.set(false);
complete.set(true);
let _ = ui_tx.send(InstallUiEvent::Finished(failed));
});
}
@ -553,12 +594,14 @@ async fn install_one(
plan: &ClusterPlan,
predownload: bool,
notifier: &NotificationService,
mut activity: State<String>,
ui_tx: &mpsc::UnboundedSender<InstallUiEvent>,
) -> Result<(), String> {
let state = oneclient_core::LauncherState::get().map_err(|err| err.to_string())?;
if !plan.overrides.is_empty() {
activity.set("Saving your package choices...".to_string());
let _ = ui_tx.send(InstallUiEvent::Activity(
"Saving your package choices...".to_string(),
));
}
for (bundle_name, package_id) in &plan.overrides {
oneclient_core::set_bundle_package_enabled(
@ -579,7 +622,10 @@ async fn install_one(
let session =
GroupedProgressSession::start(notifier, format!("Downloading {}", plan.mc_version));
activity.set(format!("Downloading Minecraft {}...", plan.mc_version));
let _ = ui_tx.send(InstallUiEvent::Activity(format!(
"Downloading Minecraft {}...",
plan.mc_version
)));
let prepared = oneclient_core::ClusterManager::prepare(
&state,
@ -592,10 +638,10 @@ async fn install_one(
.await;
let bundles_result = if prepared.is_ok() {
activity.set(format!(
let _ = ui_tx.send(InstallUiEvent::Activity(format!(
"Installing mods & content for {}...",
plan.mc_version
));
)));
oneclient_core::install_cluster_bundles(
plan.cluster_id,
state.bundles.as_ref(),

View file

@ -0,0 +1,113 @@
use super::*;
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://raw.githubusercontent.com/Polyfrost/DataStorage/main/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()
}
/// Loads onboarding tips (remote with backup fallback) and returns a single
/// stable random tip for the lifetime of the component.
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) = oneclient_core::LauncherState::get() 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}");
}
}
});
});
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 list = tips.read();
if list.is_empty() {
String::new()
} else {
list[seed % 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,8 +1,7 @@
pub const DUMMY_REPLACE_NEWLINE: &str = "\n";
// pub const MICROSOFT_CLIENT_ID: &str = "9eac3a4e-8cdd-43ef-863e-49cd601b1f03";
pub const MICROSOFT_CLIENT_ID: &str = "c36a9fb6-4f2a-41ff-90bd-ae7cc92031eb";
pub const MICROSOFT_CLIENT_ID: &str = "9419b7ee-1448-4d1b-b52a-550d8f36ab56";
pub const MINECRAFT_SCOPES: &str = "XboxLive.SignIn XboxLive.offline_access";
pub const CURSEFORGE_API_KEY: &str = "$2a$10$6utA1UNSmFPrE/Lh7b7ndeeGmiOkjKNY8kpFB0fsmE/d42ZAfFgCe";
pub const DISCORD_CLIENT_ID: &str = "1426999264633946334";