diff --git a/Cargo.lock b/Cargo.lock index f615c33c..4bbfb520 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3116,6 +3116,16 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "junction" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "160f2eade097f30263b548aae5deb12ad349c909baa710fa24b92c9090b2e006" +dependencies = [ + "scopeguard", + "windows-sys 0.61.2", +] + [[package]] name = "keyboard-types" version = "0.8.3" @@ -4133,6 +4143,7 @@ dependencies = [ "oneclient_core", "oneclient_db", "oneclient_macro", + "open", "polyio", "regex", "rfd", @@ -4142,7 +4153,6 @@ dependencies = [ "tracing", "tracing-subscriber", "uuid", - "windows-sys 0.60.2", ] [[package]] @@ -4216,9 +4226,9 @@ dependencies = [ [[package]] name = "open" -version = "5.3.6" +version = "5.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd8d3b65c44123a56e0133d2cd06ce4361bd3ca99d41198b2f25e3c3db9b8b4a" +checksum = "a0b3d059e795d52b8a72fef45658620edd4d9c359b338564aa14391ffa511ed5" dependencies = [ "is-wsl", "libc", @@ -4455,6 +4465,7 @@ dependencies = [ "dunce", "futures-lite", "futures-util", + "junction", "sanitize-filename", "serde", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index df448dc6..fd4a4990 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -95,6 +95,7 @@ sanitize-filename = { version = "=0.6.0" } regex = { version = "=1.12.3" } parking_lot = { version = "=0.12.5" } dunce = { version = "=1.0.5" } +junction = { version = "=2.0.0" } bytes = { version = "=1.11.1" } indicatif = { version = "=0.18.4", features = [ "tokio" ]} sysinfo = { version = "=0.39.5" } diff --git a/packages/oneclient_app/Cargo.toml b/packages/oneclient_app/Cargo.toml index 1e02fad3..b7f12c5a 100644 --- a/packages/oneclient_app/Cargo.toml +++ b/packages/oneclient_app/Cargo.toml @@ -34,8 +34,8 @@ regex.workspace = true cargo-packager-updater = "=0.2.3" -# file dialog rfd = "=0.17.2" +open = "=5.4.0" # clipboard image copy + decode for it arboard.workspace = true @@ -47,9 +47,6 @@ freya.workspace = true [target.'cfg(target_os = "macos")'.dependencies] mimalloc = "=0.1.52" -[target.'cfg(target_os = "windows")'.dependencies] -windows-sys = { version = "0.60", features = ["Win32_UI_Shell", "Win32_UI_WindowsAndMessaging", "Win32_Foundation"] } - # Build then package (per OS): # cargo build --release -p oneclient_app # cargo packager --release -p oneclient_app --formats diff --git a/packages/oneclient_app/src/bridge/commands.rs b/packages/oneclient_app/src/bridge/commands.rs index 5bcc310f..5e64e9f6 100644 --- a/packages/oneclient_app/src/bridge/commands.rs +++ b/packages/oneclient_app/src/bridge/commands.rs @@ -6,7 +6,7 @@ use oneclient_core::packages::{ContentType, ProviderId}; use oneclient_core::settings::{GameSettingsProfile, LauncherSettings, ProfileUpdate}; use oneclient_db::models::ClusterId; -use crate::notifications::NotificationSpec; +use crate::notifications::{ClusterUpdateSummary, NotificationSpec}; #[derive(Debug)] pub enum BridgeCommand { @@ -54,12 +54,16 @@ pub enum BridgeCommand { ToggleNotificationCenter, CloseNotificationCenter, + ToggleAccountSwitcher, + CloseAccountSwitcher, ClearNotificationInbox, DismissToast(u64), BumpToast(u64), MarkNotificationRead(u64), DismissNotification(u64), AnswerPrompt(UserChoice), + OpenClusterUpdate(ClusterUpdateSummary), + CloseClusterUpdate, SendNotification { spec: NotificationSpec }, SendTestProgress { current: u64, diff --git a/packages/oneclient_app/src/bridge/runtime.rs b/packages/oneclient_app/src/bridge/runtime.rs index 7729bd0c..7f3f70b4 100644 --- a/packages/oneclient_app/src/bridge/runtime.rs +++ b/packages/oneclient_app/src/bridge/runtime.rs @@ -1,4 +1,4 @@ -use oneclient_core::notification::{NotificationLevel, Notification, NotificationService}; +use oneclient_core::notification::{Notification, NotificationLevel, NotificationService}; use oneclient_core::packages::PackageStore; use oneclient_core::settings::store::{ self, create_profile_from_global, create_settings_profile, delete_named_profile, @@ -7,8 +7,10 @@ use oneclient_core::settings::store::{ use oneclient_core::{ClusterManager, LauncherState}; use tokio::sync::{mpsc, watch}; +use crate::components::IconType; use crate::notifications::{ - InboxEntry, MESSAGE_TOAST_TTL, NotificationState, PendingPrompt, PendingPromptView, + ClusterUpdateSummary, InboxEntry, MESSAGE_TOAST_TTL, NotificationAction, NotificationActionKind, + NotificationSpec, NotificationState, PendingPrompt, PendingPromptView, }; use super::commands::BridgeCommand; @@ -147,6 +149,10 @@ impl CoreBridgeRuntime { apply_game_failed(snapshots, cluster_id, message); immediate = true; } + Notification::MicrosoftLoginStatus(status) => { + snapshots.microsoft_login = status; + immediate = true; + } other => { let (notif, _timers, prompt) = engine.dispatch(inbox, other); snapshots.notifications = notif; @@ -402,18 +408,24 @@ impl CoreBridgeRuntime { bump_clusters_generation(snapshots); } - BridgeCommand::ImportLauncher { source, folder_name, target } => { + BridgeCommand::ImportLauncher { + source, + folder_name, + target, + } => { let state = LauncherState::get()?; tokio::spawn(async move { let notifier = state.services.notifier.clone(); - match oneclient_core::import_migration_game_dir(source, &folder_name, target).await { + match oneclient_core::import_migration_game_dir(source, &folder_name, target) + .await + { Ok(()) => { notifier.send_info( "Import complete", &format!("Copied your files from {}.", source.display_name()), ); - notifier.invalidate_clusters(); + notifier.invalidate_clusters(); } Err(err) => { notifier.send_error("Import failed", &err.to_string()); @@ -432,11 +444,7 @@ impl CoreBridgeRuntime { ) .await { - Ok(runtime) => { - notifier.send_info( - "Java installed", - &format!("Java {} ({})", runtime.major, runtime.vendor), - ); + Ok(_) => { notifier.invalidate_java(); } Err(err) => { @@ -482,6 +490,12 @@ impl CoreBridgeRuntime { BridgeCommand::CloseNotificationCenter => { *notification_center_open = false; } + BridgeCommand::ToggleAccountSwitcher => { + snapshots.account_switcher_open = !snapshots.account_switcher_open; + } + BridgeCommand::CloseAccountSwitcher => { + snapshots.account_switcher_open = false; + } BridgeCommand::ClearNotificationInbox => { inbox.clear(); notification_engine.clear_inbox(); @@ -503,6 +517,13 @@ impl CoreBridgeRuntime { let _ = reply_tx.send(choice); } } + BridgeCommand::OpenClusterUpdate(summary) => { + notification_engine.open_cluster_update(summary); + *notification_center_open = false; + } + BridgeCommand::CloseClusterUpdate => { + notification_engine.close_cluster_update(); + } BridgeCommand::SendNotification { spec } => { notification_engine.push_custom(inbox, spec); } @@ -519,14 +540,11 @@ impl CoreBridgeRuntime { let state = LauncherState::get()?; tokio::spawn(async move { let notifier = state.services.notifier.clone(); - let account = match oneclient_core::auth::get_default_account().await { - Ok(Some(account)) => Some(account), - Ok(None) => oneclient_core::auth::list_accounts() - .await - .ok() - .and_then(|accounts| accounts.into_iter().next()), + // Renews a lapsed token before launching + let account = match oneclient_core::auth::default_account_for_launch().await { + Ok(account) => account, Err(err) => { - notifier.game_failed(cluster_id, err.to_string()); + notifier.game_failed(cluster_id, format!("{err:#}")); return; } }; @@ -591,7 +609,8 @@ impl CoreBridgeRuntime { version_id, } => { let state = LauncherState::get()?; - match install_package(&state, provider, &project_id, &version_id, cluster_id).await { + match install_package(&state, provider, &project_id, &version_id, cluster_id).await + { Ok(()) => { state .services @@ -624,24 +643,36 @@ impl CoreBridgeRuntime { } BridgeCommand::ApplyBundleUpdates { cluster_id } => { let state = LauncherState::get()?; - oneclient_core::apply_bundle_updates( + let result = oneclient_core::apply_bundle_updates( cluster_id, state.bundles.as_ref(), &state.services, ) .await?; bump_clusters_generation(snapshots); + if let Some(spec) = + cluster_update_notification(cluster_id, &result, &state.services).await + { + notification_engine.push_custom(inbox, spec); + } } BridgeCommand::SyncBundles => { let state = LauncherState::get()?; state.bundles.sync(&state.services).await?; oneclient_core::clusters::ensure_from_bundles(&state).await?; - oneclient_core::bundles::sync_all_cluster_bundles( + let changed = oneclient_core::bundles::sync_all_cluster_bundles( state.bundles.as_ref(), &state.services, ) .await; bump_clusters_generation(snapshots); + for (cluster_id, result) in &changed { + if let Some(spec) = + cluster_update_notification(*cluster_id, result, &state.services).await + { + notification_engine.push_custom(inbox, spec); + } + } } } @@ -649,6 +680,63 @@ impl CoreBridgeRuntime { } } +async fn cluster_update_notification( + cluster_id: i64, + result: &oneclient_core::ApplyBundleUpdatesResult, + services: &oneclient_core::LauncherServices, +) -> Option { + let updated: Vec = result + .updates_applied + .iter() + .map(|u| u.new_file.display_name()) + .collect(); + let added: Vec = result + .additions_applied + .iter() + .map(|a| a.new_file.display_name()) + .collect(); + let removed: Vec = result + .removals_applied + .iter() + .map(|r| r.package_id.clone()) + .collect(); + + if updated.is_empty() && added.is_empty() && removed.is_empty() { + return None; + } + + let cluster_name = PackageStore::get_cluster(cluster_id, services) + .await + .map(|c| c.name) + .unwrap_or_else(|_| "Cluster".to_string()); + + let summary = ClusterUpdateSummary { + cluster_id, + cluster_name: cluster_name.clone(), + updated, + added, + removed, + }; + + let total = summary.total(); + let body = format!( + "{total} package{} changed in {cluster_name}", + if total == 1 { "" } else { "s" } + ); + + Some(NotificationSpec { + title: "Cluster updated".to_string(), + body, + level: NotificationLevel::Info, + icon: Some(IconType::DownloadCloud02), + progress: None, + actions: vec![NotificationAction { + label: "View changes".to_string(), + kind: NotificationActionKind::OpenClusterUpdate(summary), + }], + }) +} + fn publish(tx: &watch::Sender, snapshots: &BridgeSnapshot) { let _ = tx.send(snapshots.clone()); } @@ -706,21 +794,35 @@ async fn install_package( cluster_id: i64, ) -> anyhow::Result<()> { let provider_impl = state.services.packages.get(provider)?; - let project = provider_impl.get_project(project_id, &state.services).await?; + let project = provider_impl + .get_project(project_id, &state.services) + .await?; let version = provider_impl .get_version(project_id, version_id, &state.services) .await?; - PackageStore::install_to_cluster( + let session = oneclient_core::notification::GroupedProgressSession::start( + &state.services.notifier, + format!("Installing {}", project.name), + ); + let size = version.primary_file().map(|f| f.size).unwrap_or(0); + let child = session.child(project.name.clone(), size); + + let result = PackageStore::install_to_cluster( provider, &project, &version, cluster_id, false, false, + Some(&child), &state.services, ) - .await?; + .await; + + child.finish(); + session.finish(); + result?; Ok(()) } @@ -736,7 +838,10 @@ fn apply_game_stage(snapshots: &mut BridgeSnapshot, cluster_id: i64, stage: Laun } fn apply_game_failed(snapshots: &mut BridgeSnapshot, cluster_id: i64, message: String) { - snapshots.game.stages.insert(cluster_id, LaunchStage::Exited); + snapshots + .game + .stages + .insert(cluster_id, LaunchStage::Exited); snapshots.game.error = Some(message); } diff --git a/packages/oneclient_app/src/bridge/snapshot.rs b/packages/oneclient_app/src/bridge/snapshot.rs index 38716959..baa7908f 100644 --- a/packages/oneclient_app/src/bridge/snapshot.rs +++ b/packages/oneclient_app/src/bridge/snapshot.rs @@ -1,7 +1,7 @@ use std::collections::HashMap; use std::sync::Arc; -use oneclient_core::notification::LaunchStage; +use oneclient_core::notification::{LaunchStage, MicrosoftLoginStatus}; use oneclient_core::settings::LauncherSettings; #[derive(Clone, Debug, Default, PartialEq)] @@ -98,4 +98,6 @@ pub struct BridgeSnapshot { pub java: JavaSnapshot, pub notifications: crate::notifications::NotificationSnapshot, pub game: GameSnapshot, + pub account_switcher_open: bool, + pub microsoft_login: Option, } diff --git a/packages/oneclient_app/src/components/account_switcher.rs b/packages/oneclient_app/src/components/account_switcher.rs new file mode 100644 index 00000000..5725d8af --- /dev/null +++ b/packages/oneclient_app/src/components/account_switcher.rs @@ -0,0 +1,257 @@ +use freya::{ + animation::{AnimNum, Ease, OnCreation, use_animation}, + prelude::*, + router::RouterContext, +}; +use oneclient_core::auth::MinecraftAccount; + +use crate::{ + Route, + components::{Avatar, Button, Icon, IconType, OverlayPopup}, + hooks::{ + SetDefaultAccountKeys, try_accounts, try_default_account, use_account_switcher_open, + use_accounts, use_current_account, use_dispatch, use_set_default_account, + }, + theme::colors, +}; + +#[derive(PartialEq)] +pub struct AccountSwitcher; + +impl Component for AccountSwitcher { + fn render(&self) -> impl IntoElement { + let open = use_account_switcher_open(); + let dispatch = use_dispatch(); + + if !open { + return rect().into_element(); + } + + OverlayPopup::new() + .position(Position::new_global().top(72.).right(40.)) + .on_close(move |()| dispatch.close_account_switcher()) + .child(AccountPanel) + .into_element() + } +} + +#[derive(PartialEq)] +struct AccountPanel; + +impl Component for AccountPanel { + fn render(&self) -> impl IntoElement { + let accounts_query = use_accounts(); + let current_query = use_current_account(); + + let default_account = try_default_account(¤t_query); + let default_id = default_account.as_ref().map(|account| account.id); + let accounts = try_accounts(&accounts_query).unwrap_or_default(); + + let intro = use_animation(|conf| { + conf.on_creation(OnCreation::Run); + AnimNum::new(0., 1.).time(200).ease(Ease::Out) + }); + let progress = intro.read().value(); + + // active account first, then the rest + let mut ordered: Vec = Vec::new(); + if let Some(account) = default_account.clone() { + ordered.push(account); + } + for account in accounts { + if Some(account.id) != default_id { + ordered.push(account); + } + } + + let mut rows = rect().vertical().width(Size::fill()).spacing(4.); + if ordered.is_empty() { + rows = rows.child( + label() + .text("No accounts yet") + .font_size(13.) + .color(colors::fg_secondary()), + ); + } else { + for account in ordered { + let id = account.id; + rows = rows.child( + AccountRow::new(account, Some(id) == default_id) + .key(id) + .into_element(), + ); + } + } + + rect() + .vertical() + .width(Size::px(300.)) + .padding(Gaps::new_all(12.)) + .spacing(8.) + .opacity(progress) + .margin(Gaps::new((1.0 - progress) * -8.0, 0., 0., 0.)) + .background(colors::page_elevated().with_a(220)) + .blur(12.) + .corner_radius(CornerRadius::new_all(12.)) + .border( + Border::new() + .fill(colors::component_border()) + .width(1.) + .alignment(BorderAlignment::Inner), + ) + .shadow(Shadow::from(( + 0., + 8., + 32., + 0., + Color::from_argb(120, 0, 0, 0), + ))) + .child( + label() + .text("Accounts") + .font_size(18.) + .font_weight(FontWeight::MEDIUM) + .a11y_role(AccessibilityRole::TitleBar), + ) + .child(divider()) + .child(rows) + .child(divider()) + .child(Footer) + } +} + +struct AccountRow { + account: MinecraftAccount, + active: bool, + key: DiffKey, +} + +impl PartialEq for AccountRow { + fn eq(&self, other: &Self) -> bool { + self.account.id == other.account.id + && self.account.username == other.account.username + && self.active == other.active + } +} + +impl KeyExt for AccountRow { + fn write_key(&mut self) -> &mut DiffKey { + &mut self.key + } +} + +impl AccountRow { + fn new(account: MinecraftAccount, active: bool) -> Self { + Self { + account, + active, + key: DiffKey::None, + } + } +} + +impl Component for AccountRow { + fn render(&self) -> impl IntoElement { + let id = self.account.id; + let username = self.account.username.clone(); + let active = self.active; + + let dispatch = use_dispatch(); + let set_default = use_set_default_account(); + let mut hovered = use_state(|| false); + + let switch = move |_| { + if !active { + set_default.mutate(SetDefaultAccountKeys { id: Some(id) }); + } + dispatch.close_account_switcher(); + }; + + rect() + .horizontal() + .width(Size::fill()) + .content(Content::Flex) + .cross_align(Alignment::Center) + .spacing(10.) + .padding(Gaps::new_all(8.)) + .corner_radius(CornerRadius::new_all(8.)) + .maybe(*hovered.read() && !active, |el| { + el.background(colors::ghost_overlay_hover()) + }) + .on_pointer_enter(move |_| { + hovered.set(true); + if !active { + Cursor::set(CursorIcon::Pointer); + } + }) + .on_pointer_leave(move |_| { + hovered.set(false); + Cursor::set(CursorIcon::default()); + }) + .on_press(switch) + .child( + Avatar::new(id.to_string()) + .width(Size::px(32.)) + .height(Size::px(32.)), + ) + .child( + rect() + .vertical() + .width(Size::flex(1.0)) + .child( + label() + .text(username) + .font_size(14.) + .font_weight(FontWeight::MEDIUM) + .max_lines(1) + .color(colors::fg_primary()), + ) + .maybe(active, |el| { + el.child( + label() + .text("Active") + .font_size(11.) + .color(colors::fg_secondary()), + ) + }), + ) + .maybe(active, |el| { + el.child( + Icon::new(IconType::CheckCircle) + .size(18.) + .color(colors::success()), + ) + }) + } +} + +fn divider() -> impl IntoElement { + rect() + .width(Size::fill()) + .height(Size::px(1.)) + .background(colors::component_border()) +} + +#[derive(PartialEq)] +struct Footer; + +impl Component for Footer { + fn render(&self) -> impl IntoElement { + let dispatch = use_dispatch(); + let open_accounts = move |_| { + dispatch.close_account_switcher(); + let _ = RouterContext::get().push(Route::Accounts {}); + }; + + Button::new() + .ghost() + .small() + .on_press(open_accounts) + .child( + Icon::new(IconType::Users01) + .size(16.) + .color(colors::fg_primary()), + ) + .text("Manage accounts") + } +} diff --git a/packages/oneclient_app/src/components/cluster_update_popup.rs b/packages/oneclient_app/src/components/cluster_update_popup.rs new file mode 100644 index 00000000..7880627c --- /dev/null +++ b/packages/oneclient_app/src/components/cluster_update_popup.rs @@ -0,0 +1,267 @@ +use freya::{prelude::*, router::RouterContext}; + +use crate::components::{Button, Icon, IconType, OverlayPopup, ScrollArea}; +use crate::hooks::{use_dispatch, use_notifications_snapshot}; +use crate::notifications::ClusterUpdateSummary; +use crate::routes::Route; +use crate::theme::colors; +use crate::ui::border_all_color; + +const CARD_BG: Color = Color::from_rgb(26, 34, 41); +const DIALOG_W: f32 = 680.; +const DIALOG_H: f32 = 400.; +const BANNER_W: f32 = 300.; + +#[derive(PartialEq)] +pub struct ClusterUpdatePopup; + +impl Component for ClusterUpdatePopup { + fn render(&self) -> impl IntoElement { + let snapshot = use_notifications_snapshot(); + let dispatch = use_dispatch(); + + let Some(summary) = snapshot.cluster_update.clone() else { + return rect().into_element(); + }; + + let close = dispatch.clone(); + + OverlayPopup::new() + .on_close(move |_| close.close_cluster_update()) + .child( + rect() + .width(Size::window_percent(100.)) + .height(Size::window_percent(100.)) + .center() + .child(dialog(&summary, dispatch)), + ) + .into_element() + } +} + +fn dialog(summary: &ClusterUpdateSummary, dispatch: crate::BridgeDispatch) -> impl IntoElement { + rect() + .horizontal() + .width(Size::px(DIALOG_W)) + .height(Size::px(DIALOG_H)) + .max_width(Size::window_percent(95.)) + .overflow(Overflow::Clip) + .corner_radius(CornerRadius::new_all(16.)) + .background(CARD_BG) + .border(border_all_color(1., colors::component_border())) + .shadow(Shadow::from((0., 18., 52., 0., Color::from_argb(150, 0, 0, 0)))) + .child(banner(summary)) + .child(content(summary, dispatch)) +} + +fn banner(summary: &ClusterUpdateSummary) -> impl IntoElement { + rect() + .vertical() + .width(Size::px(BANNER_W)) + .height(Size::fill()) + .overflow(Overflow::Clip) + .background(colors::brand().with_a(30)) + .main_align(Alignment::SpaceBetween) + // watermark + .child( + rect() + .width(Size::fill()) + .height(Size::flex(1.0)) + .center() + .child( + Icon::new(IconType::DownloadCloud02) + .size(150.) + .color(colors::brand().with_a(34)), + ), + ) + // seated title block + .child( + rect() + .vertical() + .width(Size::fill()) + .spacing(10.) + .padding(Gaps::new(24., 20., 20., 20.)) + .background(CARD_BG.with_a(170)) + .child( + rect() + .center() + .padding(Gaps::new_symmetric(4., 10.)) + .corner_radius(CornerRadius::new_all(999.)) + .background(colors::brand()) + .child( + rect() + .horizontal() + .cross_align(Alignment::Center) + .spacing(5.) + .child(Icon::new(IconType::RefreshCw01).size(12.).color(Color::WHITE)) + .child( + label() + .text("Updated") + .font_size(11.) + .font_weight(FontWeight::SEMI_BOLD) + .color(Color::WHITE), + ), + ), + ) + .child( + label() + .text(summary.cluster_name.clone()) + .font_size(23.) + .font_weight(FontWeight::BOLD) + .max_lines(2) + .color(colors::fg_primary()), + ), + ) +} + +fn content(summary: &ClusterUpdateSummary, dispatch: crate::BridgeDispatch) -> impl IntoElement { + let dismiss = dispatch.clone(); + let cluster_id = summary.cluster_id; + let open = move |_| { + dispatch.close_cluster_update(); + let _ = RouterContext::get().push(Route::ClusterOverview { cluster_id }); + }; + + let total = summary.total(); + + let mut sections = ScrollArea::new() + .width(Size::fill()) + .height(Size::flex(1.0)) + .spacing(10.); + + if !summary.updated.is_empty() { + sections = sections.child(change_section( + IconType::RefreshCw01, + "Updated", + colors::brand(), + &summary.updated, + )); + } + if !summary.added.is_empty() { + sections = sections.child(change_section( + IconType::Plus, + "Added", + colors::success(), + &summary.added, + )); + } + if !summary.removed.is_empty() { + sections = sections.child(change_section( + IconType::Trash01, + "Removed", + colors::danger(), + &summary.removed, + )); + } + + rect() + .vertical() + .width(Size::flex(1.0)) + .height(Size::fill()) + .content(Content::Flex) + .padding(Gaps::new_all(22.)) + .spacing(14.) + .child( + rect() + .vertical() + .width(Size::fill()) + .spacing(3.) + .child( + label() + .text("Changes applied") + .font_size(17.) + .font_weight(FontWeight::SEMI_BOLD) + .color(colors::fg_primary()), + ) + .child( + label() + .text(format!( + "{total} change{} synced to this cluster.", + if total == 1 { "" } else { "s" } + )) + .font_size(12.5) + .max_lines(2) + .color(colors::fg_secondary()), + ), + ) + .child(sections) + .child( + rect() + .horizontal() + .width(Size::fill()) + .cross_align(Alignment::Center) + .main_align(Alignment::SpaceBetween) + .spacing(8.) + .child( + Button::new() + .ghost() + .on_press(move |_| dismiss.close_cluster_update()) + .text("Dismiss"), + ) + .child( + Button::new() + .primary() + .on_press(open) + .text("Open cluster") + .child(Icon::new(IconType::ArrowRight).size(15.)), + ), + ) +} + +fn change_section( + icon: IconType, + heading: &str, + accent: Color, + items: &[String], +) -> impl IntoElement { + let mut list = rect().vertical().width(Size::fill()).spacing(1.); + for item in items { + list = list.child( + rect() + .horizontal() + .width(Size::fill()) + .cross_align(Alignment::Center) + .spacing(9.) + .padding(Gaps::new_symmetric(4., 4.)) + .child( + rect() + .width(Size::px(5.)) + .height(Size::px(5.)) + .corner_radius(CornerRadius::new_all(3.)) + .background(accent), + ) + .child( + label() + .text(item.clone()) + .font_size(12.5) + .max_lines(1) + .width(Size::flex(1.0)) + .color(colors::fg_primary()), + ), + ); + } + + rect() + .vertical() + .width(Size::fill()) + .spacing(5.) + .padding(Gaps::new(9., 11., 11., 11.)) + .corner_radius(CornerRadius::new_all(10.)) + .background(colors::page_elevated().with_a(140)) + .border(border_all_color(1., colors::component_border().with_a(120))) + .child( + rect() + .horizontal() + .cross_align(Alignment::Center) + .spacing(7.) + .child(Icon::new(icon).size(14.).color(accent)) + .child( + label() + .text(format!("{heading} ยท {}", items.len())) + .font_size(12.) + .font_weight(FontWeight::SEMI_BOLD) + .color(accent), + ), + ) + .child(list) +} diff --git a/packages/oneclient_app/src/components/dropdown.rs b/packages/oneclient_app/src/components/dropdown.rs index ad16b2ec..f40a0ebf 100644 --- a/packages/oneclient_app/src/components/dropdown.rs +++ b/packages/oneclient_app/src/components/dropdown.rs @@ -59,18 +59,53 @@ impl Component for Dropdown { let mut open = use_state(|| false); let mut hovering = use_state(|| false); + let a11y_id = use_a11y(); + let focus = use_focus(a11y_id); + + let mut button_area = use_state(|| None::); + let mut list_size = use_state(|| None::); + let selected = self.selected.clone(); let options = self.options.clone(); let on_select = self.on_select.clone(); - let list_width = self.width.clone(); let is_open = open(); + if !is_open && list_size().is_some() { + let _ = list_size.take(); + } + let trigger_bg = if hovering() { colors::ghost_overlay_hover() } else { colors::ghost_overlay() }; + let visible = options.len().min(MAX_VISIBLE_OPTIONS); + let list_h = visible as f32 * OPTION_HEIGHT + + visible.saturating_sub(1) as f32 * OPTION_SPACING; + + let offset_y = match (button_area(), list_size()) { + (Some(button), Some(list)) => { + let root_height = Platform::get().root_size.peek().height; + let space_below = root_height - button.max_y(); + let space_above = button.min_y(); + if list.height > space_below && list.height <= space_above { + -(button.height() + list.height + 8.) + } else { + 0. + } + } + _ => 0., + }; + + let list_width = button_area() + .map(|b| Size::px(b.width())) + .unwrap_or_else(|| self.width.clone()); + + let on_global_pointer_press = move |_: Event| { + open.set_if_modified(false); + }; + rect() .width(self.width.clone()) .child( @@ -83,9 +118,19 @@ impl Component for Dropdown { .padding(Gaps::new_symmetric(0., 8.)) .corner_radius(CornerRadius::new_all(6.)) .background(trigger_bg) + .a11y_id(a11y_id) + .a11y_focusable(true) + .a11y_role(AccessibilityRole::Button) + .maybe(focus().is_focused(), |el| { + el.border(border_all_color(1., colors::brand())) + }) .on_pointer_enter(move |_| hovering.set(true)) .on_pointer_leave(move |_| hovering.set(false)) - .on_press(move |e: Event| { + .on_global_pointer_press(on_global_pointer_press) + .on_sized(move |e: Event| { + button_area.set_if_modified(Some(e.area)); + }) + .on_all_press(move |e: Event| { e.prevent_default(); e.stop_propagation(); open.toggle(); @@ -103,44 +148,41 @@ impl Component for Dropdown { ), ) .maybe_child(is_open.then(|| { - let visible = options.len().min(MAX_VISIBLE_OPTIONS); - let list_h = visible as f32 * OPTION_HEIGHT - + visible.saturating_sub(1) as f32 * OPTION_SPACING; - - rect().height(Size::px(0.)).width(list_width) - .layer(Layer::Overlay) - .child( - rect() - .layer(Layer::RelativeOverlay(10)) - .position(Position::new_global().top(0.).left(0.)) - .width(Size::window_percent(100.)) - .height(Size::window_percent(100.)) - .on_press(move |_| open.set(false)), - ) - .child( + rect().width(Size::px(0.)).height(Size::px(0.)).child( rect() - .width(Size::fill()) + .layer(Layer::Overlay) + .width(list_width) .margin(Gaps::new(4., 0., 0., 0.)) - .layer(Layer::RelativeOverlay(12)) - .padding(4.) - .corner_radius(CornerRadius::new_all(8.)) - .border(border_all_color(1., colors::component_border())) - .background(colors::page_elevated()) - .overflow(Overflow::Clip) + .offset_y(offset_y) + .content(Content::Fit) + .on_sized(move |e: Event| { + list_size.set_if_modified(Some(e.area.size)); + }) .child( - ScrollArea::new() - .height(Size::px(list_h)) - .spacing(OPTION_SPACING) - .children(options.into_iter().enumerate().map(|(idx, option)| { - let on_select = on_select.clone(); - DropdownOption::new(option, move |_| { - if let Some(handler) = &on_select { - handler.call(idx); - } - open.set(false); - }) - .into_element() - })), + rect() + .width(Size::fill()) + .padding(4.) + .corner_radius(CornerRadius::new_all(8.)) + .border(border_all_color(1., colors::component_border())) + .background(colors::page_elevated()) + .overflow(Overflow::Clip) + .child( + ScrollArea::new() + .height(Size::px(list_h)) + .spacing(OPTION_SPACING) + .children(options.into_iter().enumerate().map( + |(idx, option)| { + let on_select = on_select.clone(); + DropdownOption::new(option, move |_| { + if let Some(handler) = &on_select { + handler.call(idx); + } + open.set(false); + }) + .into_element() + }, + )), + ), ), ) })) @@ -178,18 +220,24 @@ impl Component for DropdownOption { fn render(&self) -> impl IntoElement { let mut hovering = use_state(|| false); + let a11y_id = use_a11y(); + let focus = use_focus(a11y_id); + rect() .width(Size::fill()) .padding(Gaps::new_symmetric(6., 8.)) .corner_radius(CornerRadius::new_all(6.)) - .background(if hovering() { + .a11y_id(a11y_id) + .a11y_focusable(true) + .a11y_role(AccessibilityRole::Button) + .background(if hovering() || focus().is_focused() { colors::ghost_overlay_hover() } else { Color::TRANSPARENT }) .on_pointer_enter(move |_| hovering.set(true)) .on_pointer_leave(move |_| hovering.set(false)) - .on_press(self.on_press.clone()) + .on_all_press(self.on_press.clone()) .child( label() .text(self.text.clone()) diff --git a/packages/oneclient_app/src/components/major_version_card.rs b/packages/oneclient_app/src/components/major_version_card.rs index bec57c63..b8c1600c 100644 --- a/packages/oneclient_app/src/components/major_version_card.rs +++ b/packages/oneclient_app/src/components/major_version_card.rs @@ -46,19 +46,25 @@ impl Component for MajorVersionCard { fn render(&self) -> impl IntoElement { let mut hovering = use_state(|| false); + let a11y_id = use_a11y(); + let focus = use_focus(a11y_id); + let selected = self.selected; let hovered = *hovering.read(); + let focused = focus().is_focused(); let on_press = self.on_press.clone(); - let opacity = if selected { - 1.0 - } else if hovered { - 0.85 + let opacity = if selected || hovered || focused { + if selected { + 1.0 + } else { + 0.85 + } } else { 0.6 }; - let border_color = if selected { + let border_color = if selected || focused { colors::brand() } else if hovered { colors::component_border_hover() @@ -70,7 +76,10 @@ impl Component for MajorVersionCard { .key(self.major) .width(Size::flex(1.0)) .height(Size::px(CARD_HEIGHT_PX)) - .on_press(move |e| on_press.call(e)) + .a11y_id(a11y_id) + .a11y_focusable(true) + .a11y_role(AccessibilityRole::Button) + .on_all_press(move |e| on_press.call(e)) .on_pointer_enter(move |_| { *hovering.write() = true; }) diff --git a/packages/oneclient_app/src/components/microsoft_login.rs b/packages/oneclient_app/src/components/microsoft_login.rs new file mode 100644 index 00000000..b84e4df4 --- /dev/null +++ b/packages/oneclient_app/src/components/microsoft_login.rs @@ -0,0 +1,334 @@ +use freya::prelude::*; +use freya::query::{MutationCapability, MutationStateData, UseMutation}; +use freya::text_edit::Clipboard; +use oneclient_core::auth::MicrosoftLoginSession; +use oneclient_core::notification::MicrosoftLoginStatus; + +use crate::components::{Button, Icon, IconType, OverlayPopup}; +use crate::hooks::{ + CancelMicrosoftLoginKeys, login_code_already_handled, reset_login_code_dedup, + use_begin_microsoft_login, use_cancel_microsoft_login, use_finish_microsoft_login, + use_microsoft_login_status, +}; +use crate::platform; +use crate::theme::colors; +use crate::ui::border_all_color; + +#[derive(Clone)] +pub struct MicrosoftLogin { + begin: UseMutation, + cancel: UseMutation, + pending_login: State>, + cancelled: State, + status: Option, + locked: bool, + pub pending: bool, + pub error: Option, +} + +impl MicrosoftLogin { + pub fn start(&self) { + let mut cancelled = self.cancelled; + cancelled.set(false); + self.begin.mutate(()); + } + + fn cancel(&self) { + if self.locked { + return; + } + if let Some(session) = self.pending_login.peek().clone() { + self.cancel.mutate(CancelMicrosoftLoginKeys { + state_token: session.dedupe_key().to_string(), + }); + } + + reset_login_code_dedup(); + + let mut cancelled = self.cancelled; + let mut pending_login = self.pending_login; + cancelled.set(true); + pending_login.set(None); + } + + pub fn popup(&self) -> Option { + let handle = self.clone(); + self.pending_login.read().clone().map(move |login| { + microsoft_dialog( + login, + handle.clone(), + handle.status.clone(), + handle.error.clone(), + ) + }) + } +} + +pub fn use_microsoft_login() -> MicrosoftLogin { + let begin = use_begin_microsoft_login(); + let finish = use_finish_microsoft_login(); + let cancel = use_cancel_microsoft_login(); + let mut pending_login = use_state(|| None::); + let cancelled = use_state(|| false); + let status = use_microsoft_login_status(); + + use_side_effect(move || { + let session = match &*begin.read().state() { + MutationStateData::Settled { + res: Ok(session), .. + } => Some(session.clone()), + _ => None, + }; + let Some(session) = session else { return }; + if login_code_already_handled(session.dedupe_key()) { + return; + } + + // open the browser immediately + platform::open_url(session.auth_url()); + finish.mutate(session.clone()); + pending_login.set(Some(session)); + }); + + use_side_effect(move || { + if matches!( + &*finish.read().state(), + MutationStateData::Settled { res: Ok(_), .. } + ) && pending_login.peek().is_some() + { + pending_login.set(None); + } + }); + + // is true once the flow has some status updates (just to ensure the user has seen the popup) and the flow is still in progress + let locked = status.as_ref().is_some_and(|s| s.current > 0); + let in_flight = begin.read().state().is_loading() || finish.read().state().is_loading(); + let pending = in_flight && !*cancelled.read(); + let error = mutation_err_text(&finish).or_else(|| mutation_err_text(&begin)); + + MicrosoftLogin { + begin, + cancel, + pending_login, + cancelled, + status, + locked, + pending, + error, + } +} + +fn mutation_err_text(mutation: &UseMutation) -> Option +where + M: MutationCapability, + M::Err: std::fmt::Display, +{ + match &*mutation.read().state() { + MutationStateData::Settled { res: Err(err), .. } => Some(err.to_string()), + MutationStateData::Loading { + res: Some(Err(err)), + } => Some(err.to_string()), + _ => None, + } +} + +fn microsoft_dialog( + login: MicrosoftLoginSession, + handle: MicrosoftLogin, + status: Option, + error: Option, +) -> impl IntoElement { + let locked = handle.locked; + let close_handle = handle.clone(); + let cancel_handle = handle; + OverlayPopup::new() + // Backdrop / Esc only dismiss while the flow is still cancellable, if its locked the popup can't be closed and cancelling aborts everything. + .on_close(move |()| close_handle.cancel()) + .child( + rect() + .width(Size::window_percent(100.)) + .height(Size::window_percent(100.)) + .center() + .child( + rect() + .vertical() + .width(Size::px(420.)) + .max_width(Size::window_percent(90.)) + .cross_align(Alignment::Center) + .spacing(18.) + .padding(Gaps::new_all(28.)) + .corner_radius(CornerRadius::new_all(16.)) + .background(colors::page_elevated()) + .border(border_all_color(1., colors::component_border())) + .child( + label() + .text("Sign in to Microsoft") + .font_size(18.) + .font_weight(FontWeight::SEMI_BOLD) + .color(colors::fg_primary()), + ) + .child(browser_dialog_body(login.auth_url().to_string())) + .child(dialog_divider()) + .child(device_code_dialog_body( + login.user_code().to_string(), + login.verification_uri().to_string(), + )) + .child(status_row(status, error)) + .maybe_child((!locked).then(|| { + Button::new() + .ghost() + .on_press(move |_| cancel_handle.cancel()) + .text("Cancel") + .into_element() + })), + ), + ) + .into_element() +} + +fn dialog_divider() -> impl IntoElement { + rect() + .horizontal() + .width(Size::fill()) + .cross_align(Alignment::Center) + .content(Content::Flex) + .spacing(10.) + .child( + rect() + .width(Size::flex(1.0)) + .height(Size::px(1.)) + .background(colors::component_border()), + ) + .child( + label() + .text("or") + .font_size(11.) + .color(colors::fg_secondary()), + ) + .child( + rect() + .width(Size::flex(1.0)) + .height(Size::px(1.)) + .background(colors::component_border()), + ) + .into_element() +} + +fn status_row(status: Option, error: Option) -> impl IntoElement { + if let Some(error) = error { + return rect() + .horizontal() + .cross_align(Alignment::Center) + .spacing(6.) + .child( + Icon::new(IconType::AlertTriangle) + .size(13.) + .color(colors::danger()), + ) + .child(label().text(error).font_size(12.).color(colors::danger())) + .into_element(); + } + + let text = match &status { + Some(status) if status.total > 0 && status.current > 0 => { + format!("{} ({}/{})", status.label, status.current, status.total) + } + _ => "Waiting for you to finish signing in...".to_string(), + }; + + rect() + .horizontal() + .cross_align(Alignment::Center) + .spacing(6.) + .child( + Icon::new(IconType::Loading02) + .size(13.) + .color(colors::brand()), + ) + .child( + label() + .text(text) + .font_size(12.) + .color(colors::fg_secondary()), + ) + .into_element() +} + +fn browser_dialog_body(auth_url: String) -> impl IntoElement { + rect() + .vertical() + .width(Size::fill()) + .cross_align(Alignment::Center) + .spacing(12.) + .child( + label() + .text("We opened the Microsoft sign-in page in your browser. Finish there and you'll be brought back automatically.") + .font_size(13.) + .color(colors::fg_secondary()), + ) + .child( + Button::new() + .primary() + .on_press(move |_| platform::open_url(&auth_url)) + .child(Icon::new(IconType::LinkExternal01).size(16.)) + .text("Open in browser again"), + ) + .into_element() +} + +fn device_code_dialog_body(code: String, verification_uri: String) -> impl IntoElement { + let copy_code = code.clone(); + rect() + .vertical() + .width(Size::fill()) + .cross_align(Alignment::Center) + .spacing(12.) + .child( + label() + .text("Or enter this code at the Microsoft sign-in page:") + .font_size(13.) + .color(colors::fg_secondary()), + ) + .child( + rect() + .width(Size::fill()) + .center() + .padding(Gaps::new_symmetric(20., 16.)) + .corner_radius(CornerRadius::new_all(14.)) + .background(colors::component_bg()) + .border(border_all_color(1., colors::brand())) + .child( + label() + .text(code) + .font_size(48.) + .font_weight(FontWeight::BOLD) + .color(colors::fg_primary()), + ), + ) + .child( + rect() + .horizontal() + .width(Size::fill()) + .main_align(Alignment::Center) + .spacing(8.) + .child( + Button::new() + .secondary() + .on_press(move |_| { + if let Err(err) = Clipboard::set(copy_code.clone()) { + tracing::warn!("clipboard copy failed: {err:?}"); + } + }) + .child(Icon::new(IconType::Copy01).size(16.)) + .text("Copy code"), + ) + .child( + Button::new() + .primary() + .on_press(move |_| platform::open_url(&verification_uri)) + .child(Icon::new(IconType::LinkExternal01).size(16.)) + .text("Open in browser"), + ), + ) + .into_element() +} diff --git a/packages/oneclient_app/src/components/mod.rs b/packages/oneclient_app/src/components/mod.rs index 42c9d806..4d708397 100644 --- a/packages/oneclient_app/src/components/mod.rs +++ b/packages/oneclient_app/src/components/mod.rs @@ -1,8 +1,10 @@ +mod account_switcher; mod active_cluster_panel; mod avatar; mod button; mod charts; mod cluster_landscape_art; +mod cluster_update_popup; mod context_menu; mod dropdown; mod dynamic_art; @@ -11,6 +13,7 @@ mod link_confirm; mod local_image; mod log_viewer; mod major_version_card; +mod microsoft_login; mod navbar; mod notifications; mod overlay_popup; @@ -28,11 +31,13 @@ mod toasts; mod toggle; mod update_prompt; +pub use account_switcher::AccountSwitcher; pub use active_cluster_panel::ActiveClusterPanel; pub use avatar::Avatar; pub use button::{Button, ButtonSize, ButtonVariant, link_button, open_folder_button}; pub use charts::{BarChart, ValueUnit}; pub use cluster_landscape_art::ClusterLandscapeArt; +pub use cluster_update_popup::ClusterUpdatePopup; pub use context_menu::ContextMenu; pub use dropdown::Dropdown; pub use dynamic_art::DynamicArt; @@ -41,8 +46,9 @@ pub use link_confirm::ConfirmLinkOverlay; pub use local_image::LocalImage; pub use log_viewer::LogViewer; pub use major_version_card::MajorVersionCard; -pub use navbar::{AppNavbar, OnboardingNavbar}; +pub use microsoft_login::use_microsoft_login; pub(crate) use navbar::window_controls; +pub use navbar::{AppNavbar, OnboardingNavbar}; pub use notifications::NotificationCenter; pub use overlay_popup::OverlayPopup; pub use package_row::{CardLayout, PackageEntry, PackageRow}; @@ -50,8 +56,8 @@ pub use pagination::Pagination; pub use player_model::PlayerModel; pub use recents_row::RecentsRow; pub use screenshot_viewer::ScreenshotViewer; -pub use scrollview::{ScrollArea, ScrollAreaCtx}; pub(crate) use scrollview::corrected_scroll; +pub use scrollview::{ScrollArea, ScrollAreaCtx}; pub use segmented_control::{Segment, SegmentedControl}; pub use status_bar::StatusBar; pub use tab_bar::{TabBar, TabItem}; diff --git a/packages/oneclient_app/src/components/navbar/app_navbar.rs b/packages/oneclient_app/src/components/navbar/app_navbar.rs index 7b3ab333..898dfb7c 100644 --- a/packages/oneclient_app/src/components/navbar/app_navbar.rs +++ b/packages/oneclient_app/src/components/navbar/app_navbar.rs @@ -106,7 +106,7 @@ impl Component for NavLink { Cursor::set(CursorIcon::default()); }); - let color = if active || *hovering.peek() || focused.peek().is_focused() { + let color = if active || hovering() || focused().is_focused() { theme::colors::fg_primary() } else { theme::colors::fg_secondary() @@ -114,7 +114,7 @@ impl Component for NavLink { let underline_width = if active { 27. - } else if *hovering.peek() || focused.peek().is_focused() { + } else if hovering() || focused().is_focused() { 18. } else { 0. @@ -170,7 +170,7 @@ struct NavbarRight; impl Component for NavbarRight { fn render(&self) -> impl IntoElement { let current_account = use_current_account(); - let dispatch: crate::BridgeDispatch = use_dispatch(); + let dispatch = use_dispatch(); let unread = use_notifications_snapshot().unread_count(); let account_uuid = try_default_account(¤t_account) @@ -178,8 +178,14 @@ impl Component for NavbarRight { .unwrap_or_else(|| uuid::Uuid::nil().to_string()); // open notification center + let notif_dispatch = dispatch.clone(); let open_notifications = move |_| { - dispatch.toggle_notification_center(); + notif_dispatch.toggle_notification_center(); + }; + + // open account switcher + let open_account_switcher = move |_| { + dispatch.toggle_account_switcher(); }; // open settings @@ -204,11 +210,14 @@ impl Component for NavbarRight { .on_press(open_settings), ) .child( - super::navbar_button().padding(0.0).child( - Avatar::new(account_uuid) - .width(Size::px(24.)) - .height(Size::px(24.)), - ), + super::navbar_button() + .padding(0.0) + .on_press(open_account_switcher) + .child( + Avatar::new(account_uuid) + .width(Size::px(24.)) + .height(Size::px(24.)), + ), ) .child(super::window_controls()) } diff --git a/packages/oneclient_app/src/components/notifications.rs b/packages/oneclient_app/src/components/notifications.rs index 210607ba..ae17e46c 100644 --- a/packages/oneclient_app/src/components/notifications.rs +++ b/packages/oneclient_app/src/components/notifications.rs @@ -9,7 +9,7 @@ use crate::{ Route, components::{Button, ButtonVariant, Icon, IconType, OverlayPopup, ScrollArea}, hooks::{use_dispatch, use_notifications_snapshot}, - notifications::InboxEntry, + notifications::{InboxEntry, NotificationActionKind}, theme::colors, }; @@ -139,18 +139,13 @@ impl Component for NotifEntryRow { let id = entry.id; let dispatch = use_dispatch(); let enter_dispatch = dispatch.clone(); + let body_dispatch = dispatch.clone(); let click_dismissable = entry.click_dismissable(); + let has_actions = !entry.actions.is_empty(); let mut hovered = use_state(|| false); let expanded = use_state(|| false); - let intro = use_animation(|conf| { - conf.on_creation(OnCreation::Run); - AnimNum::new(0., 1.).time(240).ease(Ease::Out) - }); - let progress = intro.read().value(); - let slide = (1.0 - progress) * 16.0; - let content = rect() .horizontal() .width(Size::fill()) @@ -158,9 +153,7 @@ impl Component for NotifEntryRow { .cross_align(Alignment::Start) .padding(Gaps::new_all(8.)) .corner_radius(CornerRadius::new_all(8.)) - .opacity(progress) - .margin(Gaps::new(0., 0., 0., slide)) - .maybe(*hovered.read(), |el| { + .maybe(*hovered.read() && !has_actions, |el| { el.background(colors::ghost_overlay_hover()) }) .on_pointer_enter(move |_| { @@ -183,7 +176,7 @@ impl Component for NotifEntryRow { .color(level_color(&entry.level)), ), ) - .child(row_body(&entry)); + .child(row_body(&entry, body_dispatch)); rect() .vertical() @@ -307,7 +300,7 @@ fn phase_badge(phase: &str) -> impl IntoElement { .into_element() } -fn row_body(entry: &InboxEntry) -> impl IntoElement { +fn row_body(entry: &InboxEntry, dispatch: crate::BridgeDispatch) -> impl IntoElement { rect() .vertical() .width(Size::flex(1.0)) @@ -354,10 +347,10 @@ fn row_body(entry: &InboxEntry) -> impl IntoElement { .font_size(12.) .color(colors::fg_secondary()), ) - .child(row_extra(entry)) + .child(row_extra(entry, dispatch)) } -fn row_extra(entry: &InboxEntry) -> impl IntoElement { +fn row_extra(entry: &InboxEntry, dispatch: crate::BridgeDispatch) -> impl IntoElement { if let Some((current, total)) = entry.progress.filter(|_| entry.is_loading) { let frac = (current as f32 / total.max(1) as f32).clamp(0.0, 1.0); return rect() @@ -377,17 +370,40 @@ fn row_extra(entry: &InboxEntry) -> impl IntoElement { } if !entry.actions.is_empty() { + let id = entry.id; + let dismiss_dispatch = dispatch.clone(); + let mut row = rect() .horizontal() - .spacing(16.) - .margin(Gaps::new(8., 0., 0., 0.)); + .width(Size::fill()) + .content(Content::Flex) + .cross_align(Alignment::Center) + .spacing(8.) + .margin(Gaps::new(8., 0., 0., 0.)) + .child( + Button::new() + .small() + .secondary() + .width(Size::flex(1.0)) + .text("Dismiss") + .on_press(move |_| dismiss_dispatch.dismiss_notification(id)), + ); - for (i, text) in entry.actions.iter().enumerate() { - row = row.child(Button::new().small().text(text.clone()).variant(if i == 0 { - ButtonVariant::Primary - } else { - ButtonVariant::Secondary - })); + for (i, action) in entry.actions.iter().enumerate() { + let action_dispatch = dispatch.clone(); + let kind = action.kind.clone(); + row = row.child( + Button::new() + .small() + .width(Size::flex(1.0)) + .text(action.label.clone()) + .variant(if i == 0 { + ButtonVariant::Primary + } else { + ButtonVariant::Secondary + }) + .on_press(move |_| run_action(&action_dispatch, &kind)), + ); } return row.into_element(); @@ -396,6 +412,14 @@ fn row_extra(entry: &InboxEntry) -> impl IntoElement { rect().into_element() } +fn run_action(dispatch: &crate::BridgeDispatch, kind: &NotificationActionKind) { + match kind { + NotificationActionKind::OpenClusterUpdate(summary) => { + dispatch.open_cluster_update(summary.clone()); + } + } +} + fn divider() -> impl IntoElement { rect() .width(Size::fill()) diff --git a/packages/oneclient_app/src/components/pagination.rs b/packages/oneclient_app/src/components/pagination.rs index 9a3c072b..bb3913b4 100644 --- a/packages/oneclient_app/src/components/pagination.rs +++ b/packages/oneclient_app/src/components/pagination.rs @@ -1,6 +1,6 @@ use freya::prelude::*; -use crate::components::{Icon, IconType}; +use crate::components::{Button, Icon, IconType}; use crate::theme::colors; use crate::ui::border_all_color; @@ -45,18 +45,11 @@ impl Component for Pagination { let icon_btn = move |target: usize, enabled: bool, icon: IconType| { let mut page = page; - rect() - .center() - .width(Size::px(32.)) - .height(Size::px(32.)) - .corner_radius(CornerRadius::new_all(8.)) - .background(colors::component_bg()) - .border(border_all_color(1., colors::component_border())) - .maybe(enabled, |el| { - el.on_pointer_enter(|_| Cursor::set(CursorIcon::Pointer)) - .on_pointer_leave(|_| Cursor::set(CursorIcon::default())) - .on_press(move |_| page.set(target)) - }) + Button::new() + .secondary() + .icon() + .enabled(enabled) + .on_press(move |_| page.set(target)) .child(Icon::new(icon).size(14.).color(if enabled { colors::fg_primary() } else { @@ -68,43 +61,38 @@ impl Component for Pagination { let num_btn = move |target: usize| { let mut page = page; let is_current = target == current; - rect() - .center() + + if is_current { + return rect() + .center() + .width(Size::px(32.)) + .height(Size::px(32.)) + .corner_radius(CornerRadius::new_all(8.)) + .background(colors::component_bg_pressed()) + .border(border_all_color(1., colors::brand())) + .child( + label() + .text(format!("{}", target + 1)) + .font_size(12.) + .font_weight(FontWeight::SEMI_BOLD) + .color(colors::fg_primary()), + ) + .into_element(); + } + + Button::new() + .secondary() .width(Size::px(32.)) .height(Size::px(32.)) - .corner_radius(CornerRadius::new_all(8.)) - .background(if is_current { - colors::component_bg_pressed() - } else { - colors::component_bg() - }) - .border(border_all_color( - 1., - if is_current { - colors::brand() - } else { - colors::component_border() - }, - )) - .maybe(!is_current, |el| { - el.on_pointer_enter(|_| Cursor::set(CursorIcon::Pointer)) - .on_pointer_leave(|_| Cursor::set(CursorIcon::default())) - .on_press(move |_| page.set(target)) - }) + .padding(Gaps::new_symmetric(6., 0.)) + .on_press(move |_| page.set(target)) .child( label() .text(format!("{}", target + 1)) .font_size(12.) - .font_weight(if is_current { - FontWeight::SEMI_BOLD - } else { - FontWeight::NORMAL - }) - .color(if is_current { - colors::fg_primary() - } else { - colors::fg_secondary() - }), + .font_weight(FontWeight::NORMAL) + .max_lines(1) + .color(colors::fg_secondary()), ) .into_element() }; diff --git a/packages/oneclient_app/src/components/recents_row.rs b/packages/oneclient_app/src/components/recents_row.rs index c82583bc..3a3c80bf 100644 --- a/packages/oneclient_app/src/components/recents_row.rs +++ b/packages/oneclient_app/src/components/recents_row.rs @@ -14,6 +14,7 @@ use crate::utils::sort_clusters_for_home; const ROW_HEIGHT_PX: f32 = 208.0; const CARD_GAP_PX: f32 = 24.0; const MORE_TILE_WIDTH_PX: f32 = 96.0; +const MIN_CARD_WIDTH_PX: f32 = 300.0; const MAX_CARD_WIDTH_PX: f32 = 480.0; const CARD_MS: u64 = 460; const STAGGER_MS: u64 = 85; @@ -100,6 +101,10 @@ impl Component for ClusterCard { let active = *active_id.read() == Some(self.cluster.id); let mut hovering = use_state(|| false); + let a11y_id = use_a11y(); + let focus = use_focus(a11y_id); + let focused = focus().is_focused(); + let total = CARD_MS as f32 + (self.count.saturating_sub(1) as f32) * STAGGER_MS as f32; let elapsed = self.progress * total; let local = @@ -121,6 +126,7 @@ impl Component for ClusterCard { rect() .key(self.cluster.id) .width(Size::flex(1.0)) + .min_width(Size::px(MIN_CARD_WIDTH_PX)) .max_width(Size::px(MAX_CARD_WIDTH_PX)) .height(Size::fill()) .offset_y(rise) @@ -133,7 +139,7 @@ impl Component for ClusterCard { .corner_radius(CornerRadius::new_all(12.)) .overflow(Overflow::Clip) .border( - if active { + if active || focused { border_all_color(2., colors::brand()) } else if *hovering.read() { border_all_color(1., colors::component_border_hover()) @@ -142,6 +148,9 @@ impl Component for ClusterCard { } .alignment(BorderAlignment::Outer), ) + .a11y_id(a11y_id) + .a11y_focusable(true) + .a11y_role(AccessibilityRole::Button) .on_pointer_over(move |_| { if !*hovering.peek() { *hovering.write() = true; @@ -158,7 +167,7 @@ impl Component for ClusterCard { .y(0.) .color(Color::from_af32rgb(0.3, 0, 0, 0)), ) - .on_press(on_press) + .on_all_press(on_press) .child( rect() .width(Size::fill()) @@ -218,17 +227,31 @@ impl Component for OtherVersionsTile { fn render(&self) -> impl IntoElement { let mut hovering = use_state(|| false); + let a11y_id = use_a11y(); + let focus = use_focus(a11y_id); + let focused = focus().is_focused(); + rect() .width(Size::px(MORE_TILE_WIDTH_PX)) .height(Size::fill()) .corner_radius(CornerRadius::new_all(12.)) - .border(border_all(1.)) + .border( + if focused { + border_all_color(2., colors::brand()) + } else { + border_all(1.) + } + .alignment(BorderAlignment::Outer), + ) .background(if *hovering.read() { colors::ghost_overlay_pressed() } else { colors::ghost_overlay_hover() }) .blur(32.) + .a11y_id(a11y_id) + .a11y_focusable(true) + .a11y_role(AccessibilityRole::Button) .on_pointer_enter(move |_| { *hovering.write() = true; }) @@ -243,7 +266,7 @@ impl Component for OtherVersionsTile { .y(0.) .color(Color::from_af32rgb(0.3, 0, 0, 0)), ) - .on_press(|_| { + .on_all_press(|_| { let _ = RouterContext::get().push(Route::Clusters {}); }) .child( @@ -257,15 +280,11 @@ impl Component for OtherVersionsTile { } fn recent_card_slots_for_width(row_width_px: f32) -> usize { - const MORE_TILE_WIDTH: f32 = 96.0; - const GAP: f32 = 24.0; - const MIN_CARD_WIDTH: f32 = 300.0; - - if row_width_px <= MORE_TILE_WIDTH + GAP { + if row_width_px <= MORE_TILE_WIDTH_PX + CARD_GAP_PX { return 0; } - let available = row_width_px - MORE_TILE_WIDTH - GAP; - let slot = MIN_CARD_WIDTH + GAP; + let available = row_width_px - MORE_TILE_WIDTH_PX; + let slot = MIN_CARD_WIDTH_PX + CARD_GAP_PX; (available / slot).floor() as usize } diff --git a/packages/oneclient_app/src/components/scrollview.rs b/packages/oneclient_app/src/components/scrollview.rs index 8e054209..ba49b6c3 100644 --- a/packages/oneclient_app/src/components/scrollview.rs +++ b/packages/oneclient_app/src/components/scrollview.rs @@ -42,7 +42,7 @@ pub(crate) fn scrollbar_pos_and_size(inner: f32, viewport: f32, scroll: f32) -> let scroll_range = inner - viewport; let thumb_range = viewport - thumb; let normalized = -scroll / scroll_range; - + (normalized * thumb_range, thumb) } @@ -307,30 +307,46 @@ impl ScrollArea { let us_thumb = on_user_scroll.clone(); let us_h_thumb = on_user_scroll; + let can_scroll_v = vp_h > 0. && vp_h < ct_h; + let can_scroll_h = horizontal && vp_w > 0. && vp_w < content_w; + let on_wheel = move |e: Event| { - e.stop_propagation(); + let shift = *shift_held.read(); - if (e.delta_y != 0.0 || e.delta_x != 0.0) - && let Some(cb) = &us_wheel - { - cb.call(()); - } + let h_delta = if horizontal { + if shift && e.delta_x == 0.0 { e.delta_y } else { e.delta_x } + } else { + 0.0 + }; + let v_delta = if horizontal && shift { 0.0 } else { e.delta_y }; - if horizontal && *shift_held.read() { - let delta = if e.delta_x != 0.0 { e.delta_x } else { e.delta_y }; - if delta != 0.0 { - let cur_x = corrected_scroll(content_w, vp_w, *scroll_x.read()); - let new_x = scroll_pos_from_wheel(delta as f32, content_w, vp_w, cur_x); - scroll_x.set(new_x as f32); - } + let do_h = h_delta != 0.0 && can_scroll_h; + let do_v = v_delta != 0.0 && can_scroll_v; + + // check if scrollable + if !do_h && !do_v { return; } - let (_, cur_y) = controller.into(); - let current = corrected_scroll(ct_h, vp_h, cur_y as f32); - let new_y = scroll_pos_from_wheel(e.delta_y as f32, ct_h, vp_h, current); - if new_y != cur_y { - controller.scroll_to_y(new_y); + e.stop_propagation(); + + if let Some(cb) = &us_wheel { + cb.call(()); + } + + if do_h { + let cur_x = corrected_scroll(content_w, vp_w, *scroll_x.read()); + let new_x = scroll_pos_from_wheel(h_delta as f32, content_w, vp_w, cur_x); + scroll_x.set(new_x as f32); + } + + if do_v { + let (_, cur_y) = controller.into(); + let current = corrected_scroll(ct_h, vp_h, cur_y as f32); + let new_y = scroll_pos_from_wheel(v_delta as f32, ct_h, vp_h, current); + if new_y != cur_y { + controller.scroll_to_y(new_y); + } } }; @@ -393,6 +409,11 @@ impl ScrollArea { rect() .width(self.width.clone()) .height(self.height.clone()) + .scrollable(true) + .a11y_builder(move |node| { + node.set_scroll_x(corrected_x as f64); + node.set_scroll_y(corrected_y as f64); + }) .on_wheel(on_wheel) .on_capture_global_pointer_move(on_global_move) .on_capture_global_pointer_press(on_global_release) diff --git a/packages/oneclient_app/src/components/segmented_control.rs b/packages/oneclient_app/src/components/segmented_control.rs index 87b91dba..384711a7 100644 --- a/packages/oneclient_app/src/components/segmented_control.rs +++ b/packages/oneclient_app/src/components/segmented_control.rs @@ -101,16 +101,6 @@ impl Component for SegmentedControl { let equal_width = self.equal_width; let disabled = self.disabled; - let active_color = |is_selected: bool| { - if disabled { - colors::fg_secondary().with_a(110) - } else if is_selected { - colors::fg_primary() - } else { - colors::fg_secondary() - } - }; - rect() .horizontal() .height(Size::px(self.height)) @@ -121,55 +111,103 @@ impl Component for SegmentedControl { .background(colors::component_bg()) .border(border_all_color(1., colors::component_border())) .children(self.segments.iter().map(move |seg| { - let value = seg.value; - let is_selected = value == active; - let mut selected = selected; - let label_text = seg.label.clone(); + SegmentButton { + selected, + value: seg.value, + is_selected: seg.value == active, + icon: seg.icon, + label: seg.label.clone(), + icon_size, + tint, + equal_width, + disabled, + } + .into_element() + })) + } +} - rect() - .horizontal() - .height(Size::fill()) - .cross_align(Alignment::Center) - .spacing(6.) - .corner_radius(CornerRadius::new_all(6.)) - .map(equal_width, |el, w| { - el.width(Size::px(w)).main_align(Alignment::Center) - }) - .maybe(equal_width.is_none(), |el| { - el.padding(Gaps::new_symmetric(0., 12.)) - }) - .background(if is_selected { - colors::component_bg_pressed() +#[derive(PartialEq)] +struct SegmentButton { + selected: State, + value: T, + is_selected: bool, + icon: Option, + label: Option, + icon_size: Option, + tint: bool, + equal_width: Option, + disabled: bool, +} + +impl Component for SegmentButton { + fn render(&self) -> impl IntoElement { + let a11y_id = use_a11y(); + let focus = use_focus(a11y_id); + + let mut selected = self.selected; + let value = self.value; + let is_selected = self.is_selected; + let disabled = self.disabled; + let equal_width = self.equal_width; + let focused = focus().is_focused(); + + let content_color = if disabled { + colors::fg_secondary().with_a(110) + } else if is_selected { + colors::fg_primary() + } else { + colors::fg_secondary() + }; + + rect() + .horizontal() + .height(Size::fill()) + .cross_align(Alignment::Center) + .spacing(6.) + .corner_radius(CornerRadius::new_all(6.)) + .a11y_id(a11y_id) + .a11y_focusable(!disabled) + .a11y_role(AccessibilityRole::Button) + .map(equal_width, |el, w| { + el.width(Size::px(w)).main_align(Alignment::Center) + }) + .maybe(equal_width.is_none(), |el| { + el.padding(Gaps::new_symmetric(0., 12.)) + }) + .background(if is_selected { + colors::component_bg_pressed() + } else { + Color::TRANSPARENT + }) + .maybe(focused && !disabled, |el| { + el.border(border_all_color(1., colors::brand())) + }) + .maybe(!disabled, |el| { + el.on_pointer_enter(|_| Cursor::set(CursorIcon::Pointer)) + .on_pointer_leave(|_| Cursor::set(CursorIcon::default())) + .on_all_press(move |_| *selected.write() = value) + }) + .maybe_child(self.icon.map(|icon| { + let mut ic = Icon::new(icon); + if let Some(size) = self.icon_size { + ic = ic.size(size); + } + if self.tint { + ic = ic.color(content_color); + } + ic.into_element() + })) + .maybe_child(self.label.clone().map(|text| { + label() + .text(text) + .font_size(13.) + .font_weight(if is_selected { + FontWeight::MEDIUM } else { - Color::TRANSPARENT + FontWeight::NORMAL }) - .maybe(!disabled, |el| { - el.on_pointer_enter(|_| Cursor::set(CursorIcon::Pointer)) - .on_pointer_leave(|_| Cursor::set(CursorIcon::default())) - .on_press(move |_| *selected.write() = value) - }) - .maybe_child(seg.icon.map(|icon| { - let mut ic = Icon::new(icon); - if let Some(size) = icon_size { - ic = ic.size(size); - } - if tint { - ic = ic.color(active_color(is_selected)); - } - ic.into_element() - })) - .maybe_child(label_text.map(|text| { - label() - .text(text) - .font_size(13.) - .font_weight(if is_selected { - FontWeight::MEDIUM - } else { - FontWeight::NORMAL - }) - .color(active_color(is_selected)) - .into_element() - })) + .color(content_color) .into_element() })) } diff --git a/packages/oneclient_app/src/components/tab_bar.rs b/packages/oneclient_app/src/components/tab_bar.rs index 910f98c4..f79d3409 100644 --- a/packages/oneclient_app/src/components/tab_bar.rs +++ b/packages/oneclient_app/src/components/tab_bar.rs @@ -6,7 +6,7 @@ pub struct TabItem { label: String, active: bool, count: Option, - on_press: Option)>>, + on_press: Option>>, } impl TabItem { @@ -25,8 +25,8 @@ impl TabItem { self } - pub fn on_press(mut self, handler: impl FnMut(Event) + 'static) -> Self { - self.on_press = Some(Box::new(handler)); + pub fn on_press(mut self, handler: impl Into>>) -> Self { + self.on_press = Some(handler.into()); self } } @@ -147,46 +147,79 @@ impl IntoElement for TabBar { .cross_align(Alignment::Center) .content(Content::Fit) .children(self.tabs.into_iter().map(|tab| { - let active = tab.active; - - let mut el = rect() - .vertical() - .content(Content::Fit) - .on_pointer_enter(|_| Cursor::set(CursorIcon::Pointer)) - .on_pointer_leave(|_| Cursor::set(CursorIcon::default())); - - if let Some(handler) = tab.on_press { - el = el.on_press(handler); + TabButton { + label: tab.label, + active: tab.active, + count: tab.count, + font_size, + on_press: tab.on_press, } - - el.child( - rect() - .horizontal() - .cross_align(Alignment::Center) - .spacing(6.) - .content(Content::Fit) - .child( - rect() - .vertical() - .content(Content::Fit) - .child(tab_label(&tab.label, font_size, active)) - .child( - rect() - .height(Size::px(1.5)) - .width(Size::fill_minimum()) - .margin(Gaps::new_symmetric(0., 4.0)) - .corner_radius(CornerRadius::new_all(2.)) - .background(if active { - colors::fg_primary() - } else { - Color::TRANSPARENT - }), - ), - ) - .maybe_child(tab.count.map(|c| count_pill(&c, font_size).into_element())), - ) .into_element() })) .into_element() } } + +#[derive(PartialEq)] +struct TabButton { + label: String, + active: bool, + count: Option, + font_size: f32, + on_press: Option>>, +} + +impl Component for TabButton { + fn render(&self) -> impl IntoElement { + let a11y_id = use_a11y(); + let focus = use_focus(a11y_id); + + let active = self.active; + let font_size = self.font_size; + let underline_on = active || focus().is_focused(); + + let mut el = rect() + .vertical() + .content(Content::Fit) + .a11y_id(a11y_id) + .a11y_focusable(true) + .a11y_role(AccessibilityRole::Button) + .on_pointer_enter(|_| Cursor::set(CursorIcon::Pointer)) + .on_pointer_leave(|_| Cursor::set(CursorIcon::default())); + + if let Some(handler) = self.on_press.clone() { + el = el.on_all_press(move |e| handler.call(e)); + } + + el.child( + rect() + .horizontal() + .cross_align(Alignment::Center) + .spacing(6.) + .content(Content::Fit) + .child( + rect() + .vertical() + .content(Content::Fit) + .child(tab_label(&self.label, font_size, active)) + .child( + rect() + .height(Size::px(1.5)) + .width(Size::fill_minimum()) + .margin(Gaps::new_symmetric(0., 4.0)) + .corner_radius(CornerRadius::new_all(2.)) + .background(if underline_on { + colors::fg_primary() + } else { + Color::TRANSPARENT + }), + ), + ) + .maybe_child( + self.count + .as_ref() + .map(|c| count_pill(c, font_size).into_element()), + ), + ) + } +} diff --git a/packages/oneclient_app/src/components/toasts.rs b/packages/oneclient_app/src/components/toasts.rs index 857c119e..2ea7c958 100644 --- a/packages/oneclient_app/src/components/toasts.rs +++ b/packages/oneclient_app/src/components/toasts.rs @@ -7,7 +7,7 @@ use oneclient_core::notification::NotificationLevel; use crate::{ components::{Button, ButtonSize, Icon, IconType}, hooks::{use_dispatch, use_notifications_snapshot}, - notifications::InboxEntry, + notifications::{InboxEntry, NotificationActionKind}, theme::colors, }; @@ -263,11 +263,7 @@ fn message_card( } fn action_card(entry: &InboxEntry, dispatch: crate::BridgeDispatch, id: u64, ttl_pct: f32) -> Rect { - let review_label = entry - .actions - .first() - .cloned() - .unwrap_or_else(|| "Review".to_string()); + let action = entry.actions.first().cloned(); toast_shell(entry) .child( @@ -287,60 +283,60 @@ fn action_card(entry: &InboxEntry, dispatch: crate::BridgeDispatch, id: u64, ttl .child(header(entry)), ) .child(divider()) - .child(action_row(review_label, dispatch, id)), + .child(action_row(action, dispatch, id)), ) .child(ttl_bar(entry, ttl_pct)) } -fn action_row(review_label: String, dispatch: crate::BridgeDispatch, id: u64) -> impl IntoElement { +fn action_row( + action: Option, + dispatch: crate::BridgeDispatch, + id: u64, +) -> impl IntoElement { let dismiss = dispatch.clone(); + let review_label = action + .as_ref() + .map(|a| a.label.clone()) + .unwrap_or_else(|| "Review".to_string()); + let review_kind = action.map(|a| a.kind); + rect() .horizontal() .width(Size::fill()) .content(Content::Flex) .cross_align(Alignment::Center) - .padding(Gaps::new(0., 30., 0., 30.)) + .spacing(8.) .child( - rect() - .horizontal() + Button::new() + .small() + .secondary() .width(Size::flex(1.0)) - .cross_align(Alignment::Center) - .spacing(8.) - .on_press(move |_| dispatch.mark_notification_read(id)) - .child( - Icon::new(IconType::Eye) - .size(18.) - .color(colors::toast_action()), - ) - .child( - label() - .text(review_label) - .font_size(12.) - .font_weight(FontWeight::MEDIUM) - .color(colors::toast_action()), - ), + .on_press(move |_| dismiss.dismiss_toast(id)) + .child(Icon::new(IconType::XClose).size(16.)) + .text("Dismiss"), ) .child( - rect() - .horizontal() - .cross_align(Alignment::Center) - .spacing(8.) - .on_press(move |_| dismiss.dismiss_toast(id)) - .child( - Icon::new(IconType::XClose) - .size(18.) - .color(colors::toast_action()), - ) - .child( - label() - .text("Dismiss") - .font_size(12.) - .font_weight(FontWeight::MEDIUM) - .color(colors::toast_action()), - ), + Button::new() + .small() + .primary() + .width(Size::flex(1.0)) + .on_press(move |_| match &review_kind { + Some(kind) => run_action(&dispatch, kind), + None => dispatch.mark_notification_read(id), + }) + .child(Icon::new(IconType::Eye).size(16.)) + .text(review_label), ) } +fn run_action(dispatch: &crate::BridgeDispatch, kind: &NotificationActionKind) { + match kind { + NotificationActionKind::OpenClusterUpdate(summary) => { + dispatch.open_cluster_update(summary.clone()); + } + } +} + fn progress_card(entry: &InboxEntry) -> Rect { let frac = entry .progress diff --git a/packages/oneclient_app/src/components/toggle.rs b/packages/oneclient_app/src/components/toggle.rs index eef1b4ba..453ecdd4 100644 --- a/packages/oneclient_app/src/components/toggle.rs +++ b/packages/oneclient_app/src/components/toggle.rs @@ -50,8 +50,17 @@ impl Component for Switch { let on_press = self.on_press.clone(); let on = *value.read(); + let a11y_id = use_a11y(); + let focus = use_focus(a11y_id); + let knob_align = if on { Alignment::End } else { Alignment::Start }; + let track_border = if focus().is_focused() { + colors::fg_primary() + } else { + colors::component_border() + }; + let background = use_animation_transition(value, |_, on| { if on { AnimColor::new(colors::component_bg(), colors::brand()) @@ -84,9 +93,12 @@ impl Component for Switch { .horizontal() .cross_align(Alignment::Center) .spacing(8.) + .a11y_id(a11y_id) + .a11y_focusable(true) + .a11y_role(AccessibilityRole::Button) .on_pointer_enter(|_| Cursor::set(CursorIcon::Pointer)) .on_pointer_leave(|_| Cursor::set(CursorIcon::default())) - .on_press(move |_| { + .on_all_press(move |_| { on_press.call(()); }) .child( @@ -104,7 +116,7 @@ impl Component for Switch { .main_align(knob_align) .cross_align(Alignment::Center) .background(&*background.read()) - .border(ui::border_all_color(1., colors::component_border())) + .border(ui::border_all_color(1., track_border)) .child( rect() .width(Size::px(HANDLE_SIZE)) diff --git a/packages/oneclient_app/src/hooks/dispatch.rs b/packages/oneclient_app/src/hooks/dispatch.rs index 49a35d72..488dd8b8 100644 --- a/packages/oneclient_app/src/hooks/dispatch.rs +++ b/packages/oneclient_app/src/hooks/dispatch.rs @@ -4,7 +4,7 @@ use oneclient_db::models::ClusterId; use crate::bridge::{BridgeCommand, OneClientBridge}; use crate::components::IconType; -use crate::notifications::NotificationSpec; +use crate::notifications::{ClusterUpdateSummary, NotificationAction, NotificationSpec}; #[derive(Clone)] pub struct BridgeDispatch { @@ -152,6 +152,14 @@ impl BridgeDispatch { self.send(BridgeCommand::CloseNotificationCenter); } + pub fn toggle_account_switcher(&self) { + self.send(BridgeCommand::ToggleAccountSwitcher); + } + + pub fn close_account_switcher(&self) { + self.send(BridgeCommand::CloseAccountSwitcher); + } + pub fn clear_notification_inbox(&self) { self.send(BridgeCommand::ClearNotificationInbox); } @@ -176,6 +184,14 @@ impl BridgeDispatch { self.send(BridgeCommand::AnswerPrompt(choice)); } + pub fn open_cluster_update(&self, summary: ClusterUpdateSummary) { + self.send(BridgeCommand::OpenClusterUpdate(summary)); + } + + pub fn close_cluster_update(&self) { + self.send(BridgeCommand::CloseClusterUpdate); + } + pub fn notify(&self, title: impl Into) -> NotificationBuilder { NotificationBuilder { dispatch: self.clone(), @@ -291,13 +307,13 @@ impl NotificationBuilder { self } - pub fn action(mut self, label: impl Into) -> Self { - self.spec.actions.push(label.into()); + pub fn action(mut self, action: NotificationAction) -> Self { + self.spec.actions.push(action); self } - pub fn actions(mut self, actions: impl IntoIterator>) -> Self { - self.spec.actions = actions.into_iter().map(Into::into).collect(); + pub fn actions(mut self, actions: impl IntoIterator) -> Self { + self.spec.actions = actions.into_iter().collect(); self } diff --git a/packages/oneclient_app/src/hooks/mod.rs b/packages/oneclient_app/src/hooks/mod.rs index 46593557..90623621 100644 --- a/packages/oneclient_app/src/hooks/mod.rs +++ b/packages/oneclient_app/src/hooks/mod.rs @@ -15,38 +15,43 @@ pub use active_cluster::{ pub use dispatch::BridgeDispatch; pub use queries::{ - AddOfflineAccountKeys, BROWSE_PAGE_SIZE, CachedImageQuery, ClusterAction, ClusterBundles, - ClusterLogsQuery, LogAction, LogContentQuery, MigrationQuery, OnboardingBundlesQuery, - RefreshAccountKeys, RemoveAccountKeys, ScreenshotAction, SetDefaultAccountKeys, UploadLogKeys, - UploadLogMutation, UseLogAction, UseRefreshAccount, UseRemoveAccount, UseScreenshotAction, - UseSetDefaultAccount, UseUploadLog, VERSIONS_PAGE_SIZE, accounts_have_microsoft, - bundle_overrides_map, bundles_with_status_items, category_list, changelog_error, - changelog_groups, changelog_is_loading, cluster_content_items, content_type_for_slug, - has_migration_data, invalidate_cluster_queries, invalidate_java_queries, + AddOfflineAccountKeys, BROWSE_PAGE_SIZE, BeginMicrosoftLoginMutation, CachedImageQuery, + CancelMicrosoftLoginKeys, CancelMicrosoftLoginMutation, ClusterAction, ClusterBundles, + ClusterLogsQuery, FinishMicrosoftLoginMutation, LogAction, LogContentQuery, MigrationQuery, + OnboardingBundlesQuery, RefreshAccountKeys, RemoveAccountKeys, ScreenshotAction, + SetDefaultAccountKeys, UploadLogKeys, UploadLogMutation, UseLogAction, UseRefreshAccount, + UseRemoveAccount, UseScreenshotAction, UseSetDefaultAccount, UseUploadLog, VERSIONS_PAGE_SIZE, + accounts_have_microsoft, bundle_overrides_map, bundles_with_status_items, category_list, + changelog_error, changelog_groups, changelog_is_loading, cluster_content_items, + content_type_for_slug, has_migration_data, invalidate_cluster_queries, invalidate_java_queries, invalidate_logs_queries, invalidate_profile_queries, invalidate_screenshots_queries, java_runtimes, loader_versions, login_code_already_handled, migration_detection, mutation_error, mutation_is_pending, onboarding_bundles_items, package_meta_batch, - pick_version_metadata, project_detail, provider_versions, search_items, search_pending, - search_total, try_account, try_accounts, try_cluster_analytics, try_cluster_logs, - try_cluster_screenshots, try_default_account, try_game_profile, try_global_analytics, - try_log_content, use_account, use_accounts, use_add_microsoft_account, use_add_offline_account, - use_begin_microsoft_login, use_bundle_overrides, use_bundle_updates, use_bundles_with_status, - use_cached_image, use_changelog, use_cluster_analytics, use_cluster_content, use_cluster_logs, - use_cluster_mutation, use_cluster_profile, use_cluster_screenshots, use_cluster_settings, - use_clusters, use_current_account, use_default_account, use_finish_microsoft_login, - use_game_profile, use_global_analytics, use_java_runtimes, use_loader_versions, - use_local_image, use_log_action, use_log_content, use_migration, use_named_profiles, - use_onboarding_bundles, use_package_categories, use_package_meta_batch, use_package_project, - use_package_search, use_package_versions, use_player_profile, use_player_skin, - use_provider_versions, use_refresh_account, use_refresh_all_accounts, use_remove_account, - use_screenshot_action, use_set_default_account, use_upload_log, use_version_metadata, - use_versions, version_list, versions_total, + pick_version_metadata, project_detail, provider_versions, reset_login_code_dedup, search_items, + search_pending, search_total, try_account, try_accounts, try_cluster_analytics, + try_cluster_logs, try_cluster_screenshots, try_default_account, try_game_profile, + try_global_analytics, try_log_content, use_account, use_accounts, use_add_microsoft_account, + use_add_offline_account, use_begin_microsoft_login, use_bundle_overrides, use_bundle_updates, + use_bundles_with_status, use_cached_image, use_cancel_microsoft_login, use_changelog, + use_cluster_analytics, use_cluster_content, use_cluster_logs, use_cluster_mutation, + use_cluster_profile, use_cluster_screenshots, use_cluster_settings, use_clusters, + use_current_account, use_default_account, use_finish_microsoft_login, use_game_profile, + use_global_analytics, use_java_runtimes, use_loader_versions, use_local_image, use_log_action, + use_log_content, use_migration, use_named_profiles, use_onboarding_bundles, + use_package_categories, use_package_meta_batch, use_package_project, use_package_search, + use_package_versions, use_player_profile, use_player_skin, use_provider_versions, + use_refresh_account, use_refresh_all_accounts, use_remove_account, use_screenshot_action, + use_set_default_account, use_upload_log, use_version_metadata, use_versions, version_list, + versions_total, }; -use crate::{bridge::{ - BridgeSnapshot, ClustersSnapshot, GameSnapshot, JavaSnapshot, LauncherInit, OneClientBridge, - ProfilesSnapshot, SettingsSnapshot, use_bridge_snapshot, -}, notifications::NotificationSnapshot}; +use crate::{ + bridge::{ + BridgeSnapshot, ClustersSnapshot, GameSnapshot, JavaSnapshot, LauncherInit, + OneClientBridge, ProfilesSnapshot, SettingsSnapshot, use_bridge_snapshot, + }, + notifications::NotificationSnapshot, +}; use freya::prelude::*; pub fn use_provide_bridge(bridge: &OneClientBridge) { @@ -88,10 +93,18 @@ pub fn use_notifications_snapshot() -> NotificationSnapshot { use_snapshots().notifications } +pub fn use_account_switcher_open() -> bool { + use_snapshots().account_switcher_open +} + pub fn use_game_snapshot() -> GameSnapshot { use_snapshots().game } +pub fn use_microsoft_login_status() -> Option { + use_snapshots().microsoft_login +} + pub fn use_java_snapshot() -> JavaSnapshot { use_snapshots().java } @@ -112,10 +125,12 @@ impl Component for DataSync { last_clusters.set(clusters_pulse); spawn(async move { invalidate_cluster_queries().await }); } + if *last_profiles.peek() != profiles_pulse { last_profiles.set(profiles_pulse); spawn(async move { invalidate_profile_queries().await }); } + if *last_java.peek() != java_pulse { last_java.set(java_pulse); spawn(async move { invalidate_java_queries().await }); diff --git a/packages/oneclient_app/src/hooks/queries/auth.rs b/packages/oneclient_app/src/hooks/queries/auth.rs index dfed2d9b..d777507c 100644 --- a/packages/oneclient_app/src/hooks/queries/auth.rs +++ b/packages/oneclient_app/src/hooks/queries/auth.rs @@ -6,7 +6,7 @@ use freya::query::{ QueryStateData, UseMutation, UseQuery, use_mutation, use_query, }; use oneclient_core::LauncherError; -use oneclient_core::auth::{self, AccountKind, MinecraftAccount, MinecraftLogin}; +use oneclient_core::auth::{self, AccountKind, MicrosoftLoginSession, MinecraftAccount}; use uuid::Uuid; static HANDLED_LOGIN_CODE: Mutex> = Mutex::new(None); @@ -20,6 +20,10 @@ pub fn login_code_already_handled(user_code: &str) -> bool { false } +pub fn reset_login_code_dedup() { + *HANDLED_LOGIN_CODE.lock().unwrap() = None; +} + #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] pub struct ListAccountsKeys; @@ -141,7 +145,7 @@ async fn invalidate_auth_queries(account_id: Option) { pub struct BeginMicrosoftLoginMutation; impl MutationCapability for BeginMicrosoftLoginMutation { - type Ok = MinecraftLogin; + type Ok = MicrosoftLoginSession; type Err = LauncherError; type Keys = (); @@ -156,7 +160,7 @@ pub struct FinishMicrosoftLoginMutation; impl MutationCapability for FinishMicrosoftLoginMutation { type Ok = MinecraftAccount; type Err = LauncherError; - type Keys = MinecraftLogin; + type Keys = MicrosoftLoginSession; async fn run(&self, flow: &Self::Keys) -> Result { auth::finish_microsoft_login(flow.clone()).await @@ -169,6 +173,27 @@ impl MutationCapability for FinishMicrosoftLoginMutation { } } +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct CancelMicrosoftLoginMutation; + +/// Drops a pending browser login server-side when the user cancels before the +/// flow reaches the point of no return. Keyed by the session's CSRF state token. +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub struct CancelMicrosoftLoginKeys { + pub state_token: String, +} + +impl MutationCapability for CancelMicrosoftLoginMutation { + type Ok = (); + type Err = LauncherError; + type Keys = CancelMicrosoftLoginKeys; + + async fn run(&self, keys: &Self::Keys) -> Result { + auth::cancel_microsoft_login(&keys.state_token).await; + Ok(()) + } +} + #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] pub struct AddMicrosoftAccountMutation; @@ -270,7 +295,7 @@ pub struct RefreshAccountKeys { } impl MutationCapability for RefreshAccountMutation { - type Ok = Option; + type Ok = MinecraftAccount; type Err = LauncherError; type Keys = RefreshAccountKeys; @@ -316,6 +341,10 @@ pub fn use_finish_microsoft_login() -> UseMutation use_mutation(Mutation::new(FinishMicrosoftLoginMutation)) } +pub fn use_cancel_microsoft_login() -> UseMutation { + use_mutation(Mutation::new(CancelMicrosoftLoginMutation)) +} + pub fn use_add_microsoft_account() -> UseMutation { use_mutation(Mutation::new(AddMicrosoftAccountMutation)) } diff --git a/packages/oneclient_app/src/hooks/queries/image.rs b/packages/oneclient_app/src/hooks/queries/image.rs index 10a98016..41b16178 100644 --- a/packages/oneclient_app/src/hooks/queries/image.rs +++ b/packages/oneclient_app/src/hooks/queries/image.rs @@ -21,6 +21,7 @@ impl QueryCapability for CachedImageQuery { type Err = LauncherError; type Keys = CachedImageKeys; + #[tracing::instrument(name = "cached_image", level = "debug", skip(self, keys), fields(url = %keys.url, max_edge = keys.max_edge))] async fn run(&self, keys: &Self::Keys) -> Result { if keys.url.is_empty() { return Err(LauncherError::Minecraft("no image url".to_string())); diff --git a/packages/oneclient_app/src/hooks/queries/mod.rs b/packages/oneclient_app/src/hooks/queries/mod.rs index 4b806c7a..ac8cd273 100644 --- a/packages/oneclient_app/src/hooks/queries/mod.rs +++ b/packages/oneclient_app/src/hooks/queries/mod.rs @@ -21,13 +21,15 @@ pub use analytics::{ try_cluster_analytics, try_global_analytics, use_cluster_analytics, use_global_analytics, }; 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, - use_finish_microsoft_login, use_refresh_account, use_refresh_all_accounts, use_remove_account, - use_set_default_account, + AddOfflineAccountKeys, BeginMicrosoftLoginMutation, CancelMicrosoftLoginKeys, + CancelMicrosoftLoginMutation, FinishMicrosoftLoginMutation, RefreshAccountKeys, + RemoveAccountKeys, SetDefaultAccountKeys, UseRefreshAccount, UseRemoveAccount, + UseSetDefaultAccount, accounts_have_microsoft, login_code_already_handled, mutation_error, + mutation_is_pending, reset_login_code_dedup, try_account, try_accounts, try_default_account, + use_account, use_accounts, use_add_microsoft_account, use_add_offline_account, + use_begin_microsoft_login, use_cancel_microsoft_login, use_current_account, + use_default_account, use_finish_microsoft_login, use_refresh_account, use_refresh_all_accounts, + use_remove_account, use_set_default_account, }; pub use bundles::{ ClusterBundles, OnboardingBundlesQuery, bundle_overrides_map, bundles_with_status_items, diff --git a/packages/oneclient_app/src/hooks/queries/packages.rs b/packages/oneclient_app/src/hooks/queries/packages.rs index 0a46101a..85c427a2 100644 --- a/packages/oneclient_app/src/hooks/queries/packages.rs +++ b/packages/oneclient_app/src/hooks/queries/packages.rs @@ -39,6 +39,7 @@ impl QueryCapability for PackageSearchQuery { type Err = LauncherError; type Keys = PackageSearchKeys; + #[tracing::instrument(name = "package_search", level = "debug", skip(self, keys), fields(provider = ?keys.provider, page = keys.page))] async fn run(&self, keys: &Self::Keys) -> Result { let state = LauncherState::get()?; let provider = state.services.packages.get(keys.provider)?; @@ -165,6 +166,7 @@ impl QueryCapability for PackageMetaBatchQuery { type Err = LauncherError; type Keys = PackageMetaBatchKeys; + #[tracing::instrument(name = "package_meta_batch", level = "debug", skip(self, keys), fields(provider = ?keys.provider, ids = keys.project_ids.len()))] async fn run(&self, keys: &Self::Keys) -> Result { let state = LauncherState::get()?; oneclient_core::packages::fetch_package_meta( @@ -286,6 +288,7 @@ impl QueryCapability for PackageCategoriesQuery { type Err = LauncherError; type Keys = PackageCategoriesKeys; + #[tracing::instrument(name = "package_categories", level = "debug", skip(self, keys), fields(provider = ?keys.provider))] async fn run(&self, keys: &Self::Keys) -> Result { let state = LauncherState::get()?; let provider = state.services.packages.get(keys.provider)?; diff --git a/packages/oneclient_app/src/layout/cluster_shell.rs b/packages/oneclient_app/src/layout/cluster_shell.rs index 5f7e3f1a..49b13df2 100644 --- a/packages/oneclient_app/src/layout/cluster_shell.rs +++ b/packages/oneclient_app/src/layout/cluster_shell.rs @@ -249,14 +249,13 @@ fn cluster_header( .horizontal() .content(Content::Flex) .width(Size::fill()) - .height(Size::px(HEADER_HEIGHT)) + .min_height(Size::px(HEADER_HEIGHT)) .cross_align(Alignment::Center) .spacing(12.) .child( rect() .vertical() .width(Size::flex(1.0)) - .height(Size::fill()) .main_align(Alignment::Center) .spacing(6.) .child( diff --git a/packages/oneclient_app/src/layout/root_layout.rs b/packages/oneclient_app/src/layout/root_layout.rs index 96a44da0..c8d6df51 100644 --- a/packages/oneclient_app/src/layout/root_layout.rs +++ b/packages/oneclient_app/src/layout/root_layout.rs @@ -1,7 +1,9 @@ use freya::prelude::*; use freya::router::*; -use crate::components::{NotificationCenter, StatusBar, Toasts, UpdatePromptOverlay}; +use crate::components::{ + AccountSwitcher, ClusterUpdatePopup, NotificationCenter, StatusBar, Toasts, UpdatePromptOverlay, +}; use crate::routes::Route; use crate::theme; use crate::theme::colors; @@ -26,8 +28,10 @@ impl Component for RootLayout { .child(Outlet::::new()), ) .child(NotificationCenter) + .child(AccountSwitcher) .child(Toasts) .child(UpdatePromptOverlay) + .child(ClusterUpdatePopup) .child(StatusBar) } } diff --git a/packages/oneclient_app/src/layout/settings_shell.rs b/packages/oneclient_app/src/layout/settings_shell.rs index b9d196cb..773be6c1 100644 --- a/packages/oneclient_app/src/layout/settings_shell.rs +++ b/packages/oneclient_app/src/layout/settings_shell.rs @@ -717,6 +717,9 @@ impl Component for SidebarItem { fn render(&self) -> impl IntoElement { let mut hovering = use_state(|| false); + let a11y_id = use_a11y(); + let focus = use_focus(a11y_id); + let tab = self.tab; let active = self.active; let route = tab.route(); @@ -731,7 +734,7 @@ impl Component for SidebarItem { let has_dot = matches!(tab, SettingsTab::Changelog); - rect() + let mut el = rect() .horizontal() .content(Content::Flex) .width(Size::fill()) @@ -741,6 +744,9 @@ impl Component for SidebarItem { .padding(Gaps::new_symmetric(0., 12.)) .corner_radius(CornerRadius::new_all(7.)) .background(background) + .a11y_id(a11y_id) + .a11y_focusable(true) + .a11y_role(AccessibilityRole::Button) .on_pointer_enter(move |_| { *hovering.write() = true; Cursor::set(CursorIcon::Pointer); @@ -750,11 +756,21 @@ impl Component for SidebarItem { Cursor::set(CursorIcon::default()); }) .map(route, |el, route| { - el.on_press(move |_| { + el.on_all_press(move |_| { let _ = RouterContext::get().push(route.clone()); }) - }) - .child(Icon::new(tab.icon()).size(18.).color(colors::fg_primary())) + }); + + if focus().is_focused() { + el = el.border( + Border::new() + .fill(colors::brand()) + .width(1.) + .alignment(BorderAlignment::Inner), + ); + } + + el.child(Icon::new(tab.icon()).size(18.).color(colors::fg_primary())) .child( rect() .horizontal() diff --git a/packages/oneclient_app/src/lib.rs b/packages/oneclient_app/src/lib.rs index 066daa30..673e9f73 100644 --- a/packages/oneclient_app/src/lib.rs +++ b/packages/oneclient_app/src/lib.rs @@ -1,5 +1,7 @@ // TODO: Remove this once freya has some workaround for this #![allow(float_literal_f32_fallback)] +// Deeply nested async call chains through instrumented oneclient_core fns exceed the default limit +#![recursion_limit = "256"] mod assets; mod bridge; diff --git a/packages/oneclient_app/src/notifications/mod.rs b/packages/oneclient_app/src/notifications.rs similarity index 93% rename from packages/oneclient_app/src/notifications/mod.rs rename to packages/oneclient_app/src/notifications.rs index 19d515f9..4b829575 100644 --- a/packages/oneclient_app/src/notifications/mod.rs +++ b/packages/oneclient_app/src/notifications.rs @@ -4,6 +4,7 @@ use std::time::{Duration, Instant}; use oneclient_core::notification::{ GroupedProgressEvent, Notification, NotificationLevel, PromptKind, UserChoice, }; +use oneclient_db::models::ClusterId; use tokio::sync::oneshot; use uuid::Uuid; @@ -11,6 +12,32 @@ use crate::components::IconType; pub const MESSAGE_TOAST_TTL: Duration = Duration::from_secs(5); +#[derive(Clone, Debug, PartialEq)] +pub struct NotificationAction { + pub label: String, + pub kind: NotificationActionKind, +} + +#[derive(Clone, Debug, PartialEq)] +pub enum NotificationActionKind { + OpenClusterUpdate(ClusterUpdateSummary), +} + +#[derive(Clone, Debug, PartialEq)] +pub struct ClusterUpdateSummary { + pub cluster_id: ClusterId, + pub cluster_name: String, + pub updated: Vec, + pub added: Vec, + pub removed: Vec, +} + +impl ClusterUpdateSummary { + pub fn total(&self) -> usize { + self.updated.len() + self.added.len() + self.removed.len() + } +} + #[derive(Clone, Debug, PartialEq)] pub struct InboxEntry { pub level: NotificationLevel, @@ -22,7 +49,7 @@ pub struct InboxEntry { pub is_loading: bool, pub read: bool, pub created_at: Instant, - pub actions: Vec, + pub actions: Vec, pub tasks: Vec, } @@ -41,7 +68,7 @@ pub struct NotificationSpec { pub level: NotificationLevel, pub icon: Option, pub progress: Option<(u64, u64)>, - pub actions: Vec, + pub actions: Vec, } impl InboxEntry { @@ -89,6 +116,7 @@ pub struct NotificationSnapshot { pub inbox: Vec, pub center_open: bool, pub pending_prompt: Option, + pub cluster_update: Option, pub active_toast_entry_ids: Vec, } @@ -106,6 +134,7 @@ pub struct NotificationState { grouped_entries: HashMap, grouped_tasks: HashMap, pending_timers: Vec, + cluster_update: Option, } #[derive(Default)] @@ -145,10 +174,19 @@ impl NotificationState { inbox: inbox.to_vec(), center_open, pending_prompt, + cluster_update: self.cluster_update.clone(), active_toast_entry_ids: self.active_toasts.iter().map(|t| t.entry_id).collect(), } } + pub fn open_cluster_update(&mut self, summary: ClusterUpdateSummary) { + self.cluster_update = Some(summary); + } + + pub fn close_cluster_update(&mut self) { + self.cluster_update = None; + } + pub fn unread_count(inbox: &[InboxEntry]) -> usize { inbox.iter().filter(|entry| !entry.read).count() } @@ -183,6 +221,7 @@ impl NotificationState { Notification::InvalidateClusters => {} Notification::InvalidateJava => {} Notification::SyncComplete => {} + Notification::MicrosoftLoginStatus(_) => {} Notification::GameStage { .. } | Notification::GameLog { .. } | Notification::GameFailed { .. } => {} @@ -268,7 +307,8 @@ impl NotificationState { } pub fn expire_toast(&mut self, _inbox: &[InboxEntry], entry_id: u64) { - self.active_toasts.retain(|toast| toast.entry_id != entry_id); + self.active_toasts + .retain(|toast| toast.entry_id != entry_id); } pub fn mark_read(&mut self, inbox: &mut [InboxEntry], entry_id: u64) { @@ -281,7 +321,7 @@ impl NotificationState { if inbox .iter() .find(|e| e.id == entry_id) - .is_some_and(|e| e.click_dismissable()) + .is_some_and(|e| e.dismissable()) { self.forget_entry(inbox, entry_id); } diff --git a/packages/oneclient_app/src/platform.rs b/packages/oneclient_app/src/platform.rs index fb5dee59..d6cc40a1 100644 --- a/packages/oneclient_app/src/platform.rs +++ b/packages/oneclient_app/src/platform.rs @@ -1,56 +1,9 @@ - -#[cfg(not(target_os = "windows"))] pub fn open_url(url: &str) { - let result = { - #[cfg(target_os = "linux")] - { - std::process::Command::new("xdg-open").arg(url).spawn() - } - #[cfg(target_os = "macos")] - { - std::process::Command::new("open").arg(url).spawn() - } - }; - - if let Err(err) = result { + if let Err(err) = open::that_detached(url) { tracing::warn!("failed to open url {url}: {err}"); } } -#[cfg(target_os = "windows")] -pub fn open_url(url: &str) { - use std::os::windows::ffi::OsStrExt; - use windows_sys::Win32::UI::Shell::ShellExecuteW; - - // SW_SHOWNORMAL - const SW_SHOWNORMAL: i32 = 1; - - fn wide(s: &str) -> Vec { - std::ffi::OsStr::new(s) - .encode_wide() - .chain(std::iter::once(0)) - .collect() - } - - let operation = wide("open"); - let file = wide(url); - - let ret = unsafe { - ShellExecuteW( - std::ptr::null_mut(), - operation.as_ptr(), - file.as_ptr(), - std::ptr::null(), - std::ptr::null(), - SW_SHOWNORMAL, - ) - }; - - if (ret as isize) <= 32 { - tracing::warn!("failed to open url {url}: ShellExecuteW returned {}", ret as isize); - } -} - pub fn copy_image_to_clipboard(path: std::path::PathBuf) { std::thread::spawn(move || { let img = match image::open(&path) { @@ -95,13 +48,9 @@ pub mod macos { fn release_unused_memory() { unsafe { unsafe extern "C" { - fn malloc_zone_pressure_relief( - zone: *mut core::ffi::c_void, - goal: usize, - ) -> usize; + fn malloc_zone_pressure_relief(zone: *mut core::ffi::c_void, goal: usize) -> usize; } malloc_zone_pressure_relief(core::ptr::null_mut(), 0); } } } - diff --git a/packages/oneclient_app/src/view/app/accounts.rs b/packages/oneclient_app/src/view/app/accounts.rs index ec4a886c..c94bc143 100644 --- a/packages/oneclient_app/src/view/app/accounts.rs +++ b/packages/oneclient_app/src/view/app/accounts.rs @@ -1,23 +1,21 @@ use chrono::Utc; use freya::prelude::*; use freya::query::{MutationCapability, MutationStateData, UseMutation}; -use freya::text_edit::Clipboard; -use oneclient_core::auth::{AccountKind, MinecraftAccount, MinecraftLogin}; +use oneclient_core::auth::{AccountKind, MinecraftAccount}; use crate::components::{ Avatar, Button, Icon, IconType, OverlayPopup, PlayerModel, ScrollArea, TextInput, + use_microsoft_login, }; use crate::hooks::{ AddOfflineAccountKeys, RefreshAccountKeys, RemoveAccountKeys, SetDefaultAccountKeys, - 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, + accounts_have_microsoft, try_accounts, try_default_account, use_accounts, + use_add_offline_account, use_current_account, use_refresh_account, use_remove_account, + use_set_default_account, }; -use crate::platform; use crate::theme::colors; use crate::ui::border_all_color; - #[derive(PartialEq)] pub struct Accounts; @@ -26,44 +24,16 @@ impl Component for Accounts { let accounts_query = use_accounts(); let default_query = use_current_account(); - let begin = use_begin_microsoft_login(); - let finish = use_finish_microsoft_login(); + let msa = use_microsoft_login(); let add_offline = use_add_offline_account(); let set_default = use_set_default_account(); let remove = use_remove_account(); let refresh = use_refresh_account(); let mut username = use_state(String::new); - let mut pending_login = use_state(|| None::); - let mut handled_code = use_state(|| None::); let mut show_offline = use_state(|| false); let mut closing_offline = use_state(|| false); - use_side_effect(move || { - let login = match &*begin.read().state() { - MutationStateData::Settled { res: Ok(login), .. } => Some(login.clone()), - _ => None, - }; - let Some(login) = login else { return }; - if login_code_already_handled(login.dedupe_key()) { - return; - } - handled_code.set(Some(login.dedupe_key().to_string())); - if let Some(url) = login.browser_url() { - platform::open_url(url); - } - finish.mutate(login.clone()); - pending_login.set(Some(login)); - }); - - use_side_effect(move || { - if matches!(&*finish.read().state(), MutationStateData::Settled { .. }) - && pending_login.peek().is_some() - { - pending_login.set(None); - } - }); - use_side_effect(move || { if !*closing_offline.read() { return; @@ -84,15 +54,13 @@ impl Component for Accounts { let accounts = try_accounts(&accounts_query).unwrap_or_default(); let default_account = try_default_account(&default_query); let default_id = default_account.as_ref().map(|a| a.id); - let microsoft_pending = - begin.read().state().is_loading() || finish.read().state().is_loading(); let has_microsoft = accounts_have_microsoft(&accounts); let offline_name = username.read().trim().to_string(); let offline_uuid = (!offline_name.is_empty()) .then(|| oneclient_core::auth::offline_uuid(&offline_name).to_string()); + let offline_error = mutation_err_text(&add_offline); - let microsoft_error = mutation_err_text(&finish).or_else(|| mutation_err_text(&begin)); let on_confirm_offline = move |_| { let name = username.peek().trim().to_string(); @@ -144,10 +112,13 @@ impl Component for Accounts { ) .child(add_bar( has_microsoft, - microsoft_pending, - microsoft_error, + msa.pending, + msa.error.clone(), move |_| show_offline.set(true), - move |_| begin.mutate(()), + { + let msa = msa.clone(); + move |_| msa.start() + }, )) .child( ScrollArea::new() @@ -166,12 +137,7 @@ impl Component for Accounts { show_offline, ) })) - .maybe_child( - pending_login - .read() - .clone() - .map(|login| microsoft_dialog(login, pending_login, handled_code)), - ) + .maybe_child(msa.popup()) .into_element() } } @@ -187,33 +153,6 @@ fn render_panel(account: Option) -> impl IntoElement { .corner_radius(CornerRadius::new_all(16.)) .background(colors::page_elevated()) .border(border_all_color(1., colors::component_border())) - // .maybe_child(account.as_ref().map(|a| { - // Avatar::new(a.id.to_string()) - // .width(Size::px(120.)) - // .height(Size::px(120.)) - // .into_element() - // })) - // .maybe_child(account.as_ref().map(|a| { - // label() - // .text(a.username.clone()) - // .font_size(18.) - // .font_weight(FontWeight::SEMI_BOLD) - // .color(colors::fg_primary()) - // .into_element() - // })) - // .maybe_child(account.is_none().then(|| { - // label() - // .text("No account selected") - // .font_size(14.) - // .color(colors::fg_secondary()) - // .into_element() - // })) - // .child( - // label() - // .text("3D preview coming soon") - // .font_size(11.) - // .color(colors::fg_secondary()), - // ) .maybe_child(account.as_ref().map(|account| { PlayerModel::new(account.id) .yaw(-0.5) @@ -509,166 +448,6 @@ fn account_row( .into_element() } -fn microsoft_dialog( - login: MinecraftLogin, - mut pending_login: State>, - mut handled_code: State>, -) -> impl IntoElement { - let body = match &login { - MinecraftLogin::DeviceCode(flow) => { - device_code_dialog_body(flow.user_code.clone(), flow.verification_uri.clone()) - .into_element() - } - MinecraftLogin::Browser(flow) => { - browser_dialog_body(flow.auth_url.clone()).into_element() - } - }; - - let mut close_pending = pending_login; - let mut close_handled = handled_code; - - OverlayPopup::new() - .on_close(move |()| { - pending_login.set(None); - handled_code.set(None); - }) - .child( - rect() - .width(Size::window_percent(100.)) - .height(Size::window_percent(100.)) - .center() - .child( - rect() - .vertical() - .width(Size::px(420.)) - .max_width(Size::window_percent(90.)) - .cross_align(Alignment::Center) - .spacing(18.) - .padding(Gaps::new_all(28.)) - .corner_radius(CornerRadius::new_all(16.)) - .background(colors::page_elevated()) - .border(border_all_color(1., colors::component_border())) - .child( - label() - .text("Sign in to Microsoft") - .font_size(18.) - .font_weight(FontWeight::SEMI_BOLD) - .color(colors::fg_primary()), - ) - .child(body) - .child(waiting_row()) - .child( - Button::new() - .ghost() - .on_press(move |_| { - close_pending.set(None); - close_handled.set(None); - }) - .text("Cancel"), - ), - ), - ) - .into_element() -} - -fn waiting_row() -> impl IntoElement { - rect() - .horizontal() - .cross_align(Alignment::Center) - .spacing(6.) - .child( - Icon::new(IconType::Loading02) - .size(13.) - .color(colors::brand()), - ) - .child( - label() - .text("Waiting for you to finish signing in...") - .font_size(12.) - .color(colors::fg_secondary()), - ) - .into_element() -} - -fn browser_dialog_body(auth_url: String) -> impl IntoElement { - rect() - .vertical() - .width(Size::fill()) - .cross_align(Alignment::Center) - .spacing(18.) - .child( - label() - .text("Your browser has opened the Microsoft sign-in page. Complete sign-in there and you'll be brought back automatically.") - .font_size(13.) - .color(colors::fg_secondary()), - ) - .child( - Button::new() - .primary() - .on_press(move |_| platform::open_url(&auth_url)) - .child(Icon::new(IconType::LinkExternal01).size(16.)) - .text("Open in browser again"), - ) - .into_element() -} - -fn device_code_dialog_body(code: String, verification_uri: String) -> impl IntoElement { - let copy_code = code.clone(); - rect() - .vertical() - .width(Size::fill()) - .cross_align(Alignment::Center) - .spacing(18.) - .child( - label() - .text("Enter this code on the Microsoft sign-in page:") - .font_size(13.) - .color(colors::fg_secondary()), - ) - .child( - rect() - .width(Size::fill()) - .center() - .padding(Gaps::new_symmetric(20., 16.)) - .corner_radius(CornerRadius::new_all(14.)) - .background(colors::component_bg()) - .border(border_all_color(1., colors::brand())) - .child( - label() - .text(code) - .font_size(48.) - .font_weight(FontWeight::BOLD) - .color(colors::fg_primary()), - ), - ) - .child( - rect() - .horizontal() - .width(Size::fill()) - .main_align(Alignment::Center) - .spacing(8.) - .child( - Button::new() - .secondary() - .on_press(move |_| { - if let Err(err) = Clipboard::set(copy_code.clone()) { - tracing::warn!("clipboard copy failed: {err:?}"); - } - }) - .child(Icon::new(IconType::Copy01).size(16.)) - .text("Copy code"), - ) - .child( - Button::new() - .primary() - .on_press(move |_| platform::open_url(&verification_uri)) - .child(Icon::new(IconType::LinkExternal01).size(16.)) - .text("Open in browser"), - ), - ) - .into_element() -} - fn kind_badge(kind: AccountKind) -> impl IntoElement { let (icon, text) = match kind { AccountKind::Microsoft => (IconType::Globe01, "Microsoft"), diff --git a/packages/oneclient_app/src/view/app/browser/index/cards.rs b/packages/oneclient_app/src/view/app/browser/index/cards.rs index f734b8eb..bc9c10cb 100644 --- a/packages/oneclient_app/src/view/app/browser/index/cards.rs +++ b/packages/oneclient_app/src/view/app/browser/index/cards.rs @@ -66,6 +66,10 @@ impl Component for PackageCard { let icon_url = self.item.icon_url.clone(); let cluster_id = self.cluster_id; + let a11y_id = use_a11y(); + let focus = use_focus(a11y_id); + let focused = focus().is_focused(); + use_drop(|| { Cursor::set(CursorIcon::default()); }); @@ -76,11 +80,21 @@ impl Component for PackageCard { .height(Size::px(CARD_H)) .corner_radius(CornerRadius::new_all(10.)) .background(CARD_BG) - .border(border_all_color(1., colors::component_border())) + .border(border_all_color( + 1., + if focused { + colors::brand() + } else { + colors::component_border() + }, + )) .overflow(Overflow::Clip) + .a11y_id(a11y_id) + .a11y_focusable(true) + .a11y_role(AccessibilityRole::Button) .on_pointer_enter(|_| Cursor::set(CursorIcon::Pointer)) .on_pointer_leave(|_| Cursor::set(CursorIcon::default())) - .on_press(move |_| open_package(cluster_id, &package_type, provider, &id)) + .on_all_press(move |_| open_package(cluster_id, &package_type, provider, &id)) .child( rect() .margin(Gaps::new_all(1.)) @@ -141,9 +155,35 @@ impl Component for PackageCard { } pub(super) fn list_row(item: ProjectSummary, cluster_id: i64, package_type: &str) -> impl IntoElement { - let id = item.id.clone(); - let provider = item.provider; - let package_type = package_type.to_string(); + ListRow { + item, + cluster_id, + package_type: package_type.to_string(), + } +} + +#[derive(PartialEq)] +struct ListRow { + item: ProjectSummary, + cluster_id: i64, + package_type: String, +} + +impl Component for ListRow { + fn render(&self) -> impl IntoElement { + let item = self.item.clone(); + let cluster_id = self.cluster_id; + let id = item.id.clone(); + let provider = item.provider; + let package_type = self.package_type.clone(); + + let a11y_id = use_a11y(); + let focus = use_focus(a11y_id); + let focused = focus().is_focused(); + + use_drop(|| { + Cursor::set(CursorIcon::default()); + }); rect() .horizontal() @@ -154,12 +194,22 @@ pub(super) fn list_row(item: ProjectSummary, cluster_id: i64, package_type: &str .padding(Gaps::new_all(16.)) .corner_radius(CornerRadius::new_all(10.)) .background(CARD_BG) - .border(border_all_color(1., colors::component_border())) + .border(border_all_color( + 1., + if focused { + colors::brand() + } else { + colors::component_border() + }, + )) .overflow(Overflow::Clip) .content(Content::Flex) + .a11y_id(a11y_id) + .a11y_focusable(true) + .a11y_role(AccessibilityRole::Button) .on_pointer_enter(|_| Cursor::set(CursorIcon::Pointer)) .on_pointer_leave(|_| Cursor::set(CursorIcon::default())) - .on_press(move |_| open_package(cluster_id, &package_type, provider, &id)) + .on_all_press(move |_| open_package(cluster_id, &package_type, provider, &id)) .child(Thumbnail::new(item.icon_url.clone(), 48.).radius(8.)) .child( rect() @@ -198,6 +248,7 @@ pub(super) fn list_row(item: ProjectSummary, cluster_id: i64, package_type: &str ), ) .child(downloads_row(item.downloads)) + } } fn downloads_row(downloads: u64) -> impl IntoElement { diff --git a/packages/oneclient_app/src/view/app/browser/index/sidebar.rs b/packages/oneclient_app/src/view/app/browser/index/sidebar.rs index 5faa471f..1e03d381 100644 --- a/packages/oneclient_app/src/view/app/browser/index/sidebar.rs +++ b/packages/oneclient_app/src/view/app/browser/index/sidebar.rs @@ -77,10 +77,15 @@ impl KeyExt for CategoryRow { impl Component for CategoryRow { fn render(&self) -> impl IntoElement { let mut hovering = use_state(|| false); + + let a11y_id = use_a11y(); + let focus = use_focus(a11y_id); + let selected = self.selected; let on_toggle = self.on_toggle.clone(); let hovered = *hovering.read(); - let color = if selected || hovered { + let focused = focus().is_focused(); + let color = if selected || hovered || focused { colors::fg_primary() } else { colors::fg_secondary() @@ -89,6 +94,9 @@ impl Component for CategoryRow { rect() .width(Size::fill()) .padding(Gaps::new_symmetric(3., 0.)) + .a11y_id(a11y_id) + .a11y_focusable(true) + .a11y_role(AccessibilityRole::Button) .on_pointer_enter(move |_| { hovering.set(true); Cursor::set(CursorIcon::Pointer); @@ -97,7 +105,7 @@ impl Component for CategoryRow { hovering.set(false); Cursor::set(CursorIcon::default()); }) - .on_press(move |_| on_toggle.call(())) + .on_all_press(move |_| on_toggle.call(())) .child( label() .text(capitalize(&self.name)) diff --git a/packages/oneclient_app/src/view/app/browser/package/mod.rs b/packages/oneclient_app/src/view/app/browser/package/mod.rs index bfee57af..db7aadd6 100644 --- a/packages/oneclient_app/src/view/app/browser/package/mod.rs +++ b/packages/oneclient_app/src/view/app/browser/package/mod.rs @@ -184,7 +184,7 @@ impl Component for BrowserPackage { .vertical() .width(Size::flex(1.0)) .spacing(12.) - .child(tabs(current, active_tab, has_gallery)) + .child(tabs(active_tab, has_gallery)) .child(body), ) .into_element(); diff --git a/packages/oneclient_app/src/view/app/browser/package/panels.rs b/packages/oneclient_app/src/view/app/browser/package/panels.rs index e054105f..c0e854d7 100644 --- a/packages/oneclient_app/src/view/app/browser/package/panels.rs +++ b/packages/oneclient_app/src/view/app/browser/package/panels.rs @@ -1,17 +1,21 @@ use super::*; +use freya::query::QueryStateData; use oneclient_core::packages::types::{ GalleryImage, PackageBody, ProjectDetail, ReleaseType, VersionSummary, }; use oneclient_core::packages::ProviderId; use crate::BridgeDispatch; -use crate::components::{Button, Icon, IconType}; -use crate::hooks::VERSIONS_PAGE_SIZE; +use crate::components::{Button, Icon, IconType, Segment, SegmentedControl}; +use crate::hooks::{use_cached_image, VERSIONS_PAGE_SIZE}; use crate::theme::colors; use crate::ui::border_all_color; use crate::utils::format_size; +const GALLERY_IMAGE_H: f32 = 360.; +const GALLERY_EDGE: u32 = 1024; + pub(super) fn loading_body() -> impl IntoElement { rect() @@ -27,40 +31,15 @@ pub(super) fn loading_body() -> impl IntoElement { .into_element() } -pub(super) fn tabs(current: usize, active_tab: State, has_gallery: bool) -> impl IntoElement { - let mut labels: Vec<&str> = vec!["About", "Versions"]; +pub(super) fn tabs(active_tab: State, has_gallery: bool) -> impl IntoElement { + let mut control = SegmentedControl::new(active_tab) + .height(36.) + .segment(Segment::new(0usize).label("About")) + .segment(Segment::new(1usize).label("Versions")); if has_gallery { - labels.push("Gallery"); + control = control.segment(Segment::new(2usize).label("Gallery")); } - rect() - .horizontal() - .spacing(6.) - .padding(Gaps::new_all(4.)) - .corner_radius(CornerRadius::new_all(9.)) - .background(colors::component_bg()) - .children(labels.into_iter().enumerate().map(move |(i, t)| { - let selected = i == current; - let mut active_tab = active_tab; - rect() - .center() - .padding(Gaps::new_symmetric(6., 16.)) - .corner_radius(CornerRadius::new_all(7.)) - .background(if selected { - colors::component_bg_pressed() - } else { - Color::TRANSPARENT - }) - .on_pointer_enter(|_| Cursor::set(CursorIcon::Pointer)) - .on_pointer_leave(|_| Cursor::set(CursorIcon::default())) - .on_press(move |_| *active_tab.write() = i) - .child(label().text(t).font_size(13.).color(if selected { - colors::fg_primary() - } else { - colors::fg_secondary() - })) - .into_element() - })) - .into_element() + control } pub(super) fn about_panel(project: &ProjectDetail) -> impl IntoElement { @@ -137,6 +116,31 @@ impl KeyExt for GalleryTile { impl Component for GalleryTile { fn render(&self) -> impl IntoElement { + let query = use_cached_image(Some(self.image.url.clone()), GALLERY_EDGE); + let reader = query.read(); + let loaded = match &*reader.state() { + QueryStateData::Settled { res: Ok(bytes), .. } + | QueryStateData::Loading { + res: Some(Ok(bytes)), + } => Some((self.image.url.clone(), bytes.clone())), + _ => None, + }; + + let preview = rect() + .width(Size::fill()) + .height(Size::px(GALLERY_IMAGE_H)) + .center() + .overflow(Overflow::Clip) + .background(colors::component_bg()) + .maybe_child(loaded.map(|(url, bytes)| { + ImageViewer::new((url, bytes)) + .width(Size::fill()) + .height(Size::fill()) + .aspect_ratio(AspectRatio::Max) + .image_cover(ImageCover::Center) + .into_element() + })); + rect() .vertical() .width(Size::fill()) @@ -144,7 +148,7 @@ impl Component for GalleryTile { .overflow(Overflow::Clip) .background(PANEL_BG) .border(border_all_color(1., colors::component_border())) - .child(PackageBanner::new(Some(self.image.url.clone()), 240.)) + .child(preview) .maybe(self.image.title.is_some(), |el| { el.child( rect() diff --git a/packages/oneclient_app/src/view/app/browser/package/sidebar.rs b/packages/oneclient_app/src/view/app/browser/package/sidebar.rs index 3543eb9a..3c4e40fc 100644 --- a/packages/oneclient_app/src/view/app/browser/package/sidebar.rs +++ b/packages/oneclient_app/src/view/app/browser/package/sidebar.rs @@ -174,44 +174,78 @@ fn authors_card(project: &ProjectDetail, confirm: State>) -> impl card("Authors", rows) } -fn author_row(member: &ProjectMember, mut confirm: State>) -> Element { - let url = member.url.clone(); - rect() - .horizontal() - .width(Size::fill()) - .cross_align(Alignment::Center) - .spacing(8.) - .corner_radius(CornerRadius::new_all(6.)) - .padding(Gaps::new_all(4.)) - .maybe(url.is_some(), |el| { - let url = url.clone().unwrap_or_default(); - el.on_pointer_enter(|_| Cursor::set(CursorIcon::Pointer)) - .on_pointer_leave(|_| Cursor::set(CursorIcon::default())) - .on_press(move |_| confirm.set(Some(url.clone()))) - }) - .child(Thumbnail::new(member.avatar_url.clone(), 32.).radius(6.)) - .child( - rect() - .vertical() - .width(Size::flex(1.0)) - .child( - label() - .text(member.name.clone()) - .font_size(12.) - .max_lines(1) - .color(colors::fg_primary()), - ) - .maybe(!member.role.is_empty(), |el| { - el.child( +fn author_row(member: &ProjectMember, confirm: State>) -> Element { + AuthorRow { + name: member.name.clone(), + role: member.role.clone(), + url: member.url.clone(), + avatar_url: member.avatar_url.clone(), + confirm, + } + .into_element() +} + +#[derive(PartialEq)] +struct AuthorRow { + name: String, + role: String, + url: Option, + avatar_url: Option, + confirm: State>, +} + +impl Component for AuthorRow { + fn render(&self) -> impl IntoElement { + let a11y_id = use_a11y(); + let focus = use_focus(a11y_id); + let focused = focus().is_focused(); + + let url = self.url.clone(); + let interactive = url.is_some(); + let mut confirm = self.confirm; + + rect() + .horizontal() + .width(Size::fill()) + .cross_align(Alignment::Center) + .spacing(8.) + .corner_radius(CornerRadius::new_all(6.)) + .padding(Gaps::new_all(4.)) + .maybe(interactive, |el| { + let url = url.clone().unwrap_or_default(); + el.a11y_id(a11y_id) + .a11y_focusable(true) + .a11y_role(AccessibilityRole::Button) + .on_pointer_enter(|_| Cursor::set(CursorIcon::Pointer)) + .on_pointer_leave(|_| Cursor::set(CursorIcon::default())) + .on_all_press(move |_| confirm.set(Some(url.clone()))) + }) + .maybe(interactive && focused, |el| { + el.border(border_all_color(1., colors::brand())) + }) + .child(Thumbnail::new(self.avatar_url.clone(), 32.).radius(6.)) + .child( + rect() + .vertical() + .width(Size::flex(1.0)) + .child( label() - .text(member.role.clone()) - .font_size(10.) + .text(self.name.clone()) + .font_size(12.) .max_lines(1) - .color(colors::fg_secondary()), + .color(colors::fg_primary()), ) - }), - ) - .into_element() + .maybe(!self.role.is_empty(), |el| { + el.child( + label() + .text(self.role.clone()) + .font_size(10.) + .max_lines(1) + .color(colors::fg_secondary()), + ) + }), + ) + } } fn details_card(project: &ProjectDetail) -> impl IntoElement { @@ -282,25 +316,57 @@ fn links_card(links: &[(String, String)], confirm: State>) -> imp card("Links", rows) } -fn link_row(label_text: String, url: String, mut confirm: State>) -> Element { - rect() - .horizontal() - .width(Size::fill()) - .cross_align(Alignment::Center) - .spacing(8.) - .on_pointer_enter(|_| Cursor::set(CursorIcon::Pointer)) - .on_pointer_leave(|_| Cursor::set(CursorIcon::default())) - .on_press(move |_| confirm.set(Some(url.clone()))) - .child( - Icon::new(IconType::Link03) - .size(14.) - .color(colors::code_info()), - ) - .child( - label() - .text(label_text) - .font_size(12.) - .color(colors::code_info()), - ) - .into_element() +fn link_row(label_text: String, url: String, confirm: State>) -> Element { + LinkRow { + label_text, + url, + confirm, + } + .into_element() +} + +#[derive(PartialEq)] +struct LinkRow { + label_text: String, + url: String, + confirm: State>, +} + +impl Component for LinkRow { + fn render(&self) -> impl IntoElement { + let a11y_id = use_a11y(); + let focus = use_focus(a11y_id); + let focused = focus().is_focused(); + + let url = self.url.clone(); + let mut confirm = self.confirm; + + rect() + .horizontal() + .width(Size::fill()) + .cross_align(Alignment::Center) + .spacing(8.) + .corner_radius(CornerRadius::new_all(6.)) + .padding(Gaps::new_all(4.)) + .a11y_id(a11y_id) + .a11y_focusable(true) + .a11y_role(AccessibilityRole::Button) + .maybe(focused, |el| { + el.border(border_all_color(1., colors::brand())) + }) + .on_pointer_enter(|_| Cursor::set(CursorIcon::Pointer)) + .on_pointer_leave(|_| Cursor::set(CursorIcon::default())) + .on_all_press(move |_| confirm.set(Some(url.clone()))) + .child( + Icon::new(IconType::Link03) + .size(14.) + .color(colors::code_info()), + ) + .child( + label() + .text(self.label_text.clone()) + .font_size(12.) + .color(colors::code_info()), + ) + } } diff --git a/packages/oneclient_app/src/view/app/cluster/cluster_settings.rs b/packages/oneclient_app/src/view/app/cluster/cluster_settings.rs index 15f7c16b..827836ee 100644 --- a/packages/oneclient_app/src/view/app/cluster/cluster_settings.rs +++ b/packages/oneclient_app/src/view/app/cluster/cluster_settings.rs @@ -6,7 +6,7 @@ use oneclient_core::packages::domain::GameLoader; use oneclient_core::settings::{GameSettingsProfile, ProfileUpdate, Resolution}; use crate::components::{ - Dropdown, Icon, IconType, ScrollArea, TextInput, toggle, toggle_controlled, validate_number, + Button, Dropdown, Icon, IconType, ScrollArea, TextInput, toggle, toggle_controlled, validate_number }; use crate::hooks::{ ClusterAction, java_runtimes, loader_versions, try_game_profile, use_cluster_mutation, @@ -147,16 +147,15 @@ fn reset_button(overridden: bool, on_reset: EventHandler<()>) -> impl IntoElemen colors::fg_secondary().with_a(60) }; - rect() - .center() - .width(Size::px(30.)) - .height(Size::px(30.)) + Button::new() + .small() + .ghost() + .icon() .corner_radius(CornerRadius::new_all(7.)) .maybe(overridden, |el| { - el.on_pointer_enter(|_| Cursor::set(CursorIcon::Pointer)) - .on_pointer_leave(|_| Cursor::set(CursorIcon::default())) - .on_press(move |_| on_reset.call(())) + el.on_press(move |_| on_reset.call(())) }) + .enabled(overridden) .child(Icon::new(IconType::RefreshCcw02).size(15.).color(color)) .into_element() } diff --git a/packages/oneclient_app/src/view/app/cluster/logs.rs b/packages/oneclient_app/src/view/app/cluster/logs.rs index 41d07fda..fa61a342 100644 --- a/packages/oneclient_app/src/view/app/cluster/logs.rs +++ b/packages/oneclient_app/src/view/app/cluster/logs.rs @@ -355,6 +355,9 @@ impl Component for LogPicker { let mut open = use_state(|| false); let filter = use_state(String::new); + let a11y_id = use_a11y(); + let focus = use_focus(a11y_id); + let enabled = self.enabled; let label_text = self.label.clone(); let files = self.files.clone(); @@ -400,14 +403,24 @@ impl Component for LogPicker { .padding(Gaps::new_symmetric(0., 12.)) .corner_radius(CornerRadius::new_all(9.)) .background(colors::component_bg()) - .border(ui::border_all_color(1., colors::component_border())) + .a11y_id(a11y_id) + .a11y_focusable(enabled) + .a11y_role(AccessibilityRole::Button) + .border(ui::border_all_color( + 1., + if enabled && focus().is_focused() { + colors::brand() + } else { + colors::component_border() + }, + )) .on_pointer_enter(move |_| { if enabled { Cursor::set(CursorIcon::Pointer) } }) .on_pointer_leave(|_| Cursor::set(CursorIcon::default())) - .on_press(move |e: Event| { + .on_all_press(move |e: Event| { if !enabled { return; } @@ -495,6 +508,9 @@ impl Component for PickerRow { let mut hovering = use_state(|| false); let file = self.file.clone(); + let a11y_id = use_a11y(); + let focus = use_focus(a11y_id); + rect() .width(Size::fill()) .horizontal() @@ -503,7 +519,10 @@ impl Component for PickerRow { .spacing(8.) .padding(Gaps::new_symmetric(6., 8.)) .corner_radius(CornerRadius::new_all(7.)) - .background(if hovering() { + .a11y_id(a11y_id) + .a11y_focusable(true) + .a11y_role(AccessibilityRole::Button) + .background(if hovering() || focus().is_focused() { colors::ghost_overlay_hover() } else { Color::TRANSPARENT @@ -516,7 +535,7 @@ impl Component for PickerRow { hovering.set(false); Cursor::set(CursorIcon::default()); }) - .on_press(self.on_press.clone()) + .on_all_press(self.on_press.clone()) .child( rect() .vertical() diff --git a/packages/oneclient_app/src/view/app/cluster/package_manager/views.rs b/packages/oneclient_app/src/view/app/cluster/package_manager/views.rs index 97d3953d..529adc16 100644 --- a/packages/oneclient_app/src/view/app/cluster/package_manager/views.rs +++ b/packages/oneclient_app/src/view/app/cluster/package_manager/views.rs @@ -1,5 +1,6 @@ use super::*; +use freya::animation::{AnimNum, Ease, OnCreation, use_animation}; use freya::router::RouterContext; use oneclient_core::packages::ContentType; use oneclient_core::settings::ViewLayout; @@ -184,7 +185,6 @@ struct FilterButton { impl Component for FilterButton { fn render(&self) -> impl IntoElement { let mut open = use_state(|| false); - let mut hovering = use_state(|| false); let sort = self.sort; let current_sort = self.current_sort; @@ -194,11 +194,6 @@ impl Component for FilterButton { let active = current_sort != SortMode::NameAsc || *enabled_filter.read() != EnabledFilter::All; - let bg = if is_open || hovering() { - colors::component_bg_hover() - } else { - colors::component_bg() - }; let icon_color = if active { colors::brand() } else { @@ -208,21 +203,11 @@ impl Component for FilterButton { rect() .width(Size::px(FILTER_BTN_W)) .child( - rect() + Button::new() + .secondary() + .icon() .width(Size::px(FILTER_BTN_W)) .height(Size::px(34.)) - .center() - .corner_radius(CornerRadius::new_all(8.)) - .background(bg) - .border(crate::ui::border_all_color(1., colors::component_border())) - .on_pointer_enter(move |_| { - hovering.set(true); - Cursor::set(CursorIcon::Pointer); - }) - .on_pointer_leave(move |_| { - hovering.set(false); - Cursor::set(CursorIcon::default()); - }) .on_press(move |e: Event| { e.stop_propagation(); open.toggle(); @@ -230,67 +215,112 @@ impl Component for FilterButton { .child(Icon::new(IconType::Sliders04).size(16.).color(icon_color)), ) .maybe_child(is_open.then(|| { - filter_popover(sort, current_sort, enabled_filter, move || open.set(false)) - .into_element() + let on_close: EventHandler<()> = (move |()| open.set(false)).into(); + FilterPopover { + sort, + current_sort, + enabled_filter, + on_close, + } + .into_element() })) } } -fn filter_popover( - mut sort: State>, +#[derive(PartialEq)] +struct FilterPopover { + sort: State>, current_sort: SortMode, - mut enabled_filter: State, - on_close: impl FnMut() + Clone + 'static, -) -> impl IntoElement { - let mut close_backdrop = on_close.clone(); + enabled_filter: State, + on_close: EventHandler<()>, +} - let show = *enabled_filter.read(); +impl Component for FilterPopover { + fn render(&self) -> impl IntoElement { + let mut sort = self.sort; + let current_sort = self.current_sort; + let mut enabled_filter = self.enabled_filter; + let backdrop_close = self.on_close.clone(); + let key_close = self.on_close.clone(); - let mut panel = rect() - .vertical() - .width(Size::fill()) - .spacing(4.) - .padding(Gaps::new_all(8.)) - .corner_radius(CornerRadius::new_all(10.)) - .border(crate::ui::border_all_color(1., colors::component_border())) - .background(colors::page_elevated()) - .child(section_label("Sort by")); + let a11y_id = use_a11y(); - for mode in SortMode::ALL { - let selected = mode == current_sort; - panel = panel.child(choice_row(mode.label(), selected, move |_| { - sort.set(Some(mode.key().to_string())); - })); + let fade = use_animation(|conf| { + conf.on_creation(OnCreation::Run); + AnimNum::new(0., 1.).time(160).ease(Ease::Out) + }); + let progress = fade.read().value(); + + let show = *enabled_filter.read(); + + let mut panel = rect() + .vertical() + .width(Size::fill()) + .spacing(4.) + .padding(Gaps::new_all(8.)) + .opacity(progress) + .offset_y((progress - 1.0) * 6.) + .corner_radius(CornerRadius::new_all(10.)) + .background(colors::page_elevated().with_a(230)) + .blur(12.) + .border(crate::ui::border_all_color(1., colors::component_border())) + .shadow(Shadow::from((0., 8., 32., 0., Color::from_argb(120, 0, 0, 0)))) + .a11y_id(a11y_id) + .a11y_role(AccessibilityRole::Dialog) + .on_global_key_down(move |e: Event| { + if e.key == Key::Named(NamedKey::Escape) { + key_close.call(()); + } + }) + .child(section_label("Sort by")); + + for mode in SortMode::ALL { + let selected = mode == current_sort; + let on_press: EventHandler> = (move |_| { + sort.set(Some(mode.key().to_string())); + }) + .into(); + panel = panel.child(ChoiceRow { + text: mode.label(), + selected, + on_press, + }); + } + + panel = panel.child(section_label("Show")); + for filter in EnabledFilter::ALL { + let selected = filter == show; + let on_press: EventHandler> = (move |_| { + enabled_filter.set(filter); + }) + .into(); + panel = panel.child(ChoiceRow { + text: filter.label(), + selected, + on_press, + }); + } + + rect() + .height(Size::px(0.)) + .width(Size::px(FILTER_PANEL_W)) + .layer(Layer::Overlay) + .child( + rect() + .layer(Layer::RelativeOverlay(10)) + .position(Position::new_global().top(0.).left(0.)) + .width(Size::window_percent(100.)) + .height(Size::window_percent(100.)) + .on_press(move |_| backdrop_close.call(())), + ) + .child( + rect() + .width(Size::fill()) + .layer(Layer::RelativeOverlay(12)) + .margin(Gaps::new(6., 0., 0., -(FILTER_PANEL_W - FILTER_BTN_W))) + .child(panel), + ) } - - panel = panel.child(section_label("Show")); - for filter in EnabledFilter::ALL { - let selected = filter == show; - panel = panel.child(choice_row(filter.label(), selected, move |_| { - enabled_filter.set(filter); - })); - } - - rect() - .height(Size::px(0.)) - .width(Size::px(FILTER_PANEL_W)) - .layer(Layer::Overlay) - .child( - rect() - .layer(Layer::RelativeOverlay(10)) - .position(Position::new_global().top(0.).left(0.)) - .width(Size::window_percent(100.)) - .height(Size::window_percent(100.)) - .on_press(move |_| close_backdrop()), - ) - .child( - rect() - .width(Size::fill()) - .layer(Layer::RelativeOverlay(12)) - .margin(Gaps::new(6., 0., 0., -(FILTER_PANEL_W - FILTER_BTN_W))) - .child(panel), - ) - .into_element() } fn section_label(text: &'static str) -> impl IntoElement { @@ -301,45 +331,68 @@ fn section_label(text: &'static str) -> impl IntoElement { .color(colors::fg_secondary()) } -fn choice_row( +#[derive(PartialEq)] +struct ChoiceRow { text: &'static str, selected: bool, - on_press: impl FnMut(Event) + 'static, -) -> impl IntoElement { - let color = if selected { - colors::fg_primary() - } else { - colors::fg_secondary() - }; - rect() - .horizontal() - .width(Size::fill()) - .cross_align(Alignment::Center) - .spacing(8.) - .padding(Gaps::new_symmetric(5., 8.)) - .corner_radius(CornerRadius::new_all(6.)) - .background(if selected { + on_press: EventHandler>, +} + +impl Component for ChoiceRow { + fn render(&self) -> impl IntoElement { + let text = self.text; + let selected = self.selected; + + let a11y_id = use_a11y(); + let focus = use_focus(a11y_id); + let focused = focus().is_focused(); + + let color = if selected { + colors::fg_primary() + } else { + colors::fg_secondary() + }; + + let bg = if selected { colors::component_bg() + } else if focused { + colors::ghost_overlay_hover() } else { Color::TRANSPARENT - }) - .content(Content::Flex) - .on_pointer_enter(|_| Cursor::set(CursorIcon::Pointer)) - .on_pointer_leave(|_| Cursor::set(CursorIcon::default())) - .on_press(on_press) - .child( - label() - .text(text) - .font_size(12.) - .width(Size::flex(1.0)) - .color(color), - ) - .maybe_child(selected.then(|| { - Icon::new(IconType::Check) - .size(14.) - .color(colors::brand()) - .into_element() - })) + }; + + rect() + .horizontal() + .width(Size::fill()) + .cross_align(Alignment::Center) + .spacing(8.) + .padding(Gaps::new_symmetric(5., 8.)) + .corner_radius(CornerRadius::new_all(6.)) + .background(bg) + .content(Content::Flex) + .a11y_id(a11y_id) + .a11y_focusable(true) + .a11y_role(AccessibilityRole::MenuItemRadio) + .maybe(focused, |el| { + el.border(crate::ui::border_all_color(1., colors::brand())) + }) + .on_pointer_enter(|_| Cursor::set(CursorIcon::Pointer)) + .on_pointer_leave(|_| Cursor::set(CursorIcon::default())) + .on_all_press(self.on_press.clone()) + .child( + label() + .text(text) + .font_size(12.) + .width(Size::flex(1.0)) + .color(color), + ) + .maybe_child(selected.then(|| { + Icon::new(IconType::Check) + .size(14.) + .color(colors::brand()) + .into_element() + })) + } } fn add_from_file_button( @@ -352,16 +405,18 @@ fn add_from_file_button( .on_press(move |_| { let dispatch = dispatch.clone(); spawn(async move { - if let Some(handle) = rfd::AsyncFileDialog::new() - .set_title("Select a file to import") - .pick_file() + if let Some(handles) = rfd::AsyncFileDialog::new() + .set_title("Select files to import") + .pick_files() .await { - dispatch.import_local_file( - cluster_id, - content_type, - handle.path().to_path_buf(), - ); + for handle in handles { + dispatch.import_local_file( + cluster_id, + content_type, + handle.path().to_path_buf(), + ); + } } }); }) @@ -470,14 +525,29 @@ impl Component for ContentBox { ContentKind::Other => empty_state(noun_plural).into_element(), }); + let header = (count > 0) + .then(|| match kind { + ContentKind::External => Some( + action_header(browse_button(cluster_id, package_type)).into_element(), + ), + ContentKind::Local => Some( + action_header(add_from_file_button(cluster_id, content_type, dispatch.clone())) + .into_element(), + ), + ContentKind::Other => None, + }) + .flatten(); + let mut bottom_corners = CornerRadius::new_all(0.); bottom_corners.fill_bottom(12.); let drop_dispatch = dispatch.clone(); rect() + .vertical() .width(Size::fill()) .height(Size::flex(1.0)) + .spacing(8.) .padding(Gaps::new_all(8.)) .corner_radius(bottom_corners) .background(colors::page_elevated()) @@ -498,11 +568,22 @@ impl Component for ContentBox { drop_dispatch.import_local_file(cluster_id, content_type, path.clone()); } }) + .maybe_child(header) .maybe_child(scroll) .maybe_child(empty) } } +fn action_header(button: impl IntoElement) -> impl IntoElement { + rect() + .horizontal() + .width(Size::fill()) + .cross_align(Alignment::Center) + .content(Content::Flex) + .child(rect().width(Size::flex(1.0))) + .child(button) +} + fn grid_content( items: &[PackageEntry], package_type: &'static str, diff --git a/packages/oneclient_app/src/view/app/debug.rs b/packages/oneclient_app/src/view/app/debug.rs index 68196d88..13e6b66b 100644 --- a/packages/oneclient_app/src/view/app/debug.rs +++ b/packages/oneclient_app/src/view/app/debug.rs @@ -3,6 +3,7 @@ use freya::router::RouterContext; use crate::components::{Button, Icon, IconType, TextInput, toggle}; use crate::hooks::use_dispatch; +use crate::notifications::{ClusterUpdateSummary, NotificationAction, NotificationActionKind}; use crate::routes::Route; use crate::theme::colors; @@ -20,48 +21,58 @@ impl Component for Debug { .vertical() .width(Size::fill()) .height(Size::fill()) - .overflow(Overflow::Clip) .padding(40.) .spacing(8.) .child( - rect() - .vertical() + ScrollView::new() .child( - label() - .text("Debug") - .font_size(32.) - .font_weight(FontWeight::BOLD) - .color(colors::fg_primary()), + rect() + .vertical() + .child( + label() + .text("Debug") + .font_size(32.) + .font_weight(FontWeight::BOLD) + .color(colors::fg_primary()), + ) + .child( + label() + .text("The end user won't really be looking at this page.") + .font_size(13.) + .color(colors::fg_secondary()), + ), ) - .child( - label() - .text("The end user won't really be looking at this page.") - .font_size(13.) - .color(colors::fg_secondary()), - ), + .child(divider()) + .child(section( + "Settings", + vec![ + toggle_row("Log Debug Info", log_debug_info), + toggle_row("Show Dev stuff", show_dev_stuff), + toggle_row("Seen Onboarding", seen_onboarding), + toggle_row("Use Grid On Mods List", use_grid_on_mods_list), + ], + )) + .child(divider()) + .child(section( + "Toast Controller", + vec![ToastController.into_element()], + )) + .child(divider()) + .child(section( + "Cluster Update Simulator", + vec![ClusterUpdateSimulator.into_element()], + )) + .child(divider()) + .child(section( + "Other", + vec![action_row(vec![ + ("Open Dev Tools", IconType::CodeSnippet02), + ("Open Onboarding", IconType::Rocket02), + ("Open Launcher Data", IconType::Folder), + ("Log Running Processes", IconType::Terminal), + ])], + )), ) - .child(divider()) - .child(section( - "Settings", - vec![ - toggle_row("Log Debug Info", log_debug_info), - toggle_row("Show Dev stuff", show_dev_stuff), - toggle_row("Seen Onboarding", seen_onboarding), - toggle_row("Use Grid On Mods List", use_grid_on_mods_list), - ], - )) - .child(divider()) - .child(section("Toast Controller", vec![ToastController.into_element()])) - .child(divider()) - .child(section( - "Other", - vec![action_row(vec![ - ("Open Dev Tools", IconType::CodeSnippet02), - ("Open Onboarding", IconType::Rocket02), - ("Open Launcher Data", IconType::Folder), - ("Log Running Processes", IconType::Terminal), - ])], - )) } } @@ -123,7 +134,8 @@ impl Component for ToastController { .child(Icon::new(IconType::AlertTriangle).size(16.)) .text("Send Error") .on_press(move |_| { - error.notify(title.read().clone()) + error + .notify(title.read().clone()) .body(body.read().clone()) .error() .send(); @@ -154,6 +166,101 @@ impl Component for ToastController { } } +#[derive(PartialEq)] +struct ClusterUpdateSimulator; + +impl Component for ClusterUpdateSimulator { + fn render(&self) -> impl IntoElement { + let dispatch = use_dispatch(); + let cluster_id = use_state(|| "1".to_string()); + let cluster_name = use_state(|| "PolyBlock".to_string()); + let updated = use_state(|| "Sodium 0.5 โ†’ 0.6, Iris 1.7 โ†’ 1.8".to_string()); + let added = use_state(|| "Lithium".to_string()); + let removed = use_state(|| "OptiFine".to_string()); + + let simulate = dispatch; + + rect() + .vertical() + .width(Size::fill()) + .spacing(10.) + .child( + rect() + .horizontal() + .width(Size::fill()) + .spacing(12.) + .child( + rect() + .width(Size::px(120.)) + .child(TextInput::new(cluster_id).placeholder("Cluster ID")), + ) + .child( + rect() + .width(Size::flex(1.0)) + .child(TextInput::new(cluster_name).placeholder("Cluster name")), + ), + ) + .child( + rect() + .horizontal() + .width(Size::fill()) + .spacing(12.) + .child( + rect().width(Size::flex(1.0)).child( + TextInput::new(updated).placeholder("Updated (comma separated)"), + ), + ) + .child( + rect() + .width(Size::flex(1.0)) + .child(TextInput::new(added).placeholder("Added (comma separated)")), + ) + .child( + rect().width(Size::flex(1.0)).child( + TextInput::new(removed).placeholder("Removed (comma separated)"), + ), + ), + ) + .child( + Button::new() + .primary() + .child(Icon::new(IconType::DownloadCloud02).size(16.)) + .text("Simulate Cluster Update") + .on_press(move |_| { + let name = cluster_name.read().clone(); + let summary = ClusterUpdateSummary { + cluster_id: cluster_id.read().trim().parse().unwrap_or(1), + cluster_name: name.clone(), + updated: split_csv(&updated.read()), + added: split_csv(&added.read()), + removed: split_csv(&removed.read()), + }; + let total = summary.total(); + simulate + .notify("Cluster updated") + .body(format!( + "{total} package{} changed in {name}", + if total == 1 { "" } else { "s" } + )) + .icon(IconType::DownloadCloud02) + .action(NotificationAction { + label: "View changes".to_string(), + kind: NotificationActionKind::OpenClusterUpdate(summary), + }) + .send(); + }), + ) + .into_element() + } +} + +fn split_csv(raw: &str) -> Vec { + raw.split(',') + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .collect() +} + fn divider() -> impl IntoElement { rect() .width(Size::fill()) @@ -230,10 +337,7 @@ fn toggle_row(title: &'static str, on: State) -> Element { } fn action_row(buttons: Vec<(&'static str, IconType)>) -> Element { - let mut row = rect() - .horizontal() - .width(Size::fill()) - .spacing(12.); + let mut row = rect().horizontal().width(Size::fill()).spacing(12.); for (text, icon) in buttons { let mut button = Button::new() diff --git a/packages/oneclient_app/src/view/app/settings/java.rs b/packages/oneclient_app/src/view/app/settings/java.rs index fe7277b0..5a9c3e63 100644 --- a/packages/oneclient_app/src/view/app/settings/java.rs +++ b/packages/oneclient_app/src/view/app/settings/java.rs @@ -15,6 +15,8 @@ fn providers() -> Vec<(JavaVendor, &'static str)> { vec![ (JavaVendor::Zulu, "Azul Zulu"), (JavaVendor::Adoptium, "Eclipse Temurin"), + (JavaVendor::Corretto, "Amazon Corretto"), + (JavaVendor::Liberica, "BellSoft Liberica"), ] } @@ -209,18 +211,31 @@ impl Component for RuntimeRow { .child( rect() .width(Size::flex(1.0)) + .overflow(Overflow::Clip) .child( - label() - .text(runtime.absolute_path.clone()) - .font_size(12.) - .max_lines(1) - .color(colors::fg_secondary()), + ScrollArea::new() + .horizontal(path_content_width(&runtime.absolute_path)) + .width(Size::fill()) + .height(Size::px(18.)) + .show_scrollbar(false) + .child( + label() + .text(runtime.absolute_path.clone()) + .font_size(12.) + .max_lines(1) + .color(colors::fg_secondary()) + .into_element(), + ), ), ) .child(remove_button(dispatch, path)) } } +fn path_content_width(path: &str) -> f32 { + (path.chars().count() as f32 * 7.0).max(1.0) +} + fn remove_button(dispatch: BridgeDispatch, path: String) -> impl IntoElement { Button::new() .ghost() diff --git a/packages/oneclient_app/src/view/app/settings/launcher.rs b/packages/oneclient_app/src/view/app/settings/launcher.rs index c015d426..cc518d2d 100644 --- a/packages/oneclient_app/src/view/app/settings/launcher.rs +++ b/packages/oneclient_app/src/view/app/settings/launcher.rs @@ -19,24 +19,18 @@ impl Component for SettingsLauncher { let v = settings.discord_enabled; move || v }); - let browser_login = use_state({ - let v = settings.microsoft_login_use_browser; - move || v - }); let mut first = use_state(|| true); { let settings = settings.clone(); use_side_effect(move || { let discord = *discord_rpc.read(); - let browser = *browser_login.read(); if *first.peek() { first.set(false); return; } let mut next = settings.clone(); next.discord_enabled = discord; - next.microsoft_login_use_browser = browser; dispatch.set_settings(next); }); } @@ -52,12 +46,6 @@ impl Component for SettingsLauncher { "Enable Discord Rich Presence.", toggle(discord_rpc), )) - .child(settings_row( - IconType::Globe01, - "Browser sign-in", - "Sign in to Microsoft by opening your browser instead of entering a code.", - toggle(browser_login), - )) .child(section_header("FOLDERS AND FILES")) .child(settings_row( IconType::Folder, diff --git a/packages/oneclient_app/src/view/onboarding/account.rs b/packages/oneclient_app/src/view/onboarding/account.rs index e050d245..707bd39a 100644 --- a/packages/oneclient_app/src/view/onboarding/account.rs +++ b/packages/oneclient_app/src/view/onboarding/account.rs @@ -1,17 +1,10 @@ use freya::prelude::*; -use freya::query::{MutationCapability, MutationStateData, UseMutation}; -use freya::text_edit::Clipboard; -use oneclient_core::auth::{MinecraftAccount, MinecraftLogin}; +use oneclient_core::auth::MinecraftAccount; -use crate::components::{Avatar, Button, Icon, IconType, OverlayPopup}; -use crate::hooks::{ - login_code_already_handled, try_default_account, use_begin_microsoft_login, - use_current_account, use_finish_microsoft_login, -}; -use crate::platform; +use crate::components::{Avatar, Button, Icon, IconType, use_microsoft_login}; +use crate::hooks::{try_default_account, use_current_account}; use crate::routes::Route; use crate::theme::colors; -use crate::ui::border_all_color; use crate::view::onboarding::{ onboarding_illustration, onboarding_nav, onboarding_page, step_heading, }; @@ -22,42 +15,10 @@ pub struct OnboardingAccount; impl Component for OnboardingAccount { fn render(&self) -> impl IntoElement { let account_query = use_current_account(); - - let begin = use_begin_microsoft_login(); - let finish = use_finish_microsoft_login(); - - let mut pending_login = use_state(|| None::); - - use_side_effect(move || { - let login = match &*begin.read().state() { - MutationStateData::Settled { res: Ok(login), .. } => Some(login.clone()), - _ => None, - }; - let Some(login) = login else { return }; - if login_code_already_handled(login.dedupe_key()) { - return; - } - // Browser flow: open the system browser straight away so the user - // lands on the Microsoft sign-in page without copying a code. - if let Some(url) = login.browser_url() { - platform::open_url(url); - } - finish.mutate(login.clone()); - pending_login.set(Some(login)); - }); - - use_side_effect(move || { - if matches!(&*finish.read().state(), MutationStateData::Settled { .. }) - && pending_login.peek().is_some() - { - pending_login.set(None); - } - }); + let msa = use_microsoft_login(); let account = try_default_account(&account_query); let has_account = account.is_some(); - let pending = begin.read().state().is_loading() || finish.read().state().is_loading(); - let error = mutation_err_text(&finish).or_else(|| mutation_err_text(&begin)); let content = rect() .vertical() @@ -69,7 +30,11 @@ impl Component for OnboardingAccount { )) .child(match &account { Some(account) => account_preview(account).into_element(), - None => sign_in_card(pending, error, move |_| begin.mutate(())).into_element(), + None => { + let start = msa.clone(); + sign_in_card(msa.pending, msa.error.clone(), move |_| start.start()) + .into_element() + } }) .into_element(); @@ -87,26 +52,7 @@ impl Component for OnboardingAccount { .width(Size::fill()) .height(Size::fill()) .child(page) - .maybe_child( - pending_login - .read() - .clone() - .map(|login| microsoft_dialog(login, pending_login)), - ) - } -} - -fn mutation_err_text(mutation: &UseMutation) -> Option -where - M: MutationCapability, - M::Err: std::fmt::Display, -{ - match &*mutation.read().state() { - MutationStateData::Settled { res: Err(err), .. } => Some(err.to_string()), - MutationStateData::Loading { - res: Some(Err(err)), - } => Some(err.to_string()), - _ => None, + .maybe_child(msa.popup()) } } @@ -114,23 +60,7 @@ fn account_preview(account: &MinecraftAccount) -> impl IntoElement { rect() .horizontal() .width(Size::fill()) - // .height(Size::px(260.)) .spacing(24.) - // .cross_align(Alignment::Center) - // .child( - // rect() - // .width(Size::px(200.)) - // .height(Size::fill()) - // .corner_radius(CornerRadius::new_all(16.)) - // .background(colors::page_elevated()) - // .border(border_all_color(1., colors::component_border())) - // .child( - // PlayerModel::new(account.id) - // .yaw(-0.5) - // .width(Size::fill()) - // .height(Size::fill()), - // ), - // ) .child( rect() .horizontal() @@ -205,153 +135,3 @@ fn sign_in_card( })) .into_element() } - -fn microsoft_dialog( - login: MinecraftLogin, - mut pending_login: State>, -) -> impl IntoElement { - let body = match &login { - MinecraftLogin::DeviceCode(flow) => { - device_code_dialog_body(flow.user_code.clone(), flow.verification_uri.clone()) - .into_element() - } - MinecraftLogin::Browser(flow) => { - browser_dialog_body(flow.auth_url.clone()).into_element() - } - }; - - OverlayPopup::new() - .on_close(move |()| pending_login.set(None)) - .child( - rect() - .width(Size::window_percent(100.)) - .height(Size::window_percent(100.)) - .center() - .child( - rect() - .vertical() - .width(Size::px(420.)) - .max_width(Size::window_percent(90.)) - .cross_align(Alignment::Center) - .spacing(18.) - .padding(Gaps::new_all(28.)) - .corner_radius(CornerRadius::new_all(16.)) - .background(colors::page_elevated()) - .border(border_all_color(1., colors::component_border())) - .child( - label() - .text("Sign in to Microsoft") - .font_size(18.) - .font_weight(FontWeight::SEMI_BOLD) - .color(colors::fg_primary()), - ) - .child(body) - .child(waiting_row()) - .child( - Button::new() - .ghost() - .on_press(move |_| pending_login.set(None)) - .text("Cancel"), - ), - ), - ) - .into_element() -} - -fn waiting_row() -> impl IntoElement { - rect() - .horizontal() - .cross_align(Alignment::Center) - .spacing(6.) - .child( - Icon::new(IconType::Loading02) - .size(13.) - .color(colors::brand()), - ) - .child( - label() - .text("Waiting for you to finish signing in...") - .font_size(12.) - .color(colors::fg_secondary()), - ) - .into_element() -} - -fn browser_dialog_body(auth_url: String) -> impl IntoElement { - rect() - .vertical() - .width(Size::fill()) - .cross_align(Alignment::Center) - .spacing(18.) - .child( - label() - .text("Your browser has opened the Microsoft sign-in page. Complete sign-in there and you'll be brought back automatically.") - .font_size(13.) - .color(colors::fg_secondary()), - ) - .child( - Button::new() - .primary() - .on_press(move |_| platform::open_url(&auth_url)) - .child(Icon::new(IconType::LinkExternal01).size(16.)) - .text("Open in browser again"), - ) - .into_element() -} - -fn device_code_dialog_body(code: String, verification_uri: String) -> impl IntoElement { - let copy_code = code.clone(); - rect() - .vertical() - .width(Size::fill()) - .cross_align(Alignment::Center) - .spacing(18.) - .child( - label() - .text("Enter this code on the Microsoft sign-in page:") - .font_size(13.) - .color(colors::fg_secondary()), - ) - .child( - rect() - .width(Size::fill()) - .center() - .padding(Gaps::new_symmetric(20., 16.)) - .corner_radius(CornerRadius::new_all(14.)) - .background(colors::component_bg()) - .border(border_all_color(1., colors::brand())) - .child( - label() - .text(code) - .font_size(48.) - .font_weight(FontWeight::BOLD) - .color(colors::fg_primary()), - ), - ) - .child( - rect() - .horizontal() - .width(Size::fill()) - .main_align(Alignment::Center) - .spacing(8.) - .child( - Button::new() - .secondary() - .on_press(move |_| { - if let Err(err) = Clipboard::set(copy_code.clone()) { - tracing::warn!("clipboard copy failed: {err:?}"); - } - }) - .child(Icon::new(IconType::Copy01).size(16.)) - .text("Copy code"), - ) - .child( - Button::new() - .primary() - .on_press(move |_| platform::open_url(&verification_uri)) - .child(Icon::new(IconType::LinkExternal01).size(16.)) - .text("Open in browser"), - ), - ) - .into_element() -} diff --git a/packages/oneclient_app/src/view/onboarding/downloading/mod.rs b/packages/oneclient_app/src/view/onboarding/downloading/mod.rs index 09b49b8b..aa2414e5 100644 --- a/packages/oneclient_app/src/view/onboarding/downloading/mod.rs +++ b/packages/oneclient_app/src/view/onboarding/downloading/mod.rs @@ -57,6 +57,7 @@ struct TaskLine { struct GroupedAgg { children: HashMap, done_units: u64, + carried: u64, } impl GroupedAgg { @@ -82,7 +83,8 @@ impl GroupedAgg { } fn downloaded_bytes(&self) -> u64 { - self.done_units + self.carried + + self.done_units + self .children .values() @@ -260,8 +262,12 @@ impl Component for OnboardingDownloading { let is_running = *running.read(); let is_complete = *complete.read(); - let global = if total == 0 { - if is_complete { 100.0 } else { 0.0 } + let global = if is_complete { + 100.0 + } else if total == 0 { + 0.0 + } else if total_estimate_now > 0 { + (agg_now.downloaded_bytes() as f32 / total_estimate_now as f32 * 100.0).clamp(0.0, 99.0) } else { ((done as f32 + agg_now.fraction()) / total as f32 * 100.0).clamp(0.0, 100.0) }; @@ -531,7 +537,7 @@ fn run_install_batch(plans: Vec, predownload: bool, handles: Instal let downloaded = agg.peek().downloaded_bytes(); let delta = downloaded.saturating_sub(prev); - let inst = delta as f64 / 0.5; + let inst = delta as f64; speed = if speed <= 0.0 { inst @@ -755,6 +761,12 @@ fn apply_grouped(agg: &mut GroupedAgg, event: GroupedProgressEvent) { } } GroupedProgressEvent::End { .. } => { + agg.carried += agg.done_units + + agg + .children + .values() + .map(|t| t.current.min(t.total)) + .sum::(); agg.children.clear(); agg.done_units = 0; } diff --git a/packages/oneclient_core/examples/auth_msa.rs b/packages/oneclient_core/examples/auth_msa.rs index 2eaa4964..3f97b19b 100644 --- a/packages/oneclient_core/examples/auth_msa.rs +++ b/packages/oneclient_core/examples/auth_msa.rs @@ -1,7 +1,7 @@ use std::process::Command; -use oneclient_core::auth::{self, MinecraftLogin}; +use oneclient_core::auth::{self}; use oneclient_core::{dev, logger, LauncherResult}; #[tokio::main] @@ -9,23 +9,18 @@ async fn main() -> LauncherResult<()> { logger::init_debug()?; dev::initialize().await?; - let flow = auth::begin_microsoft_login().await?; + let session = auth::begin_microsoft_login().await?; - match &flow { - MinecraftLogin::DeviceCode(device) => { - println!("{}\n", device.message); - println!("Verification URL: {}", device.verification_uri); - println!("Enter this code: {}\n", device.user_code); - try_open_browser(&device.verification_uri); - } - MinecraftLogin::Browser(browser) => { - println!("Opening browser to sign in...\n"); - try_open_browser(&browser.auth_url); - } - } + println!("Sign in with EITHER method:\n"); + println!(" Browser: opening {}", session.browser.auth_url); + try_open_browser(&session.browser.auth_url); + println!( + " Device code: go to {} and enter {}\n", + session.device.verification_uri, session.device.user_code + ); println!("Waiting for you to sign in..."); - let account = auth::finish_microsoft_login(flow).await?; + let account = auth::finish_microsoft_login(session).await?; println!( "Signed in as {} ({})\n token expires: {}", diff --git a/packages/oneclient_core/examples/download_game.rs b/packages/oneclient_core/examples/download_game.rs index 8633aba1..a2738fdc 100644 --- a/packages/oneclient_core/examples/download_game.rs +++ b/packages/oneclient_core/examples/download_game.rs @@ -1,4 +1,4 @@ - +#![recursion_limit = "256"] use oneclient_core::clusters::{ClusterManager, CreateClusterOptions}; use oneclient_core::dev; use oneclient_core::packages::domain::GameLoader; diff --git a/packages/oneclient_core/examples/java_install_provider.rs b/packages/oneclient_core/examples/java_install_provider.rs index 9f30cbe6..49f82916 100644 --- a/packages/oneclient_core/examples/java_install_provider.rs +++ b/packages/oneclient_core/examples/java_install_provider.rs @@ -4,7 +4,9 @@ use std::env; use oneclient_core::dev; use oneclient_core::java::check_java_runtime; use oneclient_core::java::vendors::AdoptiumRuntimeProvider; +use oneclient_core::java::vendors::CorrettoRuntimeProvider; use oneclient_core::java::vendors::JavaRuntimeProvider; +use oneclient_core::java::vendors::LibericaRuntimeProvider; use oneclient_core::java::vendors::ZuluRuntimeProvider; use oneclient_core::logger; use oneclient_core::LauncherResult; @@ -18,7 +20,7 @@ async fn main() -> LauncherResult<()> { let vendor = args .next() .unwrap_or_else(|| { - eprintln!("usage: java_install_provider [major]"); + eprintln!("usage: java_install_provider [major]"); std::process::exit(1); }); @@ -33,13 +35,15 @@ async fn main() -> LauncherResult<()> { let provider: Box = match vendor.as_str() { "zulu" => Box::new(ZuluRuntimeProvider), "adoptium" => Box::new(AdoptiumRuntimeProvider), + "corretto" => Box::new(CorrettoRuntimeProvider), + "liberica" => Box::new(LibericaRuntimeProvider), other => { - eprintln!("unknown vendor '{other}', use zulu, adoptium, or corretto"); + eprintln!("unknown vendor '{other}', use zulu, adoptium, corretto, or liberica"); std::process::exit(1); } }; - let packages = provider.list_packages_by_major(major, &services).await?; + let packages = provider.list_packages(Some(major), &services).await?; let package = packages .into_iter() .find(|p| p.java_version.contains(&major)) diff --git a/packages/oneclient_core/examples/java_list_providers.rs b/packages/oneclient_core/examples/java_list_providers.rs index d10f6a6a..669c0d2d 100644 --- a/packages/oneclient_core/examples/java_list_providers.rs +++ b/packages/oneclient_core/examples/java_list_providers.rs @@ -10,19 +10,25 @@ use oneclient_core::logger; async fn main() -> LauncherResult<()> { logger::init_debug()?; - let major: u32 = env::args() - .nth(1) - .unwrap_or_else(|| "21".to_string()) - .parse() - .expect("usage: java_list_providers [major_version]"); + let arg = env::args().nth(1).unwrap_or_else(|| "21".to_string()); + let major: Option = if arg == "all" { + None + } else { + Some(arg.parse().expect("usage: java_list_providers [major|all]")) + }; let services = dev::ephemeral_services().await?; - println!("Listing JRE packages for Java {major} (metadata only):\n"); + match major { + Some(major) => println!("Listing JRE packages for Java {major} (metadata only):\n"), + None => println!("Listing JRE packages for all majors (metadata only):\n"), + } for provider in runtime_providers() { - let packages = provider.list_packages_by_major(major, &services).await?; - print_vendor(provider.vendor(), &packages); + match provider.list_packages(major, &services).await { + Ok(packages) => print_vendor(provider.vendor(), &packages), + Err(err) => println!("{:?}: ERROR {err}", provider.vendor()), + } } Ok(()) diff --git a/packages/oneclient_core/examples/launch_game.rs b/packages/oneclient_core/examples/launch_game.rs index cf64a83c..989f869b 100644 --- a/packages/oneclient_core/examples/launch_game.rs +++ b/packages/oneclient_core/examples/launch_game.rs @@ -1,3 +1,4 @@ +#![recursion_limit = "256"] use oneclient_core::LauncherResult; use oneclient_core::auth; diff --git a/packages/oneclient_core/src/auth/data.rs b/packages/oneclient_core/src/auth/data.rs index b29cb80c..63beb128 100644 --- a/packages/oneclient_core/src/auth/data.rs +++ b/packages/oneclient_core/src/auth/data.rs @@ -32,6 +32,10 @@ impl MinecraftAccount { pub fn is_offline(&self) -> bool { self.kind == AccountKind::Offline } + + pub fn is_expired(&self) -> bool { + self.expires <= Utc::now() + chrono::TimeDelta::seconds(60) + } } #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] @@ -52,23 +56,25 @@ pub struct BrowserLogin { } #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] -pub enum MinecraftLogin { - DeviceCode(DeviceCodeLogin), - Browser(BrowserLogin), +pub struct MicrosoftLoginSession { + pub browser: BrowserLogin, + pub device: DeviceCodeLogin, } -impl MinecraftLogin { +impl MicrosoftLoginSession { pub fn dedupe_key(&self) -> &str { - match self { - MinecraftLogin::DeviceCode(flow) => &flow.user_code, - MinecraftLogin::Browser(flow) => &flow.state, - } + &self.browser.state } - pub fn browser_url(&self) -> Option<&str> { - match self { - MinecraftLogin::Browser(flow) => Some(&flow.auth_url), - MinecraftLogin::DeviceCode(_) => None, - } + pub fn auth_url(&self) -> &str { + &self.browser.auth_url + } + + pub fn user_code(&self) -> &str { + &self.device.user_code + } + + pub fn verification_uri(&self) -> &str { + &self.device.verification_uri } } diff --git a/packages/oneclient_core/src/auth/mod.rs b/packages/oneclient_core/src/auth/mod.rs index 8e2827ca..f7e50ee7 100644 --- a/packages/oneclient_core/src/auth/mod.rs +++ b/packages/oneclient_core/src/auth/mod.rs @@ -7,72 +7,93 @@ mod data; pub use error::{AuthError, MinecraftAuthError, MinecraftAuthStep}; pub use offline::{offline_account, offline_uuid, validate_offline_username}; pub use store::CredentialsStore; -pub use data::{AccountKind, BrowserLogin, DeviceCodeLogin, MinecraftAccount, MinecraftLogin}; +pub use data::{ + AccountKind, BrowserLogin, DeviceCodeLogin, MicrosoftLoginSession, MinecraftAccount, +}; pub use msa::PendingBrowserLogin; +use std::collections::HashMap; +use std::sync::{Arc, LazyLock, Mutex as StdMutex}; + +use tokio::sync::Mutex; use uuid::Uuid; use crate::LauncherResult; +use crate::notification::MicrosoftLoginStatus; use crate::state::LauncherState; -pub async fn begin_microsoft_login() -> LauncherResult { +#[tracing::instrument(skip_all)] +pub async fn begin_microsoft_login() -> LauncherResult { + tracing::info!("beginning Microsoft login"); let state = LauncherState::get()?; - let use_browser = state.settings.read().microsoft_login_use_browser; + let client = state.services.requester.http(); - if use_browser { - let (login, pending) = msa::begin_browser_login().await.map_err(AuthError::from)?; - state - .microsoft_logins - .lock() - .await - .insert(login.state.clone(), pending); - Ok(MinecraftLogin::Browser(login)) - } else { - let client = state.services.requester.http(); - let flow = msa::begin_device_login(client).await.map_err(AuthError::from)?; - Ok(MinecraftLogin::DeviceCode(flow)) - } + let (browser, pending) = msa::begin_browser_login().await.map_err(AuthError::from)?; + let device = msa::begin_device_login(client).await.map_err(AuthError::from)?; + + state + .microsoft_logins + .lock() + .await + .insert(browser.state.clone(), pending); + + Ok(MicrosoftLoginSession { browser, device }) } -pub async fn finish_microsoft_login(flow: MinecraftLogin) -> LauncherResult { +#[tracing::instrument(skip_all)] +pub async fn finish_microsoft_login( + session: MicrosoftLoginSession, +) -> LauncherResult { + tracing::info!("finishing Microsoft login"); let state = LauncherState::get()?; - match flow { - MinecraftLogin::DeviceCode(flow) => { - let mut auth = state.auth.lock().await; - auth.finish_device_login(&flow, &state.services).await - } - MinecraftLogin::Browser(login) => { - let pending = state - .microsoft_logins - .lock() - .await - .remove(&login.state) - .ok_or(AuthError::Minecraft( - MinecraftAuthError::BrowserLoginNotFound, - ))?; + let pending = state + .microsoft_logins + .lock() + .await + .remove(&session.browser.state) + .ok_or(AuthError::Minecraft( + MinecraftAuthError::BrowserLoginNotFound, + ))?; - let client = state.services.requester.http(); - let progress_id = Uuid::new_v4(); - let notifier = state.services.notifier.clone(); - let account = msa::finish_browser_login(client, pending, |label, current, total| { - notifier.send_progress(&progress_id, label, current, total); - }) - .await - .map_err(AuthError::from)?; + let client = state.services.requester.http(); + let notifier = state.services.notifier.clone(); - let mut auth = state.auth.lock().await; - auth.commit_account(account, &state.services).await - } + let result = msa::finish_dual_login( + client, + pending, + &session.device, + |label, current, total| { + notifier.microsoft_login_status(Some(MicrosoftLoginStatus { + label: label.to_string(), + current, + total, + })); + }, + ) + .await + .map_err(AuthError::from); + + state.services.notifier.microsoft_login_status(None); + + if let Err(err) = &result { + tracing::warn!("Microsoft login failed: {err}"); } + + let account = result?; + tracing::info!(username = %account.username, "Microsoft login succeeded"); + let mut auth = state.auth.lock().await; + auth.commit_account(account, &state.services).await } +#[tracing::instrument(level = "debug", skip_all)] pub async fn cancel_microsoft_login(state_token: &str) { if let Ok(state) = LauncherState::get() { state.microsoft_logins.lock().await.remove(state_token); } } +#[tracing::instrument(skip_all, fields(username = %username))] pub async fn add_offline_account(username: String) -> LauncherResult { let state = LauncherState::get()?; let mut auth = state.auth.lock().await; @@ -91,18 +112,21 @@ pub async fn get_account(id: Uuid) -> LauncherResult> { Ok(auth.get_account(id).cloned()) } +#[tracing::instrument(level = "debug", skip_all)] pub async fn get_default_account() -> LauncherResult> { let state = LauncherState::get()?; let mut auth = state.auth.lock().await; - auth.default_account(&state.services).await + auth.default_account().await } +#[tracing::instrument(level = "debug", skip_all, fields(?id))] pub async fn set_default_account(id: Option) -> LauncherResult<()> { let state = LauncherState::get()?; let mut auth = state.auth.lock().await; auth.set_default_user(id).await } +#[tracing::instrument(skip_all, fields(%id))] pub async fn remove_account(id: Uuid) -> LauncherResult<()> { let state = LauncherState::get()?; let mut auth = state.auth.lock().await; @@ -110,31 +134,124 @@ pub async fn remove_account(id: Uuid) -> LauncherResult<()> { Ok(()) } -pub async fn refresh_account(id: Uuid) -> LauncherResult> { - let state = LauncherState::get()?; - let mut auth = state.auth.lock().await; - auth.refresh_microsoft_account(id, &state.services).await +/// Serialises token renewal per account. +/// +/// Microsoft rotates the refresh token on every use, so two concurrent renewals +/// of one account would race: the loser spends an already-consumed refresh token +/// and the account gets signed out. Clusters can launch in parallel, so this is +/// reachable โ€” the guard makes the second caller wait and reuse the first +/// caller's result instead of starting its own chain. +static REFRESH_GUARDS: LazyLock>>>> = + LazyLock::new(|| StdMutex::new(HashMap::new())); + +fn refresh_guard(id: Uuid) -> Arc> { + Arc::clone( + REFRESH_GUARDS + .lock() + .expect("refresh guard registry poisoned") + .entry(id) + .or_default(), + ) } +/// Clones an account out of the store, returning an owned value so no caller +/// keeps the credentials-store lock alive past this call. +async fn account_snapshot( + state: &Arc, + id: Uuid, +) -> LauncherResult { + let auth = state.auth.lock().await; + auth.get_account(id) + .cloned() + .ok_or_else(|| AuthError::AccountNotFound(id).into()) +} + +/// Renews `id`'s access token if it has lapsed, returning a usable account. +/// +/// The credentials-store lock is deliberately not held across the Microsoft +/// handshake, only around the reads and the final write. Holding it across +/// those six sequential round trips is what used to stall every other account +/// read behind a multi-second network call. +#[tracing::instrument(level = "debug", skip(state), fields(%id))] +async fn renew_token( + state: &Arc, + id: Uuid, + force: bool, +) -> LauncherResult { + let existing = account_snapshot(state, id).await?; + if !existing.is_microsoft() || (!force && !existing.is_expired()) { + return Ok(existing); + } + + let guard = refresh_guard(id); + let _serialised = guard.lock().await; + + let existing = account_snapshot(state, id).await?; + if !existing.is_microsoft() || (!force && !existing.is_expired()) { + return Ok(existing); + } + + tracing::info!(username = %existing.username, "renewing Microsoft access token"); + match msa::refresh_microsoft_account(state.services.requester.http(), &existing).await { + Ok(refreshed) => { + state + .auth + .lock() + .await + .commit_refreshed_account(refreshed.clone()) + .await?; + Ok(refreshed) + } + Err(err) => { + let err = crate::LauncherError::from(AuthError::from(err)); + if store::is_transient_auth_error(&err) { + tracing::warn!("keeping existing token after transient renewal failure: {err}"); + Ok(existing) + } else { + Err(err) + } + } + } +} + +#[tracing::instrument(level = "debug", skip_all, fields(%id))] +pub async fn refresh_account(id: Uuid) -> LauncherResult { + let state = LauncherState::get()?; + renew_token(&state, id, true).await +} + +#[tracing::instrument(level = "debug", skip_all)] pub async fn refresh_all_accounts() -> LauncherResult> { let state = LauncherState::get()?; - let mut auth = state.auth.lock().await; - let ids: Vec = auth.users.keys().copied().collect(); + let ids: Vec = state.auth.lock().await.users.keys().copied().collect(); let mut refreshed = Vec::with_capacity(ids.len()); for id in ids { - if let Some(account) = auth.refresh_microsoft_account(id, &state.services).await? { - refreshed.push(account); - } + refreshed.push(renew_token(&state, id, true).await?); } Ok(refreshed) } +#[tracing::instrument(level = "debug", skip_all, fields(%id))] pub async fn account_for_launch(id: Uuid) -> LauncherResult { let state = LauncherState::get()?; - let mut auth = state.auth.lock().await; - auth.account_for_launch(id, &state.services).await + let account = renew_token(&state, id, false).await?; + + if account.is_offline() && !state.auth.lock().await.has_microsoft_account() { + return Err(AuthError::OfflineRequiresMicrosoft.into()); + } + + Ok(account) +} + +#[tracing::instrument(level = "debug", skip_all)] +pub async fn default_account_for_launch() -> LauncherResult> { + let state = LauncherState::get()?; + let Some(id) = state.auth.lock().await.resolve_default_id().await? else { + return Ok(None); + }; + Ok(Some(account_for_launch(id).await?)) } pub async fn has_microsoft_account() -> LauncherResult { diff --git a/packages/oneclient_core/src/auth/msa.rs b/packages/oneclient_core/src/auth/msa.rs index ff3884aa..a653d914 100644 --- a/packages/oneclient_core/src/auth/msa.rs +++ b/packages/oneclient_core/src/auth/msa.rs @@ -29,23 +29,71 @@ const TOKEN_URL: &str = "https://login.microsoftonline.com/consumers/oauth2/v2.0 const AUTH_STEPS: u64 = 5; -const BROWSER_LOGIN_TIMEOUT: Duration = Duration::from_secs(5 * 60); - +#[tracing::instrument(level = "debug", skip_all)] pub async fn begin_device_login(client: &Client) -> Result { request_device_code(client).await } -pub async fn finish_device_login( +#[tracing::instrument(level = "debug", skip_all)] +pub async fn finish_dual_login( client: &Client, - flow: &DeviceCodeLogin, + pending: PendingBrowserLogin, + device: &DeviceCodeLogin, on_progress: impl Fn(&str, u64, u64), ) -> Result { + tracing::info!("waiting for Microsoft sign-in (browser or device code)"); on_progress("Waiting for sign-in", 0, AUTH_STEPS); - let msa = poll_device_token(client, flow).await?; + + let deadline = Duration::from_secs(device.expires_in); + let msa = tokio::time::timeout(deadline, race_msa_token(client, &pending, device)) + .await + .map_err(|_| MinecraftAuthError::DeviceAuthorizationExpired)??; + account_from_msa_token(client, msa, on_progress).await } +#[tracing::instrument(level = "debug", skip_all)] +async fn race_msa_token( + client: &Client, + pending: &PendingBrowserLogin, + device: &DeviceCodeLogin, +) -> Result { + let browser = browser_msa_token(client, pending); + let device = poll_device_token(client, device); + tokio::pin!(browser, device); + + let mut browser_done = false; + let mut device_done = false; + let mut last_err: Option = None; + + loop { + tokio::select! { + res = &mut browser, if !browser_done => match res { + Ok(token) => return Ok(token), + Err(err) => { + browser_done = true; + if device_done { + return Err(err); + } + last_err = Some(err); + } + }, + res = &mut device, if !device_done => match res { + Ok(token) => return Ok(token), + Err(err) => { + device_done = true; + if browser_done { + return Err(err); + } + last_err = Some(err); + } + }, + else => return Err(last_err.unwrap_or(MinecraftAuthError::DeviceAuthorizationExpired)), + } + } +} + pub struct PendingBrowserLogin { listener: TcpListener, pkce_verifier: String, @@ -53,6 +101,7 @@ pub struct PendingBrowserLogin { csrf_state: String, } +#[tracing::instrument(level = "debug", skip_all)] pub async fn begin_browser_login() -> Result<(BrowserLogin, PendingBrowserLogin), MinecraftAuthError> { let listener = TcpListener::bind("127.0.0.1:0") @@ -95,20 +144,16 @@ pub async fn begin_browser_login() -> Result<(BrowserLogin, PendingBrowserLogin) Ok((login, pending)) } -pub async fn finish_browser_login( +#[tracing::instrument(level = "debug", skip_all)] +async fn browser_msa_token( client: &Client, - pending: PendingBrowserLogin, - on_progress: impl Fn(&str, u64, u64), -) -> Result { - on_progress("Waiting for sign-in", 0, AUTH_STEPS); - let code = tokio::time::timeout(BROWSER_LOGIN_TIMEOUT, wait_for_redirect(&pending)) - .await - .map_err(|_| MinecraftAuthError::BrowserAuthorizationExpired)??; - - let msa = exchange_auth_code(client, &code, &pending).await?; - account_from_msa_token(client, msa, on_progress).await + pending: &PendingBrowserLogin, +) -> Result { + let code = wait_for_redirect(pending).await?; + exchange_auth_code(client, &code, pending).await } +#[tracing::instrument(level = "debug", skip_all)] async fn wait_for_redirect(pending: &PendingBrowserLogin) -> Result { loop { let (mut stream, _) = pending @@ -148,6 +193,7 @@ async fn wait_for_redirect(pending: &PendingBrowserLogin) -> Result String { BASE64_URL_SAFE_NO_PAD.encode(digest) } +#[tracing::instrument(level = "debug", skip_all, fields(username = %creds.username))] pub async fn refresh_microsoft_account( client: &Client, creds: &MinecraftAccount, ) -> Result { + tracing::debug!("refreshing Microsoft account token"); let msa = refresh_msa_token(client, &creds.refresh_token).await?; let mut account = account_from_msa_token(client, msa, |_, _, _| {}).await?; account.id = creds.id; @@ -309,6 +358,7 @@ pub async fn refresh_microsoft_account( Ok(account) } +#[tracing::instrument(level = "debug", skip_all)] async fn request_device_code(client: &Client) -> Result { let body = [ ("client_id", MICROSOFT_CLIENT_ID), @@ -331,6 +381,7 @@ async fn request_device_code(client: &Client) -> Result LauncherResult<()> { Ok(()) } +#[tracing::instrument(level = "debug", fields(username = %username))] pub fn offline_account(username: String) -> MinecraftAccount { + tracing::info!("creating offline account"); MinecraftAccount { id: offline_uuid(&username), username, diff --git a/packages/oneclient_core/src/auth/store.rs b/packages/oneclient_core/src/auth/store.rs index a9c7b8e4..91aa885c 100644 --- a/packages/oneclient_core/src/auth/store.rs +++ b/packages/oneclient_core/src/auth/store.rs @@ -1,11 +1,10 @@ use std::collections::HashMap; -use chrono::Utc; use uuid::Uuid; use super::error::AuthError; use super::offline::{offline_account, validate_offline_username}; -use super::data::{AccountKind, DeviceCodeLogin, MinecraftAccount}; +use super::data::{AccountKind, MinecraftAccount}; use crate::paths; use crate::state::LauncherServices; use crate::LauncherResult; @@ -17,6 +16,7 @@ pub struct CredentialsStore { } impl CredentialsStore { + #[tracing::instrument(level = "debug")] pub async fn load() -> LauncherResult { let path = paths::auth_file()?; if !path.exists() { @@ -32,6 +32,7 @@ impl CredentialsStore { } } + #[tracing::instrument(level = "debug", skip_all)] pub async fn save(&self) -> LauncherResult<()> { let path = paths::auth_file()?; @@ -57,23 +58,7 @@ impl CredentialsStore { self.users.get(&id) } - pub async fn finish_device_login( - &mut self, - flow: &DeviceCodeLogin, - services: &LauncherServices, - ) -> LauncherResult { - let client = services.requester.http(); - let progress_id = Uuid::new_v4(); - let notifier = services.notifier.clone(); - let account = super::msa::finish_device_login(client, flow, |label, current, total| { - notifier.send_progress(&progress_id, label, current, total); - }) - .await - .map_err(AuthError::from)?; - - self.commit_account(account, services).await - } - + #[tracing::instrument(level = "debug", skip_all, fields(username = %account.username))] pub async fn commit_account( &mut self, account: MinecraftAccount, @@ -86,6 +71,7 @@ impl CredentialsStore { } self.save().await?; + tracing::debug!("committed account to credentials store"); services .notifier .send_info("Account added", &format!("Signed in as {}", account.username)); @@ -97,6 +83,7 @@ impl CredentialsStore { Ok(account) } + #[tracing::instrument(level = "debug", skip(self), fields(username = %username))] pub async fn add_offline_account_and_save( &mut self, username: String, @@ -126,30 +113,18 @@ impl CredentialsStore { Ok(account) } - pub async fn refresh_microsoft_account( + #[tracing::instrument(level = "debug", skip_all, fields(%account.id))] + pub async fn commit_refreshed_account( &mut self, - id: Uuid, - services: &LauncherServices, - ) -> LauncherResult> { - let Some(existing) = self.users.get(&id).cloned() else { - return Ok(None); - }; - - if existing.kind != AccountKind::Microsoft { - return Ok(Some(existing)); - } - - let client = services.requester.http(); - let account = super::msa::refresh_microsoft_account(client, &existing) - .await - .map_err(AuthError::from)?; - - self.users.insert(id, account.clone()); + account: MinecraftAccount, + ) -> LauncherResult<()> { + self.users.insert(account.id, account); self.save().await?; - - Ok(Some(account)) + tracing::debug!("stored refreshed Microsoft account"); + Ok(()) } + #[tracing::instrument(level = "debug", skip(self), fields(%id))] pub async fn remove_account(&mut self, id: Uuid) -> LauncherResult> { let removed = self.users.remove(&id); @@ -161,6 +136,7 @@ impl CredentialsStore { Ok(removed) } + #[tracing::instrument(level = "debug", skip(self), fields(?id))] pub async fn set_default_user(&mut self, id: Option) -> LauncherResult<()> { if let Some(id) = id && !self.users.contains_key(&id) @@ -173,10 +149,8 @@ impl CredentialsStore { Ok(()) } - pub async fn default_account( - &mut self, - services: &LauncherServices, - ) -> LauncherResult> { + #[tracing::instrument(level = "debug", skip_all)] + pub async fn resolve_default_id(&mut self) -> LauncherResult> { let id = self .default_user .or_else(|| self.users.keys().copied().next()); @@ -185,68 +159,38 @@ impl CredentialsStore { return Ok(None); }; - let Some(account) = self.users.get(&id).cloned() else { + if !self.users.contains_key(&id) { return Ok(None); - }; + } if self.default_user != Some(id) { self.default_user = Some(id); self.save().await?; } - if account.kind == AccountKind::Offline { - if !self.has_microsoft_account() { - return Err(AuthError::OfflineRequiresMicrosoft.into()); - } - return Ok(Some(account)); - } - - if account.expires >= Utc::now() { - return Ok(Some(account)); - } - - let old = account.clone(); - match self.refresh_microsoft_account(id, services).await { - Ok(Some(updated)) => Ok(Some(updated)), - Ok(None) => Ok(None), - Err(err) => { - if is_transient_auth_error(&err) { - Ok(Some(old)) - } else { - Err(err) - } - } - } + Ok(Some(id)) } - pub async fn account_for_launch( - &mut self, - id: Uuid, - services: &LauncherServices, - ) -> LauncherResult { - if !self.users.contains_key(&id) { - return Err(AuthError::AccountNotFound(id).into()); - } + /// The current account as stored. Never touches the network: callers that + /// need a usable token go through [`crate::auth::account_for_launch`]. + #[tracing::instrument(level = "debug", skip_all)] + pub async fn default_account(&mut self) -> LauncherResult> { + let Some(id) = self.resolve_default_id().await? else { + return Ok(None); + }; + let Some(account) = self.users.get(&id).cloned() else { + return Ok(None); + }; - let previous_default = self.default_user; - self.default_user = Some(id); - - let account = self - .default_account(services) - .await? - .ok_or(AuthError::AccountNotFound(id))?; - - self.default_user = previous_default; - - if account.kind == AccountKind::Offline && !self.has_microsoft_account() { + if account.is_offline() && !self.has_microsoft_account() { return Err(AuthError::OfflineRequiresMicrosoft.into()); } - Ok(account) + Ok(Some(account)) } } -fn is_transient_auth_error(err: &crate::LauncherError) -> bool { +pub(super) fn is_transient_auth_error(err: &crate::LauncherError) -> bool { match err { crate::LauncherError::AuthError(super::error::AuthError::Minecraft( super::error::MinecraftAuthError::RequestError { source, .. }, diff --git a/packages/oneclient_core/src/bundles/install.rs b/packages/oneclient_core/src/bundles/install.rs index 4b2f9921..a1be47a7 100644 --- a/packages/oneclient_core/src/bundles/install.rs +++ b/packages/oneclient_core/src/bundles/install.rs @@ -10,7 +10,7 @@ use crate::bundles::error::BundleError; use crate::bundles::manager::BundlesManager; use crate::bundles::overrides; use crate::bundles::types::{BundleArchive, BundleFile, BundleFileKind}; -use crate::notification::{GroupedProgressSession, TaskPhase}; +use crate::notification::{GroupedProgressChild, GroupedProgressSession, TaskPhase}; use crate::packages::domain::{ContentType, GameLoader}; use crate::packages::error::PackageError; use crate::packages::store::{PackageStore, unlink_cluster_file}; @@ -21,11 +21,13 @@ fn is_base62(s: &str) -> bool { !s.is_empty() && s.chars().all(|c| c.is_ascii_alphanumeric()) } +#[tracing::instrument(level = "debug", skip(file, child, services), fields(package = %file.display_name()))] pub async fn install_package_from_bundle( file: &BundleFile, cluster_id: i64, bundle_name: &str, skip_compatibility: bool, + child: Option<&GroupedProgressChild>, services: &LauncherServices, ) -> LauncherResult { let cluster = PackageStore::get_cluster(cluster_id, services).await?; @@ -63,13 +65,14 @@ pub async fn install_package_from_bundle( cluster_id, skip_compatibility, false, + child, services, ) .await?; artifact.hash } BundleFileKind::External(ext) => { - install_external(ext, &cluster, skip_compatibility, services).await? + install_external(ext, &cluster, skip_compatibility, child, services).await? } }; @@ -86,28 +89,34 @@ pub async fn install_package_from_bundle( Ok(hash) } +#[tracing::instrument(level = "debug", skip(ext, cluster, child, services), fields(file = %ext.name))] async fn install_external( ext: &ExternalFile, cluster: &ClusterRow, skip_compatibility: bool, + child: Option<&GroupedProgressChild>, services: &LauncherServices, ) -> LauncherResult { - let artifact = crate::packages::store::download_external(ext, false, services).await?; + let artifact = crate::packages::store::download_external(ext, false, child, services).await?; PackageStore::link_artifact(&artifact, cluster, Some(&ext.name), services).await?; let _ = skip_compatibility; Ok(artifact.hash) } +#[tracing::instrument(level = "debug", skip(archive, services), fields(bundle = %archive.manifest.name))] pub async fn extract_bundle_overrides_for_cluster( archive: &BundleArchive, cluster_id: i64, services: &LauncherServices, ) -> LauncherResult<()> { let cluster = PackageStore::get_cluster(cluster_id, services).await?; - overrides::extract_bundle_overrides(&archive.bundle.path, &cluster).await + overrides::sync_bundle_overrides(&archive.bundle.path, &archive.manifest.name, &cluster, None) + .await?; + Ok(()) } +#[tracing::instrument(skip(bundles, services))] pub async fn install_bundle( cluster_id: i64, bundle_name: &str, @@ -132,6 +141,7 @@ pub async fn install_bundle( install_enabled_bundle_files(&archive, cluster_id, skip_compatibility, None, services).await } +#[tracing::instrument(level = "debug", skip(archive, progress, services), fields(bundle = %archive.manifest.name))] pub async fn install_enabled_bundle_files( archive: &BundleArchive, cluster_id: i64, @@ -194,11 +204,18 @@ pub async fn install_enabled_bundle_files( .cloned() .collect(); + tracing::info!( + cluster_id, + bundle = %bundle_name, + to_install = to_install.len(), + "installing enabled bundle files" + ); + let bundle_name = &bundle_name; let results = futures_util::stream::iter(to_install.into_iter().map(|file| async move { let child = progress.map(|p| { - let c = p.child(format!("Mod {}", file.display_name()), 1); - c.set_phase(TaskPhase::Installing); + let c = p.child(format!("Mod {}", file.display_name()), file.size.max(1)); + c.set_phase(TaskPhase::Downloading); c }); @@ -207,11 +224,13 @@ pub async fn install_enabled_bundle_files( cluster_id, bundle_name, skip_compatibility, + child.as_ref(), services, ) .await; if let Some(child) = child { + child.set_phase(TaskPhase::Installing); child.finish(); } (file.display_name(), result) @@ -234,6 +253,7 @@ pub async fn install_enabled_bundle_files( const BUNDLE_INSTALL_CONCURRENCY: usize = 6; +#[tracing::instrument(level = "debug", skip(bundles, services))] pub async fn enabled_bundle_bytes( cluster_id: i64, bundles: &BundlesManager, @@ -296,6 +316,7 @@ pub async fn enabled_bundle_bytes( Ok(total) } +#[tracing::instrument(level = "debug", skip(services))] pub async fn set_bundle_package_enabled( cluster_id: i64, bundle_name: &str, @@ -319,6 +340,7 @@ pub async fn set_bundle_package_enabled( Ok(()) } +#[tracing::instrument(level = "debug", skip(services))] pub async fn list_cluster_bundle_overrides( cluster_id: i64, services: &LauncherServices, @@ -330,6 +352,7 @@ pub async fn list_cluster_bundle_overrides( .collect()) } +#[tracing::instrument(skip(bundles, progress, services))] pub async fn install_cluster_bundles( cluster_id: i64, bundles: &BundlesManager, @@ -366,6 +389,7 @@ pub async fn install_cluster_bundles( Ok(()) } +#[tracing::instrument(level = "debug", skip(services))] pub async fn on_user_remove_artifact( cluster_id: i64, hash: &str, @@ -374,6 +398,7 @@ pub async fn on_user_remove_artifact( handle_user_artifact_action(cluster_id, hash, services, OverrideType::Removed).await } +#[tracing::instrument(level = "debug", skip(services))] pub async fn on_user_disable_artifact( cluster_id: i64, hash: &str, @@ -382,6 +407,7 @@ pub async fn on_user_disable_artifact( handle_user_artifact_action(cluster_id, hash, services, OverrideType::Disabled).await } +#[tracing::instrument(level = "debug", skip(services))] pub async fn on_user_enable_artifact( cluster_id: i64, hash: &str, @@ -395,6 +421,7 @@ pub async fn on_user_enable_artifact( Ok(()) } +#[tracing::instrument(level = "debug", skip(services))] async fn handle_user_artifact_action( cluster_id: i64, hash: &str, @@ -422,6 +449,7 @@ async fn handle_user_artifact_action( Ok(()) } +#[tracing::instrument(level = "debug", skip(services))] pub async fn remove_artifact_from_cluster( cluster_id: i64, hash: &str, diff --git a/packages/oneclient_core/src/bundles/manager.rs b/packages/oneclient_core/src/bundles/manager.rs index 0dc7a7e4..3e9b9395 100644 --- a/packages/oneclient_core/src/bundles/manager.rs +++ b/packages/oneclient_core/src/bundles/manager.rs @@ -9,7 +9,6 @@ use reqwest::Method; use reqwest::header; use reqwest::StatusCode; use tokio::sync::RwLock; -use tracing::instrument; use crate::bundles::error::BundleError; use crate::bundles::manifest::{BundleManifest as RemoteBundleManifest, RemoteBundleRef}; @@ -73,6 +72,7 @@ impl BundlesManager { } } + #[tracing::instrument(skip_all)] pub async fn sync(&self, services: &LauncherServices) -> LauncherResult { let Some((manifest, manifest_changed)) = Self::fetch_manifest(services).await? else { tracing::debug!( @@ -112,6 +112,7 @@ impl BundlesManager { Ok(true) } + #[tracing::instrument(level = "debug", skip(self, services))] pub async fn archives_for( &self, services: &LauncherServices, @@ -126,6 +127,7 @@ impl BundlesManager { Ok(archives) } + #[tracing::instrument(level = "debug", skip(self))] async fn manifest_for_archive( &self, path: &Path, @@ -141,6 +143,7 @@ impl BundlesManager { Ok(manifest) } + #[tracing::instrument(level = "debug", skip(self, services))] pub async fn list_for( &self, services: &LauncherServices, @@ -156,6 +159,7 @@ impl BundlesManager { .collect::, _>>() } + #[tracing::instrument(level = "debug", skip(self, entry, services, bundles_root), fields(remote_path = %entry.remote_path))] async fn sync_bundle( &self, entry: &RemoteBundleRef, @@ -208,6 +212,7 @@ impl BundlesManager { Ok(()) } + #[tracing::instrument(level = "debug", skip(services))] async fn fetch_manifest( services: &LauncherServices, ) -> LauncherResult> { @@ -288,7 +293,7 @@ async fn read_cached_manifest( Ok(Some((manifest, changed))) } -#[instrument(skip(services))] +#[tracing::instrument(level = "debug", skip(services))] async fn download_bundle_if_needed( services: &LauncherServices, url: &str, diff --git a/packages/oneclient_core/src/bundles/overrides.rs b/packages/oneclient_core/src/bundles/overrides.rs index 8add9309..d567adb1 100644 --- a/packages/oneclient_core/src/bundles/overrides.rs +++ b/packages/oneclient_core/src/bundles/overrides.rs @@ -1,86 +1,531 @@ +use std::collections::HashMap; use std::path::{Path, PathBuf}; use oneclient_db::models::ClusterRow; +use serde::{Deserialize, Serialize}; use crate::LauncherResult; +use crate::crypto::{sha1_bytes, sha1_file}; +use crate::notification::NotificationService; -pub async fn extract_bundle_overrides( - archive_path: &Path, - cluster: &ClusterRow, -) -> LauncherResult<()> { - extract_overrides(archive_path, &cluster_root(cluster)?, false).await -} +const ALWAYS_UPDATE_GLOBS: &[&str] = &["config/fabric_loader_dependencies.json"]; -pub async fn extract_bundle_overrides_no_overwrite( - archive_path: &Path, - cluster: &ClusterRow, -) -> LauncherResult<()> { - extract_overrides(archive_path, &cluster_root(cluster)?, true).await -} +const OVERRIDES_PREFIX: &str = "overrides/"; +const LOCK_REL_PATH: &str = ".oneclient/bundle_overrides.json"; fn cluster_root(cluster: &ClusterRow) -> LauncherResult { Ok(crate::paths::clusters_dir()?.join(&cluster.folder_name)) } -async fn extract_overrides( - archive_path: &Path, - dest_path: &Path, - skip_existing: bool, -) -> LauncherResult<()> { - if skip_existing { - extract_overrides_no_overwrite(archive_path, dest_path).await?; - return Ok(()); - } - - polyio::extract_zip_filtered( - archive_path, - dest_path, - Some(|entry: &async_zip::StoredZipEntry| { - entry - .filename() - .as_str() - .is_ok_and(|name| name.starts_with("overrides/")) - }), - Some(|name: &str| name.trim_start_matches("overrides/").to_string()), - ) - .await?; - - Ok(()) +#[derive(Debug, Default, Clone, Serialize, Deserialize)] +struct OverrideLock { + #[serde(default)] + bundles: HashMap>, } -async fn extract_overrides_no_overwrite( +impl OverrideLock { + async fn load(root: &Path) -> Self { + let path = root.join(LOCK_REL_PATH); + match polyio::read(&path).await { + Ok(bytes) => serde_json::from_slice(&bytes).unwrap_or_else(|err| { + tracing::warn!(error = %err, "corrupt bundle override lock; starting fresh"); + Self::default() + }), + Err(_) => Self::default(), + } + } + + async fn save(&self, root: &Path) -> LauncherResult<()> { + let path = root.join(LOCK_REL_PATH); + if let Some(parent) = path.parent() { + polyio::create_dir_all(parent).await.ok(); + } + let bytes = serde_json::to_vec_pretty(self).map_err(std::io::Error::other)?; + polyio::write(&path, bytes).await?; + Ok(()) + } +} + +#[derive(Debug, Default)] +pub struct OverrideSyncReport { + pub written: Vec, + pub conflicts: Vec, + pub deleted: Vec, +} + +#[tracing::instrument(level = "debug", skip(cluster, notifier))] +pub async fn sync_bundle_overrides( archive_path: &Path, - dest_path: &Path, -) -> LauncherResult<()> { - polyio::extract_zip_filtered( - archive_path, - dest_path, - Some(|entry: &async_zip::StoredZipEntry| { - if let Ok(name) = entry.filename().as_str() { - if !name.starts_with("overrides/") { - return false; - } - - let stripped = name.trim_start_matches("overrides/"); - if stripped.is_empty() { - return false; - } + bundle_name: &str, + cluster: &ClusterRow, + notifier: Option<&NotificationService>, +) -> LauncherResult { + let root = cluster_root(cluster)?; + sync_bundle_overrides_at(archive_path, bundle_name, &root, notifier).await +} - let target_path = dest_path.join(polyio::sanitize_path(stripped)); - if target_path.exists() { - return false; - } +#[tracing::instrument(level = "debug", skip(notifier))] +async fn sync_bundle_overrides_at( + archive_path: &Path, + bundle_name: &str, + root: &Path, + notifier: Option<&NotificationService>, +) -> LauncherResult { + let entries = + polyio::read_zip_file_entries(archive_path, |name| name.starts_with(OVERRIDES_PREFIX)) + .await?; - if let Ok(is_dir) = entry.dir() { - return !is_dir; + let mut lock = OverrideLock::load(root).await; + let previous = lock.bundles.remove(bundle_name).unwrap_or_default(); + let mut next: HashMap = HashMap::new(); + let mut report = OverrideSyncReport::default(); + + for (name, bytes) in entries { + let rel = name.trim_start_matches(OVERRIDES_PREFIX); + if rel.is_empty() { + continue; + } + + let new_sha1 = sha1_bytes(&bytes); + let dest = root.join(polyio::sanitize_path(rel)); + let disk_sha1 = current_sha1(&dest).await; + let base = previous.get(rel).cloned(); + + if matches_always_update(rel) { + let wrote = write_override(&dest, &bytes, rel).await; + if wrote { + if disk_sha1.as_deref() != Some(new_sha1.as_str()) { + report.written.push(rel.to_string()); + } + next.insert(rel.to_string(), new_sha1); + } else if let Some(b) = base { + next.insert(rel.to_string(), b); + } + continue; + } + + match disk_sha1 { + // Nothing on disk -> copy from bundle + None => { + if write_override(&dest, &bytes, rel).await { + report.written.push(rel.to_string()); + next.insert(rel.to_string(), new_sha1); } } + // Already matches the bundle -> so just record it in the lock + Some(disk) if disk == new_sha1 => { + next.insert(rel.to_string(), new_sha1); + } + // Untouched -< safe to update + Some(disk) if base.as_deref() == Some(disk.as_str()) => { + if write_override(&dest, &bytes, rel).await { + report.written.push(rel.to_string()); + next.insert(rel.to_string(), new_sha1); + } else { + next.insert(rel.to_string(), disk); + } + } + // Differs from both the bundle and the base -> conflict + Some(_) => { + if let Some(b) = base { + if b != new_sha1 { + report.conflicts.push(rel.to_string()); + } + next.insert(rel.to_string(), b); + } + // untracked and untouched + } + } + } + + // delete files the bundle no longer ships, but only if they were unmodified locally + for (rel, base_sha1) in &previous { + if next.contains_key(rel) { + continue; + } + let dest = root.join(polyio::sanitize_path(rel)); + + if current_sha1(&dest).await.as_deref() == Some(base_sha1.as_str()) + && polyio::remove_file(&dest).await.is_ok() + { + report.deleted.push(rel.clone()); + } + } + + lock.bundles.insert(bundle_name.to_string(), next); + if let Err(err) = lock.save(root).await { + tracing::warn!(error = %err, "failed to persist bundle override lock"); + } + + tracing::debug!( + bundle = bundle_name, + written = report.written.len(), + conflicts = report.conflicts.len(), + deleted = report.deleted.len(), + "synced bundle overrides" + ); + + if !report.conflicts.is_empty() { + notify_conflicts(notifier, bundle_name, &report.conflicts); + } + + Ok(report) +} + +async fn write_override(dest: &Path, bytes: &[u8], rel: &str) -> bool { + if let Some(parent) = dest.parent() { + polyio::create_dir_all(parent).await.ok(); + } + match polyio::write(dest, bytes).await { + Ok(()) => true, + Err(err) => { + tracing::warn!(path = rel, error = %err, "failed to write bundle override"); false - }), - Some(|old_name: &str| { - old_name.trim_start_matches("overrides/").to_string() - }), - ) - .await - .map_err(Into::into) -} \ No newline at end of file + } + } +} + +async fn current_sha1(path: &Path) -> Option { + if polyio::try_exists(path).await.unwrap_or(false) { + sha1_file(path).await.ok() + } else { + None + } +} + +fn notify_conflicts( + notifier: Option<&NotificationService>, + bundle_name: &str, + conflicts: &[String], +) { + let Some(notifier) = notifier else { + return; + }; + + const MAX_LISTED: usize = 5; + let listed = conflicts + .iter() + .take(MAX_LISTED) + .cloned() + .collect::>() + .join(", "); + let extra = conflicts.len().saturating_sub(MAX_LISTED); + let suffix = if extra > 0 { + format!(" (+{extra} more)") + } else { + String::new() + }; + + notifier.send_info( + "Kept your config edits", + &format!( + "{bundle_name}: {} config file(s) you edited were left untouched by the update: {listed}{suffix}", + conflicts.len() + ), + ); +} + +fn matches_always_update(rel: &str) -> bool { + ALWAYS_UPDATE_GLOBS + .iter() + .any(|pattern| glob_match(pattern, rel)) +} + +fn glob_match(pattern: &str, path: &str) -> bool { + let pat: Vec<&str> = pattern.split('/').collect(); + let seg: Vec<&str> = path.split('/').collect(); + glob_segments(&pat, &seg) +} + +fn glob_segments(pat: &[&str], path: &[&str]) -> bool { + match pat.split_first() { + None => path.is_empty(), + Some((&"**", rest)) => (0..=path.len()).any(|i| glob_segments(rest, &path[i..])), + Some((first, rest)) => { + if let Some((head, tail)) = path.split_first() { + segment_match(first, head) && glob_segments(rest, tail) + } else { + false + } + } + } +} + +fn segment_match(pattern: &str, segment: &str) -> bool { + if !pattern.contains('*') { + return pattern == segment; + } + + let parts: Vec<&str> = pattern.split('*').collect(); + let mut pos = 0usize; + for (index, part) in parts.iter().enumerate() { + if part.is_empty() { + continue; + } + if index == 0 { + if !segment[pos..].starts_with(part) { + return false; + } + pos += part.len(); + } else if index == parts.len() - 1 { + if !segment[pos..].ends_with(part) { + return false; + } + } else if let Some(found) = segment[pos..].find(part) { + pos += found + part.len(); + } else { + return false; + } + } + true +} + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + use std::sync::atomic::{AtomicU64, Ordering}; + + use super::*; + + #[test] + fn glob_exact_and_wildcards() { + assert!(glob_match( + "config/fabric_loader_dependencies.json", + "config/fabric_loader_dependencies.json" + )); + assert!(!glob_match( + "config/fabric_loader_dependencies.json", + "config/other.json" + )); + assert!(glob_match("config/*.json", "config/anything.json")); + assert!(!glob_match("config/*.json", "config/nested/anything.json")); + assert!(glob_match("config/**", "config/nested/deep/file.toml")); + assert!(glob_match("**/options.txt", "a/b/options.txt")); + assert!(glob_match("**/options.txt", "options.txt")); + assert!(glob_match("config/mod-*.cfg", "config/mod-foo.cfg")); + assert!(!glob_match("config/mod-*.cfg", "config/other-foo.cfg")); + } + + fn tmp_root(tag: &str) -> PathBuf { + static COUNTER: AtomicU64 = AtomicU64::new(0); + let n = COUNTER.fetch_add(1, Ordering::Relaxed); + + let dir = + std::env::temp_dir().join(format!("oneclient_ovr_{}_{tag}_{n}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + + dir + } + + async fn write_bundle(path: &Path, entries: &[(&str, &[u8])]) { + let file = tokio::fs::File::create(path).await.unwrap(); + let mut writer = async_zip::tokio::write::ZipFileWriter::with_tokio(file); + for (name, data) in entries { + let builder = async_zip::ZipEntryBuilder::new( + (*name).to_string().into(), + async_zip::Compression::Stored, + ); + writer.write_entry_whole(builder, data).await.unwrap(); + } + writer.close().await.unwrap(); + } + + async fn read(path: &Path) -> Option> { + tokio::fs::read(path).await.ok() + } + + async fn sync(zip: &Path, root: &Path) -> OverrideSyncReport { + sync_bundle_overrides_at(zip, "test-bundle", root, None) + .await + .unwrap() + } + + #[tokio::test] + async fn fresh_install_writes_all() { + let root = tmp_root("fresh"); + let zip = root.join("v1.mrpack"); + write_bundle( + &zip, + &[ + ("overrides/config/a.toml", b"alpha"), + ("overrides/config/nested/b.cfg", b"beta"), + ("overrides/", b""), + ], + ) + .await; + + let report = sync(&zip, &root).await; + assert_eq!(report.written.len(), 2); + assert!(report.conflicts.is_empty()); + assert_eq!(read(&root.join("config/a.toml")).await.unwrap(), b"alpha"); + assert_eq!( + read(&root.join("config/nested/b.cfg")).await.unwrap(), + b"beta" + ); + } + + #[tokio::test] + async fn rerun_same_version_is_noop() { + let root = tmp_root("noop"); + let zip = root.join("v1.mrpack"); + write_bundle(&zip, &[("overrides/config/a.toml", b"alpha")]).await; + sync(&zip, &root).await; + + let report = sync(&zip, &root).await; + assert!(report.written.is_empty()); + assert!(report.conflicts.is_empty()); + assert!(report.deleted.is_empty()); + } + + #[tokio::test] + async fn untouched_file_updates() { + let root = tmp_root("update"); + let v1 = root.join("v1.mrpack"); + write_bundle(&v1, &[("overrides/config/a.toml", b"alpha")]).await; + sync(&v1, &root).await; + + let v2 = root.join("v2.mrpack"); + write_bundle(&v2, &[("overrides/config/a.toml", b"alpha-v2")]).await; + let report = sync(&v2, &root).await; + + assert_eq!(report.written, vec!["config/a.toml".to_string()]); + assert!(report.conflicts.is_empty()); + assert_eq!( + read(&root.join("config/a.toml")).await.unwrap(), + b"alpha-v2" + ); + } + + #[tokio::test] + async fn user_edit_with_bundle_unchanged_is_kept_silently() { + let root = tmp_root("edit_noconflict"); + let v1 = root.join("v1.mrpack"); + write_bundle(&v1, &[("overrides/config/a.toml", b"alpha")]).await; + sync(&v1, &root).await; + + tokio::fs::write(root.join("config/a.toml"), b"user-edit") + .await + .unwrap(); + + let report = sync(&v1, &root).await; + assert!(report.written.is_empty()); + assert!(report.conflicts.is_empty()); + assert_eq!( + read(&root.join("config/a.toml")).await.unwrap(), + b"user-edit" + ); + } + + #[tokio::test] + async fn user_edit_and_bundle_changed_is_conflict_kept() { + let root = tmp_root("conflict"); + let v1 = root.join("v1.mrpack"); + write_bundle(&v1, &[("overrides/config/a.toml", b"alpha")]).await; + sync(&v1, &root).await; + + tokio::fs::write(root.join("config/a.toml"), b"user-edit") + .await + .unwrap(); + + let v2 = root.join("v2.mrpack"); + write_bundle(&v2, &[("overrides/config/a.toml", b"alpha-v2")]).await; + let report = sync(&v2, &root).await; + + assert_eq!(report.conflicts, vec!["config/a.toml".to_string()]); + assert!(report.written.is_empty()); + + assert_eq!( + read(&root.join("config/a.toml")).await.unwrap(), + b"user-edit" + ); + + let report2 = sync(&v2, &root).await; + assert_eq!( + read(&root.join("config/a.toml")).await.unwrap(), + b"user-edit" + ); + assert_eq!(report2.conflicts, vec!["config/a.toml".to_string()]); + } + + #[tokio::test] + async fn always_update_overwrites_user_edit() { + let root = tmp_root("always"); + let path = "config/fabric_loader_dependencies.json"; + let v1 = root.join("v1.mrpack"); + write_bundle(&v1, &[(&format!("overrides/{path}"), b"deps-v1")]).await; + sync(&v1, &root).await; + + tokio::fs::write(root.join(path), b"runtime-mutated") + .await + .unwrap(); + + let v2 = root.join("v2.mrpack"); + write_bundle(&v2, &[(&format!("overrides/{path}"), b"deps-v2")]).await; + let report = sync(&v2, &root).await; + + assert!(report.conflicts.is_empty()); + assert_eq!(read(&root.join(path)).await.unwrap(), b"deps-v2"); + } + + #[tokio::test] + async fn removed_file_deleted_only_when_unmodified() { + let root = tmp_root("removed"); + let v1 = root.join("v1.mrpack"); + write_bundle( + &v1, + &[ + ("overrides/config/gone.toml", b"g"), + ("overrides/config/kept.toml", b"k"), + ], + ) + .await; + sync(&v1, &root).await; + + // User modified kept.toml; gone.toml untouched. + tokio::fs::write(root.join("config/kept.toml"), b"edited") + .await + .unwrap(); + + let v2 = root.join("v2.mrpack"); + write_bundle(&v2, &[("overrides/config/a.toml", b"a")]).await; + let report = sync(&v2, &root).await; + + assert_eq!(report.deleted, vec!["config/gone.toml".to_string()]); + assert!(read(&root.join("config/gone.toml")).await.is_none()); + + assert_eq!( + read(&root.join("config/kept.toml")).await.unwrap(), + b"edited" + ); + } + + #[tokio::test] + async fn bootstrap_pre_existing_edit_is_never_overwritten() { + let root = tmp_root("bootstrap"); + + tokio::fs::create_dir_all(root.join("config")) + .await + .unwrap(); + tokio::fs::write(root.join("config/a.toml"), b"pre-existing") + .await + .unwrap(); + + let v1 = root.join("v1.mrpack"); + write_bundle(&v1, &[("overrides/config/a.toml", b"bundle-version")]).await; + let report = sync(&v1, &root).await; + + assert!(report.written.is_empty()); + assert!(report.conflicts.is_empty()); + assert_eq!( + read(&root.join("config/a.toml")).await.unwrap(), + b"pre-existing" + ); + + let report2 = sync(&v1, &root).await; + assert!(report2.written.is_empty()); + assert_eq!( + read(&root.join("config/a.toml")).await.unwrap(), + b"pre-existing" + ); + } +} diff --git a/packages/oneclient_core/src/bundles/polymrpack.rs b/packages/oneclient_core/src/bundles/polymrpack.rs index 612afe2b..4e6ec3f8 100644 --- a/packages/oneclient_core/src/bundles/polymrpack.rs +++ b/packages/oneclient_core/src/bundles/polymrpack.rs @@ -56,6 +56,7 @@ struct PolyMrpackHashes { pub sha1: String, } +#[tracing::instrument(level = "debug")] pub async fn read_meta_from_archive(path: &Path) -> LauncherResult { let manifest = read_manifest_from_archive(path).await?; Ok(PolyMrpackMeta { @@ -69,6 +70,7 @@ pub async fn read_meta_from_archive(path: &Path) -> LauncherResult LauncherResult { let file = tokio::fs::File::open(path).await?; let reader = tokio::io::BufReader::new(file); diff --git a/packages/oneclient_core/src/bundles/runtime.rs b/packages/oneclient_core/src/bundles/runtime.rs index c182cec0..fed583a7 100644 --- a/packages/oneclient_core/src/bundles/runtime.rs +++ b/packages/oneclient_core/src/bundles/runtime.rs @@ -1,6 +1,8 @@ use std::sync::atomic::{AtomicBool, Ordering}; + use crate::bundles::manager::BundlesManager; +use crate::bundles::ApplyBundleUpdatesResult; use crate::bundles::updates::apply_bundle_updates_for_all_clusters; use crate::state::LauncherServices; @@ -10,10 +12,20 @@ pub fn is_bundle_syncing() -> bool { BUNDLE_SYNCING.load(Ordering::Relaxed) } -pub async fn sync_all_cluster_bundles(bundles: &BundlesManager, services: &LauncherServices) { +#[tracing::instrument(skip_all)] +pub async fn sync_all_cluster_bundles( + bundles: &BundlesManager, + services: &LauncherServices, +) -> Vec<(i64, ApplyBundleUpdatesResult)> { + tracing::info!("syncing all cluster bundles"); BUNDLE_SYNCING.store(true, Ordering::Relaxed); - if let Err(err) = apply_bundle_updates_for_all_clusters(bundles, services).await { - tracing::error!("failed to apply bundle updates for clusters: {err:#}"); - } + let changed = match apply_bundle_updates_for_all_clusters(bundles, services).await { + Ok(changed) => changed, + Err(err) => { + tracing::error!("failed to apply bundle updates for clusters: {err:#}"); + Vec::new() + } + }; BUNDLE_SYNCING.store(false, Ordering::Relaxed); + changed } diff --git a/packages/oneclient_core/src/bundles/updates.rs b/packages/oneclient_core/src/bundles/updates.rs index 7192da30..8d90a8b4 100644 --- a/packages/oneclient_core/src/bundles/updates.rs +++ b/packages/oneclient_core/src/bundles/updates.rs @@ -8,7 +8,6 @@ use oneclient_db::dao::cluster_bundle as bundle_dao; use oneclient_db::models::ClusterPatch; use oneclient_db::models::{BundleTrackedArtifactRow, ClusterBundleOverrideRow, OverrideType}; use tokio::sync::Mutex as AsyncMutex; -use tracing::instrument; use crate::bundles::install::{install_package_from_bundle, remove_artifact_from_cluster}; use crate::bundles::manager::BundlesManager; @@ -34,6 +33,7 @@ fn cluster_lock(cluster_id: i64) -> Arc> { .clone() } +#[tracing::instrument(level = "debug", skip(bundles, services))] pub async fn check_bundle_updates( cluster_id: i64, bundles: &BundlesManager, @@ -43,7 +43,7 @@ pub async fn check_bundle_updates( check_bundle_updates_inner(cluster_id, bundles, services, &overrides).await } -#[instrument(skip(bundles, services, overrides))] +#[tracing::instrument(level = "debug", skip(bundles, services, overrides))] async fn check_bundle_updates_inner( cluster_id: i64, bundles: &BundlesManager, @@ -307,6 +307,7 @@ async fn check_bundle_updates_inner( }) } +#[tracing::instrument(skip(bundles, services))] pub async fn apply_bundle_updates( cluster_id: i64, bundles: &BundlesManager, @@ -317,6 +318,14 @@ pub async fn apply_bundle_updates( let overrides = bundle_dao::list_overrides(&services.db, cluster_id).await?; let check = check_bundle_updates_inner(cluster_id, bundles, services, &overrides).await?; + tracing::info!( + cluster_id, + updates = check.updates_available.len(), + additions = check.additions_available.len(), + removals = check.removals_available.len(), + "applying bundle updates" + ); + let mut result = ApplyBundleUpdatesResult::default(); for removal in check.removals_available { @@ -328,8 +337,21 @@ pub async fn apply_bundle_updates( } } + let session = (!check.updates_available.is_empty() || !check.additions_available.is_empty()) + .then(|| { + crate::notification::GroupedProgressSession::start( + &services.notifier, + "Updating bundle content", + ) + }); + for update in check.updates_available { - match apply_single_update(&update, &overrides, services).await { + let child = session.as_ref().map(|s| { + let c = s.child(update.new_file.display_name(), update.new_file.size.max(1)); + c.set_phase(crate::notification::TaskPhase::Downloading); + c + }); + match apply_single_update(&update, &overrides, child.as_ref(), services).await { Ok(_) => result.updates_applied.push(update), Err(err) => result.updates_failed.push(err.to_string()), } @@ -337,28 +359,22 @@ pub async fn apply_bundle_updates( for addition in check.additions_available { let file_id = addition.new_file.kind.package_id(); - match apply_single_addition(&addition, &overrides, services).await { + let child = session.as_ref().map(|s| { + let c = s.child(addition.new_file.display_name(), addition.new_file.size.max(1)); + c.set_phase(crate::notification::TaskPhase::Downloading); + c + }); + match apply_single_addition(&addition, &overrides, child.as_ref(), services).await { Ok(_) => result.additions_applied.push(addition), Err(err) => result.additions_failed.push(format!("{file_id}: {err:#}")), } } - let has_changes = !result.updates_applied.is_empty() - || !result.removals_applied.is_empty() - || !result.additions_applied.is_empty(); - - if has_changes { - let mut affected = HashSet::new(); - for u in &result.updates_applied { - affected.insert(u.bundle_name.clone()); - } - for r in &result.removals_applied { - affected.insert(r.bundle_name.clone()); - } - for a in &result.additions_applied { - affected.insert(a.bundle_name.clone()); - } + if let Some(session) = session { + session.finish(); + } + { let cluster = PackageStore::get_cluster(cluster_id, services).await?; let loader = GameLoader::from_repr(cluster.mc_loader as u8).unwrap_or(GameLoader::Fabric); if let Ok(archives) = bundles @@ -366,12 +382,19 @@ pub async fn apply_bundle_updates( .await { for archive in archives { - if affected.contains(&archive.manifest.name) { - let _ = overrides::extract_bundle_overrides_no_overwrite( - &archive.bundle.path, - &cluster, - ) - .await; + if let Err(err) = overrides::sync_bundle_overrides( + &archive.bundle.path, + &archive.manifest.name, + &cluster, + Some(&services.notifier), + ) + .await + { + tracing::warn!( + bundle = %archive.manifest.name, + error = %err, + "failed to sync bundle overrides during update" + ); } } } @@ -386,9 +409,11 @@ pub async fn apply_bundle_updates( Ok(result) } +#[tracing::instrument(level = "debug", skip_all, fields(cluster_id = update.cluster_id, bundle = %update.bundle_name, new_version = %update.new_version_id))] async fn apply_single_update( update: &BundlePackageUpdate, overrides: &[ClusterBundleOverrideRow], + child: Option<&crate::notification::GroupedProgressChild>, services: &LauncherServices, ) -> LauncherResult<()> { let hash = install_package_from_bundle( @@ -396,9 +421,14 @@ async fn apply_single_update( update.cluster_id, &update.bundle_name, true, + child, services, ) .await?; + if let Some(child) = child { + child.set_phase(crate::notification::TaskPhase::Installing); + child.finish(); + } let file_id = update.new_file.kind.package_id(); if should_be_disabled(&update.bundle_name, &file_id, overrides) { @@ -412,9 +442,11 @@ async fn apply_single_update( remove_artifact_from_cluster(update.cluster_id, &update.installed_hash, false, services).await } +#[tracing::instrument(level = "debug", skip_all, fields(cluster_id = addition.cluster_id, bundle = %addition.bundle_name))] async fn apply_single_addition( addition: &BundlePackageAddition, overrides: &[ClusterBundleOverrideRow], + child: Option<&crate::notification::GroupedProgressChild>, services: &LauncherServices, ) -> LauncherResult<()> { let hash = install_package_from_bundle( @@ -422,9 +454,14 @@ async fn apply_single_addition( addition.cluster_id, &addition.bundle_name, true, + child, services, ) .await?; + if let Some(child) = child { + child.set_phase(crate::notification::TaskPhase::Installing); + child.finish(); + } let file_id = addition.new_file.kind.package_id(); if should_be_disabled(&addition.bundle_name, &file_id, overrides) { @@ -446,6 +483,7 @@ fn should_be_disabled( }) } +#[tracing::instrument(level = "debug", skip(bundles, services))] async fn sync_cluster_loader_version_from_bundles( cluster_id: i64, bundles: &BundlesManager, @@ -494,6 +532,7 @@ async fn sync_cluster_loader_version_from_bundles( Ok(()) } +#[tracing::instrument(level = "debug", skip(bundles, services))] pub async fn get_bundles_with_update_status( cluster_id: i64, bundles: &BundlesManager, @@ -590,20 +629,30 @@ pub async fn get_bundles_with_update_status( Ok(results) } +#[tracing::instrument(level = "debug", skip_all)] pub async fn apply_bundle_updates_for_all_clusters( bundles: &BundlesManager, services: &LauncherServices, -) -> LauncherResult<()> { +) -> LauncherResult> { + let mut changed = Vec::new(); for cluster in cluster_dao::list_all(&services.db).await? { - if let Err(err) = apply_bundle_updates(cluster.id, bundles, services).await { - tracing::warn!( + match apply_bundle_updates(cluster.id, bundles, services).await { + Ok(result) => { + if !result.updates_applied.is_empty() + || !result.additions_applied.is_empty() + || !result.removals_applied.is_empty() + { + changed.push((cluster.id, result)); + } + } + Err(err) => tracing::warn!( cluster_id = cluster.id, error = %err, "bundle update apply failed for cluster" - ); + ), } } - Ok(()) + Ok(changed) } fn bundle_package_key( @@ -620,6 +669,7 @@ fn bundle_package_key( } } +#[tracing::instrument(level = "debug", skip_all)] async fn installed_bundle_keys( services: &LauncherServices, linked: &[LinkedArtifactInfo], diff --git a/packages/oneclient_core/src/changelog.rs b/packages/oneclient_core/src/changelog.rs index 343c68c7..3567e5ba 100644 --- a/packages/oneclient_core/src/changelog.rs +++ b/packages/oneclient_core/src/changelog.rs @@ -35,6 +35,7 @@ pub fn parse_changelog(data: &str) -> Vec { groups } +#[tracing::instrument(level = "debug", skip(services))] pub async fn fetch_changelog(services: &LauncherServices) -> LauncherResult { let url = format!("{}/oneclient/CHANGE_LOG.md", meta_url_base()); let cache_path = paths::caches_dir()?.join("CHANGE_LOG.md"); diff --git a/packages/oneclient_core/src/clusters/manager.rs b/packages/oneclient_core/src/clusters/manager.rs index 8e6001ab..62c0787c 100644 --- a/packages/oneclient_core/src/clusters/manager.rs +++ b/packages/oneclient_core/src/clusters/manager.rs @@ -1,6 +1,5 @@ use oneclient_db::dao::{cluster as cluster_dao, setting_profile as profile_dao}; use oneclient_db::models::{ClusterId, ClusterPatch, NewCluster}; -use tracing::instrument; use crate::packages::domain::ContentType; use crate::settings::store::{ @@ -28,6 +27,7 @@ impl ClusterManager { name.trim().to_string() } + #[tracing::instrument(level = "debug", skip(state))] pub async fn get(state: &LauncherState, cluster_id: ClusterId) -> LauncherResult { let row = cluster_dao::get_by_id(&state.services.db, cluster_id) .await? @@ -35,6 +35,7 @@ impl ClusterManager { Ok(Cluster::try_from_row(row)?) } + #[tracing::instrument(level = "debug", skip(state))] pub async fn list(state: &LauncherState) -> LauncherResult> { let rows = cluster_dao::list_all(&state.services.db).await?; rows.into_iter() @@ -43,7 +44,7 @@ impl ClusterManager { .map_err(Into::into) } - #[instrument(skip(state))] + #[tracing::instrument(skip(state))] pub async fn create( state: &LauncherState, options: CreateClusterOptions, @@ -57,14 +58,19 @@ impl ClusterManager { let cluster_path = crate::paths::clusters_dir()?.join(&folder_name); match create_inner(state, &options, &name, &folder_name, &cluster_path).await { - Ok(cluster) => Ok(cluster), + Ok(cluster) => { + tracing::info!(cluster_id = cluster.id, name = %cluster.name, "created cluster"); + Ok(cluster) + } Err(err) => { + tracing::warn!(name = %name, error = %err, "cluster creation failed, cleaning up directory"); let _ = polyio::remove_dir_all(&cluster_path).await; Err(err) } } } + #[tracing::instrument(level = "debug", skip(state))] pub async fn update( state: &LauncherState, cluster_id: ClusterId, @@ -92,6 +98,7 @@ impl ClusterManager { Ok(Cluster::try_from_row(row)?) } + #[tracing::instrument(skip(state))] pub async fn delete( state: &LauncherState, cluster_id: ClusterId, @@ -110,9 +117,11 @@ impl ClusterManager { } } + tracing::info!(cluster_id, remove_files, "deleted cluster"); Ok(()) } + #[tracing::instrument(level = "debug", skip(state))] pub async fn set_stage( state: &LauncherState, cluster_id: ClusterId, @@ -122,6 +131,7 @@ impl ClusterManager { Ok(Cluster::try_from_row(row)?) } + #[tracing::instrument(level = "debug", skip(state))] pub async fn uses_dedicated_dir( state: &LauncherState, cluster_id: ClusterId, @@ -129,6 +139,7 @@ impl ClusterManager { Ok(Self::get(state, cluster_id).await?.uses_dedicated_dir()) } + #[tracing::instrument(level = "debug", skip(state))] pub async fn set_dedicated_dir( state: &LauncherState, cluster_id: ClusterId, @@ -149,6 +160,7 @@ impl ClusterManager { Ok(()) } + #[tracing::instrument(level = "debug", skip(state))] pub async fn add_playtime( state: &LauncherState, cluster_id: ClusterId, @@ -160,6 +172,7 @@ impl ClusterManager { Ok(Cluster::try_from_row(row)?) } + #[tracing::instrument(level = "debug", skip(state, cluster), fields(cluster_id = cluster.id))] pub async fn resolve_settings( state: &LauncherState, cluster: &Cluster, @@ -174,6 +187,7 @@ impl ClusterManager { .await } + #[tracing::instrument(level = "debug", skip(state, update))] pub async fn update_profile( state: &LauncherState, cluster_id: ClusterId, @@ -187,6 +201,7 @@ impl ClusterManager { update_named_profile(&state.services.db, &profile_name, update).await } + #[tracing::instrument(skip(state, shared_progress))] pub async fn prepare( state: &Arc, cluster_id: ClusterId, @@ -208,6 +223,7 @@ impl ClusterManager { .await } + #[tracing::instrument(level = "debug", skip(state))] pub async fn create_and_assign_profile( state: &LauncherState, cluster_id: ClusterId, @@ -235,6 +251,7 @@ impl ClusterManager { } } +#[tracing::instrument(level = "debug", skip(state, options))] async fn create_inner( state: &LauncherState, options: &CreateClusterOptions, @@ -273,6 +290,7 @@ async fn create_inner( Ok(Cluster::try_from_row(row)?) } +#[tracing::instrument(level = "debug", skip(pool))] async fn ensure_profile_exists(pool: &oneclient_db::DbPool, name: &str) -> LauncherResult<()> { if profile_dao::get_by_name(pool, name).await?.is_none() { return Err(ClusterError::ProfileNotFound(name.to_string()).into()); @@ -280,6 +298,7 @@ async fn ensure_profile_exists(pool: &oneclient_db::DbPool, name: &str) -> Launc Ok(()) } +#[tracing::instrument(level = "debug")] async fn resolve_unique_folder_name(name: &str) -> LauncherResult { let cluster_dir = crate::paths::clusters_dir()?; let mut folder_name = name.to_string(); @@ -301,6 +320,7 @@ async fn resolve_unique_folder_name(name: &str) -> LauncherResult { Ok(folder_name) } +#[tracing::instrument(level = "debug")] async fn ensure_content_dirs(cluster_path: &std::path::Path) -> LauncherResult<()> { for content_type in [ ContentType::Mod, diff --git a/packages/oneclient_core/src/clusters/prepare.rs b/packages/oneclient_core/src/clusters/prepare.rs index 5ca9b0bf..d4892dd1 100644 --- a/packages/oneclient_core/src/clusters/prepare.rs +++ b/packages/oneclient_core/src/clusters/prepare.rs @@ -4,7 +4,6 @@ use std::sync::Arc; use interfrost::api::minecraft::{DownloadType, VersionInfo}; use interfrost::api::modded::SidedDataEntry; use tokio::process::Command; -use tracing::instrument; use crate::clusters::cluster::Cluster; use crate::clusters::error::ClusterError; @@ -21,7 +20,7 @@ use crate::paths; use crate::state::LauncherState; use crate::{GameError, LauncherResult}; -#[instrument(skip(state, metadata, shared_progress))] +#[tracing::instrument(skip(state, metadata, shared_progress))] pub async fn prepare_cluster( state: &Arc, metadata: &mut MetadataStore, @@ -34,6 +33,14 @@ pub async fn prepare_cluster( let cluster = ClusterManager::get(state, cluster_id).await?; let continuing = cluster.stage == ClusterStage::Downloading; + tracing::info!( + cluster_id, + mc_version = %cluster.mc_version, + force, + continuing, + "preparing cluster" + ); + if !continuing { ClusterManager::set_stage(state, cluster_id, ClusterStage::Downloading).await?; } @@ -62,6 +69,7 @@ pub async fn prepare_cluster( } if let Err(err) = result { + tracing::error!(cluster_id, error = %err, "cluster preparation failed"); if !continuing { let _ = ClusterManager::set_stage(state, cluster_id, ClusterStage::NotReady).await; } @@ -69,6 +77,7 @@ pub async fn prepare_cluster( } let cluster = ClusterManager::set_stage(state, cluster_id, ClusterStage::Ready).await?; + tracing::debug!(cluster_id, "cluster stage set to Ready"); Ok(cluster) } @@ -91,6 +100,7 @@ fn game_download_bytes(info: &VersionInfo) -> u64 { client + assets + libraries } +#[tracing::instrument(level = "debug", skip(state, bundles))] pub async fn estimate_cluster_download( state: &Arc, cluster_id: i64, @@ -134,6 +144,7 @@ pub async fn estimate_cluster_download( Ok(total) } +#[tracing::instrument(skip(state, metadata, cluster, progress), fields(cluster_id = cluster.id))] async fn install_cluster( state: &Arc, metadata: &mut MetadataStore, @@ -205,6 +216,7 @@ async fn install_cluster( Ok(()) } +#[tracing::instrument(level = "debug", skip(cluster, version_info, java), fields(cluster_id = cluster.id))] async fn run_forge_processors( cluster: &Cluster, version_info: &mut VersionInfo, diff --git a/packages/oneclient_core/src/clusters/provision.rs b/packages/oneclient_core/src/clusters/provision.rs index 59a3bcb5..c80b105f 100644 --- a/packages/oneclient_core/src/clusters/provision.rs +++ b/packages/oneclient_core/src/clusters/provision.rs @@ -1,7 +1,6 @@ use std::str::FromStr; use oneclient_db::dao::{bundle as bundle_dao, cluster as cluster_dao}; -use tracing::instrument; use crate::packages::domain::GameLoader; use crate::state::LauncherState; @@ -12,7 +11,7 @@ use super::cluster::Cluster; use super::manager::ClusterManager; use super::options::CreateClusterOptions; -#[instrument(skip(state))] +#[tracing::instrument(skip(state))] pub async fn ensure_from_bundles(state: &LauncherState) -> LauncherResult> { let groups = bundle_dao::list_distinct_version_loaders(&state.services.db).await?; let mut created = Vec::new(); @@ -69,7 +68,7 @@ pub async fn ensure_from_bundles(state: &LauncherState) -> LauncherResult LauncherResult> { let metadata = state.versions.metadata().await; let mut created = Vec::new(); diff --git a/packages/oneclient_core/src/dev.rs b/packages/oneclient_core/src/dev.rs index 1a60a38d..3e6595d1 100644 --- a/packages/oneclient_core/src/dev.rs +++ b/packages/oneclient_core/src/dev.rs @@ -160,6 +160,9 @@ fn spawn_notification_handler(mut rx: mpsc::UnboundedReceiver) { Notification::InvalidateClusters => {} Notification::InvalidateJava => {} Notification::SyncComplete => {} + Notification::MicrosoftLoginStatus(status) => { + mp.suspend(|| tracing::info!(?status, "microsoft login status")); + } Notification::GameStage { cluster_id, stage } => { mp.suspend(|| tracing::info!(cluster_id, ?stage, "game stage")); } diff --git a/packages/oneclient_core/src/game/analytics.rs b/packages/oneclient_core/src/game/analytics.rs index 6dd71423..fc416f53 100644 --- a/packages/oneclient_core/src/game/analytics.rs +++ b/packages/oneclient_core/src/game/analytics.rs @@ -233,6 +233,7 @@ pub struct Analytics { pub servers: Vec, } +#[tracing::instrument(level = "debug")] pub async fn global_analytics() -> LauncherResult { let state = LauncherState::get()?; let db = &state.services.db; @@ -244,6 +245,7 @@ pub async fn global_analytics() -> LauncherResult { }) } +#[tracing::instrument(level = "debug")] pub async fn cluster_analytics(cluster_id: i64) -> LauncherResult { let state = LauncherState::get()?; let db = &state.services.db; diff --git a/packages/oneclient_core/src/game/arguments.rs b/packages/oneclient_core/src/game/arguments.rs index bb2ea50b..7667d751 100644 --- a/packages/oneclient_core/src/game/arguments.rs +++ b/packages/oneclient_core/src/game/arguments.rs @@ -168,6 +168,7 @@ pub fn processor_arguments, S: std::hash::BuildHasher>( Ok(parsed) } +#[tracing::instrument(skip_all, level = "debug")] pub async fn main_class(path: impl AsRef) -> LauncherResult> { let data = polyio::read(path.as_ref()).await?; let mut class_name = None; diff --git a/packages/oneclient_core/src/game/download.rs b/packages/oneclient_core/src/game/download.rs index 0153f05b..5b42f10d 100644 --- a/packages/oneclient_core/src/game/download.rs +++ b/packages/oneclient_core/src/game/download.rs @@ -6,6 +6,7 @@ use crate::http::{RequestClient, ResponseExt, ResponseNotifyOptions, ResponseOpt use crate::notification::{GroupedProgressSession, NotificationService, TaskPhase}; use crate::{LauncherError, LauncherResult}; +#[tracing::instrument(skip(requester, notifier, progress, label, expected_sha1), fields(%url), level = "debug")] pub async fn download_to_path( requester: &RequestClient, notifier: &NotificationService, @@ -56,6 +57,7 @@ pub async fn download_to_path( Ok(()) } +#[tracing::instrument(skip(requester, notifier, progress, label), fields(%url), level = "debug")] pub async fn fetch_bytes_verified( requester: &RequestClient, notifier: &NotificationService, diff --git a/packages/oneclient_core/src/game/launch.rs b/packages/oneclient_core/src/game/launch.rs index ff4a2213..08cbe32d 100644 --- a/packages/oneclient_core/src/game/launch.rs +++ b/packages/oneclient_core/src/game/launch.rs @@ -40,8 +40,11 @@ pub async fn launch_cluster( account: &MinecraftAccount, search_for_java: bool, ) -> LauncherResult { + tracing::info!(cluster_id, search_for_java, "launching cluster"); + let parallel = state.settings.read().allow_parallel_running_clusters; if !parallel && state.games.is_running(cluster_id) { + tracing::warn!(cluster_id, "cluster already running; refusing launch"); return Err(GameError::AlreadyRunning(cluster_id).into()); } @@ -435,6 +438,7 @@ fn apply_env(command: &mut Command, profile: &GameSettingsProfile) { } } +#[tracing::instrument(skip(cwd), fields(hook), level = "debug")] async fn run_hook(hook: Option<&str>, cwd: &Path) { let Some(hook) = hook.map(str::trim).filter(|h| !h.is_empty()) else { return; diff --git a/packages/oneclient_core/src/game/metadata.rs b/packages/oneclient_core/src/game/metadata.rs index bf8db3de..45e75807 100644 --- a/packages/oneclient_core/src/game/metadata.rs +++ b/packages/oneclient_core/src/game/metadata.rs @@ -4,7 +4,6 @@ use futures_util::{StreamExt, stream}; use interfrost::api::minecraft::{AssetsIndex, DownloadType, Library, Os, Version, VersionInfo}; use interfrost::api::modded::LoaderVersion; use reqwest::Method; -use tracing::instrument; use crate::game::GameError; use crate::game::download::{download_to_path, fetch_bytes_verified}; @@ -18,7 +17,7 @@ use crate::paths; use crate::state::LauncherServices; use crate::{LauncherError, LauncherResult}; -#[instrument(skip_all)] +#[tracing::instrument(skip_all)] pub async fn download_minecraft( services: &LauncherServices, progress: &GroupedProgressSession, @@ -51,7 +50,7 @@ pub async fn download_minecraft( Ok(()) } -#[instrument(skip(services, progress))] +#[tracing::instrument(skip(services, progress), level = "debug")] pub async fn download_version_info( services: &LauncherServices, progress: Option<&GroupedProgressSession>, @@ -71,7 +70,7 @@ pub async fn download_version_info( let data = polyio::read(&path).await?; serde_json::from_slice(&data)? } else { - tracing::info!( + tracing::debug!( version_id = %version_id, "downloading Minecraft version metadata" ); @@ -150,7 +149,7 @@ pub async fn download_version_info( Ok(result) } -#[instrument(skip_all)] +#[tracing::instrument(skip_all, level = "debug")] pub async fn download_assets_index( services: &LauncherServices, progress: &GroupedProgressSession, @@ -180,7 +179,7 @@ pub async fn download_assets_index( polyio::read_json(&path).await.map_err(Into::into) } -#[instrument(skip_all)] +#[tracing::instrument(skip_all, level = "debug")] pub async fn download_assets( services: &LauncherServices, progress: &GroupedProgressSession, @@ -245,6 +244,7 @@ pub async fn download_assets( Ok(failed) } +#[tracing::instrument(skip(services, progress, version), fields(version_id = %version.id, force), level = "debug")] pub async fn download_client( services: &LauncherServices, progress: &GroupedProgressSession, @@ -283,6 +283,7 @@ pub async fn download_client( Ok(path) } +#[tracing::instrument(skip(version_info), level = "debug")] pub fn libraries_missing( version_info: &VersionInfo, java_arch: &str, @@ -317,6 +318,7 @@ fn lib_short(name: &str) -> String { } } +#[tracing::instrument(skip(services, progress, libraries), level = "debug")] pub async fn download_libraries( services: &LauncherServices, progress: &GroupedProgressSession, @@ -452,6 +454,7 @@ pub async fn download_libraries( Ok(failed) } +#[tracing::instrument(skip(metadata, services), level = "debug")] pub async fn get_loader_versions( metadata: &mut MetadataStore, services: &LauncherServices, @@ -475,6 +478,7 @@ pub async fn get_loader_versions( Ok(Vec::new()) } +#[tracing::instrument(skip(metadata, services), level = "debug")] pub async fn get_loader_version( metadata: &mut MetadataStore, services: &LauncherServices, @@ -557,6 +561,7 @@ pub async fn get_loader_version( } } +#[tracing::instrument(skip(metadata, services), level = "debug")] pub async fn resolve_minecraft_version( metadata: &mut MetadataStore, services: &LauncherServices, @@ -581,6 +586,7 @@ pub async fn resolve_minecraft_version( )) } +#[tracing::instrument(skip(metadata, services), level = "debug")] pub async fn get_game_versions( metadata: &mut MetadataStore, services: &LauncherServices, @@ -589,6 +595,7 @@ pub async fn get_game_versions( Ok(manifest.versions.clone()) } +#[tracing::instrument(skip(metadata, services), level = "debug")] pub async fn get_loaders_for_version( metadata: &mut MetadataStore, services: &LauncherServices, diff --git a/packages/oneclient_core/src/game/process.rs b/packages/oneclient_core/src/game/process.rs index 590f6e5e..3b13b631 100644 --- a/packages/oneclient_core/src/game/process.rs +++ b/packages/oneclient_core/src/game/process.rs @@ -31,9 +31,13 @@ impl GameProcessManager { self.kills.lock().unwrap().insert(cluster_id, tx); } + #[tracing::instrument(skip(self), level = "debug")] pub fn kill(&self, cluster_id: i64) -> bool { match self.kills.lock().unwrap().remove(&cluster_id) { - Some(tx) => tx.send(()).is_ok(), + Some(tx) => { + tracing::debug!(cluster_id, "signalling kill to running game"); + tx.send(()).is_ok() + } None => false, } } diff --git a/packages/oneclient_core/src/game/session.rs b/packages/oneclient_core/src/game/session.rs index 0056e67f..6f94e3bb 100644 --- a/packages/oneclient_core/src/game/session.rs +++ b/packages/oneclient_core/src/game/session.rs @@ -50,6 +50,7 @@ pub(crate) struct SessionRecorder { } impl SessionRecorder { + #[tracing::instrument(skip(state, java), fields(cluster_id, ram_allocated_mb), level = "debug")] pub(crate) async fn start( state: &Arc, cluster_id: i64, @@ -110,6 +111,7 @@ impl SessionRecorder { } } + #[tracing::instrument(skip(self), fields(exit_code), level = "debug")] pub(crate) async fn finish(self, exit_code: Option) { if let Some(open) = self.open_server.lock().await.take() && let Err(err) = session_dao::finish_server(&self.db, &open).await @@ -125,6 +127,7 @@ impl SessionRecorder { } } +#[tracing::instrument(skip(state), fields(cluster_id), level = "debug")] async fn count_enabled_mods(state: &Arc, cluster_id: i64) -> usize { match PackageStore::list_linked_artifacts(cluster_id, &state.services).await { Ok(linked) => linked diff --git a/packages/oneclient_core/src/game/shared_dir.rs b/packages/oneclient_core/src/game/shared_dir.rs index e54cea82..1cc5b30b 100644 --- a/packages/oneclient_core/src/game/shared_dir.rs +++ b/packages/oneclient_core/src/game/shared_dir.rs @@ -20,6 +20,7 @@ const SWAP_TYPES: [ContentType; 3] = [ const FABRIC_DEP_OVERRIDES: &str = "config/fabric_loader_dependencies.json"; +#[tracing::instrument(skip(services, cluster), fields(cluster_id = cluster.id, game_dir = %game_dir.display()), level = "debug")] pub async fn sync_shared_content( services: &LauncherServices, cluster: &Cluster, @@ -71,6 +72,7 @@ pub async fn sync_shared_content( Ok(()) } +#[tracing::instrument(skip(services, cluster), fields(cluster_id = cluster.id), level = "debug")] pub async fn import_manual_content( services: &LauncherServices, cluster: &Cluster, @@ -117,7 +119,7 @@ pub async fn import_manual_content( match PackageStore::import_local_file(&path, content_type, cluster.id, services).await { Ok(_) => { - tracing::info!(file = name, "registered manually-added shared content") + tracing::debug!(file = name, "registered manually-added shared content") } Err(err) => tracing::warn!( file = name, @@ -140,6 +142,7 @@ fn has_content_extension(content_type: ContentType, name: &str) -> bool { const ALLOWED_SYMLINKS_NAME: &str = "allowed_symlinks.txt"; +#[tracing::instrument(level = "debug")] pub async fn write_allowed_symlinks(game_dir: &Path) -> LauncherResult<()> { let root = crate::paths::launcher_dir()?; let base = polyio::canonicalize(root).unwrap_or_else(|_| root.to_path_buf()); @@ -203,6 +206,7 @@ async fn ensure_note(dir: &Path, content_type: ContentType) { .ok(); } +#[tracing::instrument(level = "debug")] pub async fn clear_shared_content(game_dir: &Path) -> LauncherResult<()> { for content_type in SWAP_TYPES { let dir = game_dir.join(content_type.folder_name()); @@ -213,6 +217,7 @@ pub async fn clear_shared_content(game_dir: &Path) -> LauncherResult<()> { Ok(()) } +#[tracing::instrument(skip(cluster), fields(cluster_id = cluster.id), level = "debug")] pub async fn link_cluster_logs(cluster: &Cluster, game_dir: &Path) { let cluster_dir = match cluster.dir() { Ok(dir) => dir, @@ -268,6 +273,7 @@ async fn redirect_dir(shared: &Path, target: &Path) -> LauncherResult<()> { Ok(()) } +#[tracing::instrument(level = "debug")] pub async fn unlink_cluster_logs(game_dir: &Path) { for name in REDIRECTED_DIRS { let shared = game_dir.join(name); diff --git a/packages/oneclient_core/src/http/response.rs b/packages/oneclient_core/src/http/response.rs index 65a23477..c160fe4d 100644 --- a/packages/oneclient_core/src/http/response.rs +++ b/packages/oneclient_core/src/http/response.rs @@ -13,6 +13,8 @@ pub struct ResponseOptions { pub struct ResponseNotifyOptions { child: Option, standalone_label: Option, + standalone_id: Option, + done_label: Option, } impl ResponseNotifyOptions { @@ -20,6 +22,8 @@ impl ResponseNotifyOptions { Self { child: Some(child), standalone_label: None, + standalone_id: None, + done_label: None, } } @@ -27,8 +31,20 @@ impl ResponseNotifyOptions { Self { child: None, standalone_label: Some(label.into()), + standalone_id: None, + done_label: None, } } + + pub fn with_id(mut self, id: Uuid) -> Self { + self.standalone_id = Some(id); + self + } + + pub fn done_label(mut self, label: impl Into) -> Self { + self.done_label = Some(label.into()); + self + } } #[async_trait::async_trait] @@ -42,6 +58,7 @@ pub trait ResponseExt { #[async_trait::async_trait] impl ResponseExt for Response { + #[tracing::instrument(level = "debug", skip_all)] async fn stream( self, options: ResponseOptions, @@ -59,7 +76,14 @@ impl ResponseExt for Response { .notify .as_ref() .and_then(|n| n.standalone_label.clone()); - let standalone_id = standalone_label.as_ref().map(|_| Uuid::new_v4()); + let done_label = options + .notify + .as_ref() + .and_then(|n| n.done_label.clone()); + + let standalone_id = standalone_label + .as_ref() + .map(|_| options.notify.as_ref().and_then(|n| n.standalone_id).unwrap_or_else(Uuid::new_v4)); if let Some(ref child) = grouped_child { child.set_progress(0, Some(total)); @@ -67,6 +91,7 @@ impl ResponseExt for Response { notifier.send_progress(id, label, 0, total); } + let notifier = notifier.clone(); let stream = futures_lite::StreamExt::map(self.bytes_stream(), move |item| { match item { Ok(chunk) => { @@ -75,6 +100,12 @@ impl ResponseExt for Response { if let Some(ref child) = grouped_child { child.set_progress(current, Some(total)); } else if let (Some(id), Some(label)) = (&standalone_id, &standalone_label) { + let done = current >= total; + let label = if done { + done_label.as_deref().unwrap_or(label) + } else { + label + }; notifier.send_progress(id, label, current, total); } diff --git a/packages/oneclient_core/src/http/service.rs b/packages/oneclient_core/src/http/service.rs index 247e46ea..d108865e 100644 --- a/packages/oneclient_core/src/http/service.rs +++ b/packages/oneclient_core/src/http/service.rs @@ -87,6 +87,7 @@ impl RequestClient { } impl RequestClient { + #[tracing::instrument(level = "debug", skip_all)] pub async fn send(&self, request: impl Into) -> Result { let request: HttpRequest = request.into(); let mut retries = 0; @@ -103,6 +104,12 @@ impl RequestClient { let mut active_request = request.request; + tracing::debug!( + method = %active_request.method(), + url = %active_request.url(), + "dispatching http request" + ); + let res = loop { let permit = if request.options.use_semaphore { self.semaphore.acquire().await.ok() @@ -159,6 +166,7 @@ impl RequestClient { Ok(res) } + #[tracing::instrument(level = "debug", skip(self, request, options, services), fields(dest = %dest.as_ref().display()))] pub async fn download_file( &self, request: impl Into, @@ -169,12 +177,13 @@ impl RequestClient { let res = self.send(request).await?; let http_stream = res.stream(options, &services.notifier).await?; let http_stream = std::pin::pin!(http_stream); - + polyio::write_stream(dest, http_stream).await?; Ok(()) } + #[tracing::instrument(level = "debug", skip_all)] pub async fn send_as(&self, request: impl Into) -> Result { let res = self.send(request).await?; let status = res.status(); @@ -198,6 +207,7 @@ impl RequestClient { }) } + #[tracing::instrument(level = "debug", skip(self, body, extra_headers), fields(method = %method, %url))] pub async fn send_json( &self, method: reqwest::Method, diff --git a/packages/oneclient_core/src/images/mod.rs b/packages/oneclient_core/src/images/mod.rs index 0c405980..b2de265d 100644 --- a/packages/oneclient_core/src/images/mod.rs +++ b/packages/oneclient_core/src/images/mod.rs @@ -4,7 +4,6 @@ use std::path::PathBuf; use bytes::Bytes; use reqwest::Method; use tokio::sync::Mutex; -use tracing::instrument; use crate::crypto::sha1_bytes; use crate::http::RequestError; @@ -25,7 +24,7 @@ impl ImageCacheStore { Self::default() } - #[instrument(skip(self, services))] + #[tracing::instrument(level = "debug", skip(self, services))] pub async fn get( &self, services: &LauncherServices, @@ -109,6 +108,7 @@ fn downscale(bytes: &[u8], max_edge: u32) -> Option { Some(Bytes::from(out)) } +#[tracing::instrument(level = "debug", skip(services))] async fn download(services: &LauncherServices, url: &str) -> LauncherResult { let parsed = url.parse().map_err(RequestError::from)?; let request = reqwest::Request::new(Method::GET, parsed); diff --git a/packages/oneclient_core/src/java/checker.rs b/packages/oneclient_core/src/java/checker.rs index 22b19d91..c1cca32c 100644 --- a/packages/oneclient_core/src/java/checker.rs +++ b/packages/oneclient_core/src/java/checker.rs @@ -2,7 +2,6 @@ use std::{collections::HashMap, path::PathBuf}; use async_tempfile::TempDir; use tokio::sync::OnceCell; -use tracing::instrument; use crate::java::{JavaError, JavaResult}; @@ -15,7 +14,7 @@ pub struct JavaCheckInfo { pub os_arch: String, } -#[instrument(level = "info", skip(absolute_path))] +#[tracing::instrument(level = "debug", skip(absolute_path))] pub async fn check_java_runtime(absolute_path: String) -> JavaResult { let temp_dir = get_java_info_dir().await?; @@ -33,7 +32,7 @@ pub async fn check_java_runtime(absolute_path: String) -> JavaResult JavaResult = OnceCell::const_new(); +#[tracing::instrument(level = "debug")] async fn get_java_info_dir() -> Result<&'static PathBuf, polyio::IOError> { let dir: Result<&TempDir, polyio::IOError> = TEMP_JAVA_INFO .get_or_try_init(async || { diff --git a/packages/oneclient_core/src/java/install.rs b/packages/oneclient_core/src/java/install.rs index 65bf5475..1a8c2716 100644 --- a/packages/oneclient_core/src/java/install.rs +++ b/packages/oneclient_core/src/java/install.rs @@ -1,7 +1,6 @@ use std::path::{Path, PathBuf}; use reqwest::Method; -use tracing::instrument; use url::Url; use crate::http::{ResponseNotifyOptions, ResponseOptions}; @@ -12,7 +11,7 @@ use crate::paths; use crate::state::LauncherServices; use crate::LauncherResult; -#[instrument(skip(services))] +#[tracing::instrument(level = "debug", skip(services))] pub async fn install_package( package: &JavaPackage, services: &LauncherServices, @@ -22,16 +21,19 @@ pub async fn install_package( let archive_path = java_dir.join(polyio::sanitize_path(&package.name)); + let major = package.java_version.first().copied().unwrap_or(0); services.requester .download_file( reqwest::Request::new(Method::GET, Url::parse(&package.download_url)?), &archive_path, ResponseOptions { - notify: Some(ResponseNotifyOptions::standalone(format!( - "Installing {} {}", - package.vendor, - package.java_version.first().copied().unwrap_or(0) - ))), + notify: Some( + ResponseNotifyOptions::standalone(format!( + "Installing {} {}", + package.vendor, major + )) + .done_label(format!("Installed {} {}", package.vendor, major)), + ), }, services, ) @@ -57,10 +59,12 @@ pub async fn install_package( let _ = tokio::fs::remove_file(&archive_path).await; + tracing::info!(vendor = %package.vendor, major, "installed Java runtime"); + Ok(executable) } -#[instrument] +#[tracing::instrument(level = "debug")] fn resolve_installed_executable(extract_root: &Path, package: &JavaPackage) -> PathBuf { let mut base_path = extract_root.to_path_buf(); @@ -90,7 +94,7 @@ fn resolve_installed_executable(extract_root: &Path, package: &JavaPackage) -> P #[cfg(target_os = "macos")] { use crate::java::vendors::JavaVendor; - + if !base_path.join("Contents").join("Home").exists() { if let Ok(mut entries) = std::fs::read_dir(&base_path) { if let Some(dir) = entries.flatten().find(|entry| { diff --git a/packages/oneclient_core/src/java/locate.rs b/packages/oneclient_core/src/java/locate.rs index 1eca7744..535c1694 100644 --- a/packages/oneclient_core/src/java/locate.rs +++ b/packages/oneclient_core/src/java/locate.rs @@ -1,11 +1,13 @@ use std::collections::HashMap; use std::path::PathBuf; + use crate::constants::JAVA_BIN; use crate::java::checker::{check_java_runtime, JavaCheckInfo}; use crate::java::data::java_executable_relative_path; use crate::LauncherResult; +#[tracing::instrument(level = "debug")] pub async fn locate_java() -> LauncherResult> { let paths = internal_locate_java(); let mut valid = HashMap::new(); @@ -18,10 +20,13 @@ pub async fn locate_java() -> LauncherResult> { valid.insert(path, info); } + tracing::debug!(count = valid.len(), "located Java runtimes"); + Ok(valid) } #[cfg(target_os = "windows")] +#[tracing::instrument(level = "debug")] fn internal_locate_java() -> Vec { let mut java_homes = Vec::new(); @@ -78,6 +83,7 @@ fn internal_locate_java() -> Vec { } #[cfg(target_os = "macos")] +#[tracing::instrument(level = "debug")] fn internal_locate_java() -> Vec { let mut found = Vec::new(); @@ -115,6 +121,7 @@ fn internal_locate_java() -> Vec { } #[cfg(target_os = "linux")] +#[tracing::instrument(level = "debug")] fn internal_locate_java() -> Vec { let mut found = Vec::new(); diff --git a/packages/oneclient_core/src/java/manager.rs b/packages/oneclient_core/src/java/manager.rs index 7e6271cd..11b52697 100644 --- a/packages/oneclient_core/src/java/manager.rs +++ b/packages/oneclient_core/src/java/manager.rs @@ -1,3 +1,4 @@ +use std::collections::BTreeMap; use std::path::{Path, PathBuf}; use std::sync::Arc; @@ -23,6 +24,7 @@ pub struct AvailableJava { pub struct JavaManager; impl JavaManager { + #[tracing::instrument(skip(state))] pub async fn prepare_java( state: &Arc, major: u32, @@ -31,6 +33,7 @@ impl JavaManager { Self::prepare_java_with_services(&state.services, major, search_system, false).await } + #[tracing::instrument(skip(state))] pub async fn prepare_java_auto( state: &Arc, major: u32, @@ -39,6 +42,7 @@ impl JavaManager { Self::prepare_java_with_services(&state.services, major, search_system, true).await } + #[tracing::instrument(level = "debug", skip(services))] pub async fn prepare_java_with_services( services: &LauncherServices, major: u32, @@ -68,15 +72,18 @@ impl JavaManager { } } + #[tracing::instrument(level = "debug", skip(pool))] pub async fn list_runtimes(pool: &DbPool) -> LauncherResult> { let rows = dao::java::list_all(pool).await?; Ok(rows.into_iter().map(JavaRuntime::from_row).collect()) } + #[tracing::instrument(level = "debug", skip(pool))] pub async fn rescan(pool: &DbPool) -> LauncherResult<()> { register_located_runtimes(pool, java::locate::locate_java().await?).await } + #[tracing::instrument(skip(services))] pub async fn install_runtime( services: &LauncherServices, major: u32, @@ -84,6 +91,7 @@ impl JavaManager { download_and_register(major, services).await } + #[tracing::instrument(level = "debug", skip(services))] pub async fn available_versions( services: &LauncherServices, vendor: &JavaVendor, @@ -92,28 +100,29 @@ impl JavaManager { return Ok(Vec::new()); }; - let results = futures_util::future::join_all(INSTALLABLE_MAJORS.iter().map(|&major| { - let provider = &provider; - async move { - (major, provider.latest_package_by_major(major, services).await) - } - })) - .await; + let packages = provider.list_packages(None, services).await?; - let mut available = Vec::new(); - for (major, result) in results { - match result { - Ok(Some(package)) => available.push(AvailableJava { major, package }), - Ok(None) => {} - Err(err) => tracing::warn!(?vendor, major, "failed to query packages: {err}"), - } + let mut by_major = + BTreeMap::::new(); + + for package in packages { + let Some(&major) = package.java_version.first() else { + continue; + }; + by_major.entry(major).or_insert(package); } + let mut available: Vec = by_major + .into_iter() + .map(|(major, package)| AvailableJava { major, package }) + .collect(); + available.sort_by_key(|b| std::cmp::Reverse(b.major)); Ok(available) } + #[tracing::instrument(skip(services))] pub async fn install_runtime_from( services: &LauncherServices, vendor: &JavaVendor, @@ -129,6 +138,7 @@ impl JavaManager { register_checked_java(&services.db, &executable, Some(major)).await } + #[tracing::instrument(skip(pool))] pub async fn add_custom_runtime( pool: &DbPool, folder: PathBuf, @@ -137,11 +147,14 @@ impl JavaManager { register_checked_java(pool, &executable, None).await } + #[tracing::instrument(skip(pool))] pub async fn remove_runtime(pool: &DbPool, absolute_path: &str) -> LauncherResult<()> { dao::java::delete_by_path(pool, absolute_path).await?; + tracing::info!("removed Java runtime"); Ok(()) } + #[tracing::instrument(level = "debug", skip(pool))] pub async fn java_for_profile( pool: &DbPool, java_path: Option<&str>, @@ -164,6 +177,7 @@ fn provider_for_vendor(vendor: &JavaVendor) -> Option match packages.first() { + Ok(packages) => match packages.iter().find(|p| p.java_version.first() == Some(&major)).or_else(|| packages.first()) { Some(package) => package, None => { tracing::warn!(?vendor, major, "no packages found"); @@ -202,6 +216,7 @@ async fn download_and_register( Err(JavaError::PackageNotFound { major }.into()) } +#[tracing::instrument(level = "debug", skip(pool))] async fn register_custom_java( pool: &DbPool, selection: PathBuf, @@ -211,6 +226,7 @@ async fn register_custom_java( register_checked_java(pool, &executable, Some(expected_major)).await } +#[tracing::instrument(level = "debug", skip(pool))] async fn register_checked_java( pool: &DbPool, executable: &Path, @@ -232,11 +248,13 @@ async fn register_checked_java( persist_runtime(pool, executable, &info).await } +#[tracing::instrument(level = "debug", skip(pool))] async fn get_latest_runtime(pool: &DbPool, major: u32) -> LauncherResult> { let row = dao::java::get_latest_by_major(pool, major).await?; Ok(row.map(JavaRuntime::from_row)) } +#[tracing::instrument(level = "debug", skip_all)] async fn register_located_runtimes( pool: &DbPool, located: impl IntoIterator, @@ -247,6 +265,7 @@ async fn register_located_runtimes( Ok(()) } +#[tracing::instrument(level = "debug", skip(pool))] async fn persist_runtime( pool: &DbPool, executable: &Path, diff --git a/packages/oneclient_core/src/java/resolve.rs b/packages/oneclient_core/src/java/resolve.rs index f5e11239..b2784f4e 100644 --- a/packages/oneclient_core/src/java/resolve.rs +++ b/packages/oneclient_core/src/java/resolve.rs @@ -1,9 +1,11 @@ use std::path::{Path, PathBuf}; + use crate::constants::JAVA_BIN; use crate::java::data::java_executable_relative_path; use crate::java::{JavaError, JavaResult}; +#[tracing::instrument(level = "debug", skip_all)] pub fn resolve_java_executable(selection: impl AsRef) -> JavaResult { let selection = selection.as_ref(); @@ -42,6 +44,8 @@ pub fn resolve_java_executable(selection: impl AsRef) -> JavaResult, +} + #[derive(Debug, Deserialize)] struct AdoptiumRelease { version_data: AdoptiumVersionData, @@ -19,6 +25,7 @@ struct AdoptiumRelease { #[derive(Debug, Deserialize)] struct AdoptiumVersionData { semver: String, + major: u32, } #[derive(Debug, Deserialize)] @@ -38,30 +45,51 @@ impl JavaRuntimeProvider for AdoptiumRuntimeProvider { JavaVendor::Adoptium } - async fn list_packages_by_major( + #[tracing::instrument(level = "debug", skip(self, services))] + async fn list_packages( &self, - major: u32, + major: Option, services: &LauncherServices, ) -> LauncherResult> { - let url = adoptium_url(major)?; + // fetched from `/v3/info/available_releases`. + let (low, high) = match major { + Some(major) => (major, major), + None => { + let info = services + .requester + .send_as::(Request::new( + Method::GET, + Url::parse("https://api.adoptium.net/v3/info/available_releases")?, + )) + .await?; + let low = info.available_releases.iter().copied().min().unwrap_or(8); + let high = info.available_releases.iter().copied().max().unwrap_or(low); + (low, high) + } + }; + + let url = adoptium_url(low, high)?; let releases = services .requester .send_as::>(Request::new(Method::GET, url)) .await?; let mut packages = Vec::new(); - + for release in releases { - let java_version: Vec = release + let mut java_version: Vec = release .version_data .semver .split(|c: char| !c.is_numeric()) .filter_map(|s| s.parse::().ok()) .collect(); + if java_version.first() != Some(&release.version_data.major) { + java_version.insert(0, release.version_data.major); + } for binary in release.binaries { packages.push(JavaPackage { - archive: PackageArchive::Zip, + archive: PackageArchive::from_filename(&binary.package.name), download_url: binary.package.link, java_version: java_version.clone(), name: binary.package.name, @@ -70,14 +98,35 @@ impl JavaRuntimeProvider for AdoptiumRuntimeProvider { } } + tracing::debug!(count = packages.len(), "listed Adoptium packages"); + Ok(packages) } } -fn adoptium_url(major: u32) -> LauncherResult { - Ok(Url::parse(&format!( - "https://api.adoptium.net/v3/assets/feature_releases/{major}/ga?os={ADOPTIUM_OS}&architecture={ADOPTIUM_ARCH}&image_type=jre&jvm_impl=hotspot&project=jdk&heap_size=normal&vendor=eclipse&page=0&page_size=5" - ))?) +// One request spanning every GA major via a version range (`[low,high]`), +// newest build per major, instead of one request per major. +fn adoptium_url(low: u32, high: u32) -> LauncherResult { + let range = format!("%5B{low}%2C{}%5D", high + 1); + + let mut url = Url::parse(&format!( + "https://api.adoptium.net/v3/assets/version/{range}" + ))?; + + url.query_pairs_mut() + .append_pair("os", ADOPTIUM_OS) + .append_pair("architecture", ADOPTIUM_ARCH) + .append_pair("image_type", "jre") + .append_pair("jvm_impl", "hotspot") + .append_pair("project", "jdk") + .append_pair("heap_size", "normal") + .append_pair("vendor", "eclipse") + .append_pair("release_type", "ga") + .append_pair("sort_method", "DATE") + .append_pair("sort_order", "DESC") + .append_pair("page", "0") + .append_pair("page_size", "50"); + Ok(url) } const ADOPTIUM_ARCH: &str = cfg_select! { @@ -89,7 +138,7 @@ const ADOPTIUM_ARCH: &str = cfg_select! { const ADOPTIUM_OS: &str = cfg_select! { target_os = "windows" => "windows", - target_os = "macos" => "mac", + target_os = "macos" => "mac", target_os = "linux" => cfg_select! { target_env = "musl" => "alpine-linux", _ => "linux", diff --git a/packages/oneclient_core/src/java/vendors/corretto.rs b/packages/oneclient_core/src/java/vendors/corretto.rs new file mode 100644 index 00000000..e44f1d3d --- /dev/null +++ b/packages/oneclient_core/src/java/vendors/corretto.rs @@ -0,0 +1,105 @@ +use reqwest::{Method, Request}; +use serde_json::Value; +use url::Url; + +use crate::LauncherResult; +use crate::java::PackageArchive; +use crate::java::data::JavaPackage; +use crate::java::vendors::{JavaRuntimeProvider, JavaVendor}; +use crate::state::LauncherServices; + +pub struct CorrettoRuntimeProvider; + +// { os: { arch: { "jdk": { major: { ext: ... } } } } } +const INDEX_URL: &str = + "https://corretto.github.io/corretto-downloads/latest_links/indexmap_with_checksum.json"; + +#[async_trait::async_trait] +impl JavaRuntimeProvider for CorrettoRuntimeProvider { + fn vendor(&self) -> JavaVendor { + JavaVendor::Corretto + } + + #[tracing::instrument(level = "debug", skip(self, services))] + async fn list_packages( + &self, + major: Option, + services: &LauncherServices, + ) -> LauncherResult> { + let index = services + .requester + .send_as::(Request::new(Method::GET, Url::parse(INDEX_URL)?)) + .await?; + + // Corretto ships JDK only + let Some(by_major) = index + .get(CORRETTO_OS) + .and_then(|by_arch| by_arch.get(CORRETTO_ARCH)) + .and_then(|by_type| by_type.get("jdk")) + .and_then(Value::as_object) + else { + return Ok(Vec::new()); + }; + + let mut packages = Vec::new(); + for (major_str, by_ext) in by_major { + let Ok(this_major) = major_str.parse::() else { + continue; + }; + if let Some(filter) = major + && filter != this_major + { + continue; + } + + let has_ext = by_ext + .as_object() + .is_some_and(|exts| exts.contains_key(CORRETTO_EXT.0)); + if !has_ext { + continue; + } + + let download_url = latest_url(this_major, CORRETTO_EXT.0); + let name = format!( + "amazon-corretto-{this_major}-{CORRETTO_ARCH}-{CORRETTO_OS}-jdk.{}", + CORRETTO_EXT.0 + ); + + packages.push(JavaPackage { + archive: CORRETTO_EXT.1, + download_url, + java_version: vec![this_major], + name, + vendor: JavaVendor::Corretto, + }); + } + + packages.sort_by_key(|p| std::cmp::Reverse(p.java_version.first().copied().unwrap_or(0))); + tracing::debug!(count = packages.len(), "listed Corretto packages"); + Ok(packages) + } +} + +fn latest_url(major: u32, ext: &str) -> String { + format!( + "https://corretto.aws/downloads/latest/amazon-corretto-{major}-{CORRETTO_ARCH}-{CORRETTO_OS}-jdk.{ext}" + ) +} + +const CORRETTO_ARCH: &str = cfg_select! { + target_arch = "x86" => "x86", + target_arch = "x86_64" => "x64", + target_arch = "arm" => "arm", + target_arch = "aarch64" => "aarch64", +}; + +const CORRETTO_OS: &str = cfg_select! { + target_os = "windows" => "windows", + target_os = "macos" => "macos", + target_os = "linux" => "linux", +}; + +const CORRETTO_EXT: (&str, PackageArchive) = cfg_select! { + target_os = "windows" => ("zip", PackageArchive::Zip), + _ => ("tar.gz", PackageArchive::TarGz), +}; diff --git a/packages/oneclient_core/src/java/vendors/liberica.rs b/packages/oneclient_core/src/java/vendors/liberica.rs new file mode 100644 index 00000000..50424d59 --- /dev/null +++ b/packages/oneclient_core/src/java/vendors/liberica.rs @@ -0,0 +1,126 @@ +use reqwest::{Method, Request}; +use serde::Deserialize; +use url::Url; + +use crate::LauncherResult; +use crate::java::PackageArchive; +use crate::java::data::JavaPackage; +use crate::java::vendors::{JavaRuntimeProvider, JavaVendor}; +use crate::state::LauncherServices; + +pub struct LibericaRuntimeProvider; + +#[derive(Debug, Deserialize)] +struct LibericaRelease { + #[serde(rename = "downloadUrl")] + download_url: String, + filename: String, + #[serde(rename = "featureVersion")] + feature_version: u32, + #[serde(rename = "updateVersion", default)] + update_version: u32, + #[serde(rename = "buildVersion", default)] + build_version: u32, + version: String, +} + +#[async_trait::async_trait] +impl JavaRuntimeProvider for LibericaRuntimeProvider { + fn vendor(&self) -> JavaVendor { + JavaVendor::Liberica + } + + #[tracing::instrument(level = "debug", skip(self, services))] + async fn list_packages( + &self, + major: Option, + services: &LauncherServices, + ) -> LauncherResult> { + let url = liberica_url(major)?; + let releases = services + .requester + .send_as::>(Request::new(Method::GET, url)) + .await?; + + let mut releases = releases; + releases.sort_by_key(|r| { + std::cmp::Reverse((r.feature_version, r.update_version, r.build_version)) + }); + + let packages: Vec = releases + .into_iter() + .map(|r| { + let mut java_version: Vec = r + .version + .split(|c: char| !c.is_numeric()) + .filter_map(|s| s.parse::().ok()) + .collect(); + if java_version.first() != Some(&r.feature_version) { + java_version.insert(0, r.feature_version); + } + + JavaPackage { + archive: PackageArchive::from_filename(&r.filename), + download_url: r.download_url, + java_version, + name: r.filename, + vendor: JavaVendor::Liberica, + } + }) + .collect(); + + tracing::debug!(count = packages.len(), "listed Liberica packages"); + + Ok(packages) + } +} + +fn liberica_url(major: Option) -> LauncherResult { + let mut url = Url::parse("https://api.bell-sw.com/v1/liberica/releases")?; + { + let mut q = url.query_pairs_mut(); + q.append_pair("os", LIBERICA_OS) + .append_pair("arch", LIBERICA_ARCH) + .append_pair("bundle-type", "jre") + .append_pair("bitness", LIBERICA_BITNESS) + .append_pair("package-type", LIBERICA_PACKAGE) + .append_pair("installation-type", "archive") + .append_pair( + "fields", + "downloadUrl,filename,featureVersion,updateVersion,buildVersion,version", + ) + .append_pair("output", "json"); + if let Some(major) = major { + q.append_pair("version-feature", &major.to_string()); + } + } + Ok(url) +} + +const LIBERICA_ARCH: &str = cfg_select! { + target_arch = "x86" => "x86", + target_arch = "x86_64" => "x86", + target_arch = "arm" => "arm", + target_arch = "aarch64" => "arm", +}; + +const LIBERICA_BITNESS: &str = cfg_select! { + target_arch = "x86" => "32", + target_arch = "x86_64" => "64", + target_arch = "arm" => "32", + target_arch = "aarch64" => "64", +}; + +const LIBERICA_OS: &str = cfg_select! { + target_os = "windows" => "windows", + target_os = "macos" => "macos", + target_os = "linux" => cfg_select! { + target_env = "musl" => "linux-musl", + _ => "linux" + }, +}; + +const LIBERICA_PACKAGE: &str = cfg_select! { + target_os = "windows" => "zip", + _ => "tar.gz", +}; diff --git a/packages/oneclient_core/src/java/vendors/mod.rs b/packages/oneclient_core/src/java/vendors/mod.rs index 49d8d171..b84891f7 100644 --- a/packages/oneclient_core/src/java/vendors/mod.rs +++ b/packages/oneclient_core/src/java/vendors/mod.rs @@ -1,13 +1,18 @@ use std::{path::PathBuf, str::FromStr}; + use crate::java::JavaPackage; use crate::state::LauncherServices; use crate::LauncherResult; mod adoptium; +mod corretto; +mod liberica; mod zulu; pub use adoptium::AdoptiumRuntimeProvider; +pub use corretto::CorrettoRuntimeProvider; +pub use liberica::LibericaRuntimeProvider; use serde::{Deserialize, Deserializer, Serialize}; pub use zulu::ZuluRuntimeProvider; @@ -15,6 +20,8 @@ pub fn runtime_providers() -> Vec> { vec![ Box::new(ZuluRuntimeProvider), Box::new(AdoptiumRuntimeProvider), + Box::new(CorrettoRuntimeProvider), + Box::new(LibericaRuntimeProvider), ] } @@ -22,21 +29,23 @@ pub fn runtime_providers() -> Vec> { pub trait JavaRuntimeProvider: Send + Sync { fn vendor(&self) -> JavaVendor; - async fn list_packages_by_major( + async fn list_packages( &self, - major: u32, + major: Option, services: &LauncherServices, ) -> LauncherResult>; + #[tracing::instrument(level = "debug", skip(self, services))] async fn latest_package_by_major( &self, major: u32, services: &LauncherServices ) -> LauncherResult> { - let packages = self.list_packages_by_major(major, services).await?; - Ok(packages.first().cloned()) + let packages = self.list_packages(Some(major), services).await?; + Ok(packages.into_iter().find(|p| p.java_version.first() == Some(&major))) } + #[tracing::instrument(level = "debug", skip_all)] async fn install_package( &self, package: &JavaPackage, @@ -53,6 +62,7 @@ pub enum JavaVendor { Oracle, Microsoft, Adoptium, + Liberica, OpenJDK, Other(String), } @@ -62,6 +72,7 @@ impl std::fmt::Display for JavaVendor { match self { JavaVendor::Zulu => f.write_str("Zulu"), JavaVendor::Adoptium => f.write_str("Temurin"), + JavaVendor::Liberica => f.write_str("Liberica"), JavaVendor::Corretto => f.write_str("Corretto"), JavaVendor::Microsoft => f.write_str("Microsoft"), JavaVendor::Oracle => f.write_str("Oracle"), @@ -77,8 +88,10 @@ impl FromStr for JavaVendor { fn from_str(s: &str) -> Result { let v = s.to_lowercase(); - Ok(if v.contains("adoptium") || v.contains("adoptopenjdk") { + Ok(if v.contains("adoptium") || v.contains("adoptopenjdk") || v.contains("temurin") { Self::Adoptium + } else if v.contains("liberica") || v.contains("bellsoft") { + Self::Liberica } else if v.contains("microsoft") { Self::Microsoft } else if v.contains("openjdk") { diff --git a/packages/oneclient_core/src/java/vendors/zulu.rs b/packages/oneclient_core/src/java/vendors/zulu.rs index e0699fe9..ad12b8c5 100644 --- a/packages/oneclient_core/src/java/vendors/zulu.rs +++ b/packages/oneclient_core/src/java/vendors/zulu.rs @@ -23,10 +23,13 @@ impl JavaRuntimeProvider for ZuluRuntimeProvider { JavaVendor::Zulu } - async fn list_packages_by_major(&self, major: u32, services: &LauncherServices) -> LauncherResult> { + #[tracing::instrument(level = "debug", skip(self, services))] + async fn list_packages(&self, major: Option, services: &LauncherServices) -> LauncherResult> { let url = zulu_url(major)?; let packages = services.requester.send_as::>(Request::new(Method::GET, url)).await?; - let packages = packages.into_iter().map(map_zulu_package).collect(); + let packages: Vec = packages.into_iter().map(map_zulu_package).collect(); + + tracing::debug!(count = packages.len(), "listed Zulu packages"); Ok(packages) } @@ -42,8 +45,26 @@ fn map_zulu_package(pkg: ZuluPackage) -> JavaPackage { } } -fn zulu_url(major: u32) -> LauncherResult { - Ok(Url::parse(&format!("https://api.azul.com/metadata/v1/zulu/packages/?java_version={major}&os={ZULU_OS}&arch={ZULU_ARCH}&archive_type=zip&java_package_type=jre&javafx_bundled=false&release_status=ga&availability_types=CA&certifications=tck&page=1&page_size=5"))?) +fn zulu_url(major: Option) -> LauncherResult { + let mut url = Url::parse("https://api.azul.com/metadata/v1/zulu/packages/")?; + { + let mut q = url.query_pairs_mut(); + q.append_pair("os", ZULU_OS) + .append_pair("arch", ZULU_ARCH) + .append_pair("archive_type", "zip") + .append_pair("java_package_type", "jre") + .append_pair("javafx_bundled", "false") + .append_pair("release_status", "ga") + .append_pair("availability_types", "CA") + .append_pair("certifications", "tck") + .append_pair("latest", "true") + .append_pair("page", "1") + .append_pair("page_size", "100"); + if let Some(major) = major { + q.append_pair("java_version", &major.to_string()); + } + } + Ok(url) } const ZULU_ARCH: &str = cfg_select! { diff --git a/packages/oneclient_core/src/lib.rs b/packages/oneclient_core/src/lib.rs index 90acc440..f01bb607 100644 --- a/packages/oneclient_core/src/lib.rs +++ b/packages/oneclient_core/src/lib.rs @@ -1,3 +1,5 @@ +#![recursion_limit = "256"] + #[cfg(debug_assertions)] pub mod dev; diff --git a/packages/oneclient_core/src/logger.rs b/packages/oneclient_core/src/logger.rs index d10c7d73..94adf4d8 100644 --- a/packages/oneclient_core/src/logger.rs +++ b/packages/oneclient_core/src/logger.rs @@ -19,6 +19,7 @@ pub fn init() -> LauncherResult<()> { pub fn init_filtered(filter: impl FnOnce() -> EnvFilter) -> LauncherResult<()> { let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| filter()); + let stdout_layer = tracing_subscriber::fmt::layer().with_writer(io::stdout); #[cfg(debug_assertions)] diff --git a/packages/oneclient_core/src/logs/manage.rs b/packages/oneclient_core/src/logs/manage.rs index 3c4f47d8..1e3ae036 100644 --- a/packages/oneclient_core/src/logs/manage.rs +++ b/packages/oneclient_core/src/logs/manage.rs @@ -65,6 +65,7 @@ fn collect_dir( } } +#[tracing::instrument(level = "debug", skip(cluster), fields(cluster_id = cluster.id))] pub fn list_cluster_logs(cluster: &Cluster) -> LauncherResult> { let mut out = Vec::new(); @@ -148,12 +149,14 @@ pub(super) async fn read_file_string(path: &Path) -> LauncherResult { } } +#[tracing::instrument(level = "debug", skip(opts))] pub async fn read_log_at(path: &Path, opts: &ReadOptions) -> LauncherResult> { let path = ensure_allowed(path)?; let content = read_file_string(&path).await?; Ok(lines_from(&content, opts)) } +#[tracing::instrument(level = "debug")] pub async fn delete_log_at(path: &Path) -> LauncherResult<()> { let path = ensure_allowed(path)?; diff --git a/packages/oneclient_core/src/logs/mclogs.rs b/packages/oneclient_core/src/logs/mclogs.rs index a977e422..6f3e0b4e 100644 --- a/packages/oneclient_core/src/logs/mclogs.rs +++ b/packages/oneclient_core/src/logs/mclogs.rs @@ -26,6 +26,7 @@ struct MclogsResponse { error: Option, } +#[tracing::instrument(skip(services))] pub async fn upload_log_at( services: &LauncherServices, path: &Path, @@ -62,9 +63,13 @@ pub async fn upload_log_at( let parsed: MclogsResponse = serde_json::from_slice(&bytes)?; if !parsed.success { - return Err(LogsError::Upload(parsed.error.unwrap_or_else(|| "unknown error".into())).into()); + let reason = parsed.error.unwrap_or_else(|| "unknown error".into()); + tracing::warn!(reason = %reason, "mclogs upload failed"); + return Err(LogsError::Upload(reason).into()); } + tracing::info!(url = parsed.url.as_deref().unwrap_or(""), "uploaded log to mclo.gs"); + Ok(MclogsUploadResponse { id: parsed.id.unwrap_or_default(), url: parsed.url.unwrap_or_default(), diff --git a/packages/oneclient_core/src/metadata/store.rs b/packages/oneclient_core/src/metadata/store.rs index ba1476c9..216050c0 100644 --- a/packages/oneclient_core/src/metadata/store.rs +++ b/packages/oneclient_core/src/metadata/store.rs @@ -6,7 +6,6 @@ use interfrost::api::modded::Manifest as ModdedManifest; use reqwest::Method; use serde::de::DeserializeOwned; use serde::{Deserialize, Serialize}; -use tracing::instrument; use crate::api_config::metadata_api_url; use crate::metadata::MetadataError; @@ -43,7 +42,7 @@ impl MetadataStore { Self::default() } - #[instrument(skip(self, services))] + #[tracing::instrument(level = "debug", skip(self, services))] pub async fn get_vanilla_or_fetch( &mut self, services: &LauncherServices, @@ -55,7 +54,7 @@ impl MetadataStore { self.get_vanilla() } - #[instrument(skip(self, services))] + #[tracing::instrument(level = "debug", skip(self, services))] pub async fn get_modded_or_fetch( &mut self, services: &LauncherServices, @@ -95,7 +94,7 @@ impl MetadataStore { .ok_or_else(|| MetadataError::FetchError.into()) } - #[instrument(skip_all)] + #[tracing::instrument(skip_all)] pub async fn initialize(&mut self, services: &LauncherServices) -> LauncherResult<()> { let path = paths::caches_dir()?.join("metadata.json"); let mut save_file = false; @@ -130,7 +129,7 @@ impl MetadataStore { Ok(()) } - #[instrument(skip_all)] + #[tracing::instrument(level = "debug", skip_all)] pub async fn refetch_errored(&mut self, services: &LauncherServices) -> u8 { let mut changed: u8 = 0; @@ -171,7 +170,7 @@ impl MetadataStore { changed } - #[instrument(skip_all)] + #[tracing::instrument(level = "debug", skip_all)] pub async fn fetch_all(&mut self, services: &LauncherServices) { let (minecraft, forge, neo, fabric, quilt, legacyfabric) = tokio::join!( fetch_vanilla_manifest(services), @@ -190,6 +189,7 @@ impl MetadataStore { self.inner.legacyfabric = legacyfabric.ok(); } + #[tracing::instrument(level = "debug", skip(self, services))] pub async fn get_loaders_for_version( &mut self, services: &LauncherServices, @@ -233,6 +233,7 @@ impl MetadataStore { } } +#[tracing::instrument(level = "debug", skip(services))] async fn fetch_vanilla_manifest(services: &LauncherServices) -> LauncherResult { match fetch_manifest::(services, GameLoader::Vanilla).await { Ok(manifest) => Ok(manifest), @@ -255,6 +256,7 @@ async fn fetch_vanilla_manifest(services: &LauncherServices) -> LauncherResult( services: &LauncherServices, loader: GameLoader, diff --git a/packages/oneclient_core/src/migration/fs.rs b/packages/oneclient_core/src/migration/fs.rs index 1b9cbb82..c9499222 100644 --- a/packages/oneclient_core/src/migration/fs.rs +++ b/packages/oneclient_core/src/migration/fs.rs @@ -1,7 +1,9 @@ use std::path::{Path, PathBuf}; + use crate::LauncherResult; +#[tracing::instrument(level = "debug", skip(exclude_top))] pub async fn copy_tree(src: &Path, dst: &Path, exclude_top: &[&str]) -> LauncherResult<()> { let mut stack: Vec<(PathBuf, PathBuf, bool)> = vec![(src.to_path_buf(), dst.to_path_buf(), true)]; diff --git a/packages/oneclient_core/src/migration/mod.rs b/packages/oneclient_core/src/migration/mod.rs index ca20034a..93970eca 100644 --- a/packages/oneclient_core/src/migration/mod.rs +++ b/packages/oneclient_core/src/migration/mod.rs @@ -65,6 +65,7 @@ pub enum ImportTarget { Dedicated { new_cluster_id: i64 }, } +#[tracing::instrument] pub async fn detect() -> LauncherResult> { for source in MigrationSource::ALL.iter().copied() { let detection = match source { @@ -80,6 +81,7 @@ pub async fn detect() -> LauncherResult> { Ok(None) } +#[tracing::instrument] pub async fn import_game_dir( source: MigrationSource, folder_name: &str, diff --git a/packages/oneclient_core/src/migration/oneclient_v1.rs b/packages/oneclient_core/src/migration/oneclient_v1.rs index e554995f..2fd56470 100644 --- a/packages/oneclient_core/src/migration/oneclient_v1.rs +++ b/packages/oneclient_core/src/migration/oneclient_v1.rs @@ -2,7 +2,6 @@ use std::path::{Path, PathBuf}; use sqlx::Row; use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions}; -use tracing::instrument; use crate::LauncherResult; use crate::packages::domain::GameLoader; @@ -33,7 +32,7 @@ pub fn category_from_bundle_name(name: &str) -> Option { (!inner.is_empty()).then(|| inner.to_string()) } -#[instrument] +#[tracing::instrument] pub async fn detect() -> LauncherResult> { let Some(root) = old_root() else { return Ok(None); @@ -60,6 +59,7 @@ pub async fn detect() -> LauncherResult> { } } +#[tracing::instrument(level = "debug")] async fn detect_inner(root: &Path, db_path: &Path) -> LauncherResult { let opts = SqliteConnectOptions::new() .filename(db_path) @@ -113,6 +113,7 @@ async fn detect_inner(root: &Path, db_path: &Path) -> LauncherResult LauncherResult> { let rows = sqlx::query( "SELECT DISTINCT bundle_name FROM cluster_packages \ @@ -137,7 +138,7 @@ async fn fetch_categories(pool: &sqlx::SqlitePool, cluster_id: i64) -> LauncherR Ok(categories) } -#[instrument(skip(target))] +#[tracing::instrument(skip(target))] pub async fn import_game_dir(folder_name: &str, target: ImportTarget) -> LauncherResult<()> { let Some(root) = old_root() else { return Ok(()); diff --git a/packages/oneclient_core/src/minecraft/profile.rs b/packages/oneclient_core/src/minecraft/profile.rs index e4763d2c..777567bf 100644 --- a/packages/oneclient_core/src/minecraft/profile.rs +++ b/packages/oneclient_core/src/minecraft/profile.rs @@ -1,4 +1,5 @@ use base64::Engine; +use chrono::{DateTime, TimeDelta, Utc}; use reqwest::Method; use serde::{Deserialize, Serialize}; @@ -126,6 +127,7 @@ fn auth_headers(access_token: &str) -> [(&'static str, String); 1] { [("Authorization", format!("Bearer {access_token}"))] } +#[tracing::instrument(level = "debug", skip(client))] pub async fn fetch_player_profile( client: &RequestClient, uuid: &str, @@ -213,6 +215,7 @@ pub async fn fetch_player_profile( }) } +#[tracing::instrument(level = "debug", skip(client, access_token))] pub async fn fetch_logged_in_profile( client: &RequestClient, access_token: &str, @@ -241,30 +244,75 @@ pub async fn fetch_logged_in_profile( Ok(profile) } +/// How long a cached profile is served without revalidating. +/// +/// The alternative is a Mojang round trip before the first avatar can paint on +/// every cold start. Skins change rarely, so this trades a bounded amount of +/// staleness for startup that doesn't wait on the network. +const PROFILE_CACHE_TTL_HOURS: i64 = 6; + +#[derive(Debug, Serialize, Deserialize)] +struct CachedProfile { + fetched_at: DateTime, + view: PlayerProfileView, +} + +impl CachedProfile { + fn is_fresh(&self) -> bool { + Utc::now() - self.fetched_at < TimeDelta::hours(PROFILE_CACHE_TTL_HOURS) + } +} + +async fn fetch_profile_view( + client: &RequestClient, + uuid: &str, + access_token: Option<&str>, +) -> LauncherResult { + if let Some(token) = access_token.filter(|t| !t.is_empty()) { + let full = fetch_logged_in_profile(client, token).await?; + return Ok(PlayerProfileView::from(full)); + } + let summary = fetch_player_profile(client, uuid).await?; + Ok(PlayerProfileView::from(summary)) +} + +/// Resolves a player's public profile preferring on-disk cache +#[tracing::instrument(level = "debug", skip(client, access_token))] pub async fn fetch_player_profile_view( client: &RequestClient, uuid: &str, access_token: Option<&str>, ) -> LauncherResult { - let fetched = async { - if let Some(token) = access_token.filter(|t| !t.is_empty()) { - let full = fetch_logged_in_profile(client, token).await?; - return Ok(PlayerProfileView::from(full)); - } - let summary = fetch_player_profile(client, uuid).await?; - Ok(PlayerProfileView::from(summary)) - } - .await; + let authenticated = access_token.is_some_and(|t| !t.is_empty()); - match fetched { + if !authenticated && let Some(cached) = read_cached_profile(uuid).await { + if cached.is_fresh() { + return Ok(cached.view); + } + + return match fetch_profile_view(client, uuid, access_token).await { + Ok(view) => { + write_cached_profile(uuid, &view).await; + Ok(view) + } + Err(err) => { + tracing::warn!("serving stale cached profile for {uuid}: {err}"); + Ok(cached.view) + } + }; + } + + match fetch_profile_view(client, uuid, access_token).await { Ok(view) => { - write_cached_profile(uuid, &view).await; + if !authenticated { + write_cached_profile(uuid, &view).await; + } Ok(view) } Err(err) => match read_cached_profile(uuid).await { Some(cached) => { tracing::warn!("using cached player profile for {uuid}: {err}"); - Ok(cached) + Ok(cached.view) } None => Err(err), }, @@ -280,7 +328,11 @@ async fn write_cached_profile(uuid: &str, view: &PlayerProfileView) { let Some(path) = profile_cache_path(uuid) else { return; }; - let Ok(bytes) = serde_json::to_vec(view) else { + let entry = CachedProfile { + fetched_at: Utc::now(), + view: view.clone(), + }; + let Ok(bytes) = serde_json::to_vec(&entry) else { return; }; if let Some(parent) = path.parent() { @@ -291,8 +343,89 @@ async fn write_cached_profile(uuid: &str, view: &PlayerProfileView) { } } -async fn read_cached_profile(uuid: &str) -> Option { +async fn read_cached_profile(uuid: &str) -> Option { let path = profile_cache_path(uuid)?; let bytes = polyio::read(&path).await.ok()?; - serde_json::from_slice(&bytes).ok() + + if let Ok(entry) = serde_json::from_slice::(&bytes) { + return Some(entry); + } + + let view = serde_json::from_slice::(&bytes).ok()?; + Some(CachedProfile { + fetched_at: DateTime::UNIX_EPOCH, + view, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn temp_launcher_dir() { + let dir = std::env::temp_dir().join(format!("oneclient-profile-test-{}", uuid::Uuid::new_v4())); + crate::paths::set_launcher_dir(dir); + } + + fn sample_view(username: &str) -> PlayerProfileView { + PlayerProfileView { + uuid: "069a79f4-44e9-4726-a5be-fca90e38aaf5".into(), + username: username.into(), + is_slim: true, + skin_url: Some("https://textures.minecraft.net/texture/abc".into()), + cape_url: None, + skins: Vec::new(), + capes: Vec::new(), + } + } + + #[tokio::test] + async fn fresh_cache_entry_is_served_without_network() { + temp_launcher_dir(); + let uuid = uuid::Uuid::new_v4().to_string(); + let view = sample_view("Cached"); + write_cached_profile(&uuid, &view).await; + + let client = RequestClient::new().unwrap(); + let resolved = fetch_player_profile_view(&client, &uuid, None) + .await + .expect("fresh cache entry should resolve offline"); + + assert_eq!(resolved, view); + } + + #[tokio::test] + async fn entry_past_ttl_is_not_fresh() { + let stale = CachedProfile { + fetched_at: Utc::now() - TimeDelta::hours(PROFILE_CACHE_TTL_HOURS + 1), + view: sample_view("Stale"), + }; + assert!(!stale.is_fresh()); + + let recent = CachedProfile { + fetched_at: Utc::now() - TimeDelta::hours(PROFILE_CACHE_TTL_HOURS - 1), + view: sample_view("Recent"), + }; + assert!(recent.is_fresh()); + } + + #[tokio::test] + async fn legacy_cache_entry_loads_and_revalidates() { + temp_launcher_dir(); + let uuid = uuid::Uuid::new_v4().to_string(); + let view = sample_view("Legacy"); + + let path = profile_cache_path(&uuid).unwrap(); + polyio::create_dir_all(path.parent().unwrap()).await.unwrap(); + polyio::write(&path, &serde_json::to_vec(&view).unwrap()) + .await + .unwrap(); + + let cached = read_cached_profile(&uuid) + .await + .expect("legacy entry should still parse"); + + assert_eq!(cached.view, view); + assert!(!cached.is_fresh(), "legacy entry should be due for revalidation"); + } } diff --git a/packages/oneclient_core/src/notification/data.rs b/packages/oneclient_core/src/notification/data.rs index 46fab4a4..e8741584 100644 --- a/packages/oneclient_core/src/notification/data.rs +++ b/packages/oneclient_core/src/notification/data.rs @@ -99,6 +99,17 @@ pub enum Notification { InvalidateJava, SyncComplete, + + /// Live status of Microsoft Auth + /// `None` clears the status + MicrosoftLoginStatus(Option), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MicrosoftLoginStatus { + pub label: String, + pub current: u64, + pub total: u64, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] diff --git a/packages/oneclient_core/src/notification/mod.rs b/packages/oneclient_core/src/notification/mod.rs index 8a86e66e..80e44880 100644 --- a/packages/oneclient_core/src/notification/mod.rs +++ b/packages/oneclient_core/src/notification/mod.rs @@ -1,6 +1,6 @@ pub use data::{ - GroupedProgressEvent, LaunchStage, Notification, NotificationLevel, PromptKind, TaskPhase, - UserChoice, + GroupedProgressEvent, LaunchStage, MicrosoftLoginStatus, Notification, NotificationLevel, + PromptKind, TaskPhase, UserChoice, }; pub use grouped::{GroupedProgressChild, GroupedProgressSession}; pub use service::NotificationService; @@ -14,5 +14,5 @@ pub enum NotificationError { #[error("Notification service is down")] ServiceDown, #[error("Notification prompt timed out")] - PromptTimedOut -} \ No newline at end of file + PromptTimedOut, +} diff --git a/packages/oneclient_core/src/notification/service.rs b/packages/oneclient_core/src/notification/service.rs index a605cec9..503daf7d 100644 --- a/packages/oneclient_core/src/notification/service.rs +++ b/packages/oneclient_core/src/notification/service.rs @@ -2,7 +2,8 @@ use tokio::sync::{mpsc, oneshot}; use uuid::Uuid; use crate::notification::{ - LaunchStage, Notification, NotificationError, NotificationLevel, PromptKind, UserChoice, + LaunchStage, MicrosoftLoginStatus, Notification, NotificationError, NotificationLevel, + PromptKind, UserChoice, }; #[derive(Clone)] @@ -40,6 +41,7 @@ impl NotificationService { } } + #[tracing::instrument(level = "debug", skip(self))] pub async fn prompt_java_install(&self, major: u32) -> Result { self.prompt( "Java required", @@ -51,6 +53,7 @@ impl NotificationService { .await } + #[tracing::instrument(level = "debug", skip(self))] pub async fn prompt_update(&self, version: &str) -> Result { self.prompt( "Update available", @@ -60,6 +63,7 @@ impl NotificationService { .await } + #[tracing::instrument(level = "debug", skip(self, question))] pub async fn prompt( &self, title: &str, @@ -80,6 +84,15 @@ impl NotificationService { reply_rx.await.map_err(|_| NotificationError::ServiceDown) } + pub fn microsoft_login_status(&self, status: Option) { + if let Err(err) = self + .channel + .send(Notification::MicrosoftLoginStatus(status)) + { + tracing::error!("{err}"); + } + } + pub fn send_info(&self, title: &str, body: &str) { self.send_message(title, body, NotificationLevel::Info); } diff --git a/packages/oneclient_core/src/packages/file_identity.rs b/packages/oneclient_core/src/packages/file_identity.rs index cea9193e..ac49e89d 100644 --- a/packages/oneclient_core/src/packages/file_identity.rs +++ b/packages/oneclient_core/src/packages/file_identity.rs @@ -1,5 +1,6 @@ use std::path::Path; + use crate::LauncherResult; use crate::crypto::{normalize_hash, sha1_bytes}; @@ -17,6 +18,7 @@ impl FileIdentity { } } + #[tracing::instrument(level = "debug", skip(path))] pub async fn from_path(path: impl AsRef) -> LauncherResult { let bytes = polyio::read(path).await?; Ok(Self::from_bytes(&bytes)) diff --git a/packages/oneclient_core/src/packages/metadata_cache.rs b/packages/oneclient_core/src/packages/metadata_cache.rs index 9bea1a2c..cbff192a 100644 --- a/packages/oneclient_core/src/packages/metadata_cache.rs +++ b/packages/oneclient_core/src/packages/metadata_cache.rs @@ -23,6 +23,7 @@ pub struct CachedPackageMeta { pub icon_url: Option, } +#[tracing::instrument(level = "debug", skip(services, project_ids))] pub async fn read_cached_package_meta( services: &LauncherServices, provider: ProviderId, @@ -53,6 +54,7 @@ pub async fn read_cached_package_meta( .collect() } +#[tracing::instrument(level = "debug", skip(services))] pub async fn cached_project_detail( services: &LauncherServices, provider: ProviderId, @@ -89,6 +91,7 @@ pub async fn cached_project_detail( } } +#[tracing::instrument(level = "debug", skip(services))] pub async fn get_version_cached( services: &LauncherServices, provider: ProviderId, @@ -106,6 +109,7 @@ pub async fn get_version_cached( .await } +#[tracing::instrument(level = "debug", skip(services))] async fn cached_version_detail( services: &LauncherServices, provider: ProviderId, @@ -157,6 +161,7 @@ async fn cached_version_detail( }) } +#[tracing::instrument(level = "debug", skip(services, project_ids))] pub async fn fetch_package_meta( services: &LauncherServices, provider: ProviderId, @@ -193,6 +198,8 @@ pub async fn fetch_package_meta( return Ok(out); } + tracing::debug!(count = missing.len(), "fetching missing package metadata from provider"); + let details = services .packages .get(provider)? diff --git a/packages/oneclient_core/src/packages/modpack/mod.rs b/packages/oneclient_core/src/packages/modpack/mod.rs index 892eaf3b..15c3e3ec 100644 --- a/packages/oneclient_core/src/packages/modpack/mod.rs +++ b/packages/oneclient_core/src/packages/modpack/mod.rs @@ -2,11 +2,13 @@ mod mrpack; use std::path::Path; + pub use mrpack::{install_mrpack_to_cluster, MrpackInstaller}; use crate::state::LauncherServices; use crate::LauncherResult; +#[tracing::instrument(level = "debug", skip(archive_path, services))] pub async fn install_modpack_archive( archive_path: impl AsRef, cluster_id: i64, diff --git a/packages/oneclient_core/src/packages/modpack/mrpack.rs b/packages/oneclient_core/src/packages/modpack/mrpack.rs index 30574bbd..eb4e413a 100644 --- a/packages/oneclient_core/src/packages/modpack/mrpack.rs +++ b/packages/oneclient_core/src/packages/modpack/mrpack.rs @@ -41,11 +41,13 @@ struct MrpackHashes { } impl MrpackInstaller { + #[tracing::instrument(skip(services))] pub async fn install_archive( archive_path: PathBuf, cluster_id: i64, services: &LauncherServices, ) -> LauncherResult<()> { + tracing::info!("installing mrpack archive to cluster"); let cluster = PackageStore::get_cluster(cluster_id, services).await?; let cluster_root = crate::paths::clusters_dir()?.join(&cluster.folder_name); @@ -131,13 +133,16 @@ impl MrpackInstaller { .await?; if failed > 0 { + tracing::error!(failed, total, "modpack install completed with failures"); return Err(PackageError::PartialModpackInstall { failed, total }.into()); } + tracing::info!(total, "modpack install complete"); Ok(()) } } +#[tracing::instrument(level = "debug", skip(entry, cluster, services))] async fn install_mrpack_file( entry: MrpackFileEntry, content_type: ContentType, @@ -168,6 +173,7 @@ async fn install_mrpack_file( &project, &version, false, + None, services, ) .await?; @@ -189,7 +195,7 @@ async fn install_mrpack_file( content_type, }; - let artifact = store::download_external(&external, false, services).await?; + let artifact = store::download_external(&external, false, None, services).await?; PackageStore::link_artifact(&artifact, cluster, Some(&file_name), services).await?; Ok(()) @@ -207,6 +213,7 @@ fn io_err(err: impl std::error::Error + Send + Sync + 'static) -> polyio::IOErro } } +#[tracing::instrument(level = "debug", skip(services))] pub async fn install_mrpack_to_cluster( archive_path: PathBuf, cluster_id: i64, diff --git a/packages/oneclient_core/src/packages/provider/curseforge.rs b/packages/oneclient_core/src/packages/provider/curseforge.rs index aaadf0a1..1108a965 100644 --- a/packages/oneclient_core/src/packages/provider/curseforge.rs +++ b/packages/oneclient_core/src/packages/provider/curseforge.rs @@ -31,11 +31,13 @@ impl PackageProvider for CurseForgeProvider { ProviderId::CurseForge } + #[tracing::instrument(level = "debug", skip(self, filters, services))] async fn search( &self, filters: &SearchFilters, services: &LauncherServices, ) -> LauncherResult> { + tracing::debug!(query = ?filters.query, "searching curseforge"); let mut url = Url::parse(&api_url("/mods/search"))?; { @@ -93,6 +95,7 @@ impl PackageProvider for CurseForgeProvider { }) } + #[tracing::instrument(level = "debug", skip(self, services))] async fn get_project( &self, project_id: &str, @@ -109,6 +112,7 @@ impl PackageProvider for CurseForgeProvider { Ok(response.data.into_detail()) } + #[tracing::instrument(level = "debug", skip(self, project_ids, services))] async fn get_projects( &self, project_ids: &[String], @@ -127,6 +131,7 @@ impl PackageProvider for CurseForgeProvider { Ok(response.data.into_iter().map(CfMod::into_detail).collect()) } + #[tracing::instrument(level = "debug", skip(self, services))] async fn list_versions( &self, project_id: &str, @@ -172,6 +177,7 @@ impl PackageProvider for CurseForgeProvider { }) } + #[tracing::instrument(level = "debug", skip(self, services))] async fn list_categories( &self, content_type: ContentType, @@ -197,6 +203,7 @@ impl PackageProvider for CurseForgeProvider { Ok(response.data.into_iter().map(|c| c.name).collect()) } + #[tracing::instrument(level = "debug", skip(self, _project_id, services))] async fn get_version( &self, _project_id: &str, @@ -214,6 +221,7 @@ impl PackageProvider for CurseForgeProvider { Ok(response.data.into()) } + #[tracing::instrument(level = "debug", skip(self, version_ids, services))] async fn get_versions( &self, version_ids: &[String], @@ -232,6 +240,7 @@ impl PackageProvider for CurseForgeProvider { Ok(response.data.into_iter().map(Into::into).collect()) } + #[tracing::instrument(level = "debug", skip_all)] async fn lookup_versions( &self, identities: &[FileIdentity], diff --git a/packages/oneclient_core/src/packages/provider/http.rs b/packages/oneclient_core/src/packages/provider/http.rs index b1844f60..248ad3a9 100644 --- a/packages/oneclient_core/src/packages/provider/http.rs +++ b/packages/oneclient_core/src/packages/provider/http.rs @@ -1,9 +1,11 @@ use reqwest::Method; use serde::de::DeserializeOwned; -use crate::http::{RequestClient, RequestError, ResponseOptions}; +use crate::http::{RequestClient, RequestError, ResponseNotifyOptions, ResponseOptions}; +use crate::notification::GroupedProgressChild; use crate::state::LauncherServices; +#[tracing::instrument(level = "debug", skip(client, url, body))] pub async fn fetch_json( client: &RequestClient, method: Method, @@ -14,6 +16,7 @@ pub async fn fetch_json( client.send_json(method, url, body, &[]).await } +#[tracing::instrument(level = "debug", skip(client, url, body, headers))] pub async fn fetch_json_with_headers( client: &RequestClient, method: Method, @@ -29,14 +32,19 @@ pub async fn fetch_json_with_headers( client.send_json(method, url, body, &borrowed).await } +#[tracing::instrument(level = "debug", skip(client, dest, child, services))] pub async fn download_url( client: &RequestClient, url: &str, dest: impl AsRef + Send, + child: Option<&GroupedProgressChild>, services: &LauncherServices, ) -> Result<(), RequestError> { let request = reqwest::Request::new(Method::GET, url.parse()?); + let options = ResponseOptions { + notify: child.map(|c| ResponseNotifyOptions::grouped(c.clone())), + }; client - .download_file(request, dest, ResponseOptions::default(), services) + .download_file(request, dest, options, services) .await } diff --git a/packages/oneclient_core/src/packages/provider/modrinth.rs b/packages/oneclient_core/src/packages/provider/modrinth.rs index 0e762549..eab6fa25 100644 --- a/packages/oneclient_core/src/packages/provider/modrinth.rs +++ b/packages/oneclient_core/src/packages/provider/modrinth.rs @@ -43,11 +43,13 @@ impl PackageProvider for ModrinthProvider { ProviderId::Modrinth } + #[tracing::instrument(level = "debug", skip(self, filters, services))] async fn search( &self, filters: &SearchFilters, services: &LauncherServices, ) -> LauncherResult> { + tracing::debug!(query = ?filters.query, "searching modrinth"); let mut url = Url::parse(&v2("/search"))?; { let limit = filters @@ -152,6 +154,7 @@ impl PackageProvider for ModrinthProvider { }) } + #[tracing::instrument(level = "debug", skip(self, services))] async fn get_project( &self, project_id: &str, @@ -172,6 +175,7 @@ impl PackageProvider for ModrinthProvider { Ok(detail) } + #[tracing::instrument(level = "debug", skip(self, services))] async fn list_categories( &self, content_type: ContentType, @@ -192,6 +196,7 @@ impl PackageProvider for ModrinthProvider { .collect()) } + #[tracing::instrument(level = "debug", skip(self, project_ids, services))] async fn get_projects( &self, project_ids: &[String], @@ -205,7 +210,7 @@ impl PackageProvider for ModrinthProvider { .map(|id| format!("\"{id}\"")) .collect::>() .join(","); - + let raw: Vec = fetch_json( &services.requester, Method::GET, @@ -220,7 +225,7 @@ impl PackageProvider for ModrinthProvider { .collect::>() .into_iter() .collect(); - + let mut members_by_team = fetch_modrinth_team_members(&services.requester, &team_ids) .await .unwrap_or_default(); @@ -238,6 +243,7 @@ impl PackageProvider for ModrinthProvider { .collect()) } + #[tracing::instrument(level = "debug", skip(self, services))] async fn list_versions( &self, project_id: &str, @@ -276,6 +282,7 @@ impl PackageProvider for ModrinthProvider { }) } + #[tracing::instrument(level = "debug", skip(self, _project_id, services))] async fn get_version( &self, _project_id: &str, @@ -292,6 +299,7 @@ impl PackageProvider for ModrinthProvider { Ok(raw.into()) } + #[tracing::instrument(level = "debug", skip(self, version_ids, services))] async fn get_versions( &self, version_ids: &[String], @@ -315,6 +323,7 @@ impl PackageProvider for ModrinthProvider { Ok(raw.into_iter().map(Into::into).collect()) } + #[tracing::instrument(level = "debug", skip_all)] async fn lookup_versions( &self, identities: &[FileIdentity], @@ -355,6 +364,7 @@ impl PackageProvider for ModrinthProvider { } } +#[tracing::instrument(level = "debug", skip(client, team_ids))] async fn fetch_modrinth_team_members( client: &RequestClient, team_ids: &[String], diff --git a/packages/oneclient_core/src/packages/provider/registry.rs b/packages/oneclient_core/src/packages/provider/registry.rs index 5dd727f7..0a8b7418 100644 --- a/packages/oneclient_core/src/packages/provider/registry.rs +++ b/packages/oneclient_core/src/packages/provider/registry.rs @@ -1,6 +1,7 @@ use std::collections::HashMap; use std::sync::Arc; + use super::{CurseForgeProvider, ModrinthProvider, PackageProvider}; use crate::LauncherResult; use crate::packages::domain::ProviderId; @@ -51,6 +52,7 @@ impl PackageProviderRegistry { .collect() } + #[tracing::instrument(level = "debug", skip_all)] pub async fn lookup_versions( &self, identities: &[FileIdentity], @@ -76,6 +78,7 @@ impl PackageProviderRegistry { Ok(merged) } + #[tracing::instrument(level = "debug", skip(self, sha1, services))] pub async fn lookup_version( &self, sha1: impl AsRef, @@ -96,6 +99,7 @@ impl PackageProviderRegistry { Ok(None) } + #[tracing::instrument(level = "debug", skip(self, services), fields(sha1 = %identity.sha1))] pub async fn lookup_version_identity( &self, identity: &FileIdentity, @@ -123,6 +127,7 @@ impl Default for PackageProviderRegistry { } } +#[tracing::instrument(level = "debug", skip(identity, services), fields(sha1 = %identity.sha1))] async fn enrich_curseforge_fingerprint( identity: &mut FileIdentity, services: &LauncherServices, diff --git a/packages/oneclient_core/src/packages/store/download.rs b/packages/oneclient_core/src/packages/store/download.rs index 8a408996..1bfd817e 100644 --- a/packages/oneclient_core/src/packages/store/download.rs +++ b/packages/oneclient_core/src/packages/store/download.rs @@ -6,14 +6,17 @@ use crate::LauncherResult; use crate::crypto::{normalize_hash, sha1_file}; use crate::packages::domain::{ContentType, ProviderId}; use crate::packages::error::PackageError; +use crate::notification::{GroupedProgressChild, TaskPhase}; use crate::packages::provider::http::download_url; use crate::packages::types::{ExternalFile, VersionDetail, VersionFile}; use crate::state::LauncherServices; +#[tracing::instrument(level = "debug", skip(child, services))] pub async fn ensure_artifact_file( hash: &str, url: &str, dest: &std::path::Path, + child: Option<&GroupedProgressChild>, services: &LauncherServices, ) -> LauncherResult { polyio::create_dir_all(dest.parent().unwrap_or(dest)).await?; @@ -26,10 +29,15 @@ pub async fn ensure_artifact_file( tokio::fs::remove_file(dest).await?; } - download_url(&services.requester, url, dest, services).await?; + tracing::debug!("downloading artifact file"); + download_url(&services.requester, url, dest, child, services).await?; + if let Some(child) = child { + child.set_phase(TaskPhase::Verifying); + } let actual = sha1_file(dest).await?; if normalize_hash(&actual) != normalize_hash(hash) { + tracing::warn!(expected = %hash, %actual, "downloaded artifact hash mismatch"); return Err(PackageError::HashMismatch { expected: hash.to_string(), actual, @@ -40,6 +48,8 @@ pub async fn ensure_artifact_file( Ok(tokio::fs::metadata(dest).await?.len()) } +#[allow(clippy::too_many_arguments)] +#[tracing::instrument(level = "debug", skip(version, file, child, services))] pub async fn download_version_file( provider: ProviderId, project_id: &str, @@ -47,6 +57,7 @@ pub async fn download_version_file( content_type: ContentType, file: &VersionFile, force: bool, + child: Option<&GroupedProgressChild>, services: &LauncherServices, ) -> LauncherResult { let hash = normalize_hash(&file.sha1); @@ -68,7 +79,7 @@ pub async fn download_version_file( &file.file_name, )?; - let size = ensure_artifact_file(&hash, &file.url, &dest, services).await?; + let size = ensure_artifact_file(&hash, &file.url, &dest, child, services).await?; let stored_path = relative_cache_path(&dest)?; let published_at = version.published.to_rfc3339(); @@ -100,9 +111,11 @@ pub async fn download_version_file( Ok(row) } +#[tracing::instrument(level = "debug", skip(file, child, services), fields(name = %file.name))] pub async fn download_external( file: &ExternalFile, force: bool, + child: Option<&GroupedProgressChild>, services: &LauncherServices, ) -> LauncherResult { let hash = normalize_hash(&file.sha1); @@ -120,7 +133,7 @@ pub async fn download_external( &hash[..hash.len().min(16)], &file.name, )?; - let size = ensure_artifact_file(&hash, &file.url, &dest, services).await?; + let size = ensure_artifact_file(&hash, &file.url, &dest, child, services).await?; let stored_path = relative_cache_path(&dest)?; artifact_dao::insert_artifact( diff --git a/packages/oneclient_core/src/packages/store/link.rs b/packages/oneclient_core/src/packages/store/link.rs index 53462be1..7205bc1a 100644 --- a/packages/oneclient_core/src/packages/store/link.rs +++ b/packages/oneclient_core/src/packages/store/link.rs @@ -9,6 +9,7 @@ use crate::packages::error::PackageError; use crate::paths; use crate::{LauncherError, LauncherResult}; +#[tracing::instrument(level = "debug", skip(artifact, cluster))] pub async fn link_artifact_to_cluster( artifact: &ArtifactRow, cluster: &ClusterRow, @@ -47,6 +48,7 @@ pub async fn link_artifact_to_cluster( Ok(()) } +#[tracing::instrument(level = "debug")] pub async fn link_or_copy(src: &Path, dest: &Path) -> LauncherResult<()> { if dest.exists() { polyio::remove_file(dest).await?; @@ -60,6 +62,7 @@ pub async fn link_or_copy(src: &Path, dest: &Path) -> LauncherResult<()> { Ok(()) } +#[tracing::instrument(level = "debug", skip(cluster))] pub async fn unlink_cluster_file( cluster: &ClusterRow, content_type: ContentType, diff --git a/packages/oneclient_core/src/packages/store/mod.rs b/packages/oneclient_core/src/packages/store/mod.rs index e4495016..12911166 100644 --- a/packages/oneclient_core/src/packages/store/mod.rs +++ b/packages/oneclient_core/src/packages/store/mod.rs @@ -13,6 +13,7 @@ use super::domain::{ContentType, GameLoader, ProviderId}; use super::error::PackageError; use super::types::{CachedArtifact, ProjectDetail, ProviderReleaseInfo, VersionDetail, LinkedArtifactInfo}; use crate::crypto::{normalize_hash, sha1_file}; +use crate::notification::GroupedProgressChild; use crate::state::LauncherServices; use crate::{LauncherError, LauncherResult}; use std::path::Path; @@ -20,6 +21,7 @@ use std::path::Path; pub struct PackageStore; impl PackageStore { + #[tracing::instrument(level = "debug", skip(services))] pub async fn get_cluster( cluster_id: i64, services: &LauncherServices, @@ -29,6 +31,7 @@ impl PackageStore { .ok_or(PackageError::ClusterNotFound(cluster_id).into()) } + #[tracing::instrument(level = "debug", skip(services))] pub async fn cached_artifact( hash: &str, services: &LauncherServices, @@ -40,11 +43,13 @@ impl PackageStore { Ok(Some(row_to_cached(row, services).await?)) } + #[tracing::instrument(level = "debug", skip(project, version, child, services), fields(project_id = %project.id, version_id = %version.version_id))] pub async fn download_and_cache( provider_id: ProviderId, project: &ProjectDetail, version: &VersionDetail, force: bool, + child: Option<&GroupedProgressChild>, services: &LauncherServices, ) -> LauncherResult { let file = version.primary_file().ok_or(PackageError::NoPrimaryFile)?; @@ -56,11 +61,14 @@ impl PackageStore { project.content_type, file, force, + child, services, ) .await } + #[allow(clippy::too_many_arguments)] + #[tracing::instrument(skip(project, version, child, services), fields(project_id = %project.id, version_id = %version.version_id))] pub async fn install_to_cluster( provider_id: ProviderId, project: &ProjectDetail, @@ -68,8 +76,10 @@ impl PackageStore { cluster_id: i64, skip_compatibility: bool, force_download: bool, + child: Option<&GroupedProgressChild>, services: &LauncherServices, ) -> LauncherResult { + tracing::info!("installing package to cluster"); let cluster = Self::get_cluster(cluster_id, services).await?; if !skip_compatibility { @@ -81,6 +91,7 @@ impl PackageStore { project, version, force_download, + child, services, ) .await?; @@ -89,6 +100,7 @@ impl PackageStore { Ok(artifact) } + #[tracing::instrument(level = "debug", skip(artifact, cluster, services))] pub async fn link_artifact( artifact: &ArtifactRow, cluster: &ClusterRow, @@ -103,6 +115,7 @@ impl PackageStore { Ok(()) } + #[tracing::instrument(level = "debug", skip(services))] pub async fn link_by_hash( hash: &str, cluster_id: i64, @@ -118,6 +131,7 @@ impl PackageStore { Self::link_artifact(&artifact, &cluster, cluster_file_name, services).await } + #[tracing::instrument(level = "debug", skip(services))] pub async fn unlink_from_cluster( hash: &str, cluster_id: i64, @@ -126,6 +140,7 @@ impl PackageStore { crate::bundles::remove_artifact_from_cluster(cluster_id, hash, true, services).await } + #[tracing::instrument(level = "debug", skip(services))] pub async fn unlink_from_cluster_system( hash: &str, cluster_id: i64, @@ -134,6 +149,7 @@ impl PackageStore { crate::bundles::remove_artifact_from_cluster(cluster_id, hash, false, services).await } + #[tracing::instrument(level = "debug", skip(services))] pub async fn list_linked_artifacts( cluster_id: i64, services: &LauncherServices, @@ -170,6 +186,7 @@ impl PackageStore { Ok(items) } + #[tracing::instrument(level = "debug", skip(services))] pub async fn toggle_artifact_enabled( cluster_id: i64, hash: &str, @@ -222,6 +239,7 @@ impl PackageStore { Ok(enabled) } + #[tracing::instrument(level = "debug", skip(services))] pub async fn import_local_file( path: &Path, content_type: ContentType, @@ -272,6 +290,7 @@ impl PackageStore { Ok(row) } + #[tracing::instrument(level = "debug", skip(services))] pub async fn resolve_or_download( provider_id: ProviderId, project_id: &str, @@ -286,10 +305,11 @@ impl PackageStore { let project = provider.get_project(project_id, services).await?; - Self::download_and_cache(provider_id, &project, &version, false, services).await + Self::download_and_cache(provider_id, &project, &version, false, None, services).await } } +#[tracing::instrument(level = "debug", skip(row, services), fields(hash = %row.hash))] async fn row_to_cached( row: ArtifactRow, services: &LauncherServices, diff --git a/packages/oneclient_core/src/recovery.rs b/packages/oneclient_core/src/recovery.rs index 9cc497a2..70b98c6c 100644 --- a/packages/oneclient_core/src/recovery.rs +++ b/packages/oneclient_core/src/recovery.rs @@ -6,7 +6,6 @@ use oneclient_db::dao::{ use oneclient_db::models::NewCluster; use oneclient_db::DbPool; use strum::IntoEnumIterator; -use tracing::instrument; use crate::clusters::{ClusterManager, ClusterStage}; use crate::crypto::sha1_file; @@ -37,7 +36,7 @@ impl RecoveryReport { } } -#[instrument(skip(state))] +#[tracing::instrument(skip(state))] pub async fn reconstruct_from_disk(state: &LauncherState) -> LauncherResult { let mut report = RecoveryReport::default(); @@ -77,6 +76,7 @@ pub async fn reconstruct_from_disk(state: &LauncherState) -> LauncherResult LauncherResult { let cache_root = paths::packages_cache_dir()?; if !cache_root.exists() { @@ -143,6 +144,7 @@ async fn rebuild_artifact_cache(pool: &DbPool) -> LauncherResult { } #[allow(clippy::too_many_arguments)] +#[tracing::instrument(level = "debug", skip(pool, launcher_dir, file))] async fn index_cache_file( pool: &DbPool, launcher_dir: &Path, @@ -189,6 +191,7 @@ async fn index_cache_file( Ok(()) } +#[tracing::instrument(level = "debug", skip(state, report))] async fn adopt_cluster( state: &LauncherState, folder_name: &str, @@ -226,6 +229,7 @@ async fn adopt_cluster( Ok(()) } +#[tracing::instrument(level = "debug", skip(state, report))] async fn relink_cluster_files( state: &LauncherState, cluster_id: i64, @@ -284,7 +288,7 @@ async fn relink_cluster_files( Ok(()) } -#[instrument(skip(state))] +#[tracing::instrument(skip(state))] pub async fn restore_bundle_tracking(state: &LauncherState) -> LauncherResult<()> { let clusters = ClusterManager::list(state).await?; diff --git a/packages/oneclient_core/src/screenshots/mod.rs b/packages/oneclient_core/src/screenshots/mod.rs index 73cb8059..db2b08f6 100644 --- a/packages/oneclient_core/src/screenshots/mod.rs +++ b/packages/oneclient_core/src/screenshots/mod.rs @@ -44,6 +44,7 @@ fn ensure_in_clusters(path: &Path) -> LauncherResult { Err(ScreenshotsError::InvalidPath(path.display().to_string()).into()) } +#[tracing::instrument(level = "debug", skip(cluster), fields(cluster_id = cluster.id))] pub fn list_cluster_screenshots(cluster: &Cluster) -> LauncherResult> { let dir = cluster.game_dir()?.join("screenshots"); let mut out = Vec::new(); @@ -91,6 +92,7 @@ pub fn list_cluster_screenshots(cluster: &Cluster) -> LauncherResult) -> LauncherResult { let path = ensure_in_clusters(path)?; let raw = std::fs::read(&path).map_err(ScreenshotsError::Io)?; @@ -114,9 +116,10 @@ fn thumbnail(raw: &[u8], max_edge: u32) -> Option { Some(Bytes::from(out)) } +#[tracing::instrument(level = "debug")] pub fn delete_screenshot(path: &Path) -> LauncherResult<()> { let path = ensure_in_clusters(path)?; - + // TODO: maybe async? match trash::delete(&path) { Ok(()) => Ok(()), diff --git a/packages/oneclient_core/src/settings/launcher.rs b/packages/oneclient_core/src/settings/launcher.rs index cd930f99..9991984f 100644 --- a/packages/oneclient_core/src/settings/launcher.rs +++ b/packages/oneclient_core/src/settings/launcher.rs @@ -28,7 +28,6 @@ pub struct LauncherSettings { pub auto_update: bool, pub enable_gamemode: bool, pub discord_enabled: bool, - pub microsoft_login_use_browser: bool, pub max_concurrent_requests: usize, pub global_game_settings: GameSettingsProfile, pub allow_parallel_running_clusters: bool, @@ -59,7 +58,6 @@ impl Default for LauncherSettings { log_debug: false, auto_update: true, discord_enabled: true, - microsoft_login_use_browser: true, enable_gamemode: false, max_concurrent_requests: 25, global_game_settings: GameSettingsProfile::default_global_profile(), diff --git a/packages/oneclient_core/src/settings/store.rs b/packages/oneclient_core/src/settings/store.rs index 975d5828..c92c41b5 100644 --- a/packages/oneclient_core/src/settings/store.rs +++ b/packages/oneclient_core/src/settings/store.rs @@ -1,6 +1,5 @@ use oneclient_db::DbPool; use parking_lot::RwLock; -use tracing::instrument; use crate::notification::NotificationService; use crate::patch::Patch; @@ -10,7 +9,7 @@ use crate::{LauncherError, LauncherResult}; use super::launcher::LauncherSettings; use super::profile::{GameSettingsProfile, Resolution, SettingsOsExtra}; -#[instrument(skip(notify))] +#[tracing::instrument(level = "debug", skip(notify))] pub async fn load_settings(notify: Option<&NotificationService>) -> LauncherSettings { match async { let path = paths::settings_file()?; @@ -38,7 +37,7 @@ pub async fn load_settings(notify: Option<&NotificationService>) -> LauncherSett } } -#[instrument(skip(settings))] +#[tracing::instrument(level = "debug", skip(settings))] pub async fn save_settings(settings: &LauncherSettings) -> LauncherResult<()> { let path = paths::settings_file()?; @@ -51,6 +50,7 @@ pub async fn save_settings(settings: &LauncherSettings) -> LauncherResult<()> { Ok(()) } +#[tracing::instrument(level = "debug", skip(pool, settings))] pub async fn get_profile_or_default( pool: &DbPool, settings: &LauncherSettings, @@ -65,6 +65,7 @@ pub async fn get_profile_or_default( Ok(settings.global_game_settings.clone()) } +#[tracing::instrument(level = "debug", skip(pool, settings))] pub async fn resolve_cluster_profile( pool: &DbPool, settings: &LauncherSettings, @@ -88,6 +89,7 @@ pub async fn resolve_cluster_profile( Ok(profile) } +#[tracing::instrument(level = "debug", skip_all)] pub async fn upsert_named_profile( pool: &DbPool, profile: &GameSettingsProfile, @@ -103,6 +105,7 @@ pub async fn upsert_named_profile( GameSettingsProfile::from_row(saved) } +#[tracing::instrument(level = "debug", skip(pool, settings))] pub async fn create_profile_from_global( pool: &DbPool, settings: &LauncherSettings, @@ -124,6 +127,7 @@ pub async fn create_profile_from_global( upsert_named_profile(pool, &profile).await } +#[tracing::instrument(level = "debug", skip(pool, settings))] pub async fn create_settings_profile( pool: &DbPool, settings: &LauncherSettings, @@ -132,6 +136,7 @@ pub async fn create_settings_profile( create_profile_from_global(pool, settings, name, None, None).await } +#[tracing::instrument(level = "debug", skip(pool))] pub async fn list_named_profiles(pool: &DbPool) -> LauncherResult> { let rows = oneclient_db::dao::setting_profile::list_all(pool).await?; rows.into_iter() @@ -139,6 +144,7 @@ pub async fn list_named_profiles(pool: &DbPool) -> LauncherResult LauncherResult<()> { oneclient_db::dao::setting_profile::delete_by_name(pool, name).await?; Ok(()) @@ -178,6 +184,7 @@ impl ProfileUpdate { } } +#[tracing::instrument(level = "debug", skip(pool, update))] pub async fn update_named_profile( pool: &DbPool, name: &str, @@ -195,6 +202,7 @@ pub async fn update_named_profile( upsert_named_profile(pool, &profile).await } +#[tracing::instrument(level = "debug", skip_all)] pub async fn save_global_profile( settings: &RwLock, global: GameSettingsProfile, diff --git a/packages/oneclient_core/src/state.rs b/packages/oneclient_core/src/state.rs index 01a9d52a..80a187e2 100644 --- a/packages/oneclient_core/src/state.rs +++ b/packages/oneclient_core/src/state.rs @@ -3,7 +3,6 @@ use std::sync::Arc; use oneclient_db::DbPool; use tokio::sync::{Mutex, OnceCell}; -use tracing::instrument; use crate::auth::{CredentialsStore, PendingBrowserLogin}; use crate::bundles::BundlesManager; @@ -42,7 +41,7 @@ pub struct LauncherState { } impl LauncherState { - #[instrument(skip(notifier))] + #[tracing::instrument(skip(notifier))] pub async fn initialize( notifier: NotificationService, ) -> LauncherResult> { diff --git a/packages/oneclient_core/src/versions/manager.rs b/packages/oneclient_core/src/versions/manager.rs index c375b441..aabb5dbd 100644 --- a/packages/oneclient_core/src/versions/manager.rs +++ b/packages/oneclient_core/src/versions/manager.rs @@ -3,7 +3,6 @@ use std::path::Path; use reqwest::header; use reqwest::{Method, StatusCode}; use tokio::sync::RwLock; -use tracing::instrument; use crate::api_config::meta_url_base; use crate::paths; @@ -23,7 +22,7 @@ impl VersionsManager { } } - #[instrument(skip(self, services))] + #[tracing::instrument(level = "debug", skip(self, services))] pub async fn sync(&self, services: &LauncherServices) -> LauncherResult { let Some((manifest, changed)) = Self::fetch_manifest(services).await? else { tracing::debug!("skipping versions sync; no remote or cached manifest available"); @@ -37,6 +36,7 @@ impl VersionsManager { self.manifest.read().await.metadata() } + #[tracing::instrument(level = "debug", skip(services))] async fn fetch_manifest( services: &LauncherServices, ) -> LauncherResult> { diff --git a/packages/oneclient_core/tests/auth_offline.rs b/packages/oneclient_core/tests/auth_offline.rs index d1b5812e..844e4ee0 100644 --- a/packages/oneclient_core/tests/auth_offline.rs +++ b/packages/oneclient_core/tests/auth_offline.rs @@ -1,8 +1,16 @@ +use chrono::{TimeDelta, Utc}; use oneclient_core::auth::{ offline_account, offline_uuid, AccountKind, CredentialsStore, }; use uuid::Uuid; +// fake launcher dir because yes +fn isolate_launcher_dir() { + oneclient_core::paths::set_launcher_dir( + std::env::temp_dir().join(format!("oneclient-auth-test-{}", Uuid::new_v4())), + ); +} + fn fake_microsoft_account(username: &str) -> oneclient_core::auth::MinecraftAccount { let mut account = offline_account(username.to_string()); account.kind = AccountKind::Microsoft; @@ -14,7 +22,7 @@ fn fake_microsoft_account(username: &str) -> oneclient_core::auth::MinecraftAcco #[test] fn offline_uuid_matches_vanilla_algorithm() { let id = offline_uuid("Notch"); - + assert_eq!( id, Uuid::parse_str("b50ad385-829d-3141-a216-7e7d7539ba7f").unwrap() @@ -24,6 +32,8 @@ fn offline_uuid_matches_vanilla_algorithm() { #[tokio::test] async fn offline_account_blocked_without_microsoft() { + isolate_launcher_dir(); + let mut store = CredentialsStore::default(); let err = store.add_offline_account("Steve".into()).unwrap_err(); @@ -32,6 +42,8 @@ async fn offline_account_blocked_without_microsoft() { #[tokio::test] async fn offline_account_allowed_when_microsoft_exists() { + isolate_launcher_dir(); + let mut store = CredentialsStore::default(); let msa = fake_microsoft_account("MsaUser"); store.users.insert(msa.id, msa); @@ -43,6 +55,8 @@ async fn offline_account_allowed_when_microsoft_exists() { #[tokio::test] async fn default_offline_fails_when_last_microsoft_removed() { + isolate_launcher_dir(); + let mut store = CredentialsStore::default(); let msa = fake_microsoft_account("MsaUser"); let msa_id = msa.id; @@ -52,7 +66,45 @@ async fn default_offline_fails_when_last_microsoft_removed() { store.default_user = Some(offline.id); store.users.remove(&msa_id); - let services = oneclient_core::dev::ephemeral_services().await.unwrap(); - let err = store.default_account(&services).await.unwrap_err(); + let err = store.default_account().await.unwrap_err(); assert!(err.to_string().contains("Microsoft")); } + +#[test] +fn token_expiring_imminently_counts_as_expired() { + let mut account = fake_microsoft_account("MsaUser"); + + account.expires = Utc::now() + TimeDelta::hours(1); + assert!(!account.is_expired(), "a token with an hour left is usable"); + + account.expires = Utc::now() + TimeDelta::seconds(5); + assert!( + account.is_expired(), + "a token about to lapse must be renewed before launching" + ); + + account.expires = Utc::now() - TimeDelta::hours(1); + assert!(account.is_expired()); +} + +#[tokio::test] +async fn default_account_returns_expired_token_without_refreshing() { + isolate_launcher_dir(); + + let mut store = CredentialsStore::default(); + let mut msa = fake_microsoft_account("MsaUser"); + msa.expires = Utc::now() - TimeDelta::hours(5); + let msa_id = msa.id; + store.users.insert(msa_id, msa); + + store.default_user = Some(msa_id); + + let account = store + .default_account() + .await + .expect("an expired token must not fail the read path") + .expect("the only account should be the default"); + + assert_eq!(account.id, msa_id); + assert!(account.is_expired()); +} diff --git a/packages/oneclient_db/src/pool.rs b/packages/oneclient_db/src/pool.rs index 54d4ad79..4a9d1b2a 100644 --- a/packages/oneclient_db/src/pool.rs +++ b/packages/oneclient_db/src/pool.rs @@ -2,14 +2,13 @@ use sqlx::SqlitePool; use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions}; use std::path::Path; use std::str::FromStr; -use tracing::instrument; use crate::DbError; pub type DbPool = SqlitePool; -#[instrument( - skip(database_path), +#[tracing::instrument( + skip(database_path), fields(database_path = %database_path.as_ref().display()) )] pub async fn connect(database_path: impl AsRef) -> Result { diff --git a/packages/polyio/Cargo.toml b/packages/polyio/Cargo.toml index 0097c165..05d097ba 100644 --- a/packages/polyio/Cargo.toml +++ b/packages/polyio/Cargo.toml @@ -37,3 +37,6 @@ serde_json.workspace = true dunce.workspace = true bytes.workspace = true +[target.'cfg(windows)'.dependencies] +junction.workspace = true + diff --git a/packages/polyio/src/archive.rs b/packages/polyio/src/archive.rs index ffe7567c..64a74db5 100644 --- a/packages/polyio/src/archive.rs +++ b/packages/polyio/src/archive.rs @@ -5,12 +5,11 @@ use async_zip::StoredZipEntry; use async_zip::base::read::WithoutEntry; use futures_util::{Stream, TryStreamExt, pin_mut}; use tokio_util::compat::TokioAsyncWriteCompatExt; -use tracing::instrument; use crate::{IOError, PolyIOResult}; /// Reads a zip archive from a byte array -#[instrument(skip(data, f), level = "debug")] +#[tracing::instrument(skip(data, f), level = "debug")] pub async fn read_zip_entries_bytes(data: Vec, mut f: F) -> PolyIOResult<()> where F: AsyncFnMut( @@ -40,7 +39,7 @@ where } /// Reads a zip archive from a byte array and returns a stream of entries. -#[instrument(skip(data), level = "debug")] +#[tracing::instrument(skip(data), level = "debug")] pub fn stream_zip_entries_bytes( data: Vec, ) -> impl Stream< @@ -66,12 +65,12 @@ pub fn stream_zip_entries_bytes( } /// Unzips a zip archive from a byte array -#[instrument( +#[tracing::instrument( level = "debug", skip(data, dest_path), fields( dest_path = %dest_path.as_ref().display() - ) + ) )] pub async fn unzip_bytes( data: Vec, @@ -81,12 +80,12 @@ pub async fn unzip_bytes( } /// Unzips a zip archive from a byte array -#[instrument( +#[tracing::instrument( level = "debug", skip(data, filter_entries, dest_path), fields( dest_path = %dest_path.as_ref().display() - ) + ) )] pub async fn unzip_bytes_filtered( data: Vec, @@ -128,13 +127,13 @@ pub async fn unzip_bytes_filtered( Ok(()) } -#[instrument( +#[tracing::instrument( level = "debug", skip(zip_path, dest_path), fields( zip_path = %zip_path.as_ref().display(), dest_path = %dest_path.as_ref().display() - ) + ) )] pub async fn extract_zip( zip_path: impl AsRef, @@ -150,13 +149,13 @@ pub async fn extract_zip( } /// Unzips a zip archive from a file -#[instrument( +#[tracing::instrument( level = "debug", skip(zip_path, dest_path, filter_entries, modify_entry_name), fields( zip_path = %zip_path.as_ref().display(), dest_path = %dest_path.as_ref().display() - ) + ) )] pub async fn extract_zip_filtered( zip_path: impl AsRef, @@ -207,8 +206,47 @@ pub async fn extract_zip_filtered( Ok(()) } +/// Reads the bytes of every non-directory zip entry whose name passes `filter`. +/// +/// Returns `(entry_name, bytes)` pairs. Each matching entry is read fully into +/// memory, so this is meant for small files (e.g. bundle config overrides), not +/// large archives. +#[tracing::instrument( + level = "debug", + skip(zip_path, filter), + fields(zip_path = %zip_path.as_ref().display()) +)] +pub async fn read_zip_file_entries( + zip_path: impl AsRef, + filter: impl Fn(&str) -> bool, +) -> PolyIOResult)>> { + let zip_path = zip_path.as_ref(); + let reader = async_zip::tokio::read::fs::ZipFileReader::new(zip_path).await?; + let entries = reader.file().entries(); + + let mut out = Vec::new(); + for index in 0..entries.len() { + let entry = entries.get(index).expect("expected more zip entries"); + let Ok(name) = entry.filename().as_str() else { + continue; + }; + let name = name.to_string(); + + if entry.dir().unwrap_or(false) || !filter(&name) { + continue; + } + + let mut entry_reader = reader.reader_without_entry(index).await?; + let mut data = Vec::new(); + futures_lite::AsyncReadExt::read_to_end(&mut entry_reader, &mut data).await?; + out.push((name, data)); + } + + Ok(out) +} + /// Returns a zip file entry's bytes without reading the entire file into memory. -#[instrument( +#[tracing::instrument( level = "debug", skip(reader) )] @@ -239,7 +277,7 @@ where Ok(data) } -#[instrument( +#[tracing::instrument( level = "debug", skip(archive, dest), fields( @@ -253,7 +291,7 @@ pub async fn extract_tar_gz(archive: impl AsRef, dest: impl AsR let file = tokio::fs::File::open(archive).await?; let buf_reader = tokio::io::BufReader::new(file); let gzip_decoder = async_compression::tokio::bufread::GzipDecoder::new(buf_reader); - + let mut tar_archive = tokio_tar::Archive::new(gzip_decoder); tar_archive.unpack(dest).await?; diff --git a/packages/polyio/src/file.rs b/packages/polyio/src/file.rs index 855cb247..566d46d0 100644 --- a/packages/polyio/src/file.rs +++ b/packages/polyio/src/file.rs @@ -2,12 +2,11 @@ use std::{fs::Metadata, path::PathBuf}; use async_tempfile::{TempDir, TempFile}; use serde::{Serialize, de::DeserializeOwned}; -use tracing::instrument; use crate::{IOError, PolyIOResult}; /// Returns a stream over the entries within a directory. -#[instrument( +#[tracing::instrument( level = "debug", skip(path), fields(path = %path.as_ref().display()) @@ -24,7 +23,7 @@ pub async fn read_dir(path: impl AsRef) -> PolyIOResult) -> PolyIOResult<()> { } /// Recursively creates a directory and all of its parent components if they are missing. -#[instrument( +#[tracing::instrument( level = "debug", skip(path), fields(path = %path.as_ref().display()) @@ -60,7 +59,7 @@ pub async fn create_dir_all(path: impl AsRef) -> PolyIOResult<( } /// Removes a directory at this path, after removing all its contents. Use carefully! -#[instrument( +#[tracing::instrument( level = "debug", skip(path), fields(path = %path.as_ref().display()) @@ -77,7 +76,7 @@ pub async fn remove_dir_all(path: impl AsRef) -> PolyIOResult<( } /// Checks if a path exists -#[instrument( +#[tracing::instrument( level = "debug", skip(path), fields(path = %path.as_ref().display()) @@ -93,7 +92,7 @@ pub async fn try_exists(path: impl AsRef) -> PolyIOResult } /// Creates a future which will open a gzip compressed file for reading and read the entire contents into a string and return said string. -#[instrument( +#[tracing::instrument( level = "debug", skip(path), fields(path = %path.as_ref().display()) @@ -111,7 +110,7 @@ pub async fn read_gz_to_string(path: impl AsRef) -> PolyIOResul } /// Creates a future which will open a file for reading and read the entire contents into a string and return said string. -#[instrument( +#[tracing::instrument( level = "debug", skip(path), fields(path = %path.as_ref().display()) @@ -128,7 +127,7 @@ pub async fn read_to_string(path: impl AsRef) -> PolyIOResult) -> PolyIOResult> { } /// Asynchronously read a file as JSON and return the deserialized object -#[instrument( +#[tracing::instrument( level = "debug", skip(path), fields(path = %path.as_ref().display()) @@ -161,7 +160,7 @@ pub async fn read_json( } /// Asynchrously write to a file. -#[instrument( +#[tracing::instrument( level = "debug", skip(path, data), fields(path = %path.as_ref().display()) @@ -181,7 +180,7 @@ pub async fn write( } /// Asynchronously write buffered data to a file, creating it if it does not exist. -#[instrument( +#[tracing::instrument( level = "debug", skip(path, f), fields(path = %path.as_ref().display()) @@ -206,7 +205,7 @@ where Ok(()) } -#[instrument( +#[tracing::instrument( level = "debug", skip(path, stream), fields(path = %path.as_ref().display()) @@ -243,7 +242,7 @@ where } /// Asynchronously write json to a file, creating it if it does not exist. -#[instrument( +#[tracing::instrument( level = "debug", skip(path, data), fields(path = %path.as_ref().display()) @@ -261,7 +260,7 @@ pub async fn write_json( } /// Renames a file or directory to a new name, replacing the original file if `to` already exists. -#[instrument( +#[tracing::instrument( level = "debug", skip(from, to), fields( @@ -285,7 +284,7 @@ pub async fn rename( } /// Copies the contents of one file to another. This function will also copy the permission bits of the original file to the destination file. This function will overwrite the contents of to. -#[instrument( +#[tracing::instrument( level = "debug", skip(from, to), fields( @@ -309,7 +308,7 @@ pub async fn copy( } /// Removes a file from the filesystem. -#[instrument( +#[tracing::instrument( level = "debug", skip(path), fields(path = %path.as_ref().display()) @@ -328,7 +327,7 @@ pub async fn remove_file(path: impl AsRef) -> PolyIOResult<()> /// /// Unlike [`stat`], if `path` is a symlink this returns metadata about the /// link itself, not its target. -#[instrument( +#[tracing::instrument( level = "debug", skip(path), fields(path = %path.as_ref().display()) @@ -348,7 +347,7 @@ pub async fn symlink_metadata(path: impl AsRef) -> PolyIOResult /// On Unix this creates a symlink. On Windows it creates a hard link /// instead (both paths must then live on the same volume). Either way the /// caller gets a file at `link` that shares the contents of `original`. -#[instrument( +#[tracing::instrument( level = "debug", skip(original, link), fields( @@ -379,7 +378,7 @@ pub async fn symlink_file( /// On Windows this creates a directory *junction* (a reparse point), which /// needs no elevated privilege unlike a real directory symlink. On Unix it /// creates an ordinary directory symlink. Remove it with [`remove_symlink_dir`]. -#[instrument( +#[tracing::instrument( level = "debug", skip(original, link), fields( @@ -394,19 +393,15 @@ pub async fn symlink_dir( let original = original.as_ref(); let link = link.as_ref(); - // tokio has no async junction API, so the Windows path runs the blocking - // std call on the blocking pool; Unix uses tokio's async symlink directly. #[cfg(windows)] { let path = link.to_string_lossy().to_string(); let original = original.to_path_buf(); let link = link.to_path_buf(); - return tokio::task::spawn_blocking(move || { - std::os::windows::fs::junction_point(&original, &link) - }) - .await - .map_err(std::io::Error::other)? - .map_err(|e| IOError::PathIOError { source: e, path }); + return tokio::task::spawn_blocking(move || junction::create(&original, &link)) + .await + .map_err(std::io::Error::other)? + .map_err(|e| IOError::PathIOError { source: e, path }); } #[cfg(not(windows))] @@ -422,7 +417,7 @@ pub async fn symlink_dir( /// /// On Windows a junction must be removed with `remove_dir` rather than /// `remove_file`; this handles the platform difference. -#[instrument( +#[tracing::instrument( level = "debug", skip(path), fields(path = %path.as_ref().display()) @@ -443,19 +438,19 @@ pub async fn remove_symlink_dir(path: impl AsRef) -> PolyIOResu } /// Creates a temporary directory. -#[instrument(level = "debug")] +#[tracing::instrument(level = "debug")] pub async fn tempdir() -> PolyIOResult { Ok(TempDir::new().await?) } /// Creates a temporary file. -#[instrument(level = "debug")] +#[tracing::instrument(level = "debug")] pub async fn tempfile() -> PolyIOResult { Ok(TempFile::new().await?) } /// Makes sure a path is a valid path -#[instrument( +#[tracing::instrument( level = "debug", skip(path), fields(path = %path.as_ref().display()) @@ -470,7 +465,7 @@ pub fn sanitize_path(path: impl AsRef) -> PathBuf { } /// Returns file metadata -#[instrument( +#[tracing::instrument( level = "debug", skip(path), fields(path = %path.as_ref().display())